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:
@@ -48,6 +48,9 @@ const baseNote: Note = {
|
||||
deletedAt: null,
|
||||
lastRecalledAt: null,
|
||||
recallDismissedAt: null,
|
||||
status: 'active',
|
||||
statusChangedAt: null,
|
||||
moveReason: null,
|
||||
createdAt: '2026-05-09T00:00:00Z',
|
||||
updatedAt: '2026-05-09T00:00:00Z',
|
||||
tags: [],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ const noteStub = (id: string): Note => ({
|
||||
userIntent: null, intentPromptedAt: null,
|
||||
dueDate: '2026-04-20', dueDateEditedByUser: false,
|
||||
deletedAt: null, lastRecalledAt: null, recallDismissedAt: null,
|
||||
status: 'active', statusChangedAt: null, moveReason: null,
|
||||
createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z',
|
||||
tags: [], media: []
|
||||
});
|
||||
|
||||
@@ -37,7 +37,8 @@ const note = (id: string): Note => ({
|
||||
dueDate: null, dueDateEditedByUser: false,
|
||||
userIntent: null, intentPromptedAt: null,
|
||||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
deletedAt: null, lastRecalledAt: null, recallDismissedAt: null
|
||||
deletedAt: null, lastRecalledAt: null, recallDismissedAt: null,
|
||||
status: 'active', statusChangedAt: null, moveReason: null
|
||||
});
|
||||
|
||||
describe('store recall actions', () => {
|
||||
|
||||
@@ -21,6 +21,9 @@ function sample(id: string, tags: string[]): Note {
|
||||
deletedAt: null,
|
||||
lastRecalledAt: null,
|
||||
recallDismissedAt: null,
|
||||
status: 'active',
|
||||
statusChangedAt: null,
|
||||
moveReason: null,
|
||||
createdAt: '2026-04-26T00:00:00Z',
|
||||
updatedAt: '2026-04-26T00:00:00Z',
|
||||
tags: tags.map((name) => ({ name, source: 'ai' as const })),
|
||||
|
||||
@@ -30,6 +30,7 @@ const noteStub = (id: string, deletedAt: string | null = null): Note => ({
|
||||
userIntent: null, intentPromptedAt: null,
|
||||
dueDate: null, dueDateEditedByUser: false,
|
||||
deletedAt, lastRecalledAt: null, recallDismissedAt: null,
|
||||
status: deletedAt ? 'trashed' : 'active', statusChangedAt: deletedAt, moveReason: null,
|
||||
createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z',
|
||||
tags: [], media: []
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user