Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/shared-fetch-utils/common/test/advancedFetcher.spec.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { beforeEach, describe, expect, it, vi } from 'vitest';
7
import { composeFetchMiddleware, createAdvancedFetch } from '../advancedFetcher';
8
import type { FetchMiddleware, HttpFetchFn, HttpHeaders, HttpRequest, HttpResponse, WindowStateProvider } from '../fetchTypes';
9
import { AuthBlockedError, authBlockedMiddleware } from '../middleware/authBlockedMiddleware';
10
import { etagMiddleware } from '../middleware/etagMiddleware';
11
import { ServerBackoffError, serverErrorBackoffMiddleware } from '../middleware/serverErrorBackoffMiddleware';
12
import { WindowInactiveError, windowActiveMiddleware } from '../middleware/windowActiveMiddleware';
13
14
// ── Helpers ─────────────────────────────────────────────────────────────
15
16
function makeHeaders(entries: Record<string, string> = {}): HttpHeaders {
17
const map = new Map(Object.entries(entries));
18
return { get: (name: string) => map.get(name.toLowerCase()) ?? null };
19
}
20
21
function makeResponse(status: number, headers: Record<string, string> = {}, body: unknown = null): HttpResponse {
22
// Normalise header keys to lowercase for realistic behaviour
23
const lower: Record<string, string> = {};
24
for (const [k, v] of Object.entries(headers)) {
25
lower[k.toLowerCase()] = v;
26
}
27
const serialized = body !== null ? JSON.stringify(body) : null;
28
const bodyStream = serialized !== null
29
? new ReadableStream<Uint8Array>({
30
start(controller) {
31
controller.enqueue(new TextEncoder().encode(serialized));
32
controller.close();
33
},
34
})
35
: null;
36
return {
37
status,
38
headers: makeHeaders(lower),
39
body: bodyStream,
40
async text() { return serialized ?? ''; },
41
async json() { return JSON.parse(await this.text()); },
42
};
43
}
44
45
function stubFetch(response: HttpResponse): HttpFetchFn {
46
return vi.fn<HttpFetchFn>().mockResolvedValue(response);
47
}
48
49
const defaultRequest: HttpRequest = { url: 'https://api.test/data', headers: {} };
50
51
// ── etagMiddleware ──────────────────────────────────────────────────────
52
53
describe('etagMiddleware', () => {
54
it('does not add conditional headers on first request', async () => {
55
const inner = stubFetch(makeResponse(200, { 'ETag': '"abc"' }));
56
const fetch = etagMiddleware()(inner);
57
58
await fetch(defaultRequest);
59
60
expect(inner).toHaveBeenCalledWith(expect.objectContaining({
61
headers: expect.not.objectContaining({ 'If-None-Match': expect.anything() }),
62
}));
63
});
64
65
it('adds If-None-Match on subsequent requests', async () => {
66
const inner = stubFetch(makeResponse(200, { 'ETag': '"abc"' }));
67
const fetch = etagMiddleware()(inner);
68
69
await fetch(defaultRequest);
70
71
// Second call should include conditional header
72
await fetch(defaultRequest);
73
expect(inner).toHaveBeenLastCalledWith(expect.objectContaining({
74
headers: expect.objectContaining({ 'If-None-Match': '"abc"' }),
75
}));
76
});
77
78
it('adds If-Modified-Since on subsequent requests', async () => {
79
const inner = stubFetch(makeResponse(200, { 'Last-Modified': 'Wed, 01 Jan 2025 00:00:00 GMT' }));
80
const fetch = etagMiddleware()(inner);
81
82
await fetch(defaultRequest);
83
await fetch(defaultRequest);
84
85
expect(inner).toHaveBeenLastCalledWith(expect.objectContaining({
86
headers: expect.objectContaining({ 'If-Modified-Since': 'Wed, 01 Jan 2025 00:00:00 GMT' }),
87
}));
88
});
89
90
it('returns cached response on 304', async () => {
91
const original = makeResponse(200, { 'ETag': '"v1"' });
92
const notModified = makeResponse(304);
93
const inner = vi.fn<HttpFetchFn>()
94
.mockResolvedValueOnce(original)
95
.mockResolvedValueOnce(notModified);
96
97
const fetch = etagMiddleware()(inner);
98
99
const first = await fetch(defaultRequest);
100
const second = await fetch(defaultRequest);
101
102
expect(first.status).toBe(200);
103
expect(second.status).toBe(200); // cached response returned on 304
104
});
105
106
it('updates cache on new 200 response', async () => {
107
const v1 = makeResponse(200, { 'ETag': '"v1"' });
108
const v2 = makeResponse(200, { 'ETag': '"v2"' });
109
const inner = vi.fn<HttpFetchFn>()
110
.mockResolvedValueOnce(v1)
111
.mockResolvedValueOnce(v2);
112
113
const fetch = etagMiddleware()(inner);
114
115
await fetch(defaultRequest);
116
const second = await fetch(defaultRequest);
117
118
expect(second.status).toBe(200);
119
});
120
});
121
122
// ── authBlockedMiddleware ─────────────────────────────────────────────
123
124
describe('authBlockedMiddleware', () => {
125
const authedRequest: HttpRequest = { url: 'https://api.test/data', headers: { 'Authorization': 'Bearer token-a' } };
126
127
it('allows requests normally', async () => {
128
const inner = stubFetch(makeResponse(200));
129
const fetch = authBlockedMiddleware()(inner);
130
131
const result = await fetch(authedRequest);
132
expect(result.status).toBe(200);
133
});
134
135
it('throws AuthBlockedError after 401', async () => {
136
const inner = stubFetch(makeResponse(401));
137
const fetch = authBlockedMiddleware()(inner);
138
139
await expect(fetch(authedRequest)).rejects.toThrow(AuthBlockedError);
140
});
141
142
it('throws AuthBlockedError after 403', async () => {
143
const inner = stubFetch(makeResponse(403));
144
const fetch = authBlockedMiddleware()(inner);
145
146
await expect(fetch(authedRequest)).rejects.toThrow(AuthBlockedError);
147
});
148
149
it('blocks subsequent requests with same token after blocking', async () => {
150
const inner = vi.fn<HttpFetchFn>()
151
.mockResolvedValueOnce(makeResponse(401))
152
.mockResolvedValueOnce(makeResponse(200));
153
const fetch = authBlockedMiddleware(60_000)(inner);
154
155
await expect(fetch(authedRequest)).rejects.toThrow(AuthBlockedError);
156
157
// Subsequent request with same token should be blocked without calling inner
158
await expect(fetch(authedRequest)).rejects.toThrow(AuthBlockedError);
159
expect(inner).toHaveBeenCalledTimes(1);
160
});
161
162
it('clears block when token changes', async () => {
163
const inner = vi.fn<HttpFetchFn>()
164
.mockResolvedValueOnce(makeResponse(401))
165
.mockResolvedValueOnce(makeResponse(200));
166
const fetch = authBlockedMiddleware(60_000)(inner);
167
168
await expect(fetch(authedRequest)).rejects.toThrow(AuthBlockedError);
169
170
// Change token → block should clear
171
const newTokenRequest: HttpRequest = { url: 'https://api.test/data', headers: { 'Authorization': 'Bearer token-new' } };
172
const result = await fetch(newTokenRequest);
173
expect(result.status).toBe(200);
174
expect(inner).toHaveBeenCalledTimes(2);
175
});
176
177
it('clears block after duration expires', async () => {
178
const inner = vi.fn<HttpFetchFn>()
179
.mockResolvedValueOnce(makeResponse(401))
180
.mockResolvedValueOnce(makeResponse(200));
181
const fetch = authBlockedMiddleware(100)(inner);
182
183
await expect(fetch(authedRequest)).rejects.toThrow(AuthBlockedError);
184
185
vi.useFakeTimers();
186
try {
187
vi.advanceTimersByTime(150);
188
// Block expired → request should proceed
189
const result = await fetch(authedRequest);
190
expect(result.status).toBe(200);
191
} finally {
192
vi.useRealTimers();
193
}
194
});
195
196
it('does not block when there is no Authorization header', async () => {
197
const inner = stubFetch(makeResponse(401));
198
const fetch = authBlockedMiddleware()(inner);
199
200
// No Authorization header → middleware passes through the response
201
const result = await fetch(defaultRequest);
202
expect(result.status).toBe(401);
203
});
204
});
205
206
// ── serverErrorBackoffMiddleware ────────────────────────────────────────
207
208
describe('serverErrorBackoffMiddleware', () => {
209
it('allows requests normally on success', async () => {
210
const inner = stubFetch(makeResponse(200));
211
const fetch = serverErrorBackoffMiddleware()(inner);
212
213
const result = await fetch(defaultRequest);
214
expect(result.status).toBe(200);
215
});
216
217
it('throws ServerBackoffError on 500', async () => {
218
const inner = stubFetch(makeResponse(500));
219
const fetch = serverErrorBackoffMiddleware({ initialDelayMs: 100 })(inner);
220
221
await expect(fetch(defaultRequest)).rejects.toThrow(ServerBackoffError);
222
});
223
224
it('blocks subsequent requests during backoff window', async () => {
225
const inner = vi.fn<HttpFetchFn>()
226
.mockResolvedValueOnce(makeResponse(503))
227
.mockResolvedValueOnce(makeResponse(200));
228
const fetch = serverErrorBackoffMiddleware({ initialDelayMs: 60_000 })(inner);
229
230
await expect(fetch(defaultRequest)).rejects.toThrow(ServerBackoffError);
231
232
// Within backoff window → blocked without calling inner
233
await expect(fetch(defaultRequest)).rejects.toThrow(ServerBackoffError);
234
expect(inner).toHaveBeenCalledTimes(1);
235
});
236
237
it('applies exponential backoff on consecutive failures', async () => {
238
const inner = vi.fn<HttpFetchFn>().mockResolvedValue(makeResponse(500));
239
const fetch = serverErrorBackoffMiddleware({ initialDelayMs: 100, multiplier: 2 })(inner);
240
241
vi.useFakeTimers();
242
try {
243
// First failure → 100ms backoff
244
await expect(fetch(defaultRequest)).rejects.toThrow(ServerBackoffError);
245
vi.advanceTimersByTime(110);
246
247
// Second failure → 200ms backoff
248
await expect(fetch(defaultRequest)).rejects.toThrow(ServerBackoffError);
249
vi.advanceTimersByTime(210);
250
251
// Third failure → 400ms backoff
252
await expect(fetch(defaultRequest)).rejects.toSatisfy(
253
(err: ServerBackoffError) => err.retryAfterMs >= 399,
254
);
255
} finally {
256
vi.useRealTimers();
257
}
258
});
259
260
it('caps backoff at maxDelayMs', async () => {
261
const inner = vi.fn<HttpFetchFn>().mockResolvedValue(makeResponse(500));
262
const fetch = serverErrorBackoffMiddleware({ initialDelayMs: 100, maxDelayMs: 300, multiplier: 10 })(inner);
263
264
vi.useFakeTimers();
265
try {
266
await expect(fetch(defaultRequest)).rejects.toThrow(ServerBackoffError);
267
vi.advanceTimersByTime(110);
268
269
// Second failure → min(100*10, 300) = 300
270
await expect(fetch(defaultRequest)).rejects.toSatisfy(
271
(err: ServerBackoffError) => err.retryAfterMs <= 300,
272
);
273
} finally {
274
vi.useRealTimers();
275
}
276
});
277
278
it('resets backoff on success', async () => {
279
const inner = vi.fn<HttpFetchFn>()
280
.mockResolvedValueOnce(makeResponse(500))
281
.mockResolvedValueOnce(makeResponse(200))
282
.mockResolvedValueOnce(makeResponse(500));
283
const fetch = serverErrorBackoffMiddleware({ initialDelayMs: 100 })(inner);
284
285
vi.useFakeTimers();
286
try {
287
await expect(fetch(defaultRequest)).rejects.toThrow(ServerBackoffError);
288
vi.advanceTimersByTime(110);
289
290
// Success → resets
291
const ok = await fetch(defaultRequest);
292
expect(ok.status).toBe(200);
293
294
// Next 500 → starts at initialDelayMs again
295
await expect(fetch(defaultRequest)).rejects.toSatisfy(
296
(err: ServerBackoffError) => err.retryAfterMs <= 100,
297
);
298
} finally {
299
vi.useRealTimers();
300
}
301
});
302
});
303
304
// ── windowActiveMiddleware ──────────────────────────────────────────────
305
306
describe('windowActiveMiddleware', () => {
307
it('calls next when window is active', async () => {
308
const provider: WindowStateProvider = { isActive: true };
309
const inner = stubFetch(makeResponse(200));
310
const fetch = windowActiveMiddleware(provider)(inner);
311
312
const result = await fetch(defaultRequest);
313
expect(result.status).toBe(200);
314
expect(inner).toHaveBeenCalledTimes(1);
315
});
316
317
it('throws WindowInactiveError when window is inactive', async () => {
318
const provider: WindowStateProvider = { isActive: false };
319
const inner = stubFetch(makeResponse(200));
320
const fetch = windowActiveMiddleware(provider)(inner);
321
322
await expect(fetch(defaultRequest)).rejects.toThrow(WindowInactiveError);
323
expect(inner).not.toHaveBeenCalled();
324
});
325
});
326
327
// ── composeFetchMiddleware ──────────────────────────────────────────────
328
329
describe('composeFetchMiddleware', () => {
330
it('composes middlewares left-to-right (first = outermost)', async () => {
331
const order: string[] = [];
332
333
const mwA: FetchMiddleware = (next) => async (req) => {
334
order.push('a-before');
335
const res = await next(req);
336
order.push('a-after');
337
return res;
338
};
339
const mwB: FetchMiddleware = (next) => async (req) => {
340
order.push('b-before');
341
const res = await next(req);
342
order.push('b-after');
343
return res;
344
};
345
346
const inner = stubFetch(makeResponse(200));
347
const fetch = composeFetchMiddleware(mwA, mwB)(inner);
348
349
await fetch(defaultRequest);
350
351
expect(order).toEqual(['a-before', 'b-before', 'b-after', 'a-after']);
352
});
353
354
it('identity when no middlewares are provided', async () => {
355
const inner = stubFetch(makeResponse(200));
356
const fetch = composeFetchMiddleware()(inner);
357
358
const result = await fetch(defaultRequest);
359
expect(result.status).toBe(200);
360
});
361
});
362
363
// ── createAdvancedFetch ─────────────────────────────────────────────────
364
365
describe('createAdvancedFetch', () => {
366
it('creates a () => Promise<T> that parses responses', async () => {
367
const fetchFn = createAdvancedFetch({
368
request: defaultRequest,
369
httpFetch: async () => makeResponse(200, {}, { name: 'test' }),
370
parseResponse: async (res) => ((await res.json()) as { name: string }).name,
371
});
372
373
const result = await fetchFn();
374
expect(result).toBe('test');
375
});
376
377
it('accepts a request factory function', async () => {
378
let callCount = 0;
379
const fetchFn = createAdvancedFetch({
380
request: () => {
381
callCount++;
382
return { url: `https://api.test/${callCount}`, headers: { 'X-Count': String(callCount) } };
383
},
384
httpFetch: async (req) => makeResponse(200, {}, { url: req.url }),
385
parseResponse: async (res) => ((await res.json()) as { url: string }).url,
386
});
387
388
expect(await fetchFn()).toBe('https://api.test/1');
389
expect(await fetchFn()).toBe('https://api.test/2');
390
});
391
392
it('applies middleware stack', async () => {
393
const provider = { isActive: false };
394
395
const fetchFn = createAdvancedFetch({
396
request: defaultRequest,
397
httpFetch: async () => makeResponse(200),
398
parseResponse: async (res) => res.status,
399
middleware: [windowActiveMiddleware(provider)],
400
});
401
402
// Inactive → middleware throws
403
await expect(fetchFn()).rejects.toThrow(WindowInactiveError);
404
});
405
406
it('composes with FetchedValue', async () => {
407
// Integration-level smoke test: createAdvancedFetch produces a function
408
// compatible with FetchedValue's `fetch` option signature.
409
const { FetchedValue } = await import('../fetchedValue');
410
411
const fetchFn = createAdvancedFetch({
412
request: defaultRequest,
413
httpFetch: async () => makeResponse(999),
414
parseResponse: async (res) => res.status,
415
});
416
417
const fv = new FetchedValue({
418
fetch: fetchFn,
419
isStale: () => false,
420
});
421
422
expect(fv.value).toBeUndefined();
423
const value = await fv.resolve();
424
expect(value).toBe(999);
425
expect(fv.value).toBe(999);
426
427
fv.dispose();
428
});
429
});
430
431
// ── Full-stack integration ──────────────────────────────────────────────
432
433
describe('full middleware stack', () => {
434
let provider: { isActive: boolean };
435
let authedRequest: HttpRequest;
436
let inner: ReturnType<typeof vi.fn<HttpFetchFn>>;
437
let fetch: (request: HttpRequest) => Promise<HttpResponse>;
438
439
beforeEach(() => {
440
provider = { isActive: true };
441
authedRequest = { url: 'https://api.test/data', headers: { 'Authorization': 'Bearer tok-1' } };
442
inner = vi.fn<HttpFetchFn>().mockResolvedValue(makeResponse(200, { 'ETag': '"v1"' }));
443
444
const composed = composeFetchMiddleware(
445
windowActiveMiddleware(provider),
446
authBlockedMiddleware(),
447
serverErrorBackoffMiddleware({ initialDelayMs: 100 }),
448
etagMiddleware(),
449
);
450
fetch = composed(inner);
451
});
452
453
it('happy path passes through all middlewares', async () => {
454
const res = await fetch(authedRequest);
455
expect(res.status).toBe(200);
456
expect(inner).toHaveBeenCalledTimes(1);
457
});
458
459
it('window inactive throws WindowInactiveError', async () => {
460
// Prime the cache
461
await fetch(authedRequest);
462
expect(inner).toHaveBeenCalledTimes(1);
463
464
// Become inactive → middleware throws before reaching network
465
provider.isActive = false;
466
await expect(fetch(authedRequest)).rejects.toThrow(WindowInactiveError);
467
expect(inner).toHaveBeenCalledTimes(1); // no additional call
468
});
469
470
it('auth failure blocks all further requests until token changes', async () => {
471
inner.mockResolvedValueOnce(makeResponse(401));
472
473
await expect(fetch(authedRequest)).rejects.toThrow(AuthBlockedError);
474
await expect(fetch(authedRequest)).rejects.toThrow(AuthBlockedError);
475
476
const newTokenRequest: HttpRequest = { url: 'https://api.test/data', headers: { 'Authorization': 'Bearer tok-2' } };
477
inner.mockResolvedValueOnce(makeResponse(200));
478
const res = await fetch(newTokenRequest);
479
expect(res.status).toBe(200);
480
});
481
});
482
483