import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() })); vi.mock('../../lib/permission-middleware', () => ({ getUserFromRequest: vi.fn(), })); import { sql } from '../../lib/sql.js'; import { getUserFromRequest } from '../../lib/permission-middleware'; import handler from '../../pages/api/custom-frames/index.js'; import itemHandler from '../../pages/api/custom-frames/[id].js'; import { validatePalette, PALETTE_SLOTS, validateLayout } from '../../lib/frame-palette.js'; function createRes() { const res = { statusCode: 200, body: null, status(code) { res.statusCode = code; return res; }, json(data) { res.body = data; return res; }, }; return res; } const GOOD_PALETTE = Object.fromEntries( PALETTE_SLOTS.map(({ key }) => [key, '#123456']) ); const DEFAULT_LAYOUT = { art: { x: 16 / 420, y: 64 / 588, w: 388 / 420, h: 234 / 588 }, text: { x: 16 / 420, y: 342 / 588, w: 388 / 420, h: 224 / 588 }, }; describe('validatePalette', () => { it('accepts and normalizes a complete palette', () => { const { palette, error } = validatePalette( Object.fromEntries(PALETTE_SLOTS.map(({ key }) => [key, '#ABCDEF'])) ); expect(error).toBeUndefined(); expect(palette.outer).toBe('#abcdef'); }); it('rejects missing slots and bad colors', () => { expect(validatePalette({}).error).toBeTruthy(); const missing = { ...GOOD_PALETTE }; delete missing.accent; expect(validatePalette(missing).error).toBeTruthy(); expect(validatePalette({ ...GOOD_PALETTE, border: 'red' }).error).toBeTruthy(); }); }); describe('validateLayout', () => { it('accepts and normalizes a valid layout', () => { const { layout, error } = validateLayout({ art: { x: 0.038, y: 0.109, w: 0.924, h: 0.398 }, text: { x: 0.038, y: 0.582, w: 0.924, h: 0.381 }, }); expect(error).toBeUndefined(); expect(layout.art.x).toBeCloseTo(0.038); }); it('rejects missing zones', () => { expect(validateLayout({ art: { x: 0, y: 0, w: 1, h: 1 } }).error).toBeTruthy(); }); it('rejects zones extending past the card', () => { expect(validateLayout({ art: { x: 0.9, y: 0, w: 0.2, h: 0.5 }, text: { x: 0, y: 0, w: 1, h: 1 } }).error).toBeTruthy(); }); }); describe('/api/custom-frames', () => { beforeEach(() => { vi.clearAllMocks(); getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' }); sql.mockReturnValue({ rows: [] }); sql.json = vi.fn((v) => v); }); 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 palettes on create', async () => { const res = createRes(); await handler( { method: 'POST', body: { name: 'My Frame', palette: { outer: 'nope' } } }, res ); expect(res.statusCode).toBe(400); expect(res.body.error).toContain('Invalid or missing color'); }); it('rejects invalid layouts on create', async () => { const res = createRes(); await handler( { method: 'POST', body: { name: 'My Frame', palette: GOOD_PALETTE, layout: { art: { x: 1, y: 0, w: 1, h: 1 } } } }, res ); expect(res.statusCode).toBe(400); expect(res.body.error).toContain('extends past the card'); }); it('rejects duplicate frame names', async () => { sql.mockResolvedValueOnce({ rows: [{ id: 4 }] }); // clash lookup hits const res = createRes(); await handler( { method: 'POST', body: { name: 'Molten', palette: GOOD_PALETTE } }, res ); expect(res.statusCode).toBe(409); }); it('creates a frame with the normalized palette and default layout', async () => { sql .mockResolvedValueOnce({ rows: [] }) // clash lookup misses .mockResolvedValueOnce({ rows: [{ id: 4, name: 'Molten', palette: GOOD_PALETTE, layout: { art: { x: 16/420, y: 64/588, w: 388/420, h: 234/588 }, text: { x: 16/420, y: 342/588, w: 388/420, h: 224/588 } } }], }); const res = createRes(); await handler( { method: 'POST', body: { name: 'Molten', palette: GOOD_PALETTE } }, res ); expect(res.statusCode).toBe(201); expect(res.body.frame.name).toBe('Molten'); expect(res.body.frame.layout).toBeDefined(); }); }); describe('/api/custom-frames/[id]', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' }); sql.mockResolvedValue({ rows: [] }); sql.json.mockImplementation((v) => v); }); it('returns 404 when no frame exists', async () => { sql.mockResolvedValueOnce({ rows: [] }); const res = createRes(); await itemHandler({ method: 'GET', query: { id: '999' } }, res); expect(res.statusCode).toBe(404); }); it('updates name, palette, and layout', async () => { sql.mockResolvedValueOnce({ rows: [{ id: 4, name: 'Molten' }] }); // found frame sql.mockResolvedValueOnce({ rows: [] }); // clash check sql.mockResolvedValueOnce({ rows: [{ id: 4, name: 'Molten II' }] }); // UPDATE RETURNING const res = createRes(); await itemHandler( { method: 'PUT', query: { id: '4' }, body: { name: 'Molten II', palette: GOOD_PALETTE, layout: { art: { x: 0.02, y: 0.05, w: 0.96, h: 0.30 }, text: { x: 0.02, y: 0.50, w: 0.96, h: 0.35 } } } }, res ); expect(res.statusCode).toBe(200); expect(res.body.frame.name).toBe('Molten II'); }); it('deletes the frame', async () => { sql .mockResolvedValueOnce({ rows: [{ id: 4 }] }) // ownership .mockResolvedValueOnce({ rows: [] }); // DELETE const res = createRes(); await itemHandler({ method: 'DELETE', query: { id: '4' } }, res); expect(res.statusCode).toBe(200); expect(sql).toHaveBeenCalledTimes(2); }); });