import { describe, it, expect } from 'vitest' import { processUpload } from '../../skills/data-portal/template/functions/api/upload' import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types' // The fake must model the constraints the schema actually declares, not just // the happy path. `objectKey TEXT UNIQUE` is what makes a re-upload an upsert // rather than a second row; a fake that accepts duplicates would let the // "exactly one manifest row" assertion pass while the real D1 threw. function makeEnv() { const manifestRows: unknown[][] = [] const objects = new Map() const env = { DB: { prepare(query: string) { let bound: unknown[] = [] const stmt = { bind(...v: unknown[]) { bound = v return stmt }, async run() { if (/INSERT INTO manifest/i.test(query)) { const objectKey = bound[3] as string const existing = manifestRows.findIndex((r) => r[3] === objectKey) if (existing >= 0) { if (!/ON CONFLICT\s*\(\s*objectKey\s*\)\s*DO UPDATE/i.test(query)) { throw new Error('UNIQUE constraint failed: manifest.objectKey') } manifestRows[existing] = bound } else { manifestRows.push(bound) } } return { meta: { changes: 1 } } }, async all() { return { results: [] } }, async first() { if (/FROM sessions/i.test(query)) { return (bound[0] === 'sess-a' ? { ownerId: 'alice' } : null) as T | null } if (/FROM manifest/i.test(query)) { const key = bound[0] as string return (objects.has(key) ? { fileId: 'x' } : null) as T | null } return null as T | null }, } return stmt }, }, BUCKET: { async put(key: string, body: Uint8Array) { objects.set(key, body) return {} }, async get(key: string) { const v = objects.get(key) return v ? { body: null, size: v.length } : null }, async head(key: string) { const v = objects.get(key) return v ? { size: v.length } : null }, async delete(key: string) { objects.delete(key) }, }, } as unknown as PortalEnv return { env, manifestRows, objects } } describe('processUpload', () => { it('writes the object under the owner prefix and exactly one manifest row', async () => { const { env, manifestRows, objects } = makeEnv() const lines: string[] = [] const res = await processUpload( 'sess-a', 'invoice.pdf', '', new Uint8Array([1, 2, 3]), env, (l) => lines.push(l), () => 'up-1', () => 1_000_000, ) expect(res.status).toBe(200) expect([...objects.keys()]).toEqual(['alice/invoice.pdf']) expect(manifestRows.length).toBe(1) }) it('writes the manifest row with ingested=0', async () => { const { env, manifestRows } = makeEnv() await processUpload( 'sess-a', 'invoice.pdf', '', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000_000, ) // Column order: fileId, ownerId, filename, objectKey, size, uploadedAt, ingested expect(manifestRows[0][6]).toBe(0) }) it('emits the full lifeline correlated by uploadId, ending in a verified post-condition', async () => { const { env } = makeEnv() const lines: string[] = [] await processUpload( 'sess-a', 'invoice.pdf', '', new Uint8Array([1]), env, (l) => lines.push(l), () => 'up-1', () => 1_000_000, ) const joined = lines.join('\n') expect(joined).toContain('op=request uploadId=up-1') expect(joined).toContain('op=r2-put uploadId=up-1') expect(joined).toContain('op=manifest-write uploadId=up-1') expect(joined).toMatch(/op=complete uploadId=up-1 .*objectPresent=true rowPresent=true/) }) it('denies an unauthenticated caller and writes nothing', async () => { const { env, manifestRows, objects } = makeEnv() const res = await processUpload( '', 'invoice.pdf', '', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000_000, ) expect(res.status).toBe(401) expect(objects.size).toBe(0) expect(manifestRows.length).toBe(0) }) it('rejects a filename that escapes the owner prefix, and writes nothing', async () => { const { env, objects, manifestRows } = makeEnv() const res = await processUpload( 'sess-a', '../bob/evil.pdf', '', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000_000, ) expect(res.status).toBe(400) expect(objects.size).toBe(0) expect(manifestRows.length).toBe(0) }) it('rejects an empty filename', async () => { const { env } = makeEnv() expect( ( await processUpload( 'sess-a', '', '', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000_000, ) ).status, ).toBe(400) }) it('caps the upload size and says so, rather than OOMing the isolate silently', async () => { const { env, objects, manifestRows } = makeEnv() const lines: string[] = [] const tooBig = new Uint8Array(25 * 1024 * 1024 + 1) const res = await processUpload( 'sess-a', 'huge.bin', '', tooBig, env, (l) => lines.push(l), () => 'up-1', () => 1_000_000, ) expect(res.status).toBe(413) expect(objects.size).toBe(0) expect(manifestRows.length).toBe(0) expect(lines.join('\n')).toContain('result=too-large') }) it('measures elapsed time rather than printing a fabricated zero', async () => { const { env } = makeEnv() const lines: string[] = [] let t = 1_000_000 await processUpload( 'sess-a', 'invoice.pdf', '', new Uint8Array([1]), env, (l) => lines.push(l), () => 'up-1', () => (t += 5), ) // A hardcoded ms=0 reads to an operator as "took no time", not "was never // measured". With a real clock the value moves. expect(lines.join('\n')).toMatch(/op=complete uploadId=up-1 ms=([1-9]\d*)/) }) // R2 overwrites an existing key unconditionally, so a re-upload of the same // filename REPLACES the object whatever the manifest does. The manifest must // therefore replace its row too, or it ends up describing a file that no // longer exists. Re-sending a corrected invoice.pdf is the single most // ordinary thing this portal will ever be asked to do. describe('re-uploading the same filename', () => { it('replaces the object, keeps exactly one row, and succeeds', async () => { const { env, manifestRows, objects } = makeEnv() await processUpload('sess-a', 'invoice.pdf', '', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000) const second = await processUpload( 'sess-a', 'invoice.pdf', '', new Uint8Array([9, 9, 9]), env, () => {}, () => 'up-2', () => 2_000, ) expect(second.status).toBe(200) expect(manifestRows.length).toBe(1) expect(objects.get('alice/invoice.pdf')?.length).toBe(3) }) it('updates the row to describe the new object, not the old one', async () => { const { env, manifestRows } = makeEnv() await processUpload('sess-a', 'invoice.pdf', '', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000) await processUpload('sess-a', 'invoice.pdf', '', new Uint8Array([9, 9, 9]), env, () => {}, () => 'up-2', () => 2_000) // Column order: fileId, ownerId, filename, objectKey, size, uploadedAt, ingested expect(manifestRows[0][4]).toBe(3) expect(manifestRows[0][5]).toBe(new Date(2_000).toISOString()) }) it('re-arms ingestion by resetting ingested to 0', async () => { // The load-bearing part. If the first file was already swept to // ingested=1, a replacement that left the flag set would never be // ingested, and nothing would ever say so. const { env, manifestRows } = makeEnv() await processUpload('sess-a', 'invoice.pdf', '', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000) manifestRows[0][6] = 1 // the device-side sweep marks it ingested await processUpload('sess-a', 'invoice.pdf', '', new Uint8Array([9, 9, 9]), env, () => {}, () => 'up-2', () => 2_000) expect(manifestRows[0][6]).toBe(0) }) }) })