Files
OwnCord/Client/tauri-client/tests/unit/dispatcher.test.ts
T
J3vbandClaude Fable 5 ff55da7161 test(voice): pin voiceStatus transitions, widget indicators, and control freeze
- livekit-session: assert joining→securing→connected on join, idle on leave,
  connected after auto-reconnect
- voice-widget: assert each status label + secured badge visibility, and controls
  disabled with reason while the socket is down, re-enabled on reconnect
- voice-callbacks: assert join/leave/disconnect do not send over a down socket
- voice.store: assert join seeds joining, leave resets idle, setter writes status
- thread voiceStatus through existing full-state fixtures; keep an active-call
  socket live in widget fixtures so control-click tests still operate enabled

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 09:31:27 +02:00

1286 lines
37 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { wireDispatcher, wireConnectionStatus } from "../../src/lib/dispatcher";
import { createMockWsClient } from "../helpers/mock-ws";
import { authStore, clearAuth } from "../../src/stores/auth.store";
import { channelsStore } from "../../src/stores/channels.store";
import {
messagesStore,
addOptimisticMessage,
getChannelMessages,
} from "../../src/stores/messages.store";
import { membersStore } from "../../src/stores/members.store";
import { voiceStore } from "../../src/stores/voice.store";
import { dmStore } from "../../src/stores/dm.store";
import { uiStore } from "../../src/stores/ui.store";
import type { WsClient, WsListener } from "../../src/lib/ws";
import type { ServerMessage } from "../../src/lib/types";
// Mock notifications and livekitSession to avoid side effects
vi.mock("@lib/notifications", () => ({
notifyIncomingMessage: vi.fn(),
cleanupNotificationAudio: vi.fn(),
}));
vi.mock("@lib/livekitSession", () => ({
handleVoiceToken: vi.fn(async () => {}),
handleParticipantLeft: vi.fn(async () => {}),
handleE2EEAnnounce: vi.fn(async () => {}),
handleE2EEOffer: vi.fn(async () => {}),
leaveVoice: vi.fn(),
cleanupAll: vi.fn(),
isVoiceConnected: vi.fn(() => false),
}));
import { isVoiceConnected as _isVoiceConnected } from "../../src/lib/livekitSession";
const mockIsVoiceConnected = vi.mocked(_isVoiceConnected);
// Suppress console output
vi.spyOn(console, "info").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
/**
* Create a mock WsClient that stores listener registrations
* and provides a `dispatch` helper to fire events.
*/
function createMockWs() {
const listeners = new Map<string, Set<WsListener<ServerMessage["type"]>>>();
const sendFailureListeners = new Set<(id: string, code: string) => void>();
const ws: WsClient = {
connect: vi.fn(),
disconnect: vi.fn(),
send: vi.fn(() => "test-id"),
on<T extends ServerMessage["type"]>(type: T, listener: WsListener<T>): () => void {
if (!listeners.has(type)) {
listeners.set(type, new Set());
}
listeners.get(type)!.add(listener as unknown as WsListener<ServerMessage["type"]>);
return () => {
listeners.get(type)?.delete(listener as unknown as WsListener<ServerMessage["type"]>);
};
},
onStateChange: vi.fn(() => () => {}),
onSendFailure(listener: (id: string, code: string) => void): () => void {
sendFailureListeners.add(listener);
return () => sendFailureListeners.delete(listener);
},
onCertFirstTrust: vi.fn(() => () => {}),
onCertMismatch: vi.fn(() => () => {}),
acceptCertFingerprint: vi.fn(async () => {}),
getState: vi.fn(() => "disconnected" as const),
isReplaying: vi.fn(() => false),
_getWs: vi.fn(() => null),
};
function dispatch(type: string, payload: unknown, id?: string): void {
const set = listeners.get(type);
if (set) {
for (const listener of set) {
(listener as (p: unknown, id?: string) => void)(payload, id);
}
}
}
function dispatchSendFailure(id: string, code: string): void {
for (const listener of sendFailureListeners) {
listener(id, code);
}
}
return { ws, dispatch, dispatchSendFailure, listeners };
}
describe("WS Dispatcher", () => {
let cleanup: () => void;
let mock: ReturnType<typeof createMockWs>;
beforeEach(() => {
vi.useFakeTimers();
// Reset all stores to initial state
authStore.setState(() => ({
token: "test-token",
user: null,
serverName: null,
motd: null,
isAuthenticated: false,
}));
channelsStore.setState(() => ({
channels: new Map(),
activeChannelId: null,
roles: [],
}));
messagesStore.setState(() => ({
messagesByChannel: new Map(),
pendingSends: new Map(),
loadedChannels: new Set(),
hasMore: new Map(),
historyLoadState: new Map(),
}));
membersStore.setState(() => ({
members: new Map(),
typingUsers: new Map(),
}));
voiceStore.setState(() => ({
currentChannelId: null,
voiceUsers: new Map(),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
dmStore.setState(() => ({ channels: [] }));
uiStore.setState((prev) => ({ ...prev, transientError: null }));
mock = createMockWs();
cleanup = wireDispatcher(mock.ws);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("wires auth_ok to auth store", () => {
mock.dispatch("auth_ok", {
user: { id: 1, username: "alex", avatar: null, role: "admin" },
server_name: "TestServer",
motd: "Welcome!",
});
const state = authStore.getState();
expect(state.isAuthenticated).toBe(true);
expect(state.user?.username).toBe("alex");
expect(state.serverName).toBe("TestServer");
});
it("wires auth_error to clear auth", () => {
mock.dispatch("auth_error", { message: "Invalid token" });
expect(authStore.getState().isAuthenticated).toBe(false);
});
it("wires ready to channels, members, and voice stores", () => {
mock.dispatch("ready", {
channels: [
{ id: 1, name: "general", type: "text", category: null, position: 0 },
{ id: 2, name: "voice", type: "voice", category: null, position: 1 },
],
members: [{ id: 1, username: "alex", avatar: null, role: "admin", status: "online" }],
voice_states: [{ channel_id: 2, user_id: 1, muted: false, deafened: false }],
roles: [],
});
expect(channelsStore.getState().channels.size).toBe(2);
expect(membersStore.getState().members.size).toBe(1);
expect(voiceStore.getState().voiceUsers.size).toBe(1);
});
it("wires chat_message to messages store", () => {
mock.dispatch("chat_message", {
id: 100,
channel_id: 1,
user: { id: 1, username: "alex", avatar: null },
content: "Hello world",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
});
const msgs = messagesStore.getState().messagesByChannel.get(1);
expect(msgs).toHaveLength(1);
expect(msgs![0]!.content).toBe("Hello world");
});
it("wires chat_message to increment unread for non-active channel", () => {
// Set up a channel first
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(5, {
id: 5,
name: "off-topic",
type: "text" as const,
category: null,
position: 0,
unreadCount: 0,
lastMessageId: null,
canSend: true,
});
return { ...prev, channels: ch, activeChannelId: 1 }; // active is channel 1
});
mock.dispatch("chat_message", {
id: 200,
channel_id: 5, // different from active
user: { id: 2, username: "bob", avatar: null },
content: "ping",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
});
const ch = channelsStore.getState().channels.get(5);
expect(ch?.unreadCount).toBe(1);
});
it("wires presence to members store", () => {
// Add a member first
membersStore.setState((prev) => {
const m = new Map(prev.members);
m.set(1, { id: 1, username: "alex", avatar: null, role: "admin", status: "online" as const });
return { ...prev, members: m };
});
mock.dispatch("presence", { user_id: 1, status: "idle" });
expect(membersStore.getState().members.get(1)?.status).toBe("idle");
});
it("wires typing to members store", () => {
mock.dispatch("typing", { channel_id: 1, user_id: 42, username: "bob" });
const typing = membersStore.getState().typingUsers.get(1);
expect(typing?.has(42)).toBe(true);
});
it("wires channel_create to channels store", () => {
mock.dispatch("channel_create", {
id: 10,
name: "new-channel",
type: "text",
category: "General",
position: 5,
});
expect(channelsStore.getState().channels.has(10)).toBe(true);
});
it("wires channel_delete to channels store", () => {
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(10, {
id: 10,
name: "doomed",
type: "text" as const,
category: null,
position: 0,
unreadCount: 0,
lastMessageId: null,
canSend: true,
});
return { ...prev, channels: ch };
});
mock.dispatch("channel_delete", { id: 10 });
expect(channelsStore.getState().channels.has(10)).toBe(false);
});
it("wires member_join to members store", () => {
mock.dispatch("member_join", {
user: { id: 99, username: "newuser", avatar: null, role: "member" },
});
expect(membersStore.getState().members.has(99)).toBe(true);
});
it("wires chat_send_ok to confirmSend in messages store", () => {
// Add a pending send (correlationId -> channelId)
messagesStore.setState((prev) => {
const pending = new Map(prev.pendingSends);
pending.set("corr-123", 1);
return { ...prev, pendingSends: pending };
});
expect(messagesStore.getState().pendingSends.has("corr-123")).toBe(true);
mock.dispatch(
"chat_send_ok",
{ message_id: 500, timestamp: "2026-03-15T10:00:00Z" },
"corr-123",
);
expect(messagesStore.getState().pendingSends.has("corr-123")).toBe(false);
});
it("wires member_ban to remove member from members store", () => {
membersStore.setState((prev) => {
const m = new Map(prev.members);
m.set(77, {
id: 77,
username: "banned-user",
avatar: null,
role: "member",
status: "online" as const,
});
return { ...prev, members: m };
});
mock.dispatch("member_ban", { user_id: 77 });
expect(membersStore.getState().members.has(77)).toBe(false);
});
it("wires member_leave to members store", () => {
membersStore.setState((prev) => {
const m = new Map(prev.members);
m.set(99, {
id: 99,
username: "bye",
avatar: null,
role: "member",
status: "online" as const,
});
return { ...prev, members: m };
});
mock.dispatch("member_leave", { user_id: 99 });
expect(membersStore.getState().members.has(99)).toBe(false);
});
it("wires voice_state to voice store", () => {
mock.dispatch("voice_state", {
channel_id: 2,
user_id: 1,
username: "alex",
muted: true,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
const users = voiceStore.getState().voiceUsers.get(2);
expect(users?.get(1)?.muted).toBe(true);
});
it("wires ready with DM channels in payload", () => {
mock.dispatch("ready", {
channels: [{ id: 1, name: "general", type: "text", category: null, position: 0 }],
members: [],
voice_states: [],
roles: [{ id: 1, name: "admin", permissions: 0x7fffffff }],
dm_channels: [
{
channel_id: 100,
recipient: { id: 10, username: "bob", avatar: "", status: "online" },
last_message_id: 5,
last_message: "hello",
last_message_at: "2026-03-15T10:00:00Z",
unread_count: 2,
},
],
});
const dms = dmStore.getState().channels;
expect(dms).toHaveLength(1);
expect(dms[0]!.channelId).toBe(100);
expect(dms[0]!.recipient.username).toBe("bob");
expect(dms[0]!.unreadCount).toBe(2);
});
it("ready auto-selects first text channel when no active channel", () => {
mock.dispatch("ready", {
channels: [
{ id: 5, name: "voice-only", type: "voice", category: null, position: 0 },
{ id: 7, name: "general", type: "text", category: null, position: 1 },
],
members: [],
voice_states: [],
roles: [],
});
expect(channelsStore.getState().activeChannelId).toBe(7);
});
it("ready does NOT change active channel when one is already set", () => {
channelsStore.setState((prev) => ({
...prev,
activeChannelId: 99,
}));
mock.dispatch("ready", {
channels: [{ id: 1, name: "general", type: "text", category: null, position: 0 }],
members: [],
voice_states: [],
roles: [],
});
expect(channelsStore.getState().activeChannelId).toBe(99);
});
it("ready with no text channels does not set active", () => {
mock.dispatch("ready", {
channels: [{ id: 5, name: "voice-only", type: "voice", category: null, position: 0 }],
members: [],
voice_states: [],
roles: [],
});
expect(channelsStore.getState().activeChannelId).toBeNull();
});
it("ready with no DM channels in payload skips setDmChannels", () => {
mock.dispatch("ready", {
channels: [],
members: [],
voice_states: [],
roles: [],
});
expect(dmStore.getState().channels).toHaveLength(0);
});
it("wires chat_edited to messages store", () => {
// First add a message
mock.dispatch("chat_message", {
id: 100,
channel_id: 1,
user: { id: 1, username: "alex", avatar: null },
content: "original",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
});
mock.dispatch("chat_edited", {
message_id: 100,
channel_id: 1,
content: "edited content",
edited_at: "2026-03-15T10:01:00Z",
});
const msgs = messagesStore.getState().messagesByChannel.get(1);
expect(msgs).toBeDefined();
const edited = msgs!.find((m) => m.id === 100);
expect(edited?.content).toBe("edited content");
});
it("wires chat_deleted to messages store", () => {
mock.dispatch("chat_message", {
id: 100,
channel_id: 1,
user: { id: 1, username: "alex", avatar: null },
content: "doomed",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
});
mock.dispatch("chat_deleted", { message_id: 100, channel_id: 1 });
const msgs = messagesStore.getState().messagesByChannel.get(1);
const found = msgs?.find((m) => m.id === 100);
expect(found?.deleted).toBe(true);
});
it("wires chat_send_ok without id does not crash", () => {
expect(() => {
mock.dispatch("chat_send_ok", { message_id: 500, timestamp: "2026-03-15T10:00:00Z" });
}).not.toThrow();
});
it("wires reaction_update to messages store", () => {
// Seed current user
authStore.setState((prev) => ({
...prev,
user: { id: 1, username: "alex", avatar: null, role: "admin" },
}));
mock.dispatch("chat_message", {
id: 200,
channel_id: 1,
user: { id: 2, username: "bob", avatar: null },
content: "react to me",
reply_to: null,
attachments: [],
reactions: [],
timestamp: "2026-03-15T10:00:00Z",
});
mock.dispatch("reaction_update", {
message_id: 200,
channel_id: 1,
emoji: "thumbsup",
user_ids: [1, 2],
count: 2,
});
// Verify it doesn't crash (the actual reaction update is in messages store)
const msgs = messagesStore.getState().messagesByChannel.get(1);
expect(msgs).toBeDefined();
});
it("wires channel_update to channels store", () => {
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(10, {
id: 10,
name: "old-name",
type: "text" as const,
category: null,
position: 0,
unreadCount: 0,
lastMessageId: null,
canSend: true,
});
return { ...prev, channels: ch };
});
mock.dispatch("channel_update", {
id: 10,
name: "new-name",
type: "text",
category: "General",
position: 3,
});
const ch = channelsStore.getState().channels.get(10);
expect(ch?.name).toBe("new-name");
});
it("wires channel_delete and redirects to first text channel when active is deleted", () => {
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(10, {
id: 10,
name: "active-ch",
type: "text" as const,
category: null,
position: 0,
unreadCount: 0,
lastMessageId: null,
canSend: true,
});
ch.set(20, {
id: 20,
name: "fallback",
type: "text" as const,
category: null,
position: 1,
unreadCount: 0,
lastMessageId: null,
canSend: true,
});
return { ...prev, channels: ch, activeChannelId: 10 };
});
mock.dispatch("channel_delete", { id: 10 });
expect(channelsStore.getState().channels.has(10)).toBe(false);
expect(channelsStore.getState().activeChannelId).toBe(20);
});
it("wires channel_delete sets active to null when no text channels remain", () => {
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(10, {
id: 10,
name: "only-ch",
type: "text" as const,
category: null,
position: 0,
unreadCount: 0,
lastMessageId: null,
canSend: true,
});
return { ...prev, channels: ch, activeChannelId: 10 };
});
mock.dispatch("channel_delete", { id: 10 });
expect(channelsStore.getState().activeChannelId).toBeNull();
});
it("wires member_update to update role", () => {
membersStore.setState((prev) => {
const m = new Map(prev.members);
m.set(42, {
id: 42,
username: "alice",
avatar: null,
role: "member",
status: "online" as const,
});
return { ...prev, members: m };
});
mock.dispatch("member_update", { user_id: 42, role: "admin" });
expect(membersStore.getState().members.get(42)?.role).toBe("admin");
});
it("wires voice_state and auto-joins if current user", () => {
authStore.setState((prev) => ({
...prev,
user: { id: 5, username: "me", avatar: null, role: "member" },
}));
mock.dispatch("voice_state", {
channel_id: 3,
user_id: 5,
username: "me",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
expect(voiceStore.getState().currentChannelId).toBe(3);
});
it("wires voice_state does NOT auto-join for other users", () => {
authStore.setState((prev) => ({
...prev,
user: { id: 5, username: "me", avatar: null, role: "member" },
}));
mock.dispatch("voice_state", {
channel_id: 3,
user_id: 99,
username: "other",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
expect(voiceStore.getState().currentChannelId).toBeNull();
});
it("wires voice_leave and clears local voice state if current user kicked", () => {
authStore.setState((prev) => ({
...prev,
user: { id: 5, username: "me", avatar: null, role: "member" },
}));
// First join voice
voiceStore.setState((prev) => ({
...prev,
currentChannelId: 3,
}));
mock.dispatch("voice_leave", {
channel_id: 3,
user_id: 5,
});
expect(voiceStore.getState().currentChannelId).toBeNull();
});
it("wires voice_leave does NOT clear local state for other users", () => {
authStore.setState((prev) => ({
...prev,
user: { id: 5, username: "me", avatar: null, role: "member" },
}));
voiceStore.setState((prev) => ({
...prev,
currentChannelId: 3,
}));
mock.dispatch("voice_leave", {
channel_id: 3,
user_id: 99,
});
expect(voiceStore.getState().currentChannelId).toBe(3);
});
it("wires voice_config to voice store", () => {
mock.dispatch("voice_config", {
channel_id: 3,
max_bitrate: 128000,
});
const configs = voiceStore.getState().voiceConfigs;
expect(configs.get(3)).toBeDefined();
});
it("wires voice_speakers to voice store", () => {
mock.dispatch("voice_speakers", {
channel_id: 3,
speakers: [1, 2, 3],
});
// Verify it runs without error
expect(true).toBe(true);
});
it("wires voice_token to handleVoiceToken", async () => {
const { handleVoiceToken } = await import("@lib/livekitSession");
mock.dispatch("voice_token", {
token: "lk-token",
url: "wss://livekit.example.com",
channel_id: 3,
direct_url: "wss://direct.example.com",
});
expect(handleVoiceToken).toHaveBeenCalledWith(
"lk-token",
"wss://livekit.example.com",
3,
"wss://direct.example.com",
undefined,
);
});
it("wires server_restart to transient error", () => {
mock.dispatch("server_restart", {
reason: "update",
delay_seconds: 10,
});
const error = uiStore.getState().transientError;
expect(error).toContain("Server is restarting");
expect(error).toContain("update");
});
it("wires server_restart with null reason to maintenance", () => {
mock.dispatch("server_restart", {
reason: null,
delay_seconds: 5,
});
const error = uiStore.getState().transientError;
expect(error).toContain("maintenance");
});
it("wires error BANNED to clear auth and show error", () => {
authStore.setState((prev) => ({
...prev,
isAuthenticated: true,
user: { id: 1, username: "banned-user", avatar: null, role: "member" },
}));
mock.dispatch("error", {
code: "BANNED",
message: "You have been banned from this server",
});
expect(authStore.getState().isAuthenticated).toBe(false);
const error = uiStore.getState().transientError;
expect(error).toContain("banned");
});
it("wires error BANNED with empty message uses default", () => {
mock.dispatch("error", { code: "BANNED", message: "" });
const error = uiStore.getState().transientError;
expect(error).toBe("You have been banned");
});
it("wires error RATE_LIMITED to transient error", () => {
mock.dispatch("error", {
code: "RATE_LIMITED",
message: "Too many requests",
});
const error = uiStore.getState().transientError;
expect(error).toBe("Too many requests");
});
it("wires error FORBIDDEN to transient error", () => {
mock.dispatch("error", {
code: "FORBIDDEN",
message: "Insufficient permissions",
});
const error = uiStore.getState().transientError;
expect(error).toBe("Insufficient permissions");
});
it("wires error RATE_LIMITED with empty message uses default", () => {
mock.dispatch("error", { code: "RATE_LIMITED", message: "" });
const error = uiStore.getState().transientError;
expect(error).toBe("Server error");
});
it("wires error with unknown code does not set transient error", () => {
// Clear any previous errors
uiStore.setState((prev) => ({ ...prev, transientError: null }));
mock.dispatch("error", {
code: "UNKNOWN",
message: "Something odd",
});
expect(uiStore.getState().transientError).toBeNull();
});
it("wires an error carrying a pending send id to mark that row failed (not a toast)", () => {
messagesStore.setState(() => ({
messagesByChannel: new Map(),
pendingSends: new Map(),
loadedChannels: new Set(),
hasMore: new Map(),
historyLoadState: new Map(),
}));
uiStore.setState((prev) => ({ ...prev, transientError: null }));
addOptimisticMessage({
correlationId: "corr-1",
channelId: 7,
user: { id: 1, username: "alex", avatar: null },
content: "hi",
replyTo: null,
timestamp: "2026-03-15T10:00:00Z",
});
// Error echoes the request id → the specific row is marked failed…
mock.dispatch("error", { code: "SLOW_MODE", message: "slow down" }, "corr-1");
const row = getChannelMessages(7)[0]!;
expect(row.status).toBe("failed");
expect(row.errorCode).toBe("SLOW_MODE");
// …and it is not surfaced as a global transient error.
expect(uiStore.getState().transientError).toBeNull();
});
it("wires a local transport send failure to mark the pending row failed", () => {
uiStore.setState((prev) => ({ ...prev, transientError: null }));
addOptimisticMessage({
correlationId: "corr-2",
channelId: 7,
user: { id: 1, username: "alex", avatar: null },
content: "hi",
replyTo: null,
timestamp: "2026-03-15T10:00:00Z",
});
// ws_send rejected locally (outbound channel full) → the row fails with retry…
mock.dispatchSendFailure("corr-2", "NETWORK");
const row = getChannelMessages(7)[0]!;
expect(row.status).toBe("failed");
expect(row.errorCode).toBe("NETWORK");
// …and no global transient error is raised.
expect(uiStore.getState().transientError).toBeNull();
});
it("ignores a transport send failure for an id with no pending send (fire-and-forget)", () => {
const before = messagesStore.getState();
mock.dispatchSendFailure("typing-id", "NETWORK");
// No store mutation: fire-and-forget sends (typing, presence…) stay silent.
expect(messagesStore.getState()).toBe(before);
});
it("does not increment unread for own messages", () => {
authStore.setState((prev) => ({
...prev,
user: { id: 1, username: "alex", avatar: null, role: "admin" },
}));
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(5, {
id: 5,
name: "other-ch",
type: "text" as const,
category: null,
position: 0,
unreadCount: 0,
lastMessageId: null,
canSend: true,
});
return { ...prev, channels: ch, activeChannelId: 1 };
});
mock.dispatch("chat_message", {
id: 300,
channel_id: 5,
user: { id: 1, username: "alex", avatar: null },
content: "my own message",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
});
expect(channelsStore.getState().channels.get(5)?.unreadCount).toBe(0);
});
it("does not increment unread during replay", () => {
(mock.ws.isReplaying as ReturnType<typeof vi.fn>).mockReturnValue(true);
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(5, {
id: 5,
name: "other-ch",
type: "text" as const,
category: null,
position: 0,
unreadCount: 0,
lastMessageId: null,
canSend: true,
});
return { ...prev, channels: ch, activeChannelId: 1 };
});
mock.dispatch("chat_message", {
id: 300,
channel_id: 5,
user: { id: 2, username: "bob", avatar: null },
content: "replayed message",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
});
expect(channelsStore.getState().channels.get(5)?.unreadCount).toBe(0);
(mock.ws.isReplaying as ReturnType<typeof vi.fn>).mockReturnValue(false);
});
describe("chat_message DM store updates", () => {
const dmChannel = {
channelId: 50,
recipient: { id: 10, username: "bob", avatar: "", status: "online" as const },
lastMessageId: null,
lastMessage: "",
lastMessageAt: "",
unreadCount: 0,
};
beforeEach(() => {
dmStore.setState(() => ({ channels: [{ ...dmChannel }] }));
});
it("updates DM last message with unread for non-active, non-own message", () => {
channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 }));
authStore.setState((prev) => ({
...prev,
user: { id: 5, username: "me", avatar: null, role: "member" },
}));
mock.dispatch("chat_message", {
id: 500,
channel_id: 50,
user: { id: 10, username: "bob", avatar: "" },
content: "hey there",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
});
const dms = dmStore.getState().channels;
const dm = dms.find((c) => c.channelId === 50);
expect(dm?.lastMessage).toBe("hey there");
expect(dm?.unreadCount).toBe(1);
});
it("updates DM preview (no unread) for own message", () => {
channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 }));
authStore.setState((prev) => ({
...prev,
user: { id: 5, username: "me", avatar: null, role: "member" },
}));
mock.dispatch("chat_message", {
id: 501,
channel_id: 50,
user: { id: 5, username: "me", avatar: null },
content: "my DM reply",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
});
const dms = dmStore.getState().channels;
const dm = dms.find((c) => c.channelId === 50);
expect(dm?.lastMessage).toBe("my DM reply");
expect(dm?.unreadCount).toBe(0);
});
it("updates DM preview (no unread) when DM channel is active", () => {
channelsStore.setState((prev) => ({ ...prev, activeChannelId: 50 }));
authStore.setState((prev) => ({
...prev,
user: { id: 5, username: "me", avatar: null, role: "member" },
}));
mock.dispatch("chat_message", {
id: 502,
channel_id: 50,
user: { id: 10, username: "bob", avatar: "" },
content: "active DM msg",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
});
const dms = dmStore.getState().channels;
const dm = dms.find((c) => c.channelId === 50);
expect(dm?.lastMessage).toBe("active DM msg");
expect(dm?.unreadCount).toBe(0);
});
it("updates DM preview (no unread) during replay", () => {
(mock.ws.isReplaying as ReturnType<typeof vi.fn>).mockReturnValue(true);
channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 }));
authStore.setState((prev) => ({
...prev,
user: { id: 5, username: "me", avatar: null, role: "member" },
}));
mock.dispatch("chat_message", {
id: 503,
channel_id: 50,
user: { id: 10, username: "bob", avatar: "" },
content: "replayed DM",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
});
const dms = dmStore.getState().channels;
const dm = dms.find((c) => c.channelId === 50);
expect(dm?.lastMessage).toBe("replayed DM");
expect(dm?.unreadCount).toBe(0);
(mock.ws.isReplaying as ReturnType<typeof vi.fn>).mockReturnValue(false);
});
});
// ── DM events ─────────────────────────────────────────
describe("DM events", () => {
it("should call addDmChannel on dm_channel_open", () => {
mock.dispatch("dm_channel_open", {
channel_id: 50,
recipient: { id: 10, username: "bob", avatar: "", status: "online" },
last_message_id: null,
last_message: "",
last_message_at: "",
unread_count: 0,
});
const channels = dmStore.getState().channels;
expect(channels).toHaveLength(1);
expect(channels[0]!.channelId).toBe(50);
expect(channels[0]!.recipient.username).toBe("bob");
});
it("should call removeDmChannel on dm_channel_close", () => {
// Seed a DM channel first
dmStore.setState(() => ({
channels: [
{
channelId: 50,
recipient: { id: 10, username: "bob", avatar: "", status: "online" },
lastMessageId: null,
lastMessage: "",
lastMessageAt: "",
unreadCount: 0,
},
],
}));
mock.dispatch("dm_channel_close", { channel_id: 50 });
expect(dmStore.getState().channels).toHaveLength(0);
});
});
it("auth_ok with null token uses empty string fallback", () => {
authStore.setState(() => ({
token: null,
user: null,
serverName: null,
motd: null,
isAuthenticated: false,
}));
mock.dispatch("auth_ok", {
user: { id: 1, username: "alex", avatar: null, role: "admin" },
server_name: "TestServer",
motd: "Welcome!",
});
expect(authStore.getState().isAuthenticated).toBe(true);
});
it("ready with undefined roles uses empty array fallback", () => {
mock.dispatch("ready", {
channels: [],
members: [],
voice_states: [],
// roles is intentionally undefined
});
// Should not crash, roles should be empty
expect(channelsStore.getState().roles).toEqual([]);
});
it("reaction_update with no user in auth uses 0 as fallback", () => {
authStore.setState(() => ({
token: "t",
user: null,
serverName: null,
motd: null,
isAuthenticated: false,
}));
// Just dispatch without crashing
expect(() => {
mock.dispatch("reaction_update", {
message_id: 200,
channel_id: 1,
emoji: "thumbsup",
user_ids: [1],
count: 1,
});
}).not.toThrow();
});
it("voice_leave with no user in auth uses 0 as fallback userId", () => {
authStore.setState(() => ({
token: "t",
user: null,
serverName: null,
motd: null,
isAuthenticated: false,
}));
expect(() => {
mock.dispatch("voice_leave", {
channel_id: 3,
user_id: 99,
});
}).not.toThrow();
});
it("ready sends voice_leave when user appears in voice_states but LiveKit is disconnected", () => {
mockIsVoiceConnected.mockReturnValue(false);
// Set up auth so the current user ID is 42
authStore.setState(() => ({
token: "test-token",
user: { id: 42, username: "ghost", avatar: null, role: "member" },
serverName: "Test",
motd: "",
isAuthenticated: true,
}));
mock.dispatch("ready", {
channels: [{ id: 1, name: "general", type: "text", category: "", position: 0 }],
members: [],
voice_states: [{ user_id: 42, channel_id: 10, muted: false, deafened: false }],
roles: [],
dm_channels: [],
});
// The dispatcher should detect stale voice state and send voice_leave
expect(mock.ws.send).toHaveBeenCalledWith(
expect.objectContaining({ type: "voice_leave", payload: {} }),
);
// And clear the local voice channel
expect(voiceStore.getState().currentChannelId).toBeNull();
});
it("ready does NOT send voice_leave when LiveKit IS connected", () => {
mockIsVoiceConnected.mockReturnValue(true);
authStore.setState(() => ({
token: "test-token",
user: { id: 42, username: "active", avatar: null, role: "member" },
serverName: "Test",
motd: "",
isAuthenticated: true,
}));
mock.dispatch("ready", {
channels: [{ id: 1, name: "general", type: "text", category: "", position: 0 }],
members: [],
voice_states: [{ user_id: 42, channel_id: 10, muted: false, deafened: false }],
roles: [],
dm_channels: [],
});
// voice_leave should NOT be sent — the LiveKit room is active
const sendCalls = (mock.ws.send as ReturnType<typeof vi.fn>).mock.calls;
const voiceLeaveSent = sendCalls.some(
(args: unknown[]) => (args[0] as Record<string, unknown>)?.type === "voice_leave",
);
expect(voiceLeaveSent).toBe(false);
});
it("ready does NOT send voice_leave when user is NOT in voice_states", () => {
mockIsVoiceConnected.mockReturnValue(false);
authStore.setState(() => ({
token: "test-token",
user: { id: 42, username: "notinvoice", avatar: null, role: "member" },
serverName: "Test",
motd: "",
isAuthenticated: true,
}));
mock.dispatch("ready", {
channels: [{ id: 1, name: "general", type: "text", category: "", position: 0 }],
members: [],
voice_states: [{ user_id: 99, channel_id: 10, muted: false, deafened: false }],
roles: [],
dm_channels: [],
});
// voice_leave should NOT be sent — user 42 is not in voice_states
const sendCalls = (mock.ws.send as ReturnType<typeof vi.fn>).mock.calls;
const voiceLeaveSent = sendCalls.some(
(args: unknown[]) => (args[0] as Record<string, unknown>)?.type === "voice_leave",
);
expect(voiceLeaveSent).toBe(false);
});
it("unknown event type does not throw", () => {
expect(() => {
mock.dispatch("totally_unknown_server_event", { some: "data" });
}).not.toThrow();
});
it("cleanup removes all listeners", () => {
cleanup();
// After cleanup, dispatching should not affect stores
mock.dispatch("chat_message", {
id: 999,
channel_id: 1,
user: { id: 1, username: "ghost", avatar: null },
content: "should not appear",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T12:00:00Z",
});
expect(messagesStore.getState().messagesByChannel.get(1)).toBeUndefined();
});
});
describe("wireConnectionStatus", () => {
beforeEach(() => {
uiStore.setState((prev) => ({ ...prev, connectionStatus: "disconnected" }));
});
it("writes ws state changes into ui.store.connectionStatus via the 5→3 mapping", () => {
const mockWs = createMockWsClient();
const unsub = wireConnectionStatus(mockWs);
mockWs.simulateStateChange("connecting");
expect(uiStore.getState().connectionStatus).toBe("reconnecting");
mockWs.simulateStateChange("authenticating");
expect(uiStore.getState().connectionStatus).toBe("reconnecting");
mockWs.simulateStateChange("connected");
expect(uiStore.getState().connectionStatus).toBe("connected");
mockWs.simulateStateChange("disconnected");
expect(uiStore.getState().connectionStatus).toBe("disconnected");
unsub();
mockWs.simulateStateChange("connected");
expect(uiStore.getState().connectionStatus).toBe("disconnected");
});
});