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,
|
||||
} 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' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user