integration tests

This commit is contained in:
seavor 2026-04-15 22:19:59 -05:00
parent 0ff391491d
commit 4f68190a25
13 changed files with 1149 additions and 0 deletions

View file

@ -0,0 +1,136 @@
// Authentication scenarios — login success/failure, register, and activate.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { App, Data } from '@app/types';
import { store } from '@app/store';
import { connectAndHandshake } from './helpers/setup';
import {
buildResponse,
buildResponseMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture';
function makeUser(name: string): Data.ServerInfo_User {
return create(Data.ServerInfo_UserSchema, {
name,
userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered,
});
}
describe('authentication', () => {
describe('login', () => {
it('drives LOGIN → LOGGED_IN and populates user info + buddy/ignore lists', () => {
connectAndHandshake({ userName: 'alice' });
const { cmdId, value } = findLastSessionCommand(Data.Command_Login_ext);
expect(value.userName).toBe('alice');
const loginPayload = create(Data.Response_LoginSchema, {
userInfo: makeUser('alice'),
buddyList: [makeUser('bob')],
ignoreList: [makeUser('mallory')],
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_Login_ext,
value: loginPayload,
})));
const state = store.getState().server;
expect(state.status.state).toBe(App.StatusEnum.LOGGED_IN);
expect(state.status.description).toBe('Logged in.');
expect(state.user?.name).toBe('alice');
expect(Object.keys(state.buddyList)).toEqual(['bob']);
expect(Object.keys(state.ignoreList)).toEqual(['mallory']);
expect(() => findLastSessionCommand(Data.Command_ListUsers_ext)).not.toThrow();
expect(() => findLastSessionCommand(Data.Command_ListRooms_ext)).not.toThrow();
});
it('flips status to DISCONNECTED on RespWrongPassword', () => {
connectAndHandshake();
const { cmdId } = findLastSessionCommand(Data.Command_Login_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespWrongPassword,
})));
const state = store.getState().server;
expect(state.status.state).toBe(App.StatusEnum.DISCONNECTED);
expect(state.user).toBeNull();
expect(state.buddyList).toEqual({});
});
});
describe('register', () => {
const registerOptions = {
reason: App.WebSocketConnectReason.REGISTER,
host: 'localhost',
port: '4748',
userName: 'newbie',
password: 'hunter2',
email: 'newbie@example.com',
country: 'US',
realName: 'New Bie',
} as const;
it('auto-logs-in on RespRegistrationAccepted', () => {
connectAndHandshake(registerOptions as any);
const register = findLastSessionCommand(Data.Command_Register_ext);
expect(register.value.userName).toBe('newbie');
deliverMessage(buildResponseMessage(buildResponse({
cmdId: register.cmdId,
responseCode: Data.Response_ResponseCode.RespRegistrationAccepted,
})));
const login = findLastSessionCommand(Data.Command_Login_ext);
expect(login.value.userName).toBe('newbie');
expect(login.cmdId).toBeGreaterThan(register.cmdId);
});
it('parks registration in awaiting-activation on RespRegistrationAcceptedNeedsActivation', () => {
connectAndHandshake(registerOptions as any);
const register = findLastSessionCommand(Data.Command_Register_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: register.cmdId,
responseCode: Data.Response_ResponseCode.RespRegistrationAcceptedNeedsActivation,
})));
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
});
});
describe('activate', () => {
it('auto-logs-in on RespActivationAccepted', () => {
connectAndHandshake({
reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT,
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');
deliverMessage(buildResponseMessage(buildResponse({
cmdId: activate.cmdId,
responseCode: Data.Response_ResponseCode.RespActivationAccepted,
})));
const login = findLastSessionCommand(Data.Command_Login_ext);
expect(login.value.userName).toBe('alice');
});
});
});

View file

