test: add test helpers and integration tests for dispatcher + stores

- Create mock-ws helper with simulateMessage/simulateStateChange
- Create fixture factories for messages, members, channels, reactions
- Create resetAllStores utility for clean test isolation
- Add 16 integration tests covering:
  - Ready payload hydration (channels, members, voice)
  - Chat message flow with unread tracking
  - Message edit and delete
  - Reaction add/remove via reaction_update
  - Chat send confirmation via chat_send_ok
  - Typing indicator updates
  - Member ban handling
  - Voice config and speakers state

385 tests passing across 22 test files.
This commit is contained in:
jevb
2026-03-15 20:46:38 +01:00
parent e07a1abede
commit 3d022b68e4
4 changed files with 1091 additions and 0 deletions
@@ -0,0 +1,219 @@
/**
* Test data factories for OwnCord protocol types.
* Every factory returns a new object with sensible defaults.
* Pass partial overrides to customize individual fields.
*/
import type {
MessageResponse,
MemberResponse,
ReadyChannel,
ReactionSummary,
VoiceStatePayload,
ReadyMember,
ReadyVoiceState,
ReadyRole,
ReadyPayload,
ChatMessagePayload,
MessageUser,
Attachment,
} from "@lib/types";
// ---------------------------------------------------------------------------
// Atomic factories
// ---------------------------------------------------------------------------
/** Create a MessageResponse with sensible defaults. */
export function makeMessage(
overrides?: Partial<MessageResponse>,
): MessageResponse {
return {
id: 1,
channel_id: 1,
user: { id: 1, username: "testuser", avatar: null },
content: "Hello, world!",
reply_to: null,
attachments: [],
reactions: [],
pinned: false,
edited_at: null,
deleted: false,
timestamp: "2026-03-15T12:00:00Z",
...overrides,
};
}
/** Create a MemberResponse with sensible defaults. */
export function makeMember(
overrides?: Partial<MemberResponse>,
): MemberResponse {
return {
id: 1,
username: "testuser",
avatar: null,
role: "member",
status: "online",
...overrides,
};
}
/** Create a ReadyChannel with sensible defaults. */
export function makeChannel(
overrides?: Partial<ReadyChannel>,
): ReadyChannel {
return {
id: 1,
name: "general",
type: "text",
category: "Text Channels",
position: 0,
unread_count: 0,
last_message_id: undefined,
...overrides,
};
}
/** Create a ReactionSummary with sensible defaults. */
export function makeReaction(
overrides?: Partial<ReactionSummary>,
): ReactionSummary {
return {
emoji: "👍",
count: 1,
me: false,
...overrides,
};
}
/** Create a VoiceStatePayload with sensible defaults. */
export function makeVoiceState(
overrides?: Partial<VoiceStatePayload>,
): VoiceStatePayload {
return {
channel_id: 3,
user_id: 1,
username: "testuser",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Composite factories
// ---------------------------------------------------------------------------
/** Create a MessageUser with sensible defaults. */
export function makeMessageUser(
overrides?: Partial<MessageUser>,
): MessageUser {
return {
id: 1,
username: "testuser",
avatar: null,
...overrides,
};
}
/** Create an Attachment with sensible defaults. */
export function makeAttachment(
overrides?: Partial<Attachment>,
): Attachment {
return {
id: "att-1",
filename: "image.png",
size: 1024,
mime: "image/png",
url: "/uploads/image.png",
...overrides,
};
}
/** Create a ChatMessagePayload (WS wire format) with sensible defaults. */
export function makeChatMessagePayload(
overrides?: Partial<ChatMessagePayload>,
): ChatMessagePayload {
return {
id: 1,
channel_id: 1,
user: { id: 1, username: "testuser", avatar: null },
content: "Hello, world!",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T12:00:00Z",
...overrides,
};
}
/** Create a ReadyMember with sensible defaults. */
export function makeReadyMember(
overrides?: Partial<ReadyMember>,
): ReadyMember {
return {
id: 1,
username: "testuser",
avatar: null,
role: "member",
status: "online",
...overrides,
};
}
/** Create a ReadyVoiceState with sensible defaults. */
export function makeReadyVoiceState(
overrides?: Partial<ReadyVoiceState>,
): ReadyVoiceState {
return {
channel_id: 3,
user_id: 1,
muted: false,
deafened: false,
...overrides,
};
}
/** Create a ReadyRole with sensible defaults. */
export function makeReadyRole(
overrides?: Partial<ReadyRole>,
): ReadyRole {
return {
id: 1,
name: "Member",
color: null,
permissions: 0x3,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Full ready payload fixture
// ---------------------------------------------------------------------------
/** Create a full ReadyPayload fixture for integration tests. */
export function makeReadyPayload(
overrides?: Partial<ReadyPayload>,
): ReadyPayload {
return {
channels: [
makeChannel({ id: 1, name: "general", type: "text", position: 0, unread_count: 3, last_message_id: 100 }),
makeChannel({ id: 2, name: "random", type: "text", position: 1, unread_count: 0, last_message_id: 50 }),
makeChannel({ id: 3, name: "Voice Chat", type: "voice", category: "Voice Channels", position: 0 }),
],
members: [
makeReadyMember({ id: 1, username: "admin", role: "admin", status: "online" }),
makeReadyMember({ id: 2, username: "user1", role: "member", status: "online" }),
],
voice_states: [
makeReadyVoiceState({ user_id: 1, channel_id: 3 }),
],
roles: [
makeReadyRole({ id: 1, name: "Owner", color: "#e74c3c", permissions: 0x7FFFFFFF }),
makeReadyRole({ id: 2, name: "Admin", color: "#f1c40f", permissions: 0x3FFFFFFF }),
makeReadyRole({ id: 3, name: "Member", color: null, permissions: 0x3 }),
],
...overrides,
};
}
@@ -0,0 +1,139 @@
/**
* Mock WebSocket client that implements the same public interface as
* createWsClient() from @lib/ws. Used in unit and integration tests
* to simulate server messages and inspect outbound sends without
* requiring Tauri IPC or a real WebSocket connection.
*/
import type {
ServerMessage,
ClientMessage,
} from "@lib/types";
import type { ConnectionState, WsListener } from "@lib/ws";
interface SentEnvelope {
readonly type: string;
readonly id: string;
readonly payload: unknown;
}
export function createMockWsClient() {
let state: ConnectionState = "disconnected";
const sent: SentEnvelope[] = [];
const listeners = new Map<string, Set<WsListener<ServerMessage["type"]>>>();
const stateListeners = new Set<(state: ConnectionState) => void>();
let idCounter = 0;
function nextId(): string {
idCounter += 1;
return `mock-${idCounter}`;
}
function setState(newState: ConnectionState): void {
if (state !== newState) {
state = newState;
for (const listener of stateListeners) {
listener(state);
}
}
}
return {
// ---------------------------------------------------------------
// Public API — mirrors WsClient from @lib/ws
// ---------------------------------------------------------------
connect(): void {
setState("connected");
},
disconnect(): void {
setState("disconnected");
},
send(msg: ClientMessage): string {
const id = nextId();
sent.push({ type: msg.type, id, payload: msg.payload });
return id;
},
on<T extends ServerMessage["type"]>(
type: T,
listener: WsListener<T>,
): () => void {
if (!listeners.has(type)) {
listeners.set(type, new Set());
}
const set = listeners.get(type)!;
set.add(listener as unknown as WsListener<ServerMessage["type"]>);
return () => {
set.delete(listener as unknown as WsListener<ServerMessage["type"]>);
};
},
onStateChange(listener: (s: ConnectionState) => void): () => void {
stateListeners.add(listener);
return () => stateListeners.delete(listener);
},
getState(): ConnectionState {
return state;
},
// ---------------------------------------------------------------
// Test-only helpers
// ---------------------------------------------------------------
/**
* Simulate a server message arriving. Fires all registered listeners
* for the given message type.
*/
simulateMessage<T extends ServerMessage["type"]>(
type: T,
payload: Extract<ServerMessage, { type: T }>["payload"],
id?: string,
): void {
const typeListeners = listeners.get(type);
if (typeListeners) {
for (const listener of typeListeners) {
// Cast through unknown: the generic constraints guarantee type
// safety at call sites, but TS cannot narrow inside the loop.
const fn = listener as unknown as (p: unknown, i?: string) => void;
fn(payload, id);
}
}
},
/**
* Simulate a connection state change (e.g. reconnecting, disconnected).
*/
simulateStateChange(newState: ConnectionState): void {
setState(newState);
},
/**
* Return all messages passed to send(), in order.
*/
getSentMessages(): readonly SentEnvelope[] {
return sent;
},
/**
* Convenience: return the last sent message, or undefined if none.
*/
get lastSent(): SentEnvelope | undefined {
return sent[sent.length - 1];
},
/**
* Clear the sent message buffer.
*/
clearSent(): void {
sent.length = 0;
},
};
}
export type MockWsClient = ReturnType<typeof createMockWsClient>;
@@ -0,0 +1,155 @@
/**
* Common test utilities for OwnCord Tauri client tests.
* Provides store reset and async store waiting helpers.
*/
import type { Store } from "@lib/store";
import { authStore } from "@stores/auth.store";
import { channelsStore } from "@stores/channels.store";
import { membersStore } from "@stores/members.store";
import { messagesStore } from "@stores/messages.store";
import { voiceStore } from "@stores/voice.store";
import { uiStore } from "@stores/ui.store";
import type { AuthState } from "@stores/auth.store";
import type { ChannelsState } from "@stores/channels.store";
import type { MembersState } from "@stores/members.store";
import type { MessagesState } from "@stores/messages.store";
import type { VoiceState } from "@stores/voice.store";
import type { UiState } from "@stores/ui.store";
// ---------------------------------------------------------------------------
// Initial states (must match those in each store module)
// ---------------------------------------------------------------------------
const AUTH_INITIAL: AuthState = {
token: null,
user: null,
serverName: null,
motd: null,
isAuthenticated: false,
};
const CHANNELS_INITIAL: ChannelsState = {
channels: new Map(),
activeChannelId: null,
};
const MEMBERS_INITIAL: MembersState = {
members: new Map(),
typingUsers: new Map(),
};
const MESSAGES_INITIAL: MessagesState = {
messagesByChannel: new Map(),
pendingSends: new Map(),
loadedChannels: new Set(),
hasMore: new Map(),
};
const VOICE_INITIAL: VoiceState = {
currentChannelId: null,
voiceUsers: new Map(),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
};
const UI_INITIAL: UiState = {
sidebarCollapsed: false,
memberListVisible: true,
settingsOpen: false,
activeModal: null,
theme: "dark",
connectionStatus: "disconnected",
transientError: null,
persistentError: null,
collapsedCategories: new Set(),
};
// ---------------------------------------------------------------------------
// resetAllStores
// ---------------------------------------------------------------------------
/**
* Reset every store to its initial state. Call this in `beforeEach` to
* ensure test isolation.
*/
export function resetAllStores(): void {
authStore.setState(() => ({ ...AUTH_INITIAL }));
channelsStore.setState(() => ({ ...CHANNELS_INITIAL, channels: new Map() }));
membersStore.setState(() => ({
...MEMBERS_INITIAL,
members: new Map(),
typingUsers: new Map(),
}));
messagesStore.setState(() => ({
...MESSAGES_INITIAL,
messagesByChannel: new Map(),
pendingSends: new Map(),
loadedChannels: new Set(),
hasMore: new Map(),
}));
voiceStore.setState(() => ({
...VOICE_INITIAL,
voiceUsers: new Map(),
voiceConfigs: new Map(),
}));
uiStore.setState(() => ({
...UI_INITIAL,
collapsedCategories: new Set(),
}));
}
// ---------------------------------------------------------------------------
// waitForStoreUpdate
// ---------------------------------------------------------------------------
/**
* Returns a promise that resolves when the store's state matches the given
* predicate. Useful for waiting on asynchronous store updates (e.g. after
* dispatching a WS message that triggers a store change).
*
* Times out after `timeoutMs` (default 2000ms) to prevent hanging tests.
*
* @example
* ```ts
* await waitForStoreUpdate(authStore, (s) => s.isAuthenticated);
* ```
*/
export function waitForStoreUpdate<T>(
store: Store<T>,
predicate: (state: T) => boolean,
timeoutMs = 2000,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
// Check immediately — predicate may already be true
const current = store.getState();
if (predicate(current)) {
resolve(current);
return;
}
let timer: ReturnType<typeof setTimeout> | null = null;
const unsub = store.subscribe((state) => {
if (predicate(state)) {
if (timer !== null) {
clearTimeout(timer);
}
unsub();
resolve(state);
}
});
timer = setTimeout(() => {
unsub();
reject(
new Error(
`waitForStoreUpdate timed out after ${timeoutMs}ms. ` +
`Last state: ${JSON.stringify(store.getState())}`,
),
);
}, timeoutMs);
});
}
@@ -0,0 +1,578 @@
/**
* Integration tests — Store hydration via dispatcher.
* Verifies that WS events, routed through wireDispatcher, correctly
* update all domain stores (channels, members, messages, voice).
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type { WsClient, WsListener, ConnectionState } from "@lib/ws";
import type { ServerMessage } from "@lib/types";
import { wireDispatcher } from "@lib/dispatcher";
// ── Stores ──────────────────────────────────────────────────────────
import { channelsStore, setActiveChannel } from "@stores/channels.store";
import { membersStore } from "@stores/members.store";
import { messagesStore, addPendingSend, addMessage } from "@stores/messages.store";
import { voiceStore } from "@stores/voice.store";
import { authStore, setAuth } from "@stores/auth.store";
// ── Mock WsClient ───────────────────────────────────────────────────
interface MockWsClient extends WsClient {
/** Fire a server event into all registered handlers. */
simulate(type: string, payload: unknown, id?: string): void;
/** All messages passed to send(). */
readonly sent: Array<{ type: string; payload: unknown }>;
}
function createMockWsClient(): MockWsClient {
const listeners = new Map<string, Set<WsListener<ServerMessage["type"]>>>();
const stateListeners = new Set<(s: ConnectionState) => void>();
const sent: Array<{ type: string; payload: unknown }> = [];
let currentState: ConnectionState = "connected";
return {
connect() {
// no-op
},
disconnect() {
// no-op
},
send(msg) {
sent.push(msg as { type: string; payload: unknown });
return crypto.randomUUID();
},
on<T extends ServerMessage["type"]>(
type: T,
listener: WsListener<T>,
): () => void {
if (!listeners.has(type)) {
listeners.set(type, new Set());
}
const set = listeners.get(type)!;
const wrapped = listener as unknown as WsListener<ServerMessage["type"]>;
set.add(wrapped);
return () => {
set.delete(wrapped);
};
},
onStateChange(listener: (s: ConnectionState) => void): () => void {
stateListeners.add(listener);
return () => stateListeners.delete(listener);
},
getState(): ConnectionState {
return currentState;
},
_getWs() {
return null;
},
simulate(type: string, payload: unknown, id?: string): void {
const typeListeners = listeners.get(type);
if (!typeListeners) return;
for (const listener of typeListeners) {
(listener as (p: unknown, i?: string) => void)(payload, id);
}
},
get sent() {
return sent;
},
};
}
// ── Helpers ─────────────────────────────────────────────────────────
function resetAllStores(): void {
channelsStore.setState(() => ({
channels: new Map(),
activeChannelId: null,
}));
membersStore.setState(() => ({
members: new Map(),
typingUsers: new Map(),
}));
messagesStore.setState(() => ({
messagesByChannel: new Map(),
pendingSends: new Map(),
loadedChannels: new Set(),
hasMore: new Map(),
}));
voiceStore.setState(() => ({
currentChannelId: null,
voiceUsers: new Map(),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
}));
authStore.setState(() => ({
token: null,
user: null,
serverName: null,
motd: null,
isAuthenticated: false,
}));
}
// ── Test Suite ───────────────────────────────────────────────────────
describe("Store integration via dispatcher", () => {
let ws: MockWsClient;
let cleanup: () => void;
beforeEach(() => {
vi.restoreAllMocks();
resetAllStores();
ws = createMockWsClient();
cleanup = wireDispatcher(ws);
});
afterEach(() => {
cleanup();
});
// ────────────────────────────────────────────────────────────────
// 1. Ready payload hydration
// ────────────────────────────────────────────────────────────────
describe("ready payload hydration", () => {
it("populates channels, members, and voice stores from ready event", () => {
ws.simulate("ready", {
channels: [
{ id: 1, name: "general", type: "text", category: "Text Channels", position: 0, unread_count: 3, last_message_id: 100 },
{ id: 2, name: "random", type: "text", category: "Text Channels", position: 1, unread_count: 0, last_message_id: 50 },
{ id: 3, name: "Voice Chat", type: "voice", category: "Voice Channels", position: 0 },
],
members: [
{ id: 1, username: "admin", avatar: null, role: "admin", status: "online" },
{ id: 2, username: "user1", avatar: null, role: "member", status: "idle" },
{ id: 3, username: "user2", avatar: null, role: "member", status: "offline" },
],
voice_states: [
{ channel_id: 3, user_id: 1, muted: false, deafened: false },
{ channel_id: 3, user_id: 2, muted: true, deafened: false },
],
roles: [
{ id: 1, name: "Admin", color: "#f1c40f", permissions: 0x3FFFFFFF },
{ id: 2, name: "Member", color: null, permissions: 0x3 },
],
});
// Channels
const channels = channelsStore.getState().channels;
expect(channels.size).toBe(3);
expect(channels.get(1)?.name).toBe("general");
expect(channels.get(1)?.unreadCount).toBe(3);
expect(channels.get(3)?.type).toBe("voice");
// Members
const members = membersStore.getState().members;
expect(members.size).toBe(3);
expect(members.get(1)?.username).toBe("admin");
expect(members.get(1)?.role).toBe("admin");
expect(members.get(2)?.status).toBe("idle");
// Voice
const voiceUsers = voiceStore.getState().voiceUsers;
const channel3Users = voiceUsers.get(3);
expect(channel3Users).toBeDefined();
expect(channel3Users!.size).toBe(2);
expect(channel3Users!.get(1)?.muted).toBe(false);
expect(channel3Users!.get(2)?.muted).toBe(true);
});
});
// ────────────────────────────────────────────────────────────────
// 2. Chat message flow (unread tracking)
// ────────────────────────────────────────────────────────────────
describe("chat message flow", () => {
beforeEach(() => {
// Seed channels
ws.simulate("ready", {
channels: [
{ id: 1, name: "general", type: "text", category: null, position: 0, unread_count: 0 },
{ id: 2, name: "random", type: "text", category: null, position: 1, unread_count: 0 },
],
members: [
{ id: 10, username: "sender", avatar: null, role: "member", status: "online" },
],
voice_states: [],
roles: [],
});
});
it("adds message to store and increments unread on non-active channel", () => {
// No active channel set, so channel 1 is non-active
ws.simulate("chat_message", {
id: 100,
channel_id: 1,
user: { id: 10, username: "sender", avatar: null },
content: "Hello!",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T12:00:00Z",
});
const messages = messagesStore.getState().messagesByChannel.get(1);
expect(messages).toHaveLength(1);
expect(messages![0]!.content).toBe("Hello!");
const channel = channelsStore.getState().channels.get(1);
expect(channel?.unreadCount).toBe(1);
});
it("does not increment unread when message arrives on active channel", () => {
setActiveChannel(1);
ws.simulate("chat_message", {
id: 101,
channel_id: 1,
user: { id: 10, username: "sender", avatar: null },
content: "Active channel message",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T12:01:00Z",
});
const messages = messagesStore.getState().messagesByChannel.get(1);
expect(messages).toHaveLength(1);
const channel = channelsStore.getState().channels.get(1);
expect(channel?.unreadCount).toBe(0);
});
});
// ────────────────────────────────────────────────────────────────
// 3. Message edit and delete
// ────────────────────────────────────────────────────────────────
describe("message edit and delete", () => {
beforeEach(() => {
// Seed a message directly
addMessage({
id: 200,
channel_id: 5,
user: { id: 1, username: "author", avatar: null },
content: "Original content",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
});
});
it("updates content on chat_edited event", () => {
ws.simulate("chat_edited", {
message_id: 200,
channel_id: 5,
content: "Edited content",
edited_at: "2026-03-15T10:05:00Z",
});
const messages = messagesStore.getState().messagesByChannel.get(5);
expect(messages).toHaveLength(1);
expect(messages![0]!.content).toBe("Edited content");
expect(messages![0]!.editedAt).toBe("2026-03-15T10:05:00Z");
});
it("marks message as deleted on chat_deleted event", () => {
ws.simulate("chat_deleted", {
message_id: 200,
channel_id: 5,
});
const messages = messagesStore.getState().messagesByChannel.get(5);
expect(messages).toHaveLength(1);
expect(messages![0]!.deleted).toBe(true);
});
});
// ────────────────────────────────────────────────────────────────
// 4. Reaction update
// ────────────────────────────────────────────────────────────────
describe("reaction update", () => {
beforeEach(() => {
// Set up auth so updateReaction knows the current user
setAuth(
"test-token",
{ id: 99, username: "me", avatar: null, role: "member" },
"Test Server",
"Welcome",
);
// Seed a message
addMessage({
id: 300,
channel_id: 7,
user: { id: 1, username: "someone", avatar: null },
content: "React to this",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T11:00:00Z",
});
});
it("increases reaction count on add", () => {
ws.simulate("reaction_update", {
message_id: 300,
channel_id: 7,
emoji: "thumbsup",
user_id: 99,
action: "add",
});
const messages = messagesStore.getState().messagesByChannel.get(7);
const msg = messages![0]!;
expect(msg.reactions).toHaveLength(1);
expect(msg.reactions[0]!.emoji).toBe("thumbsup");
expect(msg.reactions[0]!.count).toBe(1);
expect(msg.reactions[0]!.me).toBe(true);
});
it("decreases reaction count on remove and filters zero-count", () => {
// First add
ws.simulate("reaction_update", {
message_id: 300,
channel_id: 7,
emoji: "thumbsup",
user_id: 99,
action: "add",
});
// Then remove
ws.simulate("reaction_update", {
message_id: 300,
channel_id: 7,
emoji: "thumbsup",
user_id: 99,
action: "remove",
});
const messages = messagesStore.getState().messagesByChannel.get(7);
const msg = messages![0]!;
// Count drops to 0, so the reaction is filtered out
expect(msg.reactions).toHaveLength(0);
});
it("increments existing reaction count from another user", () => {
// First add from user 99 (me)
ws.simulate("reaction_update", {
message_id: 300,
channel_id: 7,
emoji: "heart",
user_id: 99,
action: "add",
});
// Second add from user 50 (someone else)
ws.simulate("reaction_update", {
message_id: 300,
channel_id: 7,
emoji: "heart",
user_id: 50,
action: "add",
});
const messages = messagesStore.getState().messagesByChannel.get(7);
const msg = messages![0]!;
expect(msg.reactions).toHaveLength(1);
expect(msg.reactions[0]!.count).toBe(2);
expect(msg.reactions[0]!.me).toBe(true); // still me
});
});
// ────────────────────────────────────────────────────────────────
// 5. Chat send confirmation
// ────────────────────────────────────────────────────────────────
describe("chat send confirmation", () => {
it("removes pending send on chat_send_ok with matching correlation ID", () => {
const correlationId = "corr-abc-123";
addPendingSend(correlationId, 1);
expect(messagesStore.getState().pendingSends.has(correlationId)).toBe(true);
ws.simulate(
"chat_send_ok",
{ message_id: 500, timestamp: "2026-03-15T13:00:00Z" },
correlationId,
);
expect(messagesStore.getState().pendingSends.has(correlationId)).toBe(false);
});
it("does not remove pending send when correlation ID is missing", () => {
const correlationId = "corr-xyz-789";
addPendingSend(correlationId, 1);
// Simulate without an id
ws.simulate(
"chat_send_ok",
{ message_id: 501, timestamp: "2026-03-15T13:01:00Z" },
);
// Pending send remains because no correlation ID was provided
expect(messagesStore.getState().pendingSends.has(correlationId)).toBe(true);
});
});
// ────────────────────────────────────────────────────────────────
// 6. Typing indicator
// ────────────────────────────────────────────────────────────────
describe("typing indicator", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("sets typing user in membersStore on typing event", () => {
ws.simulate("typing", {
channel_id: 1,
user_id: 42,
username: "typer",
});
const typing = membersStore.getState().typingUsers.get(1);
expect(typing).toBeDefined();
expect(typing!.has(42)).toBe(true);
});
it("clears typing user after 5 seconds", () => {
ws.simulate("typing", {
channel_id: 1,
user_id: 42,
username: "typer",
});
vi.advanceTimersByTime(5001);
const typing = membersStore.getState().typingUsers.get(1);
// Either the map entry is gone or the set is empty
const hasUser = typing?.has(42) ?? false;
expect(hasUser).toBe(false);
});
});
// ────────────────────────────────────────────────────────────────
// 7. Member ban
// ────────────────────────────────────────────────────────────────
describe("member ban", () => {
it("removes member from store on member_ban event", () => {
ws.simulate("ready", {
channels: [],
members: [
{ id: 10, username: "innocent", avatar: null, role: "member", status: "online" },
{ id: 20, username: "troublemaker", avatar: null, role: "member", status: "online" },
],
voice_states: [],
roles: [],
});
expect(membersStore.getState().members.has(20)).toBe(true);
ws.simulate("member_ban", { user_id: 20 });
expect(membersStore.getState().members.has(20)).toBe(false);
// Other members remain
expect(membersStore.getState().members.has(10)).toBe(true);
});
});
// ────────────────────────────────────────────────────────────────
// 8. Voice config and speakers
// ────────────────────────────────────────────────────────────────
describe("voice config and speakers", () => {
it("stores voice config from voice_config event", () => {
ws.simulate("voice_config", {
channel_id: 3,
quality: "high",
bitrate: 128000,
threshold_mode: "selective",
mixing_threshold: 5,
top_speakers: 3,
max_users: 25,
});
const config = voiceStore.getState().voiceConfigs.get(3);
expect(config).toBeDefined();
expect(config!.quality).toBe("high");
expect(config!.bitrate).toBe(128000);
expect(config!.threshold_mode).toBe("selective");
expect(config!.mixing_threshold).toBe(5);
expect(config!.top_speakers).toBe(3);
expect(config!.max_users).toBe(25);
});
it("updates speaking states from voice_speakers event", () => {
// First seed voice users in channel 3
ws.simulate("ready", {
channels: [],
members: [],
voice_states: [
{ channel_id: 3, user_id: 1, muted: false, deafened: false },
{ channel_id: 3, user_id: 2, muted: false, deafened: false },
{ channel_id: 3, user_id: 3, muted: false, deafened: false },
],
roles: [],
});
// User 1 and 3 are speaking
ws.simulate("voice_speakers", {
channel_id: 3,
speakers: [1, 3],
threshold_mode: "selective",
});
const channelUsers = voiceStore.getState().voiceUsers.get(3);
expect(channelUsers).toBeDefined();
expect(channelUsers!.get(1)?.speaking).toBe(true);
expect(channelUsers!.get(2)?.speaking).toBe(false);
expect(channelUsers!.get(3)?.speaking).toBe(true);
});
it("clears speaking when user is no longer in speakers list", () => {
// Seed voice users
ws.simulate("ready", {
channels: [],
members: [],
voice_states: [
{ channel_id: 3, user_id: 1, muted: false, deafened: false },
{ channel_id: 3, user_id: 2, muted: false, deafened: false },
],
roles: [],
});
// User 1 speaking
ws.simulate("voice_speakers", {
channel_id: 3,
speakers: [1],
threshold_mode: "forwarding",
});
expect(voiceStore.getState().voiceUsers.get(3)!.get(1)?.speaking).toBe(true);
// Now nobody speaking
ws.simulate("voice_speakers", {
channel_id: 3,
speakers: [],
threshold_mode: "forwarding",
});
expect(voiceStore.getState().voiceUsers.get(3)!.get(1)?.speaking).toBe(false);
expect(voiceStore.getState().voiceUsers.get(3)!.get(2)?.speaking).toBe(false);
});
});
});