Path: blob/main/extensions/copilot/src/shared-fetch-utils/common/test/advancedFetcher.spec.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { beforeEach, describe, expect, it, vi } from 'vitest';6import { composeFetchMiddleware, createAdvancedFetch } from '../advancedFetcher';7import type { FetchMiddleware, HttpFetchFn, HttpHeaders, HttpRequest, HttpResponse, WindowStateProvider } from '../fetchTypes';8import { AuthBlockedError, authBlockedMiddleware } from '../middleware/authBlockedMiddleware';9import { etagMiddleware } from '../middleware/etagMiddleware';10import { ServerBackoffError, serverErrorBackoffMiddleware } from '../middleware/serverErrorBackoffMiddleware';11import { WindowInactiveError, windowActiveMiddleware } from '../middleware/windowActiveMiddleware';1213// ── Helpers ─────────────────────────────────────────────────────────────1415function makeHeaders(entries: Record<string, string> = {}): HttpHeaders {16const map = new Map(Object.entries(entries));17return { get: (name: string) => map.get(name.toLowerCase()) ?? null };18}1920function makeResponse(status: number, headers: Record<string, string> = {}, body: unknown = null): HttpResponse {21// Normalise header keys to lowercase for realistic behaviour22const lower: Record<string, string> = {};23for (const [k, v] of Object.entries(headers)) {24lower[k.toLowerCase()] = v;25}26const serialized = body !== null ? JSON.stringify(body) : null;27const bodyStream = serialized !== null28? new ReadableStream<Uint8Array>({29start(controller) {30controller.enqueue(new TextEncoder().encode(serialized));31controller.close();32},33})34: null;35return {36status,37headers: makeHeaders(lower),38body: bodyStream,39async text() { return serialized ?? ''; },40async json() { return JSON.parse(await this.text()); },41};42}4344function stubFetch(response: HttpResponse): HttpFetchFn {45return vi.fn<HttpFetchFn>().mockResolvedValue(response);46}4748const defaultRequest: HttpRequest = { url: 'https://api.test/data', headers: {} };4950// ── etagMiddleware ──────────────────────────────────────────────────────5152describe('etagMiddleware', () => {53it('does not add conditional headers on first request', async () => {54const inner = stubFetch(makeResponse(200, { 'ETag': '"abc"' }));55const fetch = etagMiddleware()(inner);5657await fetch(defaultRequest);5859expect(inner).toHaveBeenCalledWith(expect.objectContaining({60headers: expect.not.objectContaining({ 'If-None-Match': expect.anything() }),61}));62});6364it('adds If-None-Match on subsequent requests', async () => {65const inner = stubFetch(makeResponse(200, { 'ETag': '"abc"' }));66const fetch = etagMiddleware()(inner);6768await fetch(defaultRequest);6970// Second call should include conditional header71await fetch(defaultRequest);72expect(inner).toHaveBeenLastCalledWith(expect.objectContaining({73headers: expect.objectContaining({ 'If-None-Match': '"abc"' }),74}));75});7677it('adds If-Modified-Since on subsequent requests', async () => {78const inner = stubFetch(makeResponse(200, { 'Last-Modified': 'Wed, 01 Jan 2025 00:00:00 GMT' }));79const fetch = etagMiddleware()(inner);8081await fetch(defaultRequest);82await fetch(defaultRequest);8384expect(inner).toHaveBeenLastCalledWith(expect.objectContaining({85headers: expect.objectContaining({ 'If-Modified-Since': 'Wed, 01 Jan 2025 00:00:00 GMT' }),86}));87});8889it('returns cached response on 304', async () => {90const original = makeResponse(200, { 'ETag': '"v1"' });91const notModified = makeResponse(304);92const inner = vi.fn<HttpFetchFn>()93.mockResolvedValueOnce(original)94.mockResolvedValueOnce(notModified);9596const fetch = etagMiddleware()(inner);9798const first = await fetch(defaultRequest);99const second = await fetch(defaultRequest);100101expect(first.status).toBe(200);102expect(second.status).toBe(200); // cached response returned on 304103});104105it('updates cache on new 200 response', async () => {106const v1 = makeResponse(200, { 'ETag': '"v1"' });107const v2 = makeResponse(200, { 'ETag': '"v2"' });108const inner = vi.fn<HttpFetchFn>()109.mockResolvedValueOnce(v1)110.mockResolvedValueOnce(v2);111112const fetch = etagMiddleware()(inner);113114await fetch(defaultRequest);115const second = await fetch(defaultRequest);116117expect(second.status).toBe(200);118});119});120121// ── authBlockedMiddleware ─────────────────────────────────────────────122123describe('authBlockedMiddleware', () => {124const authedRequest: HttpRequest = { url: 'https://api.test/data', headers: { 'Authorization': 'Bearer token-a' } };125126it('allows requests normally', async () => {127const inner = stubFetch(makeResponse(200));128const fetch = authBlockedMiddleware()(inner);129130const result = await fetch(authedRequest);131expect(result.status).toBe(200);132});133134it('throws AuthBlockedError after 401', async () => {135const inner = stubFetch(makeResponse(401));136const fetch = authBlockedMiddleware()(inner);137138await expect(fetch(authedRequest)).rejects.toThrow(AuthBlockedError);139});140141it('throws AuthBlockedError after 403', async () => {142const inner = stubFetch(makeResponse(403));143const fetch = authBlockedMiddleware()(inner);144145await expect(fetch(authedRequest)).rejects.toThrow(AuthBlockedError);146});147148it('blocks subsequent requests with same token after blocking', async () => {149const inner = vi.fn<HttpFetchFn>()150.mockResolvedValueOnce(makeResponse(401))151.mockResolvedValueOnce(makeResponse(200));152const fetch = authBlockedMiddleware(60_000)(inner);153154await expect(fetch(authedRequest)).rejects.toThrow(AuthBlockedError);155156// Subsequent request with same token should be blocked without calling inner157await expect(fetch(authedRequest)).rejects.toThrow(AuthBlockedError);158expect(inner).toHaveBeenCalledTimes(1);159});160161it('clears block when token changes', async () => {162const inner = vi.fn<HttpFetchFn>()163.mockResolvedValueOnce(makeResponse(401))164.mockResolvedValueOnce(makeResponse(200));165const fetch = authBlockedMiddleware(60_000)(inner);166167await expect(fetch(authedRequest)).rejects.toThrow(AuthBlockedError);168169// Change token → block should clear170const newTokenRequest: HttpRequest = { url: 'https://api.test/data', headers: { 'Authorization': 'Bearer token-new' } };171const result = await fetch(newTokenRequest);172expect(result.status).toBe(200);173expect(inner).toHaveBeenCalledTimes(2);174});175176it('clears block after duration expires', async () => {177const inner = vi.fn<HttpFetchFn>()178.mockResolvedValueOnce(makeResponse(401))179.mockResolvedValueOnce(makeResponse(200));180const fetch = authBlockedMiddleware(100)(inner);181182await expect(fetch(authedRequest)).rejects.toThrow(AuthBlockedError);183184vi.useFakeTimers();185try {186vi.advanceTimersByTime(150);187// Block expired → request should proceed188const result = await fetch(authedRequest);189expect(result.status).toBe(200);190} finally {191vi.useRealTimers();192}193});194195it('does not block when there is no Authorization header', async () => {196const inner = stubFetch(makeResponse(401));197const fetch = authBlockedMiddleware()(inner);198199// No Authorization header → middleware passes through the response200const result = await fetch(defaultRequest);201expect(result.status).toBe(401);202});203});204205// ── serverErrorBackoffMiddleware ────────────────────────────────────────206207describe('serverErrorBackoffMiddleware', () => {208it('allows requests normally on success', async () => {209const inner = stubFetch(makeResponse(200));210const fetch = serverErrorBackoffMiddleware()(inner);211212const result = await fetch(defaultRequest);213expect(result.status).toBe(200);214});215216it('throws ServerBackoffError on 500', async () => {217const inner = stubFetch(makeResponse(500));218const fetch = serverErrorBackoffMiddleware({ initialDelayMs: 100 })(inner);219220await expect(fetch(defaultRequest)).rejects.toThrow(ServerBackoffError);221});222223it('blocks subsequent requests during backoff window', async () => {224const inner = vi.fn<HttpFetchFn>()225.mockResolvedValueOnce(makeResponse(503))226.mockResolvedValueOnce(makeResponse(200));227const fetch = serverErrorBackoffMiddleware({ initialDelayMs: 60_000 })(inner);228229await expect(fetch(defaultRequest)).rejects.toThrow(ServerBackoffError);230231// Within backoff window → blocked without calling inner232await expect(fetch(defaultRequest)).rejects.toThrow(ServerBackoffError);233expect(inner).toHaveBeenCalledTimes(1);234});235236it('applies exponential backoff on consecutive failures', async () => {237const inner = vi.fn<HttpFetchFn>().mockResolvedValue(makeResponse(500));238const fetch = serverErrorBackoffMiddleware({ initialDelayMs: 100, multiplier: 2 })(inner);239240vi.useFakeTimers();241try {242// First failure → 100ms backoff243await expect(fetch(defaultRequest)).rejects.toThrow(ServerBackoffError);244vi.advanceTimersByTime(110);245246// Second failure → 200ms backoff247await expect(fetch(defaultRequest)).rejects.toThrow(ServerBackoffError);248vi.advanceTimersByTime(210);249250// Third failure → 400ms backoff251await expect(fetch(defaultRequest)).rejects.toSatisfy(252(err: ServerBackoffError) => err.retryAfterMs >= 399,253);254} finally {255vi.useRealTimers();256}257});258259it('caps backoff at maxDelayMs', async () => {260const inner = vi.fn<HttpFetchFn>().mockResolvedValue(makeResponse(500));261const fetch = serverErrorBackoffMiddleware({ initialDelayMs: 100, maxDelayMs: 300, multiplier: 10 })(inner);262263vi.useFakeTimers();264try {265await expect(fetch(defaultRequest)).rejects.toThrow(ServerBackoffError);266vi.advanceTimersByTime(110);267268// Second failure → min(100*10, 300) = 300269await expect(fetch(defaultRequest)).rejects.toSatisfy(270(err: ServerBackoffError) => err.retryAfterMs <= 300,271);272} finally {273vi.useRealTimers();274}275});276277it('resets backoff on success', async () => {278const inner = vi.fn<HttpFetchFn>()279.mockResolvedValueOnce(makeResponse(500))280.mockResolvedValueOnce(makeResponse(200))281.mockResolvedValueOnce(makeResponse(500));282const fetch = serverErrorBackoffMiddleware({ initialDelayMs: 100 })(inner);283284vi.useFakeTimers();285try {286await expect(fetch(defaultRequest)).rejects.toThrow(ServerBackoffError);287vi.advanceTimersByTime(110);288289// Success → resets290const ok = await fetch(defaultRequest);291expect(ok.status).toBe(200);292293// Next 500 → starts at initialDelayMs again294await expect(fetch(defaultRequest)).rejects.toSatisfy(295(err: ServerBackoffError) => err.retryAfterMs <= 100,296);297} finally {298vi.useRealTimers();299}300});301});302303// ── windowActiveMiddleware ──────────────────────────────────────────────304305describe('windowActiveMiddleware', () => {306it('calls next when window is active', async () => {307const provider: WindowStateProvider = { isActive: true };308const inner = stubFetch(makeResponse(200));309const fetch = windowActiveMiddleware(provider)(inner);310311const result = await fetch(defaultRequest);312expect(result.status).toBe(200);313expect(inner).toHaveBeenCalledTimes(1);314});315316it('throws WindowInactiveError when window is inactive', async () => {317const provider: WindowStateProvider = { isActive: false };318const inner = stubFetch(makeResponse(200));319const fetch = windowActiveMiddleware(provider)(inner);320321await expect(fetch(defaultRequest)).rejects.toThrow(WindowInactiveError);322expect(inner).not.toHaveBeenCalled();323});324});325326// ── composeFetchMiddleware ──────────────────────────────────────────────327328describe('composeFetchMiddleware', () => {329it('composes middlewares left-to-right (first = outermost)', async () => {330const order: string[] = [];331332const mwA: FetchMiddleware = (next) => async (req) => {333order.push('a-before');334const res = await next(req);335order.push('a-after');336return res;337};338const mwB: FetchMiddleware = (next) => async (req) => {339order.push('b-before');340const res = await next(req);341order.push('b-after');342return res;343};344345const inner = stubFetch(makeResponse(200));346const fetch = composeFetchMiddleware(mwA, mwB)(inner);347348await fetch(defaultRequest);349350expect(order).toEqual(['a-before', 'b-before', 'b-after', 'a-after']);351});352353it('identity when no middlewares are provided', async () => {354const inner = stubFetch(makeResponse(200));355const fetch = composeFetchMiddleware()(inner);356357const result = await fetch(defaultRequest);358expect(result.status).toBe(200);359});360});361362// ── createAdvancedFetch ─────────────────────────────────────────────────363364describe('createAdvancedFetch', () => {365it('creates a () => Promise<T> that parses responses', async () => {366const fetchFn = createAdvancedFetch({367request: defaultRequest,368httpFetch: async () => makeResponse(200, {}, { name: 'test' }),369parseResponse: async (res) => ((await res.json()) as { name: string }).name,370});371372const result = await fetchFn();373expect(result).toBe('test');374});375376it('accepts a request factory function', async () => {377let callCount = 0;378const fetchFn = createAdvancedFetch({379request: () => {380callCount++;381return { url: `https://api.test/${callCount}`, headers: { 'X-Count': String(callCount) } };382},383httpFetch: async (req) => makeResponse(200, {}, { url: req.url }),384parseResponse: async (res) => ((await res.json()) as { url: string }).url,385});386387expect(await fetchFn()).toBe('https://api.test/1');388expect(await fetchFn()).toBe('https://api.test/2');389});390391it('applies middleware stack', async () => {392const provider = { isActive: false };393394const fetchFn = createAdvancedFetch({395request: defaultRequest,396httpFetch: async () => makeResponse(200),397parseResponse: async (res) => res.status,398middleware: [windowActiveMiddleware(provider)],399});400401// Inactive → middleware throws402await expect(fetchFn()).rejects.toThrow(WindowInactiveError);403});404405it('composes with FetchedValue', async () => {406// Integration-level smoke test: createAdvancedFetch produces a function407// compatible with FetchedValue's `fetch` option signature.408const { FetchedValue } = await import('../fetchedValue');409410const fetchFn = createAdvancedFetch({411request: defaultRequest,412httpFetch: async () => makeResponse(999),413parseResponse: async (res) => res.status,414});415416const fv = new FetchedValue({417fetch: fetchFn,418isStale: () => false,419});420421expect(fv.value).toBeUndefined();422const value = await fv.resolve();423expect(value).toBe(999);424expect(fv.value).toBe(999);425426fv.dispose();427});428});429430// ── Full-stack integration ──────────────────────────────────────────────431432describe('full middleware stack', () => {433let provider: { isActive: boolean };434let authedRequest: HttpRequest;435let inner: ReturnType<typeof vi.fn<HttpFetchFn>>;436let fetch: (request: HttpRequest) => Promise<HttpResponse>;437438beforeEach(() => {439provider = { isActive: true };440authedRequest = { url: 'https://api.test/data', headers: { 'Authorization': 'Bearer tok-1' } };441inner = vi.fn<HttpFetchFn>().mockResolvedValue(makeResponse(200, { 'ETag': '"v1"' }));442443const composed = composeFetchMiddleware(444windowActiveMiddleware(provider),445authBlockedMiddleware(),446serverErrorBackoffMiddleware({ initialDelayMs: 100 }),447etagMiddleware(),448);449fetch = composed(inner);450});451452it('happy path passes through all middlewares', async () => {453const res = await fetch(authedRequest);454expect(res.status).toBe(200);455expect(inner).toHaveBeenCalledTimes(1);456});457458it('window inactive throws WindowInactiveError', async () => {459// Prime the cache460await fetch(authedRequest);461expect(inner).toHaveBeenCalledTimes(1);462463// Become inactive → middleware throws before reaching network464provider.isActive = false;465await expect(fetch(authedRequest)).rejects.toThrow(WindowInactiveError);466expect(inner).toHaveBeenCalledTimes(1); // no additional call467});468469it('auth failure blocks all further requests until token changes', async () => {470inner.mockResolvedValueOnce(makeResponse(401));471472await expect(fetch(authedRequest)).rejects.toThrow(AuthBlockedError);473await expect(fetch(authedRequest)).rejects.toThrow(AuthBlockedError);474475const newTokenRequest: HttpRequest = { url: 'https://api.test/data', headers: { 'Authorization': 'Bearer tok-2' } };476inner.mockResolvedValueOnce(makeResponse(200));477const res = await fetch(newTokenRequest);478expect(res.status).toBe(200);479});480});481482483