feat(recall): NoteRepository — findRecallCandidate + markRecallOpened + dismissRecall (#6 v0.2.3)

- findRecallCandidate(): 7일+ 안 본 + 30일+ dismiss 만료 + ai='done' + 마감 안 임박 + LIMIT 1
- markRecallOpened(id, now): last_recalled_at 갱신
- dismissRecall(id, now): recall_dismissed_at 갱신
- KST 보정 SQL date('now','+9 hours')
- 단위 +5 cases (empty/recent/old/dismiss expiry/exclude variants)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-02 13:11:14 +09:00
parent 746671059e
commit 0eb2e6282f
2 changed files with 90 additions and 0 deletions

View File

@@ -214,6 +214,59 @@ describe('NoteRepository', () => {
expect(typeof n).toBe('number');
expect(n).toBeGreaterThanOrEqual(0);
});
it('findRecallCandidate returns null for empty db', () => {
expect(repo.findRecallCandidate()).toBeNull();
});
it('findRecallCandidate excludes notes recalled within 7 days', () => {
const id = repo.create({ rawText: 'x' }).id;
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
// 5일 전 본 노트 → 제외
const fiveDaysAgo = new Date(Date.now() - 5 * 86_400_000).toISOString();
repo.markRecallOpened(id, fiveDaysAgo);
expect(repo.findRecallCandidate()).toBeNull();
});
it('findRecallCandidate includes notes recalled 8+ days ago', () => {
const id = repo.create({ rawText: 'x' }).id;
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
const eightDaysAgo = new Date(Date.now() - 8 * 86_400_000).toISOString();
repo.markRecallOpened(id, eightDaysAgo);
expect(repo.findRecallCandidate()?.id).toBe(id);
});
it('findRecallCandidate respects dismiss expiry (25일 제외, 35일 후보)', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
repo.updateAiResult(b, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
const twentyFiveDaysAgo = new Date(Date.now() - 25 * 86_400_000).toISOString();
const thirtyFiveDaysAgo = new Date(Date.now() - 35 * 86_400_000).toISOString();
repo.dismissRecall(a, twentyFiveDaysAgo); // 25일 — 아직 dismiss 만료 안 됨
repo.dismissRecall(b, thirtyFiveDaysAgo); // 35일 — dismiss 만료, 재추천 가능
const candidate = repo.findRecallCandidate();
expect(candidate?.id).toBe(b);
});
it('findRecallCandidate excludes deleted/pending/imminent due', () => {
const todayKst = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10);
const yesterdayKst = new Date(Date.now() + 9 * 60 * 60 * 1000 - 86_400_000).toISOString().slice(0, 10);
// (a) deleted
const a = repo.create({ rawText: 'a' }).id;
repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
repo.trash(a, new Date().toISOString());
// (b) pending (no AI)
repo.create({ rawText: 'b' });
// (c) due_date 어제
const c = repo.create({ rawText: 'c' }).id;
repo.updateAiResult(c, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: yesterdayKst, provider: 'p' });
expect(repo.findRecallCandidate()).toBeNull();
// (d) due_date today 는 OK (>=today 통과)
const d = repo.create({ rawText: 'd' }).id;
repo.updateAiResult(d, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: todayKst, provider: 'p' });
expect(repo.findRecallCandidate()?.id).toBe(d);
});
});
describe('NoteRepository.trash', () => {