Files
OwnCord/Client/tauri-client/tests/unit/channel-sidebar.test.ts
T
J3vbandClaude Fable 5 d880b64d64 test: audit 2026-08-19 — fix stale tests, close coverage gaps (#1397)
* test(server): admin/handlers/channels — test-audit 2026-08-19 fixes

* test(server): api/constants — test-audit 2026-08-19 fixes

* test(server): api/middleware — test-audit 2026-08-19 fixes

* test(server): api/waf — test-audit 2026-08-19 fixes

* test(server): auth/totp/encrypt — test-audit 2026-08-19 fixes

* test(server): db/session/expiry/test — test-audit 2026-08-19 fixes

* test(server): migrations/030/attachments/unlink/on/message/delete — test-audit 2026-08-19 fixes

* test(server): updater/download — test-audit 2026-08-19 fixes

* test(server): ws/handlers_command — test-audit 2026-08-19 fixes

* test(server): ws/hub/broadcast — test-audit 2026-08-19 fixes

* test(server): ws/hub/events — test-audit 2026-08-19 fixes

* test(server): ws/livekit/webhook — test-audit 2026-08-19 fixes

* test(server): ws/voice/controls — test-audit 2026-08-19 fixes

* test(server): ws/voice/join — test-audit 2026-08-19 fixes

* test(server): ws/voice/moderation — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/commands.rs — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/secret_store.rs — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/update_commands.rs — test-audit 2026-08-19 fixes

* test(client): src/components/ChannelSidebar.ts — test-audit 2026-08-19 fixes

* test(client): src/lib/ws.ts — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/credentials.rs — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/tofu.rs — test-audit 2026-08-19 fixes

* test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 fixes

* test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 fixes

* test(client): src/pages/connect-page/LoginForm.ts — test-audit 2026-08-19 fixes

* test(client): src/pages/main-page/SidebarArea.ts — test-audit 2026-08-19 fixes

* test(client): src/stores/voice.store.ts — test-audit 2026-08-19 fixes

* test(client): tests/browser/smoke.test.ts — test-audit 2026-08-19 fixes

* test(client): tests/unit/media.test.ts — test-audit 2026-08-19 fixes

* test(client): tests/unit/renderers.test.ts — test-audit 2026-08-19 fixes

* test(client): src/components/UserProfilePopup.ts — test-audit 2026-08-19 fixes

* test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 fixes

* test(client): tests/unit/log-persistence.test.ts — test-audit 2026-08-19 fixes

* test(client): keep tests/browser out of the jsdom suite and run it in CI

* test(server): ws/hub_broadcast_test.go — bytes.Equal payload compare (gocritic)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(client): src/lib/credentials.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/dispatcher.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/permissions.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/stores/messages.store.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/ws.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/identity.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/livekitE2EE.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/stores/auth.store.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/stores/voice.store.ts — test-audit 2026-08-19 round 2 (Stryker)

* docs: test audit 2026-08-19 — findings, fixes, measured baselines

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(graph): refresh the knowledge graph after the 2026-08-19 test audit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 19:29:31 +02:00

2429 lines
78 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// Mock livekitSession (required by streamPreview). rePinPeerIdentity is hoisted
// so the mock factory can reference it and tests can assert re-pin was invoked.
const { mockRePinPeerIdentity } = vi.hoisted(() => ({
mockRePinPeerIdentity: vi.fn(() => Promise.resolve(true)),
}));
vi.mock("@lib/livekitSession", () => ({
setUserVolume: vi.fn(),
getUserVolume: vi.fn(() => 1),
getRemoteVideoStream: vi.fn(() => null),
rePinPeerIdentity: mockRePinPeerIdentity,
}));
// Mock streamPreview to isolate sidebar tests from preview DOM logic
const mockAttachStreamPreview = vi.fn();
const mockAttachScrollCollapse = vi.fn();
vi.mock("@lib/streamPreview", () => ({
attachStreamPreview: (...args: unknown[]) => mockAttachStreamPreview(...args),
attachScrollCollapse: (...args: unknown[]) => mockAttachScrollCollapse(...args),
}));
// Stub the identity-key crypto so the mismatch modal's fingerprint compute is
// deterministic in jsdom (real WebCrypto key import needs a valid SPKI blob).
vi.mock("@lib/e2eeCrypto", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../src/lib/e2eeCrypto")>();
return {
...actual,
importIdentityPublicKey: vi.fn(() => Promise.resolve({} as CryptoKey)),
computeKeyFingerprint: vi.fn(() => Promise.resolve("FEED FACE 1234 5678")),
};
});
import { createChannelSidebar } from "../../src/components/ChannelSidebar";
import {
channelsStore,
setChannels,
setActiveChannel,
setRoles,
type Channel,
} from "../../src/stores/channels.store";
import { authStore } from "../../src/stores/auth.store";
import { uiStore, toggleCategory } from "../../src/stores/ui.store";
import { voiceStore, updateVoiceState } from "../../src/stores/voice.store";
import type { PeerVerification } from "../../src/stores/voice.store";
import { membersStore } from "../../src/stores/members.store";
import { Permission, type ReadyChannel, type VoiceStatePayload } from "../../src/lib/types";
import { computeKeyFingerprint } from "@lib/e2eeCrypto";
function resetStores(): void {
channelsStore.setState(() => ({
channels: new Map(),
activeChannelId: null,
roles: [],
}));
authStore.setState(() => ({
token: null,
user: null,
serverName: "Test Server",
motd: null,
isAuthenticated: false,
}));
uiStore.setState(() => ({
sidebarCollapsed: false,
memberListVisible: true,
settingsOpen: false,
activeModal: null,
theme: "dark" as const,
// Default to a live socket: the voice join/leave affordance is only usable
// when connected. Frozen-state behavior is exercised explicitly below.
connectionStatus: "connected" as const,
transientError: null,
persistentError: null,
collapsedCategories: new Set<string>(),
sidebarMode: "channels" as const,
activeDmUserId: null,
}));
voiceStore.setState(() => ({
currentChannelId: null,
voiceUsers: new Map(),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
peerVerifications: new Map(),
}));
membersStore.setState(() => ({
members: new Map(),
typingUsers: new Map(),
}));
}
/** Add a connected voice user to a channel (via the same store path the WS uses). */
function addVoiceUser(channelId: number, userId: number, username: string): void {
updateVoiceState({
channel_id: channelId,
user_id: userId,
username,
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
}
/** Record a peer's E2EE identity verification result in the voice store. */
function setPeerVerif(
userId: number,
status: PeerVerification["status"],
safetyNumber: string | null = null,
sessionFingerprint: string | null = null,
): void {
voiceStore.setState((prev) => {
const peerVerifications = new Map(prev.peerVerifications ?? []);
peerVerifications.set(userId, { userId, status, safetyNumber, sessionFingerprint });
return { ...prev, peerVerifications };
});
}
const testChannels: ReadyChannel[] = [
{
id: 1,
name: "general",
type: "text",
category: "Text Channels",
position: 0,
unread_count: 2,
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-lobby",
type: "voice",
category: "Voice Channels",
position: 0,
},
{
id: 4,
name: "announcements",
type: "announcement",
category: "Info",
position: 0,
unread_count: 5,
last_message_id: 200,
},
];
/** Fresh spies for the four voice moderation callbacks. */
function voiceModCallbacks() {
return {
onServerMute: vi.fn(),
onServerDeafen: vi.fn(),
onMove: vi.fn(),
onDisconnect: vi.fn(),
};
}
/** Set auth user so admin-gated features (context menus, drag, create channel) activate. */
function setAdminUser(): void {
authStore.setState(() => ({
token: "tok",
user: { id: 1, username: "Admin", avatar: null, role: "admin" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
}
describe("ChannelSidebar", () => {
let container: HTMLDivElement;
let sidebar: ReturnType<typeof createChannelSidebar>;
let onVoiceJoin: ReturnType<typeof vi.fn>;
let onVoiceLeave: ReturnType<typeof vi.fn>;
beforeEach(() => {
resetStores();
container = document.createElement("div");
document.body.appendChild(container);
onVoiceJoin = vi.fn();
onVoiceLeave = vi.fn();
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave });
});
afterEach(() => {
sidebar.destroy?.();
container.remove();
// Clean up any context menus left on document.body
document.querySelectorAll(".context-menu").forEach((el) => el.remove());
document.querySelectorAll(".user-vol-menu").forEach((el) => el.remove());
});
it("renders channel list from store", () => {
setChannels(testChannels);
sidebar.mount(container);
const items = container.querySelectorAll(".channel-item");
expect(items.length).toBe(4);
const names = Array.from(container.querySelectorAll(".ch-name")).map((el) => el.textContent);
expect(names).toContain("general");
expect(names).toContain("random");
expect(names).toContain("voice-lobby");
expect(names).toContain("announcements");
});
it("groups channels by category", () => {
setChannels(testChannels);
sidebar.mount(container);
const categories = container.querySelectorAll(".category");
const categoryNames = Array.from(categories).map(
(el) => el.querySelector(".category-name")?.textContent,
);
expect(categoryNames).toContain("Text Channels");
expect(categoryNames).toContain("Voice Channels");
expect(categoryNames).toContain("Info");
});
// Categories are free text: a voice channel groups under whatever it carries,
// and shares a category with text channels rather than being pulled into a
// hardcoded voice section.
it("groups a voice channel under an arbitrary shared category", () => {
setChannels([
{ id: 1, name: "chat", type: "text", category: "Gaming", position: 0 },
{ id: 2, name: "lounge", type: "voice", category: "Gaming", position: 1 },
]);
sidebar.mount(container);
const headers = Array.from(container.querySelectorAll(".category"));
const names = headers.map((el) => el.querySelector(".category-name")?.textContent);
expect(names).toEqual(["Gaming"]);
const group = headers[0]?.parentElement;
const rendered = Array.from(group?.querySelectorAll(".ch-name") ?? []).map(
(el) => el.textContent,
);
expect(rendered).toEqual(["chat", "lounge"]);
});
it("puts only uncategorized voice channels in the fallback Voice group", () => {
setChannels([
{ id: 1, name: "loose-text", type: "text", category: null, position: 0 },
{ id: 2, name: "loose-voice", type: "voice", category: null, position: 1 },
]);
sidebar.mount(container);
const names = Array.from(container.querySelectorAll(".category-name")).map(
(el) => el.textContent,
);
expect(names).toEqual(["Voice"]);
// The uncategorized TEXT channel still renders, headerless.
const allNames = Array.from(container.querySelectorAll(".ch-name")).map((el) => el.textContent);
expect(allNames).toContain("loose-text");
expect(allNames).toContain("loose-voice");
});
it("click channel sets active and clears unread", () => {
setChannels(testChannels);
sidebar.mount(container);
// Channel 1 (general) has unread_count of 2
const ch1Before = channelsStore.getState().channels.get(1);
expect(ch1Before?.unreadCount).toBe(2);
const firstItem = container.querySelector('[data-channel-id="1"]') as HTMLElement;
expect(firstItem).not.toBeNull();
firstItem.click();
const state = channelsStore.getState();
expect(state.activeChannelId).toBe(1);
expect(state.channels.get(1)?.unreadCount).toBe(0);
});
it("category collapse toggles visibility", () => {
setChannels(testChannels);
sidebar.mount(container);
// Text Channels category should have 2 channels visible
const textChannelsBefore = container.querySelectorAll(".channel-item");
expect(textChannelsBefore.length).toBe(4);
// Click the "Text Channels" category header to collapse
const headers = container.querySelectorAll(".category");
const textHeader = Array.from(headers).find(
(h) => h.querySelector(".category-name")?.textContent === "Text Channels",
) as HTMLElement;
expect(textHeader).not.toBeUndefined();
textHeader.click();
uiStore.flush();
// After collapse, "Text Channels" channels should be hidden
// The sidebar re-renders on uiStore change, so channels under
// collapsed category are not in the DOM
const itemsAfter = container.querySelectorAll(".channel-item");
expect(itemsAfter.length).toBe(2); // only Voice + Info channels remain
// Expand again
const headersAfter = container.querySelectorAll(".category");
const textHeaderAfter = Array.from(headersAfter).find(
(h) => h.querySelector(".category-name")?.textContent === "Text Channels",
) as HTMLElement;
textHeaderAfter.click();
uiStore.flush();
const itemsExpanded = container.querySelectorAll(".channel-item");
expect(itemsExpanded.length).toBe(4);
});
it("displays server name from auth store", () => {
sidebar.mount(container);
const serverName = container.querySelector(".channel-sidebar-header h2");
expect(serverName?.textContent).toBe("Test Server");
});
it("shows unread badge for channels with unread messages", () => {
setChannels(testChannels);
sidebar.mount(container);
const badges = container.querySelectorAll(".unread-badge");
expect(badges.length).toBe(2); // general (2) and announcements (5)
const badgeTexts = Array.from(badges).map((b) => b.textContent);
expect(badgeTexts).toContain("2");
expect(badgeTexts).toContain("5");
});
it("shows the red mention badge instead of the unread badge", () => {
setChannels([{ ...testChannels[0]!, unread_count: 7, mention_count: 2 }, testChannels[1]!]);
sidebar.mount(container);
const item = container.querySelector('[data-channel-id="1"]')!;
expect(item.classList.contains("mentioned")).toBe(true);
const mentionBadge = item.querySelector(".mention-badge");
expect(mentionBadge?.textContent).toBe("2");
// The mention badge wins outright — no double badge on one row.
expect(item.querySelector(".unread-badge")).toBeNull();
});
it("falls back to the unread badge when there are no mentions", () => {
setChannels([{ ...testChannels[0]!, unread_count: 7, mention_count: 0 }]);
sidebar.mount(container);
const item = container.querySelector('[data-channel-id="1"]')!;
expect(item.classList.contains("mentioned")).toBe(false);
expect(item.querySelector(".mention-badge")).toBeNull();
expect(item.querySelector(".unread-badge")?.textContent).toBe("7");
});
it("clears the mention badge when the channel is activated", () => {
setChannels([{ ...testChannels[0]!, unread_count: 7, mention_count: 2 }]);
sidebar.mount(container);
(container.querySelector('[data-channel-id="1"]') as HTMLElement).click();
channelsStore.flush();
expect(channelsStore.getState().channels.get(1)?.mentionCount).toBe(0);
expect(channelsStore.getState().activeChannelId).toBe(1);
expect(container.querySelector(".mention-badge")).toBeNull();
});
it("marks active channel with active class", () => {
setChannels(testChannels);
setActiveChannel(2);
sidebar.mount(container);
const activeItem = container.querySelector('[data-channel-id="2"]');
expect(activeItem?.classList.contains("active")).toBe(true);
});
it("shows voice icon for voice channels", () => {
setChannels(testChannels);
sidebar.mount(container);
const voiceItem = container.querySelector('[data-channel-id="3"]');
const icon = voiceItem?.querySelector(".ch-icon");
expect(icon).not.toBeNull();
});
it("clicking voice channel calls onVoiceJoin instead of setActiveChannel", () => {
setChannels(testChannels);
sidebar.mount(container);
const voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement;
voiceItem.click();
// Should NOT set active channel
expect(channelsStore.getState().activeChannelId).toBeNull();
// Should call onVoiceJoin with channel id
expect(onVoiceJoin).toHaveBeenCalledWith(3);
});
it("clicking text channel still sets active channel normally", () => {
setChannels(testChannels);
sidebar.mount(container);
const textItem = container.querySelector('[data-channel-id="1"]') as HTMLElement;
textItem.click();
expect(channelsStore.getState().activeChannelId).toBe(1);
expect(onVoiceJoin).not.toHaveBeenCalled();
});
it("clicking joined voice channel calls onVoiceLeave", () => {
setChannels(testChannels);
voiceStore.setState((prev) => ({ ...prev, currentChannelId: 3 }));
sidebar.mount(container);
const voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement;
voiceItem.click();
expect(onVoiceLeave).toHaveBeenCalled();
expect(onVoiceJoin).not.toHaveBeenCalled();
});
// ── Voice join/leave freeze while the WS socket is not connected (§3) ──
it("disables voice channel join with a 'Reconnecting…' reason while reconnecting", () => {
setChannels(testChannels);
uiStore.setState((prev) => ({ ...prev, connectionStatus: "reconnecting" }));
sidebar.mount(container);
const voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement;
expect(voiceItem.classList.contains("disabled")).toBe(true);
expect(voiceItem.getAttribute("aria-disabled")).toBe("true");
expect(voiceItem.title).toBe("Reconnecting…");
// Frozen: the click must not fire the join callback.
voiceItem.click();
expect(onVoiceJoin).not.toHaveBeenCalled();
});
it("disables voice channel join with a 'Not connected' reason while disconnected", () => {
setChannels(testChannels);
uiStore.setState((prev) => ({ ...prev, connectionStatus: "disconnected" }));
sidebar.mount(container);
const voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement;
expect(voiceItem.classList.contains("disabled")).toBe(true);
expect(voiceItem.title).toBe("Not connected");
voiceItem.click();
expect(onVoiceJoin).not.toHaveBeenCalled();
});
it("frozen voice channel does not fire onVoiceLeave even when joined", () => {
setChannels(testChannels);
voiceStore.setState((prev) => ({ ...prev, currentChannelId: 3 }));
uiStore.setState((prev) => ({ ...prev, connectionStatus: "reconnecting" }));
sidebar.mount(container);
const voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement;
expect(voiceItem.classList.contains("disabled")).toBe(true);
voiceItem.click();
expect(onVoiceLeave).not.toHaveBeenCalled();
});
it("re-enables voice channel join when the connection returns to connected", () => {
setChannels(testChannels);
uiStore.setState((prev) => ({ ...prev, connectionStatus: "reconnecting" }));
sidebar.mount(container);
// Initially frozen.
let voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement;
expect(voiceItem.classList.contains("disabled")).toBe(true);
// Connection restored — the sidebar re-renders and unfreezes the row.
uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected" }));
uiStore.flush();
voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement;
expect(voiceItem.classList.contains("disabled")).toBe(false);
expect(voiceItem.hasAttribute("aria-disabled")).toBe(false);
voiceItem.click();
expect(onVoiceJoin).toHaveBeenCalledWith(3);
});
it("shows connected voice users under voice channel", () => {
setChannels(testChannels);
// Add a member so username resolves
membersStore.setState((prev) => ({
...prev,
members: new Map([
[
10,
{ id: 10, username: "Alice", avatar: null, role: "member", status: "online" as const },
],
]),
}));
updateVoiceState({
channel_id: 3,
user_id: 10,
username: "Alice",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const voiceUsersList = container.querySelector(".voice-users-list");
expect(voiceUsersList).not.toBeNull();
const userItems = container.querySelectorAll(".voice-user-item");
expect(userItems.length).toBe(1);
const userName = userItems[0]?.querySelector(".vu-name");
expect(userName?.textContent).toBe("Alice");
});
it("shows the member's display name (nickname), not the raw username, in the voice roster", () => {
setChannels(testChannels);
// Member has a nickname set — every other identity surface (member list,
// message rows, DM sidebar) renders it instead of the raw username.
membersStore.setState((prev) => ({
...prev,
members: new Map([
[
10,
{
id: 10,
username: "Bob",
displayName: "Bobby",
avatar: null,
role: "member",
status: "online" as const,
},
],
]),
}));
updateVoiceState({
channel_id: 3,
user_id: 10,
username: "Bob",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const userName = container.querySelector(".voice-user-item .vu-name");
expect(userName?.textContent).toBe("Bobby");
});
it("highlights voice channel as active when user is joined", () => {
setChannels(testChannels);
voiceStore.setState((prev) => ({ ...prev, currentChannelId: 3 }));
sidebar.mount(container);
const voiceItem = container.querySelector('[data-channel-id="3"]');
expect(voiceItem?.classList.contains("active")).toBe(true);
});
it("re-renders when voice store changes", () => {
setChannels(testChannels);
sidebar.mount(container);
// Initially no voice users
let voiceUsers = container.querySelectorAll(".voice-user-item");
expect(voiceUsers.length).toBe(0);
// Add a voice user
updateVoiceState({
channel_id: 3,
user_id: 20,
username: "Bob",
muted: true,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
voiceStore.flush();
voiceUsers = container.querySelectorAll(".voice-user-item");
expect(voiceUsers.length).toBe(1);
// Should show muted icon
const mutedIcon = voiceUsers[0]?.querySelector(".vu-muted");
expect(mutedIcon).not.toBeNull();
});
it("shows LIVE badge when user has screenshare active", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 30,
username: "Streamer",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: true,
});
sidebar.mount(container);
const liveBadge = container.querySelector(".vu-live-badge");
expect(liveBadge).not.toBeNull();
expect(liveBadge!.textContent).toBe("LIVE");
});
it("shows monitor icon when user has screenshare active", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 30,
username: "Streamer",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: true,
});
sidebar.mount(container);
// The screenshare user row should contain an SVG icon (monitor)
const voiceUserItems = container.querySelectorAll(".voice-user-item");
expect(voiceUserItems.length).toBe(1);
const screenIcon = voiceUserItems[0]?.querySelector("svg");
expect(screenIcon).not.toBeNull();
});
it("calls onWatchStream when clicking a user row with active stream", () => {
const onWatchStream = vi.fn();
sidebar.destroy?.();
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onWatchStream });
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 30,
username: "Streamer",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: true,
});
sidebar.mount(container);
const voiceUserItem = container.querySelector(".voice-user-item") as HTMLElement;
expect(voiceUserItem).not.toBeNull();
voiceUserItem.click();
// User has screenshare: true, so tileId = userId + SCREENSHARE_TILE_ID_OFFSET
expect(onWatchStream).toHaveBeenCalledWith(30 + 1_000_000);
});
it("clicking a peer's stream row also joins the channel when not already in it", () => {
const onWatchStream = vi.fn();
sidebar.destroy?.();
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onWatchStream });
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 30,
username: "Streamer",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: true,
});
sidebar.mount(container);
const voiceUserItem = container.querySelector(".voice-user-item") as HTMLElement;
expect(voiceUserItem).not.toBeNull();
voiceUserItem.click();
// Watching a peer's stream needs a live LiveKit room -- without joining
// first, showVideoGrid has no cells to focus and the user is stranded on
// an empty grid. currentChannelId defaults to null (not channel 3 here).
expect(onVoiceJoin).toHaveBeenCalledWith(3);
});
// ── Empty state ──
it("shows empty state when no channels exist", () => {
sidebar.mount(container);
const emptyText = container.querySelector(".channel-list-empty-text");
expect(emptyText).not.toBeNull();
expect(emptyText!.textContent).toBe("No channels yet");
const hint = container.querySelector(".channel-list-empty-hint");
expect(hint).not.toBeNull();
});
// ── Server name updates ──
it("updates server name when auth store changes", () => {
sidebar.mount(container);
const h2 = container.querySelector(".channel-sidebar-header h2");
expect(h2?.textContent).toBe("Test Server");
authStore.setState((prev) => ({ ...prev, serverName: "Renamed Server" }));
authStore.flush();
expect(h2?.textContent).toBe("Renamed Server");
});
it("falls back to 'Server Name' when serverName is null", () => {
authStore.setState((prev) => ({ ...prev, serverName: null }));
sidebar.mount(container);
const h2 = container.querySelector(".channel-sidebar-header h2");
expect(h2?.textContent).toBe("Server Name");
});
// ── Deafened and camera icons on voice users ──
it("shows both mic-off and headphones-off icons for deafened user", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 40,
username: "DeafUser",
muted: false,
deafened: true,
speaking: false,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const userRow = container.querySelector(".voice-user-item");
expect(userRow).not.toBeNull();
// Deafened shows TWO .vu-muted elements (mic-off + headphones-off)
const mutedIcons = userRow!.querySelectorAll(".vu-muted");
expect(mutedIcons.length).toBe(2);
});
it("shows camera icon for user with active camera", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 50,
username: "CameraUser",
muted: false,
deafened: false,
speaking: false,
camera: true,
screenshare: false,
});
sidebar.mount(container);
const statusIcon = container.querySelector(".vu-status");
expect(statusIcon).not.toBeNull();
});
// ── Speaking state in-place toggle ──
it("toggles speaking class in-place without re-rendering entire DOM", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 60,
username: "Talker",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const userRow = container.querySelector('.voice-user-item[data-voice-uid="60"]');
expect(userRow).not.toBeNull();
expect(userRow!.classList.contains("speaking")).toBe(false);
// Update only speaking flag (structural signature stays the same)
updateVoiceState({
channel_id: 3,
user_id: 60,
username: "Talker",
muted: false,
deafened: false,
speaking: true,
camera: false,
screenshare: false,
});
voiceStore.flush();
// The same DOM element should now have speaking class toggled
const updatedRow = container.querySelector('.voice-user-item[data-voice-uid="60"]');
expect(updatedRow).not.toBeNull();
expect(updatedRow!.classList.contains("speaking")).toBe(true);
});
it("speaking patch keeps the exact row element (cached map, no rebuild)", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 61,
username: "Talker2",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const rowBefore = container.querySelector('.voice-user-item[data-voice-uid="61"]');
expect(rowBefore).not.toBeNull();
// speaking-only flip → patched via the cached row map, not re-rendered
updateVoiceState({
channel_id: 3,
user_id: 61,
username: "Talker2",
muted: false,
deafened: false,
speaking: true,
camera: false,
screenshare: false,
});
voiceStore.flush();
const rowAfter = container.querySelector('.voice-user-item[data-voice-uid="61"]');
expect(rowAfter).toBe(rowBefore); // same element instance
expect(rowAfter!.classList.contains("speaking")).toBe(true);
// …and a structural change (mute) still re-renders with a fresh row.
updateVoiceState({
channel_id: 3,
user_id: 61,
username: "Talker2",
muted: true,
deafened: false,
speaking: true,
camera: false,
screenshare: false,
});
voiceStore.flush();
const rowRebuilt = container.querySelector('.voice-user-item[data-voice-uid="61"]');
expect(rowRebuilt).not.toBe(rowBefore);
expect(rowRebuilt!.querySelector(".vu-muted")).not.toBeNull();
// The rebuilt row keeps the speaking class (patch runs after re-render).
expect(rowRebuilt!.classList.contains("speaking")).toBe(true);
});
// ── Voice user avatar ──
it("renders first-letter avatar with deterministic color for voice user", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 70,
username: "Zara",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const avatar = container.querySelector(".vu-avatar");
expect(avatar).not.toBeNull();
expect(avatar!.textContent).toBe("Z");
// Avatar should have a background color set
expect((avatar as HTMLElement).style.background).not.toBe("");
});
it("shows '?' avatar for user with empty username", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 71,
username: "",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const avatar = container.querySelector(".vu-avatar");
expect(avatar).not.toBeNull();
expect(avatar!.textContent).toBe("?");
});
it("shows 'Unknown' name for user with empty username", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 71,
username: "",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const name = container.querySelector(".vu-name");
expect(name?.textContent).toBe("Unknown");
});
// ── Context menu for channel edit/delete ──
it("right-click on channel opens context menu with Edit and Delete for admin", () => {
const onEditChannel = vi.fn();
const onDeleteChannel = vi.fn();
sidebar.destroy?.();
setAdminUser();
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onEditChannel,
onDeleteChannel,
});
setChannels(testChannels);
sidebar.mount(container);
const channelEl = container.querySelector('[data-channel-id="1"]') as HTMLElement;
expect(channelEl).not.toBeNull();
// Dispatch right-click
channelEl.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
clientX: 100,
clientY: 200,
}),
);
const ctxMenu = document.querySelector('[data-testid="channel-context-menu"]');
expect(ctxMenu).not.toBeNull();
const editItem = document.querySelector('[data-testid="ctx-edit-channel"]');
expect(editItem).not.toBeNull();
expect(editItem!.textContent).toBe("Edit Channel");
const deleteItem = document.querySelector('[data-testid="ctx-delete-channel"]');
expect(deleteItem).not.toBeNull();
expect(deleteItem!.textContent).toBe("Delete Channel");
});
it("clicking Edit in context menu calls onEditChannel with the correct channel", () => {
const onEditChannel = vi.fn();
sidebar.destroy?.();
setAdminUser();
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onEditChannel,
});
setChannels(testChannels);
sidebar.mount(container);
const channelEl = container.querySelector('[data-channel-id="1"]') as HTMLElement;
channelEl.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
clientX: 100,
clientY: 200,
}),
);
const editItem = document.querySelector('[data-testid="ctx-edit-channel"]') as HTMLElement;
editItem.click();
expect(onEditChannel).toHaveBeenCalledTimes(1);
const calledWith = onEditChannel.mock.calls[0]![0];
expect(calledWith.id).toBe(1);
expect(calledWith.name).toBe("general");
});
it("clicking Delete in context menu calls onDeleteChannel with the correct channel", () => {
const onDeleteChannel = vi.fn();
sidebar.destroy?.();
setAdminUser();
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onDeleteChannel,
});
setChannels(testChannels);
sidebar.mount(container);
const channelEl = container.querySelector('[data-channel-id="1"]') as HTMLElement;
channelEl.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
clientX: 100,
clientY: 200,
}),
);
const deleteItem = document.querySelector('[data-testid="ctx-delete-channel"]') as HTMLElement;
deleteItem.click();
expect(onDeleteChannel).toHaveBeenCalledTimes(1);
expect(onDeleteChannel.mock.calls[0]![0].id).toBe(1);
});
// Mark as Read is offered to everyone (it touches only the caller's own read
// state), so a non-admin now gets a menu — just without the admin entries.
it("shows only Mark as Read in the context menu for non-admin users", () => {
const onEditChannel = vi.fn();
sidebar.destroy?.();
// Set a regular member (not admin/owner)
authStore.setState(() => ({
token: "tok",
user: { id: 2, username: "Member", avatar: null, role: "member" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onEditChannel,
});
setChannels(testChannels);
sidebar.mount(container);
const channelEl = container.querySelector('[data-channel-id="1"]') as HTMLElement;
channelEl.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
clientX: 100,
clientY: 200,
}),
);
const ctxMenu = document.querySelector('[data-testid="channel-context-menu"]');
expect(ctxMenu).not.toBeNull();
expect(ctxMenu?.querySelector('[data-testid="ctx-mark-read"]')).not.toBeNull();
expect(ctxMenu?.querySelector('[data-testid="ctx-edit-channel"]')).toBeNull();
expect(ctxMenu?.querySelector('[data-testid="ctx-delete-channel"]')).toBeNull();
expect(onEditChannel).not.toHaveBeenCalled();
});
// ── Purge messages context-menu item ──
/** Right-click channel 1 and return the opened context menu. */
function openChannelCtxMenu(): HTMLElement | null {
const channelEl = container.querySelector('[data-channel-id="1"]') as HTMLElement;
channelEl.dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, clientX: 5, clientY: 5 }),
);
return document.querySelector('[data-testid="channel-context-menu"]');
}
it("shows Purge Messages for a role holding MANAGE_MESSAGES", () => {
const onPurgeChannel = vi.fn<(channel: Channel, count: number) => Promise<void>>(
async () => {},
);
sidebar.destroy?.();
setRoles([{ id: 3, name: "Moderator", color: null, permissions: Permission.MANAGE_MESSAGES }]);
authStore.setState(() => ({
token: "tok",
user: { id: 3, username: "Mod", avatar: null, role: "moderator" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onPurgeChannel });
setChannels(testChannels);
sidebar.mount(container);
// A moderator is not owner/admin, so the menu exists only because of purge.
expect(openChannelCtxMenu()).not.toBeNull();
expect(document.querySelector('[data-testid="ctx-purge-messages"]')).not.toBeNull();
expect(document.querySelector('[data-testid="ctx-edit-channel"]')).toBeNull();
});
it("hides Purge Messages for a role without MANAGE_MESSAGES", () => {
const onPurgeChannel = vi.fn<(channel: Channel, count: number) => Promise<void>>(
async () => {},
);
sidebar.destroy?.();
setRoles([{ id: 2, name: "Admin", color: null, permissions: Permission.MANAGE_CHANNELS }]);
setAdminUser();
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onEditChannel: vi.fn(),
onPurgeChannel,
});
setChannels(testChannels);
sidebar.mount(container);
expect(openChannelCtxMenu()).not.toBeNull();
expect(document.querySelector('[data-testid="ctx-purge-messages"]')).toBeNull();
});
it("purge prompt clamps the count and calls onPurgeChannel with the channel", async () => {
const onPurgeChannel = vi.fn<(channel: Channel, count: number) => Promise<void>>(
async () => {},
);
sidebar.destroy?.();
setRoles([{ id: 2, name: "Admin", color: null, permissions: Permission.ADMINISTRATOR }]);
setAdminUser();
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onPurgeChannel });
setChannels(testChannels);
sidebar.mount(container);
openChannelCtxMenu();
const trigger = document.querySelector('[data-testid="ctx-purge-messages"]') as HTMLElement;
trigger.click();
const input = document.querySelector('[data-testid="purge-count-input"]') as HTMLInputElement;
expect(input).not.toBeNull();
input.value = "5000";
(document.querySelector('[data-testid="purge-confirm"]') as HTMLElement).click();
await vi.waitFor(() => {
expect(onPurgeChannel).toHaveBeenCalledTimes(1);
});
expect(onPurgeChannel.mock.calls[0]![0]).toMatchObject({ id: 1, name: "general" });
expect(onPurgeChannel.mock.calls[0]![1]).toBe(100);
});
it("does not offer purge on a voice channel", () => {
const onPurgeChannel = vi.fn<(channel: Channel, count: number) => Promise<void>>(
async () => {},
);
sidebar.destroy?.();
setRoles([{ id: 2, name: "Admin", color: null, permissions: Permission.ADMINISTRATOR }]);
setAdminUser();
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onPurgeChannel });
setChannels(testChannels);
sidebar.mount(container);
const voiceEl = container.querySelector('[data-channel-id="3"]') as HTMLElement;
voiceEl.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, clientX: 5, clientY: 5 }));
expect(document.querySelector('[data-testid="ctx-purge-messages"]')).toBeNull();
});
// ── Create channel button ──
it("shows create channel button on category header for admin users", () => {
const onCreateChannel = vi.fn();
sidebar.destroy?.();
setAdminUser();
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onCreateChannel,
});
setChannels(testChannels);
sidebar.mount(container);
const addBtn = container.querySelector('[data-testid="create-channel-text-channels"]');
expect(addBtn).not.toBeNull();
expect(addBtn!.textContent).toBe("+");
});
it("clicking create channel button calls onCreateChannel with category name", () => {
const onCreateChannel = vi.fn();
sidebar.destroy?.();
setAdminUser();
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onCreateChannel,
});
setChannels(testChannels);
sidebar.mount(container);
const addBtn = container.querySelector(
'[data-testid="create-channel-text-channels"]',
) as HTMLElement;
addBtn.click();
expect(onCreateChannel).toHaveBeenCalledWith("Text Channels");
});
it("create channel button does not collapse the category", () => {
const onCreateChannel = vi.fn();
sidebar.destroy?.();
setAdminUser();
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onCreateChannel,
});
setChannels(testChannels);
sidebar.mount(container);
// All 4 channels visible before click
expect(container.querySelectorAll(".channel-item").length).toBe(4);
const addBtn = container.querySelector(
'[data-testid="create-channel-text-channels"]',
) as HTMLElement;
addBtn.click();
// Category should NOT have collapsed (stopPropagation in the handler)
expect(container.querySelectorAll(".channel-item").length).toBe(4);
});
it("does not show create channel button for non-admin users", () => {
const onCreateChannel = vi.fn();
sidebar.destroy?.();
authStore.setState(() => ({
token: "tok",
user: { id: 2, username: "Member", avatar: null, role: "member" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onCreateChannel,
});
setChannels(testChannels);
sidebar.mount(container);
const addBtn = container.querySelector(".category-add-btn");
expect(addBtn).toBeNull();
});
// The button used to be gated on the role NAME, so a Moderator holding
// MANAGE_CHANNELS — which the server now honors on /admin/api/channels — saw
// no way to create one.
it("shows create channel button for a role holding MANAGE_CHANNELS", () => {
const onCreateChannel = vi.fn();
sidebar.destroy?.();
setRoles([{ id: 3, name: "Moderator", color: null, permissions: Permission.MANAGE_CHANNELS }]);
authStore.setState(() => ({
token: "tok",
user: { id: 3, username: "Mod", avatar: null, role: "moderator" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onCreateChannel });
setChannels(testChannels);
sidebar.mount(container);
expect(container.querySelector(".category-add-btn")).not.toBeNull();
});
it("hides create channel button for a server role without MANAGE_CHANNELS", () => {
const onCreateChannel = vi.fn();
sidebar.destroy?.();
// A role the server DID send, holding no channel bit: the name fallback
// must not apply, even for a role called "admin".
setRoles([{ id: 2, name: "Admin", color: null, permissions: Permission.SEND_MESSAGES }]);
authStore.setState(() => ({
token: "tok",
user: { id: 2, username: "Admin", avatar: null, role: "admin" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onCreateChannel });
setChannels(testChannels);
sidebar.mount(container);
expect(container.querySelector(".category-add-btn")).toBeNull();
});
// ── Live role-change repaint (OC-0142) ──
// canManageChannels() is evaluated at render time from authStore.user.role,
// but the sidebar's only authStore subscription selects serverName. A
// MEMBER_UPDATE for the signed-in user (dispatcher.ts writes the new role
// via updateUser) must still cause a repaint without any unrelated event.
it("repaints channel-management affordances when the signed-in user's own role changes", () => {
const onCreateChannel = vi.fn();
sidebar.destroy?.();
authStore.setState(() => ({
token: "tok",
user: { id: 2, username: "Member", avatar: null, role: "member" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onCreateChannel });
setChannels(testChannels);
sidebar.mount(container);
// Starts as a plain member: no "+" button.
expect(container.querySelector(".category-add-btn")).toBeNull();
// Promoted to admin (mirrors dispatcher.ts's MEMBER_UPDATE self-branch,
// which patches authStore via updateUser({ role })) — no channel/voice
// event fires alongside it.
authStore.setState((prev) => ({
...prev,
user: prev.user === null ? null : { ...prev.user, role: "admin" },
}));
authStore.flush();
expect(container.querySelector(".category-add-btn")).not.toBeNull();
});
it("repaints channel-management affordances when the role list's permission mask changes", () => {
const onCreateChannel = vi.fn();
sidebar.destroy?.();
setRoles([{ id: 3, name: "Moderator", color: null, permissions: Permission.SEND_MESSAGES }]);
authStore.setState(() => ({
token: "tok",
user: { id: 3, username: "Mod", avatar: null, role: "moderator" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onCreateChannel });
setChannels(testChannels);
sidebar.mount(container);
// Moderator role holds no MANAGE_CHANNELS bit yet.
expect(container.querySelector(".category-add-btn")).toBeNull();
// A ROLES_UPDATE grants MANAGE_CHANNELS to the same role (dispatcher.ts's
// ROLES_UPDATE handler replaces the whole list via setRoles) — again with
// no accompanying channel/voice event.
setRoles([{ id: 3, name: "Moderator", color: null, permissions: Permission.MANAGE_CHANNELS }]);
channelsStore.flush();
expect(container.querySelector(".category-add-btn")).not.toBeNull();
});
// ── Voice user volume context menu ──
it("right-click on other user's voice row opens volume context menu", () => {
// Set current user to something different from the voice user
authStore.setState(() => ({
token: "tok",
user: { id: 99, username: "Me", avatar: null, role: "member" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 80,
username: "OtherUser",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const voiceRow = container.querySelector(".voice-user-item") as HTMLElement;
voiceRow.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
clientX: 150,
clientY: 250,
}),
);
const volMenu = document.querySelector(".user-vol-menu");
expect(volMenu).not.toBeNull();
// Should display the username
expect(volMenu!.textContent).toContain("OtherUser");
// Should have a volume slider
const slider = volMenu!.querySelector('input[type="range"]');
expect(slider).not.toBeNull();
// Should have a Reset Volume button
expect(volMenu!.textContent).toContain("Reset Volume");
});
// ── Voice moderation context menu (MUTE_MEMBERS) ──
/** Signs in as a user whose role holds exactly `permissions`, puts one other
* user in the voice channel, mounts the sidebar and right-clicks their row.
* Returns the open menu element (or null). */
function openVoiceMenuAs(
permissions: number,
voiceUser: Partial<VoiceStatePayload> = {},
onVoiceModerate?: Parameters<typeof createChannelSidebar>[0]["onVoiceModerate"],
channels: ReadyChannel[] = testChannels,
): HTMLElement | null {
sidebar.destroy?.();
setRoles([{ id: 3, name: "Moderator", color: null, permissions }]);
authStore.setState(() => ({
token: "tok",
user: { id: 99, username: "Mod", avatar: null, role: "moderator" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onVoiceModerate });
setChannels(channels);
updateVoiceState({
channel_id: 3,
user_id: 80,
username: "Target",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
...voiceUser,
});
sidebar.mount(container);
const voiceRow = container.querySelector(".voice-user-item") as HTMLElement;
voiceRow.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
return document.querySelector(".user-vol-menu");
}
it("hides the moderation section for a role without MUTE_MEMBERS", () => {
const menu = openVoiceMenuAs(Permission.MANAGE_CHANNELS, {}, voiceModCallbacks());
expect(menu).not.toBeNull();
expect(menu!.querySelector('[data-action="server-mute"]')).toBeNull();
expect(menu!.querySelector('[data-action="voice-disconnect"]')).toBeNull();
// The volume controls stay available to everyone.
expect(menu!.textContent).toContain("Reset Volume");
});
it("hides the moderation section when the page wired no callbacks", () => {
const menu = openVoiceMenuAs(Permission.MUTE_MEMBERS);
expect(menu).not.toBeNull();
expect(menu!.querySelector('[data-action="server-mute"]')).toBeNull();
});
it("shows the moderation section for a role holding MUTE_MEMBERS", () => {
const menu = openVoiceMenuAs(Permission.MUTE_MEMBERS, {}, voiceModCallbacks());
expect(menu).not.toBeNull();
expect(menu!.querySelector('[data-action="server-mute"]')!.textContent).toBe("Server Mute");
expect(menu!.querySelector('[data-action="server-deafen"]')!.textContent).toBe("Server Deafen");
expect(menu!.querySelector('[data-action="voice-disconnect"]')).not.toBeNull();
});
it("shows the moderation section for an ADMINISTRATOR role", () => {
const menu = openVoiceMenuAs(Permission.ADMINISTRATOR, {}, voiceModCallbacks());
expect(menu!.querySelector('[data-action="server-mute"]')).not.toBeNull();
});
it("labels the toggles by the target's current server-imposed state", () => {
const menu = openVoiceMenuAs(
Permission.MUTE_MEMBERS,
{ muted: true, deafened: true, server_muted: true, server_deafened: true },
voiceModCallbacks(),
);
expect(menu!.querySelector('[data-action="server-mute"]')!.textContent).toBe("Server Unmute");
expect(menu!.querySelector('[data-action="server-deafen"]')!.textContent).toBe(
"Server Undeafen",
);
});
it("sends server mute with the channel and target, and disconnect with the target", () => {
const cb = voiceModCallbacks();
const menu = openVoiceMenuAs(Permission.MUTE_MEMBERS, {}, cb);
(menu!.querySelector('[data-action="server-mute"]') as HTMLElement).click();
expect(cb.onServerMute).toHaveBeenCalledWith(3, 80, true);
const menu2 = openVoiceMenuAs(Permission.MUTE_MEMBERS, {}, cb);
(menu2!.querySelector('[data-action="voice-disconnect"]') as HTMLElement).click();
expect(cb.onDisconnect).toHaveBeenCalledWith(80);
});
it("offers only the other voice channels as move targets", () => {
const cb = voiceModCallbacks();
const menu = openVoiceMenuAs(Permission.MUTE_MEMBERS, {}, cb);
const moveItems = menu!.querySelectorAll("[data-move-channel]");
// testChannels has one voice channel (id 3) — the one the target is in.
expect(moveItems.length).toBe(0);
const menu2 = openVoiceMenuAs(Permission.MUTE_MEMBERS, {}, cb, [
...testChannels,
{ id: 5, name: "voice-two", type: "voice", category: "Voice Channels", position: 1 },
]);
const moveItems2 = menu2!.querySelectorAll("[data-move-channel]");
expect(moveItems2.length).toBe(1);
expect((moveItems2[0] as HTMLElement).dataset.moveChannel).toBe("5");
(moveItems2[0] as HTMLElement).click();
expect(cb.onMove).toHaveBeenCalledWith(80, 5);
});
it("marks a server-muted participant distinctly from a self-muted one", () => {
sidebar.destroy?.();
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave });
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 81,
username: "Selfmuted",
muted: true,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
sidebar.mount(container);
expect(container.querySelector(".vu-muted.vu-server-muted")).toBeNull();
updateVoiceState({
channel_id: 3,
user_id: 81,
username: "Selfmuted",
muted: true,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
server_muted: true,
});
voiceStore.flush();
const icon = container.querySelector(".vu-muted.vu-server-muted") as HTMLElement;
expect(icon).not.toBeNull();
expect(icon.title).toBe("Muted by a moderator");
});
// ── Collapsed category shows arrow-right, expanded shows arrow-down ──
it("collapsed category header has 'collapsed' class", () => {
setChannels(testChannels);
uiStore.setState((prev) => ({
...prev,
collapsedCategories: new Set(["Text Channels"]),
}));
sidebar.mount(container);
const headers = container.querySelectorAll(".category");
const textHeader = Array.from(headers).find(
(h) => h.querySelector(".category-name")?.textContent === "Text Channels",
);
expect(textHeader).not.toBeUndefined();
expect(textHeader!.classList.contains("collapsed")).toBe(true);
});
// ── Channels store subscription re-renders on channel map changes ──
it("re-renders when channels store changes after mount", () => {
sidebar.mount(container);
expect(container.querySelectorAll(".channel-item").length).toBe(0);
// Add channels after mount
setChannels(testChannels);
channelsStore.flush();
expect(container.querySelectorAll(".channel-item").length).toBe(4);
});
// ── Destroy cleanup ──
it("destroy removes the sidebar from the DOM", () => {
setChannels(testChannels);
sidebar.mount(container);
expect(container.querySelector('[data-testid="channel-sidebar"]')).not.toBeNull();
sidebar.destroy?.();
expect(container.querySelector('[data-testid="channel-sidebar"]')).toBeNull();
});
// ── Drag reorder setup for admin (attaches drag handlers) ──
it("admin sidebar with onReorderChannel adds channel-draggable class to items", () => {
const onReorderChannel = vi.fn();
sidebar.destroy?.();
setAdminUser();
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onReorderChannel,
});
setChannels(testChannels);
sidebar.mount(container);
// Admin + onReorderChannel -> items should have channel-draggable class
const draggables = container.querySelectorAll(".channel-draggable");
expect(draggables.length).toBeGreaterThan(0);
});
it("non-admin does not get draggable class on channel items", () => {
const onReorderChannel = vi.fn();
sidebar.destroy?.();
authStore.setState(() => ({
token: "tok",
user: { id: 2, username: "Member", avatar: null, role: "member" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onReorderChannel,
});
setChannels(testChannels);
sidebar.mount(container);
const draggables = container.querySelectorAll(".channel-draggable");
expect(draggables.length).toBe(0);
});
it("destroy cleans up global drag listeners when last sidebar instance is destroyed", () => {
const onReorderChannel = vi.fn();
sidebar.destroy?.();
setAdminUser();
sidebar = createChannelSidebar({
onVoiceJoin,
onVoiceLeave,
onReorderChannel,
});
setChannels(testChannels);
sidebar.mount(container);
// After mount with drag support, destroy should not throw
sidebar.destroy?.();
// Verify sidebar is removed
expect(container.querySelector('[data-testid="channel-sidebar"]')).toBeNull();
// Re-create for afterEach cleanup
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave });
});
// ── Multiple voice users in same channel ──
it("renders multiple voice users under the same channel", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 90,
username: "UserA",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
updateVoiceState({
channel_id: 3,
user_id: 91,
username: "UserB",
muted: true,
deafened: false,
speaking: true,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const userItems = container.querySelectorAll(".voice-user-item");
expect(userItems.length).toBe(2);
const names = Array.from(userItems).map((el) => el.querySelector(".vu-name")?.textContent);
expect(names).toContain("UserA");
expect(names).toContain("UserB");
});
// ── User with camera + screenshare shows both icons ──
it("shows both camera and monitor icons when user has camera and screenshare", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 92,
username: "MultiStream",
muted: false,
deafened: false,
speaking: false,
camera: true,
screenshare: true,
});
sidebar.mount(container);
const userRow = container.querySelector(".voice-user-item");
expect(userRow).not.toBeNull();
// Camera icon + screen icon = 2 .vu-status elements
const statusIcons = userRow!.querySelectorAll(".vu-status");
expect(statusIcons.length).toBe(2);
// Plus LIVE badge
const liveBadge = userRow!.querySelector(".vu-live-badge");
expect(liveBadge).not.toBeNull();
});
// T1: Screenshare click → offset tileId
it("passes screenshare tile offset when clicking screensharing user", () => {
const onWatchStream = vi.fn();
const sidebarWithWatch = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onWatchStream });
setChannels(testChannels);
voiceStore.setState(() => ({
currentChannelId: 3,
voiceUsers: new Map([
[
3,
new Map([
[
99,
{
userId: 99,
username: "Streamer",
speaking: false,
muted: false,
deafened: false,
camera: false,
screenshare: true,
},
],
]),
],
]),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebarWithWatch.mount(container);
const userRow = container.querySelector<HTMLElement>(".voice-user-item");
expect(userRow).not.toBeNull();
userRow!.click();
expect(onWatchStream).toHaveBeenCalledWith(99 + 1_000_000);
sidebarWithWatch.destroy?.();
});
// T2: Camera-only click → raw userId
it("passes raw userId when clicking camera-only user", () => {
const onWatchStream = vi.fn();
const sidebarWithWatch = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onWatchStream });
setChannels(testChannels);
voiceStore.setState(() => ({
currentChannelId: 3,
voiceUsers: new Map([
[
3,
new Map([
[
99,
{
userId: 99,
username: "Cammer",
speaking: false,
muted: false,
deafened: false,
camera: true,
screenshare: false,
},
],
]),
],
]),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebarWithWatch.mount(container);
const userRow = container.querySelector<HTMLElement>(".voice-user-item");
expect(userRow).not.toBeNull();
userRow!.click();
expect(onWatchStream).toHaveBeenCalledWith(99);
sidebarWithWatch.destroy?.();
});
// T12: Self-user → no preview attached
it("does not attach stream preview for self user", () => {
mockAttachStreamPreview.mockClear();
authStore.setState(() => ({
token: "tok",
user: { id: 42, username: "Me", avatar: null, role: "member" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
setChannels(testChannels);
voiceStore.setState(() => ({
currentChannelId: 3,
voiceUsers: new Map([
[
3,
new Map([
[
42,
{
userId: 42,
username: "Me",
speaking: false,
muted: false,
deafened: false,
camera: true,
screenshare: false,
},
],
]),
],
]),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebar.mount(container);
// Should not have called attachStreamPreview for self
expect(mockAttachStreamPreview).not.toHaveBeenCalled();
});
// T20: Constant shared — sidebar uses SCREENSHARE_TILE_ID_OFFSET from constants
it("uses shared SCREENSHARE_TILE_ID_OFFSET constant", async () => {
// Verify the constant is imported and used by checking the offset value
const onWatchStream = vi.fn();
const sidebarWithWatch = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onWatchStream });
setChannels(testChannels);
voiceStore.setState(() => ({
currentChannelId: 3,
voiceUsers: new Map([
[
3,
new Map([
[
1,
{
userId: 1,
username: "User",
speaking: false,
muted: false,
deafened: false,
camera: false,
screenshare: true,
},
],
]),
],
]),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebarWithWatch.mount(container);
container.querySelector<HTMLElement>(".voice-user-item")?.click();
// 1 + 1_000_000 = 1_000_001 — proves the shared constant is used
expect(onWatchStream).toHaveBeenCalledWith(1_000_001);
sidebarWithWatch.destroy?.();
});
// T14: attachScrollCollapse is called for voice users containers
it("attaches scroll collapse to voice-users-list containers", () => {
mockAttachScrollCollapse.mockClear();
setChannels(testChannels);
voiceStore.setState(() => ({
currentChannelId: 3,
voiceUsers: new Map([
[
3,
new Map([
[
99,
{
userId: 99,
username: "User",
speaking: false,
muted: false,
deafened: false,
camera: true,
screenshare: false,
},
],
]),
],
]),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebar.mount(container);
expect(mockAttachScrollCollapse).toHaveBeenCalled();
});
});
// ── E2EE identity verification badge on voice user rows (F3 TOFU) ──
describe("ChannelSidebar voice identity badge", () => {
let container: HTMLDivElement;
let sidebar: ReturnType<typeof createChannelSidebar>;
const VOICE_CH = 3; // "voice-lobby" in testChannels
beforeEach(() => {
resetStores();
setChannels(testChannels);
container = document.createElement("div");
document.body.appendChild(container);
sidebar = createChannelSidebar({ onVoiceJoin: vi.fn(), onVoiceLeave: vi.fn() });
});
afterEach(() => {
sidebar.destroy?.();
container.remove();
document.querySelectorAll(".modal-overlay").forEach((el) => el.remove());
mockRePinPeerIdentity.mockClear();
});
function badgeFor(userId: number): HTMLElement | null {
return container.querySelector(`.voice-user-item[data-voice-uid="${userId}"] .vu-verify`);
}
it("shows a verified badge carrying the safety number in its title", () => {
addVoiceUser(VOICE_CH, 10, "Alice");
setPeerVerif(10, "verified", "AB12 CD34 EF56 7890");
sidebar.mount(container);
const badge = badgeFor(10);
expect(badge).not.toBeNull();
expect(badge!.classList.contains("verified")).toBe(true);
expect(badge!.getAttribute("title")).toContain("AB12 CD34 EF56 7890");
});
it("shows an unverified badge for a legacy peer", () => {
addVoiceUser(VOICE_CH, 10, "Alice");
setPeerVerif(10, "unverified", null);
sidebar.mount(container);
const badge = badgeFor(10);
expect(badge).not.toBeNull();
expect(badge!.classList.contains("unverified")).toBe(true);
});
it("shows the session fingerprint, labelled as not an identity, on an unverified badge", () => {
addVoiceUser(VOICE_CH, 10, "Alice");
setPeerVerif(10, "unverified", null, "5E55 1234 5678 9ABC");
sidebar.mount(container);
const title = badgeFor(10)!.getAttribute("title") ?? "";
expect(title).toContain("5E55 1234 5678 9ABC");
expect(title).toContain("not an identity");
});
it("shows the local user's own session fingerprint on their voice row", () => {
authStore.setState((prev) => ({
...prev,
user: { id: 7, username: "Me", avatar: null, role: "member", status: "online" } as never,
isAuthenticated: true,
}));
addVoiceUser(VOICE_CH, 7, "Me");
voiceStore.setState((prev) => ({ ...prev, localSessionFingerprint: "0123 4567 89AB CDEF" }));
sidebar.mount(container);
const own = container.querySelector(
`.voice-user-item[data-voice-uid="7"] .vu-session-fp`,
) as HTMLElement | null;
expect(own).not.toBeNull();
expect(own!.getAttribute("title")).toContain("0123 4567 89AB CDEF");
});
it("shows a mismatch badge for a peer whose identity key changed", () => {
addVoiceUser(VOICE_CH, 10, "Alice");
setPeerVerif(10, "mismatch", null);
sidebar.mount(container);
const badge = badgeFor(10);
expect(badge).not.toBeNull();
expect(badge!.classList.contains("mismatch")).toBe(true);
});
it("shows a distinct 'could not check' badge when the pin store was unreadable (DC-08)", () => {
addVoiceUser(VOICE_CH, 10, "Alice");
setPeerVerif(10, "unknown", null);
sidebar.mount(container);
const badge = badgeFor(10);
expect(badge).not.toBeNull();
expect(badge!.classList.contains("unknown")).toBe(true);
// Must not read as the legacy "no key published" state — the message is
// about local storage failing, not about the peer.
expect(badge!.classList.contains("unverified")).toBe(false);
expect(badge!.getAttribute("title")).toContain("Could not check");
});
it("shows no badge when the peer's verification is unresolved", () => {
addVoiceUser(VOICE_CH, 10, "Alice");
sidebar.mount(container);
expect(badgeFor(10)).toBeNull();
});
it("re-renders the badge when a peer's verification changes after mount", () => {
addVoiceUser(VOICE_CH, 10, "Alice");
setPeerVerif(10, "unverified", null);
sidebar.mount(container);
expect(badgeFor(10)!.classList.contains("unverified")).toBe(true);
setPeerVerif(10, "verified", "AB12 CD34");
voiceStore.flush();
const badge = badgeFor(10);
expect(badge).not.toBeNull();
expect(badge!.classList.contains("verified")).toBe(true);
});
it("opens the identity-mismatch modal when the mismatch badge is clicked", async () => {
addVoiceUser(VOICE_CH, 10, "Alice");
setPeerVerif(10, "mismatch", null);
sidebar.mount(container);
// Opening is async (computes the changed key's fingerprint before mounting).
(badgeFor(10) as HTMLElement).click();
await vi.waitFor(() => {
expect(document.body.querySelector(".modal-overlay")).not.toBeNull();
});
});
it("shows the changed key's fingerprint in the mismatch modal for out-of-band verification", async () => {
addVoiceUser(VOICE_CH, 10, "Alice");
// The peer must have a published identity key for its fingerprint to be shown.
membersStore.setState((prev) => {
const members = new Map(prev.members);
members.set(10, {
id: 10,
username: "Alice",
avatar: null,
role: "member",
status: "online",
identityPublicKey: "alice-published-key-b64",
});
return { ...prev, members };
});
setPeerVerif(10, "mismatch", null);
sidebar.mount(container);
(badgeFor(10) as HTMLElement).click();
await vi.waitFor(() => {
const fp = document.body.querySelector(".modal-overlay .cert-fingerprint");
expect(fp?.textContent).toBe("FEED FACE 1234 5678");
});
});
it("re-pins the displayed key when the mismatch modal's Trust button is clicked", async () => {
addVoiceUser(VOICE_CH, 10, "Alice");
// The peer must have a published key so its fingerprint is shown and there
// is a concrete verified key to re-pin.
membersStore.setState((prev) => {
const members = new Map(prev.members);
members.set(10, {
id: 10,
username: "Alice",
avatar: null,
role: "member",
status: "online",
identityPublicKey: "alice-published-key-b64",
});
return { ...prev, members };
});
setPeerVerif(10, "mismatch", null);
sidebar.mount(container);
(badgeFor(10) as HTMLElement).click();
const trustBtn = await vi.waitFor(() => {
const btn = document.body.querySelector(".modal-overlay .btn-danger") as HTMLButtonElement;
expect(btn).not.toBeNull();
return btn;
});
trustBtn.click();
// Pins the exact key whose fingerprint was displayed, not a bare userId.
expect(mockRePinPeerIdentity).toHaveBeenCalledWith(10, "alice-published-key-b64");
expect(document.body.querySelector(".modal-overlay")).toBeNull();
});
// The user verifies the DISPLAYED fingerprint out of band, which takes human
// time. A malicious server can push a user_update during that window; if
// Trust re-read membersStore instead of using the captured key, the swapped
// key would get pinned (TOCTOU).
it("pins the displayed key, not one the server swapped in during the verification window", async () => {
addVoiceUser(VOICE_CH, 10, "Alice");
membersStore.setState((prev) => {
const members = new Map(prev.members);
members.set(10, {
id: 10,
username: "Alice",
avatar: null,
role: "member",
status: "online",
identityPublicKey: "alice-published-key-b64",
});
return { ...prev, members };
});
setPeerVerif(10, "mismatch", null);
sidebar.mount(container);
(badgeFor(10) as HTMLElement).click();
const trustBtn = await vi.waitFor(() => {
const btn = document.body.querySelector(".modal-overlay .btn-danger") as HTMLButtonElement;
expect(btn).not.toBeNull();
return btn;
});
// Server mutates the peer's key while the modal is open and the human is
// still comparing the fingerprint it displayed.
membersStore.setState((prev) => {
const members = new Map(prev.members);
members.set(10, { ...members.get(10)!, identityPublicKey: "attacker-swapped-key-b64" });
return { ...prev, members };
});
trustBtn.click();
expect(mockRePinPeerIdentity).toHaveBeenCalledWith(10, "alice-published-key-b64");
expect(mockRePinPeerIdentity).not.toHaveBeenCalledWith(10, "attacker-swapped-key-b64");
});
it("does not re-pin when the fingerprint could not be computed (no blind accept)", async () => {
addVoiceUser(VOICE_CH, 10, "Alice");
membersStore.setState((prev) => {
const members = new Map(prev.members);
members.set(10, {
id: 10,
username: "Alice",
avatar: null,
role: "member",
status: "online",
identityPublicKey: "alice-published-key-b64",
});
return { ...prev, members };
});
setPeerVerif(10, "mismatch", null);
// The changed key's fingerprint cannot be computed → the modal shows no
// fingerprint, so Trust must not pin a key the user never got to verify.
(computeKeyFingerprint as any).mockRejectedValueOnce(new Error("bad key"));
sidebar.mount(container);
(badgeFor(10) as HTMLElement).click();
const trustBtn = await vi.waitFor(() => {
const btn = document.body.querySelector(".modal-overlay .btn-danger") as HTMLButtonElement;
expect(btn).not.toBeNull();
return btn;
});
trustBtn.click();
expect(mockRePinPeerIdentity).not.toHaveBeenCalled();
expect(document.body.querySelector(".modal-overlay")).toBeNull();
});
it("closes an open mismatch modal on sidebar destroy", async () => {
addVoiceUser(VOICE_CH, 10, "Alice");
setPeerVerif(10, "mismatch", null);
sidebar.mount(container);
(badgeFor(10) as HTMLElement).click();
await vi.waitFor(() => {
expect(document.body.querySelector(".modal-overlay")).not.toBeNull();
});
sidebar.destroy?.();
expect(document.body.querySelector(".modal-overlay")).toBeNull();
});
});
// ── Channel feature flags in the sidebar ──
//
// nsfw and voice_max_users reach the sidebar through the channel store, so
// these mount a fresh sidebar per case rather than reusing another suite's
// fixture (which seeds voice users and identity state the readouts would pick
// up).
describe("ChannelSidebar channel feature flags", () => {
let container: HTMLDivElement;
let sidebar: ReturnType<typeof createChannelSidebar>;
let onVoiceJoin: ReturnType<typeof vi.fn>;
let onVoiceLeave: ReturnType<typeof vi.fn>;
beforeEach(() => {
resetStores();
container = document.createElement("div");
document.body.appendChild(container);
onVoiceJoin = vi.fn();
onVoiceLeave = vi.fn();
sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave });
});
afterEach(() => {
sidebar.destroy?.();
container.remove();
});
describe("NSFW indicator", () => {
it("marks a flagged text channel", () => {
setChannels([
{ id: 1, name: "spicy", type: "text", category: "Text Channels", position: 0, nsfw: true },
{ id: 2, name: "general", type: "text", category: "Text Channels", position: 1 },
]);
sidebar.mount(container);
expect(container.querySelector("[data-testid='channel-nsfw-1']")).not.toBeNull();
expect(container.querySelector("[data-testid='channel-nsfw-2']")).toBeNull();
});
it("marks a flagged voice channel too", () => {
setChannels([
{ id: 5, name: "lounge", type: "voice", category: "Voice", position: 0, nsfw: true },
]);
sidebar.mount(container);
expect(container.querySelector("[data-testid='channel-nsfw-5']")).not.toBeNull();
});
// The marker is information, not a state — it must not colonise the "#"
// glyph or the name, which already encode unread/mention/active.
it("leaves the channel name intact", () => {
setChannels([
{ id: 1, name: "spicy", type: "text", category: "Text Channels", position: 0, nsfw: true },
]);
sidebar.mount(container);
expect(container.querySelector("[data-testid='channel-1'] .ch-name")?.textContent).toBe(
"spicy",
);
});
});
describe("voice capacity readout", () => {
it("shows connected/limit when the channel has a user limit", () => {
setChannels([
{
id: 5,
name: "lounge",
type: "voice",
category: "Voice",
position: 0,
voice_max_users: 5,
},
]);
addVoiceUser(5, 10, "Alice");
addVoiceUser(5, 11, "Bob");
sidebar.mount(container);
expect(container.querySelector("[data-testid='channel-capacity-5']")?.textContent).toBe(
"2/5",
);
});
it("shows 0/limit for an empty limited channel", () => {
setChannels([
{
id: 5,
name: "lounge",
type: "voice",
category: "Voice",
position: 0,
voice_max_users: 3,
},
]);
sidebar.mount(container);
expect(container.querySelector("[data-testid='channel-capacity-5']")?.textContent).toBe(
"0/3",
);
});
// "3/0" would read as a bug, and the participant rows underneath already
// show the count.
it("shows nothing when the channel is unlimited", () => {
setChannels([{ id: 5, name: "lounge", type: "voice", category: "Voice", position: 0 }]);
addVoiceUser(5, 10, "Alice");
sidebar.mount(container);
expect(container.querySelector("[data-testid='channel-capacity-5']")).toBeNull();
});
it("is not rendered for a text channel", () => {
setChannels([
{ id: 1, name: "general", type: "text", category: "Text Channels", position: 0 },
]);
sidebar.mount(container);
expect(container.querySelector("[data-testid='channel-capacity-1']")).toBeNull();
});
// The client never blocks the join: its participant list can lag, and a
// refusal it invented would be uncorrectable. The server answers with
// CHANNEL_FULL.
it("still joins on click when the channel looks full", () => {
setChannels([
{
id: 5,
name: "lounge",
type: "voice",
category: "Voice",
position: 0,
voice_max_users: 1,
},
]);
addVoiceUser(5, 10, "Alice");
sidebar.mount(container);
(container.querySelector("[data-testid='channel-5']") as HTMLElement).click();
expect(onVoiceJoin).toHaveBeenCalledWith(5);
});
});
});
// ── Channel context menu: Edit/Delete follow MANAGE_CHANNELS ──
//
// These items used to be gated on the role NAME ("owner"/"admin"), so a custom
// role the server would happily let edit a channel saw no way to, and a role
// merely *called* "admin" with no channel bit saw items every click would be
// refused for.
describe("ChannelSidebar channel context menu permissions", () => {
let container: HTMLDivElement;
let sidebar: ReturnType<typeof createChannelSidebar>;
beforeEach(() => {
resetStores();
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
sidebar.destroy?.();
container.remove();
document.querySelectorAll(".context-menu").forEach((el) => el.remove());
});
function mountAs(roleName: string, permissions: number): HTMLElement | null {
setRoles([{ id: 9, name: roleName, color: null, permissions }]);
authStore.setState(() => ({
token: "tok",
user: { id: 9, username: "U", avatar: null, role: roleName },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
sidebar = createChannelSidebar({
onVoiceJoin: vi.fn(),
onVoiceLeave: vi.fn(),
onEditChannel: vi.fn(),
onDeleteChannel: vi.fn(),
});
setChannels([{ id: 1, name: "general", type: "text", category: "Text Channels", position: 0 }]);
sidebar.mount(container);
(container.querySelector('[data-channel-id="1"]') as HTMLElement).dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, clientX: 5, clientY: 5 }),
);
return document.querySelector('[data-testid="channel-context-menu"]');
}
it("offers Edit and Delete to a custom role holding MANAGE_CHANNELS", () => {
const menu = mountAs("Curator", Permission.MANAGE_CHANNELS);
expect(menu?.querySelector('[data-testid="ctx-edit-channel"]')).not.toBeNull();
expect(menu?.querySelector('[data-testid="ctx-delete-channel"]')).not.toBeNull();
});
it("hides them from a role named 'admin' that lacks the bit", () => {
const menu = mountAs("Admin", Permission.SEND_MESSAGES);
expect(menu?.querySelector('[data-testid="ctx-edit-channel"]')).toBeNull();
expect(menu?.querySelector('[data-testid="ctx-delete-channel"]')).toBeNull();
});
it("offers them to a role holding ADMINISTRATOR", () => {
const menu = mountAs("Owner", Permission.ADMINISTRATOR);
expect(menu?.querySelector('[data-testid="ctx-edit-channel"]')).not.toBeNull();
});
});