fix unit tests and refactor types

This commit is contained in:
seavor 2026-04-16 12:45:47 -05:00
parent decebc25c7
commit fea21b5057
75 changed files with 908 additions and 501 deletions

View file

@ -1 +0,0 @@
export const PROTOCOL_VERSION = 14;

View file

@ -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;
}

View file

@ -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,

View file

@ -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<Enriched.LoginConnectOptions, 'reason'>;
ConnectTarget: Omit<Enriched.TestConnectionOptions, 'reason'>;
RegisterParams: Omit<Enriched.RegisterConnectOptions, 'reason'>;
ActivateParams: Omit<Enriched.ActivateConnectOptions, 'reason'>;
ForgotPasswordRequestParams: Omit<Enriched.PasswordResetRequestConnectOptions, 'reason'>;
ForgotPasswordChallengeParams: Omit<Enriched.PasswordResetChallengeConnectOptions, 'reason'>;
ForgotPasswordResetParams: Omit<Enriched.PasswordResetConnectOptions, 'reason'>;
LoginParams: Omit<LoginConnectOptions, 'reason'>;
ConnectTarget: Omit<TestConnectionOptions, 'reason'>;
RegisterParams: Omit<RegisterConnectOptions, 'reason'>;
ActivateParams: Omit<ActivateConnectOptions, 'reason'>;
ForgotPasswordRequestParams: Omit<PasswordResetRequestConnectOptions, 'reason'>;
ForgotPasswordChallengeParams: Omit<PasswordResetChallengeConnectOptions, 'reason'>;
ForgotPasswordResetParams: Omit<PasswordResetConnectOptions, 'reason'>;
}
export class AuthenticationRequestImpl implements IAuthenticationRequest<AppAuthRequestOverrides> {
login(options: Omit<Enriched.LoginConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: App.WebSocketConnectReason.LOGIN });
login(options: Omit<LoginConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: WebSocketConnectReason.LOGIN });
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
}
testConnection(options: Omit<Enriched.TestConnectionOptions, 'reason'>): void {
testConnection(options: Omit<TestConnectionOptions, 'reason'>): void {
WebClient.instance.testConnect({ host: options.host, port: options.port });
}
register(options: Omit<Enriched.RegisterConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: App.WebSocketConnectReason.REGISTER });
register(options: Omit<RegisterConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: WebSocketConnectReason.REGISTER });
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
}
activateAccount(options: Omit<Enriched.ActivateConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT });
activateAccount(options: Omit<ActivateConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: WebSocketConnectReason.ACTIVATE_ACCOUNT });
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
}
resetPasswordRequest(options: Omit<Enriched.PasswordResetRequestConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_REQUEST });
resetPasswordRequest(options: Omit<PasswordResetRequestConnectOptions, 'reason'>): 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<Enriched.PasswordResetChallengeConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE });
resetPasswordChallenge(options: Omit<PasswordResetChallengeConnectOptions, 'reason'>): 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<Enriched.PasswordResetConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: App.WebSocketConnectReason.PASSWORD_RESET });
resetPassword(options: Omit<PasswordResetConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: WebSocketConnectReason.PASSWORD_RESET });
SessionCommands.updateStatus(StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
}

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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<typeof Command_XSchema> & ...`
// because MessageInitShape is a `Message<T> | { 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

View file

@ -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;

View file

@ -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<MessageEvent>;
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) {

View file

@ -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;

View file

@ -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 },
},
};

View file

@ -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(),

View file

@ -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', () => {

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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);

View file

@ -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 {

View file

@ -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 {

View file

@ -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 '.';

View file

@ -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,

View file

@ -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 './';

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -1,3 +1,5 @@
export const PROTOCOL_VERSION = 14;
export const CLIENT_CONFIG = {
clientid: 'webatrice',
clientver: 'webclient-1.0 (2019-10-31)',

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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', () => {

View file

@ -1,4 +1,4 @@
import type { GameEventMeta } from '../../types';
import type { GameEventMeta } from '../../interfaces/WebSocketConfig';
import { WebClient } from '../../WebClient';
/**

View file

@ -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 {

View file

@ -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 {

View file

@ -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';

View file

@ -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';

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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', () => {

View file

@ -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 {

View file

@ -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);
}

View file

@ -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<string, unknown> = {}) =>
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');
});
});

View file

@ -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';

View file

@ -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;

View file

@ -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<unknown, SessionEvent>[];
roomEvents: RegistryEntry<unknown, RoomEvent, RoomEvent>[];
gameEvents: RegistryEntry<unknown, GameEvent, GameEventMeta>[];

View file

@ -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.

View file

@ -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<T extends ResponseMap = WebSocketSessionResponseOverrides> {
initialized(): void;

View file

@ -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<typeof vi.fn>; send: ReturnType<typeof vi.fn> };
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<typeof import('@bufbuild/protobuf')>('@bufbuild/protobuf');
const { CommandContainerSchema } =
await vi.importActual<typeof import('@app/generated')>('@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<typeof import('@bufbuild/protobuf')>('@bufbuild/protobuf');
const { ServerMessageSchema, ServerMessage_MessageType, ResponseSchema, Response_ResponseCode } =
await vi.importActual<typeof import('@app/generated')>('@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<typeof import('@bufbuild/protobuf')>('@bufbuild/protobuf');
const {
CommandContainerSchema, SessionCommandSchema,
Command_Ping_ext, Command_PingSchema,
} = await vi.importActual<typeof import('@app/generated')>('@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();
});
});

View file

@ -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 {

View file

@ -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;

View file

@ -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;

View file

@ -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;
}