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>
86 lines
3.1 KiB
TypeScript
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);
|
|
});
|
|
});
|