📄 src/downloader/test/files.test.ts
D-OPEN SOVEREIGN
1import 'fake-indexeddb/auto';
2import { describe, it, beforeEach, expect, vi } from 'vitest';
3import { installCacheMock, installStorageEstimate } from './cache-mock';
4import { putPdf, putPreview } from '../cache-store';
5import { putDownloadRecord } from '../meta-db';
6
7const createFileThread = vi.fn();
8const createMuThread = vi.fn();
9const EnsurePdf = vi.fn();
10const EnsurePageImage = vi.fn();
11const initOrchestrator = vi.fn();
12
13class FakeThread {
14    sent: unknown[] = [];
15    listeners: Array<(msg: unknown) => void> = [];
16    rpcResult: any = { ok: true, key: '' };
17    send(cmd: unknown) { this.sent.push(cmd); }
18    listen(cb: (msg: unknown) => void) { this.listeners.push(cb); }
19    unlisten(cb: (msg: unknown) => void) { this.listeners = this.listeners.filter(h => h !== cb); }
20    async rpc(cmd: any) { return this.rpcResult; }
21}
22
23vi.mock('../client/worker-client', () => ({
24    createFileThread: (...args: unknown[]) => createFileThread(...args),
25    createMuThread: (...args: unknown[]) => createMuThread(...args),
26}));
27vi.mock('../orchestrator', () => ({
28    EnsurePdf: (...args: unknown[]) => EnsurePdf(...args),
29    EnsurePageImage: (...args: unknown[]) => EnsurePageImage(...args),
30    initOrchestrator: (...args: unknown[]) => initOrchestrator(...args),
31}));
32
33let files: typeof import('../client/files');
34
35beforeEach(async () => {
36    vi.clearAllMocks();
37    installCacheMock();
38    installStorageEstimate(10, 100);
39
40    const fileThread = new FakeThread();
41    const muThread = new FakeThread();
42    createFileThread.mockReturnValue(fileThread);
43    createMuThread.mockReturnValue(muThread);
44    EnsurePdf.mockResolvedValue(undefined);
45    EnsurePageImage.mockResolvedValue(new Response('rendered'));
46
47    vi.resetModules();
48    files = await import('../client/files');
49});
50
51describe('client/files — worker construction (regression: dead dist worker path, bug #3)', () => {
52    it('preloadWorkersV2 constructs the file thread via createFileThread() with its prebuilt default URL', () => {
53        files.preloadWorkersV2();
54        expect(createFileThread).toHaveBeenCalledWith();
55        expect(createMuThread).toHaveBeenCalledWith();
56    });
57
58    it('reuses the same singleton thread across multiple calls', () => {
59        files.preloadWorkersV2();
60        files.preloadWorkersV2();
61        expect(createFileThread).toHaveBeenCalledTimes(1);
62        expect(createMuThread).toHaveBeenCalledTimes(1);
63    });
64});
65
66describe('client/files — InitDownloaderV2', () => {
67    it('only runs the init sequence once even when called concurrently', async () => {
68        const a = files.InitDownloaderV2();
69        const b = files.InitDownloaderV2();
70        expect(a).toBe(b);
71        await a;
72        expect(initOrchestrator).toHaveBeenCalledTimes(1);
73    });
74
75    it('sends MuInit to the mu-thread with the default mupdfUrl', async () => {
76        await files.InitDownloaderV2();
77        expect(createMuThread).toHaveBeenCalled();
78        const muThread = createMuThread.mock.results[0].value as FakeThread;
79        expect(muThread.sent).toContainEqual({ type: 'MuInit', mupdfUrl: '/js/mupdf.js' });
80    });
81});
82
83describe('client/files — GetPdf', () => {
84    it('returns the cached PDF without calling EnsurePdf on a cache hit', async () => {
85        await putPdf('cached-tx', new Response('pdf-data'));
86        const resp = await files.GetPdf('cached-tx');
87        expect(await resp.text()).toBe('pdf-data');
88        expect(EnsurePdf).not.toHaveBeenCalled();
89    });
90
91    it('calls EnsurePdf then returns the newly cached PDF on a miss', async () => {
92        EnsurePdf.mockImplementation(async (txId: string) => {
93            await putPdf(txId, new Response('downloaded'));
94        });
95        const resp = await files.GetPdf('miss-tx');
96        expect(await resp.text()).toBe('downloaded');
97        expect(EnsurePdf).toHaveBeenCalledWith('miss-tx');
98    });
99
100    it('throws if the PDF is still missing from cache after EnsurePdf resolves', async () => {
101        EnsurePdf.mockResolvedValue(undefined); // never actually caches it
102        await expect(files.GetPdf('still-missing')).rejects.toThrow(/not in cache after download/);
103    });
104});
105
106describe('client/files — GetImage', () => {
107    it('returns the cached preview without an rpc on a cache hit', async () => {
108        await putPreview('cached-preview', new Response('preview-data'));
109        const resp = await files.GetImage('cached-preview');
110        expect(await resp.text()).toBe('preview-data');
111    });
112
113    it('triggers a GetFile preview rpc and returns the cached result on a miss', async () => {
114        await files.InitDownloaderV2();
115        const fileThread = createFileThread.mock.results[0].value as FakeThread;
116        fileThread.rpc = vi.fn(async (cmd: any) => {
117            await putPreview(cmd.txId, new Response('fresh-preview'));
118            return { ok: true, key: `${cmd.txId}:preview` };
119        });
120
121        const resp = await files.GetImage('miss-preview');
122        expect(await resp.text()).toBe('fresh-preview');
123    });
124
125    it('de-dupes concurrent GetImage calls for the same txId (bounded fan-out)', async () => {
126        await files.InitDownloaderV2();
127        const fileThread = createFileThread.mock.results[0].value as FakeThread;
128        let rpcCalls = 0;
129        fileThread.rpc = vi.fn(async (cmd: any) => {
130            rpcCalls++;
131            await putPreview(cmd.txId, new Response('once'));
132            return { ok: true, key: `${cmd.txId}:preview` };
133        });
134
135        await Promise.all([files.GetImage('dup-preview'), files.GetImage('dup-preview')]);
136        expect(rpcCalls).toBe(1);
137    });
138});
139
140describe('client/files — EnsurePdf / EnsurePageImage delegation', () => {
141    it('EnsurePdf delegates to the orchestrator after init', async () => {
142        await files.EnsurePdf('tx1');
143        expect(EnsurePdf).toHaveBeenCalledWith('tx1');
144    });
145
146    it('EnsurePageImage delegates to the orchestrator after init', async () => {
147        const resp = await files.EnsurePageImage('tx1', 2);
148        expect(EnsurePageImage).toHaveBeenCalledWith('tx1', 2);
149        expect(await resp.text()).toBe('rendered');
150    });
151});
152
153describe('client/files — _reconcileDownloads', () => {
154    it('writes a synthetic zeroed-lastAccessedAt record for cached txIds missing from the downloads store', async () => {
155        await putPdf('orphaned-pdf', new Response('x', { headers: { 'content-length': '1' } }));
156        await files._reconcileDownloads(['orphaned-pdf'], []);
157
158        const { getDownloadRecord } = await import('../meta-db');
159        const rec = await getDownloadRecord('orphaned-pdf');
160        expect(rec).toMatchObject({ txId: 'orphaned-pdf', fileType: 'pdf', downloadedAt: 0, lastAccessedAt: 0 });
161    });
162
163    it('reads sizeBytes from the cached response Content-Length, falling back to 0 when absent', async () => {
164        await putPdf('sized-pdf', new Response('12345', { headers: { 'content-length': '5' } }));
165        await putPdf('unsized-pdf', new Response('x'));
166        await files._reconcileDownloads(['sized-pdf', 'unsized-pdf'], []);
167
168        const { getDownloadRecord } = await import('../meta-db');
169        expect((await getDownloadRecord('sized-pdf'))?.sizeBytes).toBe(5);
170        expect((await getDownloadRecord('unsized-pdf'))?.sizeBytes).toBe(0);
171    });
172
173    it('does not overwrite an existing download record for the same txId', async () => {
174        await putDownloadRecord({ txId: 'already-tracked', fileType: 'pdf', sizeBytes: 999, downloadedAt: 123, lastAccessedAt: 456 });
175        await putPdf('already-tracked', new Response('x'));
176        await files._reconcileDownloads(['already-tracked'], []);
177
178        const { getDownloadRecord } = await import('../meta-db');
179        expect((await getDownloadRecord('already-tracked'))?.sizeBytes).toBe(999);
180    });
181
182    it('reconciles previews with fileType "preview"', async () => {
183        await putPreview('orphaned-preview', new Response('x'));
184        await files._reconcileDownloads([], ['orphaned-preview']);
185
186        const { getDownloadRecord } = await import('../meta-db');
187        expect((await getDownloadRecord('orphaned-preview'))?.fileType).toBe('preview');
188    });
189});
190
191describe('client/files — storageEstimate', () => {
192    it('delegates to navigator.storage.estimate()', async () => {
193        const estimate = await files.storageEstimate();
194        expect(estimate).toEqual({ usage: 10, quota: 100 });
195    });
196});
197