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.
@@ -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' };
}
}