upgrade packages + improve typing

This commit is contained in:
seavor 2026-04-14 11:34:29 -05:00
parent fd55f4fb7f
commit 19f5eefdd2
138 changed files with 4504 additions and 11015 deletions

View file

@ -1,18 +1,22 @@
vi.mock('./services/WebSocketService', () => ({
WebSocketService: vi.fn().mockImplementation(() => ({
message$: { subscribe: vi.fn() },
connect: vi.fn(),
testConnect: vi.fn(),
disconnect: vi.fn(),
})),
WebSocketService: vi.fn().mockImplementation(function WebSocketServiceImpl() {
return {
message$: { subscribe: vi.fn() },
connect: vi.fn(),
testConnect: vi.fn(),
disconnect: vi.fn(),
};
}),
}));
vi.mock('./services/ProtobufService', () => ({
ProtobufService: vi.fn().mockImplementation(() => ({
handleMessageEvent: vi.fn(),
sendKeepAliveCommand: vi.fn(),
resetCommands: vi.fn(),
})),
ProtobufService: vi.fn().mockImplementation(function ProtobufServiceImpl() {
return {
handleMessageEvent: vi.fn(),
sendKeepAliveCommand: vi.fn(),
resetCommands: vi.fn(),
};
}),
}));
vi.mock('./persistence', () => ({
@ -20,12 +24,17 @@ vi.mock('./persistence', () => ({
SessionPersistence: { clearStore: vi.fn(), initialized: vi.fn() },
}));
vi.mock('store', () => ({
GameDispatch: { clearStore: vi.fn() },
}));
import { WebClient } from './WebClient';
import { WebSocketService } from './services/WebSocketService';
import { ProtobufService } from './services/ProtobufService';
import { RoomPersistence, SessionPersistence } from './persistence';
import { StatusEnum } from 'types';
import { Subject } from 'rxjs';
import { Mock } from 'vitest';
describe('WebClient', () => {
let client: WebClient;
@ -33,18 +42,22 @@ describe('WebClient', () => {
beforeEach(() => {
vi.clearAllMocks();
(ProtobufService as vi.Mock).mockImplementation(() => ({
handleMessageEvent: vi.fn(),
sendKeepAliveCommand: vi.fn(),
resetCommands: vi.fn(),
}));
(ProtobufService as Mock).mockImplementation(function ProtobufServiceImpl() {
return {
handleMessageEvent: vi.fn(),
sendKeepAliveCommand: vi.fn(),
resetCommands: vi.fn(),
};
});
messageSubject = new Subject<MessageEvent>();
(WebSocketService as vi.Mock).mockImplementation(() => ({
message$: messageSubject,
connect: vi.fn(),
testConnect: vi.fn(),
disconnect: vi.fn(),
}));
(WebSocketService as Mock).mockImplementation(function WebSocketServiceImpl() {
return {
message$: messageSubject,
connect: vi.fn(),
testConnect: vi.fn(),
disconnect: vi.fn(),
};
});
// suppress console.log from constructor in non-test-env check
vi.spyOn(console, 'log').mockImplementation(() => {});
client = new WebClient();

View file

@ -77,7 +77,7 @@ export class WebClient {
}
}
public keepAlive(pingReceived: Function) {
public keepAlive(pingReceived: () => void) {
this.protobuf.sendKeepAliveCommand(pingReceived);
}

View file

@ -7,7 +7,9 @@
* Defaults to 2 (ext, value, options).
* Use 3 for sendRoomCommand (roomId, ext, value, options).
*/
export function makeCallbackHelpers(mockFn: vi.Mock, optsArgIndex = 2) {
import { Mock } from 'vitest';
export function makeCallbackHelpers(mockFn: Mock, optsArgIndex = 2) {
function getLastSendOpts() {
const calls = mockFn.mock.calls;
return calls[calls.length - 1]?.[optsArgIndex];

View file

@ -9,7 +9,7 @@ export function makeMockWebSocketInstance() {
return {
send: vi.fn(),
close: vi.fn(),
readyState: WebSocket.OPEN,
readyState: WebSocket.OPEN as number,
binaryType: '' as BinaryType,
onopen: null as any,
onclose: null as any,
@ -18,12 +18,17 @@ export function makeMockWebSocketInstance() {
};
}
/** Installs a mock WebSocket constructor on global. Returns the mock instance. */
/** Installs a mock WebSocket constructor on global. Returns the mock instance and a cleanup function. */
export function installMockWebSocket() {
const originalWebSocket = (globalThis as any).WebSocket;
const mockInstance = makeMockWebSocketInstance();
const MockWS = vi.fn(() => mockInstance) as any;
const MockWS = vi.fn(function MockWebSocket() {
return mockInstance;
}) as any;
MockWS.OPEN = 1;
MockWS.CLOSED = 3;
(global as any).WebSocket = MockWS;
return { MockWS, mockInstance };
(globalThis as any).WebSocket = MockWS;
return { MockWS, mockInstance, restore: () => {
(globalThis as any).WebSocket = originalWebSocket;
} };
}

View file

@ -21,8 +21,10 @@ import { reloadConfig } from './reloadConfig';
import { shutdownServer } from './shutdownServer';
import { updateServerMessage } from './updateServerMessage';
const { getLastSendOpts, invokeOnSuccess } = makeCallbackHelpers(
BackendService.sendAdminCommand as vi.Mock,
import { Mock } from 'vitest';
const { invokeOnSuccess } = makeCallbackHelpers(
BackendService.sendAdminCommand as Mock,
2
);

View file

@ -72,8 +72,10 @@ vi.mock('../../services/BackendService', () => ({
const gameId = 1;
import { Mock } from 'vitest';
beforeEach(() => {
(BackendService.sendGameCommand as vi.Mock).mockClear();
(BackendService.sendGameCommand as Mock).mockClear();
});
describe('Game commands — delegate to BackendService.sendGameCommand', () => {

View file

@ -8,7 +8,7 @@ export function getWarnList(modName: string, userName: string, userClientid: str
BackendService.sendModeratorCommand(Command_GetWarnList_ext, create(Command_GetWarnListSchema, { modName, userName, userClientid }), {
responseExt: Response_WarnList_ext,
onSuccess: (response) => {
ModeratorPersistence.warnListOptions(response.warning);
ModeratorPersistence.warnListOptions([response]);
},
});
}

View file

@ -50,8 +50,10 @@ import { updateAdminNotes } from './updateAdminNotes';
import { viewLogHistory } from './viewLogHistory';
import { warnUser } from './warnUser';
const { getLastSendOpts, invokeOnSuccess } = makeCallbackHelpers(
BackendService.sendModeratorCommand as vi.Mock,
import { Mock } from 'vitest';
const { invokeOnSuccess } = makeCallbackHelpers(
BackendService.sendModeratorCommand as Mock,
2
);
@ -175,11 +177,11 @@ describe('getWarnList', () => {
);
});
it('onSuccess calls ModeratorPersistence.warnListOptions with warning', () => {
it('onSuccess calls ModeratorPersistence.warnListOptions with the response', () => {
getWarnList('mod1', 'alice', 'US');
const resp = { warning: ['w1', 'w2'] };
const resp = { warning: ['w1', 'w2'], userName: 'alice', userClientid: 'US' };
invokeOnSuccess(resp, { responseCode: 0 });
expect(ModeratorPersistence.warnListOptions).toHaveBeenCalledWith(['w1', 'w2']);
expect(ModeratorPersistence.warnListOptions).toHaveBeenCalledWith([resp]);
});
});

View file

@ -21,8 +21,10 @@ import { joinGame } from './joinGame';
import { leaveRoom } from './leaveRoom';
import { roomSay } from './roomSay';
const { getLastSendOpts, invokeOnSuccess } = makeCallbackHelpers(
BackendService.sendRoomCommand as vi.Mock,
import { Mock } from 'vitest';
const { invokeOnSuccess } = makeCallbackHelpers(
BackendService.sendRoomCommand as Mock,
// sendRoomCommand(roomId, ext, value, options) — options at index 3
3
);

View file

@ -8,7 +8,9 @@ export function deckList(): void {
BackendService.sendSessionCommand(Command_DeckList_ext, create(Command_DeckListSchema), {
responseExt: Response_DeckList_ext,
onSuccess: (response) => {
SessionPersistence.updateServerDecks(response);
if (response.root) {
SessionPersistence.updateServerDecks(response);
}
},
});
}

View file

@ -8,7 +8,9 @@ export function deckUpload(path: string, deckId: number, deckList: string): void
BackendService.sendSessionCommand(Command_DeckUpload_ext, create(Command_DeckUploadSchema, { path, deckId, deckList }), {
responseExt: Response_DeckUpload_ext,
onSuccess: (response) => {
SessionPersistence.uploadServerDeck(path, response.newFile);
if (response.newFile) {
SessionPersistence.uploadServerDeck(path, response.newFile);
}
},
});
}

View file

@ -8,7 +8,9 @@ export function joinRoom(roomId: number): void {
BackendService.sendSessionCommand(Command_JoinRoom_ext, create(Command_JoinRoomSchema, { roomId }), {
responseExt: Response_JoinRoom_ext,
onSuccess: (response) => {
RoomPersistence.joinRoom(response.roomInfo);
if (response.roomInfo) {
RoomPersistence.joinRoom(response.roomInfo);
}
},
});
}

View file

@ -2,8 +2,8 @@ import { create } from '@bufbuild/protobuf';
import { BackendService } from '../../services/BackendService';
import { Command_Ping_ext, Command_PingSchema } from 'generated/proto/session_commands_pb';
export function ping(pingReceived: Function): void {
export function ping(pingReceived: () => void): void {
BackendService.sendSessionCommand(Command_Ping_ext, create(Command_PingSchema), {
onResponse: (raw) => pingReceived(raw),
onResponse: () => pingReceived(),
});
}

View file

@ -1,7 +1,7 @@
import { ServerRegisterParams } from 'store';
import { StatusEnum, WebSocketConnectOptions } from 'types';
import { create } from '@bufbuild/protobuf';
import { create, getExtension } from '@bufbuild/protobuf';
import type { MessageInitShape } from '@bufbuild/protobuf';
import webClient from '../../WebClient';
import { BackendService } from '../../services/BackendService';
@ -9,6 +9,7 @@ import { Command_Register_ext, Command_RegisterSchema } from 'generated/proto/se
import { SessionPersistence } from '../../persistence';
import { hashPassword } from '../../utils';
import { Response_ResponseCode } from 'generated/proto/response_pb';
import { Response_Register_ext } from 'generated/proto/response_register_pb';
import { login, disconnect, updateStatus } from './';
@ -65,9 +66,12 @@ export function register(options: WebSocketConnectOptions, password?: string, pa
[Response_ResponseCode.RespRegistrationDisabled]: () => onRegistrationError(
() => SessionPersistence.registrationFailed('Registration is currently disabled')
),
[Response_ResponseCode.RespUserIsBanned]: (raw) => onRegistrationError(
() => SessionPersistence.registrationFailed(raw.reasonStr, raw.endTime)
),
[Response_ResponseCode.RespUserIsBanned]: (raw) => {
const register = getExtension(raw, Response_Register_ext);
onRegistrationError(
() => SessionPersistence.registrationFailed(register.deniedReasonStr, Number(register.deniedEndTime))
);
},
},
onError: () => onRegistrationError(
() => SessionPersistence.registrationFailed('Registration failed due to a server issue')

View file

@ -31,6 +31,7 @@ vi.mock('./', async () => {
return makeSessionBarrelMock();
});
import { Mock } from 'vitest';
import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers';
import { BackendService } from '../../services/BackendService';
import { SessionPersistence } from '../../persistence';
@ -51,6 +52,9 @@ import {
import { Response_ForgotPasswordRequest_ext } from 'generated/proto/response_forgotpasswordrequest_pb';
import { Response_Login_ext } from 'generated/proto/response_login_pb';
import { Response_PasswordSalt_ext } from 'generated/proto/response_password_salt_pb';
import { Response_Register_ext, Response_RegisterSchema } from 'generated/proto/response_register_pb';
import { create, setExtension } from '@bufbuild/protobuf';
import { ResponseSchema } from 'generated/proto/response_pb';
import { connect } from './connect';
import { updateStatus } from './updateStatus';
import { login } from './login';
@ -61,16 +65,16 @@ import { forgotPasswordRequest } from './forgotPasswordRequest';
import { forgotPasswordReset } from './forgotPasswordReset';
import { requestPasswordSalt } from './requestPasswordSalt';
const { getLastSendOpts, invokeOnSuccess, invokeResponseCode, invokeOnError } = makeCallbackHelpers(
BackendService.sendSessionCommand as vi.Mock,
const { invokeOnSuccess, invokeResponseCode, invokeOnError } = makeCallbackHelpers(
BackendService.sendSessionCommand as Mock,
2
);
beforeEach(() => {
vi.clearAllMocks();
(hashPassword as vi.Mock).mockReturnValue('hashed_pw');
(generateSalt as vi.Mock).mockReturnValue('randSalt');
(passwordSaltSupported as vi.Mock).mockReturnValue(0);
(hashPassword as Mock).mockReturnValue('hashed_pw');
(generateSalt as Mock).mockReturnValue('randSalt');
(passwordSaltSupported as Mock).mockReturnValue(0);
});
// ----------------------------------------------------------------
@ -182,7 +186,7 @@ describe('login', () => {
login({ userName: 'alice' } as any, 'secret');
const loginResp = { buddyList: [], ignoreList: [], userInfo: { name: 'alice' } };
invokeOnSuccess(loginResp, { responseCode: 0 });
const calledWith = (SessionPersistence.loginSuccessful as vi.Mock).mock.calls[0][0];
const calledWith = (SessionPersistence.loginSuccessful as Mock).mock.calls[0][0];
expect(calledWith).not.toHaveProperty('password');
});
@ -190,7 +194,7 @@ describe('login', () => {
login({ userName: 'alice' } as any, 'pw', 'salt');
const loginResp = { buddyList: [], ignoreList: [], userInfo: { name: 'alice' } };
invokeOnSuccess(loginResp, { responseCode: 0 });
const calledWith = (SessionPersistence.loginSuccessful as vi.Mock).mock.calls[0][0];
const calledWith = (SessionPersistence.loginSuccessful as Mock).mock.calls[0][0];
expect(calledWith).toHaveProperty('hashedPassword', 'hashed_pw');
});
@ -347,9 +351,11 @@ describe('register', () => {
expect(SessionPersistence.registrationFailed).toHaveBeenCalled();
});
it('RespUserIsBanned calls registrationFailed with raw.reasonStr and raw.endTime', () => {
it('RespUserIsBanned calls registrationFailed with deniedReasonStr and deniedEndTime', () => {
register({ userName: 'alice' } as any, 'pw');
invokeResponseCode(Response_ResponseCode.RespUserIsBanned, { reasonStr: 'bad user', endTime: 9999 });
const raw = create(ResponseSchema, { responseCode: Response_ResponseCode.RespUserIsBanned });
setExtension(raw, Response_Register_ext, create(Response_RegisterSchema, { deniedReasonStr: 'bad user', deniedEndTime: 9999n }));
invokeResponseCode(Response_ResponseCode.RespUserIsBanned, raw);
expect(SessionPersistence.registrationFailed).toHaveBeenCalledWith('bad user', 9999);
});

View file

@ -31,12 +31,12 @@ vi.mock('./', async () => {
return { ...(actual as any), ...makeSessionBarrelMock() };
});
import { Mock } from 'vitest';
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';
import {
Command_AccountEdit_ext,
@ -95,15 +95,15 @@ import { replayGetCode } from './replayGetCode';
import { replaySubmitCode } from './replaySubmitCode';
const { invokeOnSuccess, invokeCallback } = makeCallbackHelpers(
BackendService.sendSessionCommand as vi.Mock,
BackendService.sendSessionCommand as Mock,
2
);
beforeEach(() => {
vi.clearAllMocks();
(hashPassword as vi.Mock).mockReturnValue('hashed_pw');
(generateSalt as vi.Mock).mockReturnValue('randSalt');
(passwordSaltSupported as vi.Mock).mockReturnValue(0);
(hashPassword as Mock).mockReturnValue('hashed_pw');
(generateSalt as Mock).mockReturnValue('randSalt');
(passwordSaltSupported as Mock).mockReturnValue(0);
});
// ----------------------------------------------------------------
@ -215,9 +215,9 @@ describe('deckList', () => {
it('calls updateServerDecks on success', () => {
deckList();
const resp = { folders: [] };
invokeOnSuccess(resp, { responseCode: 0 });
expect(SessionPersistence.updateServerDecks).toHaveBeenCalledWith(resp);
const root = { items: [] };
invokeOnSuccess({ root }, { responseCode: 0 });
expect(SessionPersistence.updateServerDecks).toHaveBeenCalledWith({ root });
});
});
@ -380,9 +380,8 @@ describe('ping', () => {
it('calls pingReceived via onResponse', () => {
const pingReceived = vi.fn();
ping(pingReceived);
const raw = {};
invokeCallback('onResponse', raw);
expect(pingReceived).toHaveBeenCalledWith(raw);
invokeCallback('onResponse', {});
expect(pingReceived).toHaveBeenCalled();
});
});

View file

@ -1,3 +1,3 @@
import { ExtensionRegistry } from '../../services/ProtobufService';
import { SessionExtensionRegistry } from '../../services/ProtobufService';
export const CommonEvents: ExtensionRegistry = [];
export const CommonEvents: SessionExtensionRegistry = [];

View file

@ -1,4 +1,4 @@
import { ExtensionRegistry } from '../../services/ProtobufService';
import { GameExtensionRegistry, makeGameEntry } from '../../services/ProtobufService';
import { attachCard } from './attachCard';
import { changeZoneProperties } from './changeZoneProperties';
import { createArrow } from './createArrow';
@ -59,35 +59,35 @@ import { Event_DumpZone_ext } from 'generated/proto/event_dump_zone_pb';
import { Event_ChangeZoneProperties_ext } from 'generated/proto/event_change_zone_properties_pb';
import { Event_ReverseTurn_ext } from 'generated/proto/event_reverse_turn_pb';
export const GameEvents: ExtensionRegistry = [
[Event_Join_ext, joinGame],
[Event_Leave_ext, leaveGame],
[Event_GameClosed_ext, gameClosed],
[Event_GameHostChanged_ext, gameHostChanged],
[Event_Kicked_ext, kicked],
[Event_GameStateChanged_ext, gameStateChanged],
[Event_PlayerPropertiesChanged_ext, playerPropertiesChanged],
[Event_GameSay_ext, gameSay],
[Event_CreateArrow_ext, createArrow],
[Event_DeleteArrow_ext, deleteArrow],
[Event_CreateCounter_ext, createCounter],
[Event_SetCounter_ext, setCounter],
[Event_DelCounter_ext, delCounter],
[Event_DrawCards_ext, drawCards],
[Event_RevealCards_ext, revealCards],
[Event_Shuffle_ext, shuffle],
[Event_RollDie_ext, rollDie],
[Event_MoveCard_ext, moveCard],
[Event_FlipCard_ext, flipCard],
[Event_DestroyCard_ext, destroyCard],
[Event_AttachCard_ext, attachCard],
[Event_CreateToken_ext, createToken],
[Event_SetCardAttr_ext, setCardAttr],
[Event_SetCardCounter_ext, setCardCounter],
[Event_SetActivePlayer_ext, setActivePlayer],
[Event_SetActivePhase_ext, setActivePhase],
[Event_DumpZone_ext, dumpZone],
[Event_ChangeZoneProperties_ext, changeZoneProperties],
[Event_ReverseTurn_ext, reverseTurn],
export const GameEvents: GameExtensionRegistry = [
makeGameEntry(Event_Join_ext, joinGame),
makeGameEntry(Event_Leave_ext, leaveGame),
makeGameEntry(Event_GameClosed_ext, gameClosed),
makeGameEntry(Event_GameHostChanged_ext, gameHostChanged),
makeGameEntry(Event_Kicked_ext, kicked),
makeGameEntry(Event_GameStateChanged_ext, gameStateChanged),
makeGameEntry(Event_PlayerPropertiesChanged_ext, playerPropertiesChanged),
makeGameEntry(Event_GameSay_ext, gameSay),
makeGameEntry(Event_CreateArrow_ext, createArrow),
makeGameEntry(Event_DeleteArrow_ext, deleteArrow),
makeGameEntry(Event_CreateCounter_ext, createCounter),
makeGameEntry(Event_SetCounter_ext, setCounter),
makeGameEntry(Event_DelCounter_ext, delCounter),
makeGameEntry(Event_DrawCards_ext, drawCards),
makeGameEntry(Event_RevealCards_ext, revealCards),
makeGameEntry(Event_Shuffle_ext, shuffle),
makeGameEntry(Event_RollDie_ext, rollDie),
makeGameEntry(Event_MoveCard_ext, moveCard),
makeGameEntry(Event_FlipCard_ext, flipCard),
makeGameEntry(Event_DestroyCard_ext, destroyCard),
makeGameEntry(Event_AttachCard_ext, attachCard),
makeGameEntry(Event_CreateToken_ext, createToken),
makeGameEntry(Event_SetCardAttr_ext, setCardAttr),
makeGameEntry(Event_SetCardCounter_ext, setCardCounter),
makeGameEntry(Event_SetActivePlayer_ext, setActivePlayer),
makeGameEntry(Event_SetActivePhase_ext, setActivePhase),
makeGameEntry(Event_DumpZone_ext, dumpZone),
makeGameEntry(Event_ChangeZoneProperties_ext, changeZoneProperties),
makeGameEntry(Event_ReverseTurn_ext, reverseTurn),
];

View file

@ -1,4 +1,4 @@
import { ExtensionRegistry } from '../../services/ProtobufService';
import { RoomExtensionRegistry, makeRoomEntry } from '../../services/ProtobufService';
import { joinRoom } from './joinRoom';
import { leaveRoom } from './leaveRoom';
@ -12,11 +12,11 @@ import { Event_ListGames_ext } from 'generated/proto/event_list_games_pb';
import { Event_RemoveMessages_ext } from 'generated/proto/event_remove_messages_pb';
import { Event_RoomSay_ext } from 'generated/proto/event_room_say_pb';
export const RoomEvents: ExtensionRegistry = [
[Event_JoinRoom_ext, joinRoom],
[Event_LeaveRoom_ext, leaveRoom],
[Event_ListGames_ext, listGames],
[Event_RemoveMessages_ext, removeMessages],
[Event_RoomSay_ext, roomSay],
export const RoomEvents: RoomExtensionRegistry = [
makeRoomEntry(Event_JoinRoom_ext, joinRoom),
makeRoomEntry(Event_LeaveRoom_ext, leaveRoom),
makeRoomEntry(Event_ListGames_ext, listGames),
makeRoomEntry(Event_RemoveMessages_ext, removeMessages),
makeRoomEntry(Event_RoomSay_ext, roomSay),
];

View file

@ -2,10 +2,12 @@ import type { Event_JoinRoom } from 'generated/proto/event_join_room_pb';
import type { Event_LeaveRoom } from 'generated/proto/event_leave_room_pb';
import type { Event_ListGames } from 'generated/proto/event_list_games_pb';
import type { Event_RemoveMessages } from 'generated/proto/event_remove_messages_pb';
import type { Event_RoomSay } from 'generated/proto/event_room_say_pb';
import type { RoomEvent as GeneratedRoomEvent } from 'generated/proto/room_event_pb';
export type JoinRoomData = Event_JoinRoom;
export type LeaveRoomData = Event_LeaveRoom;
export type ListGamesData = Event_ListGames;
export type RemoveMessagesData = Event_RemoveMessages;
export type RoomSayData = Event_RoomSay;
export type RoomEvent = GeneratedRoomEvent;

View file

@ -8,30 +8,37 @@ vi.mock('../../persistence', () => ({
},
}));
import { create } from '@bufbuild/protobuf';
import { RoomPersistence } from '../../persistence';
import { joinRoom } from './joinRoom';
import { leaveRoom } from './leaveRoom';
import { listGames } from './listGames';
import { removeMessages } from './removeMessages';
import { roomSay } from './roomSay';
import { Event_JoinRoomSchema } from 'generated/proto/event_join_room_pb';
import { Event_LeaveRoomSchema } from 'generated/proto/event_leave_room_pb';
import { Event_ListGamesSchema } from 'generated/proto/event_list_games_pb';
import { Event_RemoveMessagesSchema } from 'generated/proto/event_remove_messages_pb';
import { Event_RoomSaySchema } from 'generated/proto/event_room_say_pb';
import { RoomEventSchema } from 'generated/proto/room_event_pb';
const makeRoomEvent = (roomId: number) => ({ roomId }) as any;
const makeRoomEvent = (roomId: number) => create(RoomEventSchema, { roomId });
beforeEach(() => vi.clearAllMocks());
describe('joinRoom room event', () => {
it('calls RoomPersistence.userJoined with roomId and userInfo', () => {
const userInfo = { name: 'alice' } as any;
joinRoom({ userInfo }, makeRoomEvent(3));
expect(RoomPersistence.userJoined).toHaveBeenCalledWith(3, userInfo);
const data = create(Event_JoinRoomSchema, { userInfo: { name: 'alice' } });
joinRoom(data, makeRoomEvent(3));
expect(RoomPersistence.userJoined).toHaveBeenCalledWith(3, data.userInfo);
});
});
describe('leaveRoom room event', () => {
it('calls RoomPersistence.userLeft with roomId and name', () => {
leaveRoom({ name: 'alice' }, makeRoomEvent(4));
leaveRoom(create(Event_LeaveRoomSchema, { name: 'alice' }), makeRoomEvent(4));
expect(RoomPersistence.userLeft).toHaveBeenCalledWith(4, 'alice');
});
});
@ -39,25 +46,29 @@ describe('leaveRoom room event', () => {
describe('listGames room event', () => {
it('calls RoomPersistence.updateGames with roomId and gameList', () => {
const gameList = [{ gameId: 1 }] as any;
listGames({ gameList }, makeRoomEvent(5));
expect(RoomPersistence.updateGames).toHaveBeenCalledWith(5, gameList);
const data = create(Event_ListGamesSchema, { gameList: [{ gameId: 1 }] });
listGames(data, makeRoomEvent(5));
expect(RoomPersistence.updateGames).toHaveBeenCalledWith(5, data.gameList);
});
});
describe('removeMessages room event', () => {
it('calls RoomPersistence.removeMessages with roomId, name, amount', () => {
removeMessages({ name: 'bob', amount: 10 }, makeRoomEvent(6));
removeMessages(create(Event_RemoveMessagesSchema, { name: 'bob', amount: 10 }), makeRoomEvent(6));
expect(RoomPersistence.removeMessages).toHaveBeenCalledWith(6, 'bob', 10);
});
});
describe('roomSay room event', () => {
beforeEach(() => {
vi.useFakeTimers(); vi.setSystemTime(0);
});
afterEach(() => vi.useRealTimers());
it('calls RoomPersistence.addMessage with roomId and message', () => {
const msg = { text: 'hello' } as any;
roomSay(msg, makeRoomEvent(7));
expect(RoomPersistence.addMessage).toHaveBeenCalledWith(7, msg);
const data = create(Event_RoomSaySchema, { message: 'hello' });
roomSay(data, makeRoomEvent(7));
expect(RoomPersistence.addMessage).toHaveBeenCalledWith(7, { ...data, timeReceived: 0 });
});
});

View file

@ -1,8 +1,9 @@
import { Message } from 'types';
import { RoomPersistence } from '../../persistence';
import { RoomEvent } from './interfaces';
import { RoomSayData, RoomEvent } from './interfaces';
export function roomSay(message: Message, { roomId }: RoomEvent): void {
export function roomSay(data: RoomSayData, { roomId }: RoomEvent): void {
const message: Message = { ...data, timeReceived: Date.now() };
RoomPersistence.addMessage(roomId, message);
}

View file

@ -1,4 +1,4 @@
import { ExtensionRegistry } from '../../services/ProtobufService';
import { SessionExtensionRegistry, makeSessionEntry } from '../../services/ProtobufService';
import { addToList } from './addToList';
import { connectionClosed } from './connectionClosed';
import { listRooms } from './listRooms';
@ -29,20 +29,20 @@ import { Event_UserJoined_ext } from 'generated/proto/event_user_joined_pb';
import { Event_UserLeft_ext } from 'generated/proto/event_user_left_pb';
import { Event_UserMessage_ext } from 'generated/proto/event_user_message_pb';
export const SessionEvents: ExtensionRegistry = [
[Event_AddToList_ext, addToList],
[Event_ConnectionClosed_ext, connectionClosed],
[Event_GameJoined_ext, gameJoined],
[Event_ListRooms_ext, listRooms],
[Event_NotifyUser_ext, notifyUser],
[Event_RemoveFromList_ext, removeFromList],
[Event_ReplayAdded_ext, replayAdded],
[Event_ServerCompleteList_ext, serverCompleteList],
[Event_ServerIdentification_ext, serverIdentification],
[Event_ServerMessage_ext, serverMessage],
[Event_ServerShutdown_ext, serverShutdown],
[Event_UserJoined_ext, userJoined],
[Event_UserLeft_ext, userLeft],
[Event_UserMessage_ext, userMessage],
export const SessionEvents: SessionExtensionRegistry = [
makeSessionEntry(Event_AddToList_ext, addToList),
makeSessionEntry(Event_ConnectionClosed_ext, connectionClosed),
makeSessionEntry(Event_GameJoined_ext, gameJoined),
makeSessionEntry(Event_ListRooms_ext, listRooms),
makeSessionEntry(Event_NotifyUser_ext, notifyUser),
makeSessionEntry(Event_RemoveFromList_ext, removeFromList),
makeSessionEntry(Event_ReplayAdded_ext, replayAdded),
makeSessionEntry(Event_ServerCompleteList_ext, serverCompleteList),
makeSessionEntry(Event_ServerIdentification_ext, serverIdentification),
makeSessionEntry(Event_ServerMessage_ext, serverMessage),
makeSessionEntry(Event_ServerShutdown_ext, serverShutdown),
makeSessionEntry(Event_UserJoined_ext, userJoined),
makeSessionEntry(Event_UserLeft_ext, userLeft),
makeSessionEntry(Event_UserMessage_ext, userMessage),
];

View file

@ -52,7 +52,21 @@ vi.mock('../../utils', () => ({
}));
import { WebSocketConnectReason } from 'types';
import { Event_ConnectionClosed_CloseReason } from 'generated/proto/event_connection_closed_pb';
import { create } from '@bufbuild/protobuf';
import { Event_ConnectionClosed_CloseReason, Event_ConnectionClosedSchema } from 'generated/proto/event_connection_closed_pb';
import { Event_GameJoinedSchema } from 'generated/proto/event_game_joined_pb';
import { Event_NotifyUserSchema } from 'generated/proto/event_notify_user_pb';
import { Event_ReplayAddedSchema } from 'generated/proto/event_replay_added_pb';
import { Event_ServerCompleteListSchema } from 'generated/proto/event_server_complete_list_pb';
import { Event_ServerMessageSchema } from 'generated/proto/event_server_message_pb';
import { Event_ServerShutdownSchema } from 'generated/proto/event_server_shutdown_pb';
import { Event_UserJoinedSchema } from 'generated/proto/event_user_joined_pb';
import { Event_UserLeftSchema } from 'generated/proto/event_user_left_pb';
import { Event_UserMessageSchema } from 'generated/proto/event_user_message_pb';
import { Event_AddToListSchema } from 'generated/proto/event_add_to_list_pb';
import { Event_RemoveFromListSchema } from 'generated/proto/event_remove_from_list_pb';
import { Event_ListRoomsSchema } from 'generated/proto/event_list_rooms_pb';
import { Event_ServerIdentificationSchema } from 'generated/proto/event_server_identification_pb';
import { SessionPersistence, RoomPersistence } from '../../persistence';
import webClient from '../../WebClient';
@ -72,11 +86,12 @@ import { removeFromList } from './removeFromList';
import { listRooms } from './listRooms';
import { connectionClosed } from './connectionClosed';
import { serverIdentification } from './serverIdentification';
import { Mock } from 'vitest';
beforeEach(() => {
vi.clearAllMocks();
(Utils.generateSalt as vi.Mock).mockReturnValue('newSalt');
(Utils.passwordSaltSupported as vi.Mock).mockReturnValue(0);
(Utils.generateSalt as Mock).mockReturnValue('newSalt');
(Utils.passwordSaltSupported as Mock).mockReturnValue(0);
});
// ----------------------------------------------------------------
@ -85,7 +100,7 @@ beforeEach(() => {
describe('gameJoined', () => {
it('calls SessionPersistence.gameJoined', () => {
const data = { gameId: 1 } as any;
const data = create(Event_GameJoinedSchema, { playerId: 1 });
gameJoined(data);
expect(SessionPersistence.gameJoined).toHaveBeenCalledWith(data);
});
@ -97,7 +112,7 @@ describe('gameJoined', () => {
describe('notifyUser', () => {
it('calls SessionPersistence.notifyUser', () => {
const data = { message: 'yo' } as any;
const data = create(Event_NotifyUserSchema, { warningReason: 'yo' });
notifyUser(data);
expect(SessionPersistence.notifyUser).toHaveBeenCalledWith(data);
});
@ -109,8 +124,10 @@ describe('notifyUser', () => {
describe('replayAdded', () => {
it('calls SessionPersistence.replayAdded with matchInfo', () => {
replayAdded({ matchInfo: { id: 42 } } as any);
expect(SessionPersistence.replayAdded).toHaveBeenCalledWith({ id: 42 });
const data = create(Event_ReplayAddedSchema);
data.matchInfo = { gameId: 42 } as any;
replayAdded(data);
expect(SessionPersistence.replayAdded).toHaveBeenCalledWith(data.matchInfo);
});
});
@ -120,9 +137,10 @@ describe('replayAdded', () => {
describe('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']);
const data = create(Event_ServerCompleteListSchema, { userList: [], roomList: [] });
serverCompleteList(data);
expect(SessionPersistence.updateUsers).toHaveBeenCalledWith(data.userList);
expect(RoomPersistence.updateRooms).toHaveBeenCalledWith(data.roomList);
});
});
@ -132,7 +150,7 @@ describe('serverCompleteList', () => {
describe('serverMessage', () => {
it('calls SessionPersistence.serverMessage with message', () => {
serverMessage({ message: 'hello server' });
serverMessage(create(Event_ServerMessageSchema, { message: 'hello server' }));
expect(SessionPersistence.serverMessage).toHaveBeenCalledWith('hello server');
});
});
@ -143,7 +161,7 @@ describe('serverMessage', () => {
describe('serverShutdown', () => {
it('calls SessionPersistence.serverShutdown', () => {
const payload = { reason: 'maintenance' } as any;
const payload = create(Event_ServerShutdownSchema, { reason: 'maintenance' });
serverShutdown(payload);
expect(SessionPersistence.serverShutdown).toHaveBeenCalledWith(payload);
});
@ -155,8 +173,10 @@ describe('serverShutdown', () => {
describe('userJoined', () => {
it('calls SessionPersistence.userJoined with userInfo', () => {
userJoined({ userInfo: { name: 'alice' } } as any);
expect(SessionPersistence.userJoined).toHaveBeenCalledWith({ name: 'alice' });
const data = create(Event_UserJoinedSchema);
data.userInfo = { name: 'alice' } as any;
userJoined(data);
expect(SessionPersistence.userJoined).toHaveBeenCalledWith(data.userInfo);
});
});
@ -166,7 +186,7 @@ describe('userJoined', () => {
describe('userLeft', () => {
it('calls SessionPersistence.userLeft with name', () => {
userLeft({ name: 'bob' });
userLeft(create(Event_UserLeftSchema, { name: 'bob' }));
expect(SessionPersistence.userLeft).toHaveBeenCalledWith('bob');
});
});
@ -177,7 +197,7 @@ describe('userLeft', () => {
describe('userMessage', () => {
it('calls SessionPersistence.userMessage', () => {
const payload = { userName: 'alice', message: 'hi' } as any;
const payload = create(Event_UserMessageSchema, { senderName: 'alice', message: 'hi' });
userMessage(payload);
expect(SessionPersistence.userMessage).toHaveBeenCalledWith(payload);
});
@ -187,21 +207,27 @@ describe('userMessage', () => {
// addToList
// ----------------------------------------------------------------
describe('addToList', () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
afterAll(() => logSpy.mockRestore());
let logSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
it('buddy list → addToBuddyList', () => {
addToList({ listName: 'buddy', userInfo: { name: 'alice' } } as any);
expect(SessionPersistence.addToBuddyList).toHaveBeenCalledWith({ name: 'alice' });
const data = create(Event_AddToListSchema, { listName: 'buddy' });
data.userInfo = { name: 'alice' } as any;
addToList(data);
expect(SessionPersistence.addToBuddyList).toHaveBeenCalledWith(data.userInfo);
});
it('ignore list → addToIgnoreList', () => {
addToList({ listName: 'ignore', userInfo: { name: 'bob' } } as any);
expect(SessionPersistence.addToIgnoreList).toHaveBeenCalledWith({ name: 'bob' });
const data = create(Event_AddToListSchema, { listName: 'ignore' });
data.userInfo = { name: 'bob' } as any;
addToList(data);
expect(SessionPersistence.addToIgnoreList).toHaveBeenCalledWith(data.userInfo);
});
it('unknown list → console.log', () => {
addToList({ listName: 'unknown', userInfo: {} } as any);
addToList(create(Event_AddToListSchema, { listName: 'unknown' }));
expect(logSpy).toHaveBeenCalled();
});
});
@ -212,18 +238,18 @@ describe('addToList', () => {
describe('removeFromList', () => {
it('buddy list → removeFromBuddyList', () => {
removeFromList({ listName: 'buddy', userName: 'alice' } as any);
removeFromList(create(Event_RemoveFromListSchema, { listName: 'buddy', userName: 'alice' }));
expect(SessionPersistence.removeFromBuddyList).toHaveBeenCalledWith('alice');
});
it('ignore list → removeFromIgnoreList', () => {
removeFromList({ listName: 'ignore', userName: 'bob' } as any);
removeFromList(create(Event_RemoveFromListSchema, { listName: 'ignore', userName: 'bob' }));
expect(SessionPersistence.removeFromIgnoreList).toHaveBeenCalledWith('bob');
});
it('unknown list → console.log', () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
removeFromList({ listName: 'other', userName: 'x' } as any);
removeFromList(create(Event_RemoveFromListSchema, { listName: 'other', userName: 'x' }));
expect(logSpy).toHaveBeenCalled();
logSpy.mockRestore();
});
@ -235,19 +261,19 @@ describe('removeFromList', () => {
describe('listRooms', () => {
it('calls RoomPersistence.updateRooms', () => {
listRooms({ roomList: [] });
listRooms(create(Event_ListRoomsSchema, { 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);
listRooms(create(Event_ListRoomsSchema, { 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);
listRooms(create(Event_ListRoomsSchema, { roomList: [{ autoJoin: true, roomId: 2 }, { autoJoin: false, roomId: 3 }] as any[] }));
expect(SessionCmds.joinRoom).toHaveBeenCalledTimes(1);
expect(SessionCmds.joinRoom).toHaveBeenCalledWith(2);
});
@ -259,12 +285,12 @@ describe('listRooms', () => {
describe('connectionClosed', () => {
it('uses reasonStr when provided', () => {
connectionClosed({ reason: 0, reasonStr: 'custom' } as any);
connectionClosed(create(Event_ConnectionClosedSchema, { reason: 0, reasonStr: 'custom' }));
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'custom');
});
it('USER_LIMIT_REACHED → specific message', () => {
connectionClosed({ reason: Event_ConnectionClosed_CloseReason.USER_LIMIT_REACHED } as any);
connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.USER_LIMIT_REACHED }));
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('maximum user capacity')
@ -272,42 +298,42 @@ describe('connectionClosed', () => {
});
it('TOO_MANY_CONNECTIONS → specific message', () => {
connectionClosed({ reason: Event_ConnectionClosed_CloseReason.TOO_MANY_CONNECTIONS } as any);
connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.TOO_MANY_CONNECTIONS }));
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('too many concurrent'));
});
it('BANNED → specific message', () => {
connectionClosed({ reason: Event_ConnectionClosed_CloseReason.BANNED } as any);
connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.BANNED }));
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'You are banned');
});
it('DEMOTED → specific message', () => {
connectionClosed({ reason: Event_ConnectionClosed_CloseReason.DEMOTED } as any);
connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.DEMOTED }));
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('demoted'));
});
it('SERVER_SHUTDOWN → specific message', () => {
connectionClosed({ reason: Event_ConnectionClosed_CloseReason.SERVER_SHUTDOWN } as any);
connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.SERVER_SHUTDOWN }));
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('shutdown'));
});
it('USERNAMEINVALID → specific message', () => {
connectionClosed({ reason: Event_ConnectionClosed_CloseReason.USERNAMEINVALID } as any);
connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.USERNAMEINVALID }));
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('username'));
});
it('LOGGEDINELSEWERE → specific message', () => {
connectionClosed({ reason: Event_ConnectionClosed_CloseReason.LOGGEDINELSEWERE } as any);
connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.LOGGEDINELSEWERE }));
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('logged out'));
});
it('OTHER → "Unknown reason"', () => {
connectionClosed({ reason: Event_ConnectionClosed_CloseReason.OTHER } as any);
connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.OTHER }));
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'Unknown reason');
});
it('BANNED with valid positive endTime → shows formatted date', () => {
connectionClosed({ reason: Event_ConnectionClosed_CloseReason.BANNED, endTime: 1700000000 } as any);
connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.BANNED, endTime: 1700000000 }));
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('You are banned until')
@ -315,27 +341,28 @@ describe('connectionClosed', () => {
});
it('BANNED with endTime = 0 → shows generic banned message', () => {
connectionClosed({ reason: Event_ConnectionClosed_CloseReason.BANNED, endTime: 0 } as any);
connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.BANNED, endTime: 0 }));
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'You are banned');
});
it('BANNED with endTime = -1 → shows generic banned message', () => {
connectionClosed({ reason: Event_ConnectionClosed_CloseReason.BANNED, endTime: -1 } as any);
connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.BANNED, endTime: -1 }));
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'You are banned');
});
it('BANNED with endTime = NaN → shows generic banned message', () => {
connectionClosed({ reason: Event_ConnectionClosed_CloseReason.BANNED, endTime: NaN } as any);
connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.BANNED, endTime: NaN }));
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'You are banned');
});
it('BANNED with endTime = Infinity → shows generic banned message', () => {
connectionClosed({ reason: Event_ConnectionClosed_CloseReason.BANNED, endTime: Infinity } as any);
connectionClosed(create(Event_ConnectionClosedSchema, { reason: Event_ConnectionClosed_CloseReason.BANNED, endTime: Infinity }));
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'You are banned');
});
it('BANNED with reasonStr → uses reasonStr regardless of endTime', () => {
connectionClosed({ reason: Event_ConnectionClosed_CloseReason.BANNED, endTime: 0, reasonStr: 'custom ban reason' } as any);
connectionClosed(create(Event_ConnectionClosedSchema,
{ reason: Event_ConnectionClosed_CloseReason.BANNED, endTime: 0, reasonStr: 'custom ban reason' }));
expect(SessionCmds.updateStatus).toHaveBeenCalledWith(expect.anything(), 'custom ban reason');
});
});
@ -351,15 +378,17 @@ describe('serverIdentification', () => {
});
it('disconnects when protocolVersion mismatches', () => {
serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 99, serverOptions: 0 } as any);
serverIdentification(create(Event_ServerIdentificationSchema,
{ serverName: 's', serverVersion: '1', protocolVersion: 99, serverOptions: 0 }));
expect(SessionCmds.updateStatus).toHaveBeenCalled();
expect(SessionCmds.disconnect).toHaveBeenCalled();
});
it('LOGIN reason without salt → calls login with password as separate param', () => {
(webClient as any).options = { reason: WebSocketConnectReason.LOGIN, password: 'secret' };
(Utils.passwordSaltSupported as vi.Mock).mockReturnValue(0);
serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 } as any);
(Utils.passwordSaltSupported as Mock).mockReturnValue(0);
serverIdentification(create(Event_ServerIdentificationSchema,
{ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 }));
expect(SessionCmds.login).toHaveBeenCalledWith(
expect.not.objectContaining({ password: expect.anything() }),
'secret'
@ -368,8 +397,9 @@ describe('serverIdentification', () => {
it('LOGIN reason with salt → calls requestPasswordSalt with password as separate param', () => {
(webClient as any).options = { reason: WebSocketConnectReason.LOGIN, password: 'secret' };
(Utils.passwordSaltSupported as vi.Mock).mockReturnValue(1);
serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 1 } as any);
(Utils.passwordSaltSupported as Mock).mockReturnValue(1);
serverIdentification(create(Event_ServerIdentificationSchema,
{ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 1 }));
expect(SessionCmds.requestPasswordSalt).toHaveBeenCalledWith(
expect.not.objectContaining({ password: expect.anything() }),
'secret'
@ -378,8 +408,9 @@ describe('serverIdentification', () => {
it('REGISTER reason without salt → calls register with password and null salt', () => {
(webClient as any).options = { reason: WebSocketConnectReason.REGISTER, password: 'secret' };
(Utils.passwordSaltSupported as vi.Mock).mockReturnValue(0);
serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 } as any);
(Utils.passwordSaltSupported as Mock).mockReturnValue(0);
serverIdentification(create(Event_ServerIdentificationSchema,
{ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 }));
expect(SessionCmds.register).toHaveBeenCalledWith(
expect.not.objectContaining({ password: expect.anything() }),
'secret',
@ -389,8 +420,9 @@ describe('serverIdentification', () => {
it('REGISTER reason with salt → calls register with password and generated salt', () => {
(webClient as any).options = { reason: WebSocketConnectReason.REGISTER, password: 'secret' };
(Utils.passwordSaltSupported as vi.Mock).mockReturnValue(1);
serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 1 } as any);
(Utils.passwordSaltSupported as Mock).mockReturnValue(1);
serverIdentification(create(Event_ServerIdentificationSchema,
{ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 1 }));
expect(SessionCmds.register).toHaveBeenCalledWith(
expect.not.objectContaining({ password: expect.anything() }),
'secret',
@ -400,8 +432,9 @@ describe('serverIdentification', () => {
it('ACTIVATE_ACCOUNT reason without salt → calls activate with password as separate param', () => {
(webClient as any).options = { reason: WebSocketConnectReason.ACTIVATE_ACCOUNT, password: 'secret' };
(Utils.passwordSaltSupported as vi.Mock).mockReturnValue(0);
serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 } as any);
(Utils.passwordSaltSupported as Mock).mockReturnValue(0);
serverIdentification(create(Event_ServerIdentificationSchema,
{ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 }));
expect(SessionCmds.activate).toHaveBeenCalledWith(
expect.not.objectContaining({ password: expect.anything() }),
'secret'
@ -410,8 +443,9 @@ describe('serverIdentification', () => {
it('ACTIVATE_ACCOUNT reason with salt → calls requestPasswordSalt with password as separate param', () => {
(webClient as any).options = { reason: WebSocketConnectReason.ACTIVATE_ACCOUNT, password: 'secret' };
(Utils.passwordSaltSupported as vi.Mock).mockReturnValue(1);
serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 1 } as any);
(Utils.passwordSaltSupported as Mock).mockReturnValue(1);
serverIdentification(create(Event_ServerIdentificationSchema,
{ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 1 }));
expect(SessionCmds.requestPasswordSalt).toHaveBeenCalledWith(
expect.not.objectContaining({ password: expect.anything() }),
'secret'
@ -420,20 +454,23 @@ describe('serverIdentification', () => {
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);
serverIdentification(create(Event_ServerIdentificationSchema,
{ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 }));
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);
serverIdentification(create(Event_ServerIdentificationSchema,
{ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 }));
expect(SessionCmds.forgotPasswordChallenge).toHaveBeenCalled();
});
it('PASSWORD_RESET reason without salt → calls forgotPasswordReset with newPassword as separate param', () => {
(webClient as any).options = { reason: WebSocketConnectReason.PASSWORD_RESET, newPassword: 'newpw' };
(Utils.passwordSaltSupported as vi.Mock).mockReturnValue(0);
serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 } as any);
(Utils.passwordSaltSupported as Mock).mockReturnValue(0);
serverIdentification(create(Event_ServerIdentificationSchema,
{ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 }));
expect(SessionCmds.forgotPasswordReset).toHaveBeenCalledWith(
expect.not.objectContaining({ newPassword: expect.anything() }),
'newpw'
@ -442,8 +479,9 @@ describe('serverIdentification', () => {
it('PASSWORD_RESET reason with salt → calls requestPasswordSalt with newPassword as separate param', () => {
(webClient as any).options = { reason: WebSocketConnectReason.PASSWORD_RESET, newPassword: 'newpw' };
(Utils.passwordSaltSupported as vi.Mock).mockReturnValue(1);
serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 1 } as any);
(Utils.passwordSaltSupported as Mock).mockReturnValue(1);
serverIdentification(create(Event_ServerIdentificationSchema,
{ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 1 }));
expect(SessionCmds.requestPasswordSalt).toHaveBeenCalledWith(
expect.not.objectContaining({ newPassword: expect.anything() }),
undefined,
@ -453,14 +491,16 @@ describe('serverIdentification', () => {
it('unknown reason → updateStatus DISCONNECTED and disconnect', () => {
(webClient as any).options = { reason: 999 };
serverIdentification({ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 } as any);
serverIdentification(create(Event_ServerIdentificationSchema,
{ serverName: 's', serverVersion: '1', protocolVersion: 14, serverOptions: 0 }));
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);
serverIdentification(create(Event_ServerIdentificationSchema,
{ serverName: 'myServer', serverVersion: '2.0', protocolVersion: 14, serverOptions: 0 }));
expect(SessionPersistence.updateInfo).toHaveBeenCalledWith('myServer', '2.0');
expect((webClient as any).options).toEqual({});
});

View file

@ -1,3 +1,4 @@
import { create } from '@bufbuild/protobuf';
import { GamePersistence } from './GamePersistence';
vi.mock('store', () => ({
@ -34,19 +35,40 @@ vi.mock('store', () => ({
},
}));
import { Event_GameStateChangedSchema } from 'generated/proto/event_game_state_changed_pb';
import { Event_MoveCardSchema } from 'generated/proto/event_move_card_pb';
import { Event_FlipCardSchema } from 'generated/proto/event_flip_card_pb';
import { Event_DestroyCardSchema } from 'generated/proto/event_destroy_card_pb';
import { Event_AttachCardSchema } from 'generated/proto/event_attach_card_pb';
import { Event_CreateTokenSchema } from 'generated/proto/event_create_token_pb';
import { Event_SetCardAttrSchema } from 'generated/proto/event_set_card_attr_pb';
import { Event_SetCardCounterSchema } from 'generated/proto/event_set_card_counter_pb';
import { Event_CreateArrowSchema } from 'generated/proto/event_create_arrow_pb';
import { Event_DeleteArrowSchema } from 'generated/proto/event_delete_arrow_pb';
import { Event_CreateCounterSchema } from 'generated/proto/event_create_counter_pb';
import { Event_SetCounterSchema } from 'generated/proto/event_set_counter_pb';
import { Event_DelCounterSchema } from 'generated/proto/event_del_counter_pb';
import { Event_DrawCardsSchema } from 'generated/proto/event_draw_cards_pb';
import { Event_RevealCardsSchema } from 'generated/proto/event_reveal_cards_pb';
import { Event_ShuffleSchema } from 'generated/proto/event_shuffle_pb';
import { Event_RollDieSchema } from 'generated/proto/event_roll_die_pb';
import { Event_DumpZoneSchema } from 'generated/proto/event_dump_zone_pb';
import { Event_ChangeZonePropertiesSchema } from 'generated/proto/event_change_zone_properties_pb';
import { ServerInfo_PlayerPropertiesSchema } from 'generated/proto/serverinfo_playerproperties_pb';
import { GameDispatch } from 'store';
beforeEach(() => vi.clearAllMocks());
describe('GamePersistence', () => {
it('gameStateChanged dispatches via GameDispatch', () => {
const data = { playerList: [] } as any;
const data = create(Event_GameStateChangedSchema, { playerList: [] });
GamePersistence.gameStateChanged(5, data);
expect(GameDispatch.gameStateChanged).toHaveBeenCalledWith(5, data);
});
it('playerJoined dispatches via GameDispatch', () => {
const data = { playerId: 1 } as any;
const data = create(ServerInfo_PlayerPropertiesSchema, { playerId: 1 });
GamePersistence.playerJoined(5, data);
expect(GameDispatch.playerJoined).toHaveBeenCalledWith(5, data);
});
@ -57,7 +79,7 @@ describe('GamePersistence', () => {
});
it('playerPropertiesChanged dispatches via GameDispatch', () => {
const props = { playerId: 2 } as any;
const props = create(ServerInfo_PlayerPropertiesSchema, { playerId: 2 });
GamePersistence.playerPropertiesChanged(5, 2, props);
expect(GameDispatch.playerPropertiesChanged).toHaveBeenCalledWith(5, 2, props);
});
@ -83,97 +105,97 @@ describe('GamePersistence', () => {
});
it('cardMoved dispatches via GameDispatch', () => {
const data = { cardId: 3 } as any;
const data = create(Event_MoveCardSchema, { cardId: 3 });
GamePersistence.cardMoved(5, 1, data);
expect(GameDispatch.cardMoved).toHaveBeenCalledWith(5, 1, data);
});
it('cardFlipped dispatches via GameDispatch', () => {
const data = { cardId: 3 } as any;
const data = create(Event_FlipCardSchema, { cardId: 3 });
GamePersistence.cardFlipped(5, 1, data);
expect(GameDispatch.cardFlipped).toHaveBeenCalledWith(5, 1, data);
});
it('cardDestroyed dispatches via GameDispatch', () => {
const data = { cardId: 3 } as any;
const data = create(Event_DestroyCardSchema, { cardId: 3 });
GamePersistence.cardDestroyed(5, 1, data);
expect(GameDispatch.cardDestroyed).toHaveBeenCalledWith(5, 1, data);
});
it('cardAttached dispatches via GameDispatch', () => {
const data = { cardId: 3 } as any;
const data = create(Event_AttachCardSchema, { cardId: 3 });
GamePersistence.cardAttached(5, 1, data);
expect(GameDispatch.cardAttached).toHaveBeenCalledWith(5, 1, data);
});
it('tokenCreated dispatches via GameDispatch', () => {
const data = { cardId: 3 } as any;
const data = create(Event_CreateTokenSchema, { cardId: 3 });
GamePersistence.tokenCreated(5, 1, data);
expect(GameDispatch.tokenCreated).toHaveBeenCalledWith(5, 1, data);
});
it('cardAttrChanged dispatches via GameDispatch', () => {
const data = { cardId: 3 } as any;
const data = create(Event_SetCardAttrSchema, { cardId: 3 });
GamePersistence.cardAttrChanged(5, 1, data);
expect(GameDispatch.cardAttrChanged).toHaveBeenCalledWith(5, 1, data);
});
it('cardCounterChanged dispatches via GameDispatch', () => {
const data = { cardId: 3 } as any;
const data = create(Event_SetCardCounterSchema, { cardId: 3 });
GamePersistence.cardCounterChanged(5, 1, data);
expect(GameDispatch.cardCounterChanged).toHaveBeenCalledWith(5, 1, data);
});
it('arrowCreated dispatches via GameDispatch', () => {
const data = { arrowInfo: {} } as any;
const data = create(Event_CreateArrowSchema, {});
GamePersistence.arrowCreated(5, 1, data);
expect(GameDispatch.arrowCreated).toHaveBeenCalledWith(5, 1, data);
});
it('arrowDeleted dispatches via GameDispatch', () => {
const data = { arrowId: 9 };
const data = create(Event_DeleteArrowSchema, { arrowId: 9 });
GamePersistence.arrowDeleted(5, 1, data);
expect(GameDispatch.arrowDeleted).toHaveBeenCalledWith(5, 1, data);
});
it('counterCreated dispatches via GameDispatch', () => {
const data = { counterInfo: {} } as any;
const data = create(Event_CreateCounterSchema, {});
GamePersistence.counterCreated(5, 1, data);
expect(GameDispatch.counterCreated).toHaveBeenCalledWith(5, 1, data);
});
it('counterSet dispatches via GameDispatch', () => {
const data = { counterId: 1, value: 20 };
const data = create(Event_SetCounterSchema, { counterId: 1, value: 20 });
GamePersistence.counterSet(5, 1, data);
expect(GameDispatch.counterSet).toHaveBeenCalledWith(5, 1, data);
});
it('counterDeleted dispatches via GameDispatch', () => {
const data = { counterId: 1 };
const data = create(Event_DelCounterSchema, { counterId: 1 });
GamePersistence.counterDeleted(5, 1, data);
expect(GameDispatch.counterDeleted).toHaveBeenCalledWith(5, 1, data);
});
it('cardsDrawn dispatches via GameDispatch', () => {
const data = { number: 2, cards: [] } as any;
const data = create(Event_DrawCardsSchema, { number: 2, cards: [] });
GamePersistence.cardsDrawn(5, 1, data);
expect(GameDispatch.cardsDrawn).toHaveBeenCalledWith(5, 1, data);
});
it('cardsRevealed dispatches via GameDispatch', () => {
const data = { zoneName: 'hand', cards: [] } as any;
const data = create(Event_RevealCardsSchema, { zoneName: 'hand', cards: [] });
GamePersistence.cardsRevealed(5, 1, data);
expect(GameDispatch.cardsRevealed).toHaveBeenCalledWith(5, 1, data);
});
it('zoneShuffled dispatches via GameDispatch', () => {
const data = { zoneName: 'deck' } as any;
const data = create(Event_ShuffleSchema, { zoneName: 'deck' });
GamePersistence.zoneShuffled(5, 1, data);
expect(GameDispatch.zoneShuffled).toHaveBeenCalledWith(5, 1, data);
});
it('dieRolled dispatches via GameDispatch', () => {
const data = { die: 6, result: 4 } as any;
const data = create(Event_RollDieSchema, { sides: 6, value: 4 });
GamePersistence.dieRolled(5, 1, data);
expect(GameDispatch.dieRolled).toHaveBeenCalledWith(5, 1, data);
});
@ -194,13 +216,13 @@ describe('GamePersistence', () => {
});
it('zoneDumped dispatches via GameDispatch', () => {
const data = { zoneName: 'hand' } as any;
const data = create(Event_DumpZoneSchema, { zoneName: 'hand' });
GamePersistence.zoneDumped(5, 1, data);
expect(GameDispatch.zoneDumped).toHaveBeenCalledWith(5, 1, data);
});
it('zonePropertiesChanged dispatches via GameDispatch', () => {
const data = { zoneName: 'hand', alwaysRevealTopCard: true } as any;
const data = create(Event_ChangeZonePropertiesSchema, { zoneName: 'hand', alwaysRevealTopCard: true });
GamePersistence.zonePropertiesChanged(5, 1, data);
expect(GameDispatch.zonePropertiesChanged).toHaveBeenCalledWith(5, 1, data);
});

View file

@ -13,20 +13,11 @@ vi.mock('store', () => ({
},
}));
vi.mock('../utils/NormalizeService', () => ({
__esModule: true,
default: {
normalizeLogs: vi.fn((logs: any) => ({ normalized: logs })),
},
}));
import { ModeratorPersistence } from './ModeratorPersistence';
import { ServerDispatch } from 'store';
import NormalizeService from '../utils/NormalizeService';
beforeEach(() => {
vi.clearAllMocks();
(NormalizeService.normalizeLogs as vi.Mock).mockImplementation((logs: any) => ({ normalized: logs }));
});
describe('ModeratorPersistence', () => {
@ -40,11 +31,10 @@ describe('ModeratorPersistence', () => {
expect(ServerDispatch.banHistory).toHaveBeenCalledWith('alice', []);
});
it('viewLogs normalizes logs and dispatches', () => {
it('viewLogs dispatches raw logs', () => {
const logs = [{ targetType: 'room' }] as any;
ModeratorPersistence.viewLogs(logs);
expect(NormalizeService.normalizeLogs).toHaveBeenCalledWith(logs);
expect(ServerDispatch.viewLogs).toHaveBeenCalledWith({ normalized: logs });
expect(ServerDispatch.viewLogs).toHaveBeenCalledWith(logs);
});
it('warnHistory passes userName and warnHistory', () => {

View file

@ -1,8 +1,6 @@
import { ServerDispatch } from 'store';
import { BanHistoryItem, LogItem, WarnHistoryItem, WarnListItem } from 'types';
import NormalizeService from '../utils/NormalizeService';
export class ModeratorPersistence {
static banFromServer(userName: string): void {
ServerDispatch.banFromServer(userName);
@ -13,7 +11,7 @@ export class ModeratorPersistence {
}
static viewLogs(logs: LogItem[]): void {
ServerDispatch.viewLogs(NormalizeService.normalizeLogs(logs));
ServerDispatch.viewLogs(logs);
}
static warnHistory(userName: string, warnHistory: WarnHistoryItem[]): void {

View file

@ -1,5 +1,4 @@
vi.mock('store', () => ({
store: { getState: vi.fn().mockReturnValue({}) },
RoomsDispatch: {
clearStore: vi.fn(),
joinRoom: vi.fn(),
@ -13,23 +12,10 @@ vi.mock('store', () => ({
gameCreated: vi.fn(),
joinedGame: vi.fn(),
},
RoomsSelectors: {
getRoom: vi.fn(),
},
}));
vi.mock('../utils/NormalizeService', () => ({
__esModule: true,
default: {
normalizeRoomInfo: vi.fn(),
normalizeGameObject: vi.fn(),
normalizeUserMessage: vi.fn(),
},
}));
import { RoomPersistence } from './RoomPersistence';
import { store, RoomsDispatch, RoomsSelectors } from 'store';
import NormalizeService from '../utils/NormalizeService';
import { RoomsDispatch } from 'store';
beforeEach(() => {
vi.clearAllMocks();
@ -41,10 +27,9 @@ describe('RoomPersistence', () => {
expect(RoomsDispatch.clearStore).toHaveBeenCalled();
});
it('joinRoom normalizes and dispatches', () => {
it('joinRoom dispatches raw roomInfo', () => {
const room = { roomId: 1 } as any;
RoomPersistence.joinRoom(room);
expect(NormalizeService.normalizeRoomInfo).toHaveBeenCalledWith(room);
expect(RoomsDispatch.joinRoom).toHaveBeenCalledWith(room);
});
@ -53,34 +38,19 @@ describe('RoomPersistence', () => {
expect(RoomsDispatch.leaveRoom).toHaveBeenCalledWith(5);
});
it('updateRooms -> RoomsDispatch.updateRooms', () => {
RoomPersistence.updateRooms([]);
expect(RoomsDispatch.updateRooms).toHaveBeenCalledWith([]);
it('updateRooms dispatches raw rooms', () => {
const rooms = [{ roomId: 1 }] as any;
RoomPersistence.updateRooms(rooms);
expect(RoomsDispatch.updateRooms).toHaveBeenCalledWith(rooms);
});
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 vi.Mock).mockReturnValue(room);
it('dispatches raw game list', () => {
const game = { gameTypes: [1] } as any;
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 vi.Mock).mockReturnValue(null);
RoomPersistence.updateGames(1, [game]);
expect(NormalizeService.normalizeGameObject).not.toHaveBeenCalled();
});
it('returns without error when gameList is empty', () => {
expect(() => RoomPersistence.updateGames(1, [])).not.toThrow();
expect(RoomsDispatch.updateGames).not.toHaveBeenCalled();
@ -92,10 +62,9 @@ describe('RoomPersistence', () => {
});
});
it('addMessage normalizes message and dispatches', () => {
it('addMessage dispatches without pre-normalizing', () => {
const msg = { name: 'alice', message: 'hi' } as any;
RoomPersistence.addMessage(1, msg);
expect(NormalizeService.normalizeUserMessage).toHaveBeenCalledWith(msg);
expect(RoomsDispatch.addMessage).toHaveBeenCalledWith(1, msg);
});

View file

@ -1,14 +1,14 @@
import { store, RoomsDispatch, RoomsSelectors } from 'store';
import { Game, Message, Room, User } from 'types';
import NormalizeService from '../utils/NormalizeService';
import { RoomsDispatch } from 'store';
import { Message, User } from 'types';
import type { ServerInfo_Room } from 'generated/proto/serverinfo_room_pb';
import type { ServerInfo_Game } from 'generated/proto/serverinfo_game_pb';
export class RoomPersistence {
static clearStore() {
RoomsDispatch.clearStore();
}
static joinRoom(roomInfo: Room) {
NormalizeService.normalizeRoomInfo(roomInfo);
static joinRoom(roomInfo: ServerInfo_Room) {
RoomsDispatch.joinRoom(roomInfo);
}
@ -16,32 +16,22 @@ export class RoomPersistence {
RoomsDispatch.leaveRoom(roomId);
}
static updateRooms(rooms: Room[]) {
static updateRooms(rooms: ServerInfo_Room[]) {
RoomsDispatch.updateRooms(rooms);
}
static updateGames(roomId: number, gameList: Game[]) {
static updateGames(roomId: number, gameList: ServerInfo_Game[]) {
// Guard: the server never sends an empty gameList to signal "clear all games".
// An empty array here means no game updates — skip the dispatch to avoid
// unnecessarily overwriting the existing game list with an empty one.
if (!gameList?.length) {
return;
}
const game = gameList[0];
if (!game.gameType) {
const room = RoomsSelectors.getRoom(store.getState(), roomId);
if (room) {
const { gametypeMap } = room;
NormalizeService.normalizeGameObject(game, gametypeMap);
}
}
RoomsDispatch.updateGames(roomId, gameList);
}
static addMessage(roomId: number, message: Message) {
NormalizeService.normalizeUserMessage(message);
RoomsDispatch.addMessage(roomId, message);
}

View file

@ -64,26 +64,15 @@ vi.mock('websocket/utils', () => ({
sanitizeHtml: vi.fn((msg: string) => `sanitized:${msg}`),
}));
vi.mock('../utils/NormalizeService', () => ({
__esModule: true,
default: {
normalizeBannedUserError: vi.fn((r: string, t: number) => `banned:${r}:${t}`),
normalizeGameObject: vi.fn(),
},
}));
import { SessionPersistence } from './SessionPersistence';
import { ServerDispatch, GameDispatch } from 'store';
import { sanitizeHtml } from 'websocket/utils';
import NormalizeService from '../utils/NormalizeService';
import { StatusEnum } from 'types';
import { Mock } from 'vitest';
beforeEach(() => {
vi.clearAllMocks();
(sanitizeHtml as vi.Mock).mockImplementation((msg: string) => `sanitized:${msg}`);
(NormalizeService.normalizeBannedUserError as vi.Mock).mockImplementation(
(r: string, t: number) => `banned:${r}:${t}`
);
(sanitizeHtml as Mock).mockImplementation((msg: string) => `sanitized:${msg}`);
});
describe('SessionPersistence', () => {
@ -230,15 +219,14 @@ describe('SessionPersistence', () => {
expect(ServerDispatch.registrationSuccess).toHaveBeenCalled();
});
it('registrationFailed normalizes ban error when endTime is given', () => {
it('registrationFailed passes reason and endTime to ServerDispatch', () => {
SessionPersistence.registrationFailed('reason', 999);
expect(NormalizeService.normalizeBannedUserError).toHaveBeenCalledWith('reason', 999);
expect(ServerDispatch.registrationFailed).toHaveBeenCalledWith('banned:reason:999');
expect(ServerDispatch.registrationFailed).toHaveBeenCalledWith('reason', 999);
});
it('registrationFailed uses reason directly when no endTime', () => {
it('registrationFailed passes reason only when no endTime', () => {
SessionPersistence.registrationFailed('plain reason');
expect(ServerDispatch.registrationFailed).toHaveBeenCalledWith('plain reason');
expect(ServerDispatch.registrationFailed).toHaveBeenCalledWith('plain reason', undefined);
});
it('registrationEmailError passes error', () => {
@ -298,18 +286,17 @@ describe('SessionPersistence', () => {
expect(ServerDispatch.getUserInfo).toHaveBeenCalledWith(user);
});
it('getGamesOfUser normalizes game list and dispatches gamesOfUser', () => {
it('getGamesOfUser builds gametypeMap and dispatches raw games with map', () => {
const gt = { gameTypeId: 1, description: 'Standard' };
const room = { gametypeList: [gt] };
const game = { gameId: 5, roomId: 1, gameTypes: [1], description: 'My Game', started: false };
SessionPersistence.getGamesOfUser('alice', { roomList: [room], gameList: [game] });
expect(NormalizeService.normalizeGameObject).toHaveBeenCalledWith(game, { 1: 'Standard' });
expect(ServerDispatch.gamesOfUser).toHaveBeenCalledWith('alice', [game]);
SessionPersistence.getGamesOfUser('alice', { roomList: [room], gameList: [game] } as any);
expect(ServerDispatch.gamesOfUser).toHaveBeenCalledWith('alice', [game], { 1: 'Standard' });
});
it('getGamesOfUser handles empty response', () => {
SessionPersistence.getGamesOfUser('alice', {});
expect(ServerDispatch.gamesOfUser).toHaveBeenCalledWith('alice', []);
SessionPersistence.getGamesOfUser('alice', {} as any);
expect(ServerDispatch.gamesOfUser).toHaveBeenCalledWith('alice', [], {});
});
it('gameJoined dispatches via GameDispatch.gameJoined', () => {
@ -328,8 +315,9 @@ describe('SessionPersistence', () => {
});
it('playerPropertiesChanged dispatches via GameDispatch', () => {
SessionPersistence.playerPropertiesChanged(5, 1, {} as any);
expect(GameDispatch.playerPropertiesChanged).toHaveBeenCalledWith(5, 1, {});
const props = { pingTime: 100 };
SessionPersistence.playerPropertiesChanged(5, 1, { playerProperties: props } as any);
expect(GameDispatch.playerPropertiesChanged).toHaveBeenCalledWith(5, 1, props);
});
it('serverShutdown passes data', () => {

View file

@ -1,7 +1,6 @@
import { GameDispatch, ServerDispatch } from 'store';
import { DeckList, DeckStorageTreeItem, ReplayMatch, StatusEnum, User, WebSocketConnectOptions } from 'types';
import { GameEntry } from 'store/game/game.interfaces';
import { sanitizeHtml } from 'websocket/utils';
import {
GameJoinedData,
@ -10,11 +9,10 @@ import {
ServerShutdownData,
UserMessageData
} from '../events/session/interfaces';
import NormalizeService from '../utils/NormalizeService';
import type { Response_GetGamesOfUser } from 'generated/proto/response_get_games_of_user_pb';
import type { ServerInfo_Room } from 'generated/proto/serverinfo_room_pb';
import type { ServerInfo_GameType } from 'generated/proto/serverinfo_gametype_pb';
import type { ServerInfo_Game } from 'generated/proto/serverinfo_game_pb';
export class SessionPersistence {
static initialized() {
@ -49,7 +47,7 @@ export class SessionPersistence {
ServerDispatch.testConnectionFailed();
}
static updateBuddyList(buddyList) {
static updateBuddyList(buddyList: User[]) {
ServerDispatch.updateBuddyList(buddyList);
}
@ -61,7 +59,7 @@ export class SessionPersistence {
ServerDispatch.removeFromBuddyList(userName);
}
static updateIgnoreList(ignoreList) {
static updateIgnoreList(ignoreList: User[]) {
ServerDispatch.updateIgnoreList(ignoreList);
}
@ -126,9 +124,7 @@ export class SessionPersistence {
}
static registrationFailed(reason: string, endTime?: number) {
const reasonMsg = endTime ? NormalizeService.normalizeBannedUserError(reason, endTime) : reason;
ServerDispatch.registrationFailed(reasonMsg);
ServerDispatch.registrationFailed(reason, endTime);
}
static registrationEmailError(error: string) {
@ -182,11 +178,8 @@ export class SessionPersistence {
gametypeMap[gt.gameTypeId] = gt.description;
});
});
const games = (response.gameList || []).map((game: ServerInfo_Game) => {
NormalizeService.normalizeGameObject(game, gametypeMap);
return game;
});
ServerDispatch.gamesOfUser(userName, games);
const games = response.gameList || [];
ServerDispatch.gamesOfUser(userName, games, gametypeMap);
}
static gameJoined(gameJoinedData: GameJoinedData): void {
@ -216,7 +209,9 @@ export class SessionPersistence {
}
static playerPropertiesChanged(gameId: number, playerId: number, payload: PlayerGamePropertiesData): void {
GameDispatch.playerPropertiesChanged(gameId, playerId, payload);
if (payload.playerProperties) {
GameDispatch.playerPropertiesChanged(gameId, playerId, payload.playerProperties);
}
}
static serverShutdown(data: ServerShutdownData): void {

View file

@ -93,11 +93,11 @@ describe('BackendService', () => {
expect(onSuccess).not.toHaveBeenCalled();
});
it('calls onSuccess with raw when responseCode is RespOk and no responseExt', () => {
it('calls onSuccess when responseCode is RespOk and no responseExt', () => {
const onSuccess = vi.fn();
const raw = { responseCode: 1 };
invokeCallback({ onSuccess }, raw);
expect(onSuccess).toHaveBeenCalledWith(raw, raw);
expect(onSuccess).toHaveBeenCalledWith();
});
it('calls onSuccess with nested response when responseExt is set', () => {

View file

@ -1,5 +1,5 @@
import { create, getExtension, setExtension } from '@bufbuild/protobuf';
import type { GenExtension } from '@bufbuild/protobuf';
import type { GenExtension } from '@bufbuild/protobuf/codegenv2';
import webClient from '../WebClient';
import { Response_ResponseCode, type Response } from 'generated/proto/response_pb';
@ -9,56 +9,80 @@ import { RoomCommandSchema, type RoomCommand } from 'generated/proto/room_comman
import { ModeratorCommandSchema, type ModeratorCommand } from 'generated/proto/moderator_commands_pb';
import { AdminCommandSchema, type AdminCommand } from 'generated/proto/admin_commands_pb';
export interface CommandOptions<R = unknown> {
responseExt?: GenExtension<Response, R>;
onSuccess?: (response: R, raw: Response) => void;
interface CommandOptionsBase {
onError?: (responseCode: number, raw: Response) => void;
onResponseCode?: { [code: number]: (raw: Response) => void };
onResponse?: (raw: Response) => void;
}
export interface CommandOptionsWithResponse<R> extends CommandOptionsBase {
responseExt: GenExtension<Response, R>;
onSuccess?: (response: R, raw: Response) => void;
}
export interface CommandOptionsWithoutResponse extends CommandOptionsBase {
responseExt?: undefined;
onSuccess?: () => void;
}
export type CommandOptions<R = unknown> = CommandOptionsWithResponse<R> | CommandOptionsWithoutResponse;
function hasResponseExt<R>(options: CommandOptions<R>): options is CommandOptionsWithResponse<R> {
return options.responseExt !== undefined;
}
export class BackendService {
static sendGameCommand<V>(gameId: number, ext: GenExtension<GameCommand, V>, value: V, options: CommandOptions<V> = {}): void {
static sendGameCommand<V, R>(gameId: number, ext: GenExtension<GameCommand, V>, value: V, options?: CommandOptions<R>): void {
const cmd = create(GameCommandSchema);
setExtension(cmd, ext, value);
webClient.protobuf.sendGameCommand(gameId, cmd, (raw: Response) => {
BackendService.handleResponse(ext, raw, options);
if (options) {
BackendService.handleResponse(ext.typeName, raw, options);
}
});
}
static sendSessionCommand<V>(ext: GenExtension<SessionCommand, V>, value: V, options: CommandOptions<V> = {}): void {
static sendSessionCommand<V, R>(ext: GenExtension<SessionCommand, V>, value: V, options?: CommandOptions<R>): void {
const cmd = create(SessionCommandSchema);
setExtension(cmd, ext, value);
webClient.protobuf.sendSessionCommand(cmd, raw => {
BackendService.handleResponse(ext, raw, options);
if (options) {
BackendService.handleResponse(ext.typeName, raw, options);
}
});
}
static sendRoomCommand<V>(roomId: number, ext: GenExtension<RoomCommand, V>, value: V, options: CommandOptions<V> = {}): void {
static sendRoomCommand<V, R>(roomId: number, ext: GenExtension<RoomCommand, V>, value: V, options?: CommandOptions<R>): void {
const cmd = create(RoomCommandSchema);
setExtension(cmd, ext, value);
webClient.protobuf.sendRoomCommand(roomId, cmd, raw => {
BackendService.handleResponse(ext, raw, options);
if (options) {
BackendService.handleResponse(ext.typeName, raw, options);
}
});
}
static sendModeratorCommand<V>(ext: GenExtension<ModeratorCommand, V>, value: V, options: CommandOptions<V> = {}): void {
static sendModeratorCommand<V, R>(ext: GenExtension<ModeratorCommand, V>, value: V, options?: CommandOptions<R>): void {
const cmd = create(ModeratorCommandSchema);
setExtension(cmd, ext, value);
webClient.protobuf.sendModeratorCommand(cmd, raw => {
BackendService.handleResponse(ext, raw, options);
if (options) {
BackendService.handleResponse(ext.typeName, raw, options);
}
});
}
static sendAdminCommand<V>(ext: GenExtension<AdminCommand, V>, value: V, options: CommandOptions<V> = {}): void {
static sendAdminCommand<V, R>(ext: GenExtension<AdminCommand, V>, value: V, options?: CommandOptions<R>): void {
const cmd = create(AdminCommandSchema);
setExtension(cmd, ext, value);
webClient.protobuf.sendAdminCommand(cmd, raw => {
BackendService.handleResponse(ext, raw, options);
if (options) {
BackendService.handleResponse(ext.typeName, raw, options);
}
});
}
private static handleResponse<R>(ext: GenExtension<any, R>, raw: Response, options: CommandOptions<R>): void {
private static handleResponse<R>(typeName: string, raw: Response, options: CommandOptions<R>): void {
if (options.onResponse) {
options.onResponse(raw);
return;
@ -67,11 +91,10 @@ export class BackendService {
const { responseCode } = raw;
if (responseCode === Response_ResponseCode.RespOk) {
if (options.onSuccess) {
const response = options.responseExt
? getExtension(raw, options.responseExt)
: raw as unknown as R;
options.onSuccess(response, raw);
if (hasResponseExt(options)) {
options.onSuccess?.(getExtension(raw, options.responseExt), raw);
} else {
options.onSuccess?.();
}
return;
}
@ -84,7 +107,7 @@ export class BackendService {
if (options.onError) {
options.onError(responseCode, raw);
} else {
console.error(`${ext.typeName} failed with response code: ${responseCode}`);
console.error(`${typeName} failed with response code: ${responseCode}`);
}
}
}

View file

@ -1,3 +1,12 @@
vi.mock('../WebClient', () => ({
__esModule: true,
default: {
socket: {
checkReadyState: vi.fn(),
},
},
}));
import { KeepAliveService } from './KeepAliveService';
import webClient from '../WebClient';

View file

@ -14,7 +14,7 @@ export class KeepAliveService {
this.socket = socket;
}
public startPingLoop(interval: number, ping: Function): void {
public startPingLoop(interval: number, ping: (onPong: () => void) => void): void {
this.keepalivecb = setInterval(() => {
// check if the previous ping got no reply
if (this.lastPingPending) {

View file

@ -36,7 +36,7 @@ vi.mock('../WebClient', () => ({
default: {},
}));
import { fromBinary, toBinary, hasExtension, getExtension } from '@bufbuild/protobuf';
import { fromBinary, hasExtension, getExtension } from '@bufbuild/protobuf';
import { ServerMessage_MessageType } from 'generated/proto/server_message_pb';
import { ProtobufService } from './ProtobufService';
import { ping as sessionPing } from '../commands/session';
@ -351,36 +351,4 @@ describe('ProtobufService', () => {
});
});
describe('processEvent', () => {
it('calls matching event handler with payload and raw', () => {
const service = new ProtobufService({ socket: mockSocket } as any);
const handler = vi.fn();
const mockExt = {};
const registry = [[mockExt, handler]] as any;
const payload = { someData: 1 };
const raw = { extra: true };
vi.mocked(hasExtension).mockReturnValue(true);
vi.mocked(getExtension).mockReturnValue(payload);
(service as any).processEvent({}, registry, raw);
expect(handler).toHaveBeenCalledWith(payload, raw);
});
it('stops after first matching event', () => {
const service = new ProtobufService({ socket: mockSocket } as any);
const handler1 = vi.fn();
const handler2 = vi.fn();
const registry = [[{}, handler1], [{}, handler2]] as any;
vi.mocked(hasExtension).mockReturnValueOnce(true).mockReturnValueOnce(false);
vi.mocked(getExtension).mockReturnValue({ x: 1 });
(service as any).processEvent({}, registry, {});
expect(handler1).toHaveBeenCalled();
expect(handler2).not.toHaveBeenCalled();
});
});
});

View file

@ -1,10 +1,11 @@
import { create, fromBinary, hasExtension, getExtension, toBinary } from '@bufbuild/protobuf';
import type { GenExtension, Message } from '@bufbuild/protobuf';
import type { GenExtension } from '@bufbuild/protobuf/codegenv2';
import type { Response } from 'generated/proto/response_pb';
import type { RoomEvent } from 'generated/proto/room_event_pb';
import type { SessionEvent } from 'generated/proto/session_event_pb';
import type { GameEventContainer } from 'generated/proto/game_event_container_pb';
import type { GameEvent } from 'generated/proto/game_event_pb';
import { GameEvents, RoomEvents, SessionEvents } from '../events';
import { WebClient } from '../WebClient';
@ -19,11 +20,57 @@ import type { RoomCommand } from 'generated/proto/room_commands_pb';
import type { ModeratorCommand } from 'generated/proto/moderator_commands_pb';
import type { AdminCommand } from 'generated/proto/admin_commands_pb';
export type ExtensionRegistry = Array<[GenExtension<any, any>, (...args: unknown[]) => void]>;
// Per-family registry entry types. Each family hardcodes its parent message type
// and the handler's exact secondary-argument signature, eliminating the previous
// `...args: unknown[]` erasure.
type SessionRegistryEntry<V = unknown> = [
GenExtension<SessionEvent, V>,
(value: V) => void
];
export type SessionExtensionRegistry = SessionRegistryEntry[];
type RoomRegistryEntry<V = unknown> = [
GenExtension<RoomEvent, V>,
(value: V, roomEvent: RoomEvent) => void
];
export type RoomExtensionRegistry = RoomRegistryEntry[];
type GameRegistryEntry<V = unknown> = [
GenExtension<GameEvent, V>,
(value: V, meta: GameEventMeta) => void
];
export type GameExtensionRegistry = GameRegistryEntry[];
/**
* Type-safe factory functions. The compiler verifies at the call site that the
* handler's parameter types match the extension's value type and the family's
* secondary argument type.
*/
export function makeSessionEntry<V>(
ext: GenExtension<SessionEvent, V>,
handler: (value: V) => void
): SessionRegistryEntry {
return [ext as GenExtension<SessionEvent, unknown>, handler as (value: unknown) => void];
}
export function makeRoomEntry<V>(
ext: GenExtension<RoomEvent, V>,
handler: (value: V, roomEvent: RoomEvent) => void
): RoomRegistryEntry {
return [ext as GenExtension<RoomEvent, unknown>, handler as RoomRegistryEntry[1]];
}
export function makeGameEntry<V>(
ext: GenExtension<GameEvent, V>,
handler: (value: V, meta: GameEventMeta) => void
): GameRegistryEntry {
return [ext as GenExtension<GameEvent, unknown>, handler as GameRegistryEntry[1]];
}
export class ProtobufService {
private cmdId = 0;
private pendingCommands: { [cmdId: string]: Function } = {};
private pendingCommands: { [cmdId: string]: (response: Response) => void } = {};
private webClient: WebClient;
@ -36,44 +83,44 @@ export class ProtobufService {
this.pendingCommands = {};
}
public sendGameCommand(gameId: number, gameCmd: GameCommand, callback?: Function) {
public sendGameCommand(gameId: number, gameCmd: GameCommand, callback?: (raw: Response) => void) {
const cmd = create(CommandContainerSchema, {
gameId,
gameCommand: [gameCmd],
});
this.sendCommand(cmd, (raw: Response) => callback && callback(raw));
this.sendCommand(cmd, (raw: Response) => callback?.(raw));
}
public sendRoomCommand(roomId: number, roomCmd: RoomCommand, callback?: Function) {
public sendRoomCommand(roomId: number, roomCmd: RoomCommand, callback?: (raw: Response) => void) {
const cmd = create(CommandContainerSchema, {
roomId,
roomCommand: [roomCmd],
});
this.sendCommand(cmd, raw => callback && callback(raw));
this.sendCommand(cmd, raw => callback?.(raw));
}
public sendSessionCommand(sesCmd: SessionCommand, callback?: Function) {
public sendSessionCommand(sesCmd: SessionCommand, callback?: (raw: Response) => void) {
const cmd = create(CommandContainerSchema, {
sessionCommand: [sesCmd],
});
this.sendCommand(cmd, (raw) => callback && callback(raw));
this.sendCommand(cmd, (raw) => callback?.(raw));
}
public sendModeratorCommand(modCmd: ModeratorCommand, callback?: Function) {
public sendModeratorCommand(modCmd: ModeratorCommand, callback?: (raw: Response) => void) {
const cmd = create(CommandContainerSchema, {
moderatorCommand: [modCmd],
});
this.sendCommand(cmd, (raw) => callback && callback(raw));
this.sendCommand(cmd, (raw) => callback?.(raw));
}
public sendAdminCommand(adminCmd: AdminCommand, callback?: Function) {
public sendAdminCommand(adminCmd: AdminCommand, callback?: (raw: Response) => void) {
const cmd = create(CommandContainerSchema, {
adminCommand: [adminCmd],
});
this.sendCommand(cmd, (raw) => callback && callback(raw));
this.sendCommand(cmd, (raw) => callback?.(raw));
}
public sendCommand(cmd: CommandContainer, callback: Function) {
public sendCommand(cmd: CommandContainer, callback: (raw: Response) => void) {
this.cmdId++;
cmd.cmdId = BigInt(this.cmdId);
@ -84,7 +131,7 @@ export class ProtobufService {
}
}
public sendKeepAliveCommand(pingReceived: Function) {
public sendKeepAliveCommand(pingReceived: () => void) {
SessionCommands.ping(pingReceived);
}
@ -133,14 +180,24 @@ export class ProtobufService {
if (!event) {
return;
}
this.processEvent(event, RoomEvents, event);
for (const [ext, handler] of RoomEvents) {
if (hasExtension(event, ext)) {
handler(getExtension(event, ext), event);
return;
}
}
}
private processSessionEvent(event: SessionEvent | undefined) {
if (!event) {
return;
}
this.processEvent(event, SessionEvents);
for (const [ext, handler] of SessionEvents) {
if (hasExtension(event, ext)) {
handler(getExtension(event, ext));
return;
}
}
}
private processGameEvent(container: GameEventContainer | undefined): void {
@ -161,20 +218,12 @@ export class ProtobufService {
for (const [ext, handler] of GameEvents) {
if (hasExtension(event, ext)) {
(handler as Function)(getExtension(event, ext), meta);
handler(getExtension(event, ext), meta);
break;
}
}
}
}
private processEvent(response: Message<string>, registry: ExtensionRegistry, raw?: Message) {
for (const [ext, handler] of registry) {
if (hasExtension(response, ext)) {
(handler as Function)(getExtension(response, ext), raw);
return;
}
}
}
}

View file

@ -1,4 +1,9 @@
import { installMockWebSocket } from '../__mocks__/helpers';
import { Mock } from 'vitest';
vi.mock('../WebClient', () => ({
WebClient: vi.fn(),
}));
vi.mock('../commands/session', () => ({
updateStatus: vi.fn(),
@ -17,8 +22,9 @@ import { SessionPersistence } from '../persistence';
import { updateStatus } from '../commands/session';
import { StatusEnum } from 'types';
let MockWS: vi.Mock;
let MockWS: Mock;
let mockInstance: ReturnType<typeof installMockWebSocket>['mockInstance'];
let restoreWebSocket: ReturnType<typeof installMockWebSocket>['restore'];
let mockWebClient: any;
beforeEach(() => {
@ -28,6 +34,7 @@ beforeEach(() => {
const installed = installMockWebSocket();
MockWS = installed.MockWS;
mockInstance = installed.mockInstance;
restoreWebSocket = installed.restore;
mockWebClient = {
status: StatusEnum.CONNECTED,
@ -37,6 +44,7 @@ beforeEach(() => {
});
afterEach(() => {
restoreWebSocket();
vi.useRealTimers();
});
@ -106,7 +114,7 @@ describe('WebSocketService', () => {
describe('socket event handlers (onopen)', () => {
it('clears the connection timeout when socket opens', () => {
const clearSpy = vi.spyOn(global, 'clearTimeout');
const clearSpy = vi.spyOn(globalThis, 'clearTimeout');
createConnectedService();
mockInstance.onopen();
expect(clearSpy).toHaveBeenCalled();
@ -262,7 +270,7 @@ describe('WebSocketService', () => {
it('calls SessionPersistence.testConnectionSuccessful on open', () => {
createTestConnectedService();
const timer = vi.spyOn(global, 'clearTimeout');
vi.spyOn(globalThis, 'clearTimeout');
mockInstance.onopen();
expect(SessionPersistence.testConnectionSuccessful).toHaveBeenCalled();
expect(mockInstance.close).toHaveBeenCalled();

View file

@ -59,7 +59,7 @@ export class WebSocketService {
return this.socket?.readyState === state;
}
public send(message): void {
public send(message: Uint8Array): void {
this.socket.send(message);
}
@ -73,7 +73,7 @@ export class WebSocketService {
clearTimeout(connectionTimer);
updateStatus(StatusEnum.CONNECTED, 'Connected');
this.keepAliveService.startPingLoop(this.keepalive, (pingReceived: Function) => {
this.keepAliveService.startPingLoop(this.keepalive, (pingReceived: () => void) => {
this.webClient.keepAlive(pingReceived);
});
};

View file

@ -1,110 +0,0 @@
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');
});
});
});

View file

@ -1,63 +0,0 @@
import { Game, GametypeMap, LogItem, LogGroups, Message, Room } from 'types';
export default class NormalizeService {
// Flatten room gameTypes into map object
static normalizeRoomInfo(roomInfo: Room): void {
roomInfo.gametypeMap = {};
const { gametypeList, gametypeMap, gameList } = roomInfo;
gametypeList.reduce((map, type) => {
map[type.gameTypeId] = type.description;
return map;
}, gametypeMap);
gameList.forEach((game) => NormalizeService.normalizeGameObject(game, gametypeMap));
}
// Flatten gameTypes[] into gameType field
// Default sortable values ("" || 0 || -1)
static normalizeGameObject(game: Game, gametypeMap: GametypeMap): void {
const { gameTypes, description } = game;
const hasType = gameTypes && gameTypes.length;
game.gameType = hasType ? gametypeMap[gameTypes[0]] : '';
game.description = description || '';
}
// Flatten logs[] into object mapped by targetType (room, game, chat)
static normalizeLogs(logs: LogItem[]): LogGroups {
return logs.reduce((obj, log) => {
const { targetType } = log;
obj[targetType] = obj[targetType] || [];
obj[targetType].push(log);
return obj;
}, {} as LogGroups);
}
// messages sent by current user dont have their username prepended
static normalizeUserMessage(message: Message): void {
const { name } = message;
if (name) {
message.message = `${name}: ${message.message}`;
}
}
// Banned reason string is not being exposed by the server
static normalizeBannedUserError(reasonStr: string, endTime: number): string {
let error;
if (endTime) {
error = 'You are banned until ' + new Date(endTime).toString();
} else {
error = 'You are permanently banned';
}
if (reasonStr) {
error += '\n\n' + reasonStr;
}
return error;
}
}