import { test } from 'node:test' import assert from 'node:assert/strict' import { createUploader, checkFile, sha256Hex, refusal, UploadError, MAX_BYTES } from './uploads.mjs' // fakeFile is enough of a File for this module: a name, a type, a size, // and bytes when something wants to hash them. function fakeFile (name, type, bytes = new Uint8Array([1, 2, 3])) { return { name, type, size: bytes.length, arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) } } // rig wires an uploader over fakes and records what each step was // asked, so a test can assert on the ORDER as well as the arguments. function rig (over = {}) { const calls = [] const states = [] const uploader = createUploader({ sign: async body => { calls.push(['sign', body]); return { ref: 'abc123', url: '/put/abc123', headers: { 'Content-Type': body.type } } }, put: async (url, file, headers) => { calls.push(['put', url, file.name, headers]) }, register: async body => { calls.push(['register', body]); return { id: 'a1', ref: 'abc123', name: body.name, width: body.width, height: body.height, alt: body.alt } }, applyOp: async record => { calls.push(['op', record]) }, measure: async () => ({ width: 40, height: 30 }), hash: async () => 'abc123', onState: (s, d) => states.push([s, d]), ...over }) return { uploader, calls, states } } test('the state machine runs choose → sign → put → register → op', async () => { const { uploader, calls, states } = rig() const record = await uploader.upload(fakeFile('chart.png', 'image/png'), { alt: 'A bar chart' }) assert.deepEqual(calls.map(c => c[0]), ['sign', 'put', 'register', 'op']) assert.deepEqual(states.map(s => s[0]), ['sign', 'put', 'register', 'op', 'done']) assert.deepEqual(calls[0][1], { name: 'chart.png', size: 3, type: 'image/png', hash: 'abc123' }) assert.equal(calls[1][1], '/put/abc123') assert.deepEqual(calls[2][1], { ref: 'abc123', name: 'chart.png', width: 40, height: 30, alt: 'A bar chart', decorative: false }) // The op is applied with the SERVER's record, never with what the // client thought it uploaded. assert.deepEqual(calls[3][1], record) assert.equal(record.id, 'a1') }) test('a file the deck will not take never reaches the network', async () => { const { uploader, calls } = rig() await assert.rejects(() => uploader.upload(fakeFile('notes.pdf', 'application/pdf'), { alt: 'x' }), err => err instanceof UploadError && err.code === 'type') const big = fakeFile('huge.png', 'image/png') big.size = MAX_BYTES + 1 await assert.rejects(() => uploader.upload(big, { alt: 'x' }), err => err.code === 'toolarge') assert.deepEqual(calls, [], 'nothing was signed, put or registered') }) test('alt text is required before the bytes move, unless the image is decorative', async () => { const { uploader, calls } = rig() await assert.rejects(() => uploader.upload(fakeFile('x.png', 'image/png'), { alt: ' ' }), err => err.code === 'altfirst') assert.deepEqual(calls, []) await uploader.upload(fakeFile('x.png', 'image/png'), { alt: '', decorative: true }) assert.deepEqual(calls.map(c => c[0]), ['sign', 'put', 'register', 'op']) assert.equal(calls[2][1].decorative, true) assert.equal(calls[2][1].alt, '') }) test('a step that fails stops the ones after it and reports its own code', async () => { for (const [failing, code] of [['sign', 'no_store'], ['put', 'failed'], ['register', 'unsafe_svg']]) { const { uploader, calls, states } = rig({ [failing]: async () => { throw new UploadError(code, 'in words') } }) await assert.rejects(() => uploader.upload(fakeFile('x.png', 'image/png'), { alt: 'a' }), err => err.code === code && err.serverMessage === 'in words') assert.ok(!calls.some(c => c[0] === 'op'), `${failing}: the op ran anyway`) assert.equal(states.at(-1)[0], 'error') } }) test('a failure that is not ours still becomes an UploadError', async () => { const { uploader } = rig({ put: async () => { throw new TypeError('network down') } }) await assert.rejects(() => uploader.upload(fakeFile('x.png', 'image/png'), { alt: 'a' }), err => err instanceof UploadError && err.code === 'failed' && err.serverMessage === 'network down') }) test('a second pick while one is in flight is refused, not raced', async () => { // The first PUT hangs until the test lets it go; every later one // resolves at once, so the "and then the next one works" half of this // is not itself a hang. const held = [] const { uploader, calls } = rig({ put: () => (held.length ? Promise.resolve() : new Promise(r => held.push(r))) }) const first = uploader.upload(fakeFile('a.png', 'image/png'), { alt: 'a' }) await assert.rejects(() => uploader.upload(fakeFile('b.png', 'image/png'), { alt: 'b' }), err => err.code === 'busy') held[0]() await first await uploader.upload(fakeFile('c.png', 'image/png'), { alt: 'c' }) assert.deepEqual(calls.filter(c => c[0] === 'op').length, 2, 'b.png never became an op') }) test('an unreadable size is zero rather than a failure', async () => { const { uploader, calls } = rig({ measure: async () => ({ width: 0, height: 0 }) }) await uploader.upload(fakeFile('x.svg', 'image/svg+xml'), { alt: 'a' }) assert.equal(calls[2][1].width, 0) assert.equal(calls[2][1].height, 0) }) test('a per-call applyOp wins, so an upload lands on the block it was chosen for', async () => { const { uploader, calls } = rig() const landed = [] await uploader.upload(fakeFile('x.png', 'image/png'), { alt: 'a', applyOp: record => landed.push(record.id) }) assert.deepEqual(landed, ['a1']) assert.ok(!calls.some(c => c[0] === 'op'), 'the constructor\'s applyOp ran as well') }) test('checkFile takes exactly the four types', () => { for (const type of ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']) { assert.doesNotThrow(() => checkFile(fakeFile('x', type))) } for (const type of ['image/gif', 'text/html', 'image/svg', '']) { assert.throws(() => checkFile(fakeFile('x', type)), /type/) } assert.throws(() => checkFile(null), /nofile/) }) test('sha256Hex is the hex digest of the bytes', async () => { // sha256("abc"), the vector everybody knows. const file = fakeFile('abc.png', 'image/png', new TextEncoder().encode('abc')) assert.equal(await sha256Hex(file), 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad') }) test('refusal reads this app\'s JSON error shape, and survives one that is not', async () => { const json = { status: 503, json: async () => ({ error: 'no_store', message: 'No object store.' }) } const a = await refusal(json) assert.equal(a.code, 'no_store') assert.equal(a.serverMessage, 'No object store.') const html = { status: 502, json: async () => { throw new SyntaxError('not JSON') } } const b = await refusal(html) assert.equal(b.code, 'http_502') assert.equal(b.serverMessage, '') })