From 35be723ebff5bfba7606e00c2c496946459c2ef9 Mon Sep 17 00:00:00 2001 From: seavor Date: Sun, 12 Apr 2026 02:27:03 -0500 Subject: [PATCH] Add near 100% unit test coverage for webclient websocket layer --- webclient/src/websocket/WebClient.spec.ts | 122 +++++ .../websocket/__mocks__/callbackHelpers.ts | 35 ++ webclient/src/websocket/__mocks__/helpers.ts | 74 +++ .../__mocks__/sessionCommandMocks.ts | 132 +++++ .../commands/admin/adminCommands.spec.ts | 104 ++++ .../moderator/moderatorCommands.spec.ts | 239 ++++++++ .../commands/room/roomCommands.spec.ts | 100 ++++ .../session/sessionCommands-complex.spec.ts | 512 ++++++++++++++++++ .../session/sessionCommands-simple.spec.ts | 416 ++++++++++++++ .../events/common/commonEvents.spec.ts | 19 + .../websocket/events/game/gameEvents.spec.ts | 29 + .../websocket/events/room/roomEvents.spec.ts | 63 +++ .../events/session/sessionEvents.spec.ts | 425 +++++++++++++++ .../persistence/AdminPersistence.spec.ts | 37 ++ .../persistence/GamePersistence.spec.ts | 18 + .../persistence/ModeratorPersistence.spec.ts | 84 +++ .../persistence/RoomPersistence.spec.ts | 117 ++++ .../persistence/SessionPersistence.spec.ts | 395 ++++++++++++++ .../websocket/services/BackendService.spec.ts | 119 ++++ .../services/ProtoController.spec.ts | 41 ++ .../services/ProtobufService.spec.ts | 321 +++++++++++ .../services/WebSocketService.spec.ts | 288 ++++++++++ .../websocket/utils/NormalizeService.spec.ts | 110 ++++ .../src/websocket/utils/guid.util.spec.ts | 19 + .../websocket/utils/passwordHasher.spec.ts | 58 ++ .../websocket/utils/sanitizeHtml.util.spec.ts | 55 ++ 26 files changed, 3932 insertions(+) create mode 100644 webclient/src/websocket/WebClient.spec.ts create mode 100644 webclient/src/websocket/__mocks__/callbackHelpers.ts create mode 100644 webclient/src/websocket/__mocks__/helpers.ts create mode 100644 webclient/src/websocket/__mocks__/sessionCommandMocks.ts create mode 100644 webclient/src/websocket/commands/admin/adminCommands.spec.ts create mode 100644 webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts create mode 100644 webclient/src/websocket/commands/room/roomCommands.spec.ts create mode 100644 webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts create mode 100644 webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts create mode 100644 webclient/src/websocket/events/common/commonEvents.spec.ts create mode 100644 webclient/src/websocket/events/game/gameEvents.spec.ts create mode 100644 webclient/src/websocket/events/room/roomEvents.spec.ts create mode 100644 webclient/src/websocket/events/session/sessionEvents.spec.ts create mode 100644 webclient/src/websocket/persistence/AdminPersistence.spec.ts create mode 100644 webclient/src/websocket/persistence/GamePersistence.spec.ts create mode 100644 webclient/src/websocket/persistence/ModeratorPersistence.spec.ts create mode 100644 webclient/src/websocket/persistence/RoomPersistence.spec.ts create mode 100644 webclient/src/websocket/persistence/SessionPersistence.spec.ts create mode 100644 webclient/src/websocket/services/BackendService.spec.ts create mode 100644 webclient/src/websocket/services/ProtoController.spec.ts create mode 100644 webclient/src/websocket/services/ProtobufService.spec.ts create mode 100644 webclient/src/websocket/services/WebSocketService.spec.ts create mode 100644 webclient/src/websocket/utils/NormalizeService.spec.ts create mode 100644 webclient/src/websocket/utils/guid.util.spec.ts create mode 100644 webclient/src/websocket/utils/passwordHasher.spec.ts create mode 100644 webclient/src/websocket/utils/sanitizeHtml.util.spec.ts diff --git a/webclient/src/websocket/WebClient.spec.ts b/webclient/src/websocket/WebClient.spec.ts new file mode 100644 index 000000000..c9fa0298f --- /dev/null +++ b/webclient/src/websocket/WebClient.spec.ts @@ -0,0 +1,122 @@ +jest.mock('./services/WebSocketService', () => ({ + WebSocketService: jest.fn().mockImplementation(() => ({ + message$: { subscribe: jest.fn() }, + connect: jest.fn(), + testConnect: jest.fn(), + disconnect: jest.fn(), + })), +})); + +jest.mock('./services/ProtobufService', () => ({ + ProtobufService: jest.fn().mockImplementation(() => ({ + handleMessageEvent: jest.fn(), + sendKeepAliveCommand: jest.fn(), + resetCommands: jest.fn(), + })), +})); + +jest.mock('./persistence', () => ({ + RoomPersistence: { clearStore: jest.fn() }, + SessionPersistence: { clearStore: jest.fn() }, +})); + +import { WebClient } from './WebClient'; +import { RoomPersistence, SessionPersistence } from './persistence'; +import { StatusEnum } from 'types'; +import { Subject } from 'rxjs'; + +describe('WebClient', () => { + let client: WebClient; + let messageSubject: Subject; + + beforeEach(() => { + jest.clearAllMocks(); + const { ProtobufService } = require('./services/ProtobufService'); + ProtobufService.mockImplementation(() => ({ + handleMessageEvent: jest.fn(), + sendKeepAliveCommand: jest.fn(), + resetCommands: jest.fn(), + })); + messageSubject = new Subject(); + const { WebSocketService } = require('./services/WebSocketService'); + WebSocketService.mockImplementation(() => ({ + message$: messageSubject, + connect: jest.fn(), + testConnect: jest.fn(), + disconnect: jest.fn(), + })); + // suppress console.log from constructor in non-test-env check + jest.spyOn(console, 'log').mockImplementation(() => {}); + client = new WebClient(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('constructor', () => { + it('subscribes socket.message$ to protobuf.handleMessageEvent', () => { + const event = { data: new ArrayBuffer(0) } as MessageEvent; + messageSubject.next(event); + expect(client.protobuf.handleMessageEvent).toHaveBeenCalledWith(event); + }); + }); + + describe('connect', () => { + it('sets connectionAttemptMade to true', () => { + const opts: any = { host: 'h', port: 1 }; + client.connect(opts); + expect(client.connectionAttemptMade).toBe(true); + }); + + it('stores options and calls socket.connect', () => { + const opts: any = { host: 'h', port: 1 }; + client.connect(opts); + expect(client.options).toBe(opts); + expect(client.socket.connect).toHaveBeenCalledWith(opts); + }); + }); + + describe('testConnect', () => { + it('delegates to socket.testConnect', () => { + const opts: any = { host: 'h', port: 1 }; + client.testConnect(opts); + expect(client.socket.testConnect).toHaveBeenCalledWith(opts); + }); + }); + + describe('disconnect', () => { + it('delegates to socket.disconnect', () => { + client.disconnect(); + expect(client.socket.disconnect).toHaveBeenCalled(); + }); + }); + + describe('keepAlive', () => { + it('delegates to protobuf.sendKeepAliveCommand', () => { + const pingCb = jest.fn(); + client.keepAlive(pingCb); + expect(client.protobuf.sendKeepAliveCommand).toHaveBeenCalledWith(pingCb); + }); + }); + + describe('updateStatus', () => { + it('sets the status', () => { + client.updateStatus(StatusEnum.CONNECTED); + expect(client.status).toBe(StatusEnum.CONNECTED); + }); + + it('calls protobuf.resetCommands and clears stores on DISCONNECTED', () => { + client.updateStatus(StatusEnum.DISCONNECTED); + expect(client.protobuf.resetCommands).toHaveBeenCalled(); + expect(RoomPersistence.clearStore).toHaveBeenCalled(); + expect(SessionPersistence.clearStore).toHaveBeenCalled(); + }); + + it('does not clear stores when status is not DISCONNECTED', () => { + client.updateStatus(StatusEnum.CONNECTED); + expect(client.protobuf.resetCommands).not.toHaveBeenCalled(); + expect(RoomPersistence.clearStore).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/webclient/src/websocket/__mocks__/callbackHelpers.ts b/webclient/src/websocket/__mocks__/callbackHelpers.ts new file mode 100644 index 000000000..ffa4f04b5 --- /dev/null +++ b/webclient/src/websocket/__mocks__/callbackHelpers.ts @@ -0,0 +1,35 @@ +/** + * Factory for invoking BackendService command callbacks in unit tests. + * + * @param mockFn - The jest.Mock for the BackendService send method + * (e.g. BackendService.sendSessionCommand as jest.Mock). + * @param optsArgIndex - Index of the options argument in the mock call. + * Defaults to 2 (commandName, params, options). + * Use 3 for sendRoomCommand (roomId, commandName, params, options). + */ +export function makeCallbackHelpers(mockFn: jest.Mock, optsArgIndex = 2) { + function getLastSendOpts() { + const calls = mockFn.mock.calls; + return calls[calls.length - 1]?.[optsArgIndex]; + } + + function invokeOnSuccess(response: any = {}, raw?: any) { + getLastSendOpts()?.onSuccess?.(response, raw ?? response); + } + + function invokeResponseCode(code: number, raw: any = { responseCode: code }) { + const opts = getLastSendOpts(); + if (opts?.onResponseCode?.[code]) opts.onResponseCode[code](raw); + } + + function invokeOnError(code: number = 99, raw: any = {}) { + getLastSendOpts()?.onError?.(code, raw); + } + + function invokeCallback(callbackName: string, ...args: any[]) { + const opts = getLastSendOpts(); + if (opts?.[callbackName]) opts[callbackName](...args); + } + + return { getLastSendOpts, invokeOnSuccess, invokeResponseCode, invokeOnError, invokeCallback }; +} diff --git a/webclient/src/websocket/__mocks__/helpers.ts b/webclient/src/websocket/__mocks__/helpers.ts new file mode 100644 index 000000000..669792ea7 --- /dev/null +++ b/webclient/src/websocket/__mocks__/helpers.ts @@ -0,0 +1,74 @@ +/** + * Shared mock factories for websocket layer unit tests. + * Import the helpers you need in each spec file via: + * import { makeMockProtoRoot, makeMockWebSocket } from '../__mocks__/helpers'; + */ + +/** Builds a minimal mock of ProtoController.root */ +export function makeMockProtoRoot() { + const encode = { finish: jest.fn().mockReturnValue(new Uint8Array()) }; + return { + CommandContainer: { + create: jest.fn(args => ({ ...args })), + encode: jest.fn().mockReturnValue(encode), + }, + SessionCommand: { create: jest.fn(args => ({ ...args })) }, + RoomCommand: { create: jest.fn(args => ({ ...args })) }, + ModeratorCommand: { create: jest.fn(args => ({ ...args })) }, + AdminCommand: { create: jest.fn(args => ({ ...args })) }, + ServerMessage: { + decode: jest.fn(), + MessageType: { + RESPONSE: 'RESPONSE', + ROOM_EVENT: 'ROOM_EVENT', + SESSION_EVENT: 'SESSION_EVENT', + GAME_EVENT_CONTAINER: 'GAME_EVENT_CONTAINER', + }, + }, + Response: { + ResponseCode: { + RespOk: 0, + RespRegistrationRequired: 1, + }, + }, + Event_ServerIdentification: { + ServerOptions: { SupportsPasswordHash: 2 }, + }, + Event_ConnectionClosed: { + CloseReason: { + USER_LIMIT_REACHED: 1, + TOO_MANY_CONNECTIONS: 2, + BANNED: 3, + DEMOTED: 4, + SERVER_SHUTDOWN: 5, + USERNAMEINVALID: 6, + LOGGEDINELSEWERE: 7, + OTHER: 8, + }, + }, + }; +} + +/** Builds a mock WebSocket instance */ +export function makeMockWebSocketInstance() { + return { + send: jest.fn(), + close: jest.fn(), + readyState: WebSocket.OPEN, + binaryType: '' as BinaryType, + onopen: null as any, + onclose: null as any, + onerror: null as any, + onmessage: null as any, + }; +} + +/** Installs a mock WebSocket constructor on global. Returns the mock instance. */ +export function installMockWebSocket() { + const mockInstance = makeMockWebSocketInstance(); + const MockWS = jest.fn(() => mockInstance) as any; + MockWS.OPEN = 1; + MockWS.CLOSED = 3; + (global as any).WebSocket = MockWS; + return { MockWS, mockInstance }; +} diff --git a/webclient/src/websocket/__mocks__/sessionCommandMocks.ts b/webclient/src/websocket/__mocks__/sessionCommandMocks.ts new file mode 100644 index 000000000..7b28335b7 --- /dev/null +++ b/webclient/src/websocket/__mocks__/sessionCommandMocks.ts @@ -0,0 +1,132 @@ +/** + * Shared mock shape factories for session command specs. + * + * Usage inside jest.mock() factory callbacks (require is used because + * jest.mock() is hoisted above imports): + * + * jest.mock('../../WebClient', () => { + * const { makeWebClientMock } = require('../../__mocks__/sessionCommandMocks'); + * return { __esModule: true, default: makeWebClientMock() }; + * }); + */ + +/** Superset WebClient mock — covers all properties used across both session spec files. */ +export function makeWebClientMock() { + return { + connect: jest.fn(), + testConnect: jest.fn(), + disconnect: jest.fn(), + updateStatus: jest.fn(), + clientConfig: { clientid: 'webatrice', clientver: '1.0', clientfeatures: [] }, + options: {}, + protocolVersion: 14, + status: 0, + connectionAttemptMade: false, + }; +} + +/** Superset ProtoController.root mock — includes all ResponseCode values and Event_ServerIdentification. */ +export function makeProtoControllerRootMock() { + return { + Response: { + ResponseCode: { + RespOk: 0, + RespClientUpdateRequired: 1, + RespWrongPassword: 2, + RespUsernameInvalid: 3, + RespWouldOverwriteOldSession: 4, + RespUserIsBanned: 5, + RespRegistrationRequired: 6, + RespClientIdRequired: 7, + RespContextError: 8, + RespAccountNotActivated: 9, + RespRegistrationAccepted: 10, + RespRegistrationAcceptedNeedsActivation: 11, + RespUserAlreadyExists: 12, + RespPasswordTooShort: 13, + RespEmailRequiredToRegister: 14, + RespEmailBlackListed: 15, + RespTooManyRequests: 16, + RespRegistrationDisabled: 17, + RespActivationAccepted: 18, + }, + }, + Event_ServerIdentification: { + ServerOptions: { SupportsPasswordHash: 2 }, + }, + }; +} + +/** Utils mock with unified return values. */ +export function makeUtilsMock() { + return { + hashPassword: jest.fn().mockReturnValue('hashed_pw'), + generateSalt: jest.fn().mockReturnValue('randSalt'), + passwordSaltSupported: jest.fn().mockReturnValue(0), + }; +} + +/** Superset SessionPersistence mock — covers all methods used across both session spec files. */ +export function makeSessionPersistenceMock() { + return { + loginSuccessful: jest.fn(), + loginFailed: jest.fn(), + updateBuddyList: jest.fn(), + updateIgnoreList: jest.fn(), + updateUser: jest.fn(), + updateUsers: jest.fn(), + accountAwaitingActivation: jest.fn(), + accountActivationSuccess: jest.fn(), + accountActivationFailed: jest.fn(), + updateStatus: jest.fn(), + directMessageSent: jest.fn(), + addToList: jest.fn(), + removeFromList: jest.fn(), + deleteServerDeck: jest.fn(), + deleteServerDeckDir: jest.fn(), + updateServerDecks: jest.fn(), + uploadServerDeck: jest.fn(), + createServerDeckDir: jest.fn(), + getGamesOfUser: jest.fn(), + getUserInfo: jest.fn(), + accountPasswordChange: jest.fn(), + accountEditChanged: jest.fn(), + accountImageChanged: jest.fn(), + replayList: jest.fn(), + replayAdded: jest.fn(), + replayModifyMatch: jest.fn(), + replayDeleteMatch: jest.fn(), + resetPasswordChallenge: jest.fn(), + resetPassword: jest.fn(), + resetPasswordFailed: jest.fn(), + resetPasswordSuccess: jest.fn(), + registrationFailed: jest.fn(), + registrationSuccess: jest.fn(), + registrationUserNameError: jest.fn(), + registrationPasswordError: jest.fn(), + registrationEmailError: jest.fn(), + registrationRequiresEmail: jest.fn(), + }; +} + +/** + * Session barrel mock — pure jest.fn() map for all cross-command calls. + * Used as-is by sessionCommands-complex.spec.ts, or spread over jest.requireActual + * by sessionCommands-simple.spec.ts to preserve real implementations for + * the commands under test. + */ +export function makeSessionBarrelMock() { + return { + login: jest.fn(), + register: jest.fn(), + activate: jest.fn(), + forgotPasswordReset: jest.fn(), + forgotPasswordRequest: jest.fn(), + forgotPasswordChallenge: jest.fn(), + requestPasswordSalt: jest.fn(), + listUsers: jest.fn(), + listRooms: jest.fn(), + updateStatus: jest.fn(), + disconnect: jest.fn(), + }; +} diff --git a/webclient/src/websocket/commands/admin/adminCommands.spec.ts b/webclient/src/websocket/commands/admin/adminCommands.spec.ts new file mode 100644 index 000000000..9cfc4aa81 --- /dev/null +++ b/webclient/src/websocket/commands/admin/adminCommands.spec.ts @@ -0,0 +1,104 @@ +jest.mock('../../services/BackendService', () => ({ + BackendService: { + sendAdminCommand: jest.fn(), + }, +})); + +jest.mock('../../persistence', () => ({ + AdminPersistence: { + adjustMod: jest.fn(), + reloadConfig: jest.fn(), + shutdownServer: jest.fn(), + updateServerMessage: jest.fn(), + }, +})); + +import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; +import { BackendService } from '../../services/BackendService'; +import { AdminPersistence } from '../../persistence'; + +const { getLastSendOpts, invokeOnSuccess } = makeCallbackHelpers( + BackendService.sendAdminCommand as jest.Mock +); + +beforeEach(() => jest.clearAllMocks()); + +// ---------------------------------------------------------------- +// adjustMod +// ---------------------------------------------------------------- +describe('adjustMod', () => { + const { adjustMod } = jest.requireActual('./adjustMod'); + + it('calls sendAdminCommand with Command_AdjustMod', () => { + adjustMod('alice', true, false); + expect(BackendService.sendAdminCommand).toHaveBeenCalledWith( + 'Command_AdjustMod', + expect.objectContaining({ userName: 'alice', shouldBeMod: true, shouldBeJudge: false }), + expect.any(Object) + ); + }); + + it('onSuccess calls AdminPersistence.adjustMod', () => { + adjustMod('alice', true, false); + invokeOnSuccess(); + expect(AdminPersistence.adjustMod).toHaveBeenCalledWith('alice', true, false); + }); +}); + +// ---------------------------------------------------------------- +// reloadConfig +// ---------------------------------------------------------------- +describe('reloadConfig', () => { + const { reloadConfig } = jest.requireActual('./reloadConfig'); + + it('calls sendAdminCommand with Command_ReloadConfig', () => { + reloadConfig(); + expect(BackendService.sendAdminCommand).toHaveBeenCalledWith('Command_ReloadConfig', {}, expect.any(Object)); + }); + + it('onSuccess calls AdminPersistence.reloadConfig', () => { + reloadConfig(); + invokeOnSuccess(); + expect(AdminPersistence.reloadConfig).toHaveBeenCalled(); + }); +}); + +// ---------------------------------------------------------------- +// shutdownServer +// ---------------------------------------------------------------- +describe('shutdownServer', () => { + const { shutdownServer } = jest.requireActual('./shutdownServer'); + + it('calls sendAdminCommand with Command_ShutdownServer', () => { + shutdownServer('maintenance', 10); + expect(BackendService.sendAdminCommand).toHaveBeenCalledWith( + 'Command_ShutdownServer', + { reason: 'maintenance', minutes: 10 }, + expect.any(Object) + ); + }); + + it('onSuccess calls AdminPersistence.shutdownServer', () => { + shutdownServer('maintenance', 10); + invokeOnSuccess(); + expect(AdminPersistence.shutdownServer).toHaveBeenCalled(); + }); +}); + +// ---------------------------------------------------------------- +// updateServerMessage +// ---------------------------------------------------------------- +describe('updateServerMessage', () => { + const { updateServerMessage } = jest.requireActual('./updateServerMessage'); + + it('calls sendAdminCommand with Command_UpdateServerMessage', () => { + updateServerMessage(); + expect(BackendService.sendAdminCommand).toHaveBeenCalledWith('Command_UpdateServerMessage', {}, expect.any(Object)); + }); + + it('onSuccess calls AdminPersistence.updateServerMessage', () => { + updateServerMessage(); + invokeOnSuccess(); + expect(AdminPersistence.updateServerMessage).toHaveBeenCalled(); + }); +}); diff --git a/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts b/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts new file mode 100644 index 000000000..4f8b508b1 --- /dev/null +++ b/webclient/src/websocket/commands/moderator/moderatorCommands.spec.ts @@ -0,0 +1,239 @@ +jest.mock('../../services/BackendService', () => ({ + BackendService: { + sendModeratorCommand: jest.fn(), + }, +})); + +jest.mock('../../persistence', () => ({ + ModeratorPersistence: { + banFromServer: jest.fn(), + forceActivateUser: jest.fn(), + getAdminNotes: jest.fn(), + banHistory: jest.fn(), + warnHistory: jest.fn(), + warnListOptions: jest.fn(), + grantReplayAccess: jest.fn(), + updateAdminNotes: jest.fn(), + viewLogs: jest.fn(), + warnUser: jest.fn(), + }, +})); + +import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; +import { BackendService } from '../../services/BackendService'; +import { ModeratorPersistence } from '../../persistence'; + +const { getLastSendOpts, invokeOnSuccess } = makeCallbackHelpers( + BackendService.sendModeratorCommand as jest.Mock +); + +beforeEach(() => jest.clearAllMocks()); + +// ---------------------------------------------------------------- +// banFromServer +// ---------------------------------------------------------------- +describe('banFromServer', () => { + const { banFromServer } = jest.requireActual('./banFromServer'); + + it('calls sendModeratorCommand with Command_BanFromServer', () => { + banFromServer(30, 'alice', '1.2.3.4', 'reason', 'visible', 'cid', 1); + expect(BackendService.sendModeratorCommand).toHaveBeenCalledWith( + 'Command_BanFromServer', + expect.objectContaining({ minutes: 30, userName: 'alice' }), + expect.any(Object) + ); + }); + + it('onSuccess calls ModeratorPersistence.banFromServer', () => { + banFromServer(30, 'alice'); + invokeOnSuccess(); + expect(ModeratorPersistence.banFromServer).toHaveBeenCalledWith('alice'); + }); +}); + +// ---------------------------------------------------------------- +// forceActivateUser +// ---------------------------------------------------------------- +describe('forceActivateUser', () => { + const { forceActivateUser } = jest.requireActual('./forceActivateUser'); + + it('calls sendModeratorCommand with Command_ForceActivateUser', () => { + forceActivateUser('alice', 'mod1'); + expect(BackendService.sendModeratorCommand).toHaveBeenCalledWith('Command_ForceActivateUser', expect.any(Object), expect.any(Object)); + }); + + it('onSuccess calls ModeratorPersistence.forceActivateUser', () => { + forceActivateUser('alice', 'mod1'); + invokeOnSuccess(); + expect(ModeratorPersistence.forceActivateUser).toHaveBeenCalledWith('alice', 'mod1'); + }); +}); + +// ---------------------------------------------------------------- +// getAdminNotes +// ---------------------------------------------------------------- +describe('getAdminNotes', () => { + const { getAdminNotes } = jest.requireActual('./getAdminNotes'); + + it('calls sendModeratorCommand with Command_GetAdminNotes', () => { + getAdminNotes('alice'); + expect(BackendService.sendModeratorCommand).toHaveBeenCalledWith( + 'Command_GetAdminNotes', + expect.any(Object), + expect.objectContaining({ responseName: 'Response_GetAdminNotes' }) + ); + }); + + it('onSuccess calls ModeratorPersistence.getAdminNotes with notes', () => { + getAdminNotes('alice'); + const resp = { notes: 'some notes' }; + invokeOnSuccess(resp, { responseCode: 0, '.Response_GetAdminNotes.ext': resp }); + expect(ModeratorPersistence.getAdminNotes).toHaveBeenCalledWith('alice', 'some notes'); + }); +}); + +// ---------------------------------------------------------------- +// getBanHistory +// ---------------------------------------------------------------- +describe('getBanHistory', () => { + const { getBanHistory } = jest.requireActual('./getBanHistory'); + + it('calls sendModeratorCommand with Command_GetBanHistory', () => { + getBanHistory('alice'); + expect(BackendService.sendModeratorCommand).toHaveBeenCalledWith( + 'Command_GetBanHistory', + expect.any(Object), + expect.objectContaining({ responseName: 'Response_BanHistory' }) + ); + }); + + it('onSuccess calls ModeratorPersistence.banHistory with banList', () => { + getBanHistory('alice'); + const resp = { banList: [{ id: 1 }] }; + invokeOnSuccess(resp, { responseCode: 0, '.Response_BanHistory.ext': resp }); + expect(ModeratorPersistence.banHistory).toHaveBeenCalledWith('alice', [{ id: 1 }]); + }); +}); + +// ---------------------------------------------------------------- +// getWarnHistory +// ---------------------------------------------------------------- +describe('getWarnHistory', () => { + const { getWarnHistory } = jest.requireActual('./getWarnHistory'); + + it('calls sendModeratorCommand with Command_GetWarnHistory', () => { + getWarnHistory('alice'); + expect(BackendService.sendModeratorCommand).toHaveBeenCalledWith( + 'Command_GetWarnHistory', + expect.any(Object), + expect.objectContaining({ responseName: 'Response_WarnHistory' }) + ); + }); + + it('onSuccess calls ModeratorPersistence.warnHistory with warnList', () => { + getWarnHistory('alice'); + const resp = { warnList: [{ id: 2 }] }; + invokeOnSuccess(resp, { responseCode: 0, '.Response_WarnHistory.ext': resp }); + expect(ModeratorPersistence.warnHistory).toHaveBeenCalledWith('alice', [{ id: 2 }]); + }); +}); + +// ---------------------------------------------------------------- +// getWarnList +// ---------------------------------------------------------------- +describe('getWarnList', () => { + const { getWarnList } = jest.requireActual('./getWarnList'); + + it('calls sendModeratorCommand with Command_GetWarnList', () => { + getWarnList('mod1', 'alice', 'US'); + expect(BackendService.sendModeratorCommand).toHaveBeenCalledWith( + 'Command_GetWarnList', + expect.any(Object), + expect.objectContaining({ responseName: 'Response_WarnList' }) + ); + }); + + it('onSuccess calls ModeratorPersistence.warnListOptions with warning', () => { + getWarnList('mod1', 'alice', 'US'); + const resp = { warning: ['w1', 'w2'] }; + invokeOnSuccess(resp, { responseCode: 0, '.Response_WarnList.ext': resp }); + expect(ModeratorPersistence.warnListOptions).toHaveBeenCalledWith(['w1', 'w2']); + }); +}); + +// ---------------------------------------------------------------- +// grantReplayAccess +// ---------------------------------------------------------------- +describe('grantReplayAccess', () => { + const { grantReplayAccess } = jest.requireActual('./grantReplayAccess'); + + it('calls sendModeratorCommand with Command_GrantReplayAccess', () => { + grantReplayAccess(10, 'mod1'); + expect(BackendService.sendModeratorCommand).toHaveBeenCalledWith('Command_GrantReplayAccess', expect.any(Object), expect.any(Object)); + }); + + it('onSuccess calls ModeratorPersistence.grantReplayAccess', () => { + grantReplayAccess(10, 'mod1'); + invokeOnSuccess(); + expect(ModeratorPersistence.grantReplayAccess).toHaveBeenCalledWith(10, 'mod1'); + }); +}); + +// ---------------------------------------------------------------- +// updateAdminNotes +// ---------------------------------------------------------------- +describe('updateAdminNotes', () => { + const { updateAdminNotes } = jest.requireActual('./updateAdminNotes'); + + it('calls sendModeratorCommand with Command_UpdateAdminNotes', () => { + updateAdminNotes('alice', 'new notes'); + expect(BackendService.sendModeratorCommand).toHaveBeenCalledWith('Command_UpdateAdminNotes', expect.any(Object), expect.any(Object)); + }); + + it('onSuccess calls ModeratorPersistence.updateAdminNotes', () => { + updateAdminNotes('alice', 'new notes'); + invokeOnSuccess(); + expect(ModeratorPersistence.updateAdminNotes).toHaveBeenCalledWith('alice', 'new notes'); + }); +}); + +// ---------------------------------------------------------------- +// viewLogHistory +// ---------------------------------------------------------------- +describe('viewLogHistory', () => { + const { viewLogHistory } = jest.requireActual('./viewLogHistory'); + + it('calls sendModeratorCommand with Command_ViewLogHistory', () => { + viewLogHistory({ filters: 'all' } as any); + expect(BackendService.sendModeratorCommand).toHaveBeenCalledWith( + 'Command_ViewLogHistory', + expect.any(Object), + expect.objectContaining({ responseName: 'Response_ViewLogHistory' }) + ); + }); + + it('onSuccess calls ModeratorPersistence.viewLogs with logMessage', () => { + viewLogHistory({ filters: 'all' } as any); + const resp = { logMessage: ['log1'] }; + invokeOnSuccess(resp, { responseCode: 0, '.Response_ViewLogHistory.ext': resp }); + expect(ModeratorPersistence.viewLogs).toHaveBeenCalledWith(['log1']); + }); +}); + +// ---------------------------------------------------------------- +// warnUser +// ---------------------------------------------------------------- +describe('warnUser', () => { + const { warnUser } = jest.requireActual('./warnUser'); + + it('calls sendModeratorCommand with Command_WarnUser', () => { + warnUser('alice', 'bad behavior', 'cid'); + expect(BackendService.sendModeratorCommand).toHaveBeenCalledWith('Command_WarnUser', expect.any(Object), expect.any(Object)); + }); + + it('onSuccess calls ModeratorPersistence.warnUser', () => { + warnUser('alice', 'bad behavior', 'cid'); + invokeOnSuccess(); + expect(ModeratorPersistence.warnUser).toHaveBeenCalledWith('alice'); + }); +}); diff --git a/webclient/src/websocket/commands/room/roomCommands.spec.ts b/webclient/src/websocket/commands/room/roomCommands.spec.ts new file mode 100644 index 000000000..652a5d792 --- /dev/null +++ b/webclient/src/websocket/commands/room/roomCommands.spec.ts @@ -0,0 +1,100 @@ +jest.mock('../../services/BackendService', () => ({ + BackendService: { + sendRoomCommand: jest.fn(), + }, +})); + +jest.mock('../../persistence', () => ({ + RoomPersistence: { + gameCreated: jest.fn(), + joinedGame: jest.fn(), + leaveRoom: jest.fn(), + }, +})); + +import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; +import { BackendService } from '../../services/BackendService'; +import { RoomPersistence } from '../../persistence'; + +const { getLastSendOpts, invokeOnSuccess } = makeCallbackHelpers( + BackendService.sendRoomCommand as jest.Mock, + 3 // sendRoomCommand(roomId, commandName, params, options) — options at index 3 +); + +beforeEach(() => jest.clearAllMocks()); + +// ---------------------------------------------------------------- +// createGame +// ---------------------------------------------------------------- +describe('createGame', () => { + const { createGame } = jest.requireActual('./createGame'); + + it('calls sendRoomCommand with Command_CreateGame', () => { + createGame(5, { maxPlayers: 4 } as any); + expect(BackendService.sendRoomCommand).toHaveBeenCalledWith(5, 'Command_CreateGame', { maxPlayers: 4 }, expect.any(Object)); + }); + + it('onSuccess calls RoomPersistence.gameCreated with roomId', () => { + createGame(5, {} as any); + invokeOnSuccess(); + expect(RoomPersistence.gameCreated).toHaveBeenCalledWith(5); + }); +}); + +// ---------------------------------------------------------------- +// joinGame +// ---------------------------------------------------------------- +describe('joinGame', () => { + const { joinGame } = jest.requireActual('./joinGame'); + + it('calls sendRoomCommand with Command_JoinGame', () => { + joinGame(7, { gameId: 42, password: '' } as any); + expect(BackendService.sendRoomCommand).toHaveBeenCalledWith(7, 'Command_JoinGame', { gameId: 42, password: '' }, expect.any(Object)); + }); + + it('onSuccess calls RoomPersistence.joinedGame with roomId and gameId', () => { + joinGame(7, { gameId: 42 } as any); + invokeOnSuccess(); + expect(RoomPersistence.joinedGame).toHaveBeenCalledWith(7, 42); + }); +}); + +// ---------------------------------------------------------------- +// leaveRoom +// ---------------------------------------------------------------- +describe('leaveRoom', () => { + const { leaveRoom } = jest.requireActual('./leaveRoom'); + + it('calls sendRoomCommand with Command_LeaveRoom', () => { + leaveRoom(3); + expect(BackendService.sendRoomCommand).toHaveBeenCalledWith(3, 'Command_LeaveRoom', {}, expect.any(Object)); + }); + + it('onSuccess calls RoomPersistence.leaveRoom with roomId', () => { + leaveRoom(3); + invokeOnSuccess(); + expect(RoomPersistence.leaveRoom).toHaveBeenCalledWith(3); + }); +}); + +// ---------------------------------------------------------------- +// roomSay +// ---------------------------------------------------------------- +describe('roomSay', () => { + const { roomSay } = jest.requireActual('./roomSay'); + + it('calls sendRoomCommand with trimmed message', () => { + roomSay(2, ' hello '); + expect(BackendService.sendRoomCommand).toHaveBeenCalledWith(2, 'Command_RoomSay', { message: 'hello' }, expect.any(Object)); + }); + + it('does not call sendRoomCommand when message is blank', () => { + roomSay(2, ' '); + expect(BackendService.sendRoomCommand).not.toHaveBeenCalled(); + }); + + it('does not call sendRoomCommand when message is empty string', () => { + roomSay(2, ''); + expect(BackendService.sendRoomCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts b/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts new file mode 100644 index 000000000..c6c11c41b --- /dev/null +++ b/webclient/src/websocket/commands/session/sessionCommands-complex.spec.ts @@ -0,0 +1,512 @@ +// Tests for complex session commands that call webClient directly +// or have multiple branching callbacks. + +jest.mock('../../services/BackendService', () => ({ + BackendService: { + sendSessionCommand: jest.fn(), + }, +})); + +jest.mock('../../persistence', () => { + const { makeSessionPersistenceMock } = require('../../__mocks__/sessionCommandMocks'); + return { + SessionPersistence: makeSessionPersistenceMock(), + RoomPersistence: {}, + }; +}); + +jest.mock('../../WebClient', () => { + const { makeWebClientMock } = require('../../__mocks__/sessionCommandMocks'); + return { __esModule: true, default: makeWebClientMock() }; +}); + +jest.mock('../../services/ProtoController', () => { + const { makeProtoControllerRootMock } = require('../../__mocks__/sessionCommandMocks'); + return { ProtoController: { root: makeProtoControllerRootMock() } }; +}); + +jest.mock('../../utils', () => { + const { makeUtilsMock } = require('../../__mocks__/sessionCommandMocks'); + return makeUtilsMock(); +}); + +// Intercept all re-exported commands to avoid recursive real invocations +jest.mock('./', () => { + const { makeSessionBarrelMock } = require('../../__mocks__/sessionCommandMocks'); + return makeSessionBarrelMock(); +}); + +import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; +import { BackendService } from '../../services/BackendService'; +import { SessionPersistence } from '../../persistence'; +import webClient from '../../WebClient'; +import * as SessionIndexMocks from './'; +import { StatusEnum, WebSocketConnectReason } from 'types'; +import { hashPassword, generateSalt, passwordSaltSupported } from '../../utils'; + +const { getLastSendOpts, invokeOnSuccess, invokeResponseCode, invokeOnError } = makeCallbackHelpers( + BackendService.sendSessionCommand as jest.Mock +); + +beforeEach(() => { + jest.clearAllMocks(); + (hashPassword as jest.Mock).mockReturnValue('hashed_pw'); + (generateSalt as jest.Mock).mockReturnValue('randSalt'); + (passwordSaltSupported as jest.Mock).mockReturnValue(0); +}); + +// ---------------------------------------------------------------- +// connect.ts +// ---------------------------------------------------------------- +describe('connect', () => { + const { connect } = jest.requireActual('./connect'); + + it('calls updateStatus CONNECTING for LOGIN reason', () => { + connect({ host: 'h', port: 1 } as any, WebSocketConnectReason.LOGIN); + expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(StatusEnum.CONNECTING, 'Connecting...'); + expect(webClient.connect).toHaveBeenCalled(); + }); + + it('calls updateStatus CONNECTING for REGISTER reason', () => { + connect({ host: 'h', port: 1 } as any, WebSocketConnectReason.REGISTER); + expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(StatusEnum.CONNECTING, 'Connecting...'); + }); + + it('calls updateStatus CONNECTING for ACTIVATE_ACCOUNT reason', () => { + connect({ host: 'h', port: 1 } as any, WebSocketConnectReason.ACTIVATE_ACCOUNT); + expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(StatusEnum.CONNECTING, 'Connecting...'); + }); + + it('calls updateStatus CONNECTING for PASSWORD_RESET_REQUEST reason', () => { + connect({ host: 'h', port: 1 } as any, WebSocketConnectReason.PASSWORD_RESET_REQUEST); + expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(StatusEnum.CONNECTING, 'Connecting...'); + }); + + it('calls updateStatus CONNECTING for PASSWORD_RESET_CHALLENGE reason', () => { + connect({ host: 'h', port: 1 } as any, WebSocketConnectReason.PASSWORD_RESET_CHALLENGE); + expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(StatusEnum.CONNECTING, 'Connecting...'); + }); + + it('calls updateStatus CONNECTING for PASSWORD_RESET reason', () => { + connect({ host: 'h', port: 1 } as any, WebSocketConnectReason.PASSWORD_RESET); + expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(StatusEnum.CONNECTING, 'Connecting...'); + }); + + it('calls testConnect for TEST_CONNECTION reason', () => { + connect({ host: 'h', port: 1 } as any, WebSocketConnectReason.TEST_CONNECTION); + expect(webClient.testConnect).toHaveBeenCalled(); + expect(webClient.connect).not.toHaveBeenCalled(); + }); + + it('calls updateStatus DISCONNECTED for unknown reason', () => { + connect({ host: 'h', port: 1 } as any, 999 as WebSocketConnectReason); + expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(StatusEnum.DISCONNECTED, expect.stringContaining('Unknown')); + }); +}); + +// ---------------------------------------------------------------- +// updateStatus.ts +// ---------------------------------------------------------------- +describe('updateStatus', () => { + const { updateStatus } = jest.requireActual('./updateStatus'); + + it('calls SessionPersistence.updateStatus and webClient.updateStatus', () => { + updateStatus(StatusEnum.CONNECTED, 'OK'); + expect(SessionPersistence.updateStatus).toHaveBeenCalledWith(StatusEnum.CONNECTED, 'OK'); + expect(webClient.updateStatus).toHaveBeenCalledWith(StatusEnum.CONNECTED); + }); +}); + +// ---------------------------------------------------------------- +// login.ts +// ---------------------------------------------------------------- +describe('login', () => { + const { login } = jest.requireActual('./login'); + + it('sends Command_Login with plain password when no salt', () => { + login({ userName: 'alice', password: 'pw' } as any); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith( + 'Command_Login', + expect.objectContaining({ userName: 'alice', password: 'pw' }), + expect.any(Object) + ); + }); + + it('sends Command_Login with hashedPassword when salt is given', () => { + login({ userName: 'alice', password: 'pw' } as any, 'salt'); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith( + 'Command_Login', + expect.objectContaining({ hashedPassword: 'hashed_pw' }), + expect.any(Object) + ); + }); + + it('uses options.hashedPassword if provided', () => { + login({ userName: 'alice', password: 'pw', hashedPassword: 'pre_hashed' } as any, 'salt'); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith( + 'Command_Login', + expect.objectContaining({ hashedPassword: 'pre_hashed' }), + expect.any(Object) + ); + }); + + it('onSuccess dispatches buddy/ignore/user and calls listUsers/listRooms', () => { + login({ userName: 'alice', password: 'pw' } as any); + const loginResp = { buddyList: [], ignoreList: [], userInfo: { name: 'alice' } }; + invokeOnSuccess(loginResp, { responseCode: 0, '.Response_Login.ext': loginResp }); + expect(SessionPersistence.updateBuddyList).toHaveBeenCalledWith([]); + expect(SessionPersistence.updateIgnoreList).toHaveBeenCalledWith([]); + expect(SessionPersistence.updateUser).toHaveBeenCalledWith({ name: 'alice' }); + expect(SessionPersistence.loginSuccessful).toHaveBeenCalled(); + expect(SessionIndexMocks.listUsers).toHaveBeenCalled(); + expect(SessionIndexMocks.listRooms).toHaveBeenCalled(); + expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(StatusEnum.LOGGED_IN, 'Logged in.'); + }); + + it('onResponseCode RespClientUpdateRequired calls onLoginError', () => { + login({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(1); + expect(SessionPersistence.loginFailed).toHaveBeenCalled(); + expect(SessionIndexMocks.disconnect).toHaveBeenCalled(); + }); + + it('onResponseCode RespWrongPassword', () => { + login({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(2); + expect(SessionPersistence.loginFailed).toHaveBeenCalled(); + }); + + it('onResponseCode RespUsernameInvalid', () => { + login({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(3); + expect(SessionPersistence.loginFailed).toHaveBeenCalled(); + }); + + it('onResponseCode RespWouldOverwriteOldSession', () => { + login({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(4); + expect(SessionPersistence.loginFailed).toHaveBeenCalled(); + }); + + it('onResponseCode RespUserIsBanned', () => { + login({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(5); + expect(SessionPersistence.loginFailed).toHaveBeenCalled(); + }); + + it('onResponseCode RespRegistrationRequired', () => { + login({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(6); + expect(SessionPersistence.loginFailed).toHaveBeenCalled(); + }); + + it('onResponseCode RespClientIdRequired', () => { + login({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(7); + expect(SessionPersistence.loginFailed).toHaveBeenCalled(); + }); + + it('onResponseCode RespContextError', () => { + login({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(8); + expect(SessionPersistence.loginFailed).toHaveBeenCalled(); + }); + + it('onResponseCode RespAccountNotActivated calls accountAwaitingActivation', () => { + login({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(9); + expect(SessionPersistence.accountAwaitingActivation).toHaveBeenCalled(); + expect(SessionPersistence.loginFailed).toHaveBeenCalled(); + }); + + it('onError calls onLoginError with unknown error message', () => { + login({ userName: 'alice', password: 'pw' } as any); + invokeOnError(999); + expect(SessionPersistence.loginFailed).toHaveBeenCalled(); + }); +}); + +// ---------------------------------------------------------------- +// register.ts +// ---------------------------------------------------------------- +describe('register', () => { + const { register } = jest.requireActual('./register'); + + it('sends Command_Register with plain password when no salt', () => { + register({ userName: 'alice', password: 'pw', email: 'a@b.com', country: 'US', realName: 'Al' } as any); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith( + 'Command_Register', + expect.objectContaining({ userName: 'alice', password: 'pw' }), + expect.any(Object) + ); + }); + + it('uses hashedPassword when salt is provided', () => { + register({ userName: 'alice', password: 'pw' } as any, 'salt'); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith( + 'Command_Register', + expect.objectContaining({ hashedPassword: 'hashed_pw' }), + expect.any(Object) + ); + }); + + it('RespRegistrationAccepted calls login without salt and registrationSuccess', () => { + register({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(10); + expect(SessionIndexMocks.login).toHaveBeenCalledWith(expect.any(Object), undefined); + expect(SessionPersistence.registrationSuccess).toHaveBeenCalled(); + }); + + it('RespRegistrationAccepted forwards salt to login', () => { + register({ userName: 'alice', password: 'pw' } as any, 'mySalt'); + invokeResponseCode(10); + expect(SessionIndexMocks.login).toHaveBeenCalledWith(expect.any(Object), 'mySalt'); + expect(SessionPersistence.registrationSuccess).toHaveBeenCalled(); + }); + + it('RespRegistrationAcceptedNeedsActivation calls accountAwaitingActivation', () => { + register({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(11); + expect(SessionPersistence.accountAwaitingActivation).toHaveBeenCalled(); + expect(SessionIndexMocks.disconnect).toHaveBeenCalled(); + }); + + it('RespUserAlreadyExists calls registrationUserNameError', () => { + register({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(12); + expect(SessionPersistence.registrationUserNameError).toHaveBeenCalled(); + }); + + it('RespUsernameInvalid calls registrationUserNameError', () => { + register({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(3); + expect(SessionPersistence.registrationUserNameError).toHaveBeenCalled(); + }); + + it('RespPasswordTooShort calls registrationPasswordError', () => { + register({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(13); + expect(SessionPersistence.registrationPasswordError).toHaveBeenCalled(); + }); + + it('RespEmailRequiredToRegister calls registrationRequiresEmail', () => { + register({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(14); + expect(SessionPersistence.registrationRequiresEmail).toHaveBeenCalled(); + }); + + it('RespEmailBlackListed calls registrationEmailError', () => { + register({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(15); + expect(SessionPersistence.registrationEmailError).toHaveBeenCalled(); + }); + + it('RespTooManyRequests calls registrationEmailError', () => { + register({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(16); + expect(SessionPersistence.registrationEmailError).toHaveBeenCalled(); + }); + + it('RespRegistrationDisabled calls registrationFailed', () => { + register({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(17); + expect(SessionPersistence.registrationFailed).toHaveBeenCalled(); + }); + + it('RespUserIsBanned calls registrationFailed with raw.reasonStr and raw.endTime', () => { + register({ userName: 'alice', password: 'pw' } as any); + invokeResponseCode(5, { reasonStr: 'bad user', endTime: 9999 }); + expect(SessionPersistence.registrationFailed).toHaveBeenCalledWith('bad user', 9999); + }); + + it('onError calls registrationFailed', () => { + register({ userName: 'alice', password: 'pw' } as any); + invokeOnError(); + expect(SessionPersistence.registrationFailed).toHaveBeenCalled(); + }); +}); + +// ---------------------------------------------------------------- +// activate.ts +// ---------------------------------------------------------------- +describe('activate', () => { + const { activate } = jest.requireActual('./activate'); + + it('sends Command_Activate', () => { + activate({ userName: 'alice', token: 'tok' } as any); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_Activate', expect.any(Object), expect.any(Object)); + }); + + it('RespActivationAccepted calls accountActivationSuccess and login with salt', () => { + activate({ userName: 'alice', token: 'tok' } as any, 'salt'); + invokeResponseCode(18); + expect(SessionPersistence.accountActivationSuccess).toHaveBeenCalled(); + expect(SessionIndexMocks.login).toHaveBeenCalledWith(expect.any(Object), 'salt'); + }); + + it('onError calls accountActivationFailed and disconnect', () => { + activate({ userName: 'alice', token: 'tok' } as any); + invokeOnError(); + expect(SessionPersistence.accountActivationFailed).toHaveBeenCalled(); + expect(SessionIndexMocks.disconnect).toHaveBeenCalled(); + }); +}); + +// ---------------------------------------------------------------- +// forgotPasswordChallenge.ts +// ---------------------------------------------------------------- +describe('forgotPasswordChallenge', () => { + const { forgotPasswordChallenge } = jest.requireActual('./forgotPasswordChallenge'); + + it('sends Command_ForgotPasswordChallenge', () => { + forgotPasswordChallenge({ userName: 'alice', email: 'a@b.com' } as any); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_ForgotPasswordChallenge', expect.any(Object), expect.any(Object)); + }); + + it('onSuccess calls resetPassword and disconnect', () => { + forgotPasswordChallenge({ userName: 'alice', email: 'a@b.com' } as any); + invokeOnSuccess(); + expect(SessionPersistence.resetPassword).toHaveBeenCalled(); + expect(SessionIndexMocks.disconnect).toHaveBeenCalled(); + }); + + it('onError calls resetPasswordFailed and disconnect', () => { + forgotPasswordChallenge({ userName: 'alice', email: 'a@b.com' } as any); + invokeOnError(); + expect(SessionPersistence.resetPasswordFailed).toHaveBeenCalled(); + expect(SessionIndexMocks.disconnect).toHaveBeenCalled(); + }); +}); + +// ---------------------------------------------------------------- +// forgotPasswordRequest.ts +// ---------------------------------------------------------------- +describe('forgotPasswordRequest', () => { + const { forgotPasswordRequest } = jest.requireActual('./forgotPasswordRequest'); + + it('sends Command_ForgotPasswordRequest', () => { + forgotPasswordRequest({ userName: 'alice' } as any); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_ForgotPasswordRequest', expect.any(Object), expect.any(Object)); + }); + + it('onSuccess with challengeEmail calls resetPasswordChallenge', () => { + forgotPasswordRequest({ userName: 'alice' } as any); + const resp = { challengeEmail: true }; + invokeOnSuccess(resp, { responseCode: 0, '.Response_ForgotPasswordRequest.ext': resp }); + expect(SessionPersistence.resetPasswordChallenge).toHaveBeenCalled(); + expect(SessionIndexMocks.disconnect).toHaveBeenCalled(); + }); + + it('onSuccess without challengeEmail calls resetPassword', () => { + forgotPasswordRequest({ userName: 'alice' } as any); + const resp = { challengeEmail: false }; + invokeOnSuccess(resp, { responseCode: 0, '.Response_ForgotPasswordRequest.ext': resp }); + expect(SessionPersistence.resetPassword).toHaveBeenCalled(); + expect(SessionIndexMocks.disconnect).toHaveBeenCalled(); + }); + + it('onError calls resetPasswordFailed and disconnect', () => { + forgotPasswordRequest({ userName: 'alice' } as any); + invokeOnError(); + expect(SessionPersistence.resetPasswordFailed).toHaveBeenCalled(); + expect(SessionIndexMocks.disconnect).toHaveBeenCalled(); + }); +}); + +// ---------------------------------------------------------------- +// forgotPasswordReset.ts +// ---------------------------------------------------------------- +describe('forgotPasswordReset', () => { + const { forgotPasswordReset } = jest.requireActual('./forgotPasswordReset'); + + it('sends Command_ForgotPasswordReset with plain newPassword when no salt', () => { + forgotPasswordReset({ userName: 'alice', token: 'tok', newPassword: 'newpw' } as any); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith( + 'Command_ForgotPasswordReset', + expect.objectContaining({ newPassword: 'newpw' }), + expect.any(Object) + ); + }); + + it('sends hashed new password when salt provided', () => { + forgotPasswordReset({ userName: 'alice', token: 'tok', newPassword: 'newpw' } as any, 'salt'); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith( + 'Command_ForgotPasswordReset', + expect.objectContaining({ hashedNewPassword: 'hashed_pw' }), + expect.any(Object) + ); + }); + + it('onSuccess calls resetPasswordSuccess and disconnect', () => { + forgotPasswordReset({ userName: 'alice', token: 'tok', newPassword: 'newpw' } as any); + invokeOnSuccess(); + expect(SessionPersistence.resetPasswordSuccess).toHaveBeenCalled(); + expect(SessionIndexMocks.disconnect).toHaveBeenCalled(); + }); + + it('onError calls resetPasswordFailed and disconnect', () => { + forgotPasswordReset({ userName: 'alice', token: 'tok', newPassword: 'newpw' } as any); + invokeOnError(); + expect(SessionPersistence.resetPasswordFailed).toHaveBeenCalled(); + expect(SessionIndexMocks.disconnect).toHaveBeenCalled(); + }); +}); + +// ---------------------------------------------------------------- +// requestPasswordSalt.ts +// ---------------------------------------------------------------- +describe('requestPasswordSalt', () => { + const { requestPasswordSalt } = jest.requireActual('./requestPasswordSalt'); + + it('sends Command_RequestPasswordSalt', () => { + requestPasswordSalt({ userName: 'alice', reason: WebSocketConnectReason.LOGIN } as any); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_RequestPasswordSalt', expect.any(Object), expect.any(Object)); + }); + + it('onSuccess with LOGIN reason calls login', () => { + requestPasswordSalt({ userName: 'alice', reason: WebSocketConnectReason.LOGIN } as any); + const resp = { passwordSalt: 'salt123' }; + invokeOnSuccess(resp, { responseCode: 0, '.Response_PasswordSalt.ext': resp }); + expect(SessionIndexMocks.login).toHaveBeenCalledWith(expect.any(Object), 'salt123'); + }); + + it('onSuccess with ACTIVATE_ACCOUNT reason calls activate', () => { + requestPasswordSalt({ userName: 'alice', reason: WebSocketConnectReason.ACTIVATE_ACCOUNT } as any); + const resp = { passwordSalt: 'salt123' }; + invokeOnSuccess(resp, { responseCode: 0, '.Response_PasswordSalt.ext': resp }); + expect(SessionIndexMocks.activate).toHaveBeenCalledWith(expect.any(Object), 'salt123'); + }); + + it('onSuccess with PASSWORD_RESET reason calls forgotPasswordReset', () => { + requestPasswordSalt({ userName: 'alice', reason: WebSocketConnectReason.PASSWORD_RESET } as any); + const resp = { passwordSalt: 'salt123' }; + invokeOnSuccess(resp, { responseCode: 0, '.Response_PasswordSalt.ext': resp }); + expect(SessionIndexMocks.forgotPasswordReset).toHaveBeenCalled(); + }); + + it('onResponseCode RespRegistrationRequired calls updateStatus and disconnect', () => { + requestPasswordSalt({ userName: 'alice', reason: WebSocketConnectReason.LOGIN } as any); + invokeResponseCode(6); + expect(SessionIndexMocks.updateStatus).toHaveBeenCalledWith(StatusEnum.DISCONNECTED, expect.any(String)); + expect(SessionIndexMocks.disconnect).toHaveBeenCalled(); + }); + + it('onResponseCode RespRegistrationRequired with ACTIVATE_ACCOUNT calls accountActivationFailed', () => { + requestPasswordSalt({ userName: 'alice', reason: WebSocketConnectReason.ACTIVATE_ACCOUNT } as any); + invokeResponseCode(6); + expect(SessionPersistence.accountActivationFailed).toHaveBeenCalled(); + }); + + it('onError calls updateStatus DISCONNECTED and disconnect', () => { + requestPasswordSalt({ userName: 'alice', reason: WebSocketConnectReason.LOGIN } as any); + invokeOnError(); + expect(SessionIndexMocks.updateStatus).toHaveBeenCalled(); + expect(SessionIndexMocks.disconnect).toHaveBeenCalled(); + }); + + it('onError with PASSWORD_RESET reason calls resetPasswordFailed', () => { + requestPasswordSalt({ userName: 'alice', reason: WebSocketConnectReason.PASSWORD_RESET } as any); + invokeOnError(); + expect(SessionPersistence.resetPasswordFailed).toHaveBeenCalled(); + }); +}); diff --git a/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts b/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts new file mode 100644 index 000000000..1bd86f568 --- /dev/null +++ b/webclient/src/websocket/commands/session/sessionCommands-simple.spec.ts @@ -0,0 +1,416 @@ +// Shared mock setup for session command tests + +jest.mock('../../services/BackendService', () => ({ + BackendService: { + sendSessionCommand: jest.fn(), + }, +})); + +jest.mock('../../persistence', () => { + const { makeSessionPersistenceMock } = require('../../__mocks__/sessionCommandMocks'); + return { + SessionPersistence: makeSessionPersistenceMock(), + RoomPersistence: { joinRoom: jest.fn() }, + }; +}); + +jest.mock('../../WebClient', () => { + const { makeWebClientMock } = require('../../__mocks__/sessionCommandMocks'); + return { __esModule: true, default: makeWebClientMock() }; +}); + +jest.mock('../../services/ProtoController', () => { + const { makeProtoControllerRootMock } = require('../../__mocks__/sessionCommandMocks'); + return { ProtoController: { root: makeProtoControllerRootMock() } }; +}); + +jest.mock('../../utils', () => { + const { makeUtilsMock } = require('../../__mocks__/sessionCommandMocks'); + return makeUtilsMock(); +}); + +// Mock session commands barrel to allow cross-command calls while keeping real implementations +jest.mock('./', () => { + const actual = jest.requireActual('./'); + const { makeSessionBarrelMock } = require('../../__mocks__/sessionCommandMocks'); + return { ...actual, ...makeSessionBarrelMock() }; +}); + +import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers'; +import { BackendService } from '../../services/BackendService'; +import { SessionPersistence } from '../../persistence'; +import { RoomPersistence } from '../../persistence'; +import webClient from '../../WebClient'; +import * as SessionCommands from './'; +import { hashPassword, generateSalt, passwordSaltSupported } from '../../utils'; + +const { invokeOnSuccess, invokeCallback } = makeCallbackHelpers( + BackendService.sendSessionCommand as jest.Mock +); + +beforeEach(() => { + jest.clearAllMocks(); + (hashPassword as jest.Mock).mockReturnValue('hashed_pw'); + (generateSalt as jest.Mock).mockReturnValue('randSalt'); + (passwordSaltSupported as jest.Mock).mockReturnValue(0); +}); + +// ---------------------------------------------------------------- + +describe('accountEdit', () => { + const { accountEdit } = jest.requireActual('./accountEdit'); + beforeEach(() => jest.clearAllMocks()); + + it('sends Command_AccountEdit with correct params', () => { + accountEdit('pw', 'Alice', 'a@b.com', 'US'); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith( + 'Command_AccountEdit', + { passwordCheck: 'pw', realName: 'Alice', email: 'a@b.com', country: 'US' }, + expect.any(Object) + ); + }); + + it('calls SessionPersistence.accountEditChanged on success', () => { + accountEdit('pw', 'Alice', 'a@b.com', 'US'); + invokeOnSuccess(); + expect(SessionPersistence.accountEditChanged).toHaveBeenCalledWith('Alice', 'a@b.com', 'US'); + }); +}); + +describe('accountImage', () => { + const { accountImage } = jest.requireActual('./accountImage'); + beforeEach(() => jest.clearAllMocks()); + + it('sends Command_AccountImage', () => { + const img = new Uint8Array([1, 2]); + accountImage(img); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_AccountImage', { image: img }, expect.any(Object)); + }); + + it('calls SessionPersistence.accountImageChanged on success', () => { + const img = new Uint8Array([1, 2]); + accountImage(img); + invokeOnSuccess(); + expect(SessionPersistence.accountImageChanged).toHaveBeenCalledWith(img); + }); +}); + +describe('accountPassword', () => { + const { accountPassword } = jest.requireActual('./accountPassword'); + beforeEach(() => jest.clearAllMocks()); + + it('sends Command_AccountPassword', () => { + accountPassword('old', 'new', 'hashed'); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith( + 'Command_AccountPassword', + { oldPassword: 'old', newPassword: 'new', hashedNewPassword: 'hashed' }, + expect.any(Object) + ); + }); + + it('calls SessionPersistence.accountPasswordChange on success', () => { + accountPassword('old', 'new', 'hashed'); + invokeOnSuccess(); + expect(SessionPersistence.accountPasswordChange).toHaveBeenCalled(); + }); +}); + +describe('deckDel', () => { + const { deckDel } = jest.requireActual('./deckDel'); + beforeEach(() => jest.clearAllMocks()); + + it('sends Command_DeckDel', () => { + deckDel(42); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_DeckDel', { deckId: 42 }, expect.any(Object)); + }); + + it('calls deleteServerDeck on success', () => { + deckDel(42); + invokeOnSuccess(); + expect(SessionPersistence.deleteServerDeck).toHaveBeenCalledWith(42); + }); +}); + +describe('deckDelDir', () => { + const { deckDelDir } = jest.requireActual('./deckDelDir'); + beforeEach(() => jest.clearAllMocks()); + + it('sends Command_DeckDelDir', () => { + deckDelDir('/path'); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_DeckDelDir', { path: '/path' }, expect.any(Object)); + }); + + it('calls deleteServerDeckDir on success', () => { + deckDelDir('/path'); + invokeOnSuccess(); + expect(SessionPersistence.deleteServerDeckDir).toHaveBeenCalledWith('/path'); + }); +}); + +describe('deckList', () => { + const { deckList } = jest.requireActual('./deckList'); + beforeEach(() => jest.clearAllMocks()); + + it('sends Command_DeckList', () => { + deckList(); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_DeckList', {}, expect.any(Object)); + }); + + it('calls updateServerDecks on success', () => { + deckList(); + const resp = { folders: [] }; + invokeOnSuccess(resp, { responseCode: 0, '.Response_DeckList.ext': resp }); + expect(SessionPersistence.updateServerDecks).toHaveBeenCalledWith(resp); + }); +}); + +describe('deckNewDir', () => { + const { deckNewDir } = jest.requireActual('./deckNewDir'); + beforeEach(() => jest.clearAllMocks()); + + it('sends Command_DeckNewDir', () => { + deckNewDir('/path', 'dir'); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_DeckNewDir', { path: '/path', dirName: 'dir' }, expect.any(Object)); + }); + + it('calls createServerDeckDir on success', () => { + deckNewDir('/path', 'dir'); + invokeOnSuccess(); + expect(SessionPersistence.createServerDeckDir).toHaveBeenCalledWith('/path', 'dir'); + }); +}); + +describe('deckUpload', () => { + const { deckUpload } = jest.requireActual('./deckUpload'); + beforeEach(() => jest.clearAllMocks()); + + it('sends Command_DeckUpload', () => { + deckUpload('/path', 1, 'content'); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith( + 'Command_DeckUpload', + { path: '/path', deckId: 1, deckList: 'content' }, + expect.any(Object) + ); + }); + + it('calls uploadServerDeck on success', () => { + deckUpload('/path', 1, 'content'); + const resp = { newFile: { id: 1 } }; + invokeOnSuccess(resp, { responseCode: 0, '.Response_DeckUpload.ext': resp }); + expect(SessionPersistence.uploadServerDeck).toHaveBeenCalledWith('/path', resp.newFile); + }); +}); + +describe('disconnect', () => { + const { disconnect } = jest.requireActual('./disconnect'); + beforeEach(() => jest.clearAllMocks()); + + it('calls webClient.disconnect', () => { + disconnect(); + expect(webClient.disconnect).toHaveBeenCalled(); + }); +}); + +describe('getGamesOfUser', () => { + const { getGamesOfUser } = jest.requireActual('./getGamesOfUser'); + beforeEach(() => jest.clearAllMocks()); + + it('sends Command_GetGamesOfUser', () => { + getGamesOfUser('alice'); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_GetGamesOfUser', { userName: 'alice' }, expect.any(Object)); + }); + + it('calls getGamesOfUser on success', () => { + getGamesOfUser('alice'); + const resp = { gameList: [] }; + invokeOnSuccess(resp, { responseCode: 0, '.Response_GetGamesOfUser.ext': resp }); + expect(SessionPersistence.getGamesOfUser).toHaveBeenCalledWith('alice', resp); + }); +}); + +describe('getUserInfo', () => { + const { getUserInfo } = jest.requireActual('./getUserInfo'); + beforeEach(() => jest.clearAllMocks()); + + it('sends Command_GetUserInfo', () => { + getUserInfo('alice'); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_GetUserInfo', { userName: 'alice' }, expect.any(Object)); + }); + + it('calls getUserInfo on success', () => { + getUserInfo('alice'); + const resp = { userInfo: { name: 'alice' } }; + invokeOnSuccess(resp, { responseCode: 0, '.Response_GetUserInfo.ext': resp }); + expect(SessionPersistence.getUserInfo).toHaveBeenCalledWith(resp.userInfo); + }); +}); + +describe('joinRoom', () => { + const { joinRoom } = jest.requireActual('./joinRoom'); + beforeEach(() => jest.clearAllMocks()); + + it('sends Command_JoinRoom', () => { + joinRoom(5); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_JoinRoom', { roomId: 5 }, expect.any(Object)); + }); + + it('calls RoomPersistence.joinRoom on success', () => { + joinRoom(5); + const resp = { roomInfo: { roomId: 5 } }; + invokeOnSuccess(resp, { responseCode: 0, '.Response_JoinRoom.ext': resp }); + expect(RoomPersistence.joinRoom).toHaveBeenCalledWith(resp.roomInfo); + }); +}); + +describe('listRooms (command)', () => { + const { listRooms } = jest.requireActual('./listRooms'); + beforeEach(() => jest.clearAllMocks()); + + it('sends Command_ListRooms', () => { + listRooms(); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_ListRooms', {}, {}); + }); +}); + +describe('listUsers', () => { + const { listUsers } = jest.requireActual('./listUsers'); + beforeEach(() => jest.clearAllMocks()); + + it('sends Command_ListUsers', () => { + listUsers(); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_ListUsers', {}, expect.any(Object)); + }); + + it('calls SessionPersistence.updateUsers with the user list on success', () => { + listUsers(); + const resp = { userList: [{ name: 'Alice' }] }; + invokeOnSuccess(resp, { responseCode: 0, '.Response_ListUsers.ext': resp }); + expect(SessionPersistence.updateUsers).toHaveBeenCalledWith([{ name: 'Alice' }]); + }); +}); + +describe('message', () => { + const { message } = jest.requireActual('./message'); + beforeEach(() => jest.clearAllMocks()); + + it('sends Command_Message', () => { + message('bob', 'hi'); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_Message', { userName: 'bob', message: 'hi' }, expect.any(Object)); + }); + + it('calls directMessageSent on success', () => { + message('bob', 'hi'); + invokeOnSuccess(); + expect(SessionPersistence.directMessageSent).toHaveBeenCalledWith('bob', 'hi'); + }); +}); + +describe('ping', () => { + const { ping } = jest.requireActual('./ping'); + beforeEach(() => jest.clearAllMocks()); + + it('sends Command_Ping', () => { + const pingReceived = jest.fn(); + ping(pingReceived); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_Ping', {}, expect.any(Object)); + }); + + it('calls pingReceived via onResponse', () => { + const pingReceived = jest.fn(); + ping(pingReceived); + const raw = {}; + invokeCallback('onResponse', raw); + expect(pingReceived).toHaveBeenCalledWith(raw); + }); +}); + +describe('replayDeleteMatch', () => { + const { replayDeleteMatch } = jest.requireActual('./replayDeleteMatch'); + beforeEach(() => jest.clearAllMocks()); + + it('sends Command_ReplayDeleteMatch', () => { + replayDeleteMatch(7); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_ReplayDeleteMatch', { gameId: 7 }, expect.any(Object)); + }); + + it('calls replayDeleteMatch on success', () => { + replayDeleteMatch(7); + invokeOnSuccess(); + expect(SessionPersistence.replayDeleteMatch).toHaveBeenCalledWith(7); + }); +}); + +describe('replayList', () => { + const { replayList } = jest.requireActual('./replayList'); + beforeEach(() => jest.clearAllMocks()); + + it('sends Command_ReplayList', () => { + replayList(); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_ReplayList', {}, expect.any(Object)); + }); + + it('calls replayList on success', () => { + replayList(); + const resp = { matchList: [] }; + invokeOnSuccess(resp, { responseCode: 0, '.Response_ReplayList.ext': resp }); + expect(SessionPersistence.replayList).toHaveBeenCalledWith([]); + }); +}); + +describe('replayModifyMatch', () => { + const { replayModifyMatch } = jest.requireActual('./replayModifyMatch'); + beforeEach(() => jest.clearAllMocks()); + + it('sends Command_ReplayModifyMatch', () => { + replayModifyMatch(7, true); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_ReplayModifyMatch', { gameId: 7, doNotHide: true }, expect.any(Object)); + }); + + it('calls replayModifyMatch on success', () => { + replayModifyMatch(7, true); + invokeOnSuccess(); + expect(SessionPersistence.replayModifyMatch).toHaveBeenCalledWith(7, true); + }); +}); + +describe('addToList / addToBuddyList / addToIgnoreList', () => { + const { addToList, addToBuddyList, addToIgnoreList } = jest.requireActual('./addToList'); + beforeEach(() => jest.clearAllMocks()); + + it('addToBuddyList sends Command_AddToList with list=buddy', () => { + addToBuddyList('alice'); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_AddToList', { list: 'buddy', userName: 'alice' }, expect.any(Object)); + }); + + it('addToIgnoreList sends Command_AddToList with list=ignore', () => { + addToIgnoreList('bob'); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_AddToList', { list: 'ignore', userName: 'bob' }, expect.any(Object)); + }); + + it('onSuccess calls SessionPersistence.addToList', () => { + addToList('buddy', 'alice'); + invokeOnSuccess(); + expect(SessionPersistence.addToList).toHaveBeenCalledWith('buddy', 'alice'); + }); +}); + +describe('removeFromList / removeFromBuddyList / removeFromIgnoreList', () => { + const { removeFromList, removeFromBuddyList, removeFromIgnoreList } = jest.requireActual('./removeFromList'); + beforeEach(() => jest.clearAllMocks()); + + it('removeFromBuddyList sends Command_RemoveFromList with list=buddy', () => { + removeFromBuddyList('alice'); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_RemoveFromList', { list: 'buddy', userName: 'alice' }, expect.any(Object)); + }); + + it('removeFromIgnoreList sends Command_RemoveFromList with list=ignore', () => { + removeFromIgnoreList('bob'); + expect(BackendService.sendSessionCommand).toHaveBeenCalledWith('Command_RemoveFromList', { list: 'ignore', userName: 'bob' }, expect.any(Object)); + }); + + it('onSuccess calls SessionPersistence.removeFromList', () => { + removeFromList('buddy', 'alice'); + invokeOnSuccess(); + expect(SessionPersistence.removeFromList).toHaveBeenCalledWith('buddy', 'alice'); + }); +}); diff --git a/webclient/src/websocket/events/common/commonEvents.spec.ts b/webclient/src/websocket/events/common/commonEvents.spec.ts new file mode 100644 index 000000000..a080dc1e4 --- /dev/null +++ b/webclient/src/websocket/events/common/commonEvents.spec.ts @@ -0,0 +1,19 @@ +jest.mock('../../persistence', () => ({ + SessionPersistence: { + playerPropertiesChanged: jest.fn(), + }, +})); + +import { SessionPersistence } from '../../persistence'; + +beforeEach(() => jest.clearAllMocks()); + +describe('playerPropertiesChanged', () => { + const { playerPropertiesChanged } = jest.requireActual('./playerPropertiesChanged'); + + it('delegates to SessionPersistence.playerPropertiesChanged', () => { + const payload = { gameId: 1, player: { playerId: 2 } } as any; + playerPropertiesChanged(payload); + expect(SessionPersistence.playerPropertiesChanged).toHaveBeenCalledWith(payload); + }); +}); diff --git a/webclient/src/websocket/events/game/gameEvents.spec.ts b/webclient/src/websocket/events/game/gameEvents.spec.ts new file mode 100644 index 000000000..34154fa7e --- /dev/null +++ b/webclient/src/websocket/events/game/gameEvents.spec.ts @@ -0,0 +1,29 @@ +jest.mock('../../persistence', () => ({ + GamePersistence: { + joinGame: jest.fn(), + leaveGame: jest.fn(), + }, +})); + +import { GamePersistence } from '../../persistence'; + +beforeEach(() => jest.clearAllMocks()); + +describe('joinGame event', () => { + const { joinGame } = jest.requireActual('./joinGame'); + + it('delegates to GamePersistence.joinGame', () => { + const data = { gameId: 5, player: { playerId: 1 } } as any; + joinGame(data); + expect(GamePersistence.joinGame).toHaveBeenCalledWith(data); + }); +}); + +describe('leaveGame event', () => { + const { leaveGame } = jest.requireActual('./leaveGame'); + + it('delegates to GamePersistence.leaveGame', () => { + leaveGame(42 as any); + expect(GamePersistence.leaveGame).toHaveBeenCalledWith(42); + }); +}); diff --git a/webclient/src/websocket/events/room/roomEvents.spec.ts b/webclient/src/websocket/events/room/roomEvents.spec.ts new file mode 100644 index 000000000..79b28aada --- /dev/null +++ b/webclient/src/websocket/events/room/roomEvents.spec.ts @@ -0,0 +1,63 @@ +jest.mock('../../persistence', () => ({ + RoomPersistence: { + userJoined: jest.fn(), + userLeft: jest.fn(), + updateGames: jest.fn(), + removeMessages: jest.fn(), + addMessage: jest.fn(), + }, +})); + +import { RoomPersistence } from '../../persistence'; + +const makeRoomEvent = (roomId: number) => ({ roomEvent: { roomId } }); + +beforeEach(() => jest.clearAllMocks()); + +describe('joinRoom room event', () => { + const { joinRoom } = jest.requireActual('./joinRoom'); + + it('calls RoomPersistence.userJoined with roomId and userInfo', () => { + const userInfo = { name: 'alice' } as any; + joinRoom({ userInfo }, makeRoomEvent(3)); + expect(RoomPersistence.userJoined).toHaveBeenCalledWith(3, userInfo); + }); +}); + +describe('leaveRoom room event', () => { + const { leaveRoom } = jest.requireActual('./leaveRoom'); + + it('calls RoomPersistence.userLeft with roomId and name', () => { + leaveRoom({ name: 'alice' }, makeRoomEvent(4)); + expect(RoomPersistence.userLeft).toHaveBeenCalledWith(4, 'alice'); + }); +}); + +describe('listGames room event', () => { + const { listGames } = jest.requireActual('./listGames'); + + it('calls RoomPersistence.updateGames with roomId and gameList', () => { + const gameList = [{ gameId: 1 }] as any; + listGames({ gameList }, makeRoomEvent(5)); + expect(RoomPersistence.updateGames).toHaveBeenCalledWith(5, gameList); + }); +}); + +describe('removeMessages room event', () => { + const { removeMessages } = jest.requireActual('./removeMessages'); + + it('calls RoomPersistence.removeMessages with roomId, name, amount', () => { + removeMessages({ name: 'bob', amount: 10 }, makeRoomEvent(6)); + expect(RoomPersistence.removeMessages).toHaveBeenCalledWith(6, 'bob', 10); + }); +}); + +describe('roomSay room event', () => { + const { roomSay } = jest.requireActual('./roomSay'); + + it('calls RoomPersistence.addMessage with roomId and message', () => { + const msg = { text: 'hello' } as any; + roomSay(msg, makeRoomEvent(7)); + expect(RoomPersistence.addMessage).toHaveBeenCalledWith(7, msg); + }); +}); diff --git a/webclient/src/websocket/events/session/sessionEvents.spec.ts b/webclient/src/websocket/events/session/sessionEvents.spec.ts new file mode 100644 index 000000000..35ad9bcaa --- /dev/null +++ b/webclient/src/websocket/events/session/sessionEvents.spec.ts @@ -0,0 +1,425 @@ +// Tests for simple session events that delegate 1:1 to SessionPersistence +// or RoomPersistence with minimal logic. + +jest.mock('../../persistence', () => ({ + SessionPersistence: { + gameJoined: jest.fn(), + notifyUser: jest.fn(), + replayAdded: jest.fn(), + serverMessage: jest.fn(), + serverShutdown: jest.fn(), + updateUsers: jest.fn(), + updateInfo: jest.fn(), + userJoined: jest.fn(), + userLeft: jest.fn(), + userMessage: jest.fn(), + addToBuddyList: jest.fn(), + addToIgnoreList: jest.fn(), + removeFromBuddyList: jest.fn(), + removeFromIgnoreList: jest.fn(), + playerPropertiesChanged: jest.fn(), + }, + RoomPersistence: { + updateRooms: jest.fn(), + }, +})); + +jest.mock('../../WebClient', () => ({ + __esModule: true, + default: { + clientOptions: { autojoinrooms: false }, + options: {}, + protocolVersion: 14, + }, +})); + +jest.mock('../../commands/session', () => ({ + joinRoom: jest.fn(), + updateStatus: jest.fn(), + disconnect: jest.fn(), + login: jest.fn(), + register: jest.fn(), + activate: jest.fn(), + requestPasswordSalt: jest.fn(), + forgotPasswordRequest: jest.fn(), + forgotPasswordChallenge: jest.fn(), + forgotPasswordReset: jest.fn(), +})); + +jest.mock('../../utils', () => ({ + generateSalt: jest.fn().mockReturnValue('newSalt'), + passwordSaltSupported: jest.fn().mockReturnValue(0), +})); + +jest.mock('../../services/ProtoController', () => ({ + ProtoController: { + root: { + Event_ConnectionClosed: { + CloseReason: { + USER_LIMIT_REACHED: 0, + TOO_MANY_CONNECTIONS: 1, + BANNED: 2, + DEMOTED: 3, + SERVER_SHUTDOWN: 4, + USERNAMEINVALID: 5, + LOGGEDINELSEWERE: 6, + OTHER: 7, + }, + }, + }, + }, +})); + +import { WebSocketConnectReason } from 'types'; + +import { SessionPersistence, RoomPersistence } from '../../persistence'; +import webClient from '../../WebClient'; +import * as SessionCmds from '../../commands/session'; +import * as Utils from '../../utils'; + +beforeEach(() => { + jest.clearAllMocks(); + (Utils.generateSalt as jest.Mock).mockReturnValue('newSalt'); + (Utils.passwordSaltSupported as jest.Mock).mockReturnValue(0); +}); + +// ---------------------------------------------------------------- +// gameJoined +// ---------------------------------------------------------------- +describe('gameJoined', () => { + const { gameJoined } = jest.requireActual('./gameJoined'); + + it('calls SessionPersistence.gameJoined', () => { + const data = { gameId: 1 } as any; + gameJoined(data); + expect(SessionPersistence.gameJoined).toHaveBeenCalledWith(data); + }); +}); + +// ---------------------------------------------------------------- +// notifyUser +// ---------------------------------------------------------------- +describe('notifyUser', () => { + const { notifyUser } = jest.requireActual('./notifyUser'); + + it('calls SessionPersistence.notifyUser', () => { + const data = { message: 'yo' } as any; + notifyUser(data); + expect(SessionPersistence.notifyUser).toHaveBeenCalledWith(data); + }); +}); + +// ---------------------------------------------------------------- +// replayAdded +// ---------------------------------------------------------------- +describe('replayAdded', () => { + const { replayAdded } = jest.requireActual('./replayAdded'); + + it('calls SessionPersistence.replayAdded with matchInfo', () => { + replayAdded({ matchInfo: { id: 42 } } as any); + expect(SessionPersistence.replayAdded).toHaveBeenCalledWith({ id: 42 }); + }); +}); + +// ---------------------------------------------------------------- +// serverCompleteList +// ---------------------------------------------------------------- +describe('serverCompleteList', () => { + const { serverCompleteList } = jest.requireActual('./serverCompleteList'); + + it('calls SessionPersistence.updateUsers and RoomPersistence.updateRooms', () => { + serverCompleteList({ userList: ['u'], roomList: ['r'] } as any); + expect(SessionPersistence.updateUsers).toHaveBeenCalledWith(['u']); + expect(RoomPersistence.updateRooms).toHaveBeenCalledWith(['r']); + }); +}); + +// ---------------------------------------------------------------- +// serverMessage +// ---------------------------------------------------------------- +describe('serverMessage', () => { + const { serverMessage } = jest.requireActual('./serverMessage'); + + it('calls SessionPersistence.serverMessage with message', () => { + serverMessage({ message: 'hello server' }); + expect(SessionPersistence.serverMessage).toHaveBeenCalledWith('hello server'); + }); +}); + +// ---------------------------------------------------------------- +// serverShutdown +// ---------------------------------------------------------------- +describe('serverShutdown', () => { + const { serverShutdown } = jest.requireActual('./serverShutdown'); + + it('calls SessionPersistence.serverShutdown', () => { + const payload = { reason: 'maintenance' } as any; + serverShutdown(payload); + expect(SessionPersistence.serverShutdown).toHaveBeenCalledWith(payload); + }); +}); + +// ---------------------------------------------------------------- +// userJoined +// ---------------------------------------------------------------- +describe('userJoined', () => { + const { userJoined } = jest.requireActual('./userJoined'); + + it('calls SessionPersistence.userJoined with userInfo', () => { + userJoined({ userInfo: { name: 'alice' } } as any); + expect(SessionPersistence.userJoined).toHaveBeenCalledWith({ name: 'alice' }); + }); +}); + +// ---------------------------------------------------------------- +// userLeft +// ---------------------------------------------------------------- +describe('userLeft', () => { + const { userLeft } = jest.requireActual('./userLeft'); + + it('calls SessionPersistence.userLeft with name', () => { + userLeft({ name: 'bob' }); + expect(SessionPersistence.userLeft).toHaveBeenCalledWith('bob'); + }); +}); + +// ---------------------------------------------------------------- +// userMessage +// ---------------------------------------------------------------- +describe('userMessage', () => { + const { userMessage } = jest.requireActual('./userMessage'); + + it('calls SessionPersistence.userMessage', () => { + const payload = { userName: 'alice', message: 'hi' } as any; + userMessage(payload); + expect(SessionPersistence.userMessage).toHaveBeenCalledWith(payload); + }); +}); + +// ---------------------------------------------------------------- +// addToList +// ---------------------------------------------------------------- +describe('addToList', () => { + const { addToList } = jest.requireActual('./addToList'); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + afterAll(() => logSpy.mockRestore()); + + it('buddy list → addToBuddyList', () => { + addToList({ listName: 'buddy', userInfo: { name: 'alice' } } as any); + expect(SessionPersistence.addToBuddyList).toHaveBeenCalledWith({ name: 'alice' }); + }); + + it('ignore list → addToIgnoreList', () => { + addToList({ listName: 'ignore', userInfo: { name: 'bob' } } as any); + expect(SessionPersistence.addToIgnoreList).toHaveBeenCalledWith({ name: 'bob' }); + }); + + it('unknown list → console.log', () => { + addToList({ listName: 'unknown', userInfo: {} } as any); + expect(logSpy).toHaveBeenCalled(); + }); +}); + +// ---------------------------------------------------------------- +// removeFromList +// ---------------------------------------------------------------- +describe('removeFromList', () => { + const { removeFromList } = jest.requireActual('./removeFromList'); + + it('buddy list → removeFromBuddyList', () => { + removeFromList({ listName: 'buddy', userName: 'alice' } as any); + expect(SessionPersistence.removeFromBuddyList).toHaveBeenCalledWith('alice'); + }); + + it('ignore list → removeFromIgnoreList', () => { + removeFromList({ listName: 'ignore', userName: 'bob' } as any); + expect(SessionPersistence.removeFromIgnoreList).toHaveBeenCalledWith('bob'); + }); + + it('unknown list → console.log', () => { + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + removeFromList({ listName: 'other', userName: 'x' } as any); + expect(logSpy).toHaveBeenCalled(); + logSpy.mockRestore(); + }); +}); + +// ---------------------------------------------------------------- +// listRooms +// ---------------------------------------------------------------- +describe('listRooms', () => { + const { listRooms } = jest.requireActual('./listRooms'); + + it('calls RoomPersistence.updateRooms', () => { + listRooms({ roomList: [] }); + expect(RoomPersistence.updateRooms).toHaveBeenCalledWith([]); + }); + + it('does not call joinRoom when autojoinrooms is false', () => { + (webClient as any).clientOptions = { autojoinrooms: false }; + listRooms({ roomList: [{ autoJoin: true, roomId: 1 }] } as any); + expect(SessionCmds.joinRoom).not.toHaveBeenCalled(); + }); + + it('calls joinRoom for autoJoin rooms when autojoinrooms is true', () => { + (webClient as any).clientOptions = { autojoinrooms: true }; + listRooms({ roomList: [{ autoJoin: true, roomId: 2 }, { autoJoin: false, roomId: 3 }] } as any); + expect(SessionCmds.joinRoom).toHaveBeenCalledTimes(1); + expect(SessionCmds.joinRoom).toHaveBeenCalledWith(2); + }); +}); + +// ---------------------------------------------------------------- +// connectionClosed +// ---------------------------------------------------------------- +describe('connectionClosed', () => { + const { connectionClosed } = jest.requireActual('./connectionClosed'); + + it('uses reasonStr when provided', () => { + connectionClosed({ reason: 0, reasonStr: 'custom' } as any); + expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'custom'); + }); + + it('USER_LIMIT_REACHED → specific message', () => { + connectionClosed({ reason: 0 } as any); + expect(SessionCmds.updateStatus).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('maximum user capacity') + ); + }); + + it('TOO_MANY_CONNECTIONS → specific message', () => { + connectionClosed({ reason: 1 } as any); + expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('too many concurrent')); + }); + + it('BANNED → specific message', () => { + connectionClosed({ reason: 2 } as any); + expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('banned')); + }); + + it('DEMOTED → specific message', () => { + connectionClosed({ reason: 3 } as any); + expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('demoted')); + }); + + it('SERVER_SHUTDOWN → specific message', () => { + connectionClosed({ reason: 4 } as any); + expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('shutdown')); + }); + + it('USERNAMEINVALID → specific message', () => { + connectionClosed({ reason: 5 } as any); + expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('username')); + }); + + it('LOGGEDINELSEWERE → specific message', () => { + connectionClosed({ reason: 6 } as any); + expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('logged out')); + }); + + it('OTHER → "Unknown reason"', () => { + connectionClosed({ reason: 7 } as any); + expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'Unknown reason'); + }); +}); + +// ---------------------------------------------------------------- +// serverIdentification +// ---------------------------------------------------------------- +describe('serverIdentification', () => { + const { serverIdentification } = jest.requireActual('./serverIdentification'); + + beforeEach(() => { + (webClient as any).protocolVersion = 14; + (webClient as any).options = {}; + }); + + it('disconnects when protocolVersion mismatches', () => { + serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 99, serverOptions: 0 } as any); + expect(SessionCmds.updateStatus).toHaveBeenCalled(); + expect(SessionCmds.disconnect).toHaveBeenCalled(); + }); + + it('LOGIN reason without salt → calls login', () => { + (webClient as any).options = { reason: WebSocketConnectReason.LOGIN }; + (Utils.passwordSaltSupported as jest.Mock).mockReturnValue(0); + serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 } as any); + expect(SessionCmds.login).toHaveBeenCalled(); + }); + + it('LOGIN reason with salt → calls requestPasswordSalt', () => { + (webClient as any).options = { reason: WebSocketConnectReason.LOGIN }; + (Utils.passwordSaltSupported as jest.Mock).mockReturnValue(1); + serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 1 } as any); + expect(SessionCmds.requestPasswordSalt).toHaveBeenCalled(); + }); + + it('REGISTER reason without salt → calls register with null salt', () => { + (webClient as any).options = { reason: WebSocketConnectReason.REGISTER }; + (Utils.passwordSaltSupported as jest.Mock).mockReturnValue(0); + serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 } as any); + expect(SessionCmds.register).toHaveBeenCalledWith(expect.any(Object), null); + }); + + it('REGISTER reason with salt → calls register with generated salt', () => { + (webClient as any).options = { reason: WebSocketConnectReason.REGISTER }; + (Utils.passwordSaltSupported as jest.Mock).mockReturnValue(1); + serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 1 } as any); + expect(SessionCmds.register).toHaveBeenCalledWith(expect.any(Object), 'newSalt'); + }); + + it('ACTIVATE_ACCOUNT reason without salt → calls activate', () => { + (webClient as any).options = { reason: WebSocketConnectReason.ACTIVATE_ACCOUNT }; + (Utils.passwordSaltSupported as jest.Mock).mockReturnValue(0); + serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 } as any); + expect(SessionCmds.activate).toHaveBeenCalled(); + }); + + it('ACTIVATE_ACCOUNT reason with salt → calls requestPasswordSalt', () => { + (webClient as any).options = { reason: WebSocketConnectReason.ACTIVATE_ACCOUNT }; + (Utils.passwordSaltSupported as jest.Mock).mockReturnValue(1); + serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 1 } as any); + expect(SessionCmds.requestPasswordSalt).toHaveBeenCalled(); + }); + + it('PASSWORD_RESET_REQUEST reason → calls forgotPasswordRequest', () => { + (webClient as any).options = { reason: WebSocketConnectReason.PASSWORD_RESET_REQUEST }; + serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 } as any); + expect(SessionCmds.forgotPasswordRequest).toHaveBeenCalled(); + }); + + it('PASSWORD_RESET_CHALLENGE reason → calls forgotPasswordChallenge', () => { + (webClient as any).options = { reason: WebSocketConnectReason.PASSWORD_RESET_CHALLENGE }; + serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 } as any); + expect(SessionCmds.forgotPasswordChallenge).toHaveBeenCalled(); + }); + + it('PASSWORD_RESET reason without salt → calls forgotPasswordReset', () => { + (webClient as any).options = { reason: WebSocketConnectReason.PASSWORD_RESET }; + (Utils.passwordSaltSupported as jest.Mock).mockReturnValue(0); + serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 } as any); + expect(SessionCmds.forgotPasswordReset).toHaveBeenCalled(); + }); + + it('PASSWORD_RESET reason with salt → calls requestPasswordSalt', () => { + (webClient as any).options = { reason: WebSocketConnectReason.PASSWORD_RESET }; + (Utils.passwordSaltSupported as jest.Mock).mockReturnValue(1); + serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 1 } as any); + expect(SessionCmds.requestPasswordSalt).toHaveBeenCalled(); + }); + + it('unknown reason → updateStatus DISCONNECTED and disconnect', () => { + (webClient as any).options = { reason: 999 }; + serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 } as any); + expect(SessionCmds.updateStatus).toHaveBeenCalled(); + expect(SessionCmds.disconnect).toHaveBeenCalled(); + }); + + it('updates webClient.options to empty and calls SessionPersistence.updateInfo', () => { + (webClient as any).options = { reason: WebSocketConnectReason.LOGIN }; + serverIdentification({ serverName: 'myServer', serverVersion: '2.0', protocolVersion: 14, serverOptions: 0 } as any); + expect(SessionPersistence.updateInfo).toHaveBeenCalledWith('myServer', '2.0'); + expect((webClient as any).options).toEqual({}); + }); +}); diff --git a/webclient/src/websocket/persistence/AdminPersistence.spec.ts b/webclient/src/websocket/persistence/AdminPersistence.spec.ts new file mode 100644 index 000000000..8e34e2baf --- /dev/null +++ b/webclient/src/websocket/persistence/AdminPersistence.spec.ts @@ -0,0 +1,37 @@ +jest.mock('store', () => ({ + ServerDispatch: { + adjustMod: jest.fn(), + reloadConfig: jest.fn(), + shutdownServer: jest.fn(), + updateServerMessage: jest.fn(), + }, +})); + +import { AdminPersistence } from './AdminPersistence'; +import { ServerDispatch } from 'store'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('AdminPersistence', () => { + it('adjustMod passes userName, shouldBeMod, shouldBeJudge', () => { + AdminPersistence.adjustMod('alice', true, false); + expect(ServerDispatch.adjustMod).toHaveBeenCalledWith('alice', true, false); + }); + + it('reloadConfig -> ServerDispatch.reloadConfig', () => { + AdminPersistence.reloadConfig(); + expect(ServerDispatch.reloadConfig).toHaveBeenCalled(); + }); + + it('shutdownServer -> ServerDispatch.shutdownServer', () => { + AdminPersistence.shutdownServer(); + expect(ServerDispatch.shutdownServer).toHaveBeenCalled(); + }); + + it('updateServerMessage -> ServerDispatch.updateServerMessage', () => { + AdminPersistence.updateServerMessage(); + expect(ServerDispatch.updateServerMessage).toHaveBeenCalled(); + }); +}); diff --git a/webclient/src/websocket/persistence/GamePersistence.spec.ts b/webclient/src/websocket/persistence/GamePersistence.spec.ts new file mode 100644 index 000000000..2720b58ca --- /dev/null +++ b/webclient/src/websocket/persistence/GamePersistence.spec.ts @@ -0,0 +1,18 @@ +import { GamePersistence } from './GamePersistence'; + +describe('GamePersistence', () => { + it('joinGame logs to console', () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const data = { playerId: 1 } as any; + GamePersistence.joinGame(data); + expect(spy).toHaveBeenCalledWith('joinGame', data); + spy.mockRestore(); + }); + + it('leaveGame logs to console', () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + GamePersistence.leaveGame(0 as any); + expect(spy).toHaveBeenCalledWith('leaveGame', 0); + spy.mockRestore(); + }); +}); diff --git a/webclient/src/websocket/persistence/ModeratorPersistence.spec.ts b/webclient/src/websocket/persistence/ModeratorPersistence.spec.ts new file mode 100644 index 000000000..317fa422c --- /dev/null +++ b/webclient/src/websocket/persistence/ModeratorPersistence.spec.ts @@ -0,0 +1,84 @@ +jest.mock('store', () => ({ + ServerDispatch: { + banFromServer: jest.fn(), + banHistory: jest.fn(), + viewLogs: jest.fn(), + warnHistory: jest.fn(), + warnListOptions: jest.fn(), + warnUser: jest.fn(), + grantReplayAccess: jest.fn(), + forceActivateUser: jest.fn(), + getAdminNotes: jest.fn(), + updateAdminNotes: jest.fn(), + }, +})); + +jest.mock('../utils/NormalizeService', () => ({ + __esModule: true, + default: { + normalizeLogs: jest.fn((logs: any) => ({ normalized: logs })), + }, +})); + +import { ModeratorPersistence } from './ModeratorPersistence'; +import { ServerDispatch } from 'store'; +import NormalizeService from '../utils/NormalizeService'; + +beforeEach(() => { + jest.clearAllMocks(); + (NormalizeService.normalizeLogs as jest.Mock).mockImplementation((logs: any) => ({ normalized: logs })); +}); + +describe('ModeratorPersistence', () => { + it('banFromServer passes userName', () => { + ModeratorPersistence.banFromServer('alice'); + expect(ServerDispatch.banFromServer).toHaveBeenCalledWith('alice'); + }); + + it('banHistory passes userName and banHistory', () => { + ModeratorPersistence.banHistory('alice', []); + expect(ServerDispatch.banHistory).toHaveBeenCalledWith('alice', []); + }); + + it('viewLogs normalizes logs and dispatches', () => { + const logs = [{ targetType: 'room' }] as any; + ModeratorPersistence.viewLogs(logs); + expect(NormalizeService.normalizeLogs).toHaveBeenCalledWith(logs); + expect(ServerDispatch.viewLogs).toHaveBeenCalledWith({ normalized: logs }); + }); + + it('warnHistory passes userName and warnHistory', () => { + ModeratorPersistence.warnHistory('bob', []); + expect(ServerDispatch.warnHistory).toHaveBeenCalledWith('bob', []); + }); + + it('warnListOptions passes warnList', () => { + ModeratorPersistence.warnListOptions([]); + expect(ServerDispatch.warnListOptions).toHaveBeenCalledWith([]); + }); + + it('warnUser passes userName', () => { + ModeratorPersistence.warnUser('carol'); + expect(ServerDispatch.warnUser).toHaveBeenCalledWith('carol'); + }); + + it('grantReplayAccess passes replayId and moderatorName', () => { + ModeratorPersistence.grantReplayAccess(10, 'mod1'); + expect(ServerDispatch.grantReplayAccess).toHaveBeenCalledWith(10, 'mod1'); + }); + + it('forceActivateUser passes usernameToActivate and moderatorName', () => { + ModeratorPersistence.forceActivateUser('user1', 'mod1'); + expect(ServerDispatch.forceActivateUser).toHaveBeenCalledWith('user1', 'mod1'); + }); + + it('getAdminNotes passes userName and notes', () => { + ModeratorPersistence.getAdminNotes('alice', 'some notes'); + expect(ServerDispatch.getAdminNotes).toHaveBeenCalledWith('alice', 'some notes'); + }); + + it('updateAdminNotes passes userName and notes', () => { + ModeratorPersistence.updateAdminNotes('alice', 'new notes'); + expect(ServerDispatch.updateAdminNotes).toHaveBeenCalledWith('alice', 'new notes'); + }); +}); diff --git a/webclient/src/websocket/persistence/RoomPersistence.spec.ts b/webclient/src/websocket/persistence/RoomPersistence.spec.ts new file mode 100644 index 000000000..40874928b --- /dev/null +++ b/webclient/src/websocket/persistence/RoomPersistence.spec.ts @@ -0,0 +1,117 @@ +jest.mock('store', () => ({ + store: { getState: jest.fn().mockReturnValue({}) }, + RoomsDispatch: { + clearStore: jest.fn(), + joinRoom: jest.fn(), + leaveRoom: jest.fn(), + updateRooms: jest.fn(), + updateGames: jest.fn(), + addMessage: jest.fn(), + userJoined: jest.fn(), + userLeft: jest.fn(), + removeMessages: jest.fn(), + gameCreated: jest.fn(), + joinedGame: jest.fn(), + }, + RoomsSelectors: { + getRoom: jest.fn(), + }, +})); + +jest.mock('../utils/NormalizeService', () => ({ + __esModule: true, + default: { + normalizeRoomInfo: jest.fn(), + normalizeGameObject: jest.fn(), + normalizeUserMessage: jest.fn(), + }, +})); + +import { RoomPersistence } from './RoomPersistence'; +import { store, RoomsDispatch, RoomsSelectors } from 'store'; +import NormalizeService from '../utils/NormalizeService'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('RoomPersistence', () => { + it('clearStore -> RoomsDispatch.clearStore', () => { + RoomPersistence.clearStore(); + expect(RoomsDispatch.clearStore).toHaveBeenCalled(); + }); + + it('joinRoom normalizes and dispatches', () => { + const room = { roomId: 1 } as any; + RoomPersistence.joinRoom(room); + expect(NormalizeService.normalizeRoomInfo).toHaveBeenCalledWith(room); + expect(RoomsDispatch.joinRoom).toHaveBeenCalledWith(room); + }); + + it('leaveRoom -> RoomsDispatch.leaveRoom', () => { + RoomPersistence.leaveRoom(5); + expect(RoomsDispatch.leaveRoom).toHaveBeenCalledWith(5); + }); + + it('updateRooms -> RoomsDispatch.updateRooms', () => { + RoomPersistence.updateRooms([]); + expect(RoomsDispatch.updateRooms).toHaveBeenCalledWith([]); + }); + + describe('updateGames', () => { + it('normalizes game when gameType is missing and room exists', () => { + const game = { gameType: null, gameTypes: [1] } as any; + const room = { gametypeMap: { 1: 'Standard' } } as any; + (RoomsSelectors.getRoom as jest.Mock).mockReturnValue(room); + RoomPersistence.updateGames(1, [game]); + expect(NormalizeService.normalizeGameObject).toHaveBeenCalledWith(game, room.gametypeMap); + expect(RoomsDispatch.updateGames).toHaveBeenCalledWith(1, [game]); + }); + + it('does not normalize when game already has gameType', () => { + const game = { gameType: 'Standard' } as any; + RoomPersistence.updateGames(1, [game]); + expect(NormalizeService.normalizeGameObject).not.toHaveBeenCalled(); + }); + + it('does not normalize when room is not found', () => { + const game = { gameType: null } as any; + (RoomsSelectors.getRoom as jest.Mock).mockReturnValue(null); + RoomPersistence.updateGames(1, [game]); + expect(NormalizeService.normalizeGameObject).not.toHaveBeenCalled(); + }); + }); + + it('addMessage normalizes message and dispatches', () => { + const msg = { name: 'alice', message: 'hi' } as any; + RoomPersistence.addMessage(1, msg); + expect(NormalizeService.normalizeUserMessage).toHaveBeenCalledWith(msg); + expect(RoomsDispatch.addMessage).toHaveBeenCalledWith(1, msg); + }); + + it('userJoined -> RoomsDispatch.userJoined', () => { + const user = { name: 'bob' } as any; + RoomPersistence.userJoined(1, user); + expect(RoomsDispatch.userJoined).toHaveBeenCalledWith(1, user); + }); + + it('userLeft -> RoomsDispatch.userLeft', () => { + RoomPersistence.userLeft(1, 'bob'); + expect(RoomsDispatch.userLeft).toHaveBeenCalledWith(1, 'bob'); + }); + + it('removeMessages -> RoomsDispatch.removeMessages', () => { + RoomPersistence.removeMessages(1, 'bob', 5); + expect(RoomsDispatch.removeMessages).toHaveBeenCalledWith(1, 'bob', 5); + }); + + it('gameCreated -> RoomsDispatch.gameCreated', () => { + RoomPersistence.gameCreated(1); + expect(RoomsDispatch.gameCreated).toHaveBeenCalledWith(1); + }); + + it('joinedGame -> RoomsDispatch.joinedGame', () => { + RoomPersistence.joinedGame(1, 99); + expect(RoomsDispatch.joinedGame).toHaveBeenCalledWith(1, 99); + }); +}); diff --git a/webclient/src/websocket/persistence/SessionPersistence.spec.ts b/webclient/src/websocket/persistence/SessionPersistence.spec.ts new file mode 100644 index 000000000..d5dd8e1b5 --- /dev/null +++ b/webclient/src/websocket/persistence/SessionPersistence.spec.ts @@ -0,0 +1,395 @@ +jest.mock('store', () => ({ + ServerDispatch: { + initialized: jest.fn(), + clearStore: jest.fn(), + loginSuccessful: jest.fn(), + loginFailed: jest.fn(), + connectionClosed: jest.fn(), + connectionFailed: jest.fn(), + testConnectionSuccessful: jest.fn(), + testConnectionFailed: jest.fn(), + updateBuddyList: jest.fn(), + addToBuddyList: jest.fn(), + removeFromBuddyList: jest.fn(), + updateIgnoreList: jest.fn(), + addToIgnoreList: jest.fn(), + removeFromIgnoreList: jest.fn(), + updateInfo: jest.fn(), + updateStatus: jest.fn(), + updateUser: jest.fn(), + updateUsers: jest.fn(), + userJoined: jest.fn(), + userLeft: jest.fn(), + serverMessage: jest.fn(), + accountAwaitingActivation: jest.fn(), + accountActivationSuccess: jest.fn(), + accountActivationFailed: jest.fn(), + registrationRequiresEmail: jest.fn(), + registrationSuccess: jest.fn(), + registrationFailed: jest.fn(), + registrationEmailError: jest.fn(), + registrationPasswordError: jest.fn(), + registrationUserNameError: jest.fn(), + resetPasswordChallenge: jest.fn(), + resetPassword: jest.fn(), + resetPasswordSuccess: jest.fn(), + resetPasswordFailed: jest.fn(), + accountPasswordChange: jest.fn(), + accountEditChanged: jest.fn(), + accountImageChanged: jest.fn(), + directMessageSent: jest.fn(), + getUserInfo: jest.fn(), + notifyUser: jest.fn(), + serverShutdown: jest.fn(), + userMessage: jest.fn(), + addToList: jest.fn(), + removeFromList: jest.fn(), + deckDelete: jest.fn(), + backendDecks: jest.fn(), + deckUpload: jest.fn(), + deckNewDir: jest.fn(), + deckDelDir: jest.fn(), + replayList: jest.fn(), + replayAdded: jest.fn(), + replayModifyMatch: jest.fn(), + replayDeleteMatch: jest.fn(), + }, +})); + +jest.mock('websocket/utils', () => ({ + sanitizeHtml: jest.fn((msg: string) => `sanitized:${msg}`), +})); + +jest.mock('../utils/NormalizeService', () => ({ + __esModule: true, + default: { + normalizeBannedUserError: jest.fn((r: string, t: number) => `banned:${r}:${t}`), + }, +})); + +import { SessionPersistence } from './SessionPersistence'; +import { ServerDispatch } from 'store'; +import { sanitizeHtml } from 'websocket/utils'; +import NormalizeService from '../utils/NormalizeService'; +import { StatusEnum } from 'types'; + +beforeEach(() => { + jest.clearAllMocks(); + (sanitizeHtml as jest.Mock).mockImplementation((msg: string) => `sanitized:${msg}`); + (NormalizeService.normalizeBannedUserError as jest.Mock).mockImplementation( + (r: string, t: number) => `banned:${r}:${t}` + ); +}); + +describe('SessionPersistence', () => { + it('initialized -> ServerDispatch.initialized', () => { + SessionPersistence.initialized(); + expect(ServerDispatch.initialized).toHaveBeenCalled(); + }); + + it('clearStore -> ServerDispatch.clearStore', () => { + SessionPersistence.clearStore(); + expect(ServerDispatch.clearStore).toHaveBeenCalled(); + }); + + it('loginSuccessful passes options', () => { + const opts = { userName: 'alice' } as any; + SessionPersistence.loginSuccessful(opts); + expect(ServerDispatch.loginSuccessful).toHaveBeenCalledWith(opts); + }); + + it('loginFailed -> ServerDispatch.loginFailed', () => { + SessionPersistence.loginFailed(); + expect(ServerDispatch.loginFailed).toHaveBeenCalled(); + }); + + it('connectionClosed passes reason', () => { + SessionPersistence.connectionClosed(3); + expect(ServerDispatch.connectionClosed).toHaveBeenCalledWith(3); + }); + + it('connectionFailed -> ServerDispatch.connectionFailed', () => { + SessionPersistence.connectionFailed(); + expect(ServerDispatch.connectionFailed).toHaveBeenCalled(); + }); + + it('testConnectionSuccessful -> ServerDispatch.testConnectionSuccessful', () => { + SessionPersistence.testConnectionSuccessful(); + expect(ServerDispatch.testConnectionSuccessful).toHaveBeenCalled(); + }); + + it('testConnectionFailed -> ServerDispatch.testConnectionFailed', () => { + SessionPersistence.testConnectionFailed(); + expect(ServerDispatch.testConnectionFailed).toHaveBeenCalled(); + }); + + it('updateBuddyList passes list', () => { + SessionPersistence.updateBuddyList(['user']); + expect(ServerDispatch.updateBuddyList).toHaveBeenCalledWith(['user']); + }); + + it('addToBuddyList passes user', () => { + const user = { name: 'bob' } as any; + SessionPersistence.addToBuddyList(user); + expect(ServerDispatch.addToBuddyList).toHaveBeenCalledWith(user); + }); + + it('removeFromBuddyList passes userName', () => { + SessionPersistence.removeFromBuddyList('bob'); + expect(ServerDispatch.removeFromBuddyList).toHaveBeenCalledWith('bob'); + }); + + it('updateIgnoreList passes list', () => { + SessionPersistence.updateIgnoreList(['user']); + expect(ServerDispatch.updateIgnoreList).toHaveBeenCalledWith(['user']); + }); + + it('addToIgnoreList passes user', () => { + const user = { name: 'bob' } as any; + SessionPersistence.addToIgnoreList(user); + expect(ServerDispatch.addToIgnoreList).toHaveBeenCalledWith(user); + }); + + it('removeFromIgnoreList passes userName', () => { + SessionPersistence.removeFromIgnoreList('bob'); + expect(ServerDispatch.removeFromIgnoreList).toHaveBeenCalledWith('bob'); + }); + + it('updateInfo passes name and version', () => { + SessionPersistence.updateInfo('Server', '1.0'); + expect(ServerDispatch.updateInfo).toHaveBeenCalledWith('Server', '1.0'); + }); + + it('updateStatus dispatches status and calls connectionClosed when DISCONNECTED', () => { + SessionPersistence.updateStatus(StatusEnum.DISCONNECTED, 'bye'); + expect(ServerDispatch.updateStatus).toHaveBeenCalledWith(StatusEnum.DISCONNECTED, 'bye'); + expect(ServerDispatch.connectionClosed).toHaveBeenCalledWith(StatusEnum.DISCONNECTED); + }); + + it('updateStatus does not call connectionClosed when not DISCONNECTED', () => { + SessionPersistence.updateStatus(StatusEnum.CONNECTED, 'hi'); + expect(ServerDispatch.connectionClosed).not.toHaveBeenCalled(); + }); + + it('updateUser passes user', () => { + const user = { name: 'alice' } as any; + SessionPersistence.updateUser(user); + expect(ServerDispatch.updateUser).toHaveBeenCalledWith(user); + }); + + it('updateUsers passes users array', () => { + SessionPersistence.updateUsers([]); + expect(ServerDispatch.updateUsers).toHaveBeenCalledWith([]); + }); + + it('userJoined passes user', () => { + const user = { name: 'carol' } as any; + SessionPersistence.userJoined(user); + expect(ServerDispatch.userJoined).toHaveBeenCalledWith(user); + }); + + it('userLeft passes userName', () => { + SessionPersistence.userLeft('carol'); + expect(ServerDispatch.userLeft).toHaveBeenCalledWith('carol'); + }); + + it('serverMessage sanitizes message', () => { + SessionPersistence.serverMessage('hello'); + expect(sanitizeHtml).toHaveBeenCalledWith('hello'); + expect(ServerDispatch.serverMessage).toHaveBeenCalledWith('sanitized:hello'); + }); + + it('accountAwaitingActivation passes options', () => { + const opts = { userName: 'u' } as any; + SessionPersistence.accountAwaitingActivation(opts); + expect(ServerDispatch.accountAwaitingActivation).toHaveBeenCalledWith(opts); + }); + + it('accountActivationSuccess -> ServerDispatch.accountActivationSuccess', () => { + SessionPersistence.accountActivationSuccess(); + expect(ServerDispatch.accountActivationSuccess).toHaveBeenCalled(); + }); + + it('accountActivationFailed -> ServerDispatch.accountActivationFailed', () => { + SessionPersistence.accountActivationFailed(); + expect(ServerDispatch.accountActivationFailed).toHaveBeenCalled(); + }); + + it('registrationRequiresEmail -> ServerDispatch.registrationRequiresEmail', () => { + SessionPersistence.registrationRequiresEmail(); + expect(ServerDispatch.registrationRequiresEmail).toHaveBeenCalled(); + }); + + it('registrationSuccess -> ServerDispatch.registrationSuccess', () => { + SessionPersistence.registrationSuccess(); + expect(ServerDispatch.registrationSuccess).toHaveBeenCalled(); + }); + + it('registrationFailed normalizes ban error when endTime is given', () => { + SessionPersistence.registrationFailed('reason', 999); + expect(NormalizeService.normalizeBannedUserError).toHaveBeenCalledWith('reason', 999); + expect(ServerDispatch.registrationFailed).toHaveBeenCalledWith('banned:reason:999'); + }); + + it('registrationFailed uses reason directly when no endTime', () => { + SessionPersistence.registrationFailed('plain reason'); + expect(ServerDispatch.registrationFailed).toHaveBeenCalledWith('plain reason'); + }); + + it('registrationEmailError passes error', () => { + SessionPersistence.registrationEmailError('bad email'); + expect(ServerDispatch.registrationEmailError).toHaveBeenCalledWith('bad email'); + }); + + it('registrationPasswordError passes error', () => { + SessionPersistence.registrationPasswordError('short password'); + expect(ServerDispatch.registrationPasswordError).toHaveBeenCalledWith('short password'); + }); + + it('registrationUserNameError passes error', () => { + SessionPersistence.registrationUserNameError('taken'); + expect(ServerDispatch.registrationUserNameError).toHaveBeenCalledWith('taken'); + }); + + it('resetPasswordChallenge -> ServerDispatch.resetPasswordChallenge', () => { + SessionPersistence.resetPasswordChallenge(); + expect(ServerDispatch.resetPasswordChallenge).toHaveBeenCalled(); + }); + + it('resetPassword -> ServerDispatch.resetPassword', () => { + SessionPersistence.resetPassword(); + expect(ServerDispatch.resetPassword).toHaveBeenCalled(); + }); + + it('resetPasswordSuccess -> ServerDispatch.resetPasswordSuccess', () => { + SessionPersistence.resetPasswordSuccess(); + expect(ServerDispatch.resetPasswordSuccess).toHaveBeenCalled(); + }); + + it('resetPasswordFailed -> ServerDispatch.resetPasswordFailed', () => { + SessionPersistence.resetPasswordFailed(); + expect(ServerDispatch.resetPasswordFailed).toHaveBeenCalled(); + }); + + it('accountPasswordChange -> ServerDispatch.accountPasswordChange', () => { + SessionPersistence.accountPasswordChange(); + expect(ServerDispatch.accountPasswordChange).toHaveBeenCalled(); + }); + + it('accountEditChanged passes fields', () => { + SessionPersistence.accountEditChanged('Alice', 'a@b.com', 'US'); + expect(ServerDispatch.accountEditChanged).toHaveBeenCalledWith({ realName: 'Alice', email: 'a@b.com', country: 'US' }); + }); + + it('accountImageChanged passes avatarBmp', () => { + const buf = new Uint8Array([1, 2, 3]); + SessionPersistence.accountImageChanged(buf); + expect(ServerDispatch.accountImageChanged).toHaveBeenCalledWith({ avatarBmp: buf }); + }); + + it('directMessageSent passes userName and message', () => { + SessionPersistence.directMessageSent('bob', 'hi'); + expect(ServerDispatch.directMessageSent).toHaveBeenCalledWith('bob', 'hi'); + }); + + it('getUserInfo passes userInfo', () => { + const user = { name: 'u' } as any; + SessionPersistence.getUserInfo(user); + expect(ServerDispatch.getUserInfo).toHaveBeenCalledWith(user); + }); + + it('getGamesOfUser logs to console', () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + SessionPersistence.getGamesOfUser('user1', {}); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('gameJoined logs to console', () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + SessionPersistence.gameJoined({ gameInfo: {} } as any); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('notifyUser passes notification', () => { + const notif = { type: 1 } as any; + SessionPersistence.notifyUser(notif); + expect(ServerDispatch.notifyUser).toHaveBeenCalledWith(notif); + }); + + it('playerPropertiesChanged logs to console', () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + SessionPersistence.playerPropertiesChanged({} as any); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('serverShutdown passes data', () => { + const data = { gracePeriod: 5 } as any; + SessionPersistence.serverShutdown(data); + expect(ServerDispatch.serverShutdown).toHaveBeenCalledWith(data); + }); + + it('userMessage passes messageData', () => { + const msg = { message: 'hello' } as any; + SessionPersistence.userMessage(msg); + expect(ServerDispatch.userMessage).toHaveBeenCalledWith(msg); + }); + + it('addToList passes list and userName', () => { + SessionPersistence.addToList('buddy', 'alice'); + expect(ServerDispatch.addToList).toHaveBeenCalledWith('buddy', 'alice'); + }); + + it('removeFromList passes list and userName', () => { + SessionPersistence.removeFromList('ignore', 'bob'); + expect(ServerDispatch.removeFromList).toHaveBeenCalledWith('ignore', 'bob'); + }); + + it('deleteServerDeck passes deckId', () => { + SessionPersistence.deleteServerDeck(42); + expect(ServerDispatch.deckDelete).toHaveBeenCalledWith(42); + }); + + it('updateServerDecks passes deckList', () => { + SessionPersistence.updateServerDecks({ folders: [] } as any); + expect(ServerDispatch.backendDecks).toHaveBeenCalled(); + }); + + it('uploadServerDeck passes path and treeItem', () => { + SessionPersistence.uploadServerDeck('/path', { id: 1 } as any); + expect(ServerDispatch.deckUpload).toHaveBeenCalledWith('/path', { id: 1 }); + }); + + it('createServerDeckDir passes path and dirName', () => { + SessionPersistence.createServerDeckDir('/path', 'newdir'); + expect(ServerDispatch.deckNewDir).toHaveBeenCalledWith('/path', 'newdir'); + }); + + it('deleteServerDeckDir passes path', () => { + SessionPersistence.deleteServerDeckDir('/path'); + expect(ServerDispatch.deckDelDir).toHaveBeenCalledWith('/path'); + }); + + it('replayList passes matchList', () => { + SessionPersistence.replayList([]); + expect(ServerDispatch.replayList).toHaveBeenCalledWith([]); + }); + + it('replayAdded passes matchInfo', () => { + const match = { gameId: 1 } as any; + SessionPersistence.replayAdded(match); + expect(ServerDispatch.replayAdded).toHaveBeenCalledWith(match); + }); + + it('replayModifyMatch passes gameId and doNotHide', () => { + SessionPersistence.replayModifyMatch(7, true); + expect(ServerDispatch.replayModifyMatch).toHaveBeenCalledWith(7, true); + }); + + it('replayDeleteMatch passes gameId', () => { + SessionPersistence.replayDeleteMatch(7); + expect(ServerDispatch.replayDeleteMatch).toHaveBeenCalledWith(7); + }); +}); diff --git a/webclient/src/websocket/services/BackendService.spec.ts b/webclient/src/websocket/services/BackendService.spec.ts new file mode 100644 index 000000000..e203a3dd6 --- /dev/null +++ b/webclient/src/websocket/services/BackendService.spec.ts @@ -0,0 +1,119 @@ +import { makeMockProtoRoot } from '../__mocks__/helpers'; + +jest.mock('./ProtoController', () => ({ + ProtoController: { root: null }, +})); + +jest.mock('../WebClient', () => { + const mockProtobuf = { + sendSessionCommand: jest.fn(), + sendRoomCommand: jest.fn(), + sendModeratorCommand: jest.fn(), + sendAdminCommand: jest.fn(), + }; + return { __esModule: true, default: { protobuf: mockProtobuf } }; +}); + +import { BackendService } from './BackendService'; +import { ProtoController } from './ProtoController'; +import webClient from '../WebClient'; + +beforeEach(() => { + jest.clearAllMocks(); + ProtoController.root = makeMockProtoRoot(); + ProtoController.root['Command_Test'] = { create: jest.fn(p => ({ ...p })) }; + ProtoController.root['Command_Room'] = { create: jest.fn(p => ({ ...p })) }; + ProtoController.root['Command_Mod'] = { create: jest.fn(p => ({ ...p })) }; + ProtoController.root['Command_Admin'] = { create: jest.fn(p => ({ ...p })) }; + ProtoController.root['Response_Test'] = {}; +}); + +function captureCallback(sendFn: jest.Mock) { + return sendFn.mock.calls[0][sendFn === (webClient.protobuf as any).sendRoomCommand ? 2 : 1]; +} + +describe('BackendService', () => { + describe('send commands', () => { + it.each<[string, () => void]>([ + ['sendSessionCommand', () => BackendService.sendSessionCommand('Command_Test', { x: 1 }, {})], + ['sendRoomCommand', () => BackendService.sendRoomCommand(5, 'Command_Room', { y: 2 }, {})], + ['sendModeratorCommand', () => BackendService.sendModeratorCommand('Command_Mod', { z: 3 }, {})], + ['sendAdminCommand', () => BackendService.sendAdminCommand('Command_Admin', {}, {})], + ])('%s creates the command and delegates to protobuf', (methodName, invoke) => { + invoke(); + expect((webClient.protobuf as any)[methodName]).toHaveBeenCalled(); + }); + }); + + describe('handleResponse via non-session command callbacks', () => { + it('sendRoomCommand callback invokes handleResponse', () => { + const onSuccess = jest.fn(); + BackendService.sendRoomCommand(5, 'Command_Room', {}, { onSuccess }); + captureCallback((webClient.protobuf as any).sendRoomCommand)({ responseCode: 0 }); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('sendModeratorCommand callback invokes handleResponse', () => { + const onSuccess = jest.fn(); + BackendService.sendModeratorCommand('Command_Mod', {}, { onSuccess }); + captureCallback((webClient.protobuf as any).sendModeratorCommand)({ responseCode: 0 }); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('sendAdminCommand callback invokes handleResponse', () => { + const onSuccess = jest.fn(); + BackendService.sendAdminCommand('Command_Admin', {}, { onSuccess }); + captureCallback((webClient.protobuf as any).sendAdminCommand)({ responseCode: 0 }); + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + describe('handleResponse (via sendSessionCommand callback)', () => { + function invokeCallback(options: any, raw: any) { + BackendService.sendSessionCommand('Command_Test', {}, options); + const cb = (webClient.protobuf as any).sendSessionCommand.mock.calls[0][1]; + cb(raw); + } + + it('calls onResponse and returns early when provided', () => { + const onResponse = jest.fn(); + const onSuccess = jest.fn(); + invokeCallback({ onResponse, onSuccess }, { responseCode: 99 }); + expect(onResponse).toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('calls onSuccess with raw when responseCode is RespOk and no responseName', () => { + const onSuccess = jest.fn(); + const raw = { responseCode: 0 }; + invokeCallback({ onSuccess }, raw); + expect(onSuccess).toHaveBeenCalledWith(raw, raw); + }); + + it('calls onSuccess with nested response when responseName is set', () => { + const onSuccess = jest.fn(); + const raw = { responseCode: 0, '.Response_Test.ext': { nested: true } }; + invokeCallback({ onSuccess, responseName: 'Response_Test' }, raw); + expect(onSuccess).toHaveBeenCalledWith({ nested: true }, raw); + }); + + it('calls onResponseCode handler when code matches', () => { + const specificHandler = jest.fn(); + invokeCallback({ onResponseCode: { 5: specificHandler } }, { responseCode: 5 }); + expect(specificHandler).toHaveBeenCalled(); + }); + + it('calls onError when responseCode is not RespOk and no specific handler', () => { + const onError = jest.fn(); + invokeCallback({ onError }, { responseCode: 99 }); + expect(onError).toHaveBeenCalledWith(99, { responseCode: 99 }); + }); + + it('logs error to console when no callbacks for non-RespOk response', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + invokeCallback({}, { responseCode: 42 }); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/webclient/src/websocket/services/ProtoController.spec.ts b/webclient/src/websocket/services/ProtoController.spec.ts new file mode 100644 index 000000000..32a15a46f --- /dev/null +++ b/webclient/src/websocket/services/ProtoController.spec.ts @@ -0,0 +1,41 @@ +jest.mock('../persistence', () => ({ + SessionPersistence: { initialized: jest.fn() }, +})); + +jest.mock('../../proto-files.json', () => ['test.proto'], { virtual: true }); + +import { ProtoController } from './ProtoController'; +import { SessionPersistence } from '../persistence'; +import protobuf from 'protobufjs'; + +beforeEach(() => { + jest.clearAllMocks(); + ProtoController.root = null; + (process.env as any).PUBLIC_URL = ''; +}); + +describe('ProtoController', () => { + describe('load', () => { + it('creates a new protobuf.Root', () => { + ProtoController.load(); + expect(ProtoController.root).toBeDefined(); + }); + + it('calls initialized when callback succeeds', () => { + const loadSpy = jest.spyOn(protobuf.Root.prototype, 'load').mockImplementation( + (_files: any, _opts: any, cb: any) => cb(null) + ); + ProtoController.load(); + expect(SessionPersistence.initialized).toHaveBeenCalled(); + loadSpy.mockRestore(); + }); + + it('throws when callback receives an error', () => { + const loadSpy = jest.spyOn(protobuf.Root.prototype, 'load').mockImplementation( + (_files: any, _opts: any, cb: any) => cb(new Error('load failed')) + ); + expect(() => ProtoController.load()).toThrow('load failed'); + loadSpy.mockRestore(); + }); + }); +}); diff --git a/webclient/src/websocket/services/ProtobufService.spec.ts b/webclient/src/websocket/services/ProtobufService.spec.ts new file mode 100644 index 000000000..d0cfab31a --- /dev/null +++ b/webclient/src/websocket/services/ProtobufService.spec.ts @@ -0,0 +1,321 @@ +import { makeMockProtoRoot } from '../__mocks__/helpers'; + +jest.mock('./ProtoController', () => ({ + ProtoController: { root: null, load: jest.fn() }, +})); + +jest.mock('../commands/session', () => ({ + SessionCommands: { ping: jest.fn() }, + ping: jest.fn(), +})); + +jest.mock('../events', () => ({ + CommonEvents: {}, + GameEvents: { '.Event_Game.ext': jest.fn() }, + RoomEvents: { '.Event_Room.ext': jest.fn() }, + SessionEvents: { '.Event_Session.ext': jest.fn() }, +})); + +jest.mock('../WebClient'); + +import { ProtobufService } from './ProtobufService'; +import { ProtoController } from './ProtoController'; +import { ping as sessionPing } from '../commands/session'; + +let mockSocket: any; +let mockWebClient: any; + +beforeEach(() => { + jest.clearAllMocks(); + + ProtoController.root = makeMockProtoRoot(); + const encodeResult = { finish: jest.fn().mockReturnValue(new Uint8Array([1, 2])) }; + ProtoController.root.CommandContainer.encode = jest.fn().mockReturnValue(encodeResult); + + mockSocket = { + checkReadyState: jest.fn().mockReturnValue(true), + send: jest.fn(), + }; + + mockWebClient = { + socket: mockSocket, + }; +}); + +describe('ProtobufService', () => { + it('calls ProtoController.load on construction', () => { + new ProtobufService(mockWebClient); + expect(ProtoController.load).toHaveBeenCalled(); + }); + + describe('resetCommands', () => { + it('resets cmdId and pendingCommands', () => { + const service = new ProtobufService(mockWebClient); + // add a pending command + service.sendSessionCommand({}, jest.fn()); + expect((service as any).cmdId).toBe(1); + service.resetCommands(); + expect((service as any).cmdId).toBe(0); + expect((service as any).pendingCommands).toEqual({}); + }); + }); + + describe('sendCommand', () => { + it('increments cmdId and stores callback', () => { + const service = new ProtobufService(mockWebClient); + const cb = jest.fn(); + service.sendCommand({}, cb); + expect((service as any).cmdId).toBe(1); + expect((service as any).pendingCommands[1]).toBe(cb); + }); + + it('sends encoded data when socket is OPEN', () => { + const service = new ProtobufService(mockWebClient); + mockSocket.checkReadyState.mockReturnValue(true); + service.sendCommand({}, jest.fn()); + expect(mockSocket.send).toHaveBeenCalled(); + }); + + it('does not send when socket is not OPEN', () => { + const service = new ProtobufService(mockWebClient); + mockSocket.checkReadyState.mockReturnValue(false); + service.sendCommand({}, jest.fn()); + expect(mockSocket.send).not.toHaveBeenCalled(); + }); + }); + + describe('sendSessionCommand', () => { + it('creates a CommandContainer and calls sendCommand', () => { + const service = new ProtobufService(mockWebClient); + const cb = jest.fn(); + service.sendSessionCommand({ cmdType: 'test' }, cb); + expect(ProtoController.root.CommandContainer.create).toHaveBeenCalledWith( + expect.objectContaining({ sessionCommand: expect.anything() }) + ); + }); + + it('invokes callback with raw response when the pending command is triggered', () => { + const service = new ProtobufService(mockWebClient); + const cb = jest.fn(); + service.sendSessionCommand({ cmdType: 'test' }, cb); + + const storedCb = (service as any).pendingCommands[1]; + storedCb({ responseData: true }); + + expect(cb).toHaveBeenCalledWith({ responseData: true }); + }); + + it('does not throw when no callback is provided and pending command is triggered', () => { + const service = new ProtobufService(mockWebClient); + service.sendSessionCommand({ cmdType: 'test' }); + + const storedCb = (service as any).pendingCommands[1]; + expect(() => storedCb({ responseData: true })).not.toThrow(); + }); + }); + + describe('sendRoomCommand', () => { + it('creates a CommandContainer with roomId and calls sendCommand', () => { + const service = new ProtobufService(mockWebClient); + service.sendRoomCommand(42, { roomCmdType: 'test' }, jest.fn()); + expect(ProtoController.root.CommandContainer.create).toHaveBeenCalledWith( + expect.objectContaining({ roomId: 42 }) + ); + }); + + it('invokes callback with raw response when the pending command is triggered', () => { + const service = new ProtobufService(mockWebClient); + const cb = jest.fn(); + service.sendRoomCommand(42, { roomCmdType: 'test' }, cb); + + const storedCb = (service as any).pendingCommands[1]; + storedCb({ responseData: true }); + + expect(cb).toHaveBeenCalledWith({ responseData: true }); + }); + + it('does not throw when no callback is provided and pending command is triggered', () => { + const service = new ProtobufService(mockWebClient); + service.sendRoomCommand(42, { roomCmdType: 'test' }); + + const storedCb = (service as any).pendingCommands[1]; + expect(() => storedCb({ responseData: true })).not.toThrow(); + }); + }); + + describe('sendModeratorCommand', () => { + it('creates a CommandContainer with moderatorCommand', () => { + const service = new ProtobufService(mockWebClient); + service.sendModeratorCommand({ modCmdType: 'test' }, jest.fn()); + expect(ProtoController.root.CommandContainer.create).toHaveBeenCalledWith( + expect.objectContaining({ moderatorCommand: expect.anything() }) + ); + }); + + it('invokes callback with raw response when the pending command is triggered', () => { + const service = new ProtobufService(mockWebClient); + const cb = jest.fn(); + service.sendModeratorCommand({ modCmdType: 'test' }, cb); + + const storedCb = (service as any).pendingCommands[1]; + storedCb({ responseData: true }); + + expect(cb).toHaveBeenCalledWith({ responseData: true }); + }); + + it('does not throw when no callback is provided and pending command is triggered', () => { + const service = new ProtobufService(mockWebClient); + service.sendModeratorCommand({ modCmdType: 'test' }); + + const storedCb = (service as any).pendingCommands[1]; + expect(() => storedCb({ responseData: true })).not.toThrow(); + }); + }); + + describe('sendAdminCommand', () => { + it('creates a CommandContainer with adminCommand', () => { + const service = new ProtobufService(mockWebClient); + service.sendAdminCommand({ adminCmdType: 'test' }, jest.fn()); + expect(ProtoController.root.CommandContainer.create).toHaveBeenCalledWith( + expect.objectContaining({ adminCommand: expect.anything() }) + ); + }); + + it('invokes callback with raw response when the pending command is triggered', () => { + const service = new ProtobufService(mockWebClient); + const cb = jest.fn(); + service.sendAdminCommand({ adminCmdType: 'test' }, cb); + + const storedCb = (service as any).pendingCommands[1]; + storedCb({ responseData: true }); + + expect(cb).toHaveBeenCalledWith({ responseData: true }); + }); + + it('does not throw when no callback is provided and pending command is triggered', () => { + const service = new ProtobufService(mockWebClient); + service.sendAdminCommand({ adminCmdType: 'test' }); + + const storedCb = (service as any).pendingCommands[1]; + expect(() => storedCb({ responseData: true })).not.toThrow(); + }); + }); + + describe('sendKeepAliveCommand', () => { + it('delegates to SessionCommands.ping', () => { + const service = new ProtobufService(mockWebClient); + const pingReceived = jest.fn(); + service.sendKeepAliveCommand(pingReceived); + expect(sessionPing).toHaveBeenCalledWith(pingReceived); + }); + }); + + describe('handleMessageEvent', () => { + it('routes RESPONSE message to processServerResponse', () => { + const service = new ProtobufService(mockWebClient); + const cb = jest.fn(); + // store a callback for cmdId 1 + (service as any).cmdId = 1; + (service as any).pendingCommands[1] = cb; + + const response = { cmdId: 1 }; + ProtoController.root.ServerMessage.decode = jest.fn().mockReturnValue({ + messageType: ProtoController.root.ServerMessage.MessageType.RESPONSE, + response, + }); + + service.handleMessageEvent({ data: new ArrayBuffer(0) } as MessageEvent); + expect(cb).toHaveBeenCalledWith(response); + expect((service as any).pendingCommands[1]).toBeUndefined(); + }); + + it('routes ROOM_EVENT message', () => { + const service = new ProtobufService(mockWebClient); + const processRoomEvent = jest.spyOn(service as any, 'processRoomEvent'); + ProtoController.root.ServerMessage.decode = jest.fn().mockReturnValue({ + messageType: ProtoController.root.ServerMessage.MessageType.ROOM_EVENT, + roomEvent: { '.Event_Room.ext': {} }, + }); + service.handleMessageEvent({ data: new ArrayBuffer(0) } as MessageEvent); + expect(processRoomEvent).toHaveBeenCalled(); + }); + + it('routes SESSION_EVENT message', () => { + const service = new ProtobufService(mockWebClient); + const processSessionEvent = jest.spyOn(service as any, 'processSessionEvent'); + ProtoController.root.ServerMessage.decode = jest.fn().mockReturnValue({ + messageType: ProtoController.root.ServerMessage.MessageType.SESSION_EVENT, + sessionEvent: { '.Event_Session.ext': {} }, + }); + service.handleMessageEvent({ data: new ArrayBuffer(0) } as MessageEvent); + expect(processSessionEvent).toHaveBeenCalled(); + }); + + it('routes GAME_EVENT_CONTAINER message', () => { + const service = new ProtobufService(mockWebClient); + const processGameEvent = jest.spyOn(service as any, 'processGameEvent'); + ProtoController.root.ServerMessage.decode = jest.fn().mockReturnValue({ + messageType: ProtoController.root.ServerMessage.MessageType.GAME_EVENT_CONTAINER, + gameEvent: { '.Event_Game.ext': {} }, + }); + service.handleMessageEvent({ data: new ArrayBuffer(0) } as MessageEvent); + expect(processGameEvent).toHaveBeenCalled(); + }); + + it('logs unknown message types (default case)', () => { + const service = new ProtobufService(mockWebClient); + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + ProtoController.root.ServerMessage.decode = jest.fn().mockReturnValue({ + messageType: 'UNKNOWN_TYPE', + }); + service.handleMessageEvent({ data: new ArrayBuffer(0) } as MessageEvent); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('does nothing when decoded message is null', () => { + const service = new ProtobufService(mockWebClient); + ProtoController.root.ServerMessage.decode = jest.fn().mockReturnValue(null); + expect(() => service.handleMessageEvent({ data: new ArrayBuffer(0) } as MessageEvent)).not.toThrow(); + }); + + it('catches and logs decode errors', () => { + const service = new ProtobufService(mockWebClient); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + ProtoController.root.ServerMessage.decode = jest.fn().mockImplementation(() => { + throw new Error('decode error'); + }); + expect(() => service.handleMessageEvent({ data: new ArrayBuffer(0) } as MessageEvent)).not.toThrow(); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); + + describe('processEvent', () => { + it('calls matching event handler with payload and raw', () => { + const service = new ProtobufService(mockWebClient); + const handler = jest.fn(); + const events = { '.Event_Test.ext': handler }; + const payload = { someData: 1 }; + const response = { '.Event_Test.ext': payload }; + const raw = { extra: true }; + + (service as any).processEvent(response, events, raw); + + expect(handler).toHaveBeenCalledWith(payload, raw); + }); + + it('stops after first matching event', () => { + const service = new ProtobufService(mockWebClient); + const handler1 = jest.fn(); + const handler2 = jest.fn(); + const events = { '.Event_A.ext': handler1, '.Event_B.ext': handler2 }; + const response = { '.Event_A.ext': { x: 1 } }; + + (service as any).processEvent(response, events, {}); + + expect(handler1).toHaveBeenCalled(); + expect(handler2).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/webclient/src/websocket/services/WebSocketService.spec.ts b/webclient/src/websocket/services/WebSocketService.spec.ts new file mode 100644 index 000000000..275a88a66 --- /dev/null +++ b/webclient/src/websocket/services/WebSocketService.spec.ts @@ -0,0 +1,288 @@ +import { installMockWebSocket } from '../__mocks__/helpers'; + +jest.mock('../commands/session', () => ({ + updateStatus: jest.fn(), +})); + +jest.mock('../persistence', () => ({ + SessionPersistence: { + connectionFailed: jest.fn(), + testConnectionSuccessful: jest.fn(), + testConnectionFailed: jest.fn(), + }, +})); + +import { WebSocketService } from './WebSocketService'; +import { SessionPersistence } from '../persistence'; +import { updateStatus } from '../commands/session'; +import { StatusEnum } from 'types'; + +let MockWS: jest.Mock; +let mockInstance: ReturnType['mockInstance']; +let mockWebClient: any; + +beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + + const installed = installMockWebSocket(); + MockWS = installed.MockWS; + mockInstance = installed.mockInstance; + + mockWebClient = { + status: StatusEnum.CONNECTED, + clientOptions: { keepalive: 1000 }, + keepAlive: jest.fn(), + }; +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('WebSocketService', () => { + function createConnectedService() { + const service = new WebSocketService(mockWebClient); + service.connect({ host: 'h', port: 1 } as any, 'ws'); + return service; + } + + function createTestConnectedService() { + const service = new WebSocketService(mockWebClient); + service.testConnect({ host: 'h', port: 1 } as any, 'ws'); + return service; + } + + describe('constructor', () => { + it('subscribes disconnected$ from KeepAliveService', () => { + const service = new WebSocketService(mockWebClient); + expect(service).toBeDefined(); + }); + + it('calls disconnect and updateStatus when keepAlive disconnected$ fires', () => { + const service = new WebSocketService(mockWebClient); + service.connect({ host: 'localhost', port: 8080 } as any, 'ws'); + // trigger keepAliveService.disconnected$ + (service as any).keepAliveService.disconnected$.next(); + expect(mockInstance.close).toHaveBeenCalled(); + expect(updateStatus).toHaveBeenCalledWith(StatusEnum.DISCONNECTED, 'Connection timeout'); + }); + }); + + describe('connect', () => { + it('creates a WebSocket with wss protocol by default', () => { + const service = new WebSocketService(mockWebClient); + Object.defineProperty(window, 'location', { + value: { hostname: 'example.com' }, + writable: true, + configurable: true, + }); + service.connect({ host: 'example.com', port: 8080 } as any); + expect(MockWS).toHaveBeenCalledWith('wss://example.com:8080'); + }); + + it('switches to ws protocol when hostname is localhost', () => { + const service = new WebSocketService(mockWebClient); + Object.defineProperty(window, 'location', { + value: { hostname: 'localhost' }, + writable: true, + configurable: true, + }); + service.connect({ host: 'somehost', port: 1234 } as any); + expect(MockWS).toHaveBeenCalledWith('ws://somehost:1234'); + }); + + it('sets binaryType to arraybuffer', () => { + createConnectedService(); + expect(mockInstance.binaryType).toBe('arraybuffer'); + }); + + it('fires socket.close after keepalive timeout', () => { + createConnectedService(); + jest.advanceTimersByTime(1000); + expect(mockInstance.close).toHaveBeenCalled(); + }); + }); + + describe('socket event handlers (onopen)', () => { + it('clears the connection timeout when socket opens', () => { + const clearSpy = jest.spyOn(global, 'clearTimeout'); + createConnectedService(); + mockInstance.onopen(); + expect(clearSpy).toHaveBeenCalled(); + }); + + it('calls updateStatus CONNECTED on open', () => { + createConnectedService(); + mockInstance.onopen(); + expect(updateStatus).toHaveBeenCalledWith(StatusEnum.CONNECTED, 'Connected'); + }); + + it('starts the ping loop with the keepalive interval', () => { + const service = new WebSocketService(mockWebClient); + const startSpy = jest.spyOn((service as any).keepAliveService, 'startPingLoop'); + service.connect({ host: 'h', port: 1 } as any, 'ws'); + mockInstance.onopen(); + expect(startSpy).toHaveBeenCalledWith(1000, expect.any(Function)); + }); + + it('ping loop callback calls webClient.keepAlive', () => { + const service = new WebSocketService(mockWebClient); + const startSpy = jest.spyOn((service as any).keepAliveService, 'startPingLoop'); + service.connect({ host: 'h', port: 1 } as any, 'ws'); + mockInstance.onopen(); + const pingCb = startSpy.mock.calls[0][1]; + const done = jest.fn(); + pingCb(done); + expect(mockWebClient.keepAlive).toHaveBeenCalledWith(done); + }); + }); + + describe('socket event handlers (onclose)', () => { + it('calls updateStatus DISCONNECTED on close when not already DISCONNECTED', () => { + createConnectedService(); + mockInstance.onclose(); + expect(updateStatus).toHaveBeenCalledWith(StatusEnum.DISCONNECTED, 'Connection Closed'); + }); + + it('does not overwrite status if already DISCONNECTED', () => { + createConnectedService(); + mockWebClient.status = StatusEnum.DISCONNECTED; + mockInstance.onclose(); + expect(updateStatus).not.toHaveBeenCalledWith(StatusEnum.DISCONNECTED, 'Connection Closed'); + }); + + it('ends the ping loop on close', () => { + const service = new WebSocketService(mockWebClient); + const endSpy = jest.spyOn((service as any).keepAliveService, 'endPingLoop'); + service.connect({ host: 'h', port: 1 } as any, 'ws'); + mockInstance.onclose(); + expect(endSpy).toHaveBeenCalled(); + }); + }); + + describe('socket event handlers (onerror)', () => { + it('calls updateStatus DISCONNECTED on error', () => { + createConnectedService(); + mockInstance.onerror(); + expect(updateStatus).toHaveBeenCalledWith(StatusEnum.DISCONNECTED, 'Connection Failed'); + }); + + it('calls SessionPersistence.connectionFailed on error', () => { + createConnectedService(); + mockInstance.onerror(); + expect(SessionPersistence.connectionFailed).toHaveBeenCalled(); + }); + }); + + describe('socket event handlers (onmessage)', () => { + it('emits on message$ subject', () => { + const service = createConnectedService(); + const handler = jest.fn(); + service.message$.subscribe(handler); + const event = { data: new ArrayBuffer(4) } as MessageEvent; + mockInstance.onmessage(event); + expect(handler).toHaveBeenCalledWith(event); + }); + }); + + describe('disconnect', () => { + it('closes the socket', () => { + const service = createConnectedService(); + service.disconnect(); + expect(mockInstance.close).toHaveBeenCalled(); + }); + }); + + describe('send', () => { + it('delegates to socket.send', () => { + const service = createConnectedService(); + const data = new Uint8Array([1, 2, 3]); + service.send(data); + expect(mockInstance.send).toHaveBeenCalledWith(data); + }); + }); + + describe('checkReadyState', () => { + it('returns true when readyState matches', () => { + const service = createConnectedService(); + mockInstance.readyState = WebSocket.OPEN; + expect(service.checkReadyState(WebSocket.OPEN)).toBe(true); + }); + + it('returns false when readyState does not match', () => { + const service = createConnectedService(); + mockInstance.readyState = 3; // CLOSED + expect(service.checkReadyState(WebSocket.OPEN)).toBe(false); + }); + + it('returns false when socket is null', () => { + const service = new WebSocketService(mockWebClient); + // no connect called, socket is undefined + expect(service.checkReadyState(WebSocket.OPEN)).toBe(false); + }); + }); + + describe('testConnect', () => { + it('creates a test WebSocket with correct URL', () => { + const service = new WebSocketService(mockWebClient); + Object.defineProperty(window, 'location', { + value: { hostname: 'example.com' }, + writable: true, + configurable: true, + }); + service.testConnect({ host: 'example.com', port: 9000 } as any); + expect(MockWS).toHaveBeenCalledWith('wss://example.com:9000'); + }); + + it('uses ws protocol on localhost', () => { + const service = new WebSocketService(mockWebClient); + Object.defineProperty(window, 'location', { + value: { hostname: 'localhost' }, + writable: true, + configurable: true, + }); + service.testConnect({ host: 'h', port: 1 } as any); + expect(MockWS).toHaveBeenCalledWith('ws://h:1'); + }); + + it('closes previous testSocket when connecting again', () => { + const service = new WebSocketService(mockWebClient); + service.testConnect({ host: 'h', port: 1 } as any, 'ws'); + const firstInstance = mockInstance; + // install second mock instance and restore after test + const installed2 = installMockWebSocket(); + service.testConnect({ host: 'h', port: 2 } as any, 'ws'); + expect(firstInstance.close).toHaveBeenCalled(); + // restore original mock so subsequent tests see a clean global + mockInstance = installed2.mockInstance; + MockWS = installed2.MockWS; + }); + + it('calls SessionPersistence.testConnectionSuccessful on open', () => { + createTestConnectedService(); + const timer = jest.spyOn(global, 'clearTimeout'); + mockInstance.onopen(); + expect(SessionPersistence.testConnectionSuccessful).toHaveBeenCalled(); + expect(mockInstance.close).toHaveBeenCalled(); + }); + + it('fires socket.close after keepalive timeout for testConnect', () => { + createTestConnectedService(); + jest.advanceTimersByTime(1000); + expect(mockInstance.close).toHaveBeenCalled(); + }); + + it('calls SessionPersistence.testConnectionFailed on error', () => { + createTestConnectedService(); + mockInstance.onerror(); + expect(SessionPersistence.testConnectionFailed).toHaveBeenCalled(); + }); + + it('nulls out testSocket on close', () => { + const service = createTestConnectedService(); + mockInstance.onclose(); + expect((service as any).testSocket).toBeNull(); + }); + }); +}); diff --git a/webclient/src/websocket/utils/NormalizeService.spec.ts b/webclient/src/websocket/utils/NormalizeService.spec.ts new file mode 100644 index 000000000..c0c20adc9 --- /dev/null +++ b/webclient/src/websocket/utils/NormalizeService.spec.ts @@ -0,0 +1,110 @@ +import NormalizeService from './NormalizeService'; + +describe('NormalizeService', () => { + describe('normalizeRoomInfo', () => { + it('builds gametypeMap from gametypeList', () => { + const roomInfo: any = { + gametypeList: [ + { gameTypeId: 1, description: 'Standard' }, + { gameTypeId: 2, description: 'Draft' }, + ], + gametypeMap: {}, + gameList: [], + }; + NormalizeService.normalizeRoomInfo(roomInfo); + expect(roomInfo.gametypeMap).toEqual({ 1: 'Standard', 2: 'Draft' }); + }); + + it('normalizes each game in gameList', () => { + const roomInfo: any = { + gametypeList: [{ gameTypeId: 5, description: 'Modern' }], + gametypeMap: {}, + gameList: [{ gameTypes: [5], description: 'My Game' }], + }; + NormalizeService.normalizeRoomInfo(roomInfo); + expect(roomInfo.gameList[0].gameType).toBe('Modern'); + }); + }); + + describe('normalizeGameObject', () => { + it('sets gameType from first element of gameTypes', () => { + const game: any = { gameTypes: [3], description: 'Test' }; + const map: any = { 3: 'Legacy' }; + NormalizeService.normalizeGameObject(game, map); + expect(game.gameType).toBe('Legacy'); + }); + + it('sets gameType to empty string when gameTypes is empty', () => { + const game: any = { gameTypes: [], description: 'Test' }; + NormalizeService.normalizeGameObject(game, {}); + expect(game.gameType).toBe(''); + }); + + it('sets gameType to empty string when gameTypes is null', () => { + const game: any = { gameTypes: null, description: 'Test' }; + NormalizeService.normalizeGameObject(game, {}); + expect(game.gameType).toBe(''); + }); + + it('sets description to empty string when description is falsy', () => { + const game: any = { gameTypes: [], description: null }; + NormalizeService.normalizeGameObject(game, {}); + expect(game.description).toBe(''); + }); + }); + + describe('normalizeLogs', () => { + it('groups logs by targetType', () => { + const logs: any[] = [ + { targetType: 'room', msg: 'a' }, + { targetType: 'chat', msg: 'b' }, + { targetType: 'room', msg: 'c' }, + ]; + const result = NormalizeService.normalizeLogs(logs); + expect(result['room']).toHaveLength(2); + expect(result['chat']).toHaveLength(1); + }); + + it('returns empty object for empty array', () => { + expect(NormalizeService.normalizeLogs([])).toEqual({}); + }); + }); + + describe('normalizeUserMessage', () => { + it('prepends username when name is present', () => { + const message: any = { name: 'Alice', message: 'hello' }; + NormalizeService.normalizeUserMessage(message); + expect(message.message).toBe('Alice: hello'); + }); + + it('does not modify message when name is absent', () => { + const message: any = { name: '', message: 'hello' }; + NormalizeService.normalizeUserMessage(message); + expect(message.message).toBe('hello'); + }); + }); + + describe('normalizeBannedUserError', () => { + it('returns permanently banned message when endTime is 0', () => { + const result = NormalizeService.normalizeBannedUserError('', 0); + expect(result).toBe('You are permanently banned'); + }); + + it('returns banned until date when endTime is given', () => { + const endTime = new Date('2030-01-01').getTime(); + const result = NormalizeService.normalizeBannedUserError('', endTime); + expect(result).toContain('You are banned until'); + expect(result).toContain(new Date(endTime).toString()); + }); + + it('appends reasonStr when provided', () => { + const result = NormalizeService.normalizeBannedUserError('bad behavior', 0); + expect(result).toContain('\n\nbad behavior'); + }); + + it('does not append when reasonStr is empty', () => { + const result = NormalizeService.normalizeBannedUserError('', 0); + expect(result).not.toContain('\n\n'); + }); + }); +}); diff --git a/webclient/src/websocket/utils/guid.util.spec.ts b/webclient/src/websocket/utils/guid.util.spec.ts new file mode 100644 index 000000000..4ed8d4955 --- /dev/null +++ b/webclient/src/websocket/utils/guid.util.spec.ts @@ -0,0 +1,19 @@ +import { guid } from './guid.util'; + +describe('guid', () => { + it('returns a string', () => { + expect(typeof guid()).toBe('string'); + }); + + it('matches UUID v4 pattern', () => { + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + expect(guid()).toMatch(uuidPattern); + }); + + it('returns deterministic value when Math.random is mocked', () => { + const spy = jest.spyOn(Math, 'random').mockReturnValue(0.5); + const result = guid(); + expect(result).toBe(guid()); + spy.mockRestore(); + }); +}); diff --git a/webclient/src/websocket/utils/passwordHasher.spec.ts b/webclient/src/websocket/utils/passwordHasher.spec.ts new file mode 100644 index 000000000..d4275fa43 --- /dev/null +++ b/webclient/src/websocket/utils/passwordHasher.spec.ts @@ -0,0 +1,58 @@ +import { makeMockProtoRoot } from '../__mocks__/helpers'; + +jest.mock('../services/ProtoController', () => ({ + ProtoController: { root: null }, +})); + +import { ProtoController } from '../services/ProtoController'; +import { hashPassword, generateSalt, passwordSaltSupported } from './passwordHasher'; + +beforeEach(() => { + ProtoController.root = makeMockProtoRoot(); +}); + +describe('hashPassword', () => { + it('returns a string starting with the salt', () => { + const result = hashPassword('mysalt', 'mypassword'); + expect(result.startsWith('mysalt')).toBe(true); + }); + + it('returns the same value for the same inputs (deterministic)', () => { + expect(hashPassword('salt', 'pass')).toBe(hashPassword('salt', 'pass')); + }); + + it('returns different values for different salts', () => { + expect(hashPassword('salt1', 'pass')).not.toBe(hashPassword('salt2', 'pass')); + }); + + it('returns different values for different passwords', () => { + expect(hashPassword('salt', 'pass1')).not.toBe(hashPassword('salt', 'pass2')); + }); +}); + +describe('generateSalt', () => { + it('returns a string of 16 characters', () => { + expect(generateSalt()).toHaveLength(16); + }); + + it('only contains alphanumeric characters', () => { + expect(generateSalt()).toMatch(/^[A-Za-z0-9]{16}$/); + }); + + it('returns different values on successive calls (not constant)', () => { + const salts = new Set(Array.from({ length: 10 }, () => generateSalt())); + expect(salts.size).toBeGreaterThan(1); + }); +}); + +describe('passwordSaltSupported', () => { + it('returns non-zero when SupportsPasswordHash bit is set', () => { + // SupportsPasswordHash = 2 from mock; 2 & 2 = 2 + expect(passwordSaltSupported(2)).toBeTruthy(); + }); + + it('returns zero when SupportsPasswordHash bit is not set', () => { + // 1 & 2 = 0 + expect(passwordSaltSupported(1)).toBeFalsy(); + }); +}); diff --git a/webclient/src/websocket/utils/sanitizeHtml.util.spec.ts b/webclient/src/websocket/utils/sanitizeHtml.util.spec.ts new file mode 100644 index 000000000..cea755d98 --- /dev/null +++ b/webclient/src/websocket/utils/sanitizeHtml.util.spec.ts @@ -0,0 +1,55 @@ +import { sanitizeHtml } from './sanitizeHtml.util'; + +describe('sanitizeHtml', () => { + it('passes through plain text unchanged', () => { + expect(sanitizeHtml('hello world')).toBe('hello world'); + }); + + it('allows
tag', () => { + expect(sanitizeHtml('line1
line2')).toBe('line1
line2'); + }); + + it('allows tag', () => { + expect(sanitizeHtml('bold')).toBe('bold'); + }); + + it('allows tag', () => { + expect(sanitizeHtml('')).toBe(''); + }); + + it('allows
tag', () => { + expect(sanitizeHtml('
x
')).toBe('
x
'); + }); + + it('allows tag with color attribute', () => { + expect(sanitizeHtml('text')).toBe('text'); + }); + + it('strips disallowed tag ')).toBe(''); + }); + + it('strips disallowed tag
', () => { + expect(sanitizeHtml('
content
')).toBe('content'); + }); + + it('strips disallowed attribute onclick from ', () => { + expect(sanitizeHtml('hi')).toBe('hi'); + }); + + it('adds target=_blank and rel=noopener noreferrer to tags', () => { + const result = sanitizeHtml('link'); + expect(result).toContain('target="_blank"'); + expect(result).toContain('rel="noopener noreferrer"'); + }); + + it('allows href attribute on ', () => { + const result = sanitizeHtml('link'); + expect(result).toContain('href="https://example.com"'); + }); + + it('strips disallowed schemes like javascript:', () => { + const result = sanitizeHtml('xss'); + expect(result).not.toContain('javascript:'); + }); +});