@ -0,0 +1,108 @@
// Connection-lifecycle scenarios. Exercises the full transport handshake
// 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 { store } from '@app/store';
import { PROTOCOL_VERSION } from '../../src/websocket/config';
import { getMockWebSocket, getWebClient, openMockWebSocket } from './helpers/setup';
import {
buildSessionEventMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture';
function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}) {
return {
reason: App.WebSocketConnectReason.LOGIN,
host: 'localhost',
port: '4748',
userName: overrides.userName ?? 'alice',
password: overrides.password ?? 'secret',
} as const;
}
function serverIdentification(
protocolVersion = PROTOCOL_VERSION,
serverName = 'TestServer',
serverVersion = '2.8.0'
): Uint8Array {
const payload = create(Data.Event_ServerIdentificationSchema, {
serverName,
serverVersion,
protocolVersion,
serverOptions: Data.Event_ServerIdentification_ServerOptions.NoOptions,
});
return buildSessionEventMessage(Data.Event_ServerIdentification_ext, payload);
}
describe('connection lifecycle', () => {
it('flips status through CONNECTING → CONNECTED on socket open', () => {
getWebClient().connect(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.description).toBe('Connected');
});
it('routes a matching ServerIdentification into LOGGING_IN and sends Command_Login', () => {
getWebClient().connect(loginOptions({ userName: 'alice' }));
openMockWebSocket();
deliverMessage(serverIdentification());
expect(store.getState().server.status.state).toBe(App.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());
openMockWebSocket();
deliverMessage(serverIdentification(PROTOCOL_VERSION + 1));
const mock = getMockWebSocket();
expect(mock.close).toHaveBeenCalled();
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
});
it('times out when onopen never fires within the keepalive window', () => {
getWebClient().connect(loginOptions());
const mock = getMockWebSocket();
expect(mock.close).not.toHaveBeenCalled();
vi.advanceTimersByTime(5000);
expect(mock.close).toHaveBeenCalled();
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
});
it('releases keep-alive ping loop on explicit disconnect', () => {
getWebClient().connect(loginOptions());
openMockWebSocket();
deliverMessage(serverIdentification());
const mock = getMockWebSocket();
getWebClient().disconnect();
expect(mock.close).toHaveBeenCalled();
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
});
});

View file

@ -0,0 +1,112 @@
// Helpers for inspecting outbound commands. WebSocketService calls
// `this.socket.send(bytes)` with the encoded CommandContainer; the mock
// WebSocket records those calls on its `send` vi.fn. These helpers decode
// the bytes back into a CommandContainer so tests can assert on what was
// sent and extract the `cmdId` needed to build a correlated response.
import { fromBinary, getExtension, hasExtension } from '@bufbuild/protobuf';
import type { GenExtension } from '@bufbuild/protobuf/codegenv2';
import { Data } from '@app/types';
import { getMockWebSocket } from './setup';
/** The three command scopes a CommandContainer can carry in practice. */
type SessionCmd = Data.SessionCommand;
type RoomCmd = Data.RoomCommand;
type GameCmd = Data.GameCommand;
/** Decode every CommandContainer sent through the mock socket so far. */
export function captureAllOutbound(): Data.CommandContainer[] {
const mock = getMockWebSocket();
return mock.send.mock.calls.map(([bytes]: [Uint8Array]) =>
fromBinary(Data.CommandContainerSchema, bytes)
);
}
/** Decode the most recent CommandContainer. Throws if none has been sent. */
export function captureLastOutbound(): Data.CommandContainer {
const all = captureAllOutbound();
if (all.length === 0) {
throw new Error('No outbound command has been sent through the mock WebSocket.');
}
return all[all.length - 1];
}
/** Numeric cmdId of the most recently sent command (the BigInt cast back to number). */
export function lastCmdId(): number {
return Number(captureLastOutbound().cmdId);
}
/**
* Find the most recently sent CommandContainer whose session-scope command
* carries the given extension, and return both the container and the
* unwrapped extension value. Handy for "the login() call fired grab its
* cmdId and the Command_Login payload it sent".
*/
export function findLastSessionCommand<V>(
ext: GenExtension<SessionCmd, 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 sessionCmd of container.sessionCommand ?? []) {
if (hasExtension(sessionCmd, ext)) {
return {
container,
value: getExtension(sessionCmd, ext),
cmdId: Number(container.cmdId),
};
}
}
}
throw new Error(
`No outbound session command with extension ${ext.typeName} has been sent.`
);
}
/** Room-scoped equivalent of {@link findLastSessionCommand}. */
export function findLastRoomCommand<V>(
ext: GenExtension<RoomCmd, V>
): { container: Data.CommandContainer; value: V; cmdId: number; roomId: number } {
const containers = captureAllOutbound();
for (let i = containers.length - 1; i >= 0; i--) {
const container = containers[i];
for (const roomCmd of container.roomCommand ?? []) {
if (hasExtension(roomCmd, ext)) {
return {
container,
value: getExtension(roomCmd, ext),
cmdId: Number(container.cmdId),
roomId: container.roomId ?? 0,
};
}
}
}
throw new Error(
`No outbound room command with extension ${ext.typeName} has been sent.`
);
}
/** Game-scoped equivalent of {@link findLastSessionCommand}. */
export function findLastGameCommand<V>(
ext: GenExtension<GameCmd, V>
): { container: Data.CommandContainer; value: V; cmdId: number; gameId: number } {
const containers = captureAllOutbound();
for (let i = containers.length - 1; i >= 0; i--) {
const container = containers[i];
for (const gameCmd of container.gameCommand ?? []) {
if (hasExtension(gameCmd, ext)) {
return {
container,
value: getExtension(gameCmd, ext),
cmdId: Number(container.cmdId),
gameId: container.gameId ?? 0,
};
}
}
}
throw new Error(
`No outbound game command with extension ${ext.typeName} has been sent.`
);
}

