📄 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