Files
homeclaw/tests/workshop-routes.test.ts
T
kimandClaude Sonnet 5 37630be619 feat: 작업실 대규모 업그레이드 — 이력/충돌감지/도구 확장/표·게이지·진행률/출력 감시 외
안전망
- 변경 이력: 저장 때마다 바뀌기 전 상태를 프로젝트당 30개까지 보존(45초 합치기), 대시보드 "🕘 이력"에서
  되돌리기, 삭제된 프로젝트 복구(첨부파일 제외). 되돌리기 직전 상태도 이력에 남는다
- 낙관적 락: 프로젝트 updatedAt으로 다른 탭/채팅 도구가 먼저 고친 프로젝트 저장을 409로 보류하고
  충돌 배너(내 변경으로 덮어쓰기/서버 버전 불러오기) 제공. 15초 폴링으로 외부 변경 반영(편집 중엔 미룸)
- 명령 주입 수정: resolveToolImageContent가 파일명을 셸에 그대로 넘기던 execSync → execFileSync

채팅 도구(workshop_project)
- set_notes(메모 전체 교체), update_task/delete_task, rename/delete_phase, add_parts(일괄), rename/
  archive/unarchive/duplicate/delete_project(confirm 필수), set_budget, check_price(다나와),
  list_history/restore_history, view_image(첨부 사진을 모델이 직접 봄)
- 프롬프트에 개요·진행률·남은 작업·예산 현황 주입

대시보드
- 부품 표 보기·상태 필터·정렬, CSV/MD 내보내기, 목표 예산 게이지, 작업 진행률·기한(지남/임박),
  프로젝트 복제(템플릿)/보관, 부품 시세 조회, STL 썸네일(OpenSCAD, 캐시), 채팅 사진 첨부(📷)

장비 연동
- print3d_model start 시 작업실 프로젝트/작업을 등록 → K2 출력 완료/실패를 감시해 작업 자동 완료 +
  메모 기록 + 텔레그램 알림(workshop-print-watch.ts, job 없으면 폴링 안 함)

테스트: 서버/도구/감시/프런트 vm 시뮬레이션 + 라우트 통합(HTTP) + 실브라우저 수동 검증 하네스(tests/manual)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 11:53:21 +09:00

105 lines
6.4 KiB
TypeScript

// 작업실 라우트 통합 테스트 — 실제 registerWorkshopRoutes를 임시 데이터 디렉토리(SMALLCLAW_DATA_DIR)에서
// 띄워 HTTP로 검증한다(가짜 세션). 낙관적 락(409)/이력/복제/삭제 정리/% 파일명 업로드/썸네일.
import test from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import fs from 'fs';
import path from 'path';
import os from 'os';
import http from 'http';
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wsint-'));
process.env.SMALLCLAW_DATA_DIR = tmp;
test('작업실 라우트 통합', async () => {
const { registerWorkshopRoutes } = await import('../src/gateway/routes/routes-workshop');
const app = express();
app.use(express.json());
const isInside = (b: string, t: string) => path.resolve(t).startsWith(path.resolve(b) + path.sep);
registerWorkshopRoutes(app, () => ({ username: 'tester' }), isInside);
const server = http.createServer(app);
await new Promise<void>(r => server.listen(0, r));
const base = `http://127.0.0.1:${(server.address() as any).port}`;
const j = async (m: string, u: string, body?: any) => {
const r = await fetch(base + u, { method: m, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
const ct = r.headers.get('content-type') || '';
return { status: r.status, body: ct.includes('json') ? await r.json() : null, res: r };
};
const mk = (over: any = {}) => ({ id: 'pt', name: '임시', parts: [{ id: 'p1', name: '모터', qty: 2, unitPrice: 1000, status: '검토중', memo: '', links: [] }],
phases: [{ id: 'ph1', name: '준비', tasks: [{ id: 't1', text: '출력', done: false, due: '2026-10-01' }] }], notes: '', budget: 500000, ...over });
try {
// 1) 최초 저장 → updatedAt 발급
let r = await j('PUT', '/api/workshop/project/pt', mk());
assert.equal(r.status, 200); assert.ok(r.body.updatedAt > 0);
const v1 = r.body.updatedAt;
// 2) 낡은 버전(0)으로 저장 → 409 + 서버 버전
r = await j('PUT', '/api/workshop/project/pt', mk({ notes: '충돌', updatedAt: 1 }));
assert.equal(r.status, 409); assert.equal(r.body.server.id, 'pt');
// 3) 올바른 버전 → 200, 버전 상승
r = await j('PUT', '/api/workshop/project/pt', mk({ notes: '수정1', updatedAt: v1 }));
assert.equal(r.status, 200); assert.ok(r.body.updatedAt > v1);
// 4) force=1 → 낡은 버전이어도 통과
r = await j('PUT', '/api/workshop/project/pt?force=1', mk({ notes: '강제', updatedAt: 1 }));
assert.equal(r.status, 200);
// 5) 이력 생성 확인 (바뀌기 전 상태)
r = await j('GET', '/api/workshop/history/pt');
assert.ok(r.body.history.length >= 1);
// 6) 옛 전체문서 PUT은 없어졌다
r = await j('PUT', '/api/workshop/data', { projects: [] });
assert.equal(r.status, 404);
// 7) 잘못된 본문 → 400
r = await j('PUT', '/api/workshop/project/pt', mk({ notes: 5 }));
assert.equal(r.status, 400);
// 8) 되돌리기
const ts = (await j('GET', '/api/workshop/history/pt')).body.history.at(-1).ts;
r = await j('POST', '/api/workshop/history/pt/restore', { ts });
assert.equal(r.status, 200);
// 9) 복제 → 새 프로젝트, 작업 완료 초기화
r = await j('POST', '/api/workshop/project/pt/duplicate');
assert.equal(r.status, 200);
const data = (await j('GET', '/api/workshop/data')).body;
const copy = data.projects.find((p: any) => p.id === r.body.id);
assert.equal(copy.name, '임시 (복제)');
assert.equal(data.activeProjectId, copy.id);
// 10) 파일 업로드: % 파일명이 서버를 죽이지 않고 저장된다
const boundary = 'XBOUND';
const filename = '100%_infill.stl';
const fileBytes = Buffer.alloc(84); fileBytes.write('solid', 0);
const payload = Buffer.concat([
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`),
fileBytes, Buffer.from(`\r\n--${boundary}--\r\n`),
]);
const up = await fetch(`${base}/api/workshop/upload?projectId=pt`, { method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` }, body: payload });
const upBody: any = await up.json();
assert.equal(up.status, 200, JSON.stringify(upBody)); assert.equal(upBody.name, filename);
// 11) 썸네일: ASCII/무효 STL → 404(클라이언트가 아이콘 폴백), 잘못된 경로 → 400
r = await j('GET', `/api/workshop/thumb?projectId=pt&relPath=${encodeURIComponent(filename)}`);
assert.ok([200, 404].includes(r.status)); // 무효 STL은 렌더 결과에 따라 404(클라이언트가 🔧 아이콘으로 폴백) 또는 빈 이미지
// 유효한 바이너리 STL(사면체) → OpenSCAD 렌더 PNG
const tri = (a: number[], b: number[], c: number[]) => { const buf = Buffer.alloc(50); [0, 0, 0, ...a, ...b, ...c].forEach((v, i) => buf.writeFloatLE(v, i * 4)); return buf; };
const stl = Buffer.concat([Buffer.alloc(80), Buffer.from(Uint32Array.of(4).buffer),
tri([0, 0, 0], [10, 0, 0], [0, 10, 0]), tri([0, 0, 0], [0, 0, 10], [10, 0, 0]), tri([0, 0, 0], [0, 10, 0], [0, 0, 10]), tri([10, 0, 0], [0, 0, 10], [0, 10, 0])]);
const wsDir = path.join(tmp, '.smallclaw', 'users', 'tester', 'workspace', 'workshop', 'pt');
fs.writeFileSync(path.join(wsDir, 'tetra.stl'), stl);
const th = await fetch(`${base}/api/workshop/thumb?projectId=pt&relPath=tetra.stl`);
assert.equal(th.status, 200);
assert.equal(th.headers.get('content-type'), 'image/png');
assert.ok((await th.arrayBuffer()).byteLength > 500);
r = await j('GET', '/api/workshop/thumb?projectId=pt&relPath=' + encodeURIComponent('../../etc/passwd.stl'));
assert.equal(r.status, 400);
// 12) 삭제 → 첨부파일 폴더 정리 + 이력에서 복구 후보
r = await j('DELETE', '/api/workshop/project/pt');
assert.equal(r.status, 200);
assert.equal(fs.existsSync(path.join(tmp, '.smallclaw', 'users', 'tester', 'workspace', 'workshop', 'pt')), false);
r = await j('GET', '/api/workshop/trash');
assert.ok(r.body.deleted.some((d: any) => d.id === 'pt'));
r = await j('POST', '/api/workshop/trash/pt/restore');
assert.equal(r.status, 200);
assert.ok((await j('GET', '/api/workshop/data')).body.projects.some((p: any) => p.id === 'pt'));
} finally {
await new Promise(r => server.close(r));
fs.rmSync(tmp, { recursive: true, force: true });
}
});