feat(expiry): NoteRepository.findExpiredCandidates (#5 v0.2.3)

This commit is contained in:
altair823
2026-05-01 23:57:53 +09:00
parent 0a9dab4a7f
commit 00423fb235
2 changed files with 104 additions and 0 deletions

View File

@@ -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`)

View File

@@ -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]);
});
});