feat(v029): NoteRepository.setStatus + listByStatus + restoreNote 재구현

- NoteStatus 타입 추가 ('active'/'completed'/'archived'/'trashed')
- Note interface 에 status / statusChangedAt / moveReason 필드 추가
- setStatus(id, status, reason, now?) — 단일 transaction 으로 status + move_reason +
  status_changed_at + updated_at 갱신. status='trashed' ↔ deleted_at 동기화
  (backward compat). 그 외 status 는 deleted_at NULL.
- listByStatus(status, opts) — status 별 필터 + ORDER BY COALESCE(status_changed_at,
  created_at) DESC. limit cap 200.
- hydrate 에 status / statusChangedAt / moveReason 매핑 추가. 미설정 row 는 'active' fallback.
- restoreNote 재구현 — setStatus('active', null) 로 status + deleted_at 동기화 +
  v0.2.6 #10 round 1 fix (ai_status='failed'/'pending' → pending_jobs 재투입) 보존.
- 기존 테스트 fixture 5건에 새 필드 추가 (NoteCard, store.expired/recall/tagFilter/trash).
- 신규 테스트 11건 (setStatus + listByStatus + restoreNote 회귀).
This commit is contained in:
altair823
2026-05-09 15:33:49 +09:00
parent 06a1caf2bd
commit facbf54025
8 changed files with 225 additions and 13 deletions

View File

@@ -852,3 +852,131 @@ describe('NoteRepository — failed retry helpers', () => {
expect(repo.getTagIdByName('nothere')).toBeNull();
});
});
describe('NoteRepository — setStatus + listByStatus', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('setStatus updates status + reason + status_changed_at + updated_at', () => {
const { id } = repo.create({ rawText: 'test' });
repo.setStatus(id, 'completed', '결재 끝', new Date('2026-05-10T00:00:00.000Z'));
const note = repo.findById(id)!;
expect(note.status).toBe('completed');
expect(note.moveReason).toBe('결재 끝');
expect(note.statusChangedAt).toBe('2026-05-10T00:00:00.000Z');
expect(note.updatedAt).toBe('2026-05-10T00:00:00.000Z');
});
it('setStatus accepts null reason', () => {
const { id } = repo.create({ rawText: 'test' });
repo.setStatus(id, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
const note = repo.findById(id)!;
expect(note.status).toBe('archived');
expect(note.moveReason).toBeNull();
});
it('setStatus default now uses Date.now()', () => {
const { id } = repo.create({ rawText: 'test' });
const before = Date.now();
repo.setStatus(id, 'completed', null);
const after = Date.now();
const note = repo.findById(id)!;
const ts = new Date(note.statusChangedAt!).getTime();
expect(ts).toBeGreaterThanOrEqual(before);
expect(ts).toBeLessThanOrEqual(after);
});
it('listByStatus filters correctly', () => {
const idA = repo.create({ rawText: 'a' }).id;
const idB = repo.create({ rawText: 'b' }).id;
repo.setStatus(idB, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
const active = repo.listByStatus('active', { limit: 10 });
const archived = repo.listByStatus('archived', { limit: 10 });
expect(active.map((n) => n.id)).toContain(idA);
expect(active.map((n) => n.id)).not.toContain(idB);
expect(archived.map((n) => n.id)).toContain(idB);
expect(archived.map((n) => n.id)).not.toContain(idA);
});
it('listByStatus orders by status_changed_at DESC (NULL falls back to created_at)', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
repo.setStatus(a, 'completed', null, new Date('2026-05-10T00:00:00.000Z'));
repo.setStatus(b, 'completed', null, new Date('2026-05-12T00:00:00.000Z'));
repo.setStatus(c, 'completed', null, new Date('2026-05-11T00:00:00.000Z'));
const r = repo.listByStatus('completed', { limit: 10 });
expect(r.map((n) => n.id)).toEqual([b, c, a]);
});
it('listByStatus respects limit (cap 200)', () => {
for (let i = 0; i < 5; i++) {
const id = repo.create({ rawText: `n${i}` }).id;
repo.setStatus(id, 'archived', null, new Date(`2026-05-${10 + i}T00:00:00.000Z`));
}
expect(repo.listByStatus('archived', { limit: 3 })).toHaveLength(3);
expect(repo.listByStatus('archived', { limit: 100 })).toHaveLength(5);
});
it('listByStatus default limit 200', () => {
repo.create({ rawText: 'a' });
expect(repo.listByStatus('active')).toHaveLength(1);
});
it('setStatus("trashed") syncs deleted_at (backward compat)', () => {
const { id } = repo.create({ rawText: 't' });
repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z'));
const row = db.prepare(`SELECT deleted_at FROM notes WHERE id=?`).get(id) as {
deleted_at: string;
};
expect(row.deleted_at).toBe('2026-05-15T00:00:00.000Z');
expect(repo.findById(id)!.deletedAt).toBe('2026-05-15T00:00:00.000Z');
});
it('setStatus("active") clears deleted_at (restore from trash)', () => {
const { id } = repo.create({ rawText: 'r' });
repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z'));
repo.setStatus(id, 'active', null, new Date('2026-05-16T00:00:00.000Z'));
const row = db.prepare(`SELECT deleted_at FROM notes WHERE id=?`).get(id) as {
deleted_at: string | null;
};
expect(row.deleted_at).toBeNull();
expect(repo.findById(id)!.deletedAt).toBeNull();
});
it('setStatus("completed"/"archived") also clears deleted_at', () => {
const { id } = repo.create({ rawText: 'r' });
repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z'));
repo.setStatus(id, 'completed', null, new Date('2026-05-16T00:00:00.000Z'));
expect(repo.findById(id)!.deletedAt).toBeNull();
});
it('newly created note hydrates as status=active', () => {
const { id } = repo.create({ rawText: 'fresh' });
const note = repo.findById(id)!;
expect(note.status).toBe('active');
expect(note.statusChangedAt).toBeNull();
expect(note.moveReason).toBeNull();
});
it('restoreNote sets status=active + clears moveReason', () => {
const { id } = repo.create({ rawText: 'r' });
repo.setStatus(id, 'trashed', '실수', new Date('2026-05-15T00:00:00.000Z'));
expect(repo.findById(id)!.status).toBe('trashed');
expect(repo.findById(id)!.moveReason).toBe('실수');
repo.restoreNote(id);
const after = repo.findById(id)!;
expect(after.status).toBe('active');
expect(after.moveReason).toBeNull();
expect(after.deletedAt).toBeNull();
});
});