View file

@ -0,0 +1,125 @@
// Factory helpers that build encoded `ServerMessage` binaries for the four
// top-level message types the client consumes (RESPONSE, SESSION_EVENT,
// ROOM_EVENT, GAME_EVENT_CONTAINER). Tests call these to simulate incoming
// server traffic and then hand the resulting bytes to `deliverMessage()`.
//
// No mocking of `@bufbuild/protobuf` — every builder uses the real `create`/
// `setExtension`/`toBinary` path so the bytes that land in ProtobufService
// are byte-for-byte identical to what a Servatrice would send.
import { create, setExtension, toBinary } from '@bufbuild/protobuf';
import type { GenExtension, GenMessage } from '@bufbuild/protobuf/codegenv2';
import type { MessageInitShape } from '@bufbuild/protobuf';
import { Data } from '@app/types';
import { getMockWebSocket } from './setup';
/**
* Convenience wrapper around `create` for schemas that accept an init shape.
* Mirrors the pattern used throughout the webclient codebase.
*/
export function make<S extends GenMessage<any>>(
schema: S,
init?: MessageInitShape<S>
): ReturnType<typeof create<S>> {
return create(schema, init);
}
/** Build a top-level ServerMessage wrapping a Response. */
export function buildResponseMessage(response: Data.Response): Uint8Array {
const msg = create(Data.ServerMessageSchema, {
messageType: Data.ServerMessage_MessageType.RESPONSE,
response,
});
return toBinary(Data.ServerMessageSchema, msg);
}
/**
* Build a Response with an optional response-payload extension attached.
* `cmdId` must match the outbound command the test is responding to
* callers typically read it from `captureOutbound()`.
*/
export function buildResponse<V>(params: {
cmdId: number;
responseCode?: Data.Response_ResponseCode;
ext?: GenExtension<Data.Response, V>;
value?: V;
}): Data.Response {
const response = create(Data.ResponseSchema, {
cmdId: BigInt(params.cmdId),
responseCode: params.responseCode ?? Data.Response_ResponseCode.RespOk,
});
if (params.ext && params.value !== undefined) {
setExtension(response, params.ext, params.value);
}
return response;
}
/** Build a top-level ServerMessage wrapping a SessionEvent with the given extension. */
export function buildSessionEventMessage<V>(
ext: GenExtension<Data.SessionEvent, V>,
value: V
): Uint8Array {
const sessionEvent = create(Data.SessionEventSchema);
setExtension(sessionEvent, ext, value);
const msg = create(Data.ServerMessageSchema, {
messageType: Data.ServerMessage_MessageType.SESSION_EVENT,
sessionEvent,
});
return toBinary(Data.ServerMessageSchema, msg);
}
/** Build a top-level ServerMessage wrapping a RoomEvent with the given extension. */
export function buildRoomEventMessage<V>(
roomId: number,
ext: GenExtension<Data.RoomEvent, V>,
value: V
): Uint8Array {
const roomEvent = create(Data.RoomEventSchema, { roomId });
setExtension(roomEvent, ext, value);
const msg = create(Data.ServerMessageSchema, {
messageType: Data.ServerMessage_MessageType.ROOM_EVENT,
roomEvent,
});
return toBinary(Data.ServerMessageSchema, msg);
}
/**
* Build a top-level ServerMessage wrapping a GameEventContainer whose
* `eventList` contains a single GameEvent with the given extension attached.
*/
export function buildGameEventMessage<V>(
params: {
gameId: number;
playerId?: number;
ext: GenExtension<Data.GameEvent, V>;
value: V;
}
): Uint8Array {
const gameEvent = create(Data.GameEventSchema, {
playerId: params.playerId ?? -1,
});
setExtension(gameEvent, params.ext, params.value);
const container = create(Data.GameEventContainerSchema, {
gameId: params.gameId,
eventList: [gameEvent],
});
const msg = create(Data.ServerMessageSchema, {
messageType: Data.ServerMessage_MessageType.GAME_EVENT_CONTAINER,
gameEventContainer: container,
});
return toBinary(Data.ServerMessageSchema, msg);
}
/**
* Deliver an encoded ServerMessage to the currently-connected mock socket.
* WebSocketService wires `onmessage` to push events into its RxJS subject,
* which ProtobufService subscribes to so this triggers the full inbound
* pipeline synchronously.
*/
export function deliverMessage(binary: Uint8Array): void {
const mock = getMockWebSocket();
const event = { data: binary.buffer } as MessageEvent;
mock.onmessage?.(event);
}

