diff --git a/Client/tauri-client/tests/helpers/fixtures.ts b/Client/tauri-client/tests/helpers/fixtures.ts new file mode 100644 index 00000000..aa00c2ff --- /dev/null +++ b/Client/tauri-client/tests/helpers/fixtures.ts @@ -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 { + 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 { + return { + id: 1, + username: "testuser", + avatar: null, + role: "member", + status: "online", + ...overrides, + }; +} + +/** Create a ReadyChannel with sensible defaults. */ +export function makeChannel( + overrides?: Partial, +): 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 { + return { + emoji: "👍", + count: 1, + me: false, + ...overrides, + }; +} + +/** Create a VoiceStatePayload with sensible defaults. */ +export function makeVoiceState( + overrides?: Partial, +): 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 { + return { + id: 1, + username: "testuser", + avatar: null, + ...overrides, + }; +} + +/** Create an Attachment with sensible defaults. */ +export function makeAttachment( + overrides?: Partial, +): 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 { + 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 { + return { + id: 1, + username: "testuser", + avatar: null, + role: "member", + status: "online", + ...overrides, + }; +} + +/** Create a ReadyVoiceState with sensible defaults. */ +export function makeReadyVoiceState( + overrides?: Partial, +): 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 { + 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 { + 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, + }; +} diff --git a/Client/tauri-client/tests/helpers/mock-ws.ts b/Client/tauri-client/tests/helpers/mock-ws.ts new file mode 100644 index 00000000..8de9d9fc --- /dev/null +++ b/Client/tauri-client/tests/helpers/mock-ws.ts @@ -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>>(); + 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( + type: T, + listener: WsListener, + ): () => void { + if (!listeners.has(type)) { + listeners.set(type, new Set()); + } + const set = listeners.get(type)!; + set.add(listener as unknown as WsListener); + return () => { + set.delete(listener as unknown as WsListener); + }; + }, + + 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( + type: T, + payload: Extract["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; diff --git a/Client/tauri-client/tests/helpers/test-utils.ts b/Client/tauri-client/tests/helpers/test-utils.ts new file mode 100644 index 00000000..7f6947cf --- /dev/null +++ b/Client/tauri-client/tests/helpers/test-utils.ts @@ -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( + store: Store, + predicate: (state: T) => boolean, + timeoutMs = 2000, +): Promise { + return new Promise((resolve, reject) => { + // Check immediately — predicate may already be true + const current = store.getState(); + if (predicate(current)) { + resolve(current); + return; + } + + let timer: ReturnType | 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); + }); +} diff --git a/Client/tauri-client/tests/integration/stores.test.ts b/Client/tauri-client/tests/integration/stores.test.ts new file mode 100644 index 00000000..6e88d5bf --- /dev/null +++ b/Client/tauri-client/tests/integration/stores.test.ts @@ -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>>(); + 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( + type: T, + listener: WsListener, + ): () => void { + if (!listeners.has(type)) { + listeners.set(type, new Set()); + } + const set = listeners.get(type)!; + const wrapped = listener as unknown as WsListener; + 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); + }); + }); +});