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:
eho
2026-08-27 18:23:13 +02:00
co-authored by Claude Opus 5
parent 7fbac8fca5
commit d518a1466c
6 changed files with 337 additions and 67 deletions
@@ -8,10 +8,8 @@ import type {
SubmitApplicationResponse,
} from '@shared/infrastructure/api-client';
import { AanvraagType } from '@registratie/domain/aanvraag';
import {
ApplicationsAdapter,
parseApplications,
} from '@registratie/infrastructure/applications.adapter';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
import { findConcept, loadConcept } from './find-concept';
/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */
export interface DraftSnapshot {
@@ -70,7 +68,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
// server's guard (409) — recover by adopting the existing Concept instead of
// erroring. Only recover when one actually exists; otherwise surface the failure.
.catch(async (e) => {
const existing = await findConcept();
const existing = await findConcept(adapter, deps.type);
if (existing) return existing;
throw e;
})
@@ -140,32 +138,14 @@ export function createDraftSync(deps: DraftSyncDeps) {
// (submitted/gone) id is treated as fresh so it can't reopen as an editable draft.
const load = (linked: string): Promise<void> => {
id = linked;
return adapter
.detail(linked)
.then((dto) => {
if (dto.status && dto.status.tag !== 'Concept') {
id = undefined;
applyResume(null);
return;
}
applyResume(dto.draft ?? null);
})
.catch(() => {
return loadConcept(adapter, linked).then((result) => {
if (result.tag === 'not-concept') {
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;
}
applyResume(null);
return;
}
applyResume(result.draft);
});
};
return {
@@ -189,7 +169,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
await load(linked);
return;
}
const existing = await findConcept();
const existing = await findConcept(adapter, deps.type);
if (existing) {
await load(existing);
// Stamp the id into the URL so a reload resumes the same Concept.