refactor(registratie): extract the read half of createDraftSync (RB-21)
createDraftSync mixed a read path (findConcept, load, the read half of resume) with its write path (ensureId, flush, submit, reset) in one 187-line function -- CQ-001's finding. Move findConcept and loadConcept into a new application/find-concept.ts as free functions that take the adapter, so they get a direct spec with no Angular TestBed. createDraftSync keeps the closure state (id, ensuring, resumeGate) and the whole write path unchanged -- this is a move, not a redesign. The resumeGate coupling that lets the write path wait for the read path stays exactly where it was. createDraftSync shrinks from 187 to 169 lines. draft-sync.spec.ts is unchanged -- it never called resume()/load() directly, and its 409 recovery test for submit() still exercises the extracted findConcept through ensureId's catch branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,10 +8,8 @@ import type {
|
|||||||
SubmitApplicationResponse,
|
SubmitApplicationResponse,
|
||||||
} from '@shared/infrastructure/api-client';
|
} from '@shared/infrastructure/api-client';
|
||||||
import { AanvraagType } from '@registratie/domain/aanvraag';
|
import { AanvraagType } from '@registratie/domain/aanvraag';
|
||||||
import {
|
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
|
||||||
ApplicationsAdapter,
|
import { findConcept, loadConcept } from './find-concept';
|
||||||
parseApplications,
|
|
||||||
} from '@registratie/infrastructure/applications.adapter';
|
|
||||||
|
|
||||||
/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */
|
/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */
|
||||||
export interface DraftSnapshot {
|
export interface DraftSnapshot {
|
||||||
@@ -70,7 +68,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
|||||||
// server's guard (409) — recover by adopting the existing Concept instead of
|
// server's guard (409) — recover by adopting the existing Concept instead of
|
||||||
// erroring. Only recover when one actually exists; otherwise surface the failure.
|
// erroring. Only recover when one actually exists; otherwise surface the failure.
|
||||||
.catch(async (e) => {
|
.catch(async (e) => {
|
||||||
const existing = await findConcept();
|
const existing = await findConcept(adapter, deps.type);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
throw e;
|
throw e;
|
||||||
})
|
})
|
||||||
@@ -140,34 +138,16 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
|||||||
// (submitted/gone) id is treated as fresh so it can't reopen as an editable draft.
|
// (submitted/gone) id is treated as fresh so it can't reopen as an editable draft.
|
||||||
const load = (linked: string): Promise<void> => {
|
const load = (linked: string): Promise<void> => {
|
||||||
id = linked;
|
id = linked;
|
||||||
return adapter
|
return loadConcept(adapter, linked).then((result) => {
|
||||||
.detail(linked)
|
if (result.tag === 'not-concept') {
|
||||||
.then((dto) => {
|
|
||||||
if (dto.status && dto.status.tag !== 'Concept') {
|
|
||||||
id = undefined;
|
id = undefined;
|
||||||
applyResume(null);
|
applyResume(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
applyResume(dto.draft ?? null);
|
applyResume(result.draft);
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
id = undefined;
|
|
||||||
applyResume(null); // unknown/deleted id → start fresh
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find the user's existing Concept of this type (at most one), if any.
|
|
||||||
const findConcept = async (): Promise<string | undefined> => {
|
|
||||||
try {
|
|
||||||
const parsed = parseApplications(await adapter.list());
|
|
||||||
return parsed.ok
|
|
||||||
? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id
|
|
||||||
: undefined;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/** True while a debounced draft write is still pending (PendingSave). */
|
/** True while a debounced draft write is still pending (PendingSave). */
|
||||||
hasPendingSave,
|
hasPendingSave,
|
||||||
@@ -189,7 +169,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
|||||||
await load(linked);
|
await load(linked);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const existing = await findConcept();
|
const existing = await findConcept(adapter, deps.type);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await load(existing);
|
await load(existing);
|
||||||
// Stamp the id into the URL so a reload resumes the same Concept.
|
// Stamp the id into the URL so a reload resumes the same Concept.
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
|
||||||
|
import { findConcept, loadConcept } from './find-concept';
|
||||||
|
|
||||||
|
// Free functions taking the adapter as a parameter (no inject()) — a plain fake
|
||||||
|
// object is enough, no Angular TestBed needed.
|
||||||
|
function fakeAdapter(overrides: Partial<ApplicationsAdapter>): ApplicationsAdapter {
|
||||||
|
return overrides as ApplicationsAdapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('findConcept', () => {
|
||||||
|
it('returns the id of the existing Concept of the given type', async () => {
|
||||||
|
const adapter = fakeAdapter({
|
||||||
|
list: async () => [
|
||||||
|
{
|
||||||
|
id: 'a1',
|
||||||
|
type: 'registratie',
|
||||||
|
status: { tag: 'Concept', stepIndex: 0, stepCount: 3 },
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(findConcept(adapter, 'registratie')).resolves.toBe('a1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when the list has no application of the given type', async () => {
|
||||||
|
const adapter = fakeAdapter({ list: async () => [] });
|
||||||
|
|
||||||
|
await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when the matching type is not a Concept', async () => {
|
||||||
|
const adapter = fakeAdapter({
|
||||||
|
list: async () => [
|
||||||
|
{
|
||||||
|
id: 'a1',
|
||||||
|
type: 'registratie',
|
||||||
|
status: { tag: 'Ingediend', referentie: 'R1' },
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when adapter.list() resolves with an unparsable shape', async () => {
|
||||||
|
const adapter = fakeAdapter({ list: async () => 'not-an-array' as unknown as [] });
|
||||||
|
|
||||||
|
await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when adapter.list() rejects', async () => {
|
||||||
|
const adapter = fakeAdapter({
|
||||||
|
list: async () => {
|
||||||
|
throw new Error('network down');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadConcept', () => {
|
||||||
|
it('reads the draft off a Concept', async () => {
|
||||||
|
const adapter = fakeAdapter({
|
||||||
|
detail: async () => ({
|
||||||
|
id: 'a1',
|
||||||
|
status: { tag: 'Concept', stepIndex: 1, stepCount: 3 },
|
||||||
|
draft: { step: 1 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(loadConcept(adapter, 'a1')).resolves.toEqual({
|
||||||
|
tag: 'concept',
|
||||||
|
draft: { step: 1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a missing draft as null', async () => {
|
||||||
|
const adapter = fakeAdapter({
|
||||||
|
detail: async () => ({ id: 'a1', status: { tag: 'Concept', stepIndex: 0, stepCount: 3 } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(loadConcept(adapter, 'a1')).resolves.toEqual({ tag: 'concept', draft: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports not-concept when the id has moved past Concept (submitted)', async () => {
|
||||||
|
const adapter = fakeAdapter({
|
||||||
|
detail: async () => ({ id: 'a1', status: { tag: 'Ingediend', referentie: 'R1' } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(loadConcept(adapter, 'a1')).resolves.toEqual({ tag: 'not-concept' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports not-concept when the id is unknown or deleted (detail rejects)', async () => {
|
||||||
|
const adapter = fakeAdapter({
|
||||||
|
detail: async () => {
|
||||||
|
throw new Error('404');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(loadConcept(adapter, 'gone')).resolves.toEqual({ tag: 'not-concept' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { AanvraagType } from '@registratie/domain/aanvraag';
|
||||||
|
import {
|
||||||
|
ApplicationsAdapter,
|
||||||
|
parseApplications,
|
||||||
|
} from '@registratie/infrastructure/applications.adapter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs
|
||||||
|
* before it can start writing (RB-21 / CQ-001). Free functions that take the adapter
|
||||||
|
* as a parameter, not `inject()`, so they get a direct spec without Angular TestBed.
|
||||||
|
* `createDraftSync` keeps the closure state (`id`, `resumeGate`) and the write path;
|
||||||
|
* these two functions only read.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Find the user's existing Concept of a given type (at most one), if any. */
|
||||||
|
export async function findConcept(
|
||||||
|
adapter: ApplicationsAdapter,
|
||||||
|
type: AanvraagType,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const parsed = parseApplications(await adapter.list());
|
||||||
|
return parsed.ok
|
||||||
|
? parsed.value.find((a) => a.type === type && a.status.tag === 'Concept')?.id
|
||||||
|
: undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of loading one Concept by id: its draft (or null when it has none), or
|
||||||
|
`not-concept` when the id is not an editable Concept (submitted/gone) or the
|
||||||
|
lookup failed (unknown/deleted id) — the caller treats both the same way, as
|
||||||
|
"start fresh". */
|
||||||
|
export type LoadedConcept = { tag: 'concept'; draft: unknown | null } | { tag: 'not-concept' };
|
||||||
|
|
||||||
|
/** Load a specific Concept by id and report whether it is still editable. */
|
||||||
|
export async function loadConcept(
|
||||||
|
adapter: ApplicationsAdapter,
|
||||||
|
id: string,
|
||||||
|
): Promise<LoadedConcept> {
|
||||||
|
try {
|
||||||
|
const dto = await adapter.detail(id);
|
||||||
|
if (dto.status && dto.status.tag !== 'Concept') return { tag: 'not-concept' };
|
||||||
|
return { tag: 'concept', draft: dto.draft ?? null };
|
||||||
|
} catch {
|
||||||
|
return { tag: 'not-concept' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,7 +101,7 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita
|
|||||||
16-row "Compliance review required" list, carries it — regardless of priority.
|
16-row "Compliance review required" list, carries it — regardless of priority.
|
||||||
|
|
||||||
| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
|
| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
|
||||||
| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- |
|
| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | ----------- |
|
||||||
| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
||||||
| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
||||||
| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
|
||||||
@@ -122,7 +122,7 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita
|
|||||||
| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open |
|
| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open |
|
||||||
| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open |
|
| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open |
|
||||||
| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
|
| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
|
||||||
| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | open |
|
| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | implemented |
|
||||||
| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
|
| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
|
||||||
| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open |
|
| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open |
|
||||||
| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
|
| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# RB-21 — extract the read half of `createDraftSync` into `find-concept.ts`
|
||||||
|
|
||||||
|
Status: **implemented** · 2026-08-27 · Source finding: `04-cqrs-light.md` CQ-001 ·
|
||||||
|
`00-baseline.md` §4a (`createDraftSync` 143 lines, the largest function in the repo), §9
|
||||||
|
(`fn > 40` threshold) · `99-backlog.md` RB-21
|
||||||
|
|
||||||
|
## What was wrong
|
||||||
|
|
||||||
|
`createDraftSync` (`apps/ssp/src/app/registratie/application/draft-sync.ts`) was registered
|
||||||
|
as a command factory but owned three read paths (`load`, `findConcept`, and the read half of
|
||||||
|
`resume`) mixed into the same function as the write path (`ensureId`, `flush`, `submit`,
|
||||||
|
`reset`). CQ-001 named three pieces of shared mutable closure state — `id`, `ensuring`,
|
||||||
|
`resumeGate` — as load-bearing: `resumeGate` exists only so the write path (`ensureId`) can
|
||||||
|
wait for the read path (`resume`) to finish. That coupling is genuine and stays in place.
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
CQ-001's proposal, applied as a pure move, no redesign.
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `apps/ssp/src/app/registratie/application/find-concept.ts` (new) | `findConcept(adapter, type)` and `loadConcept(adapter, id)` — free functions taking `ApplicationsAdapter`, no `inject()`. `loadConcept` returns a `LoadedConcept` union (`{tag:'concept', draft}` \| `{tag:'not-concept'}`) instead of the boolean-shaped branching the inline version had. |
|
||||||
|
| `apps/ssp/src/app/registratie/application/find-concept.spec.ts` (new) | Direct spec, no TestBed — a fake `ApplicationsAdapter` object passed straight to the functions. |
|
||||||
|
| `apps/ssp/src/app/registratie/application/draft-sync.ts` | Removed the inline `findConcept` closure and the body of `load`; both now call the free functions. `createDraftSync` keeps `id`, `ensuring`, `resumeGate`, and the whole write path, unchanged. |
|
||||||
|
| `libs/shared/docs/behaviour-spec.mdx` | Regenerated (`npm run gen:behaviour-spec`) — picks up the new `find-concept.spec.ts` describe blocks. |
|
||||||
|
|
||||||
|
`createDraftSync` shrank from 187 lines (`export function createDraftSync` to its closing
|
||||||
|
brace, HEAD~1) to 169 lines. The whole file went from 236 to 216 lines.
|
||||||
|
|
||||||
|
The two call sites that used the old inline `findConcept()` now pass the adapter and type
|
||||||
|
explicitly:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ensureId's 409-recovery catch (WP-35)
|
||||||
|
const existing = await findConcept(adapter, deps.type);
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// resume(), no ?aanvraag in the URL
|
||||||
|
const existing = await findConcept(adapter, deps.type);
|
||||||
|
```
|
||||||
|
|
||||||
|
`load` keeps setting the closure `id` and calling `applyResume` (both closure-dependent), but
|
||||||
|
delegates the actual read to `loadConcept`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const load = (linked: string): Promise<void> => {
|
||||||
|
id = linked;
|
||||||
|
return loadConcept(adapter, linked).then((result) => {
|
||||||
|
if (result.tag === 'not-concept') {
|
||||||
|
id = undefined;
|
||||||
|
applyResume(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyResume(result.draft);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## `draft-sync.spec.ts` — unchanged
|
||||||
|
|
||||||
|
`draft-sync.spec.ts` was not edited. It never called `resume()`/`load()` directly — its
|
||||||
|
coverage is the debounce, `submit()` (including the 409-recovery path, which exercises the
|
||||||
|
extracted `findConcept` indirectly through `ensureId`'s catch), and `flushPending`. All of
|
||||||
|
that stayed in `createDraftSync`, so the spec is unchanged and still exercises the wiring
|
||||||
|
between `createDraftSync` and the two new free functions (the 409-recovery test would fail if
|
||||||
|
that wiring were wrong). It passed unchanged, 8/8.
|
||||||
|
|
||||||
|
## The new spec, and its verified red
|
||||||
|
|
||||||
|
`find-concept.spec.ts` covers the branches CQ-001 named:
|
||||||
|
|
||||||
|
- `findConcept`: match found → id returned; no match of that type → `undefined`; match found
|
||||||
|
but not `Concept` status → `undefined`; `adapter.list()` resolves to an unparsable shape
|
||||||
|
(`parseApplications` fails) → `undefined`; `adapter.list()` rejects → `undefined`.
|
||||||
|
- `loadConcept`: `Concept` with a draft → `{tag:'concept', draft}`; `Concept` with no draft →
|
||||||
|
`{tag:'concept', draft:null}`; a non-`Concept` status (e.g. `Ingediend`, submitted) →
|
||||||
|
`{tag:'not-concept'}`; `adapter.detail()` rejects (unknown/deleted id) →
|
||||||
|
`{tag:'not-concept'}`.
|
||||||
|
|
||||||
|
**Verified red without the fix.** Used `Edit` (not `git checkout`) to invert one condition in
|
||||||
|
`loadConcept` — `dto.status.tag !== 'Concept'` → `dto.status.tag === 'Concept'` — reran `ng
|
||||||
|
test ssp --include find-concept.spec.ts`. Result: 3 of 9 tests failed —
|
||||||
|
|
||||||
|
```
|
||||||
|
loadConcept > reads the draft off a Concept
|
||||||
|
AssertionError: expected { tag: 'not-concept' } to deeply equal { tag: 'concept', draft: { step: 1 } }
|
||||||
|
loadConcept > reports a missing draft as null
|
||||||
|
AssertionError: expected { tag: 'not-concept' } to deeply equal { tag: 'concept', draft: null }
|
||||||
|
loadConcept > reports not-concept when the id has moved past Concept (submitted)
|
||||||
|
AssertionError: expected { tag: 'concept', draft: null } to deeply equal { tag: 'not-concept' }
|
||||||
|
```
|
||||||
|
|
||||||
|
Then used `Edit` again to flip the condition back to `!==`, reran the same command: 9/9
|
||||||
|
green. `findConcept`'s and `loadConcept`'s other branches were not separately mutated — the
|
||||||
|
inverted condition alone was enough to prove the spec is sensitive to the extraction being
|
||||||
|
correct, and re-verifying full green after the revert confirmed no collateral change was left
|
||||||
|
in the file.
|
||||||
|
|
||||||
|
## Scope held
|
||||||
|
|
||||||
|
- No restructuring of the write path (`ensureId`, `flush`, `submit`, `reset`) — untouched
|
||||||
|
beyond the two call-site updates shown above.
|
||||||
|
- `applications.adapter.ts` was not split (CQ-002's "Not filed" note rules that out for this
|
||||||
|
design; out of scope here regardless).
|
||||||
|
- `resume()`'s semantics (URL-param precedence, the `resumeGate` release-in-`finally`, the
|
||||||
|
navigate-to-stamp-the-id side effect) are unchanged — only its two `findConcept()`/`load()`
|
||||||
|
calls now go through the free functions.
|
||||||
|
- No wire change, no DTO change, no behaviour change.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
`npm run ci` (foreground): **green** — lint, typecheck, `dep:check`, `format:check`,
|
||||||
|
`check:tokens`, `check:seam`, tests (ssp includes `find-concept.spec.ts` 9/9 new,
|
||||||
|
`draft-sync.spec.ts` 8/8 unchanged), `ng build --localize` (both apps), `npm audit`, backend
|
||||||
|
`dotnet test` (the known `OpenZaakIntegrationTests.Admin_cases_…` container-dependent failure
|
||||||
|
is expected and outside `npm run ci`'s scope), `gen:snippets` drift clean, `gen:behaviour-spec`
|
||||||
|
drift clean once the regenerated file is committed alongside the code. Full counts are in the
|
||||||
|
commit's `npm run ci` run — see the session note for the exact step-by-step output.
|
||||||
@@ -20,7 +20,7 @@ tested where._
|
|||||||
|
|
||||||
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
|
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
|
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
|
||||||
**is** the suite, reshaped for a business reader. 451 frontend behaviours across
|
**is** the suite, reshaped for a business reader. 460 frontend behaviours across
|
||||||
9 contexts; 236 backend behaviours across 41 test
|
9 contexts; 236 backend behaviours across 41 test
|
||||||
classes.
|
classes.
|
||||||
|
|
||||||
@@ -470,6 +470,14 @@ classes.
|
|||||||
- lists soort/waarvoor/status/referentie/ingediend, plus reason when rejected
|
- lists soort/waarvoor/status/referentie/ingediend, plus reason when rejected
|
||||||
- reference falls back to em dash for a Concept
|
- reference falls back to em dash for a Concept
|
||||||
|
|
||||||
|
#### findConcept
|
||||||
|
|
||||||
|
- returns the id of the existing Concept of the given type
|
||||||
|
- returns undefined when the list has no application of the given type
|
||||||
|
- returns undefined when the matching type is not a Concept
|
||||||
|
- returns undefined when adapter.list() resolves with an unparsable shape
|
||||||
|
- returns undefined when adapter.list() rejects
|
||||||
|
|
||||||
#### hasProgress
|
#### hasProgress
|
||||||
|
|
||||||
- is false for a fresh wizard
|
- is false for a fresh wizard
|
||||||
@@ -486,6 +494,13 @@ classes.
|
|||||||
|
|
||||||
- derives the beroep from the chosen diploma and flags origin duo
|
- derives the beroep from the chosen diploma and flags origin duo
|
||||||
|
|
||||||
|
#### loadConcept
|
||||||
|
|
||||||
|
- reads the draft off a Concept
|
||||||
|
- reports a missing draft as null
|
||||||
|
- reports not-concept when the id has moved past Concept (submitted)
|
||||||
|
- reports not-concept when the id is unknown or deleted (detail rejects)
|
||||||
|
|
||||||
#### manual diploma fallback
|
#### manual diploma fallback
|
||||||
|
|
||||||
- KiesHandmatig flags handmatig with the maximal question set and no beroep yet
|
- KiesHandmatig flags handmatig with the maximal question set and no beroep yet
|
||||||
|
|||||||
Reference in New Issue
Block a user