more integration tests

This commit is contained in:
seavor 2026-04-16 12:40:47 -05:00
parent 4b5f66d497
commit decebc25c7
192 changed files with 3090 additions and 1657 deletions

View file

@ -0,0 +1,66 @@
// Admin command pipeline smoke test — validates that sendAdminCommand
// encodes, correlates, and persists correctly end-to-end.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { AdminCommands } from '@app/websocket';
import { connectAndLogin } from './helpers/setup';
import {
buildResponse,
buildResponseMessage,
buildSessionEventMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastAdminCommand } from './helpers/command-capture';
describe('admin commands', () => {
it('adjustMod modifies the user level bitflags on success', () => {
connectAndLogin();
// Add bob to the user list so the reducer has a target
deliverMessage(buildSessionEventMessage(
Data.Event_UserJoined_ext,
create(Data.Event_UserJoinedSchema, {
userInfo: create(Data.ServerInfo_UserSchema, {
name: 'bob',
userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered,
}),
})
));
expect(store.getState().server.users.bob).toBeDefined();
AdminCommands.adjustMod('bob', true, false);
const { cmdId, value } = findLastAdminCommand(Data.Command_AdjustMod_ext);
expect(value.userName).toBe('bob');
expect(value.shouldBeMod).toBe(true);
expect(value.shouldBeJudge).toBe(false);
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
const bobLevel = store.getState().server.users.bob.userLevel;
expect(bobLevel & Data.ServerInfo_User_UserLevelFlag.IsModerator).toBeTruthy();
});
it('shutdownServer sends command and dispatches on success', () => {
connectAndLogin();
AdminCommands.shutdownServer('Scheduled maintenance', 10);
const { cmdId, value } = findLastAdminCommand(Data.Command_ShutdownServer_ext);
expect(value.reason).toBe('Scheduled maintenance');
expect(value.minutes).toBe(10);
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
});
});

View file

