Files
tiennm99bot/renderer/test/gacha-route.test.js
T
tiennm99 d2f09ecb33 feat(gacha): add /api/gachabeta toon-shaded wish style
The beta wish renders an 8-second astrology-themed night sky in toon
shading through a perspective action camera: it dollies toward a hero
cloud, a comet lights the cloud's rim from behind and bursts through it,
the camera chases the comet as its flare builds to a starburst, and the
label appears with an S, SS, or SSS rank. It shares the /api/gacha request
contract and render slots.
2026-10-01 15:07:56 +07:00

190 lines
6.0 KiB
JavaScript

import {describe, expect, test} from 'vitest';
import {RenderTimeoutError} from '../src/lib/render-errors.js';
import {buildServer} from '../src/server.js';
/** @type {import('../src/config.js').AppConfig} */
const config = {
apiToken: undefined,
host: '127.0.0.1',
maxConcurrentRenders: 1,
maxOptionChars: 40,
maxOptions: 32,
port: 0,
requiresApiToken: false,
renderTimeoutMs: 15000,
};
const mp4 = Buffer.from('\0\0\0\x18ftypisom-test');
/**
* @param {Partial<import('../src/config.js').AppConfig>} [overrides]
* @param {Parameters<typeof buildServer>[0]} [options]
*/
const build = (overrides = {}, options = {}) =>
buildServer({
config: {...config, ...overrides},
renderGacha: async () => ({buffer: mp4, byteLength: mp4.byteLength, durationMs: 9}),
...options,
});
describe('POST /api/gacha', () => {
test('returns mp4 bytes with rarity metadata', async () => {
/** @type {unknown} */
let rendered;
const app = await build(
{},
{
renderGacha: async (request) => {
rendered = request;
return {buffer: mp4, byteLength: mp4.byteLength, durationMs: 9};
},
},
);
const response = await app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 5}});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toBe('video/mp4');
expect(response.headers['x-gacha-rarity']).toBe('5');
expect(response.rawPayload.subarray(4, 8).toString()).toBe('ftyp');
expect(rendered).toMatchObject({label: 'Pizza', rarity: 5, fps: 24, width: 640});
expect(Number.isInteger(/** @type {{seed: number}} */ (rendered).seed)).toBe(true);
await app.close();
});
test('picks a different seed per roll unless one is given', async () => {
/** @type {number[]} */
const seeds = [];
const app = await build(
{},
{
renderGacha: async (request) => {
seeds.push(request.seed);
return {buffer: mp4, byteLength: mp4.byteLength, durationMs: 9};
},
},
);
for (let index = 0; index < 3; index += 1) {
await app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 5}});
}
await app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 5, seed: 5}});
expect(new Set(seeds.slice(0, 3)).size).toBeGreaterThan(1);
expect(seeds[3]).toBe(5);
await app.close();
});
test('rejects an invalid rarity', async () => {
const app = await build();
const response = await app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 6}});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe('invalid_request');
await app.close();
});
test('rejects labels longer than the configured limit', async () => {
const app = await build({maxOptionChars: 4});
const response = await app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 3}});
expect(response.statusCode).toBe(400);
await app.close();
});
test('enforces bearer token when configured', async () => {
const app = await build({apiToken: 'secret'});
const denied = await app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 3}});
const allowed = await app.inject({
method: 'POST',
url: '/api/gacha',
headers: {authorization: 'Bearer secret'},
payload: {label: 'Pizza', rarity: 3},
});
expect(denied.statusCode).toBe(401);
expect(allowed.statusCode).toBe(200);
await app.close();
});
test('returns timeout response when rendering exceeds configured limit', async () => {
const app = await build(
{renderTimeoutMs: 7},
{
renderGacha: async (_request, renderOptions) => {
throw new RenderTimeoutError(renderOptions.timeoutInMilliseconds);
},
},
);
const response = await app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 4}});
expect(response.statusCode).toBe(504);
expect(response.json().error).toBe('render_timeout');
await app.close();
});
test('shares the render slot with the wheel route', async () => {
/** @type {() => void} */
let finishGacha = () => {};
const app = await build(
{},
{
renderGacha: () =>
new Promise((resolve) => {
finishGacha = () => resolve({buffer: mp4, byteLength: mp4.byteLength, durationMs: 9});
}),
renderGif: async () => ({buffer: Buffer.from('GIF89a'), byteLength: 6, durationMs: 1}),
},
);
const pending = app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 3}});
await new Promise((resolve) => setTimeout(resolve, 20));
const busy = await app.inject({method: 'POST', url: '/api/gif', payload: {options: ['a', 'b']}});
finishGacha();
expect(busy.statusCode).toBe(429);
expect((await pending).statusCode).toBe(200);
await app.close();
});
test('serves the beta wish on /api/gachabeta with the same contract', async () => {
/** @type {string[]} */
const calls = [];
const app = await build(
{},
{
renderGacha: async () => {
calls.push('gacha');
return {buffer: mp4, byteLength: mp4.byteLength, durationMs: 9};
},
renderGachaBeta: async (request) => {
calls.push(`beta:${request.label}:${request.rarity}`);
return {buffer: mp4, byteLength: mp4.byteLength, durationMs: 9};
},
},
);
const response = await app.inject({method: 'POST', url: '/api/gachabeta', payload: {label: 'Bún bò', rarity: 5}});
const invalid = await app.inject({method: 'POST', url: '/api/gachabeta', payload: {label: 'Bún bò', rarity: 9}});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toBe('video/mp4');
expect(response.headers['x-gacha-rarity']).toBe('5');
expect(invalid.statusCode).toBe(400);
expect(calls).toEqual(['beta:Bún bò:5']);
await app.close();
});
});