refactor(shared): split runResult out of runSubmit (RB-17)
runSubmit did two things at once: fold a call into a Result, and mint an Idempotency-Key for it. Five call sites are reads and had no business minting one — brief.adapter.ts:load, org-template.adapter.ts :list/:load, and stamdata.adapter.ts:list/:load. stamdata.adapter.ts's own docstring already said "Both endpoints are reads … There is no write method" while both called runSubmit; that mismatch is the sharpest evidence, and the reason the baseline's original "~13 mutations" count (derived from the helper's name, not the code) was wrong by five in one direction. Split submit.ts in place: runResult is the try/catch + problemDetail fold with no mint; runSubmit is runResult wrapping withIdempotencyKey. Zero behaviour change for the 8 real mutations (brief save/submit/approve/reject/send/reset, org-template save/publish/rollback) — same fold, same mint, same timing. The five reads now run the fold with no pendingIdempotencyKey touched. submit.spec.ts asserts the split behaviourally via currentIdempotencyKey() (two reads inside the same call agree only when a key was minted and reused) rather than mocking a relative import, matching this repo's existing vitest convention. Verified red without the fix by temporarily reintroducing the mint into runResult. ApplicationsStore.cancel/AdminCasesStore.delete (RB-20) and FeatureFlagStore.set are out of scope and untouched — the latter already calls runSubmit correctly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { runSubmit } from '@shared/application/submit';
|
||||
import { runResult } from '@shared/application/submit';
|
||||
import { ApiClient, StamdataColumnDto } from '@shared/infrastructure/api-client';
|
||||
import { ColumnType, StamColumn, StamRow, StamTable } from '@beheer/domain/stamdata';
|
||||
|
||||
@@ -24,7 +24,7 @@ export class StamdataAdapter {
|
||||
|
||||
/** The tables in the catalog (schema only, no rows) — for the table switcher. */
|
||||
async list(): Promise<Result<string, StamTable[]>> {
|
||||
const r = await runSubmit(() => this.client.stamdataTables(), FAILED);
|
||||
const r = await runResult(() => this.client.stamdataTables(), FAILED);
|
||||
if (!r.ok) return r;
|
||||
const out: StamTable[] = [];
|
||||
for (const t of r.value ?? []) {
|
||||
@@ -39,7 +39,7 @@ export class StamdataAdapter {
|
||||
valid on that date; the editor uses it for a server-side cross-check, previewing
|
||||
locally for instant feedback (see `activeOn`). */
|
||||
async load(tableId: string, peildatum?: string): Promise<Result<string, LoadedTable>> {
|
||||
const r = await runSubmit(() => this.client.stamdataTable(tableId, peildatum), FAILED);
|
||||
const r = await runResult(() => this.client.stamdataTable(tableId, peildatum), FAILED);
|
||||
return r.ok ? parseStamdataTable(r.value) : r;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ tested where._
|
||||
|
||||
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
|
||||
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
|
||||
**is** the suite, reshaped for a business reader. 440 frontend behaviours across
|
||||
**is** the suite, reshaped for a business reader. 445 frontend behaviours across
|
||||
9 contexts; 231 backend behaviours across 39 test
|
||||
classes.
|
||||
|
||||
@@ -800,11 +800,19 @@ classes.
|
||||
|
||||
- leaves an unrelated endpoint untouched
|
||||
|
||||
#### runResult
|
||||
|
||||
- folds a resolved call into ok(value)
|
||||
- maps a ProblemDetails rejection to err(detail)
|
||||
- falls back when the rejection has no detail
|
||||
- mints no Idempotency-Key — the read fold
|
||||
|
||||
#### runSubmit
|
||||
|
||||
- folds a resolved call into ok(value)
|
||||
- maps a ProblemDetails rejection to err(detail)
|
||||
- falls back when the rejection has no detail
|
||||
- mints exactly one Idempotency-Key for the whole call — the write fold
|
||||
|
||||
#### satisfaction helpers
|
||||
|
||||
|
||||
@@ -1,5 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runSubmit } from './submit';
|
||||
import { runResult, runSubmit } from './submit';
|
||||
import { currentIdempotencyKey } from '@shared/infrastructure/api-client.provider';
|
||||
|
||||
// currentIdempotencyKey() returns the pending key minted by withIdempotencyKey while
|
||||
// one is in flight, else a fresh random UUID on every call (see api-client.provider.ts).
|
||||
// So calling it twice inside the same `fn` tells us, behaviourally, whether a key was
|
||||
// minted for this call: two reads agreeing means one pending key was reused; two reads
|
||||
// disagreeing means there was no pending key at all — each call fell back to its own
|
||||
// random one. This is the seam RB-17 exists to keep separated, so it is asserted
|
||||
// directly rather than via a mock (relative-import mocking is off-limits under this
|
||||
// repo's Angular/vitest setup — see role.interceptor.spec.ts).
|
||||
|
||||
describe('runResult', () => {
|
||||
it('folds a resolved call into ok(value)', async () => {
|
||||
const r = await runResult(async () => 'BIG-123', 'fallback');
|
||||
expect(r).toEqual({ ok: true, value: 'BIG-123' });
|
||||
});
|
||||
|
||||
it('maps a ProblemDetails rejection to err(detail)', async () => {
|
||||
const r = await runResult(async () => {
|
||||
throw { detail: 'Aanvraag afgewezen.' };
|
||||
}, 'fallback');
|
||||
expect(r).toEqual({ ok: false, error: 'Aanvraag afgewezen.' });
|
||||
});
|
||||
|
||||
it('falls back when the rejection has no detail', async () => {
|
||||
const r = await runResult(async () => {
|
||||
throw new Error('network');
|
||||
}, 'fallback');
|
||||
expect(r).toEqual({ ok: false, error: 'fallback' });
|
||||
});
|
||||
|
||||
it('mints no Idempotency-Key — the read fold', async () => {
|
||||
let first = '';
|
||||
let second = '';
|
||||
await runResult(async () => {
|
||||
first = currentIdempotencyKey();
|
||||
second = currentIdempotencyKey();
|
||||
return 'x';
|
||||
}, 'fallback');
|
||||
// No pending key: each read falls back to its own fresh random UUID.
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runSubmit', () => {
|
||||
it('folds a resolved call into ok(value)', async () => {
|
||||
@@ -20,4 +63,17 @@ describe('runSubmit', () => {
|
||||
}, 'fallback');
|
||||
expect(r).toEqual({ ok: false, error: 'fallback' });
|
||||
});
|
||||
|
||||
it('mints exactly one Idempotency-Key for the whole call — the write fold', async () => {
|
||||
let first = '';
|
||||
let second = '';
|
||||
await runSubmit(async () => {
|
||||
first = currentIdempotencyKey();
|
||||
second = currentIdempotencyKey();
|
||||
return 'x';
|
||||
}, 'fallback');
|
||||
// One pending key reused across both reads inside this logical submit.
|
||||
expect(first).toBe(second);
|
||||
expect(first).not.toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,26 +3,36 @@ import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import { withIdempotencyKey } from '@shared/infrastructure/api-client.provider';
|
||||
|
||||
/**
|
||||
* Run a mutating API call and fold it into a `Result` — the one place the
|
||||
* try/catch + ProblemDetails-mapping lives, so every `submit-*` command is just
|
||||
* its own payload mapping. The backend re-validates and returns a 422
|
||||
* ProblemDetails on rejection, surfaced here as the error string.
|
||||
* Run an API call and fold it into a `Result` — the one place the try/catch +
|
||||
* ProblemDetails-mapping lives, so every adapter method is just its own payload
|
||||
* mapping. The backend re-validates and returns a 422 ProblemDetails on
|
||||
* rejection, surfaced here as the error string.
|
||||
*
|
||||
* Also the one place a logical submit's Idempotency-Key is minted — once per
|
||||
* `runSubmit` call, not per HTTP attempt — so a retry of this same submit
|
||||
* dedupes on the backend (see `withIdempotencyKey`).
|
||||
* This is the **read** half: it mints no Idempotency-Key. Use it for GETs.
|
||||
* `runSubmit` below wraps it for mutations — never route a read through that
|
||||
* one, it mints a key for nothing.
|
||||
*/
|
||||
export async function runSubmit<T>(
|
||||
export async function runResult<T>(
|
||||
fn: () => Promise<T>,
|
||||
fallback: string,
|
||||
): Promise<Result<string, T>> {
|
||||
try {
|
||||
return ok(await withIdempotencyKey(crypto.randomUUID(), fn));
|
||||
return ok(await fn());
|
||||
} catch (e) {
|
||||
return err(problemDetail(e, fallback));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `runResult` for a **mutating** call: also mints the Idempotency-Key for this
|
||||
* logical submit — once per `runSubmit` call, not per HTTP attempt — so a
|
||||
* retry of this same submit dedupes on the backend (see `withIdempotencyKey`).
|
||||
* Reads must go through `runResult` instead, which mints nothing.
|
||||
*/
|
||||
export function runSubmit<T>(fn: () => Promise<T>, fallback: string): Promise<Result<string, T>> {
|
||||
return runResult(() => withIdempotencyKey(crypto.randomUUID(), fn), fallback);
|
||||
}
|
||||
|
||||
// Single shared default for a failed submit; the @@id dedupes it at the
|
||||
// translation layer.
|
||||
export const SUBMIT_FAILED = $localize`:@@submit.failed:Het indienen is niet gelukt. Probeer het later opnieuw.`;
|
||||
|
||||
Reference in New Issue
Block a user