@ -1,12 +1,14 @@
// Authentication scenarios — login success/failure, register, and activate.
// Authentication scenarios — login success/failure, register, activate,
// and the hashed-password (salt) login path.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { App, Data } from '@app/types';
import { Data } from '@app/types';
import { store } from '@app/store';
import { StatusEnum, WebSocketConnectReason } from '@app/websocket';
import { connectAndHandshake } from './helpers/setup';
import { connectAndHandshake, connectAndHandshakeWithSalt } from './helpers/setup';
import {
buildResponse,
buildResponseMessage,
@ -42,7 +44,7 @@ describe('authentication', () => {
})));
const state = store.getState().server;
expect(state.status.state).toBe(App.StatusEnum.LOGGED_IN);
expect(state.status.state).toBe(StatusEnum.LOGGED_IN);
expect(state.status.description).toBe('Logged in.');
expect(state.user?.name).toBe('alice');
expect(Object.keys(state.buddyList)).toEqual(['bob']);
@ -62,7 +64,7 @@ describe('authentication', () => {
})));
const state = store.getState().server;
expect(state.status.state).toBe(App.StatusEnum.DISCONNECTED);
expect(state.status.state).toBe(StatusEnum.DISCONNECTED);
expect(state.user).toBeNull();
expect(state.buddyList).toEqual({});
});
@ -70,7 +72,7 @@ describe('authentication', () => {
describe('register', () => {
const registerOptions = {
reason: App.WebSocketConnectReason.REGISTER,
reason: WebSocketConnectReason.REGISTER as const,
host: 'localhost',
port: '4748',
userName: 'newbie',
@ -78,10 +80,10 @@ describe('authentication', () => {
email: 'newbie@example.com',
country: 'US',
realName: 'New Bie',
} as const;
};
it('auto-logs-in on RespRegistrationAccepted', () => {
connectAndHandshake(registerOptions as any);
connectAndHandshake(registerOptions);
const register = findLastSessionCommand(Data.Command_Register_ext);
expect(register.value.userName).toBe('newbie');
@ -97,7 +99,7 @@ describe('authentication', () => {
});
it('parks registration in awaiting-activation on RespRegistrationAcceptedNeedsActivation', () => {
connectAndHandshake(registerOptions as any);
connectAndHandshake(registerOptions);
const register = findLastSessionCommand(Data.Command_Register_ext);
deliverMessage(buildResponseMessage(buildResponse({
@ -105,7 +107,7 @@ describe('authentication', () => {
responseCode: Data.Response_ResponseCode.RespRegistrationAcceptedNeedsActivation,
})));
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
});
});
@ -113,13 +115,13 @@ describe('authentication', () => {
describe('activate', () => {
it('auto-logs-in on RespActivationAccepted', () => {
connectAndHandshake({
reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT,
reason: WebSocketConnectReason.ACTIVATE_ACCOUNT as const,
host: 'localhost',
port: '4748',
userName: 'alice',
token: 'abc-123',
password: 'secret',
} as any);
});
const activate = findLastSessionCommand(Data.Command_Activate_ext);
expect(activate.value.userName).toBe('alice');
@ -133,4 +135,43 @@ describe('authentication', () => {
expect(login.value.userName).toBe('alice');
});
});
describe('hashed-password login (salt path)', () => {
it('requests salt then sends login with hashedPassword instead of plaintext', () => {
connectAndHandshakeWithSalt({ userName: 'alice', password: 'secret' });
// First command should be RequestPasswordSalt, not Login
const salt = findLastSessionCommand(Data.Command_RequestPasswordSalt_ext);
expect(salt.value.userName).toBe('alice');
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
// Deliver salt response
deliverMessage(buildResponseMessage(buildResponse({
cmdId: salt.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_PasswordSalt_ext,
value: create(Data.Response_PasswordSaltSchema, { passwordSalt: 'test-salt-value' }),
})));
// Now login should have been sent with hashedPassword
const login = findLastSessionCommand(Data.Command_Login_ext);
expect(login.value.userName).toBe('alice');
expect(login.value.hashedPassword).toBeTruthy();
expect(login.value.password).toBeFalsy();
// Complete login
deliverMessage(buildResponseMessage(buildResponse({
cmdId: login.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_Login_ext,
value: create(Data.Response_LoginSchema, {
userInfo: makeUser('alice'),
buddyList: [],
ignoreList: [],
}),
})));
expect(store.getState().server.status.state).toBe(StatusEnum.LOGGED_IN);
});
});
});

View file

@ -1,30 +1,44 @@
// Connection-lifecycle scenarios. Exercises the full transport handshake
// from `webClient.connect()` through `onopen`, ServerIdentification, and
// from webClient.connect() through onopen, ServerIdentification, and
// disconnect — with only the browser WebSocket constructor mocked.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { App, Data } from '@app/types';
import { Data } from '@app/types';
import { store } from '@app/store';
import { StatusEnum } from '@app/websocket';
import { PROTOCOL_VERSION } from '../../src/websocket/config';
import { getMockWebSocket, getWebClient, openMockWebSocket } from './helpers/setup';
import {
getMockWebSocket,
getWebClient,
openMockWebSocket,
setPendingOptions,
connectAndHandshake,
} from './helpers/setup';
import type { WebSocketConnectOptions } from '@app/websocket';
import { WebSocketConnectReason } from '@app/websocket';
import {
buildSessionEventMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture';
function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}) {
function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}): WebSocketConnectOptions {
return {
reason: App.WebSocketConnectReason.LOGIN,
reason: WebSocketConnectReason.LOGIN,
host: 'localhost',
port: '4748',
userName: overrides.userName ?? 'alice',
password: overrides.password ?? 'secret',
} as const;
};
}
function connectWithOptions(opts: WebSocketConnectOptions): void {
setPendingOptions(opts);
getWebClient().connect({ host: opts.host, port: opts.port });
}
function serverIdentification(
@ -43,47 +57,45 @@ function serverIdentification(
describe('connection lifecycle', () => {
it('flips status through CONNECTING → CONNECTED on socket open', () => {
getWebClient().connect(loginOptions());
connectWithOptions(loginOptions());
expect(store.getState().server.status.connectionAttemptMade).toBe(true);
openMockWebSocket();
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED);
expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED);
expect(store.getState().server.status.description).toBe('Connected');
});
it('routes a matching ServerIdentification into LOGGING_IN and sends Command_Login', () => {
getWebClient().connect(loginOptions({ userName: 'alice' }));
connectWithOptions(loginOptions({ userName: 'alice' }));
openMockWebSocket();
deliverMessage(serverIdentification());
expect(store.getState().server.status.state).toBe(App.StatusEnum.LOGGING_IN);
expect(store.getState().server.status.state).toBe(StatusEnum.LOGGING_IN);
expect(store.getState().server.info.name).toBe('TestServer');
expect(store.getState().server.info.version).toBe('2.8.0');
const { value, cmdId } = findLastSessionCommand(Data.Command_Login_ext);
expect(value.userName).toBe('alice');
expect(cmdId).toBeGreaterThan(0);
expect(getWebClient().options).toBeNull();
});
it('disconnects on protocol version mismatch without sending a login command', () => {
getWebClient().connect(loginOptions());
connectWithOptions(loginOptions());
openMockWebSocket();
deliverMessage(serverIdentification(PROTOCOL_VERSION + 1));
const mock = getMockWebSocket();
expect(mock.close).toHaveBeenCalled();
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
});
it('times out when onopen never fires within the keepalive window', () => {
getWebClient().connect(loginOptions());
connectWithOptions(loginOptions());
const mock = getMockWebSocket();
expect(mock.close).not.toHaveBeenCalled();
@ -91,11 +103,11 @@ describe('connection lifecycle', () => {
vi.advanceTimersByTime(5000);
expect(mock.close).toHaveBeenCalled();
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
});
it('releases keep-alive ping loop on explicit disconnect', () => {
getWebClient().connect(loginOptions());
connectWithOptions(loginOptions());
openMockWebSocket();
deliverMessage(serverIdentification());
@ -103,6 +115,20 @@ describe('connection lifecycle', () => {
getWebClient().disconnect();
expect(mock.close).toHaveBeenCalled();
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
});
});
it('drops pending commands and clears state on unexpected socket close', () => {
connectAndHandshake();
// A login command is now pending (sent during handshake)
expect(() => findLastSessionCommand(Data.Command_Login_ext)).not.toThrow();
// Simulate unexpected socket close
const mock = getMockWebSocket();
mock.readyState = 3;
mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent);
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
});
});

View file

@ -0,0 +1,119 @@
// Deck and replay command round-trips — validates the session command pipeline
// for deck CRUD and replay operations end-to-end.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { SessionCommands } from '@app/websocket';
import { connectAndLogin } from './helpers/setup';
import {
buildResponse,
buildResponseMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture';
describe('deck operations', () => {
it('populates backendDecks from deckList response', () => {
connectAndLogin();
SessionCommands.deckList();
const { cmdId } = findLastSessionCommand(Data.Command_DeckList_ext);
const deckFile = create(Data.ServerInfo_DeckStorage_TreeItemSchema, {
id: 1,
name: 'MyDeck.cod',
file: create(Data.ServerInfo_DeckStorage_FileSchema, { creationTime: 1000 }),
});
const root = create(Data.ServerInfo_DeckStorage_FolderSchema, {
items: [deckFile],
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_DeckList_ext,
value: create(Data.Response_DeckListSchema, { root }),
})));
const backendDecks = store.getState().server.backendDecks;
expect(backendDecks).not.toBeNull();
expect(backendDecks?.root?.items).toHaveLength(1);
expect(backendDecks?.root?.items[0]?.name).toBe('MyDeck.cod');
});
it('populates downloadedDeck from deckDownload response', () => {
connectAndLogin();
SessionCommands.deckDownload(42);
const { cmdId } = findLastSessionCommand(Data.Command_DeckDownload_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_DeckDownload_ext,
value: create(Data.Response_DeckDownloadSchema, { deck: '4 Lightning Bolt\n20 Mountain' }),
})));
const downloaded = store.getState().server.downloadedDeck;
expect(downloaded).not.toBeNull();
expect(downloaded?.deckId).toBe(42);
expect(downloaded?.deck).toContain('Lightning Bolt');
});
});
describe('replay operations', () => {
it('populates replays from replayList response', () => {
connectAndLogin();
SessionCommands.replayList();
const { cmdId } = findLastSessionCommand(Data.Command_ReplayList_ext);
const match = create(Data.ServerInfo_ReplayMatchSchema, {
gameId: 99,
gameName: 'Casual Game',
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_ReplayList_ext,
value: create(Data.Response_ReplayListSchema, { matchList: [match] }),
})));
const replays = store.getState().server.replays;
expect(replays[99]).toBeDefined();
expect(replays[99].gameName).toBe('Casual Game');
});
it('removes replay from state on replayDeleteMatch round-trip', () => {
connectAndLogin();
// First populate a replay
SessionCommands.replayList();
const list = findLastSessionCommand(Data.Command_ReplayList_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: list.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_ReplayList_ext,
value: create(Data.Response_ReplayListSchema, {
matchList: [create(Data.ServerInfo_ReplayMatchSchema, { gameId: 99, gameName: 'Old Game' })],
}),
})));
expect(store.getState().server.replays[99]).toBeDefined();
// Now delete it
SessionCommands.replayDeleteMatch(99);
const del = findLastSessionCommand(Data.Command_ReplayDeleteMatch_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: del.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().server.replays[99]).toBeUndefined();
});
});

