-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathBaseService.test.js
More file actions
234 lines (214 loc) · 9.97 KB
/
BaseService.test.js
File metadata and controls
234 lines (214 loc) · 9.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
import { describe, it, expect, vi, beforeEach } from 'vitest';
let BaseService;
beforeEach(async () => {
const path = require.resolve('../../services/BaseService.js');
delete require.cache[path];
await import('../../services/BaseService.js');
BaseService = global.BaseService;
});
describe('BaseService', () => {
const config = { name: 'TestService', baseUrl: 'https://test.com' };
it('Constructs with config and logs', () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const svc = new BaseService(config);
expect(svc.config).toEqual(config);
expect(svc.name).toBe('TestService');
expect(svc.baseUrl).toBe('https://test.com');
expect(logSpy).toHaveBeenCalledWith('[BaseService] Created service: TestService');
logSpy.mockRestore();
});
it('Extract pages correct array for known keys', () => {
const svc = new BaseService(config);
expect(svc.extractPages({ pages: [1, 2, 3] })).toEqual([1, 2, 3]);
expect(svc.extractPages({ images: ['a'] })).toEqual(['a']);
expect(svc.extractPages({ pages_list: [5] })).toEqual([5]);
expect(svc.extractPages({ content: ['x', 'y'] })).toEqual(['x', 'y']);
});
it('Extract pages returns empty array if no known keys', () => {
const svc = new BaseService(config);
expect(svc.extractPages({})).toEqual([]);
expect(svc.extractPages({ foo: [1, 2] })).toEqual([]);
expect(svc.extractPages({ pages: [] })).toEqual([]);
});
it('Fetch manga metadata throws error', async () => {
const svc = new BaseService(config);
await expect(svc.fetchMangaMetadata('slug')).rejects.toThrow('fetchMangaMetadata must be implemented');
});
it('Fetch chapters list throws error', async () => {
const svc = new BaseService(config);
await expect(svc.fetchChaptersList('slug')).rejects.toThrow('fetchChaptersList must be implemented');
});
it('Fetch chapter throws error', async () => {
const svc = new BaseService(config);
await expect(svc.fetchChapter('slug', 1, 1)).rejects.toThrow('fetchChapter must be implemented');
});
it('Static matches throws error', () => {
expect(() => BaseService.matches('url')).toThrow('matches must be implemented');
});
it('Delay resolves after given ms', async () => {
const svc = new BaseService(config);
const spy = vi.spyOn(global, 'setTimeout');
await svc.delay(10);
expect(spy).toHaveBeenCalledWith(expect.any(Function), 10);
spy.mockRestore();
});
it('Fetch with retry returns fetch result on first try', async () => {
const svc = new BaseService(config);
const fakeResponse = {};
global.fetch = vi.fn().mockResolvedValue(fakeResponse);
const result = await svc.fetchWithRetry('url', {});
expect(result).toBe(fakeResponse);
expect(global.fetch).toHaveBeenCalledTimes(1);
delete global.fetch;
});
it('Fetch with retry retries on failure and then throws', async () => {
const svc = new BaseService(config);
const fetchMock = vi.fn()
.mockRejectedValueOnce(new Error('fail1'))
.mockRejectedValueOnce(new Error('fail2'))
.mockRejectedValueOnce(new Error('fail3'));
global.fetch = fetchMock;
const delaySpy = vi.spyOn(svc, 'delay').mockResolvedValue();
await expect(svc.fetchWithRetry('url', {}, 3)).rejects.toThrow('fail3');
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(delaySpy).toHaveBeenCalledTimes(2);
delete global.fetch;
delaySpy.mockRestore();
});
it('loadPageAsBase64 returns base64 string', async () => {
const svc = new BaseService(config);
const fakeBlob = new Blob(['test'], { type: 'text/plain' });
const fakeResponse = { blob: vi.fn().mockResolvedValue(fakeBlob) };
vi.spyOn(svc, 'fetchWithRetry').mockResolvedValue(fakeResponse);
const origFileReader = global.FileReader;
class FakeFileReader {
constructor() { this.onloadend = null; }
readAsDataURL(blob) {
setTimeout(() => { this.result = 'data:base64,abc'; this.onloadend(); }, 0);
}
}
global.FileReader = FakeFileReader;
const result = await svc.loadPageAsBase64('url');
expect(result).toBe('data:base64,abc');
global.FileReader = origFileReader;
});
it('blobToBase64 resolves to base64 string', async () => {
const svc = new BaseService(config);
const origFileReader = global.FileReader;
class FakeFileReader {
constructor() { this.onloadend = null; }
readAsDataURL(blob) {
setTimeout(() => { this.result = 'data:base64,xyz'; this.onloadend(); }, 0);
}
}
global.FileReader = FakeFileReader;
const result = await svc.blobToBase64(new Blob(['abc']));
expect(result).toBe('data:base64,xyz');
global.FileReader = origFileReader;
});
it('Handles 429 response with on429, global throttling and delay then retries', async () => {
const svc = new BaseService({ name: 'TestService', baseUrl: 'https://test.com' });
const response429 = {
status: 429,
headers: { get: vi.fn(() => '2') }
};
const responseOk = { status: 200 };
const fetchMock = vi.fn()
.mockResolvedValueOnce(response429)
.mockResolvedValueOnce(responseOk);
global.fetch = fetchMock;
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const delaySpy = vi.spyOn(svc, 'delay').mockResolvedValue();
let on429Called = false;
svc._on429 = (ms) => { on429Called = ms === 2000; };
global.globalRateLimiter = { throttle: vi.fn() };
const result = await svc.fetchWithRateLimitRetry('url', {}, 2);
expect(result).toBe(responseOk);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(warnSpy).toHaveBeenCalledWith('[TestService] 429 Too Many Requests (attempt 1/2), waiting 2000ms...');
expect(on429Called).toBe(true);
expect(global.globalRateLimiter.throttle).toHaveBeenCalledWith(2000);
expect(delaySpy).toHaveBeenCalledWith(2000);
warnSpy.mockRestore();
delaySpy.mockRestore();
delete global.fetch;
delete global.globalRateLimiter;
});
it('Warns when no globalRateLimiter is found and proceeds with local delay', async () => {
const svc = new BaseService({ name: 'TestService', baseUrl: 'https://test.com' });
const response429 = {
status: 429,
headers: { get: vi.fn(() => '1') }
};
const responseOk = { status: 200 };
const fetchMock = vi.fn()
.mockResolvedValueOnce(response429)
.mockResolvedValueOnce(responseOk);
global.fetch = fetchMock;
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const delaySpy = vi.spyOn(svc, 'delay').mockResolvedValue();
delete global.globalRateLimiter;
delete global.self;
const result = await svc.fetchWithRateLimitRetry('url', {}, 2);
expect(result).toBe(responseOk);
expect(warnSpy).toHaveBeenCalledWith('[TestService] No globalRateLimiter found, proceeding with local delay.');
warnSpy.mockRestore();
delaySpy.mockRestore();
delete global.fetch;
});
it('Uses self globalRateLimiter throttle when global is missing but self is present', async () => {
const svc = new BaseService({ name: 'TestService', baseUrl: 'https://test.com' });
const response429 = {
status: 429,
headers: { get: vi.fn(() => '1') }
};
const responseOk = { status: 200 };
const fetchMock = vi.fn()
.mockResolvedValueOnce(response429)
.mockResolvedValueOnce(responseOk);
global.fetch = fetchMock;
delete global.globalRateLimiter;
global.self = { globalRateLimiter: { throttle: vi.fn() } };
const delaySpy = vi.spyOn(svc, 'delay').mockResolvedValue();
const result = await svc.fetchWithRateLimitRetry('url', {}, 2);
expect(result).toBe(responseOk);
expect(global.self.globalRateLimiter.throttle).toHaveBeenCalledWith(1000);
delaySpy.mockRestore();
delete global.fetch;
delete global.self;
});
it('Uses default waitMs 30000 when Retry-After header is missing or invalid', async () => {
const svc = new BaseService({ name: 'TestService', baseUrl: 'https://test.com' });
const response429 = {
status: 429,
headers: { get: vi.fn(() => null) }
};
const responseOk = { status: 200 };
const fetchMock = vi.fn()
.mockResolvedValueOnce(response429)
.mockResolvedValueOnce(responseOk);
global.fetch = fetchMock;
const delaySpy = vi.spyOn(svc, 'delay').mockResolvedValue();
global.globalRateLimiter = { throttle: vi.fn() };
const result = await svc.fetchWithRateLimitRetry('url', {}, 2);
expect(result).toBe(responseOk);
expect(delaySpy).toHaveBeenCalledWith(30000);
delaySpy.mockRestore();
delete global.fetch;
delete global.globalRateLimiter;
});
it('Throws error after maxRetries when always rate limited', async () => {
const svc = new BaseService({ name: 'TestService', baseUrl: 'https://test.com' });
const response429 = {
status: 429,
headers: { get: vi.fn(() => '1') }
};
const fetchMock = vi.fn().mockResolvedValue(response429);
global.fetch = fetchMock;
const delaySpy = vi.spyOn(svc, 'delay').mockResolvedValue();
await expect(svc.fetchWithRateLimitRetry('url', {}, 3)).rejects.toThrow('Rate limited after 3 retries (429)');
expect(fetchMock).toHaveBeenCalledTimes(3);
delaySpy.mockRestore();
delete global.fetch;
});
});