2026-08-24 22:13:12 -04:00
|
|
|
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';
|
2026-09-01 10:23:16 -04:00
|
|
|
import { validatePalette, PALETTE_SLOTS, validateLayout } from '../../lib/frame-palette.js';
|
2026-08-24 22:13:12 -04:00
|
|
|
|
|
|
|
|
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'])
|
|
|
|
|
);
|
|
|
|
|
|
2026-09-01 10:23:16 -04:00
|
|
|
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 },
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-24 22:13:12 -04:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-01 10:23:16 -04:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-24 22:13:12 -04:00
|
|
|
describe('/api/custom-frames', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
|
2026-09-01 10:40:04 -04:00
|
|
|
sql.mockReturnValue({ rows: [] });
|
2026-08-24 22:13:12 -04:00
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-01 10:23:16 -04:00
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-24 22:13:12 -04:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-01 10:23:16 -04:00
|
|
|
it('creates a frame with the normalized palette and default layout', async () => {
|
2026-08-24 22:13:12 -04:00
|
|
|
sql
|
|
|
|
|
.mockResolvedValueOnce({ rows: [] }) // clash lookup misses
|
|
|
|
|
.mockResolvedValueOnce({
|
2026-09-01 10:23:16 -04:00
|
|
|
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 } } }],
|
2026-08-24 22:13:12 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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');
|
2026-09-01 10:23:16 -04:00
|
|
|
expect(res.body.frame.layout).toBeDefined();
|
2026-08-24 22:13:12 -04:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('/api/custom-frames/[id]', () => {
|
|
|
|
|
beforeEach(() => {
|
2026-09-01 10:40:04 -04:00
|
|
|
vi.resetModules();
|
2026-08-24 22:13:12 -04:00
|
|
|
vi.clearAllMocks();
|
|
|
|
|
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
|
|
|
|
|
sql.mockResolvedValue({ rows: [] });
|
2026-09-01 10:40:04 -04:00
|
|
|
sql.json.mockImplementation((v) => v);
|
2026-08-24 22:13:12 -04:00
|
|
|
});
|
|
|
|
|
|
2026-09-01 10:40:04 -04:00
|
|
|
it('returns 404 when no frame exists', async () => {
|
2026-08-24 22:13:12 -04:00
|
|
|
sql.mockResolvedValueOnce({ rows: [] });
|
|
|
|
|
const res = createRes();
|
|
|
|
|
|
2026-09-01 10:40:04 -04:00
|
|
|
await itemHandler({ method: 'GET', query: { id: '999' } }, res);
|
2026-08-24 22:13:12 -04:00
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-01 10:23:16 -04:00
|
|
|
it('updates name, palette, and layout', async () => {
|
2026-09-01 10:40:04 -04:00
|
|
|
sql.mockResolvedValueOnce({ rows: [{ id: 4, name: 'Molten' }] }); // found frame
|
|
|
|
|
sql.mockResolvedValueOnce({ rows: [] }); // clash check
|
|
|
|
|
sql.mockResolvedValueOnce({ rows: [{ id: 4, name: 'Molten II' }] }); // UPDATE RETURNING
|
2026-08-24 22:13:12 -04:00
|
|
|
const res = createRes();
|
|
|
|
|
await itemHandler(
|
2026-09-01 10:40:04 -04:00
|
|
|
{ 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 } } } },
|
2026-08-24 22:13:12 -04:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|