import { afterEach, describe, expect, it, vi } from 'vitest' import { resumableUpload } from '../resumable-upload' const MiB = 1024 * 1024 const hash = 'a'.repeat(64) function fixture(size = 5 * MiB, name = 'resume.txt') { const file = new File([new Uint8Array(size)], name) const controller = new AbortController() const progress: number[] = [] const paused: boolean[] = [] let stage = false, active = false, bytes = 0, target = false const patches: number[] = [] const methods: string[] = [] let hook: ((url: URL, init: RequestInit) => Response | void | Promise) | undefined const response = (status: number, data?: unknown, headers?: Record) => new Response(data === undefined ? null : JSON.stringify(data), { status, headers: { ...(data ? { 'content-type': 'application/json' } : {}), ...headers } }) const transport = vi.fn(async (input: string, init: RequestInit = {}) => { const url = new URL(input) methods.push(init.method || 'GET') const special = await hook?.(url, init) if (special) return special if (url.pathname.includes('/api/tus/')) { if (init.method === 'HEAD') return active ? response(200, undefined, { 'Upload-Offset': String(bytes), 'Upload-Length': String(size) }) : response(404) if (init.method === 'POST') { expect(url.searchParams.get('override')).not.toBe('true') if (stage) return response(409) stage = active = true; return response(201) } if (init.method === 'PATCH') { const offset = Number((init.headers as Record)['Upload-Offset']) patches.push(offset) if (!active) return response(404) if (offset !== bytes) return response(409) bytes += (init.body as Blob).size if (bytes === size) active = false return response(204, undefined, { 'Upload-Offset': String(bytes) }) } if (init.method === 'DELETE') { if (!active) return response(404); stage = active = false; return response(204) } } if (init.method === 'PATCH') { expect(decodeURIComponent(url.searchParams.get('destination')!)).toBe('/folder/' + name); target = true; stage = false; return response(200) } if (init.method === 'DELETE') { expect(url.pathname).toContain('.archy-upload-'); stage = false; return response(200) } if (url.pathname.includes('.archy-upload-') ? stage : target) return response(200, { size: bytes, isDir: false, checksums: { sha256: hash } }) return response(404) }) return { file, controller, progress, paused, patches, methods, transport, response, setHook: (fn: typeof hook) => { hook = fn }, createStage: () => { stage = active = true }, setBytes: (n: number) => { bytes = n }, finishStage: () => { bytes = size; active = false }, rename: () => { target = true; stage = false }, hasStage: () => stage, hasTarget: () => target, run: () => resumableUpload('https://node/app/filebrowser', '/folder', file, transport, { signal: controller.signal, onProgress: n => progress.push(n), onPaused: p => paused.push(p) }), } } afterEach(() => vi.useRealTimers()) describe('resumable Cloud upload', () => { it('uploads bounded chunks and publishes only complete verified bytes', async () => { const f = fixture(); await f.run() expect(f.patches).toEqual([0, 2 * MiB, 4 * MiB]) expect(f.hasStage()).toBe(false); expect(f.hasTarget()).toBe(true) expect(f.progress[f.progress.length - 1]).toBe(f.file.size) }) it('resumes after partial network write at the server offset, never truncating', async () => { vi.useFakeTimers(); const f = fixture(); let lost = false f.setHook(async (url, init) => { if (url.pathname.includes('/api/tus/') && init.method === 'PATCH' && !lost) { lost = true; f.setBytes(123456); throw new TypeError('Network lost') } }) const run = f.run(); await vi.runAllTimersAsync(); await run expect(f.patches[0]).toBe(123456) expect(f.methods.filter(m => m === 'POST')).toHaveLength(1) expect(f.paused).toContain(true); expect(f.paused[f.paused.length - 1]).toBe(false) }) it('recovers a lost final chunk response after TUS completion removes the session', async () => { vi.useFakeTimers(); const f = fixture(1024); let lost = false f.setHook(async (url, init) => { if (url.pathname.includes('/api/tus/') && init.method === 'PATCH' && !lost) { lost = true; f.finishStage(); throw new TypeError('Reply lost') } }) const run = f.run(); await vi.runAllTimersAsync(); await run expect(f.methods.filter(m => m === 'POST')).toHaveLength(1); expect(f.hasTarget()).toBe(true) }) it('confirms a lost rename response using exact checksum', async () => { vi.useFakeTimers(); const f = fixture(1024); let lost = false f.setHook(async (url, init) => { if (url.pathname.includes('/api/resources/') && init.method === 'PATCH' && !lost) { lost = true; f.rename(); throw new TypeError('Reply lost') } }) const run = f.run(); await vi.runAllTimersAsync(); await run expect(f.hasTarget()).toBe(true) expect(f.transport.mock.calls.some(([url]) => url.includes('resume.txt?checksum=sha256'))).toBe(true) }) it('never accepts a same-size destination with different content after ambiguous rename', async () => { vi.useFakeTimers(); const f = fixture(1024) f.setHook(async (url, init) => { if (url.pathname.includes('/api/resources/') && init.method === 'PATCH') { f.rename(); throw new TypeError('Reply lost') } if (url.pathname.endsWith('/resume.txt')) return f.response(200, { size: 1024, checksums: { sha256: 'b'.repeat(64) } }) }) const result = expect(f.run()).rejects.toThrow('Cannot confirm'); await vi.runAllTimersAsync(); await result }) it('reconciles a lost creation reply without creating or truncating a second upload', async () => { vi.useFakeTimers(); const f = fixture(1024); let created = false f.setHook(async (_, init) => { if (init.method === 'POST' && !created) { created = true; f.createStage(); throw new TypeError('Creation reply lost') } }) const run = f.run(); await vi.runAllTimersAsync(); await run expect(f.hasTarget()).toBe(true); expect(f.patches).toEqual([0]); expect(f.methods.filter(m => m === 'POST')).toHaveLength(1) }) it('does not silently restart an expired partially saved session', async () => { const f = fixture(); let patched = false f.setHook(async (_, init) => { if (init.method === 'PATCH') patched = true if (init.method === 'HEAD' && patched) return f.response(404) }) await expect(f.run()).rejects.toThrow('session expired') expect(f.methods.filter(m => m === 'POST')).toHaveLength(1) expect(f.hasTarget()).toBe(false) }) it('wakes immediately when the network returns and cleans up wake listeners', async () => { vi.useFakeTimers(); const f = fixture(1024); let offline = true f.setHook(async (_, init) => { if (init.method === 'PATCH' && offline) throw new TypeError('Offline') }) const run = f.run(); await vi.advanceTimersByTimeAsync(0) expect(f.paused).toContain(true) offline = false; window.dispatchEvent(new Event('online')) await vi.advanceTimersByTimeAsync(0); await run expect(vi.getTimerCount()).toBe(0); expect(f.hasTarget()).toBe(true) }) it('handles empty files and encoded filenames', async () => { const f = fixture(0, 'a + 100% ? ΓΌ.txt'); await f.run(); expect(f.hasTarget()).toBe(true); expect(f.patches).toEqual([]) }) it.each([401, 403, 507])('stops on permanent HTTP %s without endless retry', async status => { const f = fixture(); f.setHook(async () => f.response(status)) await expect(f.run()).rejects.toThrow(status === 507 ? 'storage' : 'access denied') expect(f.paused).not.toContain(true) }) it('rejects HTML login interception', async () => { const f = fixture(); f.setHook(async () => new Response('', { headers: { 'content-type': 'text/html' } })) await expect(f.run()).rejects.toThrow('working File Browser') }) it('rejects corrupt server offsets', async () => { const f = fixture(); f.setHook(async (_, init) => init.method === 'HEAD' ? f.response(200, undefined, { 'Upload-Offset': '-1', 'Upload-Length': String(f.file.size) }) : undefined) await expect(f.run()).rejects.toThrow('invalid upload position') }) it('cancels during retry and removes only its staging file', async () => { const f = fixture(); f.setHook(async (_, init) => { if (init.method === 'PATCH') { queueMicrotask(() => f.controller.abort()); throw new TypeError('Offline') } }) await expect(f.run()).rejects.toMatchObject({ name: 'AbortError' }) expect(f.hasStage()).toBe(false); expect(f.hasTarget()).toBe(false) }) })