View file

@ -0,0 +1,182 @@
// Integration test setup — installs a mock WebSocket constructor, wires up
// fake timers for KeepAliveService control, and resets the webClient + Redux
// singletons between tests so real event handlers and reducers can run
// against a clean slate each time.
//
// Only `globalThis.WebSocket` is mocked. Everything downstream of it
// (ProtobufService, event registries, persistence, store, reducers) runs as
// real code, which is the whole point of the integration suite.
import '@testing-library/jest-dom/vitest';
import '../../../src/polyfills';
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 { PROTOCOL_VERSION } from '../../../src/websocket/config';
import { createWebClientResponse, createWebClientRequest } from '@app/api';
import {
buildResponse,
buildResponseMessage,
buildSessionEventMessage,
deliverMessage,
} from './protobuf-builders';
import { findLastSessionCommand } from './command-capture';
export interface MockWebSocketInstance {
send: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
readyState: number;
binaryType: BinaryType;
url: string;
onopen: ((ev?: Event) => void) | null;
onclose: ((ev?: CloseEvent) => void) | null;
onerror: ((ev?: Event) => void) | null;
onmessage: ((ev: MessageEvent) => void) | null;
}
let currentMockInstance: MockWebSocketInstance | null = null;
export function getMockWebSocket(): MockWebSocketInstance {
if (!currentMockInstance) {
throw new Error(
'No mock WebSocket has been constructed yet. Call webClient.connect(...) before reading the mock instance.'
);
}
return currentMockInstance;
}
function makeMockInstance(url: string): MockWebSocketInstance {
return {
send: vi.fn(),
close: vi.fn(function close(this: MockWebSocketInstance) {
this.readyState = 3; // CLOSED
this.onclose?.({ code: 1000, reason: '', wasClean: true } as CloseEvent);
}),
readyState: 0, // CONNECTING
binaryType: 'arraybuffer',
url,
onopen: null,
onclose: null,
onerror: null,
onmessage: null,
};
}
function installMockWebSocket(): void {
const MockWS = vi.fn(function MockWebSocket(url: string) {
currentMockInstance = makeMockInstance(url);
return currentMockInstance;
}) as unknown as typeof WebSocket;
(MockWS as unknown as { CONNECTING: number }).CONNECTING = 0;
(MockWS as unknown as { OPEN: number }).OPEN = 1;
(MockWS as unknown as { CLOSING: number }).CLOSING = 2;
(MockWS as unknown as { CLOSED: number }).CLOSED = 3;
globalThis.WebSocket = MockWS;
}
export function openMockWebSocket(): void {
const mock = getMockWebSocket();
mock.readyState = 1; // OPEN
mock.onopen?.(new Event('open'));
}
export function getWebClient(): WebClient {
return WebClient.instance;
}
function resetAll(): void {
const client = WebClient.instance;
if (currentMockInstance && currentMockInstance.readyState === 1) {
client.disconnect();
}
client.protobuf.resetCommands();
client.options = null;
client.status = App.StatusEnum.DISCONNECTED;
ServerDispatch.clearStore();
RoomsDispatch.clearStore();
GameDispatch.clearStore();
if (currentMockInstance) {
currentMockInstance.onopen = null;
currentMockInstance.onclose = null;
currentMockInstance.onerror = null;
currentMockInstance.onmessage = null;
currentMockInstance = null;
}
(WebClient as unknown as { _instance: WebClient | null })._instance = null;
}
// ── Shared connect helpers ──────────────────────────────────────────────────
const DEFAULT_LOGIN_OPTIONS: Enriched.LoginConnectOptions = {
reason: App.WebSocketConnectReason.LOGIN,
host: 'localhost',
port: '4748',
userName: 'alice',
password: 'secret',
};
export function connectRaw(
overrides: Partial<Enriched.LoginConnectOptions> = {}
): void {
getWebClient().connect({ ...DEFAULT_LOGIN_OPTIONS, ...overrides });
openMockWebSocket();
}
export function connectAndHandshake(
overrides: Partial<Enriched.LoginConnectOptions> = {}
): void {
connectRaw(overrides);
deliverMessage(buildSessionEventMessage(
Data.Event_ServerIdentification_ext,
create(Data.Event_ServerIdentificationSchema, {
serverName: 'TestServer',
serverVersion: '2.8.0',
protocolVersion: PROTOCOL_VERSION,
})
));
}
export function connectAndLogin(userName: string = 'alice'): void {
connectAndHandshake({ userName });
const login = findLastSessionCommand(Data.Command_Login_ext);
const userInfo = create(Data.ServerInfo_UserSchema, {
name: userName,
userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered,
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId: login.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_Login_ext,
value: create(Data.Response_LoginSchema, {
userInfo,
buddyList: [],
ignoreList: [],
}),
})));
}
// ── Lifecycle hooks ─────────────────────────────────────────────────────────
installMockWebSocket();
beforeEach(() => {
vi.useFakeTimers();
new WebClient(createWebClientResponse(), createWebClientRequest());
});
afterEach(() => {
resetAll();
vi.clearAllMocks();
vi.useRealTimers();
});

