mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* 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>
2429 lines
78 KiB
TypeScript
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();
|
|
});
|
|
});
|