diff --git a/renderer/test/gif-route.test.js b/renderer/test/gif-route.test.js index fca2e51..9923990 100644 --- a/renderer/test/gif-route.test.js +++ b/renderer/test/gif-route.test.js @@ -89,6 +89,47 @@ describe('POST /api/gif', () => { await app.close(); }); + test('rejects concurrent renders beyond the limit and frees the slot afterward', async () => { + /** @type {(value?: void) => void} */ + let releaseRender = () => {}; + /** @type {(value?: void) => void} */ + let markRenderStarted = () => {}; + const renderStarted = new Promise((resolve) => { + markRenderStarted = resolve; + }); + const renderGate = new Promise((resolve) => { + releaseRender = resolve; + }); + + const app = await buildServer({ + config, + renderGif: async () => { + markRenderStarted(); + await renderGate; + return {buffer: Buffer.from('GIF89a-test'), byteLength: 11, durationMs: 1}; + }, + }); + + const payload = {options: ['alpha', 'beta'], winnerIndex: 0}; + const first = app.inject({method: 'POST', url: '/api/gif', payload}); + // The route acquires the semaphore before calling renderGif, so once the + // render body runs the single slot is held. + await renderStarted; + + const rejected = await app.inject({method: 'POST', url: '/api/gif', payload}); + expect(rejected.statusCode).toBe(429); + expect(rejected.json().error).toBe('too_many_renders'); + + releaseRender(); + expect((await first).statusCode).toBe(200); + + // The slot released in `finally` must let a later request through. + const afterRelease = await app.inject({method: 'POST', url: '/api/gif', payload}); + expect(afterRelease.statusCode).toBe(200); + + await app.close(); + }); + test('returns timeout response when rendering exceeds configured limit', async () => { const app = await buildServer({ config: {...config, renderTimeoutMs: 7}, diff --git a/renderer/test/winner.test.js b/renderer/test/winner.test.js new file mode 100644 index 0000000..dd5e765 --- /dev/null +++ b/renderer/test/winner.test.js @@ -0,0 +1,23 @@ +import {describe, expect, test} from 'vitest'; +import {pickWinnerIndex} from '../src/lib/winner.js'; + +describe('pickWinnerIndex', () => { + test('returns an index within range across many draws', () => { + for (let i = 0; i < 100; i += 1) { + const index = pickWinnerIndex(4); + expect(Number.isInteger(index)).toBe(true); + expect(index).toBeGreaterThanOrEqual(0); + expect(index).toBeLessThan(4); + } + }); + + test('always returns 0 for a single option', () => { + expect(pickWinnerIndex(1)).toBe(0); + }); + + test('throws RangeError for non-positive or non-integer counts', () => { + expect(() => pickWinnerIndex(0)).toThrow(RangeError); + expect(() => pickWinnerIndex(-3)).toThrow(RangeError); + expect(() => pickWinnerIndex(2.5)).toThrow(RangeError); + }); +});