View file

@ -0,0 +1,65 @@
// KeepAliveService timing scenarios — ping loop, pong correlation, timeout.
import { describe, expect, it } from 'vitest';
import { App, Data } from '@app/types';
import { store } from '@app/store';
import { connectRaw, getMockWebSocket } from './helpers/setup';
import {
buildResponse,
buildResponseMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture';
describe('keep-alive', () => {
it('sends a Command_Ping on every keepalive interval tick', () => {
connectRaw();
expect(() => findLastSessionCommand(Data.Command_Ping_ext)).toThrow();
vi.advanceTimersByTime(5000);
const first = findLastSessionCommand(Data.Command_Ping_ext);
expect(first.cmdId).toBeGreaterThan(0);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: first.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
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);
});
it('stays CONNECTED while pongs arrive before the next tick', () => {
connectRaw();
for (let i = 0; i < 3; i++) {
vi.advanceTimersByTime(5000);
const ping = findLastSessionCommand(Data.Command_Ping_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: ping.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
}
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED);
expect(getMockWebSocket().close).not.toHaveBeenCalled();
});
it('disconnects with a timeout status when a ping goes unanswered', () => {
connectRaw();
vi.advanceTimersByTime(5000);
expect(() => findLastSessionCommand(Data.Command_Ping_ext)).not.toThrow();
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED);
vi.advanceTimersByTime(5000);
expect(getMockWebSocket().close).toHaveBeenCalled();
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
});
});

