feat: initialize wheelofnames API

This commit is contained in:
tiennm99 committed 2026-07-06 22:20:48 +07:00
1 parent 194caf17eb
commit 3f5ed700bb
41 files changed
+5031

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
import {describe, expect, test} from 'vitest';
import {loadConfig, minRenderTimeoutMs} from '../src/config.js';
describe('loadConfig', () => {
test('keeps render timeout compatible with Remotion browser timeout limits', () => {
const config = loadConfig({RENDER_TIMEOUT_MS: '500'});
expect(config.renderTimeoutMs).toBe(minRenderTimeoutMs);
});
test('requires API_TOKEN in production', () => {
expect(() => loadConfig({NODE_ENV: 'production'})).toThrow(/API_TOKEN/);
});
test('accepts API_TOKEN in production', () => {
const config = loadConfig({NODE_ENV: 'production', API_TOKEN: 'secret'});
expect(config.requiresApiToken).toBe(true);
expect(config.apiToken).toBe('secret');
});
});
+114
View File
@@ -0,0 +1,114 @@
import {describe, expect, test} from 'vitest';
import {RenderTimeoutError} from '../src/lib/render-errors.js';
import {buildServer} from '../src/server.js';
const config = {
apiToken: undefined,
host: '127.0.0.1',
maxConcurrentRenders: 1,
maxOptionChars: 40,
maxOptions: 32,
port: 0,
requiresApiToken: false,
renderTimeoutMs: 15000,
};
describe('POST /api/gif', () => {
test('returns gif bytes and winner metadata', async () => {
const app = await buildServer({
config,
renderGif: async () => ({
buffer: Buffer.from('GIF89a-test'),
byteLength: 11,
durationMs: 12,
}),
});
const response = await app.inject({
method: 'POST',
url: '/api/gif',
payload: {
options: ['alpha', 'beta', 'gamma'],
winnerIndex: 1,
},
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toBe('image/gif');
expect(response.headers['x-wheel-winner-index']).toBe('1');
expect(response.headers['x-wheel-winner']).toBe('beta');
expect(response.rawPayload.subarray(0, 6).toString()).toBe('GIF89a');
await app.close();
});
test('rejects invalid payload', async () => {
const app = await buildServer({
config,
renderGif: async () => ({
buffer: Buffer.from('GIF89a-test'),
byteLength: 11,
durationMs: 12,
}),
});
const response = await app.inject({
method: 'POST',
url: '/api/gif',
payload: {
options: ['only one'],
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe('invalid_request');
await app.close();
});
test('enforces bearer token when configured', async () => {
const app = await buildServer({
config: {...config, apiToken: 'secret'},
renderGif: async () => ({
buffer: Buffer.from('GIF89a-test'),
byteLength: 11,
durationMs: 12,
}),
});
const response = await app.inject({
method: 'POST',
url: '/api/gif',
payload: {
options: ['a', 'b'],
},
});
expect(response.statusCode).toBe(401);
await app.close();
});
test('returns timeout response when rendering exceeds configured limit', async () => {
const app = await buildServer({
config: {...config, renderTimeoutMs: 7},
renderGif: async (_request, renderOptions) => {
expect(renderOptions.timeoutInMilliseconds).toBe(7);
throw new RenderTimeoutError(renderOptions.timeoutInMilliseconds);
},
});
const response = await app.inject({
method: 'POST',
url: '/api/gif',
payload: {
options: ['alpha', 'beta'],
},
});
expect(response.statusCode).toBe(504);
expect(response.json().error).toBe('render_timeout');
await app.close();
});
});
+27
View File
@@ -0,0 +1,27 @@
import {describe, expect, test} from 'vitest';
import {
getFinalWheelRotationDegrees,
getSliceCenterDegrees,
getSliceDegrees,
} from '../src/remotion/wheel-layout.js';
describe('wheel layout', () => {
test('calculates equal slice sizes', () => {
expect(getSliceDegrees(8)).toBe(45);
});
test('calculates center angle from 3h clockwise', () => {
expect(getSliceCenterDegrees(4, 0)).toBe(45);
expect(getSliceCenterDegrees(4, 1)).toBe(135);
});
test('final rotation places winner center at 3h', () => {
const optionCount = 8;
const winnerIndex = 3;
const rotation = getFinalWheelRotationDegrees(optionCount, winnerIndex, 7);
const winnerCenter = getSliceCenterDegrees(optionCount, winnerIndex);
const screenAngle = ((winnerCenter + rotation) % 360 + 360) % 360;
expect(screenAngle).toBe(0);
});
});
+60
View File
@@ -0,0 +1,60 @@
import {describe, expect, test} from 'vitest';
import {parseWheelRequest} from '../src/schemas/wheel-request.js';
const limits = {
maxOptionChars: 12,
maxOptions: 4,
};
describe('parseWheelRequest', () => {
test('trims options and preserves provided winner', () => {
const request = parseWheelRequest(
{
options: [' alpha ', ' beta '],
winnerIndex: 1,
},
limits,
() => 0,
);
expect(request.options).toEqual(['alpha', 'beta']);
expect(request.winnerIndex).toBe(1);
expect(request.durationMs).toBe(6500);
expect(request.fps).toBe(15);
});
test('rejects blank options so winner index cannot shift after trimming', () => {
expect(() =>
parseWheelRequest(
{
options: ['', 'alpha', 'beta'],
winnerIndex: 1,
},
limits,
() => 0,
),
).toThrow();
});
test('selects winner when omitted', () => {
const request = parseWheelRequest({options: ['a', 'b']}, limits, () => 1);
expect(request.winnerIndex).toBe(1);
});
test('rejects winner outside option range', () => {
expect(() =>
parseWheelRequest(
{
options: ['a', 'b'],
winnerIndex: 2,
},
limits,
() => 0,
),
).toThrow(/winnerIndex/);
});
test('rejects too many options', () => {
expect(() => parseWheelRequest({options: ['a', 'b', 'c', 'd', 'e']}, limits, () => 0)).toThrow();
});
});