deckhearth/test/api/custom-symbols.test.js

150 lines
4.2 KiB
JavaScript
Raw Permalink Normal View History

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');
});
});