📄 src/downloader/test/lru.test.ts
D-OPEN SOVEREIGN
1import { describe, it, beforeEach, expect, vi } from 'vitest';
2import { installCacheMock, installStorageEstimate } from './cache-mock';
3import { putDownloadRecord, getDownloadRecord } from '../meta-db';
4import { putPdf, matchPdf, putPage } from '../cache-store';
5import { runLruPrune, runLruPruneAndEmit, prunePagesByTxId } from '../lru';
6import type { WorkerMessage } from '../worker-protocol';
7
8const records = new Map<string, any>();
9
10vi.mock('../meta-db', () => ({
11    getAllDownloadRecords: async () => [...records.values()],
12    deleteDownloadRecord: async (txId: string) => { records.delete(txId); },
13    deleteBookData: async (_txId: string) => {},
14    putDownloadRecord: async (rec: any) => { records.set(rec.txId, rec); },
15    getDownloadRecord: async (txId: string) => records.get(txId),
16}));
17
18beforeEach(() => {
19    installCacheMock();
20    records.clear();
21});
22
23async function seedRecord(txId: string, sizeBytes: number, lastAccessedAt: number, fileType: 'pdf' | 'preview' = 'pdf') {
24    await putDownloadRecord({ txId, fileType, sizeBytes, downloadedAt: lastAccessedAt, lastAccessedAt });
25    await putPdf(txId, new Response('x'.repeat(Math.max(sizeBytes, 1))));
26}
27
28describe('lru — runLruPrune', () => {
29    it('does nothing below the 80% threshold', async () => {
30        installStorageEstimate(79, 100);
31        await seedRecord('under-threshold', 50, 1);
32        const result = await runLruPrune();
33        expect(result).toBeNull();
34        expect(await matchPdf('under-threshold')).toBeDefined();
35    });
36
37    it('evicts oldest-accessed PDFs first, stopping once freedBytes reaches target — regression test for the sizeBytes:0 bug', async () => {
38        // usage=90/quota=100 → over the 80% threshold; target free = 90 - 100*0.65 = 25.
39        installStorageEstimate(90, 100);
40        await seedRecord('oldest', 20, 1);
41        await seedRecord('middle', 20, 2);
42        await seedRecord('newest', 20, 3);
43
44        const result = await runLruPrune();
45
46        expect(result).not.toBeNull();
47        // Before the fix, sizeBytes was always 0 so freedBytes never reached
48        // target and every PDF got evicted in one pass.
49        expect(result!.removed).toBe(2);
50        expect(result!.freedBytes).toBe(40);
51
52        expect(await matchPdf('oldest')).toBeUndefined();
53        expect(await matchPdf('middle')).toBeUndefined();
54        expect(await matchPdf('newest')).toBeDefined();
55    });
56
57    it('only considers pdf records, never previews', async () => {
58        installStorageEstimate(90, 100);
59        await seedRecord('pdf-old', 30, 1, 'pdf');
60        await seedRecord('preview-old', 30, 0, 'preview');
61
62        const result = await runLruPrune();
63
64        expect(result!.removed).toBe(1);
65        expect(await getDownloadRecord('preview-old')).toBeDefined();
66    });
67
68    it('reports 0 removed/freed when usage is over threshold but there are no pdf records', async () => {
69        installStorageEstimate(90, 100);
70        const result = await runLruPrune();
71        expect(result).toEqual({ removed: 0, freedBytes: 0, prunedTxIds: [] });
72    });
73
74    it('calls onStart with the computed target-free byte count', async () => {
75        installStorageEstimate(90, 100);
76        await seedRecord('a', 50, 1);
77        const onStart = vi.fn();
78        await runLruPrune(onStart);
79        expect(onStart).toHaveBeenCalledWith(25); // 90 - 100*0.65
80    });
81
82});
83
84describe('lru — runLruPruneAndEmit', () => {
85    it('does not post any message when under threshold', async () => {
86        installStorageEstimate(10, 100);
87        const post = vi.fn();
88        await runLruPruneAndEmit(post);
89        expect(post).not.toHaveBeenCalled();
90    });
91
92    it('posts StoragePruneStarted then StoragePruned with the eviction results', async () => {
93        installStorageEstimate(90, 100);
94        await seedRecord('a', 30, 1);
95        await seedRecord('b', 30, 2);
96        const posted: WorkerMessage[] = [];
97        await runLruPruneAndEmit((msg) => posted.push(msg));
98
99        expect(posted[0]).toMatchObject({ type: 'StoragePruneStarted', targetFreeBytes: 25 });
100        expect(posted[1]).toMatchObject({ type: 'StoragePruned' });
101        expect((posted[1] as any).removedCount ?? (posted[1] as any).removed).toBeDefined();
102    });
103});
104
105describe('lru — prunePagesByTxId', () => {
106    it('deletes only page cache entries matching the given txId prefix', async () => {
107        await putPage('tx-a', 0, new Response('1'));
108        await putPage('tx-a', 1, new Response('1'));
109        await putPage('tx-b', 0, new Response('1'));
110
111        await prunePagesByTxId('tx-a');
112
113        const { matchPage } = await import('../cache-store');
114        expect(await matchPage('tx-a', 0)).toBeUndefined();
115        expect(await matchPage('tx-a', 1)).toBeUndefined();
116        expect(await matchPage('tx-b', 0)).toBeDefined();
117    });
118});
119