Files
homeclaw/tests/workshop-trash.test.ts
T
kimandClaude Code 7e22a3074f feat: 작업실 파일·폴더 삭제를 영구 rm → 휴지통 이동으로 변경
- 계기: 작업실 화면에서 CAD/ 폴더를 지우면 되돌릴 수 없었다(LeRobot SO-101
  프로젝트 CAD/ 소실, 2026-09-25 실측).
- 삭제 시 workshop/.trash/<projectId>/<타임스탬프-접미사>/ 로 rename만
  (같은 볼륨이라 즉시, UI 변경 없음). 14일 지난 항목은 다음 삭제 시 자동 정리.
- 프로젝트 삭제 시 첨부 폴더도 휴지통으로(기존엔 통째 rm).
- 휴지통 이동 실패 시에만 기존 rm으로 폴백. 스탬프에 고유 접미사로 연속
  삭제 덮어쓰기 방지. 테스트 5건 추가.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 18:25:54 +09:00

65 lines
3.5 KiB
TypeScript

// 작업실 휴지통(soft delete, 2026-09-25) 테스트.
// 계기: 작업실 화면에서 CAD/ 폴더를 지우면 rm이라 되돌릴 수 없었다(LeRobot SO-101
// 프로젝트 CAD/ 소실). 삭제는 workshop/.trash/<projectId>/<타임스탬프>/ 이동이고,
// 14일 지난 항목은 다음 삭제 시점에 자동 영구 삭제된다.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { moveToWorkshopTrash } from '../src/gateway/routes/routes-workshop';
describe('moveToWorkshopTrash — 작업실 삭제를 휴지통 이동으로', () => {
const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'ws-trash-'));
const pid = 'proj_test1234';
const projDir = path.join(ws, 'workshop', pid);
const trashProj = path.join(ws, 'workshop', '.trash', pid);
const RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
test('폴더 삭제 → 휴지통 이동(내용물 보존, 원래 자리는 비움)', () => {
fs.mkdirSync(path.join(projDir, 'CAD'), { recursive: true });
fs.writeFileSync(path.join(projDir, 'CAD', 'part.stl'), 'BINARY');
const ok = moveToWorkshopTrash(ws, pid, path.join(projDir, 'CAD'));
assert.ok(ok);
assert.ok(!fs.existsSync(path.join(projDir, 'CAD')), '원래 폴더가 남아있음');
const stamps = fs.readdirSync(trashProj);
assert.equal(stamps.length, 1);
const moved = path.join(trashProj, stamps[0], 'CAD', 'part.stl');
assert.ok(fs.existsSync(moved), '휴지통에 내용물이 없음');
assert.equal(fs.readFileSync(moved, 'utf8'), 'BINARY');
});
test('단일 파일 삭제도 휴지통으로', () => {
fs.writeFileSync(path.join(projDir, 'note.txt'), 'x');
assert.ok(moveToWorkshopTrash(ws, pid, path.join(projDir, 'note.txt')));
assert.ok(!fs.existsSync(path.join(projDir, 'note.txt')));
assert.equal(fs.readdirSync(trashProj).length, 2, '타임스탬프 폴더가 삭제 건수만큼');
});
test('보존기간(14일) 지난 항목은 다음 삭제 시점에 자동 영구 삭제', () => {
// 새 폴더를 하나 더 지우고 → 직전 삭제 건이 14일+ 된 것처럼 mtime을 조작 → 다시 삭제
const stamps = fs.readdirSync(trashProj);
fs.utimesSync(path.join(trashProj, stamps[0]), new Date(Date.now() - RETENTION_MS - 60_000), new Date(Date.now() - RETENTION_MS - 60_000));
fs.mkdirSync(path.join(projDir, '사진'), { recursive: true });
fs.writeFileSync(path.join(projDir, '사진', 'a.jpg'), 'y');
assert.ok(moveToWorkshopTrash(ws, pid, path.join(projDir, '사진')));
const after = fs.readdirSync(trashProj);
assert.ok(!after.includes(stamps[0]), '14일 지난 항목이 정리되지 않음');
assert.equal(after.length, 2, '남은 항목 수가 다름(직전 건 + 방금 건)');
});
test('같은 이름을 여러 번 지워도 덮어쓰지 않는다(타임스탬프 분리)', () => {
for (const name of ['a.stl', 'a.stl', 'a.stl']) {
fs.writeFileSync(path.join(projDir, name), name);
assert.ok(moveToWorkshopTrash(ws, pid, path.join(projDir, name)));
}
const copies = fs.readdirSync(trashProj).flatMap(s => fs.readdirSync(path.join(trashProj, s)));
assert.equal(copies.filter(c => c === 'a.stl').length, 3);
});
test('대상이 없으면 false(호출부가 rm 폴백하지 않도록)', () => {
assert.equal(moveToWorkshopTrash(ws, pid, path.join(projDir, '없는파일.stl')), false);
});
test.after(() => { try { fs.rmSync(ws, { recursive: true, force: true }); } catch { /* noop */ } });
});