M1 서버: 통합 테스트 + 방 단위 테스트, 종료 대기·중도 이탈 자리 처리 수정
- 실제 서버 + WS 클라이언트로 테스트 문서 3절 시나리오 검증(방 만들기·링크 입장·재접속· 서버 강제 종료 후 복구·차례 위반·중복 cs·탭 교체·방장 이전·도배 제한·잘못된 메시지· Origin 검사·디스코드 콜백 state 검증·게스트 합치기) - 시계를 조작하는 방 단위 테스트: 시간 초과 자동 착수, 연결 끊김 유예, 재시작 후 마감 30초 유예, 게임 오류 격리(3회 → 일시 정지), 한 판 더 흑백 교대, 게임 중 나가기 - Bun server.stop()이 서버 쪽 WS 종료 뒤 끝나지 않는 문제: 대기 시간 1초로 제한 - 게임 중 나간 사람의 자리가 게임 종료 후 비워지지 않던 버그 수정 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
128
apps/server/src/test-utils.ts
Normal file
128
apps/server/src/test-utils.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/** Helpers for integration tests: boot a real server, act as browser clients. */
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { ServerMessage } from '@bg/shared';
|
||||
import { loadConfig } from './config';
|
||||
import { startServer, type StartOptions } from './server';
|
||||
|
||||
export const ORIGIN = 'http://localhost:5173';
|
||||
|
||||
export function tempDbPath(): string {
|
||||
const dir = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), 'bg-test-'));
|
||||
return join(dir, 'app.db');
|
||||
}
|
||||
|
||||
export function boot(dbPath = tempDbPath(), extra: Partial<StartOptions> = {}) {
|
||||
const config = loadConfig({ PORT: '0', DB_PATH: dbPath, PUBLIC_ORIGIN: ORIGIN });
|
||||
const app = startServer({ config, log: () => {}, staticDir: null, ...extra });
|
||||
const base = `http://localhost:${app.server.port}`;
|
||||
return { ...app, base, dbPath, config };
|
||||
}
|
||||
|
||||
export async function api(base: string, path: string, init: RequestInit & { cookie?: string } = {}) {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('origin', ORIGIN);
|
||||
if (init.cookie) headers.set('cookie', init.cookie);
|
||||
if (init.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
|
||||
const res = await fetch(base + path, { ...init, headers, redirect: 'manual' });
|
||||
const setCookie = res.headers.get('set-cookie');
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.clone().json();
|
||||
} catch {
|
||||
body = await res.text();
|
||||
}
|
||||
return { res, body, setCookie };
|
||||
}
|
||||
|
||||
export async function guest(base: string, nickname: string): Promise<{ cookie: string; id: string }> {
|
||||
const r = await api(base, '/api/auth/guest', { method: 'POST', body: JSON.stringify({ nickname }) });
|
||||
if (r.res.status !== 200) throw new Error(`guest failed: ${JSON.stringify(r.body)}`);
|
||||
const cookie = r.setCookie!.split(';')[0]!;
|
||||
return { cookie, id: r.body.me.id };
|
||||
}
|
||||
|
||||
export class Client {
|
||||
ws!: WebSocket;
|
||||
messages: ServerMessage[] = [];
|
||||
private waiters: { pred: (m: ServerMessage) => boolean; resolve: (m: ServerMessage) => void }[] = [];
|
||||
closed: { code: number; reason: string } | null = null;
|
||||
cs = 0;
|
||||
|
||||
constructor(
|
||||
private base: string,
|
||||
public cookie: string,
|
||||
public id: string,
|
||||
) {}
|
||||
|
||||
async connect(): Promise<this> {
|
||||
this.closed = null;
|
||||
const url = this.base.replace('http', 'ws') + '/ws';
|
||||
// Bun's WebSocket client accepts custom headers.
|
||||
this.ws = new WebSocket(url, { headers: { cookie: this.cookie, origin: ORIGIN } } as unknown as string[]);
|
||||
this.ws.onmessage = (e) => {
|
||||
const m = JSON.parse(String(e.data)) as ServerMessage;
|
||||
this.messages.push(m);
|
||||
for (const w of [...this.waiters]) {
|
||||
if (w.pred(m)) {
|
||||
this.waiters.splice(this.waiters.indexOf(w), 1);
|
||||
w.resolve(m);
|
||||
}
|
||||
}
|
||||
};
|
||||
this.ws.onclose = (e) => {
|
||||
this.closed = { code: e.code, reason: e.reason };
|
||||
};
|
||||
await this.waitFor((m) => m.t === 'welcome');
|
||||
return this;
|
||||
}
|
||||
|
||||
send(m: unknown): void {
|
||||
this.ws.send(JSON.stringify(m));
|
||||
}
|
||||
|
||||
/** Resolves with the first message (already received after `since`, or future) matching pred. */
|
||||
waitFor<T extends ServerMessage['t']>(
|
||||
pred: (m: ServerMessage) => boolean,
|
||||
timeout = 3000,
|
||||
since = 0,
|
||||
): Promise<Extract<ServerMessage, { t: T }>> {
|
||||
const existing = this.messages.slice(since).find(pred);
|
||||
if (existing) return Promise.resolve(existing as any);
|
||||
return new Promise((resolve, reject) => {
|
||||
const w = { pred, resolve: resolve as (m: ServerMessage) => void };
|
||||
this.waiters.push(w);
|
||||
setTimeout(() => {
|
||||
const i = this.waiters.indexOf(w);
|
||||
if (i >= 0) {
|
||||
this.waiters.splice(i, 1);
|
||||
reject(new Error(`timeout waiting; last: ${JSON.stringify(this.messages.slice(-3))}`));
|
||||
}
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
/** Waits for the next message of type t received after now. */
|
||||
next<T extends ServerMessage['t']>(t: T, pred: (m: Extract<ServerMessage, { t: T }>) => boolean = () => true, timeout = 3000) {
|
||||
const since = this.messages.length;
|
||||
return this.waitFor<T>((m) => m.t === t && pred(m as any), timeout, since);
|
||||
}
|
||||
|
||||
lastOf<T extends ServerMessage['t']>(t: T): Extract<ServerMessage, { t: T }> | undefined {
|
||||
for (let i = this.messages.length - 1; i >= 0; i--) if (this.messages[i]!.t === t) return this.messages[i] as any;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
act(a: unknown): number {
|
||||
const cs = ++this.cs;
|
||||
this.send({ t: 'act', cs, a });
|
||||
return cs;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
Reference in New Issue
Block a user