mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user