안전망 - 변경 이력: 저장 때마다 바뀌기 전 상태를 프로젝트당 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>
105 lines
6.4 KiB
TypeScript
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 });
|
|
}
|
|
});
|