YD-WEB/server/competition-api.smoke.mjs
ltx 84b7b0f893 feat(website): add competition results and service deployment
- Add the event results page with schedule, rankings, Battle bracket, athletes, records, bilingual labels, and navigation entry.
- Add the read/write competition data service, a complete Excel example, empty public state, and validation smoke tests.
- Handle Furion statusCode/data responses from the production API and retain static fallback for the public page.
- Document Docker sidecar deployment beside test_eventapi, persistent storage, private admin access, Nginx routing, and OSS asset rewriting.

Verification:
- node server/competition-api.smoke.mjs
- node scripts/competition-response-test.mjs
- node scripts/battle-layout-test.mjs
- npm run build -- --base=https://assets.example.test/static/test/
- npm run rewrite:oss -- https://assets.example.test/static/test/
- Production /api/competition/state returned HTTP 200 after the sidecar started.

Deployment note: publish the updated YD-WEB static build before public results can consume the unwrapped API response. The event remains unpublished until an administrator saves and publishes data.
2026-10-20 13:38:23 +08:00

56 lines
3.1 KiB
JavaScript

import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { get } from 'node:http';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const directory = await mkdtemp(join(tmpdir(), 'competition-api-'));
const port = 19000 + Math.floor(Math.random() * 30000);
const base = `http://127.0.0.1:${port}/api/competition`;
const server = spawn(process.execPath, ['server/competition-api.mjs'], {
cwd: fileURLToPath(new URL('..', import.meta.url)),
env: { ...process.env, COMPETITION_ADMIN_ALLOWED_IPS: '', COMPETITION_DATA_PATH: join(directory, 'state.json'), COMPETITION_PORT: String(port), COMPETITION_ALLOWED_ORIGINS: 'https://www.yuedongsports.com' },
stdio: 'ignore',
});
try {
let ready = false;
for (let attempt = 0; attempt < 40; attempt++) {
try { ready = (await fetch(`${base}/ping`)).ok; if (ready) break; } catch {}
await new Promise((resolve) => setTimeout(resolve, 100));
}
assert.ok(ready, 'API did not start');
assert.equal((await fetch(`${base}/admin`)).status, 200);
const deniedStatus = await new Promise((resolve, reject) => {
get(`${base}/admin`, { localAddress: '127.0.0.2' }, (response) => {
response.resume();
resolve(response.statusCode);
}).on('error', reject);
});
assert.equal(deniedStatus, 403, 'an unlisted IP reached the admin endpoint');
const crossOriginState = await fetch(`${base}/state`, { headers: { Origin: 'https://www.yuedongsports.com' } });
assert.equal(crossOriginState.headers.get('access-control-allow-origin'), 'https://www.yuedongsports.com');
const streamController = new AbortController();
const stream = await fetch(`${base}/stream`, { signal: streamController.signal });
const reader = stream.body.getReader();
await reader.read();
const draft = JSON.parse(await (await fetch(`${base}/state`)).text());
draft.competition.nameZh = '测试赛事';
draft.schedule.items = [{ id: 'M1', time: '09:00', nameZh: '单摇', nameEn: 'Speed', group: 'A', gender: 'MALE', venueZh: '主场', venueEn: 'Hall A' }];
assert.equal((await fetch(`${base}/admin`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(draft) })).status, 200);
const update = await Promise.race([reader.read(), new Promise((_, reject) => setTimeout(() => reject(new Error('SSE update timed out')), 2000))]);
assert.match(new TextDecoder().decode(update.value), /event: update/);
streamController.abort();
assert.equal((await (await fetch(`${base}/state`)).json()).competition.nameZh, '', 'draft leaked to public');
draft.published = true;
assert.equal((await fetch(`${base}/admin`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(draft) })).status, 200);
assert.equal((await (await fetch(`${base}/state`)).json()).competition.nameZh, '测试赛事');
assert.equal((await (await fetch(`${base}/admin`)).json()).schedule.items.length, 1);
console.log('Competition API smoke test passed');
} finally {
server.kill();
await rm(directory, { recursive: true, force: true });
}