123 lines
4.9 KiB
TypeScript
123 lines
4.9 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import { syncFolder, isActive, TmdbRateLimitError, 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 writes: { path: string; content: string }[] = [];
|
|
const fetches: string[] = [];
|
|
const deps: SyncDeps = {
|
|
listNotes: async () => notes,
|
|
writeNote: async (path, content) => { writes.push({ path, content }); },
|
|
fetchDetail: async (id) => { fetches.push(id); return detail; },
|
|
sleep: async () => {},
|
|
log: () => {},
|
|
};
|
|
return { deps, writes, fetches };
|
|
}
|
|
|
|
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', content: 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);
|
|
});
|
|
});
|