From 0e48f44773d71b82a65c8c66d4afad3102bb6ac3 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Mon, 29 Jun 2026 11:48:24 +0200 Subject: [PATCH] Upload feature (d): pure upload domain machine + spec Co-Authored-By: Claude Opus 4.8 --- src/app/shared/upload/upload.machine.spec.ts | 229 +++++++++++++++++++ src/app/shared/upload/upload.machine.ts | 212 +++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 src/app/shared/upload/upload.machine.spec.ts create mode 100644 src/app/shared/upload/upload.machine.ts diff --git a/src/app/shared/upload/upload.machine.spec.ts b/src/app/shared/upload/upload.machine.spec.ts new file mode 100644 index 0000000..02c9df9 --- /dev/null +++ b/src/app/shared/upload/upload.machine.spec.ts @@ -0,0 +1,229 @@ +import { describe, it, expect } from 'vitest'; +import { + DocumentCategory, + Upload, + UploadState, + initialUpload, + reduceUpload, + categorySatisfied, + requiredCategoriesSatisfied, + deliveryRefs, + inFlight, +} from './upload.machine'; + +const cat = (over: Partial = {}): DocumentCategory => ({ + categoryId: 'diploma', + label: 'Diploma', + description: '', + required: true, + acceptedTypes: ['application/pdf'], + maxSizeMb: 10, + multiple: false, + allowPostDelivery: true, + ...over, +}); + +const stateWith = (categories: DocumentCategory[], over: Partial = {}): UploadState => + reduceUpload({ ...initialUpload, ...over }, { type: 'CategoriesLoaded', categories }); + +const select = (s: UploadState, categoryId: string, localId: string): UploadState => + reduceUpload(s, { type: 'FileSelected', categoryId, localId, fileName: `${localId}.pdf`, fileSizeMb: 1 }); + +const get = (s: UploadState, localId: string): Upload | undefined => s.uploads.find((u) => u.localId === localId); + +describe('CategoriesLoaded', () => { + it('defaults every category to digital without clobbering existing choices', () => { + const s0 = stateWith([cat({ categoryId: 'a' })], { deliveryChannel: { a: 'post' } }); + expect(s0.deliveryChannel['a']).toBe('post'); + const s1 = reduceUpload(s0, { type: 'CategoriesLoaded', categories: [cat({ categoryId: 'a' }), cat({ categoryId: 'b' })] }); + expect(s1.deliveryChannel).toEqual({ a: 'post', b: 'digital' }); + }); + + it('clears a prior categoriesError', () => { + const s = reduceUpload({ ...initialUpload, categoriesError: 'boom' }, { type: 'CategoriesLoaded', categories: [] }); + expect(s.categoriesError).toBeUndefined(); + }); +}); + +describe('CategoriesLoadFailed / BackgroundSyncAvailability', () => { + it('records the error', () => { + expect(reduceUpload(initialUpload, { type: 'CategoriesLoadFailed', reason: 'boom' }).categoriesError).toBe('boom'); + }); + it('flips background sync availability', () => { + expect(reduceUpload(initialUpload, { type: 'BackgroundSyncAvailability', available: true }).backgroundSyncAvailable).toBe(true); + }); +}); + +describe('FileSelected', () => { + it('queues a new upload and clears any rejection for that category', () => { + const s = select(stateWith([cat()], { rejections: { diploma: 'old' } }), 'diploma', 'u1'); + expect(get(s, 'u1')?.status).toEqual({ type: 'queued' }); + expect(s.rejections['diploma']).toBeUndefined(); + }); + + it('ignores selection for an unknown category', () => { + const s = stateWith([cat()]); + expect(select(s, 'nope', 'u1').uploads).toHaveLength(0); + }); + + it('ignores selection for a category set to post-delivery', () => { + const s = stateWith([cat()], { deliveryChannel: { diploma: 'post' } }); + expect(select(s, 'diploma', 'u1').uploads).toHaveLength(0); + }); + + it('single-file category: a new selection replaces the existing upload', () => { + let s = select(stateWith([cat({ multiple: false })]), 'diploma', 'u1'); + s = select(s, 'diploma', 'u2'); + expect(s.uploads.map((u) => u.localId)).toEqual(['u2']); + }); + + it('multiple category: selections accumulate', () => { + let s = select(stateWith([cat({ multiple: true })]), 'diploma', 'u1'); + s = select(s, 'diploma', 'u2'); + expect(s.uploads.map((u) => u.localId)).toEqual(['u1', 'u2']); + }); +}); + +describe('FileRejected', () => { + it('stores a per-category message', () => { + const s = reduceUpload(stateWith([cat()]), { type: 'FileRejected', categoryId: 'diploma', reason: 'size' }); + expect(s.rejections['diploma']).toBeTruthy(); + }); +}); + +describe('upload lifecycle messages', () => { + it('queued → progress → complete', () => { + let s = select(stateWith([cat()]), 'diploma', 'u1'); + s = reduceUpload(s, { type: 'UploadQueued', localId: 'u1', backgroundSync: true }); + expect(get(s, 'u1')?.backgroundSync).toBe(true); + s = reduceUpload(s, { type: 'UploadProgress', localId: 'u1', progressPct: 42 }); + expect(get(s, 'u1')?.status).toEqual({ type: 'uploading', progressPct: 42 }); + s = reduceUpload(s, { type: 'UploadComplete', localId: 'u1', documentId: 'doc1' }); + expect(get(s, 'u1')?.status).toEqual({ type: 'complete', documentId: 'doc1' }); + }); + + it('failed then retried returns to queued', () => { + let s = select(stateWith([cat()]), 'diploma', 'u1'); + s = reduceUpload(s, { type: 'UploadFailed', localId: 'u1', reason: 'network' }); + expect(get(s, 'u1')?.status).toEqual({ type: 'failed', reason: 'network' }); + s = reduceUpload(s, { type: 'UploadRetried', localId: 'u1' }); + expect(get(s, 'u1')?.status).toEqual({ type: 'queued' }); + }); + + it('UploadRemoved drops the upload', () => { + let s = select(stateWith([cat()]), 'diploma', 'u1'); + s = reduceUpload(s, { type: 'UploadRemoved', localId: 'u1' }); + expect(s.uploads).toHaveLength(0); + }); +}); + +describe('delete flow (optimistic, revertible)', () => { + const completed = (): UploadState => { + let s = select(stateWith([cat()]), 'diploma', 'u1'); + return reduceUpload(s, { type: 'UploadComplete', localId: 'u1', documentId: 'doc1' }); + }; + + it('UploadDeleteRequested keeps the documentId for revert', () => { + const s = reduceUpload(completed(), { type: 'UploadDeleteRequested', localId: 'u1', documentId: 'doc1' }); + expect(get(s, 'u1')?.status).toEqual({ type: 'deleting', documentId: 'doc1' }); + }); + + it('UploadDeleteComplete removes the upload', () => { + let s = reduceUpload(completed(), { type: 'UploadDeleteRequested', localId: 'u1', documentId: 'doc1' }); + s = reduceUpload(s, { type: 'UploadDeleteComplete', localId: 'u1' }); + expect(s.uploads).toHaveLength(0); + }); + + it('UploadDeleteFailed reverts to complete with the original documentId', () => { + let s = reduceUpload(completed(), { type: 'UploadDeleting', localId: 'u1' }); + s = reduceUpload(s, { type: 'UploadDeleteFailed', localId: 'u1', reason: 'boom' }); + expect(get(s, 'u1')?.status).toEqual({ type: 'complete', documentId: 'doc1' }); + }); +}); + +describe('DeliveryChannelChanged', () => { + it('switching to post removes that category uploads', () => { + let s = select(stateWith([cat()]), 'diploma', 'u1'); + s = reduceUpload(s, { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' }); + expect(s.deliveryChannel['diploma']).toBe('post'); + expect(s.uploads).toHaveLength(0); + }); + + it('rejects post for a category that does not allow it', () => { + const s = stateWith([cat({ allowPostDelivery: false })]); + const next = reduceUpload(s, { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' }); + expect(next.deliveryChannel['diploma']).toBe('digital'); + }); + + it('switching back to digital starts clean', () => { + let s = stateWith([cat()], { deliveryChannel: { diploma: 'post' } }); + s = reduceUpload(s, { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'digital' }); + expect(s.deliveryChannel['diploma']).toBe('digital'); + expect(s.uploads).toHaveLength(0); + }); +}); + +describe('BackgroundUploadsReturned', () => { + it('resolves each in-flight upload to complete or failed', () => { + let s = select(stateWith([cat({ multiple: true })]), 'diploma', 'u1'); + s = select(s, 'diploma', 'u2'); + s = reduceUpload(s, { + type: 'BackgroundUploadsReturned', + results: [ + { localId: 'u1', success: true, documentId: 'doc1' }, + { localId: 'u2', success: false, reason: 'rejected' }, + ], + }); + expect(get(s, 'u1')?.status).toEqual({ type: 'complete', documentId: 'doc1' }); + expect(get(s, 'u2')?.status).toEqual({ type: 'failed', reason: 'rejected' }); + }); +}); + +describe('satisfaction helpers', () => { + it('a post-delivery choice satisfies a required category', () => { + const s = stateWith([cat()], { deliveryChannel: { diploma: 'post' } }); + expect(categorySatisfied(s, 'diploma')).toBe(true); + }); + + it('an active upload satisfies; a failed one does not', () => { + let s = select(stateWith([cat()]), 'diploma', 'u1'); + expect(categorySatisfied(s, 'diploma')).toBe(true); + s = reduceUpload(s, { type: 'UploadFailed', localId: 'u1', reason: 'x' }); + expect(categorySatisfied(s, 'diploma')).toBe(false); + }); + + it('requiredCategoriesSatisfied ignores optional categories', () => { + const s = stateWith([cat({ categoryId: 'req', required: true }), cat({ categoryId: 'opt', required: false })]); + expect(requiredCategoriesSatisfied(s)).toBe(false); + const s2 = select(s, 'req', 'u1'); + expect(requiredCategoriesSatisfied(s2)).toBe(true); + }); +}); + +describe('deliveryRefs', () => { + it('emits documentId for completed digital uploads and channel for post', () => { + let s = stateWith([cat({ categoryId: 'a' }), cat({ categoryId: 'b' })], { deliveryChannel: { b: 'post' } }); + s = select(s, 'a', 'u1'); + s = reduceUpload(s, { type: 'UploadComplete', localId: 'u1', documentId: 'doc1' }); + expect(deliveryRefs(s)).toEqual([ + { categoryId: 'a', channel: 'digital', documentId: 'doc1' }, + { categoryId: 'b', channel: 'post' }, + ]); + }); + + it('omits digital categories with no completed upload', () => { + const s = select(stateWith([cat({ categoryId: 'a' })]), 'a', 'u1'); // still queued + expect(deliveryRefs(s)).toEqual([]); + }); +}); + +describe('inFlight', () => { + it('returns only queued/uploading uploads', () => { + let s = select(stateWith([cat({ multiple: true })]), 'diploma', 'u1'); + s = select(s, 'diploma', 'u2'); + s = reduceUpload(s, { type: 'UploadProgress', localId: 'u2', progressPct: 10 }); + s = select(s, 'diploma', 'u3'); + s = reduceUpload(s, { type: 'UploadComplete', localId: 'u3', documentId: 'doc3' }); + expect(inFlight(s).map((u) => u.localId).sort()).toEqual(['u1', 'u2']); + }); +}); diff --git a/src/app/shared/upload/upload.machine.ts b/src/app/shared/upload/upload.machine.ts new file mode 100644 index 0000000..b584b5f --- /dev/null +++ b/src/app/shared/upload/upload.machine.ts @@ -0,0 +1,212 @@ +import { assertNever } from '@shared/kernel/fp'; + +/** + * Pure upload domain (no Angular). Reusable across wizards: the host wizard folds + * `UploadState` into its Model and delegates upload `Msg`s to `reduceUpload`. + * Illegal states are unrepresentable by construction; the few transitions a union + * can't express (channel↔upload exclusivity, single-file categories) are enforced + * here in the reducer. Effects live in the shell (upload-shell.service.ts). + */ + +export type UploadStatus = + | { type: 'idle' } + | { type: 'queued' } + | { type: 'uploading'; progressPct: number } + | { type: 'complete'; documentId: string } + | { type: 'failed'; reason: string } + | { type: 'deleting'; documentId: string } // carries the id so a failed delete can revert + | { type: 'deleted' }; + +export interface Upload { + localId: string; // client-generated UUID; the sync tag / status key + categoryId: string; + fileName: string; + fileSizeMb: number; + status: UploadStatus; + backgroundSync: boolean; +} + +export type DeliveryChannel = 'digital' | 'post'; + +export interface DocumentCategory { + categoryId: string; + label: string; + description: string; + required: boolean; + acceptedTypes: string[]; + maxSizeMb: number; + multiple: boolean; + allowPostDelivery: boolean; +} + +export interface UploadState { + categories: DocumentCategory[]; + uploads: Upload[]; + deliveryChannel: Record; // categoryId → channel; default 'digital' + rejections: Record; // categoryId → client-side validation message (transient) + backgroundSyncAvailable: boolean; + categoriesError?: string; +} + +export const initialUpload: UploadState = { + categories: [], + uploads: [], + deliveryChannel: {}, + rejections: {}, + backgroundSyncAvailable: false, +}; + +export type UploadMsg = + | { type: 'CategoriesLoaded'; categories: DocumentCategory[] } + | { type: 'CategoriesLoadFailed'; reason: string } + | { type: 'BackgroundSyncAvailability'; available: boolean } + | { type: 'FileSelected'; categoryId: string; localId: string; fileName: string; fileSizeMb: number } + | { type: 'FileRejected'; categoryId: string; reason: 'type' | 'size' | 'multiple' } + | { type: 'UploadQueued'; localId: string; backgroundSync: boolean } + | { type: 'UploadProgress'; localId: string; progressPct: number } + | { type: 'UploadComplete'; localId: string; documentId: string } + | { type: 'UploadFailed'; localId: string; reason: string } + | { type: 'UploadRetried'; localId: string } + | { type: 'UploadRemoved'; localId: string } + | { type: 'UploadDeleteRequested'; localId: string; documentId: string } + | { type: 'UploadDeleting'; localId: string } + | { type: 'UploadDeleteComplete'; localId: string } + | { type: 'UploadDeleteFailed'; localId: string; reason: string } + | { type: 'DeliveryChannelChanged'; categoryId: string; channel: DeliveryChannel } + | { + type: 'BackgroundUploadsReturned'; + results: Array<{ localId: string } & ({ success: true; documentId: string } | { success: false; reason: string })>; + }; + +const REJECTION_MESSAGES: Record<'type' | 'size' | 'multiple', string> = { + type: $localize`:@@upload.reject.type:Dit bestandstype is niet toegestaan voor deze categorie.`, + size: $localize`:@@upload.reject.size:Dit bestand is te groot.`, + multiple: $localize`:@@upload.reject.multiple:U kunt voor deze categorie maar één bestand uploaden.`, +}; + +const ACTIVE: ReadonlyArray = ['queued', 'uploading', 'complete']; + +/** A required category is satisfied by an initiated upload OR a post-delivery choice. */ +export function categorySatisfied(s: UploadState, categoryId: string): boolean { + if (s.deliveryChannel[categoryId] === 'post') return true; + return s.uploads.some((u) => u.categoryId === categoryId && ACTIVE.includes(u.status.type)); +} + +export function requiredCategoriesSatisfied(s: UploadState): boolean { + return s.categories.filter((c) => c.required).every((c) => categorySatisfied(s, c.categoryId)); +} + +/** Map one upload's status, leaving the rest of the list untouched. */ +function mapUpload(s: UploadState, localId: string, f: (u: Upload) => Upload): UploadState { + return { ...s, uploads: s.uploads.map((u) => (u.localId === localId ? f(u) : u)) }; +} + +const find = (s: UploadState, localId: string) => s.uploads.find((u) => u.localId === localId); +const categoryOf = (s: UploadState, categoryId: string) => s.categories.find((c) => c.categoryId === categoryId); + +export function reduceUpload(s: UploadState, m: UploadMsg): UploadState { + switch (m.type) { + case 'CategoriesLoaded': { + const deliveryChannel = { ...s.deliveryChannel }; + for (const c of m.categories) deliveryChannel[c.categoryId] ??= 'digital'; + return { ...s, categories: m.categories, deliveryChannel, categoriesError: undefined }; + } + case 'CategoriesLoadFailed': + return { ...s, categoriesError: m.reason }; + case 'BackgroundSyncAvailability': + return { ...s, backgroundSyncAvailable: m.available }; + + case 'FileSelected': { + // Reject at dispatch: unknown category, or a category set to post-delivery. + if (!categoryOf(s, m.categoryId) || s.deliveryChannel[m.categoryId] === 'post') return s; + const cat = categoryOf(s, m.categoryId)!; + // Single-file categories: a new selection replaces any existing upload. + const uploads = cat.multiple ? s.uploads : s.uploads.filter((u) => u.categoryId !== m.categoryId); + const next: Upload = { + localId: m.localId, + categoryId: m.categoryId, + fileName: m.fileName, + fileSizeMb: m.fileSizeMb, + status: { type: 'queued' }, + backgroundSync: false, + }; + const { [m.categoryId]: _cleared, ...rejections } = s.rejections; + return { ...s, uploads: [...uploads, next], rejections }; + } + case 'FileRejected': + return { ...s, rejections: { ...s.rejections, [m.categoryId]: REJECTION_MESSAGES[m.reason] } }; + + case 'UploadQueued': + return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'queued' }, backgroundSync: m.backgroundSync })); + case 'UploadProgress': + return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'uploading', progressPct: m.progressPct } })); + case 'UploadComplete': + return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'complete', documentId: m.documentId } })); + case 'UploadFailed': + return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'failed', reason: m.reason } })); + case 'UploadRetried': + return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'queued' } })); + + case 'UploadRemoved': + return { ...s, uploads: s.uploads.filter((u) => u.localId !== m.localId) }; + + case 'UploadDeleteRequested': + case 'UploadDeleting': + // Both mark the in-flight delete; keep the documentId so a failure can revert. + return mapUpload(s, m.localId, (u) => { + const documentId = u.status.type === 'complete' ? u.status.documentId + : u.status.type === 'deleting' ? u.status.documentId : ''; + return { ...u, status: { type: 'deleting', documentId } }; + }); + case 'UploadDeleteComplete': + return { ...s, uploads: s.uploads.filter((u) => u.localId !== m.localId) }; + case 'UploadDeleteFailed': + return mapUpload(s, m.localId, (u) => + u.status.type === 'deleting' ? { ...u, status: { type: 'complete', documentId: u.status.documentId } } : u); + + case 'DeliveryChannelChanged': { + const cat = categoryOf(s, m.categoryId); + // Reject post for a category that doesn't permit it. + if (m.channel === 'post' && (!cat || !cat.allowPostDelivery)) return s; + const deliveryChannel = { ...s.deliveryChannel, [m.categoryId]: m.channel }; + // Switching to post removes that category's uploads; switching to digital starts clean. + const uploads = s.uploads.filter((u) => u.categoryId !== m.categoryId); + return { ...s, deliveryChannel, uploads }; + } + + case 'BackgroundUploadsReturned': { + let next = s; + for (const r of m.results) { + next = mapUpload(next, r.localId, (u) => ({ + ...u, + status: r.success ? { type: 'complete', documentId: r.documentId } : { type: 'failed', reason: r.reason }, + })); + } + return next; + } + + default: + return assertNever(m); + } +} + +/** The submit payload fragment: digital docs carry their documentId, post categories the channel. */ +export function deliveryRefs(s: UploadState): Array<{ categoryId: string; channel: DeliveryChannel; documentId?: string }> { + const refs: Array<{ categoryId: string; channel: DeliveryChannel; documentId?: string }> = []; + for (const c of s.categories) { + if (s.deliveryChannel[c.categoryId] === 'post') { + refs.push({ categoryId: c.categoryId, channel: 'post' }); + } else { + for (const u of s.uploads) { + if (u.categoryId === c.categoryId && u.status.type === 'complete') { + refs.push({ categoryId: c.categoryId, channel: 'digital', documentId: u.status.documentId }); + } + } + } + } + return refs; +} + +/** Used by the shell to find what to poll on return: still-in-flight uploads. */ +export const inFlight = (s: UploadState): Upload[] => + s.uploads.filter((u) => u.status.type === 'queued' || u.status.type === 'uploading');