📄 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