import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../../lib/object-storage.js', () => ({ put: vi.fn(), del: vi.fn(), })); vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() })); vi.mock('../../lib/permission-middleware', () => ({ getUserFromRequest: vi.fn(), })); vi.mock('../../lib/rate-limit.js', () => ({ checkUploadRateLimit: vi.fn(), })); import { put, del } from '../../lib/object-storage.js'; import { sql } from '../../lib/sql.js'; import { getUserFromRequest } from '../../lib/permission-middleware'; import { checkUploadRateLimit } from '../../lib/rate-limit.js'; import handler from '../../pages/api/custom-symbols/index.js'; function createRes() { const res = { statusCode: 200, body: null, status(code) { res.statusCode = code; return res; }, json(data) { res.body = data; return res; }, setHeader() { return res; }, }; return res; } /** Build a fake multipart request whose body carries the given parts. */ function multipartRequest(fields) { const boundary = 'testboundary'; const body = fields.map((f) => `--${boundary}\r\n${f}\r\n`).join('') + `--${boundary}--\r\n`; return { method: 'POST', headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, on(event, cb) { if (event === 'data') cb(Buffer.from(body)); if (event === 'end') cb(); }, }; } function filePart(name, filename, type) { return `Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\nContent-Type: ${type}\r\n\r\nBINARYDATA`; } function textPart(name, value) { return `Content-Disposition: form-data; name="${name}"\r\n\r\n${value}`; } describe('/api/custom-symbols', () => { beforeEach(() => { vi.clearAllMocks(); getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' }); checkUploadRateLimit.mockResolvedValue({ allowed: true, reset: Date.now() + 60000 }); sql.mockResolvedValue({ rows: [] }); put.mockResolvedValue({ url: 'https://cdn.example.com/symbols/x.png' }); }); it('requires authentication', async () => { getUserFromRequest.mockResolvedValue(null); const res = createRes(); await handler({ method: 'GET' }, res); expect(res.statusCode).toBe(401); expect(sql).not.toHaveBeenCalled(); }); it('rejects invalid symbol codes', async () => { const req = multipartRequest([ filePart('image', 'f.png', 'image/png'), textPart('code', 'bad code!'), ]); const res = createRes(); await handler(req, res); expect(res.statusCode).toBe(400); expect(res.body.error).toContain('1-10 letters'); }); it('rejects unsupported file types', async () => { const req = multipartRequest([ filePart('image', 'f.gif', 'image/gif'), textPart('code', 'F'), ]); const res = createRes(); await handler(req, res); expect(res.statusCode).toBe(400); expect(res.body.error).toContain('PNG, WebP, or SVG'); }); it('uploads a symbol and returns its URL', async () => { sql .mockResolvedValueOnce({ rows: [] }) // existing lookup misses .mockResolvedValueOnce({ rows: [{ id: 7, code: 'F', image_url: 'https://cdn.example.com/symbols/x.png' }], }); const req = multipartRequest([ filePart('image', 'fire.png', 'image/png'), textPart('code', 'F'), ]); const res = createRes(); await handler(req, res); expect(res.statusCode).toBe(201); expect(res.body.symbol.code).toBe('F'); expect(put).toHaveBeenCalledTimes(1); expect(put.mock.calls[0][0]).toContain('symbols/1-f-'); }); it('replaces an existing symbol with the same code', async () => { sql .mockResolvedValueOnce({ rows: [{ id: 6, image_url: 'https://cdn.example.com/symbols/old.png' }], }) // existing lookup hits .mockResolvedValueOnce({ rows: [{ id: 6, code: 'F', image_url: 'https://cdn.example.com/symbols/x.png' }], }); const req = multipartRequest([ filePart('image', 'fire.png', 'image/png'), textPart('code', 'F'), ]); const res = createRes(); await handler(req, res); expect(res.statusCode).toBe(200); expect(del).toHaveBeenCalledWith('https://cdn.example.com/symbols/old.png'); }); });