Skip to content

Commit 7f39c57

Browse files
author
HugoFara
committed
test(frontend): add unit tests for offline, PWA, and term edit modules
Add 242 new tests across 8 test files to improve coverage: - offline/db.test.ts: IndexedDB database operations (29 tests) - offline/offline_button.test.ts: Alpine.js download button (31 tests) - offline/offline_indicator.test.ts: connectivity indicator (31 tests) - offline/offline_text_reader.test.ts: offline-first reading (21 tests) - offline/text_service.test.ts: text storage service (43 tests) - pwa/register.test.ts: service worker registration (30 tests) - pwa/sw.test.ts: service worker caching strategies (28 tests) - vocabulary/term_edit_modal.test.ts: term editing modal (29 tests) Coverage improved from 66.57% to 69.43% statements.
1 parent 9d05a5a commit 7f39c57

File tree

8 files changed

+3977
-0
lines changed

8 files changed

+3977
-0
lines changed

tests/frontend/offline/db.test.ts

Lines changed: 460 additions & 0 deletions
Large diffs are not rendered by default.

tests/frontend/offline/offline_button.test.ts

Lines changed: 419 additions & 0 deletions
Large diffs are not rendered by default.

tests/frontend/offline/offline_indicator.test.ts

Lines changed: 477 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
/**
2+
* Tests for shared/offline/offline-text-reader.ts - Offline-first text reading
3+
*/
4+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5+
6+
// Mock dependencies before importing
7+
vi.mock('../../../src/frontend/js/shared/offline/text-service', () => ({
8+
isOfflineStorageAvailable: vi.fn().mockReturnValue(true),
9+
isTextAvailableOffline: vi.fn().mockResolvedValue({ available: false }),
10+
getOfflineTextData: vi.fn().mockResolvedValue(null),
11+
}));
12+
13+
vi.mock('../../../src/frontend/js/modules/text/api/texts_api', () => ({
14+
TextsApi: {
15+
getWords: vi.fn().mockResolvedValue({ data: null, error: 'Not found' }),
16+
},
17+
}));
18+
19+
vi.mock('../../../src/frontend/js/shared/offline/db', () => ({
20+
offlineDb: {
21+
texts: {
22+
toArray: vi.fn().mockResolvedValue([]),
23+
},
24+
},
25+
}));
26+
27+
import {
28+
getTextWordsOfflineFirst,
29+
canReadText,
30+
getReadableOfflineTextIds,
31+
type TextDataResult,
32+
} from '../../../src/frontend/js/shared/offline/offline-text-reader';
33+
import {
34+
isOfflineStorageAvailable,
35+
isTextAvailableOffline,
36+
getOfflineTextData,
37+
} from '../../../src/frontend/js/shared/offline/text-service';
38+
import { TextsApi } from '../../../src/frontend/js/modules/text/api/texts_api';
39+
import { offlineDb } from '../../../src/frontend/js/shared/offline/db';
40+
41+
describe('shared/offline/offline-text-reader.ts', () => {
42+
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
43+
44+
beforeEach(() => {
45+
vi.clearAllMocks();
46+
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
47+
48+
// Default to online
49+
Object.defineProperty(navigator, 'onLine', {
50+
value: true,
51+
writable: true,
52+
configurable: true,
53+
});
54+
55+
vi.mocked(isOfflineStorageAvailable).mockReturnValue(true);
56+
});
57+
58+
afterEach(() => {
59+
consoleWarnSpy.mockRestore();
60+
});
61+
62+
// ===========================================================================
63+
// getTextWordsOfflineFirst Tests
64+
// ===========================================================================
65+
66+
describe('getTextWordsOfflineFirst', () => {
67+
const mockTextData = {
68+
config: {
69+
langId: 1,
70+
title: 'Test Text',
71+
audioUri: null,
72+
sourceUri: null,
73+
},
74+
words: [
75+
{ text: 'Hello', status: 1 },
76+
{ text: 'World', status: 2 },
77+
],
78+
};
79+
80+
describe('when online', () => {
81+
beforeEach(() => {
82+
Object.defineProperty(navigator, 'onLine', { value: true });
83+
});
84+
85+
it('fetches from network first', async () => {
86+
vi.mocked(TextsApi.getWords).mockResolvedValue({
87+
data: mockTextData,
88+
error: undefined,
89+
});
90+
91+
const result = await getTextWordsOfflineFirst(123);
92+
93+
expect(TextsApi.getWords).toHaveBeenCalledWith(123);
94+
expect(result.source).toBe('network');
95+
expect(result.data).toEqual(mockTextData);
96+
});
97+
98+
it('indicates offline availability status', async () => {
99+
vi.mocked(isTextAvailableOffline).mockResolvedValue({ available: true });
100+
vi.mocked(TextsApi.getWords).mockResolvedValue({
101+
data: mockTextData,
102+
error: undefined,
103+
});
104+
105+
const result = await getTextWordsOfflineFirst(123);
106+
107+
expect(result.offlineAvailable).toBe(true);
108+
});
109+
110+
it('falls back to offline on network error when data is cached', async () => {
111+
vi.mocked(isTextAvailableOffline).mockResolvedValue({ available: true });
112+
vi.mocked(TextsApi.getWords).mockRejectedValue(new Error('Network error'));
113+
vi.mocked(getOfflineTextData).mockResolvedValue(mockTextData);
114+
115+
const result = await getTextWordsOfflineFirst(123);
116+
117+
expect(result.source).toBe('offline');
118+
expect(result.data).toEqual(mockTextData);
119+
expect(consoleWarnSpy).toHaveBeenCalledWith(
120+
'Network failed, using offline data'
121+
);
122+
});
123+
124+
it('throws error when network fails and no offline data', async () => {
125+
vi.mocked(isTextAvailableOffline).mockResolvedValue({ available: false });
126+
vi.mocked(TextsApi.getWords).mockRejectedValue(new Error('Network error'));
127+
128+
await expect(getTextWordsOfflineFirst(123)).rejects.toThrow('Network error');
129+
});
130+
131+
it('throws error when API returns error response', async () => {
132+
vi.mocked(isTextAvailableOffline).mockResolvedValue({ available: false });
133+
vi.mocked(TextsApi.getWords).mockResolvedValue({
134+
data: undefined,
135+
error: 'Text not found',
136+
});
137+
138+
await expect(getTextWordsOfflineFirst(123)).rejects.toThrow('Text not found');
139+
});
140+
141+
it('throws generic error when API returns no data and no error', async () => {
142+
vi.mocked(isTextAvailableOffline).mockResolvedValue({ available: false });
143+
vi.mocked(TextsApi.getWords).mockResolvedValue({
144+
data: undefined,
145+
error: undefined,
146+
});
147+
148+
await expect(getTextWordsOfflineFirst(123)).rejects.toThrow('Failed to fetch text');
149+
});
150+
});
151+
152+
describe('when offline', () => {
153+
beforeEach(() => {
154+
Object.defineProperty(navigator, 'onLine', { value: false });
155+
});
156+
157+
it('returns cached data when available', async () => {
158+
vi.mocked(isTextAvailableOffline).mockResolvedValue({ available: true });
159+
vi.mocked(getOfflineTextData).mockResolvedValue(mockTextData);
160+
161+
const result = await getTextWordsOfflineFirst(123);
162+
163+
expect(result.source).toBe('offline');
164+
expect(result.data).toEqual(mockTextData);
165+
expect(result.offlineAvailable).toBe(true);
166+
expect(TextsApi.getWords).not.toHaveBeenCalled();
167+
});
168+
169+
it('throws error when text not cached', async () => {
170+
vi.mocked(isTextAvailableOffline).mockResolvedValue({ available: false });
171+
172+
await expect(getTextWordsOfflineFirst(123)).rejects.toThrow(
173+
'Text not available offline. Download it first while online.'
174+
);
175+
});
176+
177+
it('throws error when offline data retrieval returns null', async () => {
178+
vi.mocked(isTextAvailableOffline).mockResolvedValue({ available: true });
179+
vi.mocked(getOfflineTextData).mockResolvedValue(null);
180+
181+
await expect(getTextWordsOfflineFirst(123)).rejects.toThrow(
182+
'Text not available offline. Download it first while online.'
183+
);
184+
});
185+
});
186+
187+
describe('when storage is not available', () => {
188+
it('skips offline check when storage unavailable', async () => {
189+
vi.mocked(isOfflineStorageAvailable).mockReturnValue(false);
190+
vi.mocked(TextsApi.getWords).mockResolvedValue({
191+
data: mockTextData,
192+
error: undefined,
193+
});
194+
195+
const result = await getTextWordsOfflineFirst(123);
196+
197+
expect(isTextAvailableOffline).not.toHaveBeenCalled();
198+
expect(result.offlineAvailable).toBe(false);
199+
});
200+
});
201+
});
202+
203+
// ===========================================================================
204+
// canReadText Tests
205+
// ===========================================================================
206+
207+
describe('canReadText', () => {
208+
describe('when online', () => {
209+
beforeEach(() => {
210+
Object.defineProperty(navigator, 'onLine', { value: true });
211+
});
212+
213+
it('returns true regardless of offline availability', async () => {
214+
vi.mocked(isTextAvailableOffline).mockResolvedValue({ available: false });
215+
216+
const result = await canReadText(123);
217+
218+
expect(result).toBe(true);
219+
expect(isTextAvailableOffline).not.toHaveBeenCalled();
220+
});
221+
});
222+
223+
describe('when offline', () => {
224+
beforeEach(() => {
225+
Object.defineProperty(navigator, 'onLine', { value: false });
226+
});
227+
228+
it('returns true when text is cached', async () => {
229+
vi.mocked(isOfflineStorageAvailable).mockReturnValue(true);
230+
vi.mocked(isTextAvailableOffline).mockResolvedValue({ available: true });
231+
232+
const result = await canReadText(123);
233+
234+
expect(result).toBe(true);
235+
expect(isTextAvailableOffline).toHaveBeenCalledWith(123);
236+
});
237+
238+
it('returns false when text is not cached', async () => {
239+
vi.mocked(isOfflineStorageAvailable).mockReturnValue(true);
240+
vi.mocked(isTextAvailableOffline).mockResolvedValue({ available: false });
241+
242+
const result = await canReadText(456);
243+
244+
expect(result).toBe(false);
245+
});
246+
247+
it('returns false when storage is not available', async () => {
248+
vi.mocked(isOfflineStorageAvailable).mockReturnValue(false);
249+
250+
const result = await canReadText(789);
251+
252+
expect(result).toBe(false);
253+
expect(isTextAvailableOffline).not.toHaveBeenCalled();
254+
});
255+
});
256+
});
257+
258+
// ===========================================================================
259+
// getReadableOfflineTextIds Tests
260+
// ===========================================================================
261+
262+
describe('getReadableOfflineTextIds', () => {
263+
it('returns empty array when storage not available', async () => {
264+
vi.mocked(isOfflineStorageAvailable).mockReturnValue(false);
265+
266+
const result = await getReadableOfflineTextIds();
267+
268+
expect(result).toEqual([]);
269+
});
270+
271+
it('returns list of cached text IDs', async () => {
272+
vi.mocked(isOfflineStorageAvailable).mockReturnValue(true);
273+
vi.mocked(offlineDb.texts.toArray).mockResolvedValue([
274+
{ id: 1, langId: 1, title: 'Text 1' },
275+
{ id: 5, langId: 1, title: 'Text 5' },
276+
{ id: 12, langId: 2, title: 'Text 12' },
277+
] as any);
278+
279+
const result = await getReadableOfflineTextIds();
280+
281+
expect(result).toEqual([1, 5, 12]);
282+
});
283+
284+
it('returns empty array when no texts cached', async () => {
285+
vi.mocked(isOfflineStorageAvailable).mockReturnValue(true);
286+
vi.mocked(offlineDb.texts.toArray).mockResolvedValue([]);
287+
288+
const result = await getReadableOfflineTextIds();
289+
290+
expect(result).toEqual([]);
291+
});
292+
});
293+
294+
// ===========================================================================
295+
// TextDataResult Type Tests
296+
// ===========================================================================
297+
298+
describe('TextDataResult type', () => {
299+
it('has correct structure for network source', async () => {
300+
vi.mocked(TextsApi.getWords).mockResolvedValue({
301+
data: { config: {} as any, words: [] },
302+
error: undefined,
303+
});
304+
vi.mocked(isTextAvailableOffline).mockResolvedValue({ available: false });
305+
306+
const result: TextDataResult = await getTextWordsOfflineFirst(1);
307+
308+
expect(result).toHaveProperty('data');
309+
expect(result).toHaveProperty('source');
310+
expect(result).toHaveProperty('offlineAvailable');
311+
expect(result.source).toBe('network');
312+
});
313+
314+
it('has correct structure for offline source', async () => {
315+
Object.defineProperty(navigator, 'onLine', { value: false });
316+
vi.mocked(isTextAvailableOffline).mockResolvedValue({ available: true });
317+
vi.mocked(getOfflineTextData).mockResolvedValue({ config: {} as any, words: [] });
318+
319+
const result: TextDataResult = await getTextWordsOfflineFirst(1);
320+
321+
expect(result.source).toBe('offline');
322+
expect(result.offlineAvailable).toBe(true);
323+
});
324+
});
325+
326+
// ===========================================================================
327+
// Edge Cases
328+
// ===========================================================================
329+
330+
describe('Edge Cases', () => {
331+
it('handles concurrent requests', async () => {
332+
vi.mocked(TextsApi.getWords).mockResolvedValue({
333+
data: { config: {} as any, words: [] },
334+
error: undefined,
335+
});
336+
vi.mocked(isTextAvailableOffline).mockResolvedValue({ available: false });
337+
338+
const [result1, result2, result3] = await Promise.all([
339+
getTextWordsOfflineFirst(1),
340+
getTextWordsOfflineFirst(2),
341+
getTextWordsOfflineFirst(3),
342+
]);
343+
344+
expect(result1.source).toBe('network');
345+
expect(result2.source).toBe('network');
346+
expect(result3.source).toBe('network');
347+
expect(TextsApi.getWords).toHaveBeenCalledTimes(3);
348+
});
349+
350+
it('handles rapid online/offline state changes', async () => {
351+
// Start online
352+
Object.defineProperty(navigator, 'onLine', { value: true });
353+
vi.mocked(TextsApi.getWords).mockResolvedValue({
354+
data: { config: {} as any, words: [] },
355+
error: undefined,
356+
});
357+
vi.mocked(isTextAvailableOffline).mockResolvedValue({ available: true });
358+
359+
const result1 = await getTextWordsOfflineFirst(1);
360+
expect(result1.source).toBe('network');
361+
362+
// Go offline
363+
Object.defineProperty(navigator, 'onLine', { value: false });
364+
vi.mocked(getOfflineTextData).mockResolvedValue({ config: {} as any, words: [] });
365+
366+
const result2 = await getTextWordsOfflineFirst(1);
367+
expect(result2.source).toBe('offline');
368+
});
369+
});
370+
});

0 commit comments

Comments
 (0)