View file

@ -0,0 +1,416 @@
// Game scenarios — game join, state initialization, card operations,
// player counters, game chat, game close, and outbound game commands.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { GameCommands, RoomCommands } from '@app/websocket';
import { connectAndHandshake, connectAndLogin } from './helpers/setup';
import {
buildResponse,
buildResponseMessage,
buildSessionEventMessage,
buildRoomEventMessage,
buildGameEventMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastGameCommand, findLastRoomCommand, findLastSessionCommand } from './helpers/command-capture';
function joinGame(gameId: number): void {
deliverMessage(buildSessionEventMessage(
Data.Event_GameJoined_ext,
create(Data.Event_GameJoinedSchema, {
gameInfo: create(Data.ServerInfo_GameSchema, {
gameId,
description: 'Test Game',
maxPlayers: 2,
playerCount: 1,
}),
playerId: 1,
hostId: 1,
spectator: false,
judge: false,
resuming: false,
})
));
}
function setupGameState(gameId: number): void {
const deckCard = create(Data.ServerInfo_CardSchema, { id: 100, name: 'Forest' });
const handCard = create(Data.ServerInfo_CardSchema, { id: 101, name: 'Lightning Bolt' });
const deckZone = create(Data.ServerInfo_ZoneSchema, {
name: 'deck',
type: Data.ServerInfo_Zone_ZoneType.HiddenZone,
cardList: [deckCard],
});
const handZone = create(Data.ServerInfo_ZoneSchema, {
name: 'hand',
type: Data.ServerInfo_Zone_ZoneType.HiddenZone,
cardList: [handCard],
});
const tableZone = create(Data.ServerInfo_ZoneSchema, {
name: 'table',
type: Data.ServerInfo_Zone_ZoneType.PublicZone,
withCoords: true,
cardList: [],
});
const player = create(Data.ServerInfo_PlayerSchema, {
properties: create(Data.ServerInfo_PlayerPropertiesSchema, {
playerId: 1,
userInfo: create(Data.ServerInfo_UserSchema, { name: 'alice' }),
}),
zoneList: [deckZone, handZone, tableZone],
counterList: [],
arrowList: [],
});
deliverMessage(buildGameEventMessage({
gameId,
playerId: -1,
ext: Data.Event_GameStateChanged_ext,
value: create(Data.Event_GameStateChangedSchema, {
playerList: [player],
gameStarted: true,
activePlayerId: 1,
activePhase: 0,
}),
}));
}
describe('game', () => {
it('initializes game state from Event_GameJoined + Event_GameStateChanged', () => {
connectAndLogin();
joinGame(42);
const game = store.getState().games.games[42];
expect(game).toBeDefined();
expect(game.info.description).toBe('Test Game');
expect(game.localPlayerId).toBe(1);
setupGameState(42);
const updated = store.getState().games.games[42];
expect(updated.started).toBe(true);
expect(updated.activePlayerId).toBe(1);
expect(updated.players[1]).toBeDefined();
expect(updated.players[1].zones.hand).toBeDefined();
expect(updated.players[1].zones.deck).toBeDefined();
expect(updated.players[1].zones.hand.order).toContain(101);
expect(updated.players[1].zones.deck.order).toContain(100);
});
it('draws cards from deck to hand on Event_DrawCards', () => {
connectAndLogin();
joinGame(42);
setupGameState(42);
const drawnCard = create(Data.ServerInfo_CardSchema, { id: 200, name: 'Mountain' });
deliverMessage(buildGameEventMessage({
gameId: 42,
playerId: 1,
ext: Data.Event_DrawCards_ext,
value: create(Data.Event_DrawCardsSchema, {
number: 1,
cards: [drawnCard],
}),
}));
const player = store.getState().games.games[42].players[1];
expect(player.zones.hand.order).toContain(200);
expect(player.zones.hand.byId[200]?.name).toBe('Mountain');
});
it('appends chat messages on Event_GameSay', () => {
connectAndLogin();
joinGame(42);
deliverMessage(buildGameEventMessage({
gameId: 42,
playerId: 1,
ext: Data.Event_GameSay_ext,
value: create(Data.Event_GameSaySchema, { message: 'good game' }),
}));
const messages = store.getState().games.games[42].messages;
expect(messages).toHaveLength(1);
expect(messages[0].message).toBe('good game');
expect(messages[0].playerId).toBe(1);
});
it('removes game from store on Event_GameClosed', () => {
connectAndLogin();
joinGame(42);
expect(store.getState().games.games[42]).toBeDefined();
deliverMessage(buildGameEventMessage({
gameId: 42,
playerId: -1,
ext: Data.Event_GameClosed_ext,
value: create(Data.Event_GameClosedSchema),
}));
expect(store.getState().games.games[42]).toBeUndefined();
});
it('sends outbound Command_GameSay with correct gameId and message', () => {
connectAndLogin();
joinGame(42);
GameCommands.gameSay(42, { message: 'hello opponent' });
const { value, cmdId } = findLastGameCommand(Data.Command_GameSay_ext);
expect(value.message).toBe('hello opponent');
expect(cmdId).toBeGreaterThan(0);
});
it('moves a card from hand to table on Event_MoveCard', () => {
connectAndLogin();
joinGame(42);
setupGameState(42);
deliverMessage(buildGameEventMessage({
gameId: 42,
playerId: 1,
ext: Data.Event_MoveCard_ext,
value: create(Data.Event_MoveCardSchema, {
cardId: 101,
cardName: 'Lightning Bolt',
startPlayerId: 1,
startZone: 'hand',
targetPlayerId: 1,
targetZone: 'table',
x: 100,
y: 200,
faceDown: false,
newCardId: 101,
}),
}));
const player = store.getState().games.games[42].players[1];
expect(player.zones.hand.order).not.toContain(101);
expect(player.zones.table.order).toContain(101);
expect(player.zones.table.byId[101]?.name).toBe('Lightning Bolt');
expect(player.zones.table.byId[101]?.x).toBe(100);
});
it('creates and updates player counters', () => {
connectAndLogin();
joinGame(42);
setupGameState(42);
const counterInfo = create(Data.ServerInfo_CounterSchema, {
id: 1,
name: 'Life',
count: 20,
radius: 1,
});
deliverMessage(buildGameEventMessage({
gameId: 42,
playerId: 1,
ext: Data.Event_CreateCounter_ext,
value: create(Data.Event_CreateCounterSchema, { counterInfo }),
}));
const player = store.getState().games.games[42].players[1];
expect(player.counters[1]).toBeDefined();
expect(player.counters[1].name).toBe('Life');
expect(player.counters[1].count).toBe(20);
deliverMessage(buildGameEventMessage({
gameId: 42,
playerId: 1,
ext: Data.Event_SetCounter_ext,
value: create(Data.Event_SetCounterSchema, { counterId: 1, value: 17 }),
}));
expect(store.getState().games.games[42].players[1].counters[1].count).toBe(17);
});
it('full lifecycle: create → join → deck select → draw → chat → discard → concede → leave', () => {
connectAndHandshake();
// ── Setup: join a room so we can create a game in it ──────────────────
deliverMessage(buildSessionEventMessage(
Data.Event_ListRooms_ext,
create(Data.Event_ListRoomsSchema, {
roomList: [create(Data.ServerInfo_RoomSchema, { roomId: 1, autoJoin: true, gameList: [], userList: [], gametypeList: [] })],
})
));
const roomJoin = findLastSessionCommand(Data.Command_JoinRoom_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: roomJoin.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_JoinRoom_ext,
value: create(Data.Response_JoinRoomSchema, {
roomInfo: create(Data.ServerInfo_RoomSchema, { roomId: 1, gameList: [], userList: [], gametypeList: [] }),
}),
})));
// ── 1. Create game ───────────────────────────────────────────────────
RoomCommands.createGame(1, { description: 'Ranked Match', maxPlayers: 2 });
const createCmd = findLastRoomCommand(Data.Command_CreateGame_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: createCmd.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
// ── 2. Join game ─────────────────────────────────────────────────────
RoomCommands.joinGame(1, { gameId: 99 });
const joinCmd = findLastRoomCommand(Data.Command_JoinGame_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: joinCmd.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().rooms.joinedGameIds[1]?.[99]).toBe(true);
// Server sends Event_GameJoined (session event)
deliverMessage(buildSessionEventMessage(
Data.Event_GameJoined_ext,
create(Data.Event_GameJoinedSchema, {
gameInfo: create(Data.ServerInfo_GameSchema, { gameId: 99, description: 'Ranked Match', maxPlayers: 2 }),
playerId: 1,
hostId: 1,
spectator: false,
judge: false,
resuming: false,
})
));
expect(store.getState().games.games[99]).toBeDefined();
// ── 3. Select deck ───────────────────────────────────────────────────
GameCommands.deckSelect(99, { deck: '4 Lightning Bolt\n20 Mountain\n4 Goblin Guide' });
const deckCmd = findLastGameCommand(Data.Command_DeckSelect_ext);
expect(deckCmd.value.deck).toContain('Lightning Bolt');
// Server responds with full game state (deck in zones)
const deckCards = [
create(Data.ServerInfo_CardSchema, { id: 1, name: 'Lightning Bolt' }),
create(Data.ServerInfo_CardSchema, { id: 2, name: 'Mountain' }),
create(Data.ServerInfo_CardSchema, { id: 3, name: 'Goblin Guide' }),
];
deliverMessage(buildGameEventMessage({
gameId: 99,
playerId: -1,
ext: Data.Event_GameStateChanged_ext,
value: create(Data.Event_GameStateChangedSchema, {
playerList: [create(Data.ServerInfo_PlayerSchema, {
properties: create(Data.ServerInfo_PlayerPropertiesSchema, {
playerId: 1,
userInfo: create(Data.ServerInfo_UserSchema, { name: 'alice' }),
}),
zoneList: [
create(Data.ServerInfo_ZoneSchema, { name: 'deck', type: Data.ServerInfo_Zone_ZoneType.HiddenZone, cardList: deckCards, cardCount: 3 }),
create(Data.ServerInfo_ZoneSchema, { name: 'hand', type: Data.ServerInfo_Zone_ZoneType.HiddenZone, cardList: [], cardCount: 0 }),
create(Data.ServerInfo_ZoneSchema, { name: 'table', type: Data.ServerInfo_Zone_ZoneType.PublicZone, withCoords: true, cardList: [], cardCount: 0 }),
create(Data.ServerInfo_ZoneSchema, { name: 'grave', type: Data.ServerInfo_Zone_ZoneType.PublicZone, cardList: [], cardCount: 0 }),
],
counterList: [],
arrowList: [],
})],
gameStarted: true,
activePlayerId: 1,
activePhase: 0,
}),
}));
const gameAfterDeck = store.getState().games.games[99];
expect(gameAfterDeck.players[1].zones.deck.order).toHaveLength(3);
expect(gameAfterDeck.players[1].zones.hand.order).toHaveLength(0);
// ── 4. Draw cards ────────────────────────────────────────────────────
deliverMessage(buildGameEventMessage({
gameId: 99,
playerId: 1,
ext: Data.Event_DrawCards_ext,
value: create(Data.Event_DrawCardsSchema, {
number: 2,
cards: [
create(Data.ServerInfo_CardSchema, { id: 1, name: 'Lightning Bolt' }),
create(Data.ServerInfo_CardSchema, { id: 2, name: 'Mountain' }),
],
}),
}));
const afterDraw = store.getState().games.games[99].players[1];
expect(afterDraw.zones.hand.order).toHaveLength(2);
expect(afterDraw.zones.hand.order).toContain(1);
expect(afterDraw.zones.hand.order).toContain(2);
expect(afterDraw.zones.deck.cardCount).toBe(1);
// ── 5. Send game message ─────────────────────────────────────────────
GameCommands.gameSay(99, { message: 'good luck!' });
const sayCmd = findLastGameCommand(Data.Command_GameSay_ext);
expect(sayCmd.value.message).toBe('good luck!');
// Server echoes the message back
deliverMessage(buildGameEventMessage({
gameId: 99,
playerId: 1,
ext: Data.Event_GameSay_ext,
value: create(Data.Event_GameSaySchema, { message: 'good luck!' }),
}));
expect(store.getState().games.games[99].messages).toHaveLength(1);
// ── 6. Discard (move card from hand to graveyard) ────────────────────
deliverMessage(buildGameEventMessage({
gameId: 99,
playerId: 1,
ext: Data.Event_MoveCard_ext,
value: create(Data.Event_MoveCardSchema, {
cardId: 1,
cardName: 'Lightning Bolt',
startPlayerId: 1,
startZone: 'hand',
targetPlayerId: 1,
targetZone: 'grave',
faceDown: false,
newCardId: 1,
}),
}));
const afterDiscard = store.getState().games.games[99].players[1];
expect(afterDiscard.zones.hand.order).not.toContain(1);
expect(afterDiscard.zones.grave.order).toContain(1);
expect(afterDiscard.zones.grave.byId[1]?.name).toBe('Lightning Bolt');
// ── 7. Concede ───────────────────────────────────────────────────────
GameCommands.concede(99);
expect(() => findLastGameCommand(Data.Command_Concede_ext)).not.toThrow();
// Server confirms concession
deliverMessage(buildGameEventMessage({
gameId: 99,
playerId: 1,
ext: Data.Event_PlayerPropertiesChanged_ext,
value: create(Data.Event_PlayerPropertiesChangedSchema, {
playerProperties: create(Data.ServerInfo_PlayerPropertiesSchema, {
playerId: 1,
conceded: true,
userInfo: create(Data.ServerInfo_UserSchema, { name: 'alice' }),
}),
}),
}));
expect(store.getState().games.games[99].players[1].properties.conceded).toBe(true);
// ── 8. Leave game ────────────────────────────────────────────────────
GameCommands.leaveGame(99);
expect(() => findLastGameCommand(Data.Command_LeaveGame_ext)).not.toThrow();
// Server confirms player left
deliverMessage(buildGameEventMessage({
gameId: 99,
playerId: 1,
ext: Data.Event_Leave_ext,
value: create(Data.Event_LeaveSchema, { reason: Data.Event_Leave_LeaveReason.USER_LEFT }),
}));
expect(store.getState().games.games[99].players[1]).toBeUndefined();
});
});

