import { describe, it, expect } from 'vitest'; import { DocumentCategory, Upload, UploadState, initialUpload, reduceUpload, categorySatisfied, requiredCategoriesSatisfied, deliveryRefs, inFlight, rejectReason, } 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(); }); it('drops uploads + channel choices for categories that disappear', () => { const s0 = select( stateWith([cat({ categoryId: 'diploma' }), cat({ categoryId: 'id' })], { deliveryChannel: { id: 'post' }, }), 'diploma', 'u1', ); expect(get(s0, 'u1')).toBeDefined(); // Reload with the diploma category gone (e.g. DUO chosen). const s1 = reduceUpload(s0, { type: 'CategoriesLoaded', categories: [cat({ categoryId: 'id' })], }); expect(get(s1, 'u1')).toBeUndefined(); expect(s1.deliveryChannel).toEqual({ id: 'post' }); }); }); 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('rejectReason', () => { it('rejects a disallowed type', () => { expect( rejectReason(cat({ acceptedTypes: ['application/pdf'] }), { type: 'image/png', sizeMb: 1 }), ).toBe('type'); }); it('rejects an oversized file', () => { expect(rejectReason(cat({ maxSizeMb: 10 }), { type: 'application/pdf', sizeMb: 11 })).toBe( 'size', ); }); it('accepts a valid file', () => { expect( rejectReason(cat({ acceptedTypes: ['application/pdf'], maxSizeMb: 10 }), { type: 'application/pdf', sizeMb: 1, }), ).toBeNull(); }); it('allows any type when the category lists none', () => { expect( rejectReason(cat({ acceptedTypes: [], maxSizeMb: 10 }), { type: 'image/png', sizeMb: 1 }), ).toBeNull(); }); }); 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']); }); });