import { beforeEach, describe, expect, it, vi } from 'vitest' // The action runs against fakes: a Blob Storage client that records what it was // given, and a database whose note and attachment rows are set per test. const NOTE = '00000000-0000-4000-8000-0000000000aa' const mocks = vi.hoisted(() => ({ put: vi.fn(async (pathname: string) => ({ url: `https://blob.example.com/${pathname}` })), log: vi.fn(async () => {}), recordUsage: vi.fn(async () => ({ accepted: 1, duplicates: 0, rejected: [] })), consumeUploadQuota: vi.fn(async () => null as { allowed: boolean; remaining: number } | null), revalidatePath: vi.fn(), loggerInfo: vi.fn(), loggerRecordException: vi.fn(), note: undefined as { id: string; authorId: string } | undefined, inserted: [] as Record[], })) const fakeDb = { select: () => ({ from: () => ({ where: () => ({ limit: async () => (mocks.note ? [mocks.note] : []) }) }) }), insert: () => ({ values: (row: Record) => ({ returning: async () => { mocks.inserted.push(row) return [{ id: '00000000-0000-4000-8000-0000000000ff', ...row, createdAt: new Date() }] }, }), }), } vi.mock('next/cache', () => ({ revalidatePath: mocks.revalidatePath })) vi.mock('@/shared/api', () => ({ db: () => fakeDb, audit: () => ({ log: mocks.log }), blob: () => ({ put: mocks.put }), recordUsage: mocks.recordUsage, serverErrors: () => ({ captureException: vi.fn() }), logger: () => ({ info: mocks.loggerInfo, recordException: mocks.loggerRecordException }), })) vi.mock('@/shared/config', () => ({ getEnv: () => ({ CONUSAI_API_URL: 'https://console.example.com', CONUSAI_API_TOKEN: 'dt_fixture' }), })) vi.mock('@/shared/lib/visitor-session', () => ({ ensureVisitor: async () => 'visitor-1' })) vi.mock('@/shared/lib/optional', () => ({ consumeUploadQuota: mocks.consumeUploadQuota })) import { uploadFile } from '@/features/upload-file/api/actions' function attach(file: File): FormData { const data = new FormData() data.set('file', file) return data } describe('uploadFile', () => { beforeEach(() => { mocks.put.mockClear() mocks.log.mockClear() mocks.revalidatePath.mockClear() mocks.consumeUploadQuota.mockReset() mocks.consumeUploadQuota.mockResolvedValue(null) mocks.inserted.length = 0 mocks.note = { id: NOTE, authorId: 'visitor-1' } mocks.loggerInfo.mockClear() mocks.loggerRecordException.mockClear() }) it('refuses a file over the size limit before any upload, and logs the failure', async () => { const big = new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'big.png', { type: 'image/png' }) await expect(uploadFile(NOTE, attach(big))).rejects.toThrow(/5 MiB/) expect(mocks.put).not.toHaveBeenCalled() expect(mocks.log).not.toHaveBeenCalled() expect(mocks.loggerInfo).not.toHaveBeenCalled() expect(mocks.loggerRecordException).toHaveBeenCalledTimes(1) expect(mocks.loggerRecordException).toHaveBeenCalledWith(expect.any(Error), { 'conusai.action': 'file.uploaded', 'note.id': NOTE }) }) it('refuses a disallowed type before any upload', async () => { const exe = new File(['MZ'], 'run.exe', { type: 'application/x-msdownload' }) await expect(uploadFile(NOTE, attach(exe))).rejects.toThrow(/not accepted/) expect(mocks.put).not.toHaveBeenCalled() }) it('refuses a note the visitor did not write, without uploading or spending quota', async () => { mocks.note = { id: NOTE, authorId: 'visitor-2' } const png = new File(['png'], 'a.png', { type: 'image/png' }) await expect(uploadFile(NOTE, attach(png))).rejects.toThrow(/not yours/) expect(mocks.put).not.toHaveBeenCalled() expect(mocks.inserted).toHaveLength(0) expect(mocks.consumeUploadQuota).not.toHaveBeenCalled() }) it('stores the bytes, records the attachment, and audits file.uploaded once', async () => { const png = new File(['png'], 'a.png', { type: 'image/png' }) await uploadFile(NOTE, attach(png)) expect(mocks.put).toHaveBeenCalledTimes(1) expect(mocks.put.mock.calls[0]?.[0]).toBe(`notes/${NOTE}/a.png`) expect(mocks.inserted[0]).toMatchObject({ noteId: NOTE, authorId: 'visitor-1', name: 'a.png', contentType: 'image/png', size: 3, url: `https://blob.example.com/notes/${NOTE}/a.png`, }) expect(mocks.log).toHaveBeenCalledTimes(1) expect(mocks.log).toHaveBeenCalledWith('file.uploaded', { actor: { kind: 'human', id: 'visitor-1' }, target: { id: '00000000-0000-4000-8000-0000000000ff' }, }) expect(mocks.loggerInfo).toHaveBeenCalledTimes(1) expect(mocks.loggerInfo).toHaveBeenCalledWith('file.uploaded', { 'file.id': '00000000-0000-4000-8000-0000000000ff', 'note.id': NOTE, outcome: 'ok', }) expect(mocks.loggerRecordException).not.toHaveBeenCalled() }) it('sends exactly one usage event, keyed by the attachment, for one upload', async () => { const png = new File(['png'], 'a.png', { type: 'image/png' }) await uploadFile(NOTE, attach(png)) expect(mocks.recordUsage).toHaveBeenCalledTimes(1) const [config, events] = mocks.recordUsage.mock.calls[0] as unknown as [ { baseUrl: string; token: string }, Array<{ idempotency_key: string; meter: string; quantity: string }>, ] expect(config).toEqual({ baseUrl: 'https://console.example.com', token: 'dt_fixture' }) expect(events).toHaveLength(1) expect(events[0]).toMatchObject({ idempotency_key: 'upload:00000000-0000-4000-8000-0000000000ff', meter: 'uploads', quantity: '1' }) }) it('refuses an upload over the minute allowance before any write', async () => { mocks.consumeUploadQuota.mockResolvedValue({ allowed: false, remaining: 0 }) const png = new File(['png'], 'a.png', { type: 'image/png' }) await expect(uploadFile(NOTE, attach(png))).rejects.toThrow(/limit/) expect(mocks.put).not.toHaveBeenCalled() expect(mocks.inserted).toHaveLength(0) }) })