View file

@ -11,10 +11,12 @@ import { Data } from '@app/types';
import { getMockWebSocket } from './setup';
/** The three command scopes a CommandContainer can carry in practice. */
/** The command scopes a CommandContainer can carry in practice. */
type SessionCmd = Data.SessionCommand;
type RoomCmd = Data.RoomCommand;
type GameCmd = Data.GameCommand;
type AdminCmd = Data.AdminCommand;
type ModeratorCmd = Data.ModeratorCommand;
/** Decode every CommandContainer sent through the mock socket so far. */
export function captureAllOutbound(): Data.CommandContainer[] {
@ -110,3 +112,47 @@ export function findLastGameCommand<V>(
`No outbound game command with extension ${ext.typeName} has been sent.`
);
}
/** Admin-scoped equivalent of {@link findLastSessionCommand}. */
export function findLastAdminCommand<V>(
ext: GenExtension<AdminCmd, V>
): { container: Data.CommandContainer; value: V; cmdId: number } {
const containers = captureAllOutbound();
for (let i = containers.length - 1; i >= 0; i--) {
const container = containers[i];
for (const adminCmd of container.adminCommand ?? []) {
if (hasExtension(adminCmd, ext)) {
return {
container,
value: getExtension(adminCmd, ext),
cmdId: Number(container.cmdId),
};
}
}
}
throw new Error(
`No outbound admin command with extension ${ext.typeName} has been sent.`
);
}
/** Moderator-scoped equivalent of {@link findLastSessionCommand}. */
export function findLastModeratorCommand<V>(
ext: GenExtension<ModeratorCmd, V>
): { container: Data.CommandContainer; value: V; cmdId: number } {
const containers = captureAllOutbound();
for (let i = containers.length - 1; i >= 0; i--) {
const container = containers[i];
for (const modCmd of container.moderatorCommand ?? []) {
if (hasExtension(modCmd, ext)) {
return {
container,
value: getExtension(modCmd, ext),
cmdId: Number(container.cmdId),
};
}
}
}
throw new Error(
`No outbound moderator command with extension ${ext.typeName} has been sent.`
);
}

