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:
182
apps/server/src/rooms/room.test.ts
Normal file
182
apps/server/src/rooms/room.test.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
/** Room-level tests with a controllable clock: timers, grace period, restore, error isolation. */
|
||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AnyGameDefinition } from '@bg/engine';
|
||||||
|
import type { ServerMessage } from '@bg/shared';
|
||||||
|
import { GAMES } from '@bg/games';
|
||||||
|
import { openDb } from '../db';
|
||||||
|
import { Room, type Conn, type RoomConfig, type RoomSnapshot } from './room';
|
||||||
|
import { RoomStore } from './store';
|
||||||
|
|
||||||
|
function harness(games: Record<string, AnyGameDefinition> = GAMES) {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const store = new RoomStore(db);
|
||||||
|
let now = 1_000_000;
|
||||||
|
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
|
||||||
|
const deps = {
|
||||||
|
store,
|
||||||
|
games,
|
||||||
|
users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) },
|
||||||
|
now: clock.now,
|
||||||
|
};
|
||||||
|
const config: RoomConfig = {
|
||||||
|
gameId: Object.keys(games)[0]!,
|
||||||
|
options: Object.values(games)[0]!.defaultOptions,
|
||||||
|
maxPlayers: 2,
|
||||||
|
visibility: 'private',
|
||||||
|
allowSpectators: true,
|
||||||
|
chatEnabled: true,
|
||||||
|
chatFilter: true,
|
||||||
|
graceSec: 60,
|
||||||
|
};
|
||||||
|
const room = new Room('room1', '482913', config, 'A', deps);
|
||||||
|
store.insertRoom({ id: 'room1', code: '482913', hostId: 'A', gameId: config.gameId, status: 'lobby', visibility: 'private', config, stateJson: '{}', now: now });
|
||||||
|
const conn = (userId: string) => {
|
||||||
|
const msgs: ServerMessage[] = [];
|
||||||
|
const c: Conn & { msgs: ServerMessage[] } = { userId, msgs, send: (m) => msgs.push(m), close: () => {} };
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
return { room, store, clock, deps, conn, db };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Room timers', () => {
|
||||||
|
test('8. turn timeout applies onTimeout (omok perMove autoMove)', () => {
|
||||||
|
const { room, clock, conn } = harness();
|
||||||
|
const a = conn('A');
|
||||||
|
const b = conn('B');
|
||||||
|
room.join(a, 'player');
|
||||||
|
room.join(b, 'player');
|
||||||
|
room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'perMove', perMoveSec: 10 } } });
|
||||||
|
expect(room.start('A')).toBeNull();
|
||||||
|
room.clearTimer();
|
||||||
|
expect(room.effectiveDeadline()).toBe(clock.now() + 10_000);
|
||||||
|
clock.advance(9_999);
|
||||||
|
room.onTimer();
|
||||||
|
room.clearTimer();
|
||||||
|
expect((room.game!.state as any).moves).toHaveLength(0);
|
||||||
|
clock.advance(1);
|
||||||
|
room.onTimer();
|
||||||
|
room.clearTimer();
|
||||||
|
expect((room.game!.state as any).moves).toHaveLength(1);
|
||||||
|
expect((room.game!.state as any).moves[0].auto).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disconnected player is auto-acted after the grace period, not before the turn deadline rules', () => {
|
||||||
|
const { room, clock, conn } = harness();
|
||||||
|
const a = conn('A');
|
||||||
|
const b = conn('B');
|
||||||
|
room.join(a, 'player');
|
||||||
|
room.join(b, 'player');
|
||||||
|
room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'none' } } });
|
||||||
|
room.start('A');
|
||||||
|
room.clearTimer();
|
||||||
|
// No time control: idle limit 300s. Black disconnects → grace 60s wins.
|
||||||
|
room.disconnect(a);
|
||||||
|
room.clearTimer();
|
||||||
|
expect(room.effectiveDeadline()).toBe(clock.now() + 60_000);
|
||||||
|
expect(b.msgs.some((m) => m.t === 'notice' && m.code === 'disconnected')).toBe(true);
|
||||||
|
// Reconnect before the grace period ends → deadline goes back to the idle limit.
|
||||||
|
clock.advance(30_000);
|
||||||
|
room.join(conn('A'), 'player');
|
||||||
|
room.clearTimer();
|
||||||
|
expect(room.effectiveDeadline()).toBe(1_000_000 + 300_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore postpones overdue deadlines by 30s and keeps game state', () => {
|
||||||
|
const { room, clock, conn, deps, store } = harness();
|
||||||
|
room.join(conn('A'), 'player');
|
||||||
|
room.join(conn('B'), 'player');
|
||||||
|
room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'perMove', perMoveSec: 10 } } });
|
||||||
|
room.start('A');
|
||||||
|
room.clearTimer();
|
||||||
|
room.act('A', 1, { type: 'place', x: 7, y: 7 });
|
||||||
|
room.clearTimer();
|
||||||
|
const { seq, stateJson } = store.openRooms()[0]!;
|
||||||
|
clock.advance(600_000); // server was down for 10 minutes
|
||||||
|
const restored = Room.restore('room1', '482913', seq, JSON.parse(stateJson) as RoomSnapshot, deps);
|
||||||
|
restored.clearTimer();
|
||||||
|
expect((restored.game!.state as any).moves).toHaveLength(1);
|
||||||
|
expect(restored.effectiveDeadline()).toBe(clock.now() + 30_000);
|
||||||
|
expect(restored.roomView().seats.every((s) => s && !s.connected)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Room isolation and lobby', () => {
|
||||||
|
const boom: AnyGameDefinition = {
|
||||||
|
id: 'boom',
|
||||||
|
nameKo: '폭탄',
|
||||||
|
minPlayers: 2,
|
||||||
|
maxPlayers: 2,
|
||||||
|
stateVersion: 1,
|
||||||
|
defaultOptions: {},
|
||||||
|
optionsSchema: z.object({}),
|
||||||
|
actionSchema: z.object({ type: z.literal('go') }),
|
||||||
|
setup: ({ players }) => ({ players, n: 0 }),
|
||||||
|
validate: () => ({ ok: true }),
|
||||||
|
apply: () => {
|
||||||
|
throw new Error('bug');
|
||||||
|
},
|
||||||
|
view: (s) => s,
|
||||||
|
activePlayers: (s: any) => s.players,
|
||||||
|
onTimeout: () => ({ type: 'go' }),
|
||||||
|
result: () => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('a throwing game rejects the action, keeps state, pauses after 3 errors', () => {
|
||||||
|
const { room, conn } = harness({ boom });
|
||||||
|
const a = conn('A');
|
||||||
|
room.join(a, 'player');
|
||||||
|
room.join(conn('B'), 'player');
|
||||||
|
room.start('A');
|
||||||
|
for (let i = 1; i <= 3; i++) room.act('A', i, { type: 'go' });
|
||||||
|
expect(a.msgs.filter((m) => m.t === 'reject')).toHaveLength(3);
|
||||||
|
expect(room.status).toBe('paused');
|
||||||
|
expect((room.game!.state as any).n).toBe(0);
|
||||||
|
expect(room.voidGame('A')).toBeNull();
|
||||||
|
expect(room.status).toBe('finished');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('seat rules: start needs min players, only host starts, kicked users cannot rejoin', () => {
|
||||||
|
const { room, conn } = harness();
|
||||||
|
room.join(conn('A'), 'player');
|
||||||
|
expect(room.start('A')).toBe('1명 더 필요해요.');
|
||||||
|
room.join(conn('B'), 'player');
|
||||||
|
expect(room.start('B')).toBe('방장만 시작할 수 있어요.');
|
||||||
|
room.join(conn('C'), 'player');
|
||||||
|
expect(room.spectators.has('C')).toBe(true);
|
||||||
|
expect(room.kick('A', 'C')).toBeNull();
|
||||||
|
expect(room.join(conn('C'), 'player')).toBe('방장이 내보낸 방에는 다시 들어갈 수 없어요.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rematch starts when all seated players vote; colours alternate with gameNo', () => {
|
||||||
|
const { room, conn } = harness();
|
||||||
|
room.join(conn('A'), 'player');
|
||||||
|
room.join(conn('B'), 'player');
|
||||||
|
room.configure('A', { options: { colorAssignment: 'alternate' } });
|
||||||
|
room.start('A');
|
||||||
|
room.clearTimer();
|
||||||
|
expect((room.game!.state as any).players.black).toBe('A');
|
||||||
|
room.act('B', 1, { type: 'resign' });
|
||||||
|
expect(room.status).toBe('finished');
|
||||||
|
expect(room.sessionStats).toEqual({ A: { played: 1, wins: 1 }, B: { played: 1, wins: 0 } });
|
||||||
|
room.rematch('A');
|
||||||
|
expect(room.status).toBe('finished');
|
||||||
|
room.rematch('B');
|
||||||
|
room.clearTimer();
|
||||||
|
expect(room.status).toBe('playing');
|
||||||
|
expect(room.gameNo).toBe(2);
|
||||||
|
expect((room.game!.state as any).players.black).toBe('B');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaving mid-game resigns via onLeave', () => {
|
||||||
|
const { room, conn } = harness();
|
||||||
|
room.join(conn('A'), 'player');
|
||||||
|
room.join(conn('B'), 'player');
|
||||||
|
room.start('A');
|
||||||
|
room.clearTimer();
|
||||||
|
room.leave('B');
|
||||||
|
expect(room.status).toBe('finished');
|
||||||
|
expect(room.lastResult?.ranking).toEqual([['A'], ['B']]);
|
||||||
|
expect(room.seatOf('B')).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -90,6 +90,8 @@ export class Room {
|
|||||||
presence = new Map<string, Presence>();
|
presence = new Map<string, Presence>();
|
||||||
conns = new Map<string, Conn>();
|
conns = new Map<string, Conn>();
|
||||||
kicked = new Set<string>();
|
kicked = new Set<string>();
|
||||||
|
/** Players who left during a game; their seat is freed when the game ends. */
|
||||||
|
leftDuringGame = new Set<string>();
|
||||||
gameNo = 0;
|
gameNo = 0;
|
||||||
sessionStats: Record<string, { played: number; wins: number }> = {};
|
sessionStats: Record<string, { played: number; wins: number }> = {};
|
||||||
rematchVotes = new Set<string>();
|
rematchVotes = new Set<string>();
|
||||||
@@ -314,7 +316,8 @@ export class Room {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const prev = this.presence.get(u);
|
const prev = this.presence.get(u);
|
||||||
this.presence.set(u, { connected: true, since: now, joinedAt: prev?.joinedAt ?? now });
|
this.leftDuringGame.delete(u);
|
||||||
|
this.presence.set(u, { connected: true, since: now, joinedAt: prev?.joinedAt || now });
|
||||||
this.conns.set(u, conn);
|
this.conns.set(u, conn);
|
||||||
if (wasSeated && prev && !prev.connected && this.inGame(u)) {
|
if (wasSeated && prev && !prev.connected && this.inGame(u)) {
|
||||||
this.notice('reconnected', `${this.nick(u)}님이 다시 연결됐어요.`);
|
this.notice('reconnected', `${this.nick(u)}님이 다시 연결됐어요.`);
|
||||||
@@ -358,10 +361,11 @@ export class Room {
|
|||||||
const seat = this.seatOf(userId);
|
const seat = this.seatOf(userId);
|
||||||
if (seat >= 0) {
|
if (seat >= 0) {
|
||||||
if (this.inGame(userId)) {
|
if (this.inGame(userId)) {
|
||||||
|
// Stay in the seat as "away" until the game ends so the game state stays consistent.
|
||||||
|
this.leftDuringGame.add(userId);
|
||||||
|
this.presence.set(userId, { connected: false, since: 0, joinedAt: 0 });
|
||||||
const action = this.def.onLeave?.(this.game!.state, userId);
|
const action = this.def.onLeave?.(this.game!.state, userId);
|
||||||
if (action) this.runAction(userId, action, undefined, true);
|
if (action) this.runAction(userId, action, undefined, true);
|
||||||
// Stay in the seat as "away" until the game ends so the game state stays consistent.
|
|
||||||
this.presence.set(userId, { connected: false, since: 0, joinedAt: 0 });
|
|
||||||
} else {
|
} else {
|
||||||
this.seats[seat] = null;
|
this.seats[seat] = null;
|
||||||
this.rematchVotes.delete(userId);
|
this.rematchVotes.delete(userId);
|
||||||
@@ -708,12 +712,13 @@ export class Room {
|
|||||||
// Players who left during the game lose their seat now.
|
// Players who left during the game lose their seat now.
|
||||||
for (let i = 0; i < this.seats.length; i++) {
|
for (let i = 0; i < this.seats.length; i++) {
|
||||||
const s = this.seats[i];
|
const s = this.seats[i];
|
||||||
if (s && !this.presence.get(s.userId)?.connected && this.presence.get(s.userId)?.joinedAt === 0) {
|
if (s && this.leftDuringGame.has(s.userId)) {
|
||||||
this.seats[i] = null;
|
this.seats[i] = null;
|
||||||
this.presence.delete(s.userId);
|
this.presence.delete(s.userId);
|
||||||
this.deps.onSeatChange?.(this, s.userId, false);
|
this.deps.onSeatChange?.(this, s.userId, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.leftDuringGame.clear();
|
||||||
this.persist({ actorId: null, kind: 'finish', data: { result, voided } });
|
this.persist({ actorId: null, kind: 'finish', data: { result, voided } });
|
||||||
this.broadcastState([{ type: 'gameOver' }]);
|
this.broadcastState([{ type: 'gameOver' }]);
|
||||||
for (const c of this.conns.values()) c.send({ t: 'result', seq: this.seq, result });
|
for (const c of this.conns.values()) c.send({ t: 'result', seq: this.seq, result });
|
||||||
|
|||||||
300
apps/server/src/server.test.ts
Normal file
300
apps/server/src/server.test.ts
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
/** Integration scenarios from docs/10-testing.md §3. */
|
||||||
|
import { afterEach, describe, expect, test } from 'bun:test';
|
||||||
|
import { Client, api, boot, guest, sleep } from './test-utils';
|
||||||
|
|
||||||
|
type Booted = ReturnType<typeof boot>;
|
||||||
|
const running: Booted[] = [];
|
||||||
|
const clients: Client[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const c of clients.splice(0)) {
|
||||||
|
try {
|
||||||
|
c.close();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
for (const s of running.splice(0)) {
|
||||||
|
try {
|
||||||
|
await s.stop();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function start(dbPath?: string): Booted {
|
||||||
|
const s = boot(dbPath);
|
||||||
|
running.push(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function client(s: Booted, nick: string, cookie?: { cookie: string; id: string }): Promise<Client> {
|
||||||
|
const g = cookie ?? (await guest(s.base, nick));
|
||||||
|
const c = await new Client(s.base, g.cookie, g.id).connect();
|
||||||
|
clients.push(c);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRoom(s: Booted, c: Client, gameId = 'omok'): Promise<string> {
|
||||||
|
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: c.cookie, body: JSON.stringify({ gameId }) });
|
||||||
|
expect(r.res.status).toBe(200);
|
||||||
|
return r.body.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function join(c: Client, code: string, as: 'player' | 'spectator' = 'player') {
|
||||||
|
c.send({ t: 'join', code, as });
|
||||||
|
return c.next('room');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A hosts (black), B joins; game started. */
|
||||||
|
async function twoPlayerGame(s: Booted) {
|
||||||
|
const a = await client(s, '호랑이');
|
||||||
|
const b = await client(s, '고양이');
|
||||||
|
const code = await createRoom(s, a);
|
||||||
|
await join(a, code);
|
||||||
|
await join(b, code);
|
||||||
|
a.send({ t: 'config', options: { colorAssignment: 'hostBlack', ruleSet: 'free' } });
|
||||||
|
await a.next('room', (m) => (m.room.options as any).colorAssignment === 'hostBlack');
|
||||||
|
const sa = a.next('state');
|
||||||
|
const sb = b.next('state');
|
||||||
|
a.send({ t: 'start' });
|
||||||
|
await Promise.all([sa, sb]);
|
||||||
|
return { a, b, code };
|
||||||
|
}
|
||||||
|
|
||||||
|
const place = (c: Client, x: number, y: number) => c.act({ type: 'place', x, y });
|
||||||
|
|
||||||
|
describe('M1 integration scenarios', () => {
|
||||||
|
test('1. create room, join by code, auto-seat, play omok to the end, result stored', async () => {
|
||||||
|
const s = start();
|
||||||
|
const { a, b, code } = await twoPlayerGame(s);
|
||||||
|
expect(code).toMatch(/^\d{6}$/);
|
||||||
|
const room = a.lastOf('room')!.room;
|
||||||
|
expect(room.seats.filter(Boolean).map((x) => x!.user.nickname)).toEqual(['호랑이', '고양이']);
|
||||||
|
// Black (A) plays a horizontal five, White (B) answers elsewhere.
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
let p = b.next('state', (m) => (m.view as any).turn === 'white');
|
||||||
|
place(a, 3 + i, 7);
|
||||||
|
await p;
|
||||||
|
p = a.next('state', (m) => (m.view as any).turn === 'black');
|
||||||
|
place(b, 3 + i, 0);
|
||||||
|
await p;
|
||||||
|
}
|
||||||
|
const res = a.next('result');
|
||||||
|
place(a, 7, 7);
|
||||||
|
const r = await res;
|
||||||
|
expect(r.result.ranking).toEqual([[a.id], [b.id]]);
|
||||||
|
const stats = await api(s.base, '/api/me/stats', { cookie: a.cookie });
|
||||||
|
expect(stats.body.stats).toEqual([{ gameId: 'omok', played: 1, wins: 1, draws: 0 }]);
|
||||||
|
const room2 = (await a.next('room', (m) => m.room.status === 'finished').catch(() => a.lastOf('room')!)).room;
|
||||||
|
expect(room2.lastSeed).toMatch(/^[0-9a-f]{32}$/);
|
||||||
|
expect(room2.sessionStats[a.id]).toEqual({ played: 1, wins: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('2. joining via link preview without login, then guest + auto seat', async () => {
|
||||||
|
const s = start();
|
||||||
|
const a = await client(s, '방장님');
|
||||||
|
const code = await createRoom(s, a);
|
||||||
|
await join(a, code);
|
||||||
|
const preview = await api(s.base, `/api/rooms/${code}`);
|
||||||
|
expect(preview.body).toMatchObject({ code, gameId: 'omok', host: '방장님', seated: 1, maxPlayers: 2 });
|
||||||
|
const b = await client(s, '손님');
|
||||||
|
const r = await join(b, code);
|
||||||
|
expect(r.room.seats[1]?.user.id).toBe(b.id);
|
||||||
|
// A third person becomes a spectator.
|
||||||
|
const c = await client(s, '구경꾼');
|
||||||
|
const r3 = await join(c, code);
|
||||||
|
expect(r3.room.spectators.map((u) => u.id)).toContain(c.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('3. disconnect and reconnect keeps seat and state', async () => {
|
||||||
|
const s = start();
|
||||||
|
const { a, b, code } = await twoPlayerGame(s);
|
||||||
|
const p = b.next('state', (m) => (m.view as any).moveCount === 1);
|
||||||
|
place(a, 7, 7);
|
||||||
|
await p;
|
||||||
|
const notice = a.next('notice', (m) => m.code === 'disconnected');
|
||||||
|
b.close();
|
||||||
|
await notice;
|
||||||
|
const roomMsg = await a.next('room', (m) => m.room.seats[1]?.connected === false).catch(() => a.lastOf('room')!);
|
||||||
|
expect(roomMsg.room.seats[1]?.connected).toBe(false);
|
||||||
|
const b2 = await client(s, '', { cookie: b.cookie, id: b.id });
|
||||||
|
expect(b2.lastOf('welcome')!.activeRoom).toBe(code);
|
||||||
|
b2.send({ t: 'join', code, as: 'player' });
|
||||||
|
const st = await b2.next('state');
|
||||||
|
expect((st.view as any).board[7 * 15 + 7]).toBe(1);
|
||||||
|
expect((st.view as any).turn).toBe('white');
|
||||||
|
const after = b2.lastOf('room') ?? (await b2.next('room'));
|
||||||
|
expect(after.room.seats[1]?.user.id).toBe(b.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('4. server crash (no graceful stop) → restart restores the game', async () => {
|
||||||
|
const s1 = boot();
|
||||||
|
const { a, b, code } = await twoPlayerGame(s1);
|
||||||
|
let p = b.next('state', (m) => (m.view as any).moveCount === 1);
|
||||||
|
place(a, 7, 7);
|
||||||
|
await p;
|
||||||
|
p = a.next('state', (m) => (m.view as any).moveCount === 2);
|
||||||
|
place(b, 0, 0);
|
||||||
|
await p;
|
||||||
|
// Simulate a crash: drop the listener and timers without the graceful bye/cleanup.
|
||||||
|
for (const r of s1.rooms.all()) r.clearTimer();
|
||||||
|
await Promise.race([s1.server.stop(true), sleep(300)]);
|
||||||
|
s1.db.close();
|
||||||
|
const s2 = start(s1.dbPath);
|
||||||
|
const a2 = await client(s2, '', { cookie: a.cookie, id: a.id });
|
||||||
|
const b2 = await client(s2, '', { cookie: b.cookie, id: b.id });
|
||||||
|
expect(a2.lastOf('welcome')!.activeRoom).toBe(code);
|
||||||
|
a2.send({ t: 'join', code, as: 'player' });
|
||||||
|
b2.send({ t: 'join', code, as: 'player' });
|
||||||
|
const st = await a2.next('state');
|
||||||
|
const v = st.view as any;
|
||||||
|
expect(v.moveCount).toBe(2);
|
||||||
|
expect(v.turn).toBe('black');
|
||||||
|
const p2 = b2.next('state', (m) => (m.view as any).moveCount === 3);
|
||||||
|
place(a2, 8, 8);
|
||||||
|
await p2;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('5. acting out of turn is rejected without state change', async () => {
|
||||||
|
const s = start();
|
||||||
|
const { a, b } = await twoPlayerGame(s);
|
||||||
|
const before = b.lastOf('state')!.seq;
|
||||||
|
const rej = b.next('reject');
|
||||||
|
const cs = place(b, 1, 1);
|
||||||
|
const r = await rej;
|
||||||
|
expect(r.cs).toBe(cs);
|
||||||
|
expect(r.reason).toBe('지금은 내 차례가 아니에요.');
|
||||||
|
await sleep(50);
|
||||||
|
expect(a.lastOf('state')!.seq).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('6. duplicate cs is processed once', async () => {
|
||||||
|
const s = start();
|
||||||
|
const { a, b } = await twoPlayerGame(s);
|
||||||
|
const cs = place(a, 7, 7);
|
||||||
|
a.send({ t: 'act', cs, a: { type: 'place', x: 8, y: 8 } });
|
||||||
|
await b.next('state', (m) => (m.view as any).moveCount >= 1);
|
||||||
|
await sleep(100);
|
||||||
|
const states = b.messages.filter((m) => m.t === 'state');
|
||||||
|
expect((states[states.length - 1]!.view as any).moveCount).toBe(1);
|
||||||
|
const mine = a.messages.filter((m) => m.t === 'state' && m.cs === cs);
|
||||||
|
expect(mine).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('7. second tab replaces the first', async () => {
|
||||||
|
const s = start();
|
||||||
|
const a = await client(s, '탭주인');
|
||||||
|
const code = await createRoom(s, a);
|
||||||
|
await join(a, code);
|
||||||
|
const a2 = await client(s, '', { cookie: a.cookie, id: a.id });
|
||||||
|
const bye = a.next('bye');
|
||||||
|
await join(a2, code);
|
||||||
|
expect((await bye).reason).toBe('replaced');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('9. host leaves → host transfers', async () => {
|
||||||
|
const s = start();
|
||||||
|
const a = await client(s, '첫방장');
|
||||||
|
const b = await client(s, '다음방장');
|
||||||
|
const code = await createRoom(s, a);
|
||||||
|
await join(a, code);
|
||||||
|
await join(b, code);
|
||||||
|
const r = b.next('room', (m) => m.room.hostId === b.id);
|
||||||
|
a.send({ t: 'leave' });
|
||||||
|
expect((await r).room.seats.filter(Boolean)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('10. flooding is rate limited and the server stays healthy', async () => {
|
||||||
|
const s = start();
|
||||||
|
const a = await client(s, '도배왕');
|
||||||
|
for (let i = 0; i < 100; i++) a.send({ t: 'ping', ts: i });
|
||||||
|
await sleep(200);
|
||||||
|
const pongs = a.messages.filter((m) => m.t === 'pong').length;
|
||||||
|
expect(pongs).toBeLessThanOrEqual(45);
|
||||||
|
expect(pongs).toBeGreaterThan(0);
|
||||||
|
const h = await api(s.base, '/healthz');
|
||||||
|
expect(h.res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('11. malformed / oversized / schema-invalid messages', async () => {
|
||||||
|
const s = start();
|
||||||
|
const a = await client(s, '이상한값');
|
||||||
|
a.ws.send('{not json');
|
||||||
|
await a.next('error', (m) => m.code === 'bad-json');
|
||||||
|
a.send({ t: 'act', cs: 'x' });
|
||||||
|
await a.next('error', (m) => m.code === 'bad-message');
|
||||||
|
a.ws.send('x'.repeat(20 * 1024));
|
||||||
|
for (let i = 0; i < 20 && !a.closed; i++) await sleep(25);
|
||||||
|
expect(a.closed?.code).toBe(1009);
|
||||||
|
const h = await api(s.base, '/healthz');
|
||||||
|
expect(h.res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('12a. discord login is hidden when not configured', async () => {
|
||||||
|
const s = boot(undefined, {
|
||||||
|
discordExchange: async () => ({ id: '123', username: 'disco', global_name: '디코친구', avatar: null }),
|
||||||
|
});
|
||||||
|
running.push(s);
|
||||||
|
// Discord disabled in this config → 404.
|
||||||
|
const off = await api(s.base, '/api/auth/discord/start');
|
||||||
|
expect(off.res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('origin check: foreign origin cannot open WS or POST', async () => {
|
||||||
|
const s = start();
|
||||||
|
const g = await guest(s.base, '보안확인');
|
||||||
|
const res = await fetch(s.base + '/ws', { headers: { origin: 'https://evil.example', cookie: g.cookie, upgrade: 'websocket', connection: 'Upgrade', 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==', 'sec-websocket-version': '13' } });
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
const post = await fetch(s.base + '/api/rooms', { method: 'POST', headers: { origin: 'https://evil.example', cookie: g.cookie, 'content-type': 'application/json' }, body: '{"gameId":"omok"}' });
|
||||||
|
expect(post.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nickname rules and change', async () => {
|
||||||
|
const s = start();
|
||||||
|
const bad = await api(s.base, '/api/auth/guest', { method: 'POST', body: JSON.stringify({ nickname: 'a' }) });
|
||||||
|
expect(bad.res.status).toBe(400);
|
||||||
|
const g = await guest(s.base, ' 처음 이름 ');
|
||||||
|
const me = await api(s.base, '/api/me', { cookie: g.cookie });
|
||||||
|
expect(me.body.me.nickname).toBe('처음 이름');
|
||||||
|
const ch = await api(s.base, '/api/me', { method: 'PATCH', cookie: g.cookie, body: JSON.stringify({ nickname: '바뀐이름' }) });
|
||||||
|
expect(ch.body.me.nickname).toBe('바뀐이름');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chat is filtered and spectators receive public view only', async () => {
|
||||||
|
const s = start();
|
||||||
|
const { a, code } = await twoPlayerGame(s);
|
||||||
|
const c = await client(s, '관전자');
|
||||||
|
c.send({ t: 'join', code, as: 'spectator' });
|
||||||
|
const st = await c.next('state');
|
||||||
|
expect((st.view as any).myColor).toBeNull();
|
||||||
|
const chat = c.next('chat');
|
||||||
|
a.send({ t: 'chat', text: '안녕 씨발' });
|
||||||
|
expect((await chat).text).toBe('안녕 **');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Discord login flow', () => {
|
||||||
|
test('state mismatch rejected, valid flow merges guest into new member', async () => {
|
||||||
|
const s = boot(undefined, {
|
||||||
|
discordExchange: async () => ({ id: '42', username: 'disco', global_name: '디코친구', avatar: 'abc' }),
|
||||||
|
});
|
||||||
|
running.push(s);
|
||||||
|
s.config.discord = { clientId: 'cid', clientSecret: 'sec' };
|
||||||
|
const g = await guest(s.base, '게스트였음');
|
||||||
|
const startRes = await api(s.base, '/api/auth/discord/start?next=/r/123456', { cookie: g.cookie });
|
||||||
|
expect(startRes.res.status).toBe(302);
|
||||||
|
const loc = new URL(startRes.res.headers.get('location')!);
|
||||||
|
expect(loc.host).toBe('discord.com');
|
||||||
|
const state = loc.searchParams.get('state')!;
|
||||||
|
const oauthCookie = startRes.setCookie!.split(';')[0]!;
|
||||||
|
const bad = await api(s.base, `/api/auth/discord/callback?code=x&state=wrong`, { cookie: `${g.cookie}; ${oauthCookie}` });
|
||||||
|
expect(bad.res.headers.get('location')).toBe('/?login=failed');
|
||||||
|
const good = await api(s.base, `/api/auth/discord/callback?code=x&state=${state}`, { cookie: `${g.cookie}; ${oauthCookie}` });
|
||||||
|
expect(good.res.headers.get('location')).toBe('/r/123456?linked=1');
|
||||||
|
const sid = good.setCookie!.split(/,(?=\s*\w+=)/).find((c) => c.trim().startsWith('sid='))!.split(';')[0]!.trim();
|
||||||
|
const me = await api(s.base, '/api/me', { cookie: sid });
|
||||||
|
expect(me.body.me).toMatchObject({ id: g.id, nickname: '게스트였음', kind: 'member' });
|
||||||
|
expect(me.body.me.avatar).toBe('https://cdn.discordapp.com/avatars/42/abc.png?size=128');
|
||||||
|
// Old guest session no longer valid.
|
||||||
|
const old = await api(s.base, '/api/me', { cookie: g.cookie });
|
||||||
|
expect(old.body.me).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -106,7 +106,9 @@ export function startServer(opts: StartOptions) {
|
|||||||
clearInterval(sweeper);
|
clearInterval(sweeper);
|
||||||
gateway.shutdown();
|
gateway.shutdown();
|
||||||
rooms.shutdown();
|
rooms.shutdown();
|
||||||
await server.stop(true);
|
// Bun's stop() promise can stay pending after server-initiated WS closes even though the
|
||||||
|
// listener is already closed, so bound the wait.
|
||||||
|
await Promise.race([server.stop(true), Bun.sleep(1000)]);
|
||||||
db.close();
|
db.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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