2026-08-24 15:53:06 -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-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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-24 22:13:12 -04:00
|
|
|
it('returns the user designs with resolved custom frames', async () => {
|
2026-08-24 15:53:06 -04:00
|
|
|
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);
|
2026-08-24 22:13:12 -04:00
|
|
|
expect(res.body.designs).toEqual([{ ...design, custom_frame: null }]);
|
2026-08-24 15:53:06 -04:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
2026-08-24 17:19:24 -04:00
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
});
|
2026-08-24 22:01:04 -04:00
|
|
|
|
|
|
|
|
it('targets an existing game system without an extra lookup', async () => {
|
|
|
|
|
sql
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
rows: [{ id: 12, card_id: null, name: 'Bolt Clone' }],
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({ rows: [{ id: 80 }] })
|
|
|
|
|
.mockResolvedValueOnce({ rows: [] })
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
rows: [{ id: 12, card_id: 80, name: 'Bolt Clone' }],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = createRes();
|
|
|
|
|
await handler(
|
|
|
|
|
{ method: 'POST', body: { name: 'Bolt Clone', gameTarget: 'MTG' } },
|
|
|
|
|
res
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(201);
|
|
|
|
|
// No custom_games lookup needed for a known system code
|
|
|
|
|
expect(sql).toHaveBeenCalledTimes(4);
|
|
|
|
|
const twinInsert = sql.mock.calls[1][0].join('');
|
|
|
|
|
expect(twinInsert).toContain('INSERT INTO cards');
|
|
|
|
|
expect(sql.mock.calls[1]).toContain('MTG');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('resolves custom game names for the catalog twin', async () => {
|
|
|
|
|
sql
|
|
|
|
|
.mockResolvedValueOnce({ rows: [{ name: 'Aetherfall' }] }) // game lookup
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
rows: [{ id: 13, card_id: null, name: 'Sky Rune' }],
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({ rows: [{ id: 81 }] })
|
|
|
|
|
.mockResolvedValueOnce({ rows: [] })
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
rows: [{ id: 13, card_id: 81, name: 'Sky Rune' }],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = createRes();
|
|
|
|
|
await handler(
|
|
|
|
|
{
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: { name: 'Sky Rune', gameTarget: 'custom', customGameId: 9 },
|
|
|
|
|
},
|
|
|
|
|
res
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(201);
|
|
|
|
|
expect(sql).toHaveBeenCalledTimes(5);
|
|
|
|
|
// [0] game lookup, [1] design insert, [2] catalog twin insert
|
|
|
|
|
expect(sql.mock.calls[2]).toContain('Aetherfall');
|
|
|
|
|
});
|
2026-08-24 15:53:06 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|