View file

@ -0,0 +1,140 @@
// Room scenarios — Event_ListRooms handling, auto-join, Response_JoinRoom,
// room chat, and in-room game list updates.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { connectAndHandshake } from './helpers/setup';
import {
buildResponse,
buildResponseMessage,
buildRoomEventMessage,
buildSessionEventMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture';
function makeRoom(overrides: Partial<{
roomId: number;
name: string;
autoJoin: boolean;
}> = {}): Data.ServerInfo_Room {
return create(Data.ServerInfo_RoomSchema, {
roomId: overrides.roomId ?? 1,
name: overrides.name ?? 'Lobby',
description: 'Test room',
gameCount: 0,
playerCount: 0,
autoJoin: overrides.autoJoin ?? false,
gameList: [],
userList: [],
gametypeList: [],
});
}
describe('rooms', () => {
it('populates rooms state from Event_ListRooms', () => {
connectAndHandshake();
const listRooms = create(Data.Event_ListRoomsSchema, {
roomList: [
makeRoom({ roomId: 1, name: 'Lobby' }),
makeRoom({ roomId: 2, name: 'Legacy' }),
],
});
deliverMessage(buildSessionEventMessage(Data.Event_ListRooms_ext, listRooms));
const { rooms } = store.getState().rooms;
expect(rooms[1]?.info?.name).toBe('Lobby');
expect(rooms[2]?.info?.name).toBe('Legacy');
});
it('auto-joins rooms flagged with autoJoin and flips joinedRoomIds on Response_JoinRoom', () => {
connectAndHandshake();
const listRooms = create(Data.Event_ListRoomsSchema, {
roomList: [
makeRoom({ roomId: 1, name: 'Lobby', autoJoin: true }),
makeRoom({ roomId: 2, name: 'Legacy', autoJoin: false }),
],
});
deliverMessage(buildSessionEventMessage(Data.Event_ListRooms_ext, listRooms));
const join = findLastSessionCommand(Data.Command_JoinRoom_ext);
expect(join.value.roomId).toBe(1);
const joined = create(Data.Response_JoinRoomSchema, {
roomInfo: makeRoom({ roomId: 1, name: 'Lobby' }),
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId: join.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_JoinRoom_ext,
value: joined,
})));
expect(store.getState().rooms.joinedRoomIds[1]).toBe(true);
});
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 }) }),
})));
const say = create(Data.Event_RoomSaySchema, {
name: 'bob',
message: 'hello world',
messageType: Data.Event_RoomSay_RoomMessageType.UserMessage,
});
deliverMessage(buildRoomEventMessage(1, Data.Event_RoomSay_ext, say));
const messages = store.getState().rooms.messages[1];
expect(messages).toHaveLength(1);
expect(messages[0].message).toBe('bob: hello world');
expect(messages[0].name).toBe('bob');
});
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 }) }),
})));
const game = create(Data.ServerInfo_GameSchema, {
gameId: 42,
description: 'Test Game',
maxPlayers: 4,
playerCount: 1,
startTime: 1,
});
const listGames = create(Data.Event_ListGamesSchema, { gameList: [game] });
deliverMessage(buildRoomEventMessage(1, Data.Event_ListGames_ext, listGames));
const roomGames = store.getState().rooms.rooms[1]?.games;
expect(roomGames).toBeDefined();
expect(roomGames?.[42]?.info?.description).toBe('Test Game');
expect(roomGames?.[42]?.info?.gameId).toBe(42);
});
});

