Upload feature (d): pure upload domain machine + spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user