From fea21b50573e4f0142f51175cf5be18c55ac3fc4 Mon Sep 17 00:00:00 2001 From: seavor Date: Thu, 16 Apr 2026 12:45:47 -0500 Subject: [PATCH] fix unit tests and refactor types --- webclient/src/api/config.ts | 1 - webclient/src/api/connectionState.ts | 13 -- webclient/src/api/initWebClient.ts | 90 -------- .../api/request/AuthenticationRequestImpl.ts | 63 +++--- .../src/store/rooms/rooms.selectors.spec.ts | 55 ++++- .../src/store/server/server.selectors.spec.ts | 84 +++++++- webclient/src/types/enriched.ts | 92 ++------ webclient/src/types/server.ts | 12 +- webclient/src/websocket/WebClient.spec.ts | 13 +- webclient/src/websocket/WebClient.ts | 4 +- .../src/websocket/__mocks__/WebClient.ts | 185 ++++++++++++++++ .../__mocks__/sessionCommandMocks.ts | 2 +- .../commands/admin/adminCommands.spec.ts | 57 ++--- .../commands/game/gameCommands.spec.ts | 12 +- .../moderator/moderatorCommands.spec.ts | 25 +-- .../commands/room/roomCommands.spec.ts | 18 +- .../websocket/commands/session/activate.ts | 4 +- .../src/websocket/commands/session/connect.ts | 2 +- .../session/forgotPasswordChallenge.ts | 4 +- .../commands/session/forgotPasswordRequest.ts | 4 +- .../commands/session/forgotPasswordReset.ts | 4 +- .../src/websocket/commands/session/login.ts | 4 +- .../websocket/commands/session/register.ts | 4 +- .../commands/session/requestPasswordSalt.ts | 4 +- .../session/sessionCommands-complex.spec.ts | 10 +- .../session/sessionCommands-simple.spec.ts | 8 +- .../commands/session/updateStatus.ts | 2 +- webclient/src/websocket/config.ts | 2 + .../src/websocket/events/game/attachCard.ts | 2 +- .../events/game/changeZoneProperties.ts | 2 +- .../src/websocket/events/game/createArrow.ts | 2 +- .../websocket/events/game/createCounter.ts | 2 +- .../src/websocket/events/game/createToken.ts | 2 +- .../src/websocket/events/game/delCounter.ts | 2 +- .../src/websocket/events/game/deleteArrow.ts | 2 +- .../src/websocket/events/game/destroyCard.ts | 2 +- .../src/websocket/events/game/drawCards.ts | 2 +- .../src/websocket/events/game/dumpZone.ts | 2 +- .../src/websocket/events/game/flipCard.ts | 2 +- .../src/websocket/events/game/gameClosed.ts | 2 +- .../websocket/events/game/gameEvents.spec.ts | 45 +--- .../websocket/events/game/gameHostChanged.ts | 2 +- .../src/websocket/events/game/gameSay.ts | 2 +- .../websocket/events/game/gameStateChanged.ts | 2 +- webclient/src/websocket/events/game/index.ts | 2 +- .../src/websocket/events/game/joinGame.ts | 2 +- webclient/src/websocket/events/game/kicked.ts | 2 +- .../src/websocket/events/game/leaveGame.ts | 2 +- .../src/websocket/events/game/moveCard.ts | 2 +- .../events/game/playerPropertiesChanged.ts | 2 +- .../src/websocket/events/game/revealCards.ts | 2 +- .../src/websocket/events/game/reverseTurn.ts | 2 +- .../src/websocket/events/game/rollDie.ts | 2 +- .../websocket/events/game/setActivePhase.ts | 2 +- .../websocket/events/game/setActivePlayer.ts | 2 +- .../src/websocket/events/game/setCardAttr.ts | 2 +- .../websocket/events/game/setCardCounter.ts | 2 +- .../src/websocket/events/game/setCounter.ts | 2 +- .../src/websocket/events/game/shuffle.ts | 2 +- .../websocket/events/room/roomEvents.spec.ts | 19 +- .../events/session/connectionClosed.ts | 2 +- .../events/session/serverIdentification.ts | 89 +++++++- .../events/session/sessionEvents.spec.ts | 200 ++++++++++++++---- webclient/src/websocket/index.ts | 20 +- .../websocket/interfaces/ConnectOptions.ts | 82 +++++++ .../websocket/{ => interfaces}/StatusEnum.ts | 0 .../{ => interfaces}/WebClientConfig.ts | 7 +- .../websocket/interfaces/WebClientRequest.ts | 4 +- .../websocket/interfaces/WebClientResponse.ts | 4 +- .../WebSocketConfig.ts} | 0 .../services/ProtobufService.spec.ts | 78 ++++++- .../src/websocket/services/ProtobufService.ts | 2 +- .../services/WebSocketService.spec.ts | 2 +- .../websocket/services/WebSocketService.ts | 4 +- .../src/websocket/utils/connectionState.ts | 13 ++ 75 files changed, 908 insertions(+), 501 deletions(-) delete mode 100644 webclient/src/api/config.ts delete mode 100644 webclient/src/api/connectionState.ts create mode 100644 webclient/src/websocket/__mocks__/WebClient.ts create mode 100644 webclient/src/websocket/interfaces/ConnectOptions.ts rename webclient/src/websocket/{ => interfaces}/StatusEnum.ts (100%) rename webclient/src/websocket/{ => interfaces}/WebClientConfig.ts (70%) rename webclient/src/websocket/{types.ts => interfaces/WebSocketConfig.ts} (100%) create mode 100644 webclient/src/websocket/utils/connectionState.ts diff --git a/webclient/src/api/config.ts b/webclient/src/api/config.ts deleted file mode 100644 index 42ed9fd77..000000000 --- a/webclient/src/api/config.ts +++ /dev/null @@ -1 +0,0 @@ -export const PROTOCOL_VERSION = 14; diff --git a/webclient/src/api/connectionState.ts b/webclient/src/api/connectionState.ts deleted file mode 100644 index ddf7a90b5..000000000 --- a/webclient/src/api/connectionState.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Enriched } from '@app/types'; - -let pendingOptions: Enriched.WebSocketConnectOptions | null = null; - -export function setPendingOptions(options: Enriched.WebSocketConnectOptions) { - pendingOptions = options; -} - -export function consumePendingOptions(): Enriched.WebSocketConnectOptions | null { - const opts = pendingOptions; - pendingOptions = null; - return opts; -} diff --git a/webclient/src/api/initWebClient.ts b/webclient/src/api/initWebClient.ts index 89858fa4d..8337c0e0e 100644 --- a/webclient/src/api/initWebClient.ts +++ b/webclient/src/api/initWebClient.ts @@ -1,109 +1,19 @@ -import { App } from '@app/types'; import { WebClient, - StatusEnum, SessionEvents, RoomEvents, GameEvents, SessionCommands, - generateSalt, - passwordSaltSupported, } from '@app/websocket'; import type { WebClientConfig } from '@app/websocket'; import { createWebClientResponse } from './response'; -import { consumePendingOptions } from './connectionState'; -import { PROTOCOL_VERSION } from './config'; export function initWebClient(): void { const response = createWebClientResponse(); const config: WebClientConfig = { response, - - onServerIdentified: (info) => { - const { serverName, serverVersion, protocolVersion, serverOptions } = info; - if (protocolVersion !== PROTOCOL_VERSION) { - SessionCommands.updateStatus(StatusEnum.DISCONNECTED, `Protocol version mismatch: ${protocolVersion}`); - SessionCommands.disconnect(); - return; - } - - const getPasswordSalt = passwordSaltSupported(serverOptions); - const options = consumePendingOptions(); - - if (!options) { - SessionCommands.updateStatus(StatusEnum.DISCONNECTED, 'Missing connection options'); - SessionCommands.disconnect(); - return; - } - - switch (options.reason) { - case App.WebSocketConnectReason.LOGIN: { - const { password, ...rest } = options; - SessionCommands.updateStatus(StatusEnum.LOGGING_IN, 'Logging In...'); - if (getPasswordSalt) { - SessionCommands.requestPasswordSalt(rest, - (salt) => SessionCommands.login(rest, password, salt), - () => { - response.session.loginFailed(); SessionCommands.disconnect(); - }, - ); - } else { - SessionCommands.login(rest, password); - } - break; - } - case App.WebSocketConnectReason.REGISTER: { - const { password, ...rest } = options; - const passwordSalt = getPasswordSalt ? generateSalt() : null; - SessionCommands.register(rest, password, passwordSalt); - break; - } - case App.WebSocketConnectReason.ACTIVATE_ACCOUNT: { - const { password, ...rest } = options; - if (getPasswordSalt) { - SessionCommands.requestPasswordSalt(rest, - (salt) => SessionCommands.activate(rest, password, salt), - () => { - response.session.accountActivationFailed(); SessionCommands.disconnect(); - }, - ); - } else { - SessionCommands.activate(rest, password); - } - break; - } - case App.WebSocketConnectReason.PASSWORD_RESET_REQUEST: - SessionCommands.forgotPasswordRequest(options); - break; - case App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE: - SessionCommands.forgotPasswordChallenge(options); - break; - case App.WebSocketConnectReason.PASSWORD_RESET: { - const { newPassword, ...rest } = options; - if (getPasswordSalt) { - SessionCommands.requestPasswordSalt(rest, - (salt) => SessionCommands.forgotPasswordReset(rest, newPassword, salt), - () => { - response.session.resetPasswordFailed(); SessionCommands.disconnect(); - }, - ); - } else { - SessionCommands.forgotPasswordReset(rest, newPassword); - } - break; - } - default: { - SessionCommands.updateStatus(StatusEnum.DISCONNECTED, `Unknown Connection Reason: ${options.reason}`); - SessionCommands.disconnect(); - break; - } - } - - response.session.updateInfo(serverName, serverVersion); - }, - sessionEvents: SessionEvents, roomEvents: RoomEvents, gameEvents: GameEvents, diff --git a/webclient/src/api/request/AuthenticationRequestImpl.ts b/webclient/src/api/request/AuthenticationRequestImpl.ts index 7ac5de137..91788d862 100644 --- a/webclient/src/api/request/AuthenticationRequestImpl.ts +++ b/webclient/src/api/request/AuthenticationRequestImpl.ts @@ -1,56 +1,69 @@ -import { App, Enriched } from '@app/types'; -import { WebClient, StatusEnum, SessionCommands } from '@app/websocket'; -import type { IAuthenticationRequest, AuthRequestMap } from '@app/websocket'; - -import { setPendingOptions } from '../connectionState'; +import { + WebClient, + StatusEnum, + SessionCommands, + WebSocketConnectReason, + setPendingOptions, +} from '@app/websocket'; +import type { + IAuthenticationRequest, + AuthRequestMap, + LoginConnectOptions, + TestConnectionOptions, + RegisterConnectOptions, + ActivateConnectOptions, + PasswordResetRequestConnectOptions, + PasswordResetChallengeConnectOptions, + PasswordResetConnectOptions, +} from '@app/websocket'; interface AppAuthRequestOverrides extends AuthRequestMap { - LoginParams: Omit; - ConnectTarget: Omit; - RegisterParams: Omit; - ActivateParams: Omit; - ForgotPasswordRequestParams: Omit; - ForgotPasswordChallengeParams: Omit; - ForgotPasswordResetParams: Omit; + LoginParams: Omit; + ConnectTarget: Omit; + RegisterParams: Omit; + ActivateParams: Omit; + ForgotPasswordRequestParams: Omit; + ForgotPasswordChallengeParams: Omit; + ForgotPasswordResetParams: Omit; } export class AuthenticationRequestImpl implements IAuthenticationRequest { - login(options: Omit): void { - setPendingOptions({ ...options, reason: App.WebSocketConnectReason.LOGIN }); + login(options: Omit): void { + setPendingOptions({ ...options, reason: WebSocketConnectReason.LOGIN }); SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - testConnection(options: Omit): void { + testConnection(options: Omit): void { WebClient.instance.testConnect({ host: options.host, port: options.port }); } - register(options: Omit): void { - setPendingOptions({ ...options, reason: App.WebSocketConnectReason.REGISTER }); + register(options: Omit): void { + setPendingOptions({ ...options, reason: WebSocketConnectReason.REGISTER }); SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - activateAccount(options: Omit): void { - setPendingOptions({ ...options, reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT }); + activateAccount(options: Omit): void { + setPendingOptions({ ...options, reason: WebSocketConnectReason.ACTIVATE_ACCOUNT }); SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - resetPasswordRequest(options: Omit): void { - setPendingOptions({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_REQUEST }); + resetPasswordRequest(options: Omit): void { + setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST }); SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - resetPasswordChallenge(options: Omit): void { - setPendingOptions({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE }); + resetPasswordChallenge(options: Omit): void { + setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE }); SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } - resetPassword(options: Omit): void { - setPendingOptions({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET }); + resetPassword(options: Omit): void { + setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET }); SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...'); WebClient.instance.connect({ host: options.host, port: options.port }); } diff --git a/webclient/src/store/rooms/rooms.selectors.spec.ts b/webclient/src/store/rooms/rooms.selectors.spec.ts index d1c2d8e02..adbbcfe04 100644 --- a/webclient/src/store/rooms/rooms.selectors.spec.ts +++ b/webclient/src/store/rooms/rooms.selectors.spec.ts @@ -1,6 +1,7 @@ import { Selectors } from './rooms.selectors'; import { RoomsState } from './rooms.interfaces'; import { makeGame, makeMessage, makeRoom, makeRoomsState, makeUser } from './__mocks__/rooms-fixtures'; +import { App } from '@app/types'; function rootState(rooms: RoomsState) { return { rooms }; @@ -111,13 +112,23 @@ describe('Selectors', () => { expect(Selectors.getRoomUsers(rootState(state), 1)).toBe(room.users); }); - it('getSortedRoomGames → returns sorted array view of games map', () => { - const game1 = makeGame({ gameId: 1, description: 'beta' }); - const game2 = makeGame({ gameId: 2, description: 'alpha' }); + it('getSortedRoomGames → returns games sorted by the active sort config', () => { + const game1 = makeGame({ gameId: 1, description: 'Beta' }); + const game2 = makeGame({ gameId: 2, description: 'Alpha' }); const room = makeRoom({ roomId: 1, games: { 1: game1, 2: game2 } }); - const state = makeRoomsState({ rooms: { 1: room } }); + const state = makeRoomsState({ + rooms: { 1: room }, + sortGamesBy: { field: 'info.description' as App.GameSortField, order: App.SortDirection.ASC }, + }); const result = Selectors.getSortedRoomGames(rootState(state), 1); expect(result).toHaveLength(2); + expect(result[0].info.description).toBe('Alpha'); + expect(result[1].info.description).toBe('Beta'); + }); + + it('getSortedRoomGames → returns EMPTY_GAMES for unknown roomId', () => { + const state = makeRoomsState({ rooms: {} }); + expect(Selectors.getSortedRoomGames(rootState(state), 999)).toHaveLength(0); }); it('getSortedRoomUsers → returns sorted user array sorted by name', () => { @@ -129,4 +140,40 @@ describe('Selectors', () => { expect(result[0].name).toBe('Alice'); expect(result[1].name).toBe('Zane'); }); + + it('getSortedRoomUsers → returns EMPTY_USERS for unknown roomId', () => { + const state = makeRoomsState({ rooms: {} }); + expect(Selectors.getSortedRoomUsers(rootState(state), 999)).toHaveLength(0); + }); + + // ── createSelector reference stability ────────────────────────────── + + it('getSortedRoomGames → returns same array reference for identical state', () => { + const game = makeGame({ gameId: 1 }); + const room = makeRoom({ roomId: 1, games: { 1: game } }); + const state = makeRoomsState({ rooms: { 1: room } }); + const root = rootState(state); + const a = Selectors.getSortedRoomGames(root, 1); + const b = Selectors.getSortedRoomGames(root, 1); + expect(a).toBe(b); + }); + + it('getSortedRoomUsers → returns same array reference for identical state', () => { + const user = makeUser({ name: 'Alice' }); + const room = makeRoom({ roomId: 1, users: { Alice: user } }); + const state = makeRoomsState({ rooms: { 1: room } }); + const root = rootState(state); + const a = Selectors.getSortedRoomUsers(root, 1); + const b = Selectors.getSortedRoomUsers(root, 1); + expect(a).toBe(b); + }); + + it('getJoinedRooms → returns same array reference for identical state', () => { + const room = makeRoom({ roomId: 1 }); + const state = makeRoomsState({ rooms: { 1: room }, joinedRoomIds: { 1: true } }); + const root = rootState(state); + const a = Selectors.getJoinedRooms(root); + const b = Selectors.getJoinedRooms(root); + expect(a).toBe(b); + }); }); diff --git a/webclient/src/store/server/server.selectors.spec.ts b/webclient/src/store/server/server.selectors.spec.ts index 61917e580..8d9a75097 100644 --- a/webclient/src/store/server/server.selectors.spec.ts +++ b/webclient/src/store/server/server.selectors.spec.ts @@ -6,7 +6,7 @@ import { makeServerState, makeUser, } from './__mocks__/server-fixtures'; -import { App } from '@app/types'; +import { App, Data } from '@app/types'; function rootState(server: ServerState) { return { server }; @@ -149,4 +149,86 @@ describe('Selectors', () => { const state = makeServerState({ registrationError: 'bad input' }); expect(Selectors.getRegistrationError(rootState(state))).toBe('bad input'); }); + + // ── derived selectors (createSelector) ────────────────────────────── + + it('getIsConnected → true when state is LOGGED_IN', () => { + const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.LOGGED_IN, description: null } }); + expect(Selectors.getIsConnected(rootState(state))).toBe(true); + }); + + it('getIsConnected → false when state is CONNECTED', () => { + const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.CONNECTED, description: null } }); + expect(Selectors.getIsConnected(rootState(state))).toBe(false); + }); + + it('getIsConnected → false when state is DISCONNECTED', () => { + const state = makeServerState({ status: { connectionAttemptMade: false, state: App.StatusEnum.DISCONNECTED, description: null } }); + expect(Selectors.getIsConnected(rootState(state))).toBe(false); + }); + + it('getIsUserModerator → true when user has IsModerator flag', () => { + const Flag = Data.ServerInfo_User_UserLevelFlag; + const user = makeUser({ userLevel: Flag.IsUser | Flag.IsModerator }); + const state = makeServerState({ user }); + expect(Selectors.getIsUserModerator(rootState(state))).toBe(true); + }); + + it('getIsUserModerator → false when user lacks IsModerator flag', () => { + const Flag = Data.ServerInfo_User_UserLevelFlag; + const user = makeUser({ userLevel: Flag.IsUser | Flag.IsRegistered }); + const state = makeServerState({ user }); + expect(Selectors.getIsUserModerator(rootState(state))).toBe(false); + }); + + it('getIsUserModerator → false when user is null', () => { + const state = makeServerState({ user: null }); + expect(Selectors.getIsUserModerator(rootState(state))).toBe(false); + }); + + // ── createSelector reference stability ────────────────────────────── + + it('getIsConnected → returns same value reference for identical state', () => { + const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.LOGGED_IN, description: null } }); + const root = rootState(state); + const a = Selectors.getIsConnected(root); + const b = Selectors.getIsConnected(root); + expect(a).toBe(b); + }); + + it('getSortedUsers → returns same array reference for identical state', () => { + const users = { Alice: makeUser({ name: 'Alice' }), Bob: makeUser({ name: 'Bob' }) }; + const state = makeServerState({ users }); + const root = rootState(state); + const a = Selectors.getSortedUsers(root); + const b = Selectors.getSortedUsers(root); + expect(a).toBe(b); + }); + + it('getSortedBuddyList → returns same array reference for identical state', () => { + const buddyList = { Alice: makeUser({ name: 'Alice' }) }; + const state = makeServerState({ buddyList }); + const root = rootState(state); + const a = Selectors.getSortedBuddyList(root); + const b = Selectors.getSortedBuddyList(root); + expect(a).toBe(b); + }); + + it('getSortedIgnoreList → returns same array reference for identical state', () => { + const ignoreList = { Troll: makeUser({ name: 'Troll' }) }; + const state = makeServerState({ ignoreList }); + const root = rootState(state); + const a = Selectors.getSortedIgnoreList(root); + const b = Selectors.getSortedIgnoreList(root); + expect(a).toBe(b); + }); + + it('getReplaysList → returns same array reference for identical state', () => { + const replays = { 1: makeReplayMatch({ gameId: 1 }) }; + const state = makeServerState({ replays }); + const root = rootState(state); + const a = Selectors.getReplaysList(root); + const b = Selectors.getReplaysList(root); + expect(a).toBe(b); + }); }); diff --git a/webclient/src/types/enriched.ts b/webclient/src/types/enriched.ts index c0033d3c2..b111c0e5e 100644 --- a/webclient/src/types/enriched.ts +++ b/webclient/src/types/enriched.ts @@ -11,8 +11,6 @@ import type { ServerInfo_User, } from '@app/generated'; -import { WebSocketConnectReason } from './server'; - // ── Domain model types (composition: raw proto + client-side fields) ────────── // // `info` holds the proto snapshot verbatim. Normalized/client-only fields @@ -133,84 +131,20 @@ export interface LogGroups { chat: ServerInfo_ChatMessage[]; } -// ── Connect options ─────────────────────────────────────────────────────────── -// Each variant is the enriched input for one session flow: the network -// transport fields (host/port) + the subset of proto Command_* fields the UI -// actually produces (user-entered credentials, tokens, email, etc.) + a -// `reason` discriminator so the websocket layer can route. -// -// Hand-written instead of `MessageInitShape & ...` -// because MessageInitShape is a `Message | { initShape }` union which -// collapses to the Message branding when intersected, requiring `$typeName` -// on literals. Keep these in sync with the corresponding proto command by -// convention; fields here map 1:1 to Command_* members. +// ── Connect options (re-exported from @app/websocket) ──────────────────────── +// Source of truth lives in src/websocket/connectOptions.ts. Re-exported here +// so UI code can use the Enriched.* namespace without importing @app/websocket. -interface ConnectTransport { - host: string; - port: string; - keepalive?: number; - autojoinrooms?: boolean; - clientid?: string; -} - -export interface LoginConnectOptions extends ConnectTransport { - reason: WebSocketConnectReason.LOGIN; - userName: string; - password?: string; - hashedPassword?: string; -} - -export interface RegisterConnectOptions extends ConnectTransport { - reason: WebSocketConnectReason.REGISTER; - userName: string; - password: string; - email: string; - country: string; - realName: string; -} - -export interface ActivateConnectOptions extends ConnectTransport { - reason: WebSocketConnectReason.ACTIVATE_ACCOUNT; - userName: string; - token: string; - /** Plaintext password carried through so post-activation auto-login can hash it. */ - password?: string; -} - -export interface PasswordResetRequestConnectOptions extends ConnectTransport { - reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST; - userName: string; -} - -export interface PasswordResetChallengeConnectOptions extends ConnectTransport { - reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE; - userName: string; - email: string; -} - -export interface PasswordResetConnectOptions extends ConnectTransport { - reason: WebSocketConnectReason.PASSWORD_RESET; - userName: string; - token: string; - newPassword: string; -} - -/** - * Test connection has no proto command — it just opens and closes a socket to - * verify reachability. - */ -export interface TestConnectionOptions extends ConnectTransport { - reason: WebSocketConnectReason.TEST_CONNECTION; -} - -export type WebSocketConnectOptions = - | LoginConnectOptions - | RegisterConnectOptions - | ActivateConnectOptions - | PasswordResetRequestConnectOptions - | PasswordResetChallengeConnectOptions - | PasswordResetConnectOptions - | TestConnectionOptions; +export type { + LoginConnectOptions, + RegisterConnectOptions, + ActivateConnectOptions, + PasswordResetRequestConnectOptions, + PasswordResetChallengeConnectOptions, + PasswordResetConnectOptions, + TestConnectionOptions, + WebSocketConnectOptions, +} from '@app/websocket'; /** * Context preserved through the ACCOUNT_AWAITING_ACTIVATION signal so the diff --git a/webclient/src/types/server.ts b/webclient/src/types/server.ts index 4ad79e6eb..19135bc8f 100644 --- a/webclient/src/types/server.ts +++ b/webclient/src/types/server.ts @@ -1,4 +1,4 @@ -export { StatusEnum } from '@app/websocket'; +export { StatusEnum, WebSocketConnectReason } from '@app/websocket'; import type { StatusEnum } from '@app/websocket'; export interface ServerStatus { @@ -6,16 +6,6 @@ export interface ServerStatus { description: string; } -export enum WebSocketConnectReason { - LOGIN, - REGISTER, - ACTIVATE_ACCOUNT, - PASSWORD_RESET_REQUEST, - PASSWORD_RESET_CHALLENGE, - PASSWORD_RESET, - TEST_CONNECTION, -} - export class Host { id?: number; name: string; diff --git a/webclient/src/websocket/WebClient.spec.ts b/webclient/src/websocket/WebClient.spec.ts index d4e9cf696..c76911949 100644 --- a/webclient/src/websocket/WebClient.spec.ts +++ b/webclient/src/websocket/WebClient.spec.ts @@ -31,14 +31,14 @@ vi.mock('./services/ProtobufService', () => ({ import { WebClient } from './WebClient'; import { WebSocketService } from './services/WebSocketService'; import { ProtobufService } from './services/ProtobufService'; -import { StatusEnum } from './StatusEnum'; +import { StatusEnum } from './interfaces/StatusEnum'; import { Subject } from 'rxjs'; import { Mock } from 'vitest'; import { SocketTransport, EventRegistries } from './services/ProtobufService'; import { WebSocketServiceConfig } from './services/WebSocketService'; import type { IWebClientResponse } from './interfaces'; -import type { WebClientConfig, ConnectTarget } from './WebClientConfig'; -import { installMockWebSocket, useWebClientCleanup } from './__mocks__/helpers'; +import type { WebClientConfig, ConnectTarget } from './interfaces/WebClientConfig'; +import { installMockWebSocket } from './__mocks__/helpers'; function makeMockResponse(): IWebClientResponse { return { @@ -61,7 +61,6 @@ function makeMockResponse(): IWebClientResponse { function makeMockConfig(response: IWebClientResponse): WebClientConfig { return { response, - onServerIdentified: vi.fn(), sessionEvents: [], roomEvents: [], gameEvents: [], @@ -69,8 +68,6 @@ function makeMockConfig(response: IWebClientResponse): WebClientConfig { }; } -useWebClientCleanup(); - describe('WebClient', () => { let client: WebClient; let mockResponse: IWebClientResponse; @@ -78,10 +75,6 @@ describe('WebClient', () => { let messageSubject: Subject; beforeEach(() => { - // Reset the singleton so each test starts fresh. - // This direct reset is needed in addition to useWebClientCleanup() because - // this file imports the real WebClient (not a mock), and with isolate:false - // the helper's import may resolve to a different (mocked) module reference. (WebClient as unknown as { _instance: WebClient | null })._instance = null; (ProtobufService as Mock).mockImplementation(function ProtobufServiceImpl(transport: SocketTransport, events: EventRegistries) { diff --git a/webclient/src/websocket/WebClient.ts b/webclient/src/websocket/WebClient.ts index 5040e6b69..725780643 100644 --- a/webclient/src/websocket/WebClient.ts +++ b/webclient/src/websocket/WebClient.ts @@ -1,9 +1,9 @@ -import { StatusEnum } from './StatusEnum'; +import { StatusEnum } from './interfaces/StatusEnum'; import { ProtobufService } from './services/ProtobufService'; import { WebSocketService } from './services/WebSocketService'; import { CLIENT_OPTIONS } from './config'; import type { IWebClientResponse } from './interfaces'; -import type { WebClientConfig, ConnectTarget } from './WebClientConfig'; +import type { WebClientConfig, ConnectTarget } from './interfaces/WebClientConfig'; export class WebClient { private static _instance: WebClient | null = null; diff --git a/webclient/src/websocket/__mocks__/WebClient.ts b/webclient/src/websocket/__mocks__/WebClient.ts new file mode 100644 index 000000000..1219c3466 --- /dev/null +++ b/webclient/src/websocket/__mocks__/WebClient.ts @@ -0,0 +1,185 @@ +/** + * Shared WebClient mock — the single source of truth for all websocket + * layer unit tests. + * + * Vitest resolves this file whenever a spec calls `vi.mock('...WebClient')` + * without providing a factory. Each spec file gets its own module graph + * (isolate: true), so there are no factory-conflict issues. + * + * Usage in spec files: + * + * vi.mock('../../WebClient'); + * import { WebClient } from '../../WebClient'; + * // WebClient.instance.response.game.cardMoved ← vi.fn() + * // WebClient.instance.protobuf.sendGameCommand ← vi.fn() + * + * `useWebClientCleanup()` is NOT required — `instance` is a plain + * property, not a getter that throws. + */ + +// --------------------------------------------------------------------------- +// response.session (ISessionResponse) +// --------------------------------------------------------------------------- +const session = { + initialized: vi.fn(), + connectionAttempted: vi.fn(), + clearStore: vi.fn(), + loginSuccessful: vi.fn(), + loginFailed: vi.fn(), + connectionFailed: vi.fn(), + testConnectionSuccessful: vi.fn(), + testConnectionFailed: vi.fn(), + updateBuddyList: vi.fn(), + addToBuddyList: vi.fn(), + removeFromBuddyList: vi.fn(), + updateIgnoreList: vi.fn(), + addToIgnoreList: vi.fn(), + removeFromIgnoreList: vi.fn(), + updateInfo: vi.fn(), + updateStatus: vi.fn(), + updateUser: vi.fn(), + updateUsers: vi.fn(), + userJoined: vi.fn(), + userLeft: vi.fn(), + serverMessage: vi.fn(), + accountAwaitingActivation: vi.fn(), + accountActivationSuccess: vi.fn(), + accountActivationFailed: vi.fn(), + registrationRequiresEmail: vi.fn(), + registrationSuccess: vi.fn(), + registrationFailed: vi.fn(), + registrationEmailError: vi.fn(), + registrationPasswordError: vi.fn(), + registrationUserNameError: vi.fn(), + resetPasswordChallenge: vi.fn(), + resetPassword: vi.fn(), + resetPasswordSuccess: vi.fn(), + resetPasswordFailed: vi.fn(), + accountPasswordChange: vi.fn(), + accountEditChanged: vi.fn(), + accountImageChanged: vi.fn(), + getUserInfo: vi.fn(), + getGamesOfUser: vi.fn(), + gameJoined: vi.fn(), + notifyUser: vi.fn(), + playerPropertiesChanged: vi.fn(), + serverShutdown: vi.fn(), + userMessage: vi.fn(), + addToList: vi.fn(), + removeFromList: vi.fn(), + deleteServerDeck: vi.fn(), + updateServerDecks: vi.fn(), + uploadServerDeck: vi.fn(), + downloadServerDeck: vi.fn(), + createServerDeckDir: vi.fn(), + deleteServerDeckDir: vi.fn(), + replayList: vi.fn(), + replayAdded: vi.fn(), + replayModifyMatch: vi.fn(), + replayDeleteMatch: vi.fn(), + replayDownloaded: vi.fn(), +}; + +// --------------------------------------------------------------------------- +// response.room (IRoomResponse) +// --------------------------------------------------------------------------- +const room = { + clearStore: vi.fn(), + joinRoom: vi.fn(), + leaveRoom: vi.fn(), + updateRooms: vi.fn(), + updateGames: vi.fn(), + addMessage: vi.fn(), + userJoined: vi.fn(), + userLeft: vi.fn(), + removeMessages: vi.fn(), + gameCreated: vi.fn(), + joinedGame: vi.fn(), +}; + +// --------------------------------------------------------------------------- +// response.game (IGameResponse) +// --------------------------------------------------------------------------- +const game = { + clearStore: vi.fn(), + gameStateChanged: vi.fn(), + playerJoined: vi.fn(), + playerLeft: vi.fn(), + playerPropertiesChanged: vi.fn(), + gameClosed: vi.fn(), + gameHostChanged: vi.fn(), + kicked: vi.fn(), + gameSay: vi.fn(), + cardMoved: vi.fn(), + cardFlipped: vi.fn(), + cardDestroyed: vi.fn(), + cardAttached: vi.fn(), + tokenCreated: vi.fn(), + cardAttrChanged: vi.fn(), + cardCounterChanged: vi.fn(), + arrowCreated: vi.fn(), + arrowDeleted: vi.fn(), + counterCreated: vi.fn(), + counterSet: vi.fn(), + counterDeleted: vi.fn(), + cardsDrawn: vi.fn(), + cardsRevealed: vi.fn(), + zoneShuffled: vi.fn(), + dieRolled: vi.fn(), + activePlayerSet: vi.fn(), + activePhaseSet: vi.fn(), + turnReversed: vi.fn(), + zoneDumped: vi.fn(), + zonePropertiesChanged: vi.fn(), +}; + +// --------------------------------------------------------------------------- +// response.admin (IAdminResponse) +// --------------------------------------------------------------------------- +const admin = { + adjustMod: vi.fn(), + reloadConfig: vi.fn(), + shutdownServer: vi.fn(), + updateServerMessage: vi.fn(), +}; + +// --------------------------------------------------------------------------- +// response.moderator (IModeratorResponse) +// --------------------------------------------------------------------------- +const moderator = { + banFromServer: vi.fn(), + banHistory: vi.fn(), + viewLogs: vi.fn(), + warnHistory: vi.fn(), + warnListOptions: vi.fn(), + warnUser: vi.fn(), + grantReplayAccess: vi.fn(), + forceActivateUser: vi.fn(), + getAdminNotes: vi.fn(), + updateAdminNotes: vi.fn(), +}; + +// --------------------------------------------------------------------------- +// Exported mock — replaces the real WebClient module for all consumers. +// --------------------------------------------------------------------------- +export const WebClient = { + _instance: null as any, + instance: { + connect: vi.fn(), + testConnect: vi.fn(), + disconnect: vi.fn(), + updateStatus: vi.fn(), + status: 0 as number, + config: {}, + protobuf: { + sendSessionCommand: vi.fn(), + sendRoomCommand: vi.fn(), + sendGameCommand: vi.fn(), + sendAdminCommand: vi.fn(), + sendModeratorCommand: vi.fn(), + resetCommands: vi.fn(), + }, + response: { session, room, game, admin, moderator }, + }, +}; + diff --git a/webclient/src/websocket/__mocks__/sessionCommandMocks.ts b/webclient/src/websocket/__mocks__/sessionCommandMocks.ts index 7e0bda6cc..d82cbed0f 100644 --- a/webclient/src/websocket/__mocks__/sessionCommandMocks.ts +++ b/webclient/src/websocket/__mocks__/sessionCommandMocks.ts @@ -16,7 +16,7 @@ export function makeWebClientMock() { testConnect: vi.fn(), disconnect: vi.fn(), updateStatus: vi.fn(), - config: { onServerIdentified: vi.fn() }, + config: {}, status: 0, protobuf: { sendSessionCommand: vi.fn(), diff --git a/webclient/src/websocket/commands/admin/adminCommands.spec.ts b/webclient/src/websocket/commands/admin/adminCommands.spec.ts index 04da773f3..51764a627 100644 --- a/webclient/src/websocket/commands/admin/adminCommands.spec.ts +++ b/webclient/src/websocket/commands/admin/adminCommands.spec.ts @@ -1,31 +1,20 @@ -vi.mock('../../WebClient', () => ({ - WebClient: { - instance: { - protobuf: { sendAdminCommand: vi.fn() }, - response: { - admin: { - adjustMod: vi.fn(), - reloadConfig: vi.fn(), - shutdownServer: vi.fn(), - updateServerMessage: vi.fn(), - }, - }, - }, - }, -})); +vi.mock('../../WebClient'); import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; -import { useWebClientCleanup } from '../../__mocks__/helpers'; import { WebClient } from '../../WebClient'; import { adjustMod } from './adjustMod'; import { reloadConfig } from './reloadConfig'; import { shutdownServer } from './shutdownServer'; import { updateServerMessage } from './updateServerMessage'; +import { + Command_AdjustMod_ext, + Command_ReloadConfig_ext, + Command_ShutdownServer_ext, + Command_UpdateServerMessage_ext, +} from '@app/generated'; import { Mock } from 'vitest'; -useWebClientCleanup(); - const { invokeOnSuccess } = makeCallbackHelpers( WebClient.instance.protobuf.sendAdminCommand as Mock, 2 @@ -36,9 +25,13 @@ const { invokeOnSuccess } = makeCallbackHelpers( // ---------------------------------------------------------------- describe('adjustMod', () => { - it('calls sendAdminCommand with Command_AdjustMod', () => { + it('calls sendAdminCommand with Command_AdjustMod extension and fields', () => { adjustMod('alice', true, false); - expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object)); + expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith( + Command_AdjustMod_ext, + expect.objectContaining({ userName: 'alice', shouldBeMod: true, shouldBeJudge: false }), + expect.any(Object) + ); }); it('onSuccess calls response.admin.adjustMod', () => { @@ -53,9 +46,13 @@ describe('adjustMod', () => { // ---------------------------------------------------------------- describe('reloadConfig', () => { - it('calls sendAdminCommand with Command_ReloadConfig', () => { + it('calls sendAdminCommand with Command_ReloadConfig extension', () => { reloadConfig(); - expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object)); + expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith( + Command_ReloadConfig_ext, + expect.any(Object), + expect.any(Object) + ); }); it('onSuccess calls response.admin.reloadConfig', () => { @@ -70,9 +67,13 @@ describe('reloadConfig', () => { // ---------------------------------------------------------------- describe('shutdownServer', () => { - it('calls sendAdminCommand with Command_ShutdownServer', () => { + it('calls sendAdminCommand with Command_ShutdownServer extension and fields', () => { shutdownServer('maintenance', 10); - expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object)); + expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith( + Command_ShutdownServer_ext, + expect.objectContaining({ reason: 'maintenance', minutes: 10 }), + expect.any(Object) + ); }); it('onSuccess calls response.admin.shutdownServer', () => { @@ -87,9 +88,13 @@ describe('shutdownServer', () => { // ---------------------------------------------------------------- describe('updateServerMessage', () => { - it('calls sendAdminCommand with Command_UpdateServerMessage', () => { + it('calls sendAdminCommand with Command_UpdateServerMessage extension', () => { updateServerMessage(); - expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.any(Object)); + expect(WebClient.instance.protobuf.sendAdminCommand).toHaveBeenCalledWith( + Command_UpdateServerMessage_ext, + expect.any(Object), + expect.any(Object) + ); }); it('onSuccess calls response.admin.updateServerMessage', () => { diff --git a/webclient/src/websocket/commands/game/gameCommands.spec.ts b/webclient/src/websocket/commands/game/gameCommands.spec.ts index deba8e1a2..8706a9e3d 100644 --- a/webclient/src/websocket/commands/game/gameCommands.spec.ts +++ b/webclient/src/websocket/commands/game/gameCommands.spec.ts @@ -1,16 +1,6 @@ -vi.mock('../../WebClient', () => ({ - WebClient: { - instance: { - protobuf: { sendGameCommand: vi.fn() }, - response: { game: {} }, - }, - }, -})); +vi.mock('../../WebClient'); import { WebClient } from '../../WebClient'; -import { useWebClientCleanup } from '../../__mocks__/helpers'; - -useWebClientCleanup(); import { create, setExtension } from '@bufbuild/protobuf'; import { Command_AttachCard_ext, diff --git a/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts b/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts index 6af468c28..69ba240fd 100644 --- a/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts +++ b/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts @@ -1,27 +1,6 @@ -vi.mock('../../WebClient', () => ({ - WebClient: { - instance: { - protobuf: { sendModeratorCommand: vi.fn() }, - response: { - moderator: { - banFromServer: vi.fn(), - forceActivateUser: vi.fn(), - getAdminNotes: vi.fn(), - banHistory: vi.fn(), - warnHistory: vi.fn(), - warnListOptions: vi.fn(), - grantReplayAccess: vi.fn(), - updateAdminNotes: vi.fn(), - viewLogs: vi.fn(), - warnUser: vi.fn(), - }, - }, - }, - }, -})); +vi.mock('../../WebClient'); import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; -import { useWebClientCleanup } from '../../__mocks__/helpers'; import { WebClient } from '../../WebClient'; import { Command_BanFromServer_ext, @@ -55,8 +34,6 @@ import { warnUser } from './warnUser'; import { create } from '@bufbuild/protobuf'; import { Mock } from 'vitest'; -useWebClientCleanup(); - const { invokeOnSuccess } = makeCallbackHelpers( WebClient.instance.protobuf.sendModeratorCommand as Mock, 2 diff --git a/webclient/src/websocket/commands/room/roomCommands.spec.ts b/webclient/src/websocket/commands/room/roomCommands.spec.ts index 833b1deff..57dac994a 100644 --- a/webclient/src/websocket/commands/room/roomCommands.spec.ts +++ b/webclient/src/websocket/commands/room/roomCommands.spec.ts @@ -1,20 +1,6 @@ -vi.mock('../../WebClient', () => ({ - WebClient: { - instance: { - protobuf: { sendRoomCommand: vi.fn() }, - response: { - room: { - gameCreated: vi.fn(), - joinedGame: vi.fn(), - leaveRoom: vi.fn(), - }, - }, - }, - }, -})); +vi.mock('../../WebClient'); import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; -import { useWebClientCleanup } from '../../__mocks__/helpers'; import { WebClient } from '../../WebClient'; import { Command_CreateGame_ext, @@ -32,8 +18,6 @@ import { roomSay } from './roomSay'; import { create } from '@bufbuild/protobuf'; import { Mock } from 'vitest'; -useWebClientCleanup(); - const { invokeOnSuccess } = makeCallbackHelpers( WebClient.instance.protobuf.sendRoomCommand as Mock, // sendRoomCommand(roomId, ext, value, options) — options at index 3 diff --git a/webclient/src/websocket/commands/session/activate.ts b/webclient/src/websocket/commands/session/activate.ts index 5972cfcb2..422ed937f 100644 --- a/webclient/src/websocket/commands/session/activate.ts +++ b/webclient/src/websocket/commands/session/activate.ts @@ -6,10 +6,10 @@ import { type ActivateParams, } from '@app/generated'; -import { StatusEnum } from '../../StatusEnum'; +import { StatusEnum } from '../../interfaces/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../WebClientConfig'; +import type { ConnectTarget } from '../../interfaces/WebClientConfig'; import { disconnect, login, updateStatus } from './'; export function activate(options: ConnectTarget & ActivateParams, password?: string, passwordSalt?: string): void { diff --git a/webclient/src/websocket/commands/session/connect.ts b/webclient/src/websocket/commands/session/connect.ts index f9221a6a0..035f5a60a 100644 --- a/webclient/src/websocket/commands/session/connect.ts +++ b/webclient/src/websocket/commands/session/connect.ts @@ -1,5 +1,5 @@ import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../WebClientConfig'; +import type { ConnectTarget } from '../../interfaces/WebClientConfig'; export function connect(target: ConnectTarget): void { WebClient.instance.connect(target); diff --git a/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts b/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts index f5ca0212b..7246580af 100644 --- a/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts +++ b/webclient/src/websocket/commands/session/forgotPasswordChallenge.ts @@ -5,10 +5,10 @@ import { type ForgotPasswordChallengeParams, } from '@app/generated'; -import { StatusEnum } from '../../StatusEnum'; +import { StatusEnum } from '../../interfaces/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../WebClientConfig'; +import type { ConnectTarget } from '../../interfaces/WebClientConfig'; import { disconnect, updateStatus } from './'; export function forgotPasswordChallenge(options: ConnectTarget & ForgotPasswordChallengeParams): void { diff --git a/webclient/src/websocket/commands/session/forgotPasswordRequest.ts b/webclient/src/websocket/commands/session/forgotPasswordRequest.ts index e6255104f..cf8246b30 100644 --- a/webclient/src/websocket/commands/session/forgotPasswordRequest.ts +++ b/webclient/src/websocket/commands/session/forgotPasswordRequest.ts @@ -6,10 +6,10 @@ import { type ForgotPasswordRequestParams, } from '@app/generated'; -import { StatusEnum } from '../../StatusEnum'; +import { StatusEnum } from '../../interfaces/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../WebClientConfig'; +import type { ConnectTarget } from '../../interfaces/WebClientConfig'; import { disconnect, updateStatus } from './'; export function forgotPasswordRequest(options: ConnectTarget & ForgotPasswordRequestParams): void { diff --git a/webclient/src/websocket/commands/session/forgotPasswordReset.ts b/webclient/src/websocket/commands/session/forgotPasswordReset.ts index eaa2cbfea..3acd946b7 100644 --- a/webclient/src/websocket/commands/session/forgotPasswordReset.ts +++ b/webclient/src/websocket/commands/session/forgotPasswordReset.ts @@ -6,10 +6,10 @@ import { type ForgotPasswordResetParams, } from '@app/generated'; -import { StatusEnum } from '../../StatusEnum'; +import { StatusEnum } from '../../interfaces/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../WebClientConfig'; +import type { ConnectTarget } from '../../interfaces/WebClientConfig'; import { hashPassword } from '../../utils'; import { disconnect, updateStatus } from '.'; diff --git a/webclient/src/websocket/commands/session/login.ts b/webclient/src/websocket/commands/session/login.ts index b3e43b144..80fddfcc7 100644 --- a/webclient/src/websocket/commands/session/login.ts +++ b/webclient/src/websocket/commands/session/login.ts @@ -8,10 +8,10 @@ import { type LoginParams, } from '@app/generated'; -import { StatusEnum } from '../../StatusEnum'; +import { StatusEnum } from '../../interfaces/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../WebClientConfig'; +import type { ConnectTarget } from '../../interfaces/WebClientConfig'; import { hashPassword } from '../../utils'; import { disconnect, diff --git a/webclient/src/websocket/commands/session/register.ts b/webclient/src/websocket/commands/session/register.ts index 7badfc4ae..ec0b5ecfa 100644 --- a/webclient/src/websocket/commands/session/register.ts +++ b/webclient/src/websocket/commands/session/register.ts @@ -8,10 +8,10 @@ import { type RegisterParams, } from '@app/generated'; -import { StatusEnum } from '../../StatusEnum'; +import { StatusEnum } from '../../interfaces/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../WebClientConfig'; +import type { ConnectTarget } from '../../interfaces/WebClientConfig'; import { hashPassword } from '../../utils'; import { login, disconnect, updateStatus } from './'; diff --git a/webclient/src/websocket/commands/session/requestPasswordSalt.ts b/webclient/src/websocket/commands/session/requestPasswordSalt.ts index 4579edb66..73fed0aec 100644 --- a/webclient/src/websocket/commands/session/requestPasswordSalt.ts +++ b/webclient/src/websocket/commands/session/requestPasswordSalt.ts @@ -7,10 +7,10 @@ import { type RequestPasswordSaltParams, } from '@app/generated'; -import { StatusEnum } from '../../StatusEnum'; +import { StatusEnum } from '../../interfaces/StatusEnum'; import { CLIENT_CONFIG } from '../../config'; import { WebClient } from '../../WebClient'; -import type { ConnectTarget } from '../../WebClientConfig'; +import type { ConnectTarget } from '../../interfaces/WebClientConfig'; import { updateStatus } from './'; export function requestPasswordSalt( diff --git a/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts b/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts index 9a3c7c608..7f0b9e2c6 100644 --- a/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts +++ b/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts @@ -1,10 +1,7 @@ // Tests for complex session commands that call WebClient directly // or have multiple branching callbacks. -vi.mock('../../WebClient', async () => { - const { makeWebClientMock } = await import('../../__mocks__/sessionCommandMocks'); - return { WebClient: { instance: makeWebClientMock() } }; -}); +vi.mock('../../WebClient'); vi.mock('../../utils', async () => { const { makeUtilsMock } = await import('../../__mocks__/sessionCommandMocks'); @@ -19,11 +16,10 @@ vi.mock('./', async () => { import { Mock } from 'vitest'; import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; -import { useWebClientCleanup } from '../../__mocks__/helpers'; import { WebClient } from '../../WebClient'; import * as SessionIndexMocks from './'; import { App, Enriched } from '@app/types'; -import { StatusEnum } from '../../StatusEnum'; +import { StatusEnum } from '../../interfaces/StatusEnum'; import { Command_Activate_ext, Command_ForgotPasswordChallenge_ext, @@ -54,8 +50,6 @@ import { forgotPasswordRequest } from './forgotPasswordRequest'; import { forgotPasswordReset } from './forgotPasswordReset'; import { requestPasswordSalt } from './requestPasswordSalt'; -useWebClientCleanup(); - const { invokeOnSuccess, invokeResponseCode, invokeOnError } = makeCallbackHelpers( WebClient.instance.protobuf.sendSessionCommand as Mock, 2 diff --git a/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts b/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts index 689aaa3d9..18c8be728 100644 --- a/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts +++ b/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts @@ -1,9 +1,6 @@ // Shared mock setup for session command tests -vi.mock('../../WebClient', async () => { - const { makeWebClientMock } = await import('../../__mocks__/sessionCommandMocks'); - return { WebClient: { instance: makeWebClientMock() } }; -}); +vi.mock('../../WebClient'); vi.mock('../../utils', async () => { const { makeUtilsMock } = await import('../../__mocks__/sessionCommandMocks'); @@ -19,7 +16,6 @@ vi.mock('./', async () => { import { Mock } from 'vitest'; import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; -import { useWebClientCleanup } from '../../__mocks__/helpers'; import { WebClient } from '../../WebClient'; import { hashPassword, generateSalt, passwordSaltSupported } from '../../utils'; @@ -85,8 +81,6 @@ import { Response_ReplayList_ext, } from '@app/generated'; -useWebClientCleanup(); - const { invokeOnSuccess, invokeCallback } = makeCallbackHelpers( WebClient.instance.protobuf.sendSessionCommand as Mock, 2 diff --git a/webclient/src/websocket/commands/session/updateStatus.ts b/webclient/src/websocket/commands/session/updateStatus.ts index d573ff909..52cb9ccbc 100644 --- a/webclient/src/websocket/commands/session/updateStatus.ts +++ b/webclient/src/websocket/commands/session/updateStatus.ts @@ -1,4 +1,4 @@ -import { StatusEnum } from '../../StatusEnum'; +import { StatusEnum } from '../../interfaces/StatusEnum'; import { WebClient } from '../../WebClient'; export function updateStatus(status: StatusEnum, description: string): void { diff --git a/webclient/src/websocket/config.ts b/webclient/src/websocket/config.ts index ad025e3da..f27988561 100644 --- a/webclient/src/websocket/config.ts +++ b/webclient/src/websocket/config.ts @@ -1,3 +1,5 @@ +export const PROTOCOL_VERSION = 14; + export const CLIENT_CONFIG = { clientid: 'webatrice', clientver: 'webclient-1.0 (2019-10-31)', diff --git a/webclient/src/websocket/events/game/attachCard.ts b/webclient/src/websocket/events/game/attachCard.ts index dd95bf0c0..a3671cb05 100644 --- a/webclient/src/websocket/events/game/attachCard.ts +++ b/webclient/src/websocket/events/game/attachCard.ts @@ -1,5 +1,5 @@ import type { Event_AttachCard } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function attachCard(data: Event_AttachCard, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/changeZoneProperties.ts b/webclient/src/websocket/events/game/changeZoneProperties.ts index 7df8ef8f5..88d7f95f1 100644 --- a/webclient/src/websocket/events/game/changeZoneProperties.ts +++ b/webclient/src/websocket/events/game/changeZoneProperties.ts @@ -1,5 +1,5 @@ import type { Event_ChangeZoneProperties } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function changeZoneProperties(data: Event_ChangeZoneProperties, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/createArrow.ts b/webclient/src/websocket/events/game/createArrow.ts index 5ef3ea55a..3f39fa064 100644 --- a/webclient/src/websocket/events/game/createArrow.ts +++ b/webclient/src/websocket/events/game/createArrow.ts @@ -1,5 +1,5 @@ import type { Event_CreateArrow } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function createArrow(data: Event_CreateArrow, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/createCounter.ts b/webclient/src/websocket/events/game/createCounter.ts index e70dec92d..db9ca6086 100644 --- a/webclient/src/websocket/events/game/createCounter.ts +++ b/webclient/src/websocket/events/game/createCounter.ts @@ -1,5 +1,5 @@ import type { Event_CreateCounter } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function createCounter(data: Event_CreateCounter, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/createToken.ts b/webclient/src/websocket/events/game/createToken.ts index aa276406f..1542d4418 100644 --- a/webclient/src/websocket/events/game/createToken.ts +++ b/webclient/src/websocket/events/game/createToken.ts @@ -1,5 +1,5 @@ import type { Event_CreateToken } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function createToken(data: Event_CreateToken, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/delCounter.ts b/webclient/src/websocket/events/game/delCounter.ts index 8d81f18ee..1a128b0ad 100644 --- a/webclient/src/websocket/events/game/delCounter.ts +++ b/webclient/src/websocket/events/game/delCounter.ts @@ -1,5 +1,5 @@ import type { Event_DelCounter } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function delCounter(data: Event_DelCounter, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/deleteArrow.ts b/webclient/src/websocket/events/game/deleteArrow.ts index 6a3274a25..df39f5d47 100644 --- a/webclient/src/websocket/events/game/deleteArrow.ts +++ b/webclient/src/websocket/events/game/deleteArrow.ts @@ -1,5 +1,5 @@ import type { Event_DeleteArrow } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function deleteArrow(data: Event_DeleteArrow, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/destroyCard.ts b/webclient/src/websocket/events/game/destroyCard.ts index 6788e89e1..65cb87d38 100644 --- a/webclient/src/websocket/events/game/destroyCard.ts +++ b/webclient/src/websocket/events/game/destroyCard.ts @@ -1,5 +1,5 @@ import type { Event_DestroyCard } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function destroyCard(data: Event_DestroyCard, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/drawCards.ts b/webclient/src/websocket/events/game/drawCards.ts index fce24b125..c9fed3078 100644 --- a/webclient/src/websocket/events/game/drawCards.ts +++ b/webclient/src/websocket/events/game/drawCards.ts @@ -1,5 +1,5 @@ import type { Event_DrawCards } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function drawCards(data: Event_DrawCards, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/dumpZone.ts b/webclient/src/websocket/events/game/dumpZone.ts index 34c33a6c6..8302877ef 100644 --- a/webclient/src/websocket/events/game/dumpZone.ts +++ b/webclient/src/websocket/events/game/dumpZone.ts @@ -1,5 +1,5 @@ import type { Event_DumpZone } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function dumpZone(data: Event_DumpZone, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/flipCard.ts b/webclient/src/websocket/events/game/flipCard.ts index 1c78e6977..1c4e74cd3 100644 --- a/webclient/src/websocket/events/game/flipCard.ts +++ b/webclient/src/websocket/events/game/flipCard.ts @@ -1,5 +1,5 @@ import type { Event_FlipCard } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function flipCard(data: Event_FlipCard, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/gameClosed.ts b/webclient/src/websocket/events/game/gameClosed.ts index cd54984d9..73fc445e2 100644 --- a/webclient/src/websocket/events/game/gameClosed.ts +++ b/webclient/src/websocket/events/game/gameClosed.ts @@ -1,4 +1,4 @@ -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function gameClosed(_data: {}, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/gameEvents.spec.ts b/webclient/src/websocket/events/game/gameEvents.spec.ts index df0f3bb8b..1276f1724 100644 --- a/webclient/src/websocket/events/game/gameEvents.spec.ts +++ b/webclient/src/websocket/events/game/gameEvents.spec.ts @@ -1,44 +1,4 @@ -vi.mock('../../WebClient', () => ({ - WebClient: { - instance: { - response: { - game: { - gameStateChanged: vi.fn(), - playerJoined: vi.fn(), - playerLeft: vi.fn(), - playerPropertiesChanged: vi.fn(), - gameClosed: vi.fn(), - gameHostChanged: vi.fn(), - kicked: vi.fn(), - gameSay: vi.fn(), - cardMoved: vi.fn(), - cardFlipped: vi.fn(), - cardDestroyed: vi.fn(), - cardAttached: vi.fn(), - tokenCreated: vi.fn(), - cardAttrChanged: vi.fn(), - cardCounterChanged: vi.fn(), - arrowCreated: vi.fn(), - arrowDeleted: vi.fn(), - counterCreated: vi.fn(), - counterSet: vi.fn(), - counterDeleted: vi.fn(), - cardsDrawn: vi.fn(), - cardsRevealed: vi.fn(), - zoneShuffled: vi.fn(), - dieRolled: vi.fn(), - activePlayerSet: vi.fn(), - activePhaseSet: vi.fn(), - turnReversed: vi.fn(), - zoneDumped: vi.fn(), - zonePropertiesChanged: vi.fn(), - }, - }, - }, - }, -})); - -import { useWebClientCleanup } from '../../__mocks__/helpers'; +vi.mock('../../WebClient'); import { create } from '@bufbuild/protobuf'; import { Event_AttachCardSchema, @@ -67,7 +27,6 @@ import { ServerInfo_PlayerPropertiesSchema, } from '@app/generated'; import { WebClient } from '../../WebClient'; - import { attachCard } from './attachCard'; import { changeZoneProperties } from './changeZoneProperties'; import { createArrow } from './createArrow'; @@ -98,8 +57,6 @@ import { setCardCounter } from './setCardCounter'; import { setCounter } from './setCounter'; import { shuffle } from './shuffle'; -useWebClientCleanup(); - const meta = { gameId: 5, playerId: 2, context: null, secondsElapsed: 0, forcedByJudge: 0 }; describe('joinGame event', () => { diff --git a/webclient/src/websocket/events/game/gameHostChanged.ts b/webclient/src/websocket/events/game/gameHostChanged.ts index ce39254c6..2cc7e5064 100644 --- a/webclient/src/websocket/events/game/gameHostChanged.ts +++ b/webclient/src/websocket/events/game/gameHostChanged.ts @@ -1,4 +1,4 @@ -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; /** diff --git a/webclient/src/websocket/events/game/gameSay.ts b/webclient/src/websocket/events/game/gameSay.ts index 63a616cab..72a585fef 100644 --- a/webclient/src/websocket/events/game/gameSay.ts +++ b/webclient/src/websocket/events/game/gameSay.ts @@ -1,5 +1,5 @@ import type { Event_GameSay } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function gameSay(data: Event_GameSay, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/gameStateChanged.ts b/webclient/src/websocket/events/game/gameStateChanged.ts index 0c431c611..ff3d53fa3 100644 --- a/webclient/src/websocket/events/game/gameStateChanged.ts +++ b/webclient/src/websocket/events/game/gameStateChanged.ts @@ -1,5 +1,5 @@ import type { Event_GameStateChanged } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function gameStateChanged(data: Event_GameStateChanged, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/index.ts b/webclient/src/websocket/events/game/index.ts index 8d2f69821..d19292e80 100644 --- a/webclient/src/websocket/events/game/index.ts +++ b/webclient/src/websocket/events/game/index.ts @@ -35,7 +35,7 @@ import { Event_ReverseTurn_ext, } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { attachCard } from './attachCard'; import { changeZoneProperties } from './changeZoneProperties'; diff --git a/webclient/src/websocket/events/game/joinGame.ts b/webclient/src/websocket/events/game/joinGame.ts index a6275df12..f0efd619b 100644 --- a/webclient/src/websocket/events/game/joinGame.ts +++ b/webclient/src/websocket/events/game/joinGame.ts @@ -1,5 +1,5 @@ import type { Event_Join } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; diff --git a/webclient/src/websocket/events/game/kicked.ts b/webclient/src/websocket/events/game/kicked.ts index 5cbe7da21..f63951dcc 100644 --- a/webclient/src/websocket/events/game/kicked.ts +++ b/webclient/src/websocket/events/game/kicked.ts @@ -1,4 +1,4 @@ -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function kicked(_data: {}, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/leaveGame.ts b/webclient/src/websocket/events/game/leaveGame.ts index 0fd11812a..5d2df026e 100644 --- a/webclient/src/websocket/events/game/leaveGame.ts +++ b/webclient/src/websocket/events/game/leaveGame.ts @@ -1,4 +1,4 @@ -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function leaveGame(data: { reason: number }, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/moveCard.ts b/webclient/src/websocket/events/game/moveCard.ts index 67287eab2..a553e727a 100644 --- a/webclient/src/websocket/events/game/moveCard.ts +++ b/webclient/src/websocket/events/game/moveCard.ts @@ -1,5 +1,5 @@ import type { Event_MoveCard } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function moveCard(data: Event_MoveCard, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/playerPropertiesChanged.ts b/webclient/src/websocket/events/game/playerPropertiesChanged.ts index c88dc3977..073f5a408 100644 --- a/webclient/src/websocket/events/game/playerPropertiesChanged.ts +++ b/webclient/src/websocket/events/game/playerPropertiesChanged.ts @@ -1,5 +1,5 @@ import type { Event_PlayerPropertiesChanged } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function playerPropertiesChanged(data: Event_PlayerPropertiesChanged, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/revealCards.ts b/webclient/src/websocket/events/game/revealCards.ts index 078dba459..9eb08b82a 100644 --- a/webclient/src/websocket/events/game/revealCards.ts +++ b/webclient/src/websocket/events/game/revealCards.ts @@ -1,5 +1,5 @@ import type { Event_RevealCards } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function revealCards(data: Event_RevealCards, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/reverseTurn.ts b/webclient/src/websocket/events/game/reverseTurn.ts index 94473aa07..ef953aba8 100644 --- a/webclient/src/websocket/events/game/reverseTurn.ts +++ b/webclient/src/websocket/events/game/reverseTurn.ts @@ -1,5 +1,5 @@ import type { Event_ReverseTurn } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function reverseTurn(data: Event_ReverseTurn, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/rollDie.ts b/webclient/src/websocket/events/game/rollDie.ts index 7bcf5c3f8..ca5365144 100644 --- a/webclient/src/websocket/events/game/rollDie.ts +++ b/webclient/src/websocket/events/game/rollDie.ts @@ -1,5 +1,5 @@ import type { Event_RollDie } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function rollDie(data: Event_RollDie, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setActivePhase.ts b/webclient/src/websocket/events/game/setActivePhase.ts index 3f433934f..134651bdb 100644 --- a/webclient/src/websocket/events/game/setActivePhase.ts +++ b/webclient/src/websocket/events/game/setActivePhase.ts @@ -1,5 +1,5 @@ import type { Event_SetActivePhase } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setActivePhase(data: Event_SetActivePhase, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setActivePlayer.ts b/webclient/src/websocket/events/game/setActivePlayer.ts index 006964383..e84f44920 100644 --- a/webclient/src/websocket/events/game/setActivePlayer.ts +++ b/webclient/src/websocket/events/game/setActivePlayer.ts @@ -1,5 +1,5 @@ import type { Event_SetActivePlayer } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setActivePlayer(data: Event_SetActivePlayer, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setCardAttr.ts b/webclient/src/websocket/events/game/setCardAttr.ts index d2d3c6e73..3733dc7b1 100644 --- a/webclient/src/websocket/events/game/setCardAttr.ts +++ b/webclient/src/websocket/events/game/setCardAttr.ts @@ -1,5 +1,5 @@ import type { Event_SetCardAttr } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setCardAttr(data: Event_SetCardAttr, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setCardCounter.ts b/webclient/src/websocket/events/game/setCardCounter.ts index ae9fa9ccd..c96d50ac6 100644 --- a/webclient/src/websocket/events/game/setCardCounter.ts +++ b/webclient/src/websocket/events/game/setCardCounter.ts @@ -1,5 +1,5 @@ import type { Event_SetCardCounter } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setCardCounter(data: Event_SetCardCounter, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/setCounter.ts b/webclient/src/websocket/events/game/setCounter.ts index 26c35c08e..310fb9e28 100644 --- a/webclient/src/websocket/events/game/setCounter.ts +++ b/webclient/src/websocket/events/game/setCounter.ts @@ -1,5 +1,5 @@ import type { Event_SetCounter } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function setCounter(data: Event_SetCounter, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/game/shuffle.ts b/webclient/src/websocket/events/game/shuffle.ts index 1b0bb17ad..9d1689472 100644 --- a/webclient/src/websocket/events/game/shuffle.ts +++ b/webclient/src/websocket/events/game/shuffle.ts @@ -1,5 +1,5 @@ import type { Event_Shuffle } from '@app/generated'; -import type { GameEventMeta } from '../../types'; +import type { GameEventMeta } from '../../interfaces/WebSocketConfig'; import { WebClient } from '../../WebClient'; export function shuffle(data: Event_Shuffle, meta: GameEventMeta): void { diff --git a/webclient/src/websocket/events/room/roomEvents.spec.ts b/webclient/src/websocket/events/room/roomEvents.spec.ts index 3bf1e01f4..3955b524d 100644 --- a/webclient/src/websocket/events/room/roomEvents.spec.ts +++ b/webclient/src/websocket/events/room/roomEvents.spec.ts @@ -1,20 +1,5 @@ -vi.mock('../../WebClient', () => ({ - WebClient: { - instance: { - response: { - room: { - userJoined: vi.fn(), - userLeft: vi.fn(), - updateGames: vi.fn(), - removeMessages: vi.fn(), - addMessage: vi.fn(), - }, - }, - }, - }, -})); +vi.mock('../../WebClient'); -import { useWebClientCleanup } from '../../__mocks__/helpers'; import { create } from '@bufbuild/protobuf'; import { Event_JoinRoomSchema, @@ -31,8 +16,6 @@ import { listGames } from './listGames'; import { removeMessages } from './removeMessages'; import { roomSay } from './roomSay'; -useWebClientCleanup(); - const makeRoomEvent = (roomId: number) => create(RoomEventSchema, { roomId }); describe('joinRoom room event', () => { diff --git a/webclient/src/websocket/events/session/connectionClosed.ts b/webclient/src/websocket/events/session/connectionClosed.ts index f3fbd7343..b3422172f 100644 --- a/webclient/src/websocket/events/session/connectionClosed.ts +++ b/webclient/src/websocket/events/session/connectionClosed.ts @@ -1,5 +1,5 @@ import { Event_ConnectionClosed_CloseReason, type Event_ConnectionClosed } from '@app/generated'; -import { StatusEnum } from '../../StatusEnum'; +import { StatusEnum } from '../../interfaces/StatusEnum'; import { updateStatus } from '../../commands/session'; export function connectionClosed({ reason, reasonStr, endTime }: Event_ConnectionClosed): void { diff --git a/webclient/src/websocket/events/session/serverIdentification.ts b/webclient/src/websocket/events/session/serverIdentification.ts index 347558b89..6c7956f3f 100644 --- a/webclient/src/websocket/events/session/serverIdentification.ts +++ b/webclient/src/websocket/events/session/serverIdentification.ts @@ -1,6 +1,93 @@ import type { Event_ServerIdentification } from '@app/generated'; import { WebClient } from '../../WebClient'; +import { StatusEnum } from '../../interfaces/StatusEnum'; +import { PROTOCOL_VERSION } from '../../config'; +import { consumePendingOptions } from '../../utils/connectionState'; +import { WebSocketConnectReason } from '../../interfaces/ConnectOptions'; +import { generateSalt, passwordSaltSupported } from '../../utils'; +import * as SessionCommands from '../../commands/session'; export function serverIdentification(info: Event_ServerIdentification): void { - WebClient.instance.config.onServerIdentified(info); + const { serverName, serverVersion, protocolVersion, serverOptions } = info; + const response = WebClient.instance.response; + + if (protocolVersion !== PROTOCOL_VERSION) { + SessionCommands.updateStatus(StatusEnum.DISCONNECTED, `Protocol version mismatch: ${protocolVersion}`); + SessionCommands.disconnect(); + return; + } + + const getPasswordSalt = passwordSaltSupported(serverOptions); + const options = consumePendingOptions(); + + if (!options) { + SessionCommands.updateStatus(StatusEnum.DISCONNECTED, 'Missing connection options'); + SessionCommands.disconnect(); + return; + } + + switch (options.reason) { + case WebSocketConnectReason.LOGIN: { + const { password, ...rest } = options; + SessionCommands.updateStatus(StatusEnum.LOGGING_IN, 'Logging In...'); + if (getPasswordSalt) { + SessionCommands.requestPasswordSalt(rest, + (salt) => SessionCommands.login(rest, password, salt), + () => { + response.session.loginFailed(); SessionCommands.disconnect(); + }, + ); + } else { + SessionCommands.login(rest, password); + } + break; + } + case WebSocketConnectReason.REGISTER: { + const { password, ...rest } = options; + const passwordSalt = getPasswordSalt ? generateSalt() : null; + SessionCommands.register(rest, password, passwordSalt); + break; + } + case WebSocketConnectReason.ACTIVATE_ACCOUNT: { + const { password, ...rest } = options; + if (getPasswordSalt) { + SessionCommands.requestPasswordSalt(rest, + (salt) => SessionCommands.activate(rest, password, salt), + () => { + response.session.accountActivationFailed(); SessionCommands.disconnect(); + }, + ); + } else { + SessionCommands.activate(rest, password); + } + break; + } + case WebSocketConnectReason.PASSWORD_RESET_REQUEST: + SessionCommands.forgotPasswordRequest(options); + break; + case WebSocketConnectReason.PASSWORD_RESET_CHALLENGE: + SessionCommands.forgotPasswordChallenge(options); + break; + case WebSocketConnectReason.PASSWORD_RESET: { + const { newPassword, ...rest } = options; + if (getPasswordSalt) { + SessionCommands.requestPasswordSalt(rest, + (salt) => SessionCommands.forgotPasswordReset(rest, newPassword, salt), + () => { + response.session.resetPasswordFailed(); SessionCommands.disconnect(); + }, + ); + } else { + SessionCommands.forgotPasswordReset(rest, newPassword); + } + break; + } + default: { + SessionCommands.updateStatus(StatusEnum.DISCONNECTED, `Unknown Connection Reason: ${(options as { reason: number }).reason}`); + SessionCommands.disconnect(); + break; + } + } + + response.session.updateInfo(serverName, serverVersion); } diff --git a/webclient/src/websocket/events/session/sessionEvents.spec.ts b/webclient/src/websocket/events/session/sessionEvents.spec.ts index 0cf649f7f..c627a6856 100644 --- a/webclient/src/websocket/events/session/sessionEvents.spec.ts +++ b/webclient/src/websocket/events/session/sessionEvents.spec.ts @@ -1,51 +1,36 @@ // Tests for simple session events that delegate 1:1 to SessionPersistence // or RoomPersistence with minimal logic. -vi.mock('../../WebClient', () => ({ - WebClient: { - instance: { - config: { onServerIdentified: vi.fn() }, - response: { - session: { - gameJoined: vi.fn(), - notifyUser: vi.fn(), - replayAdded: vi.fn(), - serverMessage: vi.fn(), - serverShutdown: vi.fn(), - updateUsers: vi.fn(), - updateInfo: vi.fn(), - userJoined: vi.fn(), - userLeft: vi.fn(), - userMessage: vi.fn(), - addToBuddyList: vi.fn(), - addToIgnoreList: vi.fn(), - removeFromBuddyList: vi.fn(), - removeFromIgnoreList: vi.fn(), - playerPropertiesChanged: vi.fn(), - }, - room: { - updateRooms: vi.fn(), - }, - }, - }, - }, -})); +vi.mock('../../WebClient'); vi.mock('../../config', () => ({ CLIENT_OPTIONS: { autojoinrooms: false }, + PROTOCOL_VERSION: 14, })); vi.mock('../../commands/session', () => ({ joinRoom: vi.fn(), updateStatus: vi.fn(), disconnect: vi.fn(), + login: vi.fn(), + register: vi.fn(), + activate: vi.fn(), + forgotPasswordRequest: vi.fn(), + forgotPasswordChallenge: vi.fn(), + forgotPasswordReset: vi.fn(), + requestPasswordSalt: vi.fn(), })); vi.mock('../../utils', () => ({ sanitizeHtml: vi.fn((msg: string) => msg), + generateSalt: vi.fn().mockReturnValue('randSalt'), + passwordSaltSupported: vi.fn().mockReturnValue(0), +})); + +vi.mock('../../utils/connectionState', () => ({ + consumePendingOptions: vi.fn().mockReturnValue(null), })); -import { useWebClientCleanup } from '../../__mocks__/helpers'; import { Event_AddToListSchema, Event_ConnectionClosedSchema, @@ -71,6 +56,11 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; import * as Config from '../../config'; import * as SessionCmds from '../../commands/session'; +import { consumePendingOptions } from '../../utils/connectionState'; +import { passwordSaltSupported } from '../../utils'; +import { WebSocketConnectReason } from '../../interfaces/ConnectOptions'; +import { StatusEnum } from '../../interfaces/StatusEnum'; +import { Mock } from 'vitest'; import { gameJoined } from './gameJoined'; import { notifyUser } from './notifyUser'; import { replayAdded } from './replayAdded'; @@ -86,8 +76,6 @@ import { listRooms } from './listRooms'; import { connectionClosed } from './connectionClosed'; import { serverIdentification } from './serverIdentification'; -useWebClientCleanup(); - const ConfigMock = Config as { -readonly [K in keyof typeof Config]: (typeof Config)[K] }; // ---------------------------------------------------------------- @@ -387,11 +375,149 @@ describe('connectionClosed', () => { // serverIdentification // ---------------------------------------------------------------- describe('serverIdentification', () => { + const makeInfo = (overrides: Record = {}) => + create(Event_ServerIdentificationSchema, { + serverName: 'TestServer', + serverVersion: '1.0', + protocolVersion: 14, + serverOptions: 0, + ...overrides, + }); - it('calls config.onServerIdentified with the event info', () => { - const info = create(Event_ServerIdentificationSchema, - { serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 }); - serverIdentification(info); - expect(WebClient.instance.config.onServerIdentified).toHaveBeenCalledWith(info); + const makeLoginOptions = () => ({ + host: 'h', port: '1', userName: 'alice', password: 'pw', + reason: WebSocketConnectReason.LOGIN as const, + }); + + beforeEach(() => { + (consumePendingOptions as Mock).mockReturnValue(null); + (passwordSaltSupported as Mock).mockReturnValue(0); + }); + + it('disconnects on protocol version mismatch', () => { + serverIdentification(makeInfo({ protocolVersion: 99 })); + expect(SessionCmds.updateStatus).toHaveBeenCalledWith(StatusEnum.DISCONNECTED, expect.stringContaining('Protocol version mismatch')); + expect(SessionCmds.disconnect).toHaveBeenCalled(); + expect(WebClient.instance.response.session.updateInfo).not.toHaveBeenCalled(); + }); + + it('disconnects when pending options are missing', () => { + serverIdentification(makeInfo()); + expect(SessionCmds.updateStatus).toHaveBeenCalledWith(StatusEnum.DISCONNECTED, 'Missing connection options'); + expect(SessionCmds.disconnect).toHaveBeenCalled(); + expect(WebClient.instance.response.session.updateInfo).not.toHaveBeenCalled(); + }); + + it('LOGIN → calls login without salt when server does not support it', () => { + const opts = makeLoginOptions(); + (consumePendingOptions as Mock).mockReturnValue(opts); + serverIdentification(makeInfo()); + expect(SessionCmds.updateStatus).toHaveBeenCalledWith(StatusEnum.LOGGING_IN, 'Logging In...'); + expect(SessionCmds.login).toHaveBeenCalledWith(expect.objectContaining({ userName: 'alice' }), 'pw'); + expect(WebClient.instance.response.session.updateInfo).toHaveBeenCalledWith('TestServer', '1.0'); + }); + + it('LOGIN → calls requestPasswordSalt when server supports it', () => { + const opts = makeLoginOptions(); + (consumePendingOptions as Mock).mockReturnValue(opts); + (passwordSaltSupported as Mock).mockReturnValue(1); + serverIdentification(makeInfo({ serverOptions: 1 })); + expect(SessionCmds.requestPasswordSalt).toHaveBeenCalledWith( + expect.objectContaining({ userName: 'alice' }), + expect.any(Function), + expect.any(Function), + ); + }); + + it('REGISTER → calls register', () => { + const opts = { + host: 'h', port: '1', userName: 'alice', password: 'pw', + email: 'a@b.com', country: 'US', realName: 'Al', + reason: WebSocketConnectReason.REGISTER as const, + }; + (consumePendingOptions as Mock).mockReturnValue(opts); + serverIdentification(makeInfo()); + expect(SessionCmds.register).toHaveBeenCalledWith( + expect.objectContaining({ userName: 'alice' }), 'pw', null, + ); + }); + + it('REGISTER with password salt → passes generated salt', () => { + const opts = { + host: 'h', port: '1', userName: 'alice', password: 'pw', + email: 'a@b.com', country: 'US', realName: 'Al', + reason: WebSocketConnectReason.REGISTER as const, + }; + (consumePendingOptions as Mock).mockReturnValue(opts); + (passwordSaltSupported as Mock).mockReturnValue(1); + serverIdentification(makeInfo({ serverOptions: 1 })); + expect(SessionCmds.register).toHaveBeenCalledWith( + expect.objectContaining({ userName: 'alice' }), 'pw', 'randSalt', + ); + }); + + it('ACTIVATE_ACCOUNT → calls activate', () => { + const opts = { + host: 'h', port: '1', userName: 'alice', token: 'tok', password: 'pw', + reason: WebSocketConnectReason.ACTIVATE_ACCOUNT as const, + }; + (consumePendingOptions as Mock).mockReturnValue(opts); + serverIdentification(makeInfo()); + expect(SessionCmds.activate).toHaveBeenCalledWith( + expect.objectContaining({ userName: 'alice', token: 'tok' }), 'pw', + ); + }); + + it('PASSWORD_RESET_REQUEST → calls forgotPasswordRequest', () => { + const opts = { + host: 'h', port: '1', userName: 'alice', + reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST as const, + }; + (consumePendingOptions as Mock).mockReturnValue(opts); + serverIdentification(makeInfo()); + expect(SessionCmds.forgotPasswordRequest).toHaveBeenCalledWith(opts); + }); + + it('PASSWORD_RESET_CHALLENGE → calls forgotPasswordChallenge', () => { + const opts = { + host: 'h', port: '1', userName: 'alice', email: 'a@b.com', + reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE as const, + }; + (consumePendingOptions as Mock).mockReturnValue(opts); + serverIdentification(makeInfo()); + expect(SessionCmds.forgotPasswordChallenge).toHaveBeenCalledWith(opts); + }); + + it('PASSWORD_RESET → calls forgotPasswordReset without salt', () => { + const opts = { + host: 'h', port: '1', userName: 'alice', token: 'tok', newPassword: 'newpw', + reason: WebSocketConnectReason.PASSWORD_RESET as const, + }; + (consumePendingOptions as Mock).mockReturnValue(opts); + serverIdentification(makeInfo()); + expect(SessionCmds.forgotPasswordReset).toHaveBeenCalledWith( + expect.objectContaining({ userName: 'alice', token: 'tok' }), 'newpw', + ); + }); + + it('PASSWORD_RESET with salt → calls requestPasswordSalt', () => { + const opts = { + host: 'h', port: '1', userName: 'alice', token: 'tok', newPassword: 'newpw', + reason: WebSocketConnectReason.PASSWORD_RESET as const, + }; + (consumePendingOptions as Mock).mockReturnValue(opts); + (passwordSaltSupported as Mock).mockReturnValue(1); + serverIdentification(makeInfo({ serverOptions: 1 })); + expect(SessionCmds.requestPasswordSalt).toHaveBeenCalledWith( + expect.objectContaining({ userName: 'alice' }), + expect.any(Function), + expect.any(Function), + ); + }); + + it('always calls updateInfo after successful routing', () => { + (consumePendingOptions as Mock).mockReturnValue(makeLoginOptions()); + serverIdentification(makeInfo()); + expect(WebClient.instance.response.session.updateInfo).toHaveBeenCalledWith('TestServer', '1.0'); }); }); diff --git a/webclient/src/websocket/index.ts b/webclient/src/websocket/index.ts index 47325e509..9d0cd9481 100644 --- a/webclient/src/websocket/index.ts +++ b/webclient/src/websocket/index.ts @@ -2,17 +2,31 @@ export * from './commands'; export * from './interfaces'; export { WebClient } from './WebClient'; -export { StatusEnum } from './StatusEnum'; -export type { WebClientConfig, ConnectTarget } from './WebClientConfig'; +export { StatusEnum } from './interfaces/StatusEnum'; +export type { WebClientConfig, ConnectTarget } from './interfaces/WebClientConfig'; export type { KeyOf, GameEventMeta, WebSocketSessionResponseOverrides, WebSocketRoomResponseOverrides, -} from './types'; +} from './interfaces/WebSocketConfig'; export { SessionEvents } from './events/session'; export { RoomEvents } from './events/room'; export { GameEvents } from './events/game'; export { generateSalt, passwordSaltSupported, hashPassword } from './utils'; + +export { WebSocketConnectReason } from './interfaces/ConnectOptions'; +export type { + LoginConnectOptions, + RegisterConnectOptions, + ActivateConnectOptions, + PasswordResetRequestConnectOptions, + PasswordResetChallengeConnectOptions, + PasswordResetConnectOptions, + TestConnectionOptions, + WebSocketConnectOptions, +} from './interfaces/ConnectOptions'; + +export { setPendingOptions, consumePendingOptions } from './utils/connectionState'; diff --git a/webclient/src/websocket/interfaces/ConnectOptions.ts b/webclient/src/websocket/interfaces/ConnectOptions.ts new file mode 100644 index 000000000..a74402fb4 --- /dev/null +++ b/webclient/src/websocket/interfaces/ConnectOptions.ts @@ -0,0 +1,82 @@ +import type { ConnectTarget } from './WebClientConfig'; + +export enum WebSocketConnectReason { + LOGIN, + REGISTER, + ACTIVATE_ACCOUNT, + PASSWORD_RESET_REQUEST, + PASSWORD_RESET_CHALLENGE, + PASSWORD_RESET, + TEST_CONNECTION, +} + +// ── Connect options ─────────────────────────────────────────────────────────── +// Each variant is the enriched input for one session flow: the network +// transport fields (host/port) + the subset of proto Command_* fields the UI +// actually produces (user-entered credentials, tokens, email, etc.) + a +// `reason` discriminator so the websocket layer can route. + +interface ConnectTransport extends ConnectTarget { + keepalive?: number; + autojoinrooms?: boolean; + clientid?: string; +} + +export interface LoginConnectOptions extends ConnectTransport { + reason: WebSocketConnectReason.LOGIN; + userName: string; + password?: string; + hashedPassword?: string; +} + +export interface RegisterConnectOptions extends ConnectTransport { + reason: WebSocketConnectReason.REGISTER; + userName: string; + password: string; + email: string; + country: string; + realName: string; +} + +export interface ActivateConnectOptions extends ConnectTransport { + reason: WebSocketConnectReason.ACTIVATE_ACCOUNT; + userName: string; + token: string; + /** Plaintext password carried through so post-activation auto-login can hash it. */ + password?: string; +} + +export interface PasswordResetRequestConnectOptions extends ConnectTransport { + reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST; + userName: string; +} + +export interface PasswordResetChallengeConnectOptions extends ConnectTransport { + reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE; + userName: string; + email: string; +} + +export interface PasswordResetConnectOptions extends ConnectTransport { + reason: WebSocketConnectReason.PASSWORD_RESET; + userName: string; + token: string; + newPassword: string; +} + +/** + * Test connection has no proto command -- it just opens and closes a socket to + * verify reachability. + */ +export interface TestConnectionOptions extends ConnectTransport { + reason: WebSocketConnectReason.TEST_CONNECTION; +} + +export type WebSocketConnectOptions = + | LoginConnectOptions + | RegisterConnectOptions + | ActivateConnectOptions + | PasswordResetRequestConnectOptions + | PasswordResetChallengeConnectOptions + | PasswordResetConnectOptions + | TestConnectionOptions; diff --git a/webclient/src/websocket/StatusEnum.ts b/webclient/src/websocket/interfaces/StatusEnum.ts similarity index 100% rename from webclient/src/websocket/StatusEnum.ts rename to webclient/src/websocket/interfaces/StatusEnum.ts diff --git a/webclient/src/websocket/WebClientConfig.ts b/webclient/src/websocket/interfaces/WebClientConfig.ts similarity index 70% rename from webclient/src/websocket/WebClientConfig.ts rename to webclient/src/websocket/interfaces/WebClientConfig.ts index 5e562f869..6ff860009 100644 --- a/webclient/src/websocket/WebClientConfig.ts +++ b/webclient/src/websocket/interfaces/WebClientConfig.ts @@ -3,11 +3,10 @@ import type { SessionEvent, RoomEvent, GameEvent, - Event_ServerIdentification, } from '@app/generated'; -import type { GameEventMeta } from './types'; -import type { IWebClientResponse } from './interfaces'; +import type { GameEventMeta } from './WebSocketConfig'; +import type { IWebClientResponse } from '.'; export interface ConnectTarget { host: string; @@ -17,8 +16,6 @@ export interface ConnectTarget { export interface WebClientConfig { response: IWebClientResponse; - onServerIdentified(info: Event_ServerIdentification): void; - sessionEvents: RegistryEntry[]; roomEvents: RegistryEntry[]; gameEvents: RegistryEntry[]; diff --git a/webclient/src/websocket/interfaces/WebClientRequest.ts b/webclient/src/websocket/interfaces/WebClientRequest.ts index b6a8965a8..16db5fd08 100644 --- a/webclient/src/websocket/interfaces/WebClientRequest.ts +++ b/webclient/src/websocket/interfaces/WebClientRequest.ts @@ -36,8 +36,8 @@ import type { GameCommand, } from '@app/generated'; -import type { ConnectTarget } from '../WebClientConfig'; -import type { KeyOf } from '../types'; +import type { ConnectTarget } from './WebClientConfig'; +import type { KeyOf } from './WebSocketConfig'; // ── Auth request type map ──────────────────────────────────────────────────── // Keys = generated *Params type names composed with ConnectTarget. diff --git a/webclient/src/websocket/interfaces/WebClientResponse.ts b/webclient/src/websocket/interfaces/WebClientResponse.ts index 898557c67..cf3bd40d5 100644 --- a/webclient/src/websocket/interfaces/WebClientResponse.ts +++ b/webclient/src/websocket/interfaces/WebClientResponse.ts @@ -44,12 +44,12 @@ import type { ServerInfo_ReplayMatch, } from '@app/generated'; -import type { StatusEnum } from '../StatusEnum'; +import type { StatusEnum } from './StatusEnum'; import type { KeyOf, WebSocketSessionResponseOverrides, WebSocketRoomResponseOverrides, -} from '../types'; +} from './WebSocketConfig'; export interface ISessionResponse { initialized(): void; diff --git a/webclient/src/websocket/types.ts b/webclient/src/websocket/interfaces/WebSocketConfig.ts similarity index 100% rename from webclient/src/websocket/types.ts rename to webclient/src/websocket/interfaces/WebSocketConfig.ts diff --git a/webclient/src/websocket/services/ProtobufService.spec.ts b/webclient/src/websocket/services/ProtobufService.spec.ts index f7701bebe..5e7b58568 100644 --- a/webclient/src/websocket/services/ProtobufService.spec.ts +++ b/webclient/src/websocket/services/ProtobufService.spec.ts @@ -7,13 +7,8 @@ vi.mock('@bufbuild/protobuf', async (importOriginal) => ({ setExtension: vi.fn(), })); -vi.mock('../WebClient', () => ({ - __esModule: true, - default: {}, - WebClient: { _instance: null }, -})); +vi.mock('../WebClient'); -import { useWebClientCleanup } from '../__mocks__/helpers'; import { create, fromBinary, hasExtension, getExtension } from '@bufbuild/protobuf'; import type { GenExtension } from '@bufbuild/protobuf/codegenv2'; @@ -47,8 +42,6 @@ type ProtobufInternal = ProtobufService & { processServerResponse(response: unknown): void; }; -useWebClientCleanup(); - let mockSocket: { isOpen: ReturnType; send: ReturnType }; let mockEvents: EventRegistries; @@ -444,3 +437,72 @@ describe('ProtobufService', () => { }); }); + +// ── Real protobuf round-trip test ───────────────────────────────────────────── +// This describe block does NOT mock @bufbuild/protobuf so it exercises real +// binary serialization. It proves that the schemas ProtobufService uses +// survive a toBinary → fromBinary cycle without data loss. +describe('ProtobufService protobuf round-trip (real @bufbuild/protobuf)', () => { + it('CommandContainer round-trips cmdId through toBinary → fromBinary', async () => { + const { create, toBinary, fromBinary: realFromBinary } = + await vi.importActual('@bufbuild/protobuf'); + const { CommandContainerSchema } = + await vi.importActual('@app/generated'); + + const original = create(CommandContainerSchema, { cmdId: BigInt(42) }); + const bytes = toBinary(CommandContainerSchema, original); + const decoded = realFromBinary(CommandContainerSchema, bytes); + + expect(decoded.cmdId).toBe(BigInt(42)); + }); + + it('ServerMessage RESPONSE round-trips with cmdId and responseCode', async () => { + const { create, toBinary, fromBinary: realFromBinary } = + await vi.importActual('@bufbuild/protobuf'); + const { ServerMessageSchema, ServerMessage_MessageType, ResponseSchema, Response_ResponseCode } = + await vi.importActual('@app/generated'); + + const response = create(ResponseSchema, { + cmdId: BigInt(7), + responseCode: Response_ResponseCode.RespOk, + }); + const msg = create(ServerMessageSchema, { + messageType: ServerMessage_MessageType.RESPONSE, + response, + }); + + const bytes = toBinary(ServerMessageSchema, msg); + const decoded = realFromBinary(ServerMessageSchema, bytes); + + expect(decoded.messageType).toBe(ServerMessage_MessageType.RESPONSE); + expect(decoded.response?.cmdId).toBe(BigInt(7)); + expect(decoded.response?.responseCode).toBe(Response_ResponseCode.RespOk); + }); + + it('SessionCommand with extension round-trips through CommandContainer', async () => { + const { create, toBinary, fromBinary: realFromBinary, setExtension, getExtension: realGetExtension } = + await vi.importActual('@bufbuild/protobuf'); + const { + CommandContainerSchema, SessionCommandSchema, + Command_Ping_ext, Command_PingSchema, + } = await vi.importActual('@app/generated'); + + const pingCmd = create(Command_PingSchema, {}); + const sesCmd = create(SessionCommandSchema, {}); + setExtension(sesCmd, Command_Ping_ext, pingCmd); + + const container = create(CommandContainerSchema, { + cmdId: BigInt(1), + sessionCommand: [sesCmd], + }); + + const bytes = toBinary(CommandContainerSchema, container); + const decoded = realFromBinary(CommandContainerSchema, bytes); + + expect(decoded.cmdId).toBe(BigInt(1)); + expect(decoded.sessionCommand).toHaveLength(1); + + const decodedPing = realGetExtension(decoded.sessionCommand[0], Command_Ping_ext); + expect(decodedPing).toBeDefined(); + }); +}); diff --git a/webclient/src/websocket/services/ProtobufService.ts b/webclient/src/websocket/services/ProtobufService.ts index 45cf3eecf..6c0bb003f 100644 --- a/webclient/src/websocket/services/ProtobufService.ts +++ b/webclient/src/websocket/services/ProtobufService.ts @@ -25,7 +25,7 @@ import { type GameEvent, } from '@app/generated'; -import type { GameEventMeta } from '../types'; +import type { GameEventMeta } from '../interfaces/WebSocketConfig'; import { type CommandOptions, handleResponse } from './command-options'; export interface SocketTransport { diff --git a/webclient/src/websocket/services/WebSocketService.spec.ts b/webclient/src/websocket/services/WebSocketService.spec.ts index e105cd678..c565ac74e 100644 --- a/webclient/src/websocket/services/WebSocketService.spec.ts +++ b/webclient/src/websocket/services/WebSocketService.spec.ts @@ -9,7 +9,7 @@ vi.mock('../config', () => ({ import { WebSocketService } from './WebSocketService'; import type { WebSocketServiceConfig } from './WebSocketService'; import { KeepAliveService } from './KeepAliveService'; -import { StatusEnum } from '../StatusEnum'; +import { StatusEnum } from '../interfaces/StatusEnum'; type WebSocketInternal = WebSocketService & { keepAliveService: KeepAliveService; diff --git a/webclient/src/websocket/services/WebSocketService.ts b/webclient/src/websocket/services/WebSocketService.ts index 7134cbe71..cf2321667 100644 --- a/webclient/src/websocket/services/WebSocketService.ts +++ b/webclient/src/websocket/services/WebSocketService.ts @@ -1,9 +1,9 @@ import { Subject } from 'rxjs'; -import { StatusEnum } from '../StatusEnum'; +import { StatusEnum } from '../interfaces/StatusEnum'; import { KeepAliveService } from './KeepAliveService'; import { CLIENT_OPTIONS } from '../config'; -import type { ConnectTarget } from '../WebClientConfig'; +import type { ConnectTarget } from '../interfaces/WebClientConfig'; export interface WebSocketServiceConfig { keepAliveFn: (pingReceived: () => void) => void; diff --git a/webclient/src/websocket/utils/connectionState.ts b/webclient/src/websocket/utils/connectionState.ts new file mode 100644 index 000000000..de4866462 --- /dev/null +++ b/webclient/src/websocket/utils/connectionState.ts @@ -0,0 +1,13 @@ +import type { WebSocketConnectOptions } from '../interfaces/ConnectOptions'; + +let pendingOptions: WebSocketConnectOptions | null = null; + +export function setPendingOptions(options: WebSocketConnectOptions) { + pendingOptions = options; +} + +export function consumePendingOptions(): WebSocketConnectOptions | null { + const opts = pendingOptions; + pendingOptions = null; + return opts; +}