From 00423fb235c9d8a5caa3fe4b5f576196da7454a2 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 23:57:53 +0900 Subject: [PATCH] feat(expiry): NoteRepository.findExpiredCandidates (#5 v0.2.3) --- src/main/repository/NoteRepository.ts | 23 ++++++++ tests/unit/NoteRepository.test.ts | 81 +++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index f7b74b1..4884139 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -1,6 +1,7 @@ import type Database from 'better-sqlite3'; import { v7 as uuidv7, v4 as uuidv4 } from 'uuid'; import type { Note, NoteMedia, NoteTag } from '@shared/types'; +import { todayInKstString } from '../util/kstDate.js'; export interface CreateNoteInput { rawText: string; } @@ -405,6 +406,28 @@ export class NoteRepository { return row.c; } + /** + * Notes whose due_date is strictly before today (KST calendar) and that are + * still active (not trashed) and AI-processed. Includes both AI-extracted and + * user-edited due_date (v0.2.3 #5 spec §1 Q1=B). + * + * Caller may inject `now` for testability; defaults to `new Date()`. + */ + findExpiredCandidates(now: Date = new Date()): Note[] { + const today = todayInKstString(now); + const rows = this.db + .prepare( + `SELECT * FROM notes + WHERE due_date IS NOT NULL + AND due_date < ? + AND deleted_at IS NULL + AND ai_status = 'done' + ORDER BY created_at DESC, id DESC` + ) + .all(today) as any[]; + return rows.map((r) => this.hydrate(r)); + } + getAllPendingJobs(): Array<{ noteId: string; attempts: number; nextRunAt: string }> { const rows = this.db .prepare(`SELECT note_id, attempts, next_run_at FROM pending_jobs`) diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 6262ec0..9720181 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -448,3 +448,84 @@ describe('Active queries exclude deleted notes', () => { expect(repo.getPendingCount()).toBe(1); }); }); + +describe('NoteRepository.findExpiredCandidates', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + function makeDone(opts: { + rawText: string; + dueDate: string | null; + edited?: boolean; + deletedAt?: string | null; + aiStatus?: 'pending' | 'done' | 'failed'; + }): string { + const { id } = repo.create({ rawText: opts.rawText }); + db.prepare( + `UPDATE notes + SET due_date = ?, + due_date_edited_by_user = ?, + ai_status = ?, + deleted_at = ? + WHERE id = ?` + ).run( + opts.dueDate, + opts.edited ? 1 : 0, + opts.aiStatus ?? 'done', + opts.deletedAt ?? null, + id + ); + return id; + } + + it('returns notes with due_date < today (KST), ORDER BY created_at DESC', () => { + const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' }); + db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T10:00:00Z', a); + const b = makeDone({ rawText: 'b', dueDate: '2026-04-25' }); + db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T11:00:00Z', b); + const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); + expect(r.map((n) => n.id)).toEqual([b, a]); + }); + + it('includes both AI-extracted and user-edited due_date (Q1=B 회귀 가드)', () => { + const ai = makeDone({ rawText: 'a', dueDate: '2026-04-20', edited: false }); + const manual = makeDone({ rawText: 'b', dueDate: '2026-04-22', edited: true }); + const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); + expect(r.map((n) => n.id).sort()).toEqual([ai, manual].sort()); + }); + + it('excludes trashed notes (deleted_at IS NOT NULL)', () => { + const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' }); + makeDone({ rawText: 'b', dueDate: '2026-04-21', deletedAt: '2026-04-30T00:00:00Z' }); + const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); + expect(r.map((n) => n.id)).toEqual([a]); + }); + + it('excludes pending / failed notes (ai_status != done)', () => { + const done = makeDone({ rawText: 'a', dueDate: '2026-04-20' }); + makeDone({ rawText: 'b', dueDate: '2026-04-20', aiStatus: 'pending' }); + makeDone({ rawText: 'c', dueDate: '2026-04-20', aiStatus: 'failed' }); + const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); + expect(r.map((n) => n.id)).toEqual([done]); + }); + + it('excludes notes with NULL due_date (NULL < string 평가 가드)', () => { + const dated = makeDone({ rawText: 'a', dueDate: '2026-04-20' }); + makeDone({ rawText: 'b', dueDate: null }); + const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); + expect(r.map((n) => n.id)).toEqual([dated]); + }); + + it('excludes notes with due_date == today (boundary, not expired)', () => { + const past = makeDone({ rawText: 'a', dueDate: '2026-04-30' }); + makeDone({ rawText: 'b', dueDate: '2026-05-01' }); + const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); + expect(r.map((n) => n.id)).toEqual([past]); + }); +});