View file

@ -0,0 +1,105 @@
// Server-level session events — server message banner, shutdown schedule,
// user notifications, and connection-closed reason code mapping.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { App, Data } from '@app/types';
import { store } from '@app/store';
import { connectAndHandshake } from './helpers/setup';
import {
buildSessionEventMessage,
deliverMessage,
} from './helpers/protobuf-builders';
describe('server events', () => {
it('writes the server banner into server.info.message on Event_ServerMessage', () => {
connectAndHandshake();
deliverMessage(buildSessionEventMessage(
Data.Event_ServerMessage_ext,
create(Data.Event_ServerMessageSchema, { message: 'Welcome to TestServer!' })
));
expect(store.getState().server.info.message).toBe('Welcome to TestServer!');
});
it('stores the shutdown payload on Event_ServerShutdown', () => {
connectAndHandshake();
deliverMessage(buildSessionEventMessage(
Data.Event_ServerShutdown_ext,
create(Data.Event_ServerShutdownSchema, {
reason: 'Scheduled maintenance',
minutes: 5,
})
));
const shutdown = store.getState().server.serverShutdown;
expect(shutdown).not.toBeNull();
expect(shutdown?.reason).toBe('Scheduled maintenance');
expect(shutdown?.minutes).toBe(5);
});
it('appends a notification on Event_NotifyUser', () => {
connectAndHandshake();
deliverMessage(buildSessionEventMessage(
Data.Event_NotifyUser_ext,
create(Data.Event_NotifyUserSchema, {
type: Data.Event_NotifyUser_NotificationType.PROMOTION,
customTitle: 'You have been promoted',
customContent: 'Now a judge',
})
));
const notifications = store.getState().server.notifications;
expect(notifications).toHaveLength(1);
expect(notifications[0].customTitle).toBe('You have been promoted');
});
describe('connection closed', () => {
it('prefers reasonStr when provided', () => {
connectAndHandshake();
deliverMessage(buildSessionEventMessage(
Data.Event_ConnectionClosed_ext,
create(Data.Event_ConnectionClosedSchema, {
reason: Data.Event_ConnectionClosed_CloseReason.OTHER,
reasonStr: 'kicked by admin',
})
));
const status = store.getState().server.status;
expect(status.state).toBe(App.StatusEnum.DISCONNECTED);
expect(status.description).toBe('kicked by admin');
});
it('maps USER_LIMIT_REACHED to a capacity message', () => {
connectAndHandshake();
deliverMessage(buildSessionEventMessage(
Data.Event_ConnectionClosed_ext,
create(Data.Event_ConnectionClosedSchema, {
reason: Data.Event_ConnectionClosed_CloseReason.USER_LIMIT_REACHED,
})
));
expect(store.getState().server.status.description).toContain('maximum user capacity');
});
it('maps LOGGEDINELSEWERE to a multi-session message', () => {
connectAndHandshake();
deliverMessage(buildSessionEventMessage(
Data.Event_ConnectionClosed_ext,
create(Data.Event_ConnectionClosedSchema, {
reason: Data.Event_ConnectionClosed_CloseReason.LOGGEDINELSEWERE,
})
));
expect(store.getState().server.status.description).toContain('another location');
});
});
});

View file

