diff --git a/webclient/integration/src/authentication.spec.ts b/webclient/integration/src/authentication.spec.ts new file mode 100644 index 000000000..fb852b565 --- /dev/null +++ b/webclient/integration/src/authentication.spec.ts @@ -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'); + }); + }); +}); diff --git a/webclient/integration/src/connection.spec.ts b/webclient/integration/src/connection.spec.ts new file mode 100644 index 000000000..d81c35f49 --- /dev/null +++ b/webclient/integration/src/connection.spec.ts @@ -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); + }); +}); diff --git a/webclient/integration/src/helpers/command-capture.ts b/webclient/integration/src/helpers/command-capture.ts new file mode 100644 index 000000000..0b48f4df6 --- /dev/null +++ b/webclient/integration/src/helpers/command-capture.ts @@ -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( + ext: GenExtension +): { 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( + ext: GenExtension +): { 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( + ext: GenExtension +): { 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.` + ); +} diff --git a/webclient/integration/src/helpers/protobuf-builders.ts b/webclient/integration/src/helpers/protobuf-builders.ts new file mode 100644 index 000000000..8081a2b87 --- /dev/null +++ b/webclient/integration/src/helpers/protobuf-builders.ts @@ -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>( + schema: S, + init?: MessageInitShape +): ReturnType> { + 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(params: { + cmdId: number; + responseCode?: Data.Response_ResponseCode; + ext?: GenExtension; + 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( + ext: GenExtension, + 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( + roomId: number, + ext: GenExtension, + 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( + params: { + gameId: number; + playerId?: number; + ext: GenExtension; + 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); +} diff --git a/webclient/integration/src/helpers/setup.ts b/webclient/integration/src/helpers/setup.ts new file mode 100644 index 000000000..99d351311 --- /dev/null +++ b/webclient/integration/src/helpers/setup.ts @@ -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; + close: ReturnType; + 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 = {} +): void { + getWebClient().connect({ ...DEFAULT_LOGIN_OPTIONS, ...overrides }); + openMockWebSocket(); +} + +export function connectAndHandshake( + overrides: Partial = {} +): 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(); +}); diff --git a/webclient/integration/src/keep-alive.spec.ts b/webclient/integration/src/keep-alive.spec.ts new file mode 100644 index 000000000..521d4dce9 --- /dev/null +++ b/webclient/integration/src/keep-alive.spec.ts @@ -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); + }); +}); diff --git a/webclient/integration/src/rooms.spec.ts b/webclient/integration/src/rooms.spec.ts new file mode 100644 index 000000000..7e9ed519f --- /dev/null +++ b/webclient/integration/src/rooms.spec.ts @@ -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); + }); +}); diff --git a/webclient/integration/src/server-events.spec.ts b/webclient/integration/src/server-events.spec.ts new file mode 100644 index 000000000..faa42f11a --- /dev/null +++ b/webclient/integration/src/server-events.spec.ts @@ -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'); + }); + }); +}); diff --git a/webclient/integration/src/users.spec.ts b/webclient/integration/src/users.spec.ts new file mode 100644 index 000000000..03005abba --- /dev/null +++ b/webclient/integration/src/users.spec.ts @@ -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'); + }); +}); diff --git a/webclient/integration/tsconfig.json b/webclient/integration/tsconfig.json new file mode 100644 index 000000000..8f35df166 --- /dev/null +++ b/webclient/integration/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/webclient/package.json b/webclient/package.json index 27f84e51d..2af931325 100644 --- a/webclient/package.json +++ b/webclient/package.json @@ -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", diff --git a/webclient/vite.config.ts b/webclient/vite.config.ts index 8e96ac328..86c243058 100644 --- a/webclient/vite.config.ts +++ b/webclient/vite.config.ts @@ -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', + ], + }, }, }); diff --git a/webclient/vitest.integration.config.ts b/webclient/vitest.integration.config.ts new file mode 100644 index 000000000..dfba71824 --- /dev/null +++ b/webclient/vitest.integration.config.ts @@ -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', + ], + }, + }, +});