Files
atomic-design-poc/src/app/shared/infrastructure/api-client.provider.spec.ts
T
ehoandClaude Sonnet 5 40dbcb2606 feat(fp): WP-21 — resilience seams (correlation-id, idempotency, retry)
Correlation id becomes real ASP.NET Core middleware instead of a per-endpoint
read: every request gets one (client-supplied or generated), it's echoed as
an X-Correlation-Id response header, and pushed into the logging scope so
every log line for that request carries it — not just the Submit helper's,
verified against LogBrief which never threads it explicitly.

Idempotency-Key moves from per-HTTP-attempt (defeating its own purpose) to
per-logical-submit: runSubmit mints one key and threads it through a small
bridge (withIdempotencyKey/currentIdempotencyKey) since the NSwag-generated
client has no per-call header hook. Backend gains an IdempotencyStore that
short-circuits a replayed key to the first call's result instead of minting
a second reference — scoped to the Submit-helper endpoints per the WP's own
decision.

GET requests now retry transient failures (rxjs retry({count:2, delay:500}));
writes never auto-retry. Proven with a fake-HttpClient spec
(api-client.provider.spec.ts) rather than a manual network-tab check — the
WP's suggested `?scenario=error` check turned out not to exercise a real
network call at all (the interceptor throws before calling next()), so the
automated test is the actual proof.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 20:03:41 +02:00

86 lines
3.1 KiB
TypeScript

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<string, string> }) => 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<string, string> | 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);
});
});