@ -0,0 +1,120 @@
// User-list and social scenarios — user presence, buddy/ignore lists, DMs.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { connectAndLogin } from './helpers/setup';
import {
buildResponse,
buildResponseMessage,
buildSessionEventMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture';
function makeUser(name: string): Data.ServerInfo_User {
return create(Data.ServerInfo_UserSchema, {
name,
userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered,
});
}
describe('users', () => {
it('populates state.users from the Response_ListUsers post-login', () => {
connectAndLogin();
const listUsers = findLastSessionCommand(Data.Command_ListUsers_ext);
const users = [makeUser('alice'), makeUser('bob'), makeUser('carol')];
deliverMessage(buildResponseMessage(buildResponse({
cmdId: listUsers.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_ListUsers_ext,
value: create(Data.Response_ListUsersSchema, { userList: users }),
})));
expect(Object.keys(store.getState().server.users).sort()).toEqual(['alice', 'bob', 'carol']);
});
it('appends on Event_UserJoined and removes on Event_UserLeft', () => {
connectAndLogin();
deliverMessage(buildSessionEventMessage(
Data.Event_UserJoined_ext,
create(Data.Event_UserJoinedSchema, { userInfo: makeUser('bob') })
));
expect('bob' in store.getState().server.users).toBe(true);
deliverMessage(buildSessionEventMessage(
Data.Event_UserLeft_ext,
create(Data.Event_UserLeftSchema, { name: 'bob' })
));
expect('bob' in store.getState().server.users).toBe(false);
});
it('adds a user to buddyList on Event_AddToList with listName=buddy', () => {
connectAndLogin();
deliverMessage(buildSessionEventMessage(
Data.Event_AddToList_ext,
create(Data.Event_AddToListSchema, {
listName: 'buddy',
userInfo: makeUser('bob'),
})
));
expect('bob' in store.getState().server.buddyList).toBe(true);
expect(store.getState().server.ignoreList).toEqual({});
});
it('adds a user to ignoreList on Event_AddToList with listName=ignore', () => {
connectAndLogin();
deliverMessage(buildSessionEventMessage(
Data.Event_AddToList_ext,
create(Data.Event_AddToListSchema, {
listName: 'ignore',
userInfo: makeUser('mallory'),
})
));
expect('mallory' in store.getState().server.ignoreList).toBe(true);
expect(store.getState().server.buddyList).toEqual({});
});
it('files an incoming direct message under the sender', () => {
connectAndLogin('alice');
deliverMessage(buildSessionEventMessage(
Data.Event_UserMessage_ext,
create(Data.Event_UserMessageSchema, {
senderName: 'bob',
receiverName: 'alice',
message: 'hi alice',
})
));
const { messages } = store.getState().server;
expect(messages.bob).toHaveLength(1);
expect(messages.bob[0].message).toBe('hi alice');
});
it('files an outgoing direct message under the recipient', () => {
connectAndLogin('alice');
deliverMessage(buildSessionEventMessage(
Data.Event_UserMessage_ext,
create(Data.Event_UserMessageSchema, {
senderName: 'alice',
receiverName: 'bob',
message: 'hey bob',
})
));
const { messages } = store.getState().server;
expect(messages.bob).toHaveLength(1);
expect(messages.bob[0].message).toBe('hey bob');
});
});

View file

@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["./**/*.ts"]
}

View file

@ -10,6 +10,8 @@
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:integration:coverage": "vitest run --config vitest.integration.config.ts --coverage",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"golden": "npm run lint && npm run test",

View file

@ -19,5 +19,18 @@ export default defineConfig({
setupFiles: ['./src/setupTests.ts'],
include: ['src/**/*.spec.{ts,tsx}'],
isolate: false,
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
reportsDirectory: './coverage/testing',
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/generated/**',
'src/**/*.spec.{ts,tsx}',
'src/**/__mocks__/**',
'src/setupTests.ts',
'src/polyfills.ts',
],
},
},
});

View file

@ -0,0 +1,34 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
// Integration tests exercise the full inbound/outbound webclient pipeline
// (ProtobufService → event handlers → persistence → Redux) with only the
// browser WebSocket constructor mocked. They live in `integration/` and run
// under their own config so they can use `isolate: true` without slowing down
// the unit suite (which relies on `isolate: false` for shared vi.mock state).
export default defineConfig({
plugins: [react()],
resolve: {
tsconfigPaths: true,
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./integration/src/helpers/setup.ts'],
include: ['integration/src/**/*.spec.ts'],
isolate: false,
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
reportsDirectory: './coverage/integration',
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/generated/**',
'src/**/*.spec.{ts,tsx}',
'src/**/__mocks__/**',
'src/setupTests.ts',
'src/polyfills.ts',
],
},
},
});