View file

@ -14,10 +14,16 @@ import { create } from '@bufbuild/protobuf';
import { afterEach, beforeEach, vi } from 'vitest';
import { ServerDispatch, RoomsDispatch, GameDispatch } from '@app/store';
import { App, Data, Enriched } from '@app/types';
import { WebClient } from '@app/websocket';
import { Data } from '@app/types';
import {
WebClient,
StatusEnum,
WebSocketConnectReason,
setPendingOptions,
} from '@app/websocket';
import type { WebSocketConnectOptions } from '@app/websocket';
import { PROTOCOL_VERSION } from '../../../src/websocket/config';
import { createWebClientResponse, createWebClientRequest } from '@app/api';
import { initWebClient } from '@app/api';
import {
buildResponse,
@ -27,6 +33,8 @@ import {
} from './protobuf-builders';
import { findLastSessionCommand } from './command-capture';
export { setPendingOptions };
export interface MockWebSocketInstance {
send: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
@ -97,8 +105,7 @@ function resetAll(): void {
}
client.protobuf.resetCommands();
client.options = null;
client.status = App.StatusEnum.DISCONNECTED;
client.status = StatusEnum.DISCONNECTED;
ServerDispatch.clearStore();
RoomsDispatch.clearStore();
@ -117,8 +124,8 @@ function resetAll(): void {
// ── Shared connect helpers ──────────────────────────────────────────────────
const DEFAULT_LOGIN_OPTIONS: Enriched.LoginConnectOptions = {
reason: App.WebSocketConnectReason.LOGIN,
const DEFAULT_LOGIN_OPTIONS: WebSocketConnectOptions = {
reason: WebSocketConnectReason.LOGIN,
host: 'localhost',
port: '4748',
userName: 'alice',
@ -126,14 +133,16 @@ const DEFAULT_LOGIN_OPTIONS: Enriched.LoginConnectOptions = {
};
export function connectRaw(
overrides: Partial<Enriched.LoginConnectOptions> = {}
overrides: Partial<WebSocketConnectOptions> = {}
): void {
getWebClient().connect({ ...DEFAULT_LOGIN_OPTIONS, ...overrides });
const opts = { ...DEFAULT_LOGIN_OPTIONS, ...overrides };
setPendingOptions(opts as WebSocketConnectOptions);
getWebClient().connect({ host: opts.host, port: opts.port });
openMockWebSocket();
}
export function connectAndHandshake(
overrides: Partial<Enriched.LoginConnectOptions> = {}
overrides: Partial<WebSocketConnectOptions> = {}
): void {
connectRaw(overrides);
deliverMessage(buildSessionEventMessage(
@ -146,6 +155,21 @@ export function connectAndHandshake(
));
}
export function connectAndHandshakeWithSalt(
overrides: Partial<WebSocketConnectOptions> = {}
): void {
connectRaw(overrides);
deliverMessage(buildSessionEventMessage(
Data.Event_ServerIdentification_ext,
create(Data.Event_ServerIdentificationSchema, {
serverName: 'TestServer',
serverVersion: '2.8.0',
protocolVersion: PROTOCOL_VERSION,
serverOptions: Data.Event_ServerIdentification_ServerOptions.SupportsPasswordHash,
})
));
}
export function connectAndLogin(userName: string = 'alice'): void {
connectAndHandshake({ userName });
@ -172,7 +196,7 @@ installMockWebSocket();
beforeEach(() => {
vi.useFakeTimers();
new WebClient(createWebClientResponse(), createWebClientRequest());
initWebClient();
});
afterEach(() => {

View file

@ -2,8 +2,9 @@
import { describe, expect, it } from 'vitest';
import { App, Data } from '@app/types';
import { Data } from '@app/types';
import { store } from '@app/store';
import { StatusEnum } from '@app/websocket';
import { connectRaw, getMockWebSocket } from './helpers/setup';
import {
@ -31,7 +32,7 @@ describe('keep-alive', () => {
vi.advanceTimersByTime(5000);
const second = findLastSessionCommand(Data.Command_Ping_ext);
expect(second.cmdId).toBeGreaterThan(first.cmdId);
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED);
expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED);
});
it('stays CONNECTED while pongs arrive before the next tick', () => {
@ -46,7 +47,7 @@ describe('keep-alive', () => {
})));
}
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED);
expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED);
expect(getMockWebSocket().close).not.toHaveBeenCalled();
});
@ -55,11 +56,11 @@ describe('keep-alive', () => {
vi.advanceTimersByTime(5000);
expect(() => findLastSessionCommand(Data.Command_Ping_ext)).not.toThrow();
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED);
expect(store.getState().server.status.state).toBe(StatusEnum.CONNECTED);
vi.advanceTimersByTime(5000);
expect(getMockWebSocket().close).toHaveBeenCalled();
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
});
});

