mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-06-09 15:54:47 -07:00
more integration tests
This commit is contained in:
parent
4b5f66d497
commit
decebc25c7
192 changed files with 3090 additions and 1657 deletions
66
webclient/integration/src/admin.spec.ts
Normal file
66
webclient/integration/src/admin.spec.ts
Normal 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,
|
||||
})));
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
119
webclient/integration/src/deck.spec.ts
Normal file
119
webclient/integration/src/deck.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
416
webclient/integration/src/game.spec.ts
Normal file
416
webclient/integration/src/game.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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.`
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
104
webclient/integration/src/moderator.spec.ts
Normal file
104
webclient/integration/src/moderator.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
86
webclient/integration/src/password-reset.spec.ts
Normal file
86
webclient/integration/src/password-reset.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -117,4 +117,4 @@ describe('users', () => {
|
|||
expect(messages.bob).toHaveLength(1);
|
||||
expect(messages.bob[0].message).toBe('hey bob');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue