mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 12:28:54 +00:00
/api/gacha now renders the 6-second portrait pack-cards opening that was served on /api/gachabeta. The Remotion meteor wish, its timeline, and the beta route are removed, and the pack-cards renderer and page take the plain gacha names. BREAKING CHANGE: /api/gachabeta is gone, and /api/gacha returns a 6-second portrait video (360x640 for width 640, 480x854 for width 854) instead of the 7-second landscape one.
171 lines
5.2 KiB
JavaScript
171 lines
5.2 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('the retired beta path is gone', async () => {
|
|
const app = await build();
|
|
|
|
const response = await app.inject({method: 'POST', url: '/api/gachabeta', payload: {label: 'Pizza', rarity: 5}});
|
|
|
|
expect(response.statusCode).toBe(404);
|
|
|
|
await app.close();
|
|
});
|
|
});
|