- 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
149 lines
4.2 KiB
JavaScript
149 lines
4.2 KiB
JavaScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../../lib/object-storage.js', () => ({
|
|
put: vi.fn(),
|
|
del: vi.fn(),
|
|
}));
|
|
vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() }));
|
|
vi.mock('../../lib/permission-middleware', () => ({
|
|
getUserFromRequest: vi.fn(),
|
|
}));
|
|
vi.mock('../../lib/rate-limit.js', () => ({
|
|
checkUploadRateLimit: vi.fn(),
|
|
}));
|
|
|
|
import { put, del } from '../../lib/object-storage.js';
|
|
import { sql } from '../../lib/sql.js';
|
|
import { getUserFromRequest } from '../../lib/permission-middleware';
|
|
import { checkUploadRateLimit } from '../../lib/rate-limit.js';
|
|
import handler from '../../pages/api/custom-symbols/index.js';
|
|
|
|
function createRes() {
|
|
const res = {
|
|
statusCode: 200,
|
|
body: null,
|
|
status(code) {
|
|
res.statusCode = code;
|
|
return res;
|
|
},
|
|
json(data) {
|
|
res.body = data;
|
|
return res;
|
|
},
|
|
setHeader() {
|
|
return res;
|
|
},
|
|
};
|
|
return res;
|
|
}
|
|
|
|
/** Build a fake multipart request whose body carries the given parts. */
|
|
function multipartRequest(fields) {
|
|
const boundary = 'testboundary';
|
|
const body =
|
|
fields.map((f) => `--${boundary}\r\n${f}\r\n`).join('') + `--${boundary}--\r\n`;
|
|
return {
|
|
method: 'POST',
|
|
headers: { 'content-type': `multipart/form-data; boundary=${boundary}` },
|
|
on(event, cb) {
|
|
if (event === 'data') cb(Buffer.from(body));
|
|
if (event === 'end') cb();
|
|
},
|
|
};
|
|
}
|
|
|
|
function filePart(name, filename, type) {
|
|
return `Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\nContent-Type: ${type}\r\n\r\nBINARYDATA`;
|
|
}
|
|
|
|
function textPart(name, value) {
|
|
return `Content-Disposition: form-data; name="${name}"\r\n\r\n${value}`;
|
|
}
|
|
|
|
describe('/api/custom-symbols', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
|
|
checkUploadRateLimit.mockResolvedValue({ allowed: true, reset: Date.now() + 60000 });
|
|
sql.mockResolvedValue({ rows: [] });
|
|
put.mockResolvedValue({ url: 'https://cdn.example.com/symbols/x.png' });
|
|
});
|
|
|
|
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 symbol codes', async () => {
|
|
const req = multipartRequest([
|
|
filePart('image', 'f.png', 'image/png'),
|
|
textPart('code', 'bad code!'),
|
|
]);
|
|
const res = createRes();
|
|
|
|
await handler(req, res);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.body.error).toContain('1-10 letters');
|
|
});
|
|
|
|
it('rejects unsupported file types', async () => {
|
|
const req = multipartRequest([
|
|
filePart('image', 'f.gif', 'image/gif'),
|
|
textPart('code', 'F'),
|
|
]);
|
|
const res = createRes();
|
|
|
|
await handler(req, res);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.body.error).toContain('PNG, WebP, or SVG');
|
|
});
|
|
|
|
it('uploads a symbol and returns its URL', async () => {
|
|
sql
|
|
.mockResolvedValueOnce({ rows: [] }) // existing lookup misses
|
|
.mockResolvedValueOnce({
|
|
rows: [{ id: 7, code: 'F', image_url: 'https://cdn.example.com/symbols/x.png' }],
|
|
});
|
|
|
|
const req = multipartRequest([
|
|
filePart('image', 'fire.png', 'image/png'),
|
|
textPart('code', 'F'),
|
|
]);
|
|
const res = createRes();
|
|
|
|
await handler(req, res);
|
|
|
|
expect(res.statusCode).toBe(201);
|
|
expect(res.body.symbol.code).toBe('F');
|
|
expect(put).toHaveBeenCalledTimes(1);
|
|
expect(put.mock.calls[0][0]).toContain('symbols/1-f-');
|
|
});
|
|
|
|
it('replaces an existing symbol with the same code', async () => {
|
|
sql
|
|
.mockResolvedValueOnce({
|
|
rows: [{ id: 6, image_url: 'https://cdn.example.com/symbols/old.png' }],
|
|
}) // existing lookup hits
|
|
.mockResolvedValueOnce({
|
|
rows: [{ id: 6, code: 'F', image_url: 'https://cdn.example.com/symbols/x.png' }],
|
|
});
|
|
|
|
const req = multipartRequest([
|
|
filePart('image', 'fire.png', 'image/png'),
|
|
textPart('code', 'F'),
|
|
]);
|
|
const res = createRes();
|
|
|
|
await handler(req, res);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(del).toHaveBeenCalledWith('https://cdn.example.com/symbols/old.png');
|
|
});
|
|
});
|