Files
TREK/client/src/utils/uploadQueue.test.ts
T
Maurice 72a82b3c44 fix(client): work through the findings from the coverage pass
214 items came out of writing the tests. Real defects are corrected, dead code
is removed rather than covered, and the tests that pinned the old behaviour
move with them.

Also drops two agent coverage scratch files that slipped into the coverage PR
and adds the ignore rule that keeps them out.
2026-07-29 12:35:00 +02:00

159 lines
5.6 KiB
TypeScript

// FE-W4UPQ-001 to FE-W4UPQ-012
import { describe, it, expect, vi } from 'vitest'
import { uploadFilesResilient, type UploadOpts, type UploadProgress } from './uploadQueue'
function file(name: string, size = 100): File {
const f = new File(['x'], name, { type: 'text/plain' })
Object.defineProperty(f, 'size', { value: size })
return f
}
describe('uploadFilesResilient', () => {
it('FE-W4UPQ-001: returns every uploaded item and no failures', async () => {
const files = [file('a.txt'), file('b.txt')]
const uploadOne = vi.fn(async (f: File) => [{ name: f.name }])
const res = await uploadFilesResilient(files, uploadOne)
expect(res.failed).toEqual([])
expect(res.succeeded).toEqual([{ name: 'a.txt' }, { name: 'b.txt' }])
expect(uploadOne).toHaveBeenCalledTimes(2)
})
it('FE-W4UPQ-002: passes a fresh idempotency key per file', async () => {
const keys: string[] = []
const uploadOne = async (_f: File, opts: UploadOpts) => { keys.push(opts.idempotencyKey); return [] }
await uploadFilesResilient([file('a.txt'), file('b.txt')], uploadOne)
expect(keys).toHaveLength(2)
expect(keys[0]).not.toBe(keys[1])
expect(keys[0]).toMatch(/^[0-9a-f-]{36}$/)
})
it('FE-W4UPQ-003: streams onUploaded per file as it lands', async () => {
const onUploaded = vi.fn()
await uploadFilesResilient(
[file('a.txt'), file('b.txt')],
async (f: File) => [f.name],
{ concurrency: 1, onUploaded },
)
expect(onUploaded).toHaveBeenNthCalledWith(1, ['a.txt'])
expect(onUploaded).toHaveBeenNthCalledWith(2, ['b.txt'])
})
it('FE-W4UPQ-004: reports byte progress from onUploadProgress and finishes at 100%', async () => {
const seen: UploadProgress[] = []
const uploadOne = async (f: File, opts: UploadOpts) => {
opts.onUploadProgress({ loaded: f.size / 2, total: f.size } as never)
return [f.name]
}
await uploadFilesResilient([file('a.txt', 200)], uploadOne, { onProgress: p => { seen.push(p) } })
expect(seen[0]).toEqual({ done: 0, total: 1, failed: 0, percent: 50 })
expect(seen[seen.length - 1]).toEqual({ done: 1, total: 1, failed: 0, percent: 100 })
})
it('FE-W4UPQ-005: reports 0% when the files carry no bytes', async () => {
const seen: UploadProgress[] = []
await uploadFilesResilient([file('a.txt', 0)], async () => [], { onProgress: p => { seen.push(p) } })
expect(seen[seen.length - 1]).toEqual({ done: 1, total: 1, failed: 0, percent: 0 })
})
it('FE-W4UPQ-006: retries a transient failure and keeps the file', async () => {
let calls = 0
const uploadOne = vi.fn(async (f: File) => {
calls++
if (calls === 1) throw new Error('network down')
return [f.name]
})
const res = await uploadFilesResilient([file('a.txt')], uploadOne, { retries: 1 })
expect(calls).toBe(2)
expect(res.succeeded).toEqual(['a.txt'])
expect(res.failed).toEqual([])
})
it('FE-W4UPQ-007: does not retry a 4xx and reports the file as failed', async () => {
const uploadOne = vi.fn(async () => { throw { response: { status: 413 } } })
const f = file('big.txt')
const res = await uploadFilesResilient([f], uploadOne, { retries: 2 })
expect(uploadOne).toHaveBeenCalledTimes(1)
expect(res.succeeded).toEqual([])
expect(res.failed).toEqual([f])
})
it('FE-W4UPQ-008: retries a 5xx up to the limit then gives up', async () => {
const uploadOne = vi.fn(async () => { throw { response: { status: 502 } } })
const f = file('a.txt')
const res = await uploadFilesResilient([f], uploadOne, { retries: 1 })
expect(uploadOne).toHaveBeenCalledTimes(2)
expect(res.failed).toEqual([f])
})
it('FE-W4UPQ-009: a failed file counts into the progress failure tally', async () => {
const seen: UploadProgress[] = []
const ok = file('ok.txt')
const bad = file('bad.txt')
const uploadOne = async (f: File) => {
if (f.name === 'bad.txt') throw { response: { status: 400 } }
return [f.name]
}
const res = await uploadFilesResilient([ok, bad], uploadOne, { concurrency: 1, onProgress: p => { seen.push(p) } })
expect(res.succeeded).toEqual(['ok.txt'])
expect(res.failed).toEqual([bad])
expect(seen[seen.length - 1]).toMatchObject({ done: 1, failed: 1, total: 2 })
})
it('FE-W4UPQ-010: never spawns more workers than files', async () => {
let inFlight = 0
let peak = 0
const uploadOne = async (f: File) => {
inFlight++
peak = Math.max(peak, inFlight)
await Promise.resolve()
inFlight--
return [f.name]
}
const res = await uploadFilesResilient([file('a.txt'), file('b.txt')], uploadOne, { concurrency: 10 })
expect(peak).toBeLessThanOrEqual(2)
expect(res.succeeded).toHaveLength(2)
})
it('FE-W4UPQ-011: an empty file list resolves without calling the uploader', async () => {
const uploadOne = vi.fn(async () => [])
const res = await uploadFilesResilient([], uploadOne)
expect(uploadOne).not.toHaveBeenCalled()
expect(res).toEqual({ succeeded: [], failed: [] })
})
it('FE-W4UPQ-012: the bytes of an aborted attempt do not survive into the retry', async () => {
const seen: UploadProgress[] = []
let attempt = 0
const uploadOne = async (f: File, opts: UploadOpts) => {
attempt++
opts.onUploadProgress({ loaded: f.size / 2, total: f.size } as never)
if (attempt === 1) throw new Error('connection reset')
return [f.name]
}
await uploadFilesResilient([file('a.txt', 200)], uploadOne, { retries: 1, onProgress: p => { seen.push(p) } })
// 50% from the aborted attempt, then back to 0 before the retry reports its own.
expect(seen.map(p => p.percent)).toEqual([50, 0, 50, 100])
})
})