View file

@ -0,0 +1,104 @@
// Moderator command pipeline smoke tests — validates that sendModeratorCommand
// encodes, correlates, and persists correctly end-to-end. One test per
// distinct response pattern (simple vs. extension-payload).
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { ModeratorCommands } from '@app/websocket';
import { connectAndLogin } from './helpers/setup';
import {
buildResponse,
buildResponseMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastModeratorCommand } from './helpers/command-capture';
describe('moderator commands', () => {
it('getBanHistory populates server.banHistory on success', () => {
connectAndLogin();
ModeratorCommands.getBanHistory('baduser');
const { cmdId, value } = findLastModeratorCommand(Data.Command_GetBanHistory_ext);
expect(value.userName).toBe('baduser');
const banEntry = create(Data.ServerInfo_BanSchema, {
adminId: 'admin1',
adminName: 'Admin',
banTime: '2026-01-01',
banLength: '60',
visibleReason: 'spamming',
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_BanHistory_ext,
value: create(Data.Response_BanHistorySchema, { banList: [banEntry] }),
})));
const history = store.getState().server.banHistory.baduser;
expect(history).toHaveLength(1);
expect(history[0].visibleReason).toBe('spamming');
});
it('viewLogHistory populates server.logs on success', () => {
connectAndLogin();
ModeratorCommands.viewLogHistory({ dateRange: 30 });
const { cmdId } = findLastModeratorCommand(Data.Command_ViewLogHistory_ext);
const logMsg = create(Data.ServerInfo_ChatMessageSchema, {
senderName: 'alice',
message: 'test message',
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_ViewLogHistory_ext,
value: create(Data.Response_ViewLogHistorySchema, { logMessage: [logMsg] }),
})));
const logs = store.getState().server.logs;
expect(Object.keys(logs).length).toBeGreaterThan(0);
});
it('warnUser sends command and updates state on success', () => {
connectAndLogin();
ModeratorCommands.warnUser('troublemaker', 'spamming chat');
const { cmdId, value } = findLastModeratorCommand(Data.Command_WarnUser_ext);
expect(value.userName).toBe('troublemaker');
expect(value.reason).toBe('spamming chat');
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().server.warnUser).toBe('troublemaker');
});
it('banFromServer sends command and updates state on success', () => {
connectAndLogin();
ModeratorCommands.banFromServer(60, 'baduser', undefined, 'repeated offenses', 'rule violation');
const { cmdId, value } = findLastModeratorCommand(Data.Command_BanFromServer_ext);
expect(value.userName).toBe('baduser');
expect(value.minutes).toBe(60);
expect(value.visibleReason).toBe('rule violation');
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().server.banUser).toBe('baduser');
});
});

