Files
inkling/tests/unit/ContinuityService.test.ts
altair823 3c780a7464 fix(trash): close active-query invariant leaks (review T5 important #1+#2)
T5 reviewer identified 2 reads outside NoteRepository that were missing the
'WHERE deleted_at IS NULL' filter, breaking the silent invariant beyond the
3 originally-listed methods.

- ContinuityService.get() now excludes trashed notes from streak / weekCount
  / lastNoteAt / recovery-toast math. A trashed note no longer counts toward
  weekly streak (regression: streak felt fake after trash).
- NoteRepository.getPendingCount() now excludes trashed-but-still-pending
  notes. trash() removes the pending_jobs row but leaves notes.ai_status='pending';
  the count would have drifted upward as users trashed pending notes.
- MediaGc.run() gets an inline comment documenting why it intentionally does
  NOT filter — trashed notes still own their media until permanentDelete /
  emptyTrash. Removing here would defeat restore.

Also: migrations.due_date.test.ts had 2 brittle assertions
(latestVersion()===2, user_version===2) that broke with v3. Migration-system
version assertions belong in migrations.test.ts (already covered there);
m002-specific test keeps the due_date column assertion which is version-stable.

Tests: 245 → 265 (+20). typecheck 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:58:18 +09:00

106 lines
4.1 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
import { ContinuityService } from '@main/services/ContinuityService.js';
function dbWithDates(isoDates: string[]): Database.Database {
const db = new Database(':memory:');
runMigrations(db);
const insert = db.prepare(
`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
VALUES (?, ?, 'pending', ?, ?)`
);
for (const [i, d] of isoDates.entries()) insert.run(`n${i}`, 'x', d, d);
return db;
}
describe('ContinuityService', () => {
it('empty db returns zero counts and no recovery toast', () => {
const db = new Database(':memory:');
runMigrations(db);
const svc = new ContinuityService(db, () => new Date('2026-04-25T10:00:00+09:00'));
const r = svc.get();
expect(r.weekCount).toBe(0);
expect(r.weekTarget).toBe(7);
expect(r.consecutiveCompleteWeeks).toBe(0);
expect(r.showRecoveryToast).toBe(false);
expect(r.lastNoteAt).toBeNull();
});
it('counts notes in current KST week (월~일)', () => {
const db = dbWithDates([
'2026-04-20T01:00:00+09:00',
'2026-04-22T03:00:00+09:00',
'2026-04-25T22:00:00+09:00'
]);
const svc = new ContinuityService(db, () => new Date('2026-04-25T23:00:00+09:00'));
const r = svc.get();
expect(r.weekStart).toBe('2026-04-20');
expect(r.weekCount).toBe(3);
});
function isoKst(year: number, month: number, day: number, hour = 10): string {
const mm = String(month).padStart(2, '0');
const dd = String(day).padStart(2, '0');
const hh = String(hour).padStart(2, '0');
return `${year}-${mm}-${dd}T${hh}:00:00+09:00`;
}
it('consecutiveCompleteWeeks counts weeks with >=7 notes ending immediately before current week', () => {
const dates: string[] = [];
for (let d = 6; d <= 12; d++) dates.push(isoKst(2026, 4, d));
for (let d = 13; d <= 19; d++) dates.push(isoKst(2026, 4, d));
dates.push(isoKst(2026, 4, 20));
dates.push(isoKst(2026, 4, 21));
const db = dbWithDates(dates);
const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00'));
const r = svc.get();
expect(r.consecutiveCompleteWeeks).toBe(2);
expect(r.weekCount).toBe(2);
});
it('consecutiveCompleteWeeks includes current week if it already hit 7', () => {
const dates: string[] = [];
for (let d = 13; d <= 19; d++) dates.push(isoKst(2026, 4, d));
for (let d = 20; d <= 26; d++) dates.push(isoKst(2026, 4, d));
const db = dbWithDates(dates);
const svc = new ContinuityService(db, () => new Date('2026-04-26T23:00:00+09:00'));
const r = svc.get();
expect(r.weekCount).toBe(7);
expect(r.consecutiveCompleteWeeks).toBe(2);
});
it('showRecoveryToast=true when last note is >=7 days ago AND a fresh note exists today', () => {
const dates = [
'2026-04-15T10:00:00+09:00',
'2026-04-25T11:00:00+09:00'
];
const db = dbWithDates(dates);
const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00'));
expect(svc.get().showRecoveryToast).toBe(true);
});
it('showRecoveryToast=false when there are notes within last 7 days', () => {
const db = dbWithDates([
'2026-04-22T10:00:00+09:00',
'2026-04-25T11:00:00+09:00'
]);
const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00'));
expect(svc.get().showRecoveryToast).toBe(false);
});
it('excludes trashed notes from streak/recovery math (v0.2.3 #4)', () => {
const db = dbWithDates([
'2026-04-22T10:00:00+09:00',
'2026-04-25T11:00:00+09:00'
]);
// 22일 노트를 trash → 25일이 마지막. 22일 미만이라 weekCount 1 이지만 lastNoteAt
// 은 25일 (마지막 active) 이어야 함. trashed 노트가 무시되어야 함.
db.prepare(`UPDATE notes SET deleted_at='2026-04-26T00:00:00Z' WHERE id='n0'`).run();
const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00'));
const r = svc.get();
expect(r.weekCount).toBe(1);
expect(r.lastNoteAt).toBe('2026-04-25T02:00:00.000Z'); // KST 11:00 = UTC 02:00
});
});