deckhearth/test/api/custom-frames.test.js
Randall Stillwell fe1695f7ad feat(designer): custom frame editor and cost symbols
- custom_frames: per-user frames with full 9-slot palette (JSONB),
  unique names; designer frame picker lists them alongside starters,
  click to use, edit/delete via inline editor with live preview
- custom_symbols: upload cost icons (PNG/WebP/SVG, 2MB) keyed by short
  code; re-uploading a code replaces the old icon; ManaPips renders
  icon pips for {CODE} tokens with graceful text fallback
- custom_cards.custom_frame_id links designs to custom frames; API GETs
  join and nest the palette; deleting a frame falls back to starter
- migration 1787700511000
2026-08-24 21:13:12 -05:00

158 lines
4.4 KiB
JavaScript

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 } 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'])
);
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('/api/custom-frames', () => {
beforeEach(() => {
vi.clearAllMocks();
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
sql.mockResolvedValue({ 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 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', async () => {
sql
.mockResolvedValueOnce({ rows: [] }) // clash lookup misses
.mockResolvedValueOnce({
rows: [{ id: 4, name: 'Molten', palette: GOOD_PALETTE }],
});
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');
});
});
describe('/api/custom-frames/[id]', () => {
beforeEach(() => {
vi.clearAllMocks();
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
sql.mockResolvedValue({ rows: [] });
sql.json = vi.fn((v) => v);
});
it('returns 404 for another user\'s frame', async () => {
sql.mockResolvedValueOnce({ rows: [] });
const res = createRes();
await itemHandler({ method: 'GET', query: { id: '4' } }, res);
expect(res.statusCode).toBe(404);
});
it('updates name and palette', async () => {
sql
.mockResolvedValueOnce({ rows: [{ id: 4 }] }) // ownership
.mockResolvedValueOnce({ rows: [] }) // clash lookup misses
.mockResolvedValueOnce({
rows: [{ id: 4, name: 'Molten II', palette: GOOD_PALETTE }],
});
const res = createRes();
await itemHandler(
{ method: 'PUT', query: { id: '4' }, body: { name: 'Molten II', palette: GOOD_PALETTE } },
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);
});
});