Upload feature (d): pure upload domain machine + spec

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 11:48:24 +02:00
parent 0d37cc097a
commit 0e48f44773
2 changed files with 441 additions and 0 deletions

View File

@@ -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> = {}): 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> = {}): 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']);
});
});

View File

@@ -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<string, DeliveryChannel>; // categoryId → channel; default 'digital'
rejections: Record<string, string>; // 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<UploadStatus['type']> = ['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');