obsidian-media-db-sync/tests/watchlist-sync-engine.test.ts
afiqzudinhadi a4ba1b8e42 fix(watchlist): stale-read clobber, concurrency guard, leaked timeout, quiet catch-up notices
- SyncDeps.listNotes now returns paths only; new readNote(path) reads
  content immediately before parse/diff/write per note, closing the
  window where an edit made between the initial scan and a later write
  got silently overwritten. resolveMissingIds updated to match.
- WatchlistController gains a syncing flag shared by syncNow and
  resolveMissingIds so overlapping invocations short-circuit instead
  of racing on the same vault notes.
- main.ts registers cleanup for the 30s startup catch-up setTimeout so
  it doesn't fire after unload.
- syncNow takes a quiet flag (used by the scheduled catch-up path) so
  zero-change scheduled syncs don't spam a Notice; manual commands are
  unaffected.
2026-07-30 13:24:33 +08:00

187 lines
7 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { syncFolder, isActive, TmdbRateLimitError, withRateLimitRetry, type SyncDeps } from 'packages/obsidian/src/watchlist/SyncEngine';
import tvDetail from 'tests/fixtures/tmdb-tv-loki.json';
const ENDED_NOTE = `---
type: watchlist_item
media_type: TV Series
watch_status: Watched
rating: 5
rating_stars: ⭐️⭐️⭐️⭐️⭐️
status: Ended
tmdb_id: 84958
last_air_date: 2023-11-09
---
# Loki
## My Notes
keep me
`;
const AIRING_NOTE = ENDED_NOTE.replace('status: Ended', 'status: Returning Series').replace('last_air_date: 2023-11-09', 'last_air_date: 2023-10-01');
function makeDeps(notes: { path: string; content: string }[], detail: any = tvDetail) {
const contents = new Map(notes.map(n => [n.path, n.content]));
const writes: { path: string; content: string }[] = [];
const fetches: string[] = [];
const deps: SyncDeps = {
listNotes: async () => notes.map(n => ({ path: n.path })),
readNote: async path => contents.get(path)!,
writeNote: async (path, content) => { writes.push({ path, content }); },
fetchDetail: async (id) => { fetches.push(id); return detail; },
sleep: async () => {},
log: () => {},
};
return { deps, writes, fetches, contents };
}
describe('withRateLimitRetry', () => {
test('429 → sleep(retryAfterMs) then retry once, returns result', async () => {
const slept: number[] = [];
let calls = 0;
const fn = async () => {
calls++;
if (calls === 1) {
const e = new TmdbRateLimitError('429');
e.retryAfterMs = 1500;
throw e;
}
return 'ok';
};
const result = await withRateLimitRetry(fn, async ms => { slept.push(ms); });
expect(slept).toEqual([1500]);
expect(calls).toBe(2);
expect(result).toBe('ok');
});
test('non-429 error → no retry, throws immediately', async () => {
let calls = 0;
const fn = async () => {
calls++;
throw new Error('boom');
};
await expect(withRateLimitRetry(fn, async () => {})).rejects.toThrow('boom');
expect(calls).toBe(1);
});
test('429 twice → second failure propagates (retry only once)', async () => {
let calls = 0;
const fn = async () => {
calls++;
const e = new TmdbRateLimitError('429');
e.retryAfterMs = 500;
throw e;
};
await expect(withRateLimitRetry(fn, async () => {})).rejects.toBeInstanceOf(TmdbRateLimitError);
expect(calls).toBe(2);
});
});
describe('isActive tiering', () => {
test('Returning Series → active', () => expect(isActive({ status: 'Returning Series' })).toBe(true));
test('Watching → active regardless of status', () => expect(isActive({ status: 'Ended', watch_status: 'Watching' })).toBe(true));
test('next_air_date set → active', () => expect(isActive({ status: 'Ended', next_air_date: '2026-08-01' })).toBe(true));
test('Ended → static', () => expect(isActive({ status: 'Ended', next_air_date: 'null' })).toBe(false));
test('Released movie → static', () => expect(isActive({ status: 'Released' })).toBe(false));
test('missing status → active (needs first enrich)', () => expect(isActive({})).toBe(true));
});
describe('syncFolder', () => {
test('default run skips static notes', async () => {
const { deps, fetches } = makeDeps([{ path: 'Loki.md', content: ENDED_NOTE }]);
const report = await syncFolder(deps);
expect(fetches.length).toBe(0);
expect(report.skippedStatic).toBe(1);
});
test('full run processes static notes', async () => {
const { deps, fetches } = makeDeps([{ path: 'Loki.md', content: ENDED_NOTE }]);
await syncFolder(deps, { full: true });
expect(fetches).toEqual(['84958']);
});
test('diff-on-write: second pass on rendered output writes nothing', async () => {
const { deps, writes } = makeDeps([{ path: 'Loki.md', content: ENDED_NOTE }]);
await syncFolder(deps, { full: true });
const rendered = writes[0].content;
const second = makeDeps([{ path: 'Loki.md', content: rendered }]);
const report = await syncFolder(second.deps, { full: true });
expect(second.writes.length).toBe(0);
expect(report.written).toBe(0);
});
test('watch rule flips through full pipeline', async () => {
const { deps, writes } = makeDeps([{ path: 'Loki.md', content: AIRING_NOTE }]);
const report = await syncFolder(deps);
expect(writes.length).toBe(1);
expect(writes[0].content).toContain('watch_status: Unwatched');
expect(report.flipped).toEqual(['Loki.md']);
});
test('My Notes preserved through rewrite', async () => {
const { deps, writes } = makeDeps([{ path: 'Loki.md', content: AIRING_NOTE }]);
await syncFolder(deps);
expect(writes[0].content).toContain('keep me');
});
test('no tmdb ref → skipped, counted', async () => {
const { deps, fetches } = makeDeps([{ path: '_Dashboard.md', content: '# dash' }]);
const report = await syncFolder(deps, { full: true });
expect(fetches.length).toBe(0);
expect(report.skippedNoId).toBe(1);
});
test('dryRun: no writes, report counts', async () => {
const { deps, writes } = makeDeps([{ path: 'Loki.md', content: AIRING_NOTE }]);
const report = await syncFolder(deps, { dryRun: true });
expect(writes.length).toBe(0);
expect(report.written).toBe(1); // counts what WOULD be written
});
test('429 → sleep(retryAfter) then retry succeeds', async () => {
let calls = 0;
const slept: number[] = [];
const deps: SyncDeps = {
listNotes: async () => [{ path: 'Loki.md' }],
readNote: async () => AIRING_NOTE,
writeNote: async () => {},
fetchDetail: async () => {
calls++;
if (calls === 1) { const e = new TmdbRateLimitError('429'); e.retryAfterMs = 1500; throw e; }
return tvDetail;
},
sleep: async ms => { slept.push(ms); },
log: () => {},
};
const report = await syncFolder(deps);
expect(calls).toBe(2);
expect(slept).toContain(1500);
expect(report.errors.length).toBe(0);
});
test('fetch error recorded, other notes continue', async () => {
const { deps } = makeDeps([
{ path: 'Bad.md', content: AIRING_NOTE },
{ path: 'Good.md', content: AIRING_NOTE },
]);
let n = 0;
deps.fetchDetail = async () => { n++; if (n === 1) throw new Error('boom'); return tvDetail; };
const report = await syncFolder(deps);
expect(report.errors.length).toBe(1);
expect(report.errors[0].path).toBe('Bad.md');
expect(report.synced).toBe(1);
});
test('stale-read guard: content edited mid-sync is re-read fresh, not clobbered by early snapshot', async () => {
const { deps, writes, contents } = makeDeps([
{ path: 'A.md', content: AIRING_NOTE },
{ path: 'B.md', content: AIRING_NOTE },
]);
const originalFetch = deps.fetchDetail;
let calls = 0;
deps.fetchDetail = async (id, isMovie) => {
calls++;
if (calls === 1) {
// simulate the user editing B.md's "My Notes" while A.md is still mid-sync,
// i.e. after listNotes() ran but before B.md is actually processed.
contents.set('B.md', AIRING_NOTE.replace('keep me', 'edited during sync'));
}
return originalFetch(id, isMovie);
};
await syncFolder(deps);
const bWrite = writes.find(w => w.path === 'B.md');
expect(bWrite?.content).toContain('edited during sync');
expect(bWrite?.content).not.toContain('keep me');
});
});