deckhearth/test/api/custom-cards.test.js
Randall Stillwell 147041d292 feat(designer): rarity badges, flavor quotes with dividers, full-art mode
- rarity now renders as shape+color symbol anchored inside the type bar
  (circle/diamond/pentagon/star per rarity) — fixes straddling gem alignment
- new flavor_quote field: centered italic quotation with ornamental
  diamond dividers between description/actions/quote
- framed | fullart toggle: full-art bleeds artwork edge-to-edge with
  title/cost top scrim and type/text bottom scrim
- shared pickDesignFields lib so create/update routes cannot drift
- migration 1787685911000: art_mode + flavor_quote columns
2026-08-24 16:19:24 -05:00

203 lines
5.8 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-cards/index.js';
import itemHandler from '../../pages/api/custom-cards/[id].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;
}
describe('GET /api/custom-cards', () => {
beforeEach(() => {
vi.clearAllMocks();
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
sql.mockResolvedValue({ rows: [] });
});
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('returns the user designs', async () => {
const design = { id: 5, name: 'Emberwing', card_id: 77 };
sql.mockResolvedValueOnce({ rows: [design] });
const res = createRes();
await handler({ method: 'GET' }, res);
expect(res.statusCode).toBe(200);
expect(res.body.designs).toEqual([design]);
});
});
describe('POST /api/custom-cards', () => {
beforeEach(() => {
vi.clearAllMocks();
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
sql.mockResolvedValue({ rows: [] });
});
it('rejects designs without a name', async () => {
const res = createRes();
await handler(
{ method: 'POST', body: { name: ' ', rarity: 'rare' } },
res
);
expect(res.statusCode).toBe(400);
expect(sql).not.toHaveBeenCalled();
});
it('creates the design, mirrors a catalog card, and links ownership', async () => {
sql
.mockResolvedValueOnce({
rows: [{ id: 10, card_id: null, name: 'Emberwing Phoenix' }],
}) // INSERT custom_cards
.mockResolvedValueOnce({ rows: [{ id: 77 }] }) // INSERT cards twin
.mockResolvedValueOnce({ rows: [] }) // INSERT user_cards
.mockResolvedValueOnce({
rows: [{ id: 10, card_id: 77, name: 'Emberwing Phoenix' }],
}); // link card_id back onto the design
const res = createRes();
await handler(
{
method: 'POST',
body: {
name: 'Emberwing Phoenix',
mana_cost: '2RR',
rarity: 'rare',
frameId: 'classic',
description: 'A phoenix reborn.',
actions: 'Flying, haste',
},
},
res
);
expect(res.statusCode).toBe(201);
expect(res.body.design.card_id).toBe(77);
// 4 statements: insert design, insert catalog twin, own row, link back
expect(sql).toHaveBeenCalledTimes(4);
});
it('normalizes camelCase and full-art fields on create', async () => {
sql
.mockResolvedValueOnce({
rows: [{ id: 11, card_id: null, name: 'Void Mirror' }],
})
.mockResolvedValueOnce({ rows: [{ id: 78 }] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({
rows: [{ id: 11, card_id: 78, name: 'Void Mirror' }],
});
const res = createRes();
await handler(
{
method: 'POST',
body: {
name: 'Void Mirror',
artMode: 'fullart',
flavorQuote: 'What looks back is never the same twice.',
},
},
res
);
expect(res.statusCode).toBe(201);
const insertSql = sql.mock.calls[0][0].join('');
// art_mode coerced to the allowed set; flavor_quote mapped through
expect(insertSql).toContain('art_mode');
expect(insertSql).toContain('flavor_quote');
expect(sql.mock.calls[0]).toContain('fullart');
});
});
describe('/api/custom-cards/[id]', () => {
beforeEach(() => {
vi.clearAllMocks();
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
sql.mockResolvedValue({ rows: [] });
});
it('returns 404 when the design belongs to someone else', async () => {
sql.mockResolvedValueOnce({ rows: [] }); // ownership lookup misses
const res = createRes();
await itemHandler({ method: 'GET', query: { id: '12' } }, res);
expect(res.statusCode).toBe(404);
});
it('rejects invalid ids', async () => {
const res = createRes();
await itemHandler({ method: 'GET', query: { id: 'abc' } }, res);
expect(res.statusCode).toBe(400);
});
it('updates the design and keeps the catalog twin in sync', async () => {
sql
.mockResolvedValueOnce({
rows: [{ id: 10, user_id: 1, card_id: 77, name: 'Old Name' }],
}) // ownership lookup hits
.mockResolvedValueOnce({
rows: [{ id: 10, card_id: 77, name: 'New Name' }],
}) // UPDATE custom_cards
.mockResolvedValueOnce({ rows: [{ id: 77 }] }) // UPDATE cards twin
.mockResolvedValueOnce({ rows: [] }); // ensureOwnedRow (no-op if exists)
const res = createRes();
await itemHandler(
{ method: 'PUT', query: { id: '10' }, body: { name: 'New Name' } },
res
);
expect(res.statusCode).toBe(200);
expect(res.body.design.name).toBe('New Name');
// 4 statements: ownership, update design, update twin, own row
expect(sql).toHaveBeenCalledTimes(4);
});
it('deletes only the design row', async () => {
sql
.mockResolvedValueOnce({
rows: [{ id: 10, user_id: 1, card_id: 77 }],
}) // ownership lookup
.mockResolvedValueOnce({ rows: [] }); // DELETE custom_cards
const res = createRes();
await itemHandler({ method: 'DELETE', query: { id: '10' } }, res);
expect(res.statusCode).toBe(200);
expect(sql).toHaveBeenCalledTimes(2);
});
});