View file

@ -0,0 +1,86 @@
// Password-reset scenarios — the 3-step forgot-password flow. Each step
// is a separate connect → handshake → command → disconnect cycle.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { StatusEnum, WebSocketConnectReason } from '@app/websocket';
import { connectAndHandshake } from './helpers/setup';
import {
buildResponse,
buildResponseMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture';
describe('password reset', () => {
it('forgotPasswordRequest sends command and disconnects on success', () => {
connectAndHandshake({
reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST as const,
host: 'localhost',
port: '4748',
userName: 'alice',
});
const req = findLastSessionCommand(Data.Command_ForgotPasswordRequest_ext);
expect(req.value.userName).toBe('alice');
deliverMessage(buildResponseMessage(buildResponse({
cmdId: req.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_ForgotPasswordRequest_ext,
value: create(Data.Response_ForgotPasswordRequestSchema, {
challengeEmail: 'a@example.com',
}),
})));
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
});
it('forgotPasswordChallenge sends command with userName and email', () => {
connectAndHandshake({
reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE as const,
host: 'localhost',
port: '4748',
userName: 'alice',
email: 'alice@example.com',
});
const challenge = findLastSessionCommand(Data.Command_ForgotPasswordChallenge_ext);
expect(challenge.value.userName).toBe('alice');
expect(challenge.value.email).toBe('alice@example.com');
deliverMessage(buildResponseMessage(buildResponse({
cmdId: challenge.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
});
it('forgotPasswordReset sends command with userName, token, and newPassword', () => {
connectAndHandshake({
reason: WebSocketConnectReason.PASSWORD_RESET as const,
host: 'localhost',
port: '4748',
userName: 'alice',
token: 'reset-token-123',
newPassword: 'new-secret',
});
const reset = findLastSessionCommand(Data.Command_ForgotPasswordReset_ext);
expect(reset.value.userName).toBe('alice');
expect(reset.value.token).toBe('reset-token-123');
expect(reset.value.newPassword).toBe('new-secret');
deliverMessage(buildResponseMessage(buildResponse({
cmdId: reset.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().server.status.state).toBe(StatusEnum.DISCONNECTED);
});
});

View file

@ -1,11 +1,12 @@
// Room scenarios — Event_ListRooms handling, auto-join, Response_JoinRoom,
// room chat, and in-room game list updates.
// room chat (inbound + outbound), game list updates, and leaveRoom.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { RoomCommands } from '@app/websocket';
import { connectAndHandshake } from './helpers/setup';
import {
@ -15,7 +16,8 @@ import {
buildSessionEventMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture';
import { findLastSessionCommand, findLastRoomCommand, captureAllOutbound } from './helpers/command-capture';
import { fromBinary, hasExtension, getExtension } from '@bufbuild/protobuf';
function makeRoom(overrides: Partial<{
roomId: number;
@ -35,6 +37,21 @@ function makeRoom(overrides: Partial<{
});
}
/** Deliver Event_ListRooms then join a single auto-join room, returning the roomId. */
function setupJoinedRoom(roomId = 1): void {
deliverMessage(buildSessionEventMessage(
Data.Event_ListRooms_ext,
create(Data.Event_ListRoomsSchema, { roomList: [makeRoom({ roomId, autoJoin: true })] })
));
const join = findLastSessionCommand(Data.Command_JoinRoom_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: join.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_JoinRoom_ext,
value: create(Data.Response_JoinRoomSchema, { roomInfo: makeRoom({ roomId }) }),
})));
}
describe('rooms', () => {
it('populates rooms state from Event_ListRooms', () => {
connectAndHandshake();
@ -81,18 +98,7 @@ describe('rooms', () => {
it('appends a room chat message on Event_RoomSay', () => {
connectAndHandshake();
deliverMessage(buildSessionEventMessage(
Data.Event_ListRooms_ext,
create(Data.Event_ListRoomsSchema, { roomList: [makeRoom({ roomId: 1, autoJoin: true })] })
));
const join = findLastSessionCommand(Data.Command_JoinRoom_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: join.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_JoinRoom_ext,
value: create(Data.Response_JoinRoomSchema, { roomInfo: makeRoom({ roomId: 1 }) }),
})));
setupJoinedRoom(1);
const say = create(Data.Event_RoomSaySchema, {
name: 'bob',
@ -109,18 +115,7 @@ describe('rooms', () => {
it('updates the game list on Event_ListGames', () => {
connectAndHandshake();
deliverMessage(buildSessionEventMessage(
Data.Event_ListRooms_ext,
create(Data.Event_ListRoomsSchema, { roomList: [makeRoom({ roomId: 1, autoJoin: true })] })
));
const join = findLastSessionCommand(Data.Command_JoinRoom_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: join.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_JoinRoom_ext,
value: create(Data.Response_JoinRoomSchema, { roomInfo: makeRoom({ roomId: 1 }) }),
})));
setupJoinedRoom(1);
const game = create(Data.ServerInfo_GameSchema, {
gameId: 42,
@ -137,4 +132,102 @@ describe('rooms', () => {
expect(roomGames?.[42]?.info?.description).toBe('Test Game');
expect(roomGames?.[42]?.info?.gameId).toBe(42);
});
});
it('auto-join filters correctly across multiple rooms', () => {
connectAndHandshake();
deliverMessage(buildSessionEventMessage(
Data.Event_ListRooms_ext,
create(Data.Event_ListRoomsSchema, {
roomList: [
makeRoom({ roomId: 1, name: 'Lobby', autoJoin: true }),
makeRoom({ roomId: 2, name: 'Legacy', autoJoin: false }),
makeRoom({ roomId: 3, name: 'Modern', autoJoin: true }),
],
})
));
// Count outbound JoinRoom commands
const containers = captureAllOutbound();
const joinCommands: number[] = [];
for (const container of containers) {
for (const cmd of container.sessionCommand ?? []) {
if (hasExtension(cmd, Data.Command_JoinRoom_ext)) {
joinCommands.push(getExtension(cmd, Data.Command_JoinRoom_ext).roomId);
}
}
}
expect(joinCommands).toHaveLength(2);
expect(joinCommands).toContain(1);
expect(joinCommands).toContain(3);
expect(joinCommands).not.toContain(2);
});
it('sends outbound Command_RoomSay with trimmed message', () => {
connectAndHandshake();
setupJoinedRoom(1);
RoomCommands.roomSay(1, ' hello ');
const { value } = findLastRoomCommand(Data.Command_RoomSay_ext);
expect(value.message).toBe('hello');
});
it('removes room from joinedRoomIds on leaveRoom round-trip', () => {
connectAndHandshake();
setupJoinedRoom(1);
expect(store.getState().rooms.joinedRoomIds[1]).toBe(true);
RoomCommands.leaveRoom(1);
const leave = findLastRoomCommand(Data.Command_LeaveRoom_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: leave.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().rooms.joinedRoomIds[1]).toBeUndefined();
});
it('tracks user join and leave within a room', () => {
connectAndHandshake();
setupJoinedRoom(1);
deliverMessage(buildRoomEventMessage(1, Data.Event_JoinRoom_ext, create(Data.Event_JoinRoomSchema, {
userInfo: create(Data.ServerInfo_UserSchema, { name: 'bob' }),
})));
expect(store.getState().rooms.rooms[1]?.users?.bob).toBeDefined();
deliverMessage(buildRoomEventMessage(1, Data.Event_LeaveRoom_ext, create(Data.Event_LeaveRoomSchema, {
name: 'bob',
})));
expect(store.getState().rooms.rooms[1]?.users?.bob).toBeUndefined();
});
it('tracks game creation and join within a room', () => {
connectAndHandshake();
setupJoinedRoom(1);
RoomCommands.createGame(1, { description: 'Casual', maxPlayers: 2 });
const create_ = findLastRoomCommand(Data.Command_CreateGame_ext);
expect(create_.value.description).toBe('Casual');
deliverMessage(buildResponseMessage(buildResponse({
cmdId: create_.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
RoomCommands.joinGame(1, { gameId: 99 });
const join = findLastRoomCommand(Data.Command_JoinGame_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: join.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().rooms.joinedGameIds[1]?.[99]).toBe(true);
});
});

View file

@ -4,8 +4,9 @@
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { App, Data } from '@app/types';
import { Data } from '@app/types';
import { store } from '@app/store';
import { StatusEnum } from '@app/websocket';
import { connectAndHandshake } from './helpers/setup';
import {
@ -72,7 +73,7 @@ describe('server events', () => {
));
const status = store.getState().server.status;
expect(status.state).toBe(App.StatusEnum.DISCONNECTED);
expect(status.state).toBe(StatusEnum.DISCONNECTED);
expect(status.description).toBe('kicked by admin');
});

View file

@ -117,4 +117,4 @@ describe('users', () => {
expect(messages.bob).toHaveLength(1);
expect(messages.bob[0].message).toBe('hey bob');
});
});
});