mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-07-04 04:23:55 -07:00
integration tests
This commit is contained in:
parent
0ff391491d
commit
4f68190a25
13 changed files with 1149 additions and 0 deletions
136
webclient/integration/src/authentication.spec.ts
Normal file
136
webclient/integration/src/authentication.spec.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
// Authentication scenarios — login success/failure, register, and activate.
|
||||||
|
|
||||||
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { App, Data } from '@app/types';
|
||||||
|
import { store } from '@app/store';
|
||||||
|
|
||||||
|
import { connectAndHandshake } from './helpers/setup';
|
||||||
|
import {
|
||||||
|
buildResponse,
|
||||||
|
buildResponseMessage,
|
||||||
|
deliverMessage,
|
||||||
|
} from './helpers/protobuf-builders';
|
||||||
|
import { findLastSessionCommand } from './helpers/command-capture';
|
||||||
|
|
||||||
|
function makeUser(name: string): Data.ServerInfo_User {
|
||||||
|
return create(Data.ServerInfo_UserSchema, {
|
||||||
|
name,
|
||||||
|
userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('authentication', () => {
|
||||||
|
describe('login', () => {
|
||||||
|
it('drives LOGIN → LOGGED_IN and populates user info + buddy/ignore lists', () => {
|
||||||
|
connectAndHandshake({ userName: 'alice' });
|
||||||
|
|
||||||
|
const { cmdId, value } = findLastSessionCommand(Data.Command_Login_ext);
|
||||||
|
expect(value.userName).toBe('alice');
|
||||||
|
|
||||||
|
const loginPayload = create(Data.Response_LoginSchema, {
|
||||||
|
userInfo: makeUser('alice'),
|
||||||
|
buddyList: [makeUser('bob')],
|
||||||
|
ignoreList: [makeUser('mallory')],
|
||||||
|
});
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
ext: Data.Response_Login_ext,
|
||||||
|
value: loginPayload,
|
||||||
|
})));
|
||||||
|
|
||||||
|
const state = store.getState().server;
|
||||||
|
expect(state.status.state).toBe(App.StatusEnum.LOGGED_IN);
|
||||||
|
expect(state.status.description).toBe('Logged in.');
|
||||||
|
expect(state.user?.name).toBe('alice');
|
||||||
|
expect(Object.keys(state.buddyList)).toEqual(['bob']);
|
||||||
|
expect(Object.keys(state.ignoreList)).toEqual(['mallory']);
|
||||||
|
|
||||||
|
expect(() => findLastSessionCommand(Data.Command_ListUsers_ext)).not.toThrow();
|
||||||
|
expect(() => findLastSessionCommand(Data.Command_ListRooms_ext)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flips status to DISCONNECTED on RespWrongPassword', () => {
|
||||||
|
connectAndHandshake();
|
||||||
|
|
||||||
|
const { cmdId } = findLastSessionCommand(Data.Command_Login_ext);
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespWrongPassword,
|
||||||
|
})));
|
||||||
|
|
||||||
|
const state = store.getState().server;
|
||||||
|
expect(state.status.state).toBe(App.StatusEnum.DISCONNECTED);
|
||||||
|
expect(state.user).toBeNull();
|
||||||
|
expect(state.buddyList).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('register', () => {
|
||||||
|
const registerOptions = {
|
||||||
|
reason: App.WebSocketConnectReason.REGISTER,
|
||||||
|
host: 'localhost',
|
||||||
|
port: '4748',
|
||||||
|
userName: 'newbie',
|
||||||
|
password: 'hunter2',
|
||||||
|
email: 'newbie@example.com',
|
||||||
|
country: 'US',
|
||||||
|
realName: 'New Bie',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
it('auto-logs-in on RespRegistrationAccepted', () => {
|
||||||
|
connectAndHandshake(registerOptions as any);
|
||||||
|
|
||||||
|
const register = findLastSessionCommand(Data.Command_Register_ext);
|
||||||
|
expect(register.value.userName).toBe('newbie');
|
||||||
|
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: register.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespRegistrationAccepted,
|
||||||
|
})));
|
||||||
|
|
||||||
|
const login = findLastSessionCommand(Data.Command_Login_ext);
|
||||||
|
expect(login.value.userName).toBe('newbie');
|
||||||
|
expect(login.cmdId).toBeGreaterThan(register.cmdId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parks registration in awaiting-activation on RespRegistrationAcceptedNeedsActivation', () => {
|
||||||
|
connectAndHandshake(registerOptions as any);
|
||||||
|
|
||||||
|
const register = findLastSessionCommand(Data.Command_Register_ext);
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: register.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespRegistrationAcceptedNeedsActivation,
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
|
||||||
|
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('activate', () => {
|
||||||
|
it('auto-logs-in on RespActivationAccepted', () => {
|
||||||
|
connectAndHandshake({
|
||||||
|
reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT,
|
||||||
|
host: 'localhost',
|
||||||
|
port: '4748',
|
||||||
|
userName: 'alice',
|
||||||
|
token: 'abc-123',
|
||||||
|
password: 'secret',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const activate = findLastSessionCommand(Data.Command_Activate_ext);
|
||||||
|
expect(activate.value.userName).toBe('alice');
|
||||||
|
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: activate.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespActivationAccepted,
|
||||||
|
})));
|
||||||
|
|
||||||
|
const login = findLastSessionCommand(Data.Command_Login_ext);
|
||||||
|
expect(login.value.userName).toBe('alice');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
108
webclient/integration/src/connection.spec.ts
Normal file
108
webclient/integration/src/connection.spec.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
// Connection-lifecycle scenarios. Exercises the full transport handshake
|
||||||
|
// from `webClient.connect()` through `onopen`, ServerIdentification, and
|
||||||
|
// disconnect — with only the browser WebSocket constructor mocked.
|
||||||
|
|
||||||
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { App, Data } from '@app/types';
|
||||||
|
import { store } from '@app/store';
|
||||||
|
|
||||||
|
import { PROTOCOL_VERSION } from '../../src/websocket/config';
|
||||||
|
|
||||||
|
import { getMockWebSocket, getWebClient, openMockWebSocket } from './helpers/setup';
|
||||||
|
import {
|
||||||
|
buildSessionEventMessage,
|
||||||
|
deliverMessage,
|
||||||
|
} from './helpers/protobuf-builders';
|
||||||
|
import { findLastSessionCommand } from './helpers/command-capture';
|
||||||
|
|
||||||
|
function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}) {
|
||||||
|
return {
|
||||||
|
reason: App.WebSocketConnectReason.LOGIN,
|
||||||
|
host: 'localhost',
|
||||||
|
port: '4748',
|
||||||
|
userName: overrides.userName ?? 'alice',
|
||||||
|
password: overrides.password ?? 'secret',
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverIdentification(
|
||||||
|
protocolVersion = PROTOCOL_VERSION,
|
||||||
|
serverName = 'TestServer',
|
||||||
|
serverVersion = '2.8.0'
|
||||||
|
): Uint8Array {
|
||||||
|
const payload = create(Data.Event_ServerIdentificationSchema, {
|
||||||
|
serverName,
|
||||||
|
serverVersion,
|
||||||
|
protocolVersion,
|
||||||
|
serverOptions: Data.Event_ServerIdentification_ServerOptions.NoOptions,
|
||||||
|
});
|
||||||
|
return buildSessionEventMessage(Data.Event_ServerIdentification_ext, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('connection lifecycle', () => {
|
||||||
|
it('flips status through CONNECTING → CONNECTED on socket open', () => {
|
||||||
|
getWebClient().connect(loginOptions());
|
||||||
|
|
||||||
|
expect(store.getState().server.status.connectionAttemptMade).toBe(true);
|
||||||
|
|
||||||
|
openMockWebSocket();
|
||||||
|
|
||||||
|
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED);
|
||||||
|
expect(store.getState().server.status.description).toBe('Connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes a matching ServerIdentification into LOGGING_IN and sends Command_Login', () => {
|
||||||
|
getWebClient().connect(loginOptions({ userName: 'alice' }));
|
||||||
|
openMockWebSocket();
|
||||||
|
|
||||||
|
deliverMessage(serverIdentification());
|
||||||
|
|
||||||
|
expect(store.getState().server.status.state).toBe(App.StatusEnum.LOGGING_IN);
|
||||||
|
expect(store.getState().server.info.name).toBe('TestServer');
|
||||||
|
expect(store.getState().server.info.version).toBe('2.8.0');
|
||||||
|
|
||||||
|
const { value, cmdId } = findLastSessionCommand(Data.Command_Login_ext);
|
||||||
|
expect(value.userName).toBe('alice');
|
||||||
|
expect(cmdId).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(getWebClient().options).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects on protocol version mismatch without sending a login command', () => {
|
||||||
|
getWebClient().connect(loginOptions());
|
||||||
|
openMockWebSocket();
|
||||||
|
|
||||||
|
deliverMessage(serverIdentification(PROTOCOL_VERSION + 1));
|
||||||
|
|
||||||
|
const mock = getMockWebSocket();
|
||||||
|
expect(mock.close).toHaveBeenCalled();
|
||||||
|
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
|
||||||
|
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('times out when onopen never fires within the keepalive window', () => {
|
||||||
|
getWebClient().connect(loginOptions());
|
||||||
|
|
||||||
|
const mock = getMockWebSocket();
|
||||||
|
expect(mock.close).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
|
||||||
|
expect(mock.close).toHaveBeenCalled();
|
||||||
|
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases keep-alive ping loop on explicit disconnect', () => {
|
||||||
|
getWebClient().connect(loginOptions());
|
||||||
|
openMockWebSocket();
|
||||||
|
deliverMessage(serverIdentification());
|
||||||
|
|
||||||
|
const mock = getMockWebSocket();
|
||||||
|
getWebClient().disconnect();
|
||||||
|
|
||||||
|
expect(mock.close).toHaveBeenCalled();
|
||||||
|
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
|
||||||
|
});
|
||||||
|
});
|
||||||
112
webclient/integration/src/helpers/command-capture.ts
Normal file
112
webclient/integration/src/helpers/command-capture.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
// Helpers for inspecting outbound commands. WebSocketService calls
|
||||||
|
// `this.socket.send(bytes)` with the encoded CommandContainer; the mock
|
||||||
|
// WebSocket records those calls on its `send` vi.fn. These helpers decode
|
||||||
|
// the bytes back into a CommandContainer so tests can assert on what was
|
||||||
|
// sent and extract the `cmdId` needed to build a correlated response.
|
||||||
|
|
||||||
|
import { fromBinary, getExtension, hasExtension } from '@bufbuild/protobuf';
|
||||||
|
import type { GenExtension } from '@bufbuild/protobuf/codegenv2';
|
||||||
|
|
||||||
|
import { Data } from '@app/types';
|
||||||
|
|
||||||
|
import { getMockWebSocket } from './setup';
|
||||||
|
|
||||||
|
/** The three command scopes a CommandContainer can carry in practice. */
|
||||||
|
type SessionCmd = Data.SessionCommand;
|
||||||
|
type RoomCmd = Data.RoomCommand;
|
||||||
|
type GameCmd = Data.GameCommand;
|
||||||
|
|
||||||
|
/** Decode every CommandContainer sent through the mock socket so far. */
|
||||||
|
export function captureAllOutbound(): Data.CommandContainer[] {
|
||||||
|
const mock = getMockWebSocket();
|
||||||
|
return mock.send.mock.calls.map(([bytes]: [Uint8Array]) =>
|
||||||
|
fromBinary(Data.CommandContainerSchema, bytes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode the most recent CommandContainer. Throws if none has been sent. */
|
||||||
|
export function captureLastOutbound(): Data.CommandContainer {
|
||||||
|
const all = captureAllOutbound();
|
||||||
|
if (all.length === 0) {
|
||||||
|
throw new Error('No outbound command has been sent through the mock WebSocket.');
|
||||||
|
}
|
||||||
|
return all[all.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Numeric cmdId of the most recently sent command (the BigInt cast back to number). */
|
||||||
|
export function lastCmdId(): number {
|
||||||
|
return Number(captureLastOutbound().cmdId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the most recently sent CommandContainer whose session-scope command
|
||||||
|
* carries the given extension, and return both the container and the
|
||||||
|
* unwrapped extension value. Handy for "the login() call fired — grab its
|
||||||
|
* cmdId and the Command_Login payload it sent".
|
||||||
|
*/
|
||||||
|
export function findLastSessionCommand<V>(
|
||||||
|
ext: GenExtension<SessionCmd, V>
|
||||||
|
): { container: Data.CommandContainer; value: V; cmdId: number } {
|
||||||
|
const containers = captureAllOutbound();
|
||||||
|
for (let i = containers.length - 1; i >= 0; i--) {
|
||||||
|
const container = containers[i];
|
||||||
|
for (const sessionCmd of container.sessionCommand ?? []) {
|
||||||
|
if (hasExtension(sessionCmd, ext)) {
|
||||||
|
return {
|
||||||
|
container,
|
||||||
|
value: getExtension(sessionCmd, ext),
|
||||||
|
cmdId: Number(container.cmdId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`No outbound session command with extension ${ext.typeName} has been sent.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Room-scoped equivalent of {@link findLastSessionCommand}. */
|
||||||
|
export function findLastRoomCommand<V>(
|
||||||
|
ext: GenExtension<RoomCmd, V>
|
||||||
|
): { container: Data.CommandContainer; value: V; cmdId: number; roomId: number } {
|
||||||
|
const containers = captureAllOutbound();
|
||||||
|
for (let i = containers.length - 1; i >= 0; i--) {
|
||||||
|
const container = containers[i];
|
||||||
|
for (const roomCmd of container.roomCommand ?? []) {
|
||||||
|
if (hasExtension(roomCmd, ext)) {
|
||||||
|
return {
|
||||||
|
container,
|
||||||
|
value: getExtension(roomCmd, ext),
|
||||||
|
cmdId: Number(container.cmdId),
|
||||||
|
roomId: container.roomId ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`No outbound room command with extension ${ext.typeName} has been sent.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Game-scoped equivalent of {@link findLastSessionCommand}. */
|
||||||
|
export function findLastGameCommand<V>(
|
||||||
|
ext: GenExtension<GameCmd, V>
|
||||||
|
): { container: Data.CommandContainer; value: V; cmdId: number; gameId: number } {
|
||||||
|
const containers = captureAllOutbound();
|
||||||
|
for (let i = containers.length - 1; i >= 0; i--) {
|
||||||
|
const container = containers[i];
|
||||||
|
for (const gameCmd of container.gameCommand ?? []) {
|
||||||
|
if (hasExtension(gameCmd, ext)) {
|
||||||
|
return {
|
||||||
|
container,
|
||||||
|
value: getExtension(gameCmd, ext),
|
||||||
|
cmdId: Number(container.cmdId),
|
||||||
|
gameId: container.gameId ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`No outbound game command with extension ${ext.typeName} has been sent.`
|
||||||
|
);
|
||||||
|
}
|
||||||
125
webclient/integration/src/helpers/protobuf-builders.ts
Normal file
125
webclient/integration/src/helpers/protobuf-builders.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
// Factory helpers that build encoded `ServerMessage` binaries for the four
|
||||||
|
// top-level message types the client consumes (RESPONSE, SESSION_EVENT,
|
||||||
|
// ROOM_EVENT, GAME_EVENT_CONTAINER). Tests call these to simulate incoming
|
||||||
|
// server traffic and then hand the resulting bytes to `deliverMessage()`.
|
||||||
|
//
|
||||||
|
// No mocking of `@bufbuild/protobuf` — every builder uses the real `create`/
|
||||||
|
// `setExtension`/`toBinary` path so the bytes that land in ProtobufService
|
||||||
|
// are byte-for-byte identical to what a Servatrice would send.
|
||||||
|
|
||||||
|
import { create, setExtension, toBinary } from '@bufbuild/protobuf';
|
||||||
|
import type { GenExtension, GenMessage } from '@bufbuild/protobuf/codegenv2';
|
||||||
|
import type { MessageInitShape } from '@bufbuild/protobuf';
|
||||||
|
|
||||||
|
import { Data } from '@app/types';
|
||||||
|
|
||||||
|
import { getMockWebSocket } from './setup';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience wrapper around `create` for schemas that accept an init shape.
|
||||||
|
* Mirrors the pattern used throughout the webclient codebase.
|
||||||
|
*/
|
||||||
|
export function make<S extends GenMessage<any>>(
|
||||||
|
schema: S,
|
||||||
|
init?: MessageInitShape<S>
|
||||||
|
): ReturnType<typeof create<S>> {
|
||||||
|
return create(schema, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a top-level ServerMessage wrapping a Response. */
|
||||||
|
export function buildResponseMessage(response: Data.Response): Uint8Array {
|
||||||
|
const msg = create(Data.ServerMessageSchema, {
|
||||||
|
messageType: Data.ServerMessage_MessageType.RESPONSE,
|
||||||
|
response,
|
||||||
|
});
|
||||||
|
return toBinary(Data.ServerMessageSchema, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Response with an optional response-payload extension attached.
|
||||||
|
* `cmdId` must match the outbound command the test is responding to —
|
||||||
|
* callers typically read it from `captureOutbound()`.
|
||||||
|
*/
|
||||||
|
export function buildResponse<V>(params: {
|
||||||
|
cmdId: number;
|
||||||
|
responseCode?: Data.Response_ResponseCode;
|
||||||
|
ext?: GenExtension<Data.Response, V>;
|
||||||
|
value?: V;
|
||||||
|
}): Data.Response {
|
||||||
|
const response = create(Data.ResponseSchema, {
|
||||||
|
cmdId: BigInt(params.cmdId),
|
||||||
|
responseCode: params.responseCode ?? Data.Response_ResponseCode.RespOk,
|
||||||
|
});
|
||||||
|
if (params.ext && params.value !== undefined) {
|
||||||
|
setExtension(response, params.ext, params.value);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a top-level ServerMessage wrapping a SessionEvent with the given extension. */
|
||||||
|
export function buildSessionEventMessage<V>(
|
||||||
|
ext: GenExtension<Data.SessionEvent, V>,
|
||||||
|
value: V
|
||||||
|
): Uint8Array {
|
||||||
|
const sessionEvent = create(Data.SessionEventSchema);
|
||||||
|
setExtension(sessionEvent, ext, value);
|
||||||
|
const msg = create(Data.ServerMessageSchema, {
|
||||||
|
messageType: Data.ServerMessage_MessageType.SESSION_EVENT,
|
||||||
|
sessionEvent,
|
||||||
|
});
|
||||||
|
return toBinary(Data.ServerMessageSchema, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a top-level ServerMessage wrapping a RoomEvent with the given extension. */
|
||||||
|
export function buildRoomEventMessage<V>(
|
||||||
|
roomId: number,
|
||||||
|
ext: GenExtension<Data.RoomEvent, V>,
|
||||||
|
value: V
|
||||||
|
): Uint8Array {
|
||||||
|
const roomEvent = create(Data.RoomEventSchema, { roomId });
|
||||||
|
setExtension(roomEvent, ext, value);
|
||||||
|
const msg = create(Data.ServerMessageSchema, {
|
||||||
|
messageType: Data.ServerMessage_MessageType.ROOM_EVENT,
|
||||||
|
roomEvent,
|
||||||
|
});
|
||||||
|
return toBinary(Data.ServerMessageSchema, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a top-level ServerMessage wrapping a GameEventContainer whose
|
||||||
|
* `eventList` contains a single GameEvent with the given extension attached.
|
||||||
|
*/
|
||||||
|
export function buildGameEventMessage<V>(
|
||||||
|
params: {
|
||||||
|
gameId: number;
|
||||||
|
playerId?: number;
|
||||||
|
ext: GenExtension<Data.GameEvent, V>;
|
||||||
|
value: V;
|
||||||
|
}
|
||||||
|
): Uint8Array {
|
||||||
|
const gameEvent = create(Data.GameEventSchema, {
|
||||||
|
playerId: params.playerId ?? -1,
|
||||||
|
});
|
||||||
|
setExtension(gameEvent, params.ext, params.value);
|
||||||
|
const container = create(Data.GameEventContainerSchema, {
|
||||||
|
gameId: params.gameId,
|
||||||
|
eventList: [gameEvent],
|
||||||
|
});
|
||||||
|
const msg = create(Data.ServerMessageSchema, {
|
||||||
|
messageType: Data.ServerMessage_MessageType.GAME_EVENT_CONTAINER,
|
||||||
|
gameEventContainer: container,
|
||||||
|
});
|
||||||
|
return toBinary(Data.ServerMessageSchema, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver an encoded ServerMessage to the currently-connected mock socket.
|
||||||
|
* WebSocketService wires `onmessage` to push events into its RxJS subject,
|
||||||
|
* which ProtobufService subscribes to — so this triggers the full inbound
|
||||||
|
* pipeline synchronously.
|
||||||
|
*/
|
||||||
|
export function deliverMessage(binary: Uint8Array): void {
|
||||||
|
const mock = getMockWebSocket();
|
||||||
|
const event = { data: binary.buffer } as MessageEvent;
|
||||||
|
mock.onmessage?.(event);
|
||||||
|
}
|
||||||
182
webclient/integration/src/helpers/setup.ts
Normal file
182
webclient/integration/src/helpers/setup.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
// Integration test setup — installs a mock WebSocket constructor, wires up
|
||||||
|
// fake timers for KeepAliveService control, and resets the webClient + Redux
|
||||||
|
// singletons between tests so real event handlers and reducers can run
|
||||||
|
// against a clean slate each time.
|
||||||
|
//
|
||||||
|
// Only `globalThis.WebSocket` is mocked. Everything downstream of it
|
||||||
|
// (ProtobufService, event registries, persistence, store, reducers) runs as
|
||||||
|
// real code, which is the whole point of the integration suite.
|
||||||
|
|
||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
import '../../../src/polyfills';
|
||||||
|
|
||||||
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { afterEach, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ServerDispatch, RoomsDispatch, GameDispatch } from '@app/store';
|
||||||
|
import { App, Data, Enriched } from '@app/types';
|
||||||
|
import { WebClient } from '@app/websocket';
|
||||||
|
import { PROTOCOL_VERSION } from '../../../src/websocket/config';
|
||||||
|
import { createWebClientResponse, createWebClientRequest } from '@app/api';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildResponse,
|
||||||
|
buildResponseMessage,
|
||||||
|
buildSessionEventMessage,
|
||||||
|
deliverMessage,
|
||||||
|
} from './protobuf-builders';
|
||||||
|
import { findLastSessionCommand } from './command-capture';
|
||||||
|
|
||||||
|
export interface MockWebSocketInstance {
|
||||||
|
send: ReturnType<typeof vi.fn>;
|
||||||
|
close: ReturnType<typeof vi.fn>;
|
||||||
|
readyState: number;
|
||||||
|
binaryType: BinaryType;
|
||||||
|
url: string;
|
||||||
|
onopen: ((ev?: Event) => void) | null;
|
||||||
|
onclose: ((ev?: CloseEvent) => void) | null;
|
||||||
|
onerror: ((ev?: Event) => void) | null;
|
||||||
|
onmessage: ((ev: MessageEvent) => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentMockInstance: MockWebSocketInstance | null = null;
|
||||||
|
|
||||||
|
export function getMockWebSocket(): MockWebSocketInstance {
|
||||||
|
if (!currentMockInstance) {
|
||||||
|
throw new Error(
|
||||||
|
'No mock WebSocket has been constructed yet. Call webClient.connect(...) before reading the mock instance.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return currentMockInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockInstance(url: string): MockWebSocketInstance {
|
||||||
|
return {
|
||||||
|
send: vi.fn(),
|
||||||
|
close: vi.fn(function close(this: MockWebSocketInstance) {
|
||||||
|
this.readyState = 3; // CLOSED
|
||||||
|
this.onclose?.({ code: 1000, reason: '', wasClean: true } as CloseEvent);
|
||||||
|
}),
|
||||||
|
readyState: 0, // CONNECTING
|
||||||
|
binaryType: 'arraybuffer',
|
||||||
|
url,
|
||||||
|
onopen: null,
|
||||||
|
onclose: null,
|
||||||
|
onerror: null,
|
||||||
|
onmessage: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function installMockWebSocket(): void {
|
||||||
|
const MockWS = vi.fn(function MockWebSocket(url: string) {
|
||||||
|
currentMockInstance = makeMockInstance(url);
|
||||||
|
return currentMockInstance;
|
||||||
|
}) as unknown as typeof WebSocket;
|
||||||
|
(MockWS as unknown as { CONNECTING: number }).CONNECTING = 0;
|
||||||
|
(MockWS as unknown as { OPEN: number }).OPEN = 1;
|
||||||
|
(MockWS as unknown as { CLOSING: number }).CLOSING = 2;
|
||||||
|
(MockWS as unknown as { CLOSED: number }).CLOSED = 3;
|
||||||
|
globalThis.WebSocket = MockWS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openMockWebSocket(): void {
|
||||||
|
const mock = getMockWebSocket();
|
||||||
|
mock.readyState = 1; // OPEN
|
||||||
|
mock.onopen?.(new Event('open'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebClient(): WebClient {
|
||||||
|
return WebClient.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAll(): void {
|
||||||
|
const client = WebClient.instance;
|
||||||
|
|
||||||
|
if (currentMockInstance && currentMockInstance.readyState === 1) {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
client.protobuf.resetCommands();
|
||||||
|
client.options = null;
|
||||||
|
client.status = App.StatusEnum.DISCONNECTED;
|
||||||
|
|
||||||
|
ServerDispatch.clearStore();
|
||||||
|
RoomsDispatch.clearStore();
|
||||||
|
GameDispatch.clearStore();
|
||||||
|
|
||||||
|
if (currentMockInstance) {
|
||||||
|
currentMockInstance.onopen = null;
|
||||||
|
currentMockInstance.onclose = null;
|
||||||
|
currentMockInstance.onerror = null;
|
||||||
|
currentMockInstance.onmessage = null;
|
||||||
|
currentMockInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
(WebClient as unknown as { _instance: WebClient | null })._instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared connect helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_LOGIN_OPTIONS: Enriched.LoginConnectOptions = {
|
||||||
|
reason: App.WebSocketConnectReason.LOGIN,
|
||||||
|
host: 'localhost',
|
||||||
|
port: '4748',
|
||||||
|
userName: 'alice',
|
||||||
|
password: 'secret',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function connectRaw(
|
||||||
|
overrides: Partial<Enriched.LoginConnectOptions> = {}
|
||||||
|
): void {
|
||||||
|
getWebClient().connect({ ...DEFAULT_LOGIN_OPTIONS, ...overrides });
|
||||||
|
openMockWebSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectAndHandshake(
|
||||||
|
overrides: Partial<Enriched.LoginConnectOptions> = {}
|
||||||
|
): void {
|
||||||
|
connectRaw(overrides);
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_ServerIdentification_ext,
|
||||||
|
create(Data.Event_ServerIdentificationSchema, {
|
||||||
|
serverName: 'TestServer',
|
||||||
|
serverVersion: '2.8.0',
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectAndLogin(userName: string = 'alice'): void {
|
||||||
|
connectAndHandshake({ userName });
|
||||||
|
|
||||||
|
const login = findLastSessionCommand(Data.Command_Login_ext);
|
||||||
|
const userInfo = create(Data.ServerInfo_UserSchema, {
|
||||||
|
name: userName,
|
||||||
|
userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered,
|
||||||
|
});
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: login.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
ext: Data.Response_Login_ext,
|
||||||
|
value: create(Data.Response_LoginSchema, {
|
||||||
|
userInfo,
|
||||||
|
buddyList: [],
|
||||||
|
ignoreList: [],
|
||||||
|
}),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle hooks ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
installMockWebSocket();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
new WebClient(createWebClientResponse(), createWebClientRequest());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetAll();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
65
webclient/integration/src/keep-alive.spec.ts
Normal file
65
webclient/integration/src/keep-alive.spec.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
// KeepAliveService timing scenarios — ping loop, pong correlation, timeout.
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { App, Data } from '@app/types';
|
||||||
|
import { store } from '@app/store';
|
||||||
|
|
||||||
|
import { connectRaw, getMockWebSocket } from './helpers/setup';
|
||||||
|
import {
|
||||||
|
buildResponse,
|
||||||
|
buildResponseMessage,
|
||||||
|
deliverMessage,
|
||||||
|
} from './helpers/protobuf-builders';
|
||||||
|
import { findLastSessionCommand } from './helpers/command-capture';
|
||||||
|
|
||||||
|
describe('keep-alive', () => {
|
||||||
|
it('sends a Command_Ping on every keepalive interval tick', () => {
|
||||||
|
connectRaw();
|
||||||
|
|
||||||
|
expect(() => findLastSessionCommand(Data.Command_Ping_ext)).toThrow();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
const first = findLastSessionCommand(Data.Command_Ping_ext);
|
||||||
|
expect(first.cmdId).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: first.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
})));
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
const second = findLastSessionCommand(Data.Command_Ping_ext);
|
||||||
|
expect(second.cmdId).toBeGreaterThan(first.cmdId);
|
||||||
|
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays CONNECTED while pongs arrive before the next tick', () => {
|
||||||
|
connectRaw();
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
const ping = findLastSessionCommand(Data.Command_Ping_ext);
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: ping.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED);
|
||||||
|
expect(getMockWebSocket().close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects with a timeout status when a ping goes unanswered', () => {
|
||||||
|
connectRaw();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
expect(() => findLastSessionCommand(Data.Command_Ping_ext)).not.toThrow();
|
||||||
|
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
|
||||||
|
expect(getMockWebSocket().close).toHaveBeenCalled();
|
||||||
|
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
|
||||||
|
});
|
||||||
|
});
|
||||||
140
webclient/integration/src/rooms.spec.ts
Normal file
140
webclient/integration/src/rooms.spec.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
// Room scenarios — Event_ListRooms handling, auto-join, Response_JoinRoom,
|
||||||
|
// room chat, and in-room game list updates.
|
||||||
|
|
||||||
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { Data } from '@app/types';
|
||||||
|
import { store } from '@app/store';
|
||||||
|
|
||||||
|
import { connectAndHandshake } from './helpers/setup';
|
||||||
|
import {
|
||||||
|
buildResponse,
|
||||||
|
buildResponseMessage,
|
||||||
|
buildRoomEventMessage,
|
||||||
|
buildSessionEventMessage,
|
||||||
|
deliverMessage,
|
||||||
|
} from './helpers/protobuf-builders';
|
||||||
|
import { findLastSessionCommand } from './helpers/command-capture';
|
||||||
|
|
||||||
|
function makeRoom(overrides: Partial<{
|
||||||
|
roomId: number;
|
||||||
|
name: string;
|
||||||
|
autoJoin: boolean;
|
||||||
|
}> = {}): Data.ServerInfo_Room {
|
||||||
|
return create(Data.ServerInfo_RoomSchema, {
|
||||||
|
roomId: overrides.roomId ?? 1,
|
||||||
|
name: overrides.name ?? 'Lobby',
|
||||||
|
description: 'Test room',
|
||||||
|
gameCount: 0,
|
||||||
|
playerCount: 0,
|
||||||
|
autoJoin: overrides.autoJoin ?? false,
|
||||||
|
gameList: [],
|
||||||
|
userList: [],
|
||||||
|
gametypeList: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('rooms', () => {
|
||||||
|
it('populates rooms state from Event_ListRooms', () => {
|
||||||
|
connectAndHandshake();
|
||||||
|
|
||||||
|
const listRooms = create(Data.Event_ListRoomsSchema, {
|
||||||
|
roomList: [
|
||||||
|
makeRoom({ roomId: 1, name: 'Lobby' }),
|
||||||
|
makeRoom({ roomId: 2, name: 'Legacy' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
deliverMessage(buildSessionEventMessage(Data.Event_ListRooms_ext, listRooms));
|
||||||
|
|
||||||
|
const { rooms } = store.getState().rooms;
|
||||||
|
expect(rooms[1]?.info?.name).toBe('Lobby');
|
||||||
|
expect(rooms[2]?.info?.name).toBe('Legacy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-joins rooms flagged with autoJoin and flips joinedRoomIds on Response_JoinRoom', () => {
|
||||||
|
connectAndHandshake();
|
||||||
|
|
||||||
|
const listRooms = create(Data.Event_ListRoomsSchema, {
|
||||||
|
roomList: [
|
||||||
|
makeRoom({ roomId: 1, name: 'Lobby', autoJoin: true }),
|
||||||
|
makeRoom({ roomId: 2, name: 'Legacy', autoJoin: false }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
deliverMessage(buildSessionEventMessage(Data.Event_ListRooms_ext, listRooms));
|
||||||
|
|
||||||
|
const join = findLastSessionCommand(Data.Command_JoinRoom_ext);
|
||||||
|
expect(join.value.roomId).toBe(1);
|
||||||
|
|
||||||
|
const joined = create(Data.Response_JoinRoomSchema, {
|
||||||
|
roomInfo: makeRoom({ roomId: 1, name: 'Lobby' }),
|
||||||
|
});
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: join.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
ext: Data.Response_JoinRoom_ext,
|
||||||
|
value: joined,
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(store.getState().rooms.joinedRoomIds[1]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends a room chat message on Event_RoomSay', () => {
|
||||||
|
connectAndHandshake();
|
||||||
|
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_ListRooms_ext,
|
||||||
|
create(Data.Event_ListRoomsSchema, { roomList: [makeRoom({ roomId: 1, autoJoin: true })] })
|
||||||
|
));
|
||||||
|
const join = findLastSessionCommand(Data.Command_JoinRoom_ext);
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: join.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
ext: Data.Response_JoinRoom_ext,
|
||||||
|
value: create(Data.Response_JoinRoomSchema, { roomInfo: makeRoom({ roomId: 1 }) }),
|
||||||
|
})));
|
||||||
|
|
||||||
|
const say = create(Data.Event_RoomSaySchema, {
|
||||||
|
name: 'bob',
|
||||||
|
message: 'hello world',
|
||||||
|
messageType: Data.Event_RoomSay_RoomMessageType.UserMessage,
|
||||||
|
});
|
||||||
|
deliverMessage(buildRoomEventMessage(1, Data.Event_RoomSay_ext, say));
|
||||||
|
|
||||||
|
const messages = store.getState().rooms.messages[1];
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messages[0].message).toBe('bob: hello world');
|
||||||
|
expect(messages[0].name).toBe('bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the game list on Event_ListGames', () => {
|
||||||
|
connectAndHandshake();
|
||||||
|
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_ListRooms_ext,
|
||||||
|
create(Data.Event_ListRoomsSchema, { roomList: [makeRoom({ roomId: 1, autoJoin: true })] })
|
||||||
|
));
|
||||||
|
const join = findLastSessionCommand(Data.Command_JoinRoom_ext);
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: join.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
ext: Data.Response_JoinRoom_ext,
|
||||||
|
value: create(Data.Response_JoinRoomSchema, { roomInfo: makeRoom({ roomId: 1 }) }),
|
||||||
|
})));
|
||||||
|
|
||||||
|
const game = create(Data.ServerInfo_GameSchema, {
|
||||||
|
gameId: 42,
|
||||||
|
description: 'Test Game',
|
||||||
|
maxPlayers: 4,
|
||||||
|
playerCount: 1,
|
||||||
|
startTime: 1,
|
||||||
|
});
|
||||||
|
const listGames = create(Data.Event_ListGamesSchema, { gameList: [game] });
|
||||||
|
deliverMessage(buildRoomEventMessage(1, Data.Event_ListGames_ext, listGames));
|
||||||
|
|
||||||
|
const roomGames = store.getState().rooms.rooms[1]?.games;
|
||||||
|
expect(roomGames).toBeDefined();
|
||||||
|
expect(roomGames?.[42]?.info?.description).toBe('Test Game');
|
||||||
|
expect(roomGames?.[42]?.info?.gameId).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
105
webclient/integration/src/server-events.spec.ts
Normal file
105
webclient/integration/src/server-events.spec.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
// Server-level session events — server message banner, shutdown schedule,
|
||||||
|
// user notifications, and connection-closed reason code mapping.
|
||||||
|
|
||||||
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { App, Data } from '@app/types';
|
||||||
|
import { store } from '@app/store';
|
||||||
|
|
||||||
|
import { connectAndHandshake } from './helpers/setup';
|
||||||
|
import {
|
||||||
|
buildSessionEventMessage,
|
||||||
|
deliverMessage,
|
||||||
|
} from './helpers/protobuf-builders';
|
||||||
|
|
||||||
|
describe('server events', () => {
|
||||||
|
it('writes the server banner into server.info.message on Event_ServerMessage', () => {
|
||||||
|
connectAndHandshake();
|
||||||
|
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_ServerMessage_ext,
|
||||||
|
create(Data.Event_ServerMessageSchema, { message: 'Welcome to TestServer!' })
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(store.getState().server.info.message).toBe('Welcome to TestServer!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores the shutdown payload on Event_ServerShutdown', () => {
|
||||||
|
connectAndHandshake();
|
||||||
|
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_ServerShutdown_ext,
|
||||||
|
create(Data.Event_ServerShutdownSchema, {
|
||||||
|
reason: 'Scheduled maintenance',
|
||||||
|
minutes: 5,
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
const shutdown = store.getState().server.serverShutdown;
|
||||||
|
expect(shutdown).not.toBeNull();
|
||||||
|
expect(shutdown?.reason).toBe('Scheduled maintenance');
|
||||||
|
expect(shutdown?.minutes).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends a notification on Event_NotifyUser', () => {
|
||||||
|
connectAndHandshake();
|
||||||
|
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_NotifyUser_ext,
|
||||||
|
create(Data.Event_NotifyUserSchema, {
|
||||||
|
type: Data.Event_NotifyUser_NotificationType.PROMOTION,
|
||||||
|
customTitle: 'You have been promoted',
|
||||||
|
customContent: 'Now a judge',
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
const notifications = store.getState().server.notifications;
|
||||||
|
expect(notifications).toHaveLength(1);
|
||||||
|
expect(notifications[0].customTitle).toBe('You have been promoted');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('connection closed', () => {
|
||||||
|
it('prefers reasonStr when provided', () => {
|
||||||
|
connectAndHandshake();
|
||||||
|
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_ConnectionClosed_ext,
|
||||||
|
create(Data.Event_ConnectionClosedSchema, {
|
||||||
|
reason: Data.Event_ConnectionClosed_CloseReason.OTHER,
|
||||||
|
reasonStr: 'kicked by admin',
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
const status = store.getState().server.status;
|
||||||
|
expect(status.state).toBe(App.StatusEnum.DISCONNECTED);
|
||||||
|
expect(status.description).toBe('kicked by admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps USER_LIMIT_REACHED to a capacity message', () => {
|
||||||
|
connectAndHandshake();
|
||||||
|
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_ConnectionClosed_ext,
|
||||||
|
create(Data.Event_ConnectionClosedSchema, {
|
||||||
|
reason: Data.Event_ConnectionClosed_CloseReason.USER_LIMIT_REACHED,
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(store.getState().server.status.description).toContain('maximum user capacity');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps LOGGEDINELSEWERE to a multi-session message', () => {
|
||||||
|
connectAndHandshake();
|
||||||
|
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_ConnectionClosed_ext,
|
||||||
|
create(Data.Event_ConnectionClosedSchema, {
|
||||||
|
reason: Data.Event_ConnectionClosed_CloseReason.LOGGEDINELSEWERE,
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(store.getState().server.status.description).toContain('another location');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
120
webclient/integration/src/users.spec.ts
Normal file
120
webclient/integration/src/users.spec.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
// User-list and social scenarios — user presence, buddy/ignore lists, DMs.
|
||||||
|
|
||||||
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { Data } from '@app/types';
|
||||||
|
import { store } from '@app/store';
|
||||||
|
|
||||||
|
import { connectAndLogin } from './helpers/setup';
|
||||||
|
import {
|
||||||
|
buildResponse,
|
||||||
|
buildResponseMessage,
|
||||||
|
buildSessionEventMessage,
|
||||||
|
deliverMessage,
|
||||||
|
} from './helpers/protobuf-builders';
|
||||||
|
import { findLastSessionCommand } from './helpers/command-capture';
|
||||||
|
|
||||||
|
function makeUser(name: string): Data.ServerInfo_User {
|
||||||
|
return create(Data.ServerInfo_UserSchema, {
|
||||||
|
name,
|
||||||
|
userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('users', () => {
|
||||||
|
it('populates state.users from the Response_ListUsers post-login', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
|
||||||
|
const listUsers = findLastSessionCommand(Data.Command_ListUsers_ext);
|
||||||
|
const users = [makeUser('alice'), makeUser('bob'), makeUser('carol')];
|
||||||
|
deliverMessage(buildResponseMessage(buildResponse({
|
||||||
|
cmdId: listUsers.cmdId,
|
||||||
|
responseCode: Data.Response_ResponseCode.RespOk,
|
||||||
|
ext: Data.Response_ListUsers_ext,
|
||||||
|
value: create(Data.Response_ListUsersSchema, { userList: users }),
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(Object.keys(store.getState().server.users).sort()).toEqual(['alice', 'bob', 'carol']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends on Event_UserJoined and removes on Event_UserLeft', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_UserJoined_ext,
|
||||||
|
create(Data.Event_UserJoinedSchema, { userInfo: makeUser('bob') })
|
||||||
|
));
|
||||||
|
expect('bob' in store.getState().server.users).toBe(true);
|
||||||
|
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_UserLeft_ext,
|
||||||
|
create(Data.Event_UserLeftSchema, { name: 'bob' })
|
||||||
|
));
|
||||||
|
expect('bob' in store.getState().server.users).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a user to buddyList on Event_AddToList with listName=buddy', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_AddToList_ext,
|
||||||
|
create(Data.Event_AddToListSchema, {
|
||||||
|
listName: 'buddy',
|
||||||
|
userInfo: makeUser('bob'),
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
expect('bob' in store.getState().server.buddyList).toBe(true);
|
||||||
|
expect(store.getState().server.ignoreList).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a user to ignoreList on Event_AddToList with listName=ignore', () => {
|
||||||
|
connectAndLogin();
|
||||||
|
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_AddToList_ext,
|
||||||
|
create(Data.Event_AddToListSchema, {
|
||||||
|
listName: 'ignore',
|
||||||
|
userInfo: makeUser('mallory'),
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
expect('mallory' in store.getState().server.ignoreList).toBe(true);
|
||||||
|
expect(store.getState().server.buddyList).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('files an incoming direct message under the sender', () => {
|
||||||
|
connectAndLogin('alice');
|
||||||
|
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_UserMessage_ext,
|
||||||
|
create(Data.Event_UserMessageSchema, {
|
||||||
|
senderName: 'bob',
|
||||||
|
receiverName: 'alice',
|
||||||
|
message: 'hi alice',
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
const { messages } = store.getState().server;
|
||||||
|
expect(messages.bob).toHaveLength(1);
|
||||||
|
expect(messages.bob[0].message).toBe('hi alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('files an outgoing direct message under the recipient', () => {
|
||||||
|
connectAndLogin('alice');
|
||||||
|
|
||||||
|
deliverMessage(buildSessionEventMessage(
|
||||||
|
Data.Event_UserMessage_ext,
|
||||||
|
create(Data.Event_UserMessageSchema, {
|
||||||
|
senderName: 'alice',
|
||||||
|
receiverName: 'bob',
|
||||||
|
message: 'hey bob',
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
const { messages } = store.getState().server;
|
||||||
|
expect(messages.bob).toHaveLength(1);
|
||||||
|
expect(messages.bob[0].message).toBe('hey bob');
|
||||||
|
});
|
||||||
|
});
|
||||||
7
webclient/integration/tsconfig.json
Normal file
7
webclient/integration/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["./**/*.ts"]
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||||
|
"test:integration:coverage": "vitest run --config vitest.integration.config.ts --coverage",
|
||||||
"lint": "eslint src/",
|
"lint": "eslint src/",
|
||||||
"lint:fix": "eslint src/ --fix",
|
"lint:fix": "eslint src/ --fix",
|
||||||
"golden": "npm run lint && npm run test",
|
"golden": "npm run lint && npm run test",
|
||||||
|
|
|
||||||
|
|
@ -19,5 +19,18 @@ export default defineConfig({
|
||||||
setupFiles: ['./src/setupTests.ts'],
|
setupFiles: ['./src/setupTests.ts'],
|
||||||
include: ['src/**/*.spec.{ts,tsx}'],
|
include: ['src/**/*.spec.{ts,tsx}'],
|
||||||
isolate: false,
|
isolate: false,
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'html'],
|
||||||
|
reportsDirectory: './coverage/testing',
|
||||||
|
include: ['src/**/*.{ts,tsx}'],
|
||||||
|
exclude: [
|
||||||
|
'src/generated/**',
|
||||||
|
'src/**/*.spec.{ts,tsx}',
|
||||||
|
'src/**/__mocks__/**',
|
||||||
|
'src/setupTests.ts',
|
||||||
|
'src/polyfills.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
34
webclient/vitest.integration.config.ts
Normal file
34
webclient/vitest.integration.config.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
// Integration tests exercise the full inbound/outbound webclient pipeline
|
||||||
|
// (ProtobufService → event handlers → persistence → Redux) with only the
|
||||||
|
// browser WebSocket constructor mocked. They live in `integration/` and run
|
||||||
|
// under their own config so they can use `isolate: true` without slowing down
|
||||||
|
// the unit suite (which relies on `isolate: false` for shared vi.mock state).
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
tsconfigPaths: true,
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./integration/src/helpers/setup.ts'],
|
||||||
|
include: ['integration/src/**/*.spec.ts'],
|
||||||
|
isolate: false,
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'html'],
|
||||||
|
reportsDirectory: './coverage/integration',
|
||||||
|
include: ['src/**/*.{ts,tsx}'],
|
||||||
|
exclude: [
|
||||||
|
'src/generated/**',
|
||||||
|
'src/**/*.spec.{ts,tsx}',
|
||||||
|
'src/**/__mocks__/**',
|
||||||
|
'src/setupTests.ts',
|
||||||
|
'src/polyfills.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue