import { describe, it, expect } from 'vitest'; import { of, throwError } from 'rxjs'; import { HttpClient, HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { currentIdempotencyKey, httpClientFetch, withIdempotencyKey } from './api-client.provider'; /** Minimal stand-in for HttpClient — only `.request(...)` is ever called by the * adapter under test, so no TestBed/HttpClientTestingModule needed. */ function fakeHttpClient( request: (method: string, url: string, options: { headers: Record }) => unknown, ): HttpClient { return { request } as unknown as HttpClient; } describe('withIdempotencyKey / currentIdempotencyKey', () => { it('threads the key to every read made inside the wrapped fn', async () => { const seen: string[] = []; await withIdempotencyKey('fixed-key', async () => { seen.push(currentIdempotencyKey()); seen.push(currentIdempotencyKey()); }); expect(seen).toEqual(['fixed-key', 'fixed-key']); }); it('clears the key once the wrapped fn settles', async () => { await withIdempotencyKey('fixed-key', async () => undefined); expect(currentIdempotencyKey()).not.toBe('fixed-key'); }); it('falls back to a generated uuid-shaped key when none is pending', () => { expect(currentIdempotencyKey()).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, ); }); }); describe('httpClientFetch', () => { it('sends the pending idempotency key as a header for a write, not a fresh one per attempt', async () => { let sentHeaders: Record | undefined; const http = fakeHttpClient((_method, _url, opts) => { sentHeaders = opts.headers; return of(new HttpResponse({ status: 200, body: '' })); }); await withIdempotencyKey('logical-submit-key', () => httpClientFetch(http).fetch('/api/v1/change-requests', { method: 'POST' }), ); expect(sentHeaders?.['Idempotency-Key']).toBe('logical-submit-key'); }); // `http.request(...)` itself is only called once per `fetch()` — it returns a // cold Observable, and `retry` resubscribes to *that*, not to `.request()` // again (exactly how Angular's real HttpClient triggers a fresh network call // per subscription). So attempts are counted where the resubscription lands: // the `throwError` factory, not the outer mock call. it('retries a failing GET twice before giving up', async () => { let attempts = 0; const http = fakeHttpClient(() => throwError(() => { attempts++; return new HttpErrorResponse({ status: 500 }); }), ); const res = await httpClientFetch(http).fetch('/api/v1/notes', { method: 'GET' }); expect(attempts).toBe(3); // 1 original + 2 retries expect(res.status).toBe(500); }); it('never retries a failing write', async () => { let attempts = 0; const http = fakeHttpClient(() => throwError(() => { attempts++; return new HttpErrorResponse({ status: 500 }); }), ); const res = await httpClientFetch(http).fetch('/api/v1/change-requests', { method: 'POST' }); expect(attempts).toBe(1); expect(res.status).toBe(500); }); });