This commit is contained in:
seavor 2026-04-18 01:36:37 -05:00
parent d04aa83258
commit dcd6dc00f4
83 changed files with 1797 additions and 390 deletions

View file

@ -18,7 +18,7 @@ import { Mock } from 'vitest';
import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers';
import { WebClient } from '../../WebClient';
import * as SessionIndexMocks from './';
import { App, Enriched } from '@app/types';
import { Enriched } from '@app/types';
import { StatusEnum } from '../../interfaces/StatusEnum';
import {
Command_Activate_ext,
@ -59,7 +59,7 @@ const baseTransport = { host: 'h', port: '1' };
const makeLoginOpts = (overrides: Partial<Enriched.LoginConnectOptions> = {}): Enriched.LoginConnectOptions => ({
...baseTransport,
userName: 'alice',
reason: App.WebSocketConnectReason.LOGIN,
reason: Enriched.WebSocketConnectReason.LOGIN,
...overrides,
});
const makeRegisterOpts = (
@ -71,7 +71,7 @@ const makeRegisterOpts = (
email: 'a@b.com',
country: 'US',
realName: 'Al',
reason: App.WebSocketConnectReason.REGISTER,
reason: Enriched.WebSocketConnectReason.REGISTER,
...overrides,
});
const makeActivateOpts = (
@ -80,26 +80,26 @@ const makeActivateOpts = (
...baseTransport,
userName: 'alice',
token: 'tok',
reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT,
reason: Enriched.WebSocketConnectReason.ACTIVATE_ACCOUNT,
...overrides,
});
const makeForgotRequestOpts = (): Enriched.PasswordResetRequestConnectOptions => ({
...baseTransport,
userName: 'alice',
reason: App.WebSocketConnectReason.PASSWORD_RESET_REQUEST,
reason: Enriched.WebSocketConnectReason.PASSWORD_RESET_REQUEST,
});
const makeForgotChallengeOpts = (): Enriched.PasswordResetChallengeConnectOptions => ({
...baseTransport,
userName: 'alice',
email: 'a@b.com',
reason: App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE,
reason: Enriched.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE,
});
const makeForgotResetOpts = (): Enriched.PasswordResetConnectOptions => ({
...baseTransport,
userName: 'alice',
token: 'tok',
newPassword: 'newpw',
reason: App.WebSocketConnectReason.PASSWORD_RESET,
reason: Enriched.WebSocketConnectReason.PASSWORD_RESET,
});
@ -194,7 +194,7 @@ describe('login', () => {
});
it('onSuccess passes hashedPassword to loginSuccessful when salt is used', () => {
login({ host: 'h', port: '1', userName: 'alice', reason: App.WebSocketConnectReason.LOGIN }, 'pw', 'salt');
login({ host: 'h', port: '1', userName: 'alice', reason: Enriched.WebSocketConnectReason.LOGIN }, 'pw', 'salt');
const loginResp = { buddyList: [], ignoreList: [], userInfo: { name: 'alice' } };
invokeOnSuccess(loginResp, { responseCode: 0 });
const calledWith = (WebClient.instance.response.session.loginSuccessful as Mock).mock.calls[0][0];

View file

@ -115,10 +115,10 @@ describe('playerPropertiesChanged event', () => {
});
describe('gameSay event', () => {
it('delegates to WebClient.instance.response.game.gameSay with gameId, playerId, message', () => {
it('delegates to WebClient.instance.response.game.gameSay with gameId, playerId, message, timeReceived', () => {
const data = create(Event_GameSaySchema, { message: 'gg' });
gameSay(data, meta);
expect(WebClient.instance.response.game.gameSay).toHaveBeenCalledWith(5, 2, 'gg');
expect(WebClient.instance.response.game.gameSay).toHaveBeenCalledWith(5, 2, 'gg', expect.any(Number));
});
});

View file

@ -3,5 +3,5 @@ import type { GameEventMeta } from '../../interfaces/WebSocketConfig';
import { WebClient } from '../../WebClient';
export function gameSay(data: Event_GameSay, meta: GameEventMeta): void {
WebClient.instance.response.game.gameSay(meta.gameId, meta.playerId, data.message);
WebClient.instance.response.game.gameSay(meta.gameId, meta.playerId, data.message, Date.now());
}

View file

@ -134,7 +134,7 @@ export interface IGameResponse {
gameClosed(gameId: number): void;
gameHostChanged(gameId: number, hostId: number): void;
kicked(gameId: number): void;
gameSay(gameId: number, playerId: number, message: string): void;
gameSay(gameId: number, playerId: number, message: string, timeReceived: number): void;
cardMoved(gameId: number, playerId: number, data: Event_MoveCard): void;
cardFlipped(gameId: number, playerId: number, data: Event_FlipCard): void;
cardDestroyed(gameId: number, playerId: number, data: Event_DestroyCard): void;

View file

@ -64,5 +64,14 @@ describe('KeepAliveService', () => {
expect(service.endPingLoop).toHaveBeenCalled();
});
it('should clear previous interval when startPingLoop is called again', () => {
const clearSpy = vi.spyOn(globalThis, 'clearInterval');
const previousCb = (service as KeepAliveInternal).keepalivecb;
service.startPingLoop(interval, ping);
expect(clearSpy).toHaveBeenCalledWith(previousCb);
});
});
});

View file

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

View file

@ -112,6 +112,67 @@ describe('ProtobufService', () => {
expect((service as ProtobufInternal).cmdId).toBe(0);
expect((service as ProtobufInternal).pendingCommands.size).toBe(0);
});
it('returns true when command is sent', () => {
const service = new ProtobufService(mockSocket);
const result = service.sendCommand(create(CommandContainerSchema), vi.fn());
expect(result).toBe(true);
});
it('returns false when transport is closed', () => {
const service = new ProtobufService(mockSocket);
mockSocket.isOpen.mockReturnValue(false);
const result = service.sendCommand(create(CommandContainerSchema), vi.fn());
expect(result).toBe(false);
});
});
describe('send*Command when transport is closed', () => {
it('calls onError when sendSessionCommand is dropped', () => {
const service = new ProtobufService(mockSocket);
mockSocket.isOpen.mockReturnValue(false);
const onError = vi.fn();
service.sendSessionCommand(sessionExt, {}, { onError });
expect(onError).toHaveBeenCalledWith(-1, expect.any(Object));
});
it('calls onError when sendRoomCommand is dropped', () => {
const service = new ProtobufService(mockSocket);
mockSocket.isOpen.mockReturnValue(false);
const onError = vi.fn();
service.sendRoomCommand(42, roomExt, {}, { onError });
expect(onError).toHaveBeenCalledWith(-1, expect.any(Object));
});
it('calls onError when sendGameCommand is dropped', () => {
const service = new ProtobufService(mockSocket);
mockSocket.isOpen.mockReturnValue(false);
const onError = vi.fn();
service.sendGameCommand(7, gameExt, {}, { onError });
expect(onError).toHaveBeenCalledWith(-1, expect.any(Object));
});
it('calls onError when sendModeratorCommand is dropped', () => {
const service = new ProtobufService(mockSocket);
mockSocket.isOpen.mockReturnValue(false);
const onError = vi.fn();
service.sendModeratorCommand(moderatorExt, {}, { onError });
expect(onError).toHaveBeenCalledWith(-1, expect.any(Object));
});
it('calls onError when sendAdminCommand is dropped', () => {
const service = new ProtobufService(mockSocket);
mockSocket.isOpen.mockReturnValue(false);
const onError = vi.fn();
service.sendAdminCommand(adminExt, {}, { onError });
expect(onError).toHaveBeenCalledWith(-1, expect.any(Object));
});
it('does not throw when command is dropped with no options', () => {
const service = new ProtobufService(mockSocket);
mockSocket.isOpen.mockReturnValue(false);
expect(() => service.sendSessionCommand(sessionExt, {})).not.toThrow();
});
});
describe('sendSessionCommand', () => {
@ -311,9 +372,9 @@ describe('ProtobufService', () => {
expect(processGameEvent).toHaveBeenCalled();
});
it('logs unknown message types (default case)', () => {
it('warns on unknown message types (default case)', () => {
const service = new ProtobufService(mockSocket);
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.mocked(fromBinary).mockReturnValue(
create(ServerMessageSchema, {

View file

@ -54,11 +54,7 @@ export class ProtobufService {
const gameCmd = create(GameCommandSchema);
setExtension(gameCmd, ext, value);
const cmd = create(CommandContainerSchema, { gameId, gameCommand: [gameCmd] });
this.sendCommand(cmd, raw => {
if (options) {
handleResponse(ext.typeName, raw, options);
}
});
this.dispatchCommand(ext.typeName, cmd, options);
}
public sendRoomCommand<V, R = unknown>(
@ -70,11 +66,7 @@ export class ProtobufService {
const roomCmd = create(RoomCommandSchema);
setExtension(roomCmd, ext, value);
const cmd = create(CommandContainerSchema, { roomId, roomCommand: [roomCmd] });
this.sendCommand(cmd, raw => {
if (options) {
handleResponse(ext.typeName, raw, options);
}
});
this.dispatchCommand(ext.typeName, cmd, options);
}
public sendSessionCommand<V, R = unknown>(
@ -85,11 +77,7 @@ export class ProtobufService {
const sesCmd = create(SessionCommandSchema);
setExtension(sesCmd, ext, value);
const cmd = create(CommandContainerSchema, { sessionCommand: [sesCmd] });
this.sendCommand(cmd, raw => {
if (options) {
handleResponse(ext.typeName, raw, options);
}
});
this.dispatchCommand(ext.typeName, cmd, options);
}
public sendModeratorCommand<V, R = unknown>(
@ -100,11 +88,7 @@ export class ProtobufService {
const modCmd = create(ModeratorCommandSchema);
setExtension(modCmd, ext, value);
const cmd = create(CommandContainerSchema, { moderatorCommand: [modCmd] });
this.sendCommand(cmd, raw => {
if (options) {
handleResponse(ext.typeName, raw, options);
}
});
this.dispatchCommand(ext.typeName, cmd, options);
}
public sendAdminCommand<V, R = unknown>(
@ -115,22 +99,31 @@ export class ProtobufService {
const adminCmd = create(AdminCommandSchema);
setExtension(adminCmd, ext, value);
const cmd = create(CommandContainerSchema, { adminCommand: [adminCmd] });
this.sendCommand(cmd, raw => {
if (options) {
handleResponse(ext.typeName, raw, options);
}
});
this.dispatchCommand(ext.typeName, cmd, options);
}
public sendCommand(cmd: CommandContainer, callback: (raw: Response) => void) {
private dispatchCommand<R>(typeName: string, cmd: CommandContainer, options?: CommandOptions<R>): void {
const sent = this.sendCommand(cmd, raw => {
if (options) {
handleResponse(typeName, raw, options);
}
});
if (!sent) {
options?.onError?.(-1, {} as Response);
}
}
public sendCommand(cmd: CommandContainer, callback: (raw: Response) => void): boolean {
if (!this.transport.isOpen()) {
return;
return false;
}
this.cmdId++;
cmd.cmdId = BigInt(this.cmdId);
this.pendingCommands.set(this.cmdId, callback);
this.transport.send(toBinary(CommandContainerSchema, cmd));
return true;
}
public handleMessageEvent({ data }: MessageEvent): void {
@ -153,7 +146,7 @@ export class ProtobufService {
this.processGameEvent(msg.gameEventContainer);
break;
default:
console.log(msg);
console.warn('Unknown message type:', msg);
break;
}
}

View file

@ -198,6 +198,12 @@ describe('WebSocketService', () => {
service.send(data);
expect(mockInstance.send).toHaveBeenCalledWith(data);
});
it('does not throw when socket is undefined (before connect)', () => {
const service = new WebSocketService(mockConfig);
const data = new Uint8Array([1, 2, 3]);
expect(() => service.send(data)).not.toThrow();
});
});
describe('checkReadyState', () => {

View file

@ -54,6 +54,9 @@ export class WebSocketService {
}
public send(message: Uint8Array): void {
if (!this.socket) {
return;
}
this.socket.send(message as unknown as ArrayBufferView);
}