Files
OwnCord/Client/tauri-client/tests/unit/channel-sidebar.test.ts
T
J3vbandClaude Opus 5 8cf019c03f fix: 40 correctness fixes from the 2026-08-19 bug hunt (#1392)
* fix(identity): 1 defect(s) (OC-0151)

* fix(ws): 1 defect(s) (OC-0152)

* fix(admin): 1 defect(s) (OC-0153)

* fix(admin): 1 defect(s) (OC-0154)

* fix(voice): 2 defect(s) (OC-0155, OC-0167)

Replace distributeRoomKey's per-call offer counter with an instance-level
sliding-window budget shared by every voice_e2ee_offer send path.

- OC-0155: back-to-back rotations (the second run immediately by
  drainPendingRotationOrArmTimer) each got a fresh pacing budget, so their
  combined sends could exceed the server's single per-second cap.
- OC-0167: handleAnnounceInner's drain-time offer send bypassed pacing
  entirely, letting a key holder joining a large ongoing call burst every
  queued announce's offer unpaced.

The shared budget is reset in clearState() since the server's limit is
scoped per (sender, channel).

* fix(client): 1 defect(s) (OC-0156)

createPresenceSender dropped a queued custom_status when a later plain
status change superseded the pending retry. The retry now carries the
last committed custom_status forward.

* fix(client): 2 defect(s) (OC-0160, OC-0163)

OC-0160: exempt the handshake frames (ready, auth_ok) from the ws message
size limit and run the guard after parsing. A 'ready' frame grows unbounded
with member/channel/DM counts and carries no seq, so dropping it left the
client on empty stores with no error and no recovery path.

OC-0163: bracket a bare IPv6 host when building the wss:// URL so the
authority parses, and collapse bracketed/bare IPv6 literals to the same
cert_store_key so one server is not pinned (and user-confirmed) twice.

* fix(voice): 1 defect(s) (OC-0162)

updatePttKey armed the Rust poller when a PTT key was bound mid-call but
never applied the gate. The poller only emits 'ptt-state' on a press/release
transition, so an idle key produced no event and the already-published mic
stayed hot until the user's first physical press+release. Mirror the join-time
gate computation in updatePttKey, guarded on being in a call, polling actually
being live, and the mic not already being gated.

* fix(client): 1 defect(s) (OC-0164)

* fix(plugin): 1 defect(s) (OC-0165)

scanPluginDirectory now skips a malformed plugin subdirectory and joins its
error instead of aborting the whole scan, and LoadAll logs-and-continues so
one bad plugin directory cannot disable every other plugin.

* fix(ws): 1 defect(s) (OC-0166)

Route PresenceSelfEvent onto the owner's normal-priority queue instead of
letting it fall through to the UserTargetedEvent high-priority case, so a
user's own presence frames all share one FIFO and cannot be delivered out
of order relative to the visible presence_update path.

* fix(db): 1 defect(s) (OC-0168)

* fix(client): 1 defect(s) (OC-0169)

* fix(client): 1 defect(s) (OC-0171)

addMessage appended a broadcast at the tail even when trailing optimistic
rows were still unreconciled, so a message that committed while our own
send was in flight ended up ordered behind the row confirmSend later
stamped with a higher server id/timestamp. Insert before the trailing
unreconciled run instead.

* fix(voice): 1 defect(s) (OC-0172)

* fix(client): 1 defect(s) (OC-0174)

* fix(ws): 1 defect(s) (OC-0175)

* fix(client): 1 defect(s) (OC-0177)

* fix(client): 1 defect(s) (OC-0178)

* fix(voice): 1 defect(s) (OC-0179)

Undeafening no longer sends a voice_mute{muted:false} the server will
refuse while a moderator-imposed mute stands, matching the localServerMuted
guard already present in onMuteToggle.

* fix(client): 1 defect(s) (OC-0182)

* fix(plugin): 1 defect(s) (OC-0183)

* fix(client): 1 defect(s) (OC-0184)

Treat a trailing underscore as an emphasis delimiter, not part of the URL,
when scanning for the end of an autolinked URL.

* fix(client): 1 defect(s) (OC-0185)

Reveal .msg-actions-bar on .message:focus-within, not only on hover, so
keyboard users can see the per-message action buttons they Tab into
instead of activating them at opacity: 0.

Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* fix(client): 1 defect(s) (OC-0186)

* fix(client): 1 defect(s) (OC-0187)

The Add Server modal validated addresses with its own narrower regex that
never gained IPv6 support when api.ts's validator did, so an IPv6 server
could be logged into but never saved as a profile. Extract the validator
into src/lib/hostValidation.ts and use it from both call sites.

* fix(client): 1 defect(s) (OC-0189)

DM sidebar rows dropped mention counts entirely and the header total
excluded muted conversations outright, so a direct mention in a muted DM
was invisible. Render a mention badge that outranks the plain unread
badge, and count a muted channel's mentionCount toward the header total.

* fix(client): 1 defect(s) (OC-0190)

* fix(client): 1 defect(s) (OC-0191)

* fix(client): 2 defect(s) (OC-0157, OC-0176)

* fix(client): 1 defect(s) (OC-0161)

confirmTotp answers 401 for a wrong enrollment code while the session is still valid; firing the global onUnauthorized sink signed the user out and deleted their stored credential. Opt that one call out via a skipUnauthorized flag on doFetch.

* fix(admin): 1 defect(s) (OC-0173)

* fix(identity): 1 defect(s) (OC-0180)

* fix(admin): archived channel PATCH skips voice eviction and fan-out (OC-0158)

handlePatchChannel commits the AdminUpdateChannel write, then re-reads the
channel to drive voice eviction and the visibility fan-out. When that
post-commit re-read failed, the handler returned early: the archive was
durable but connected clients were never told and voice members were never
evicted, leaving users talking in a channel that no longer exists for them.

Drive the post-commit work off the values already in hand rather than
abandoning it when the re-read fails.

Adds SetPatchChannelPostCommitHook so the test can land a cancellation in
that exact window deterministically instead of racing wall-clock timing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* fix(admin): role changes commit with no client ever notified (OC-0170)

broadcastRoles derived its context from the inbound *http.Request, so the
roles_update fan-out was tied to the request lifetime. A role create,
update, or delete could commit to the database and then broadcast nothing
once that request context was done, leaving every connected client on a
stale role list until the next full resync.

Decouple the fan-out from the request context so the broadcast follows the
commit rather than the caller.

Adds BroadcastRolesForTest to reach broadcastRoles from the external test
package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* fix(client): username rename stomps the profile card header (OC-0188)

The account profile card's header is a resolveDisplayName() slot, but the
username-rename save path wrote the raw username straight into it. A user
with a display name set would see the header switch from their display
name to their new username after a rename, disagreeing with every other
surface that renders the same identity.

Resolve the header through the same display-name path the initial render
uses, so a rename updates the username field without touching the header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* fix(client): settings overlay never focuses when mounted already-open (OC-0181)

mount() synced initial state — including the show() that calls
focusDialog() — before appending root to the container. .focus() on a
still-detached subtree is a silent no-op, so a caller that mounts while
uiStore.settingsOpen is already true (ConnectPage's lazy first-open path)
got a visible overlay whose focus trap never captured focus: keyboard
users landed outside the dialog with Tab escaping to the page behind it.

Attach root before syncing initial state so focusDialog() runs against a
connected subtree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* chore: satisfy the CI gates for this fix batch

The fix batch's own commits left three CI gates red. Nothing here changes
behaviour; every edit is a lint, type, or formatting correction to code
this batch introduced.

golangci-lint:
- OC-0153 and OC-0173 replaced the last two uses of admin's setupSanitizer,
  and OC-0151 the last use of api's sanitizer, leaving both package-level
  bluemonday vars unused. Remove them along with the now-unused imports,
  and reword the comments that named them so they still explain why the
  fixpoint sanitizer is the right one without pointing at deleted symbols.
- Modernize the new handshake-deadline test's loop to range-over-int.

tsc --noEmit:
- jsdom ships no types and @types/jsdom is not a dependency, so declare the
  surface the new admin-panel test uses, following src/types/jitsi-rnnoise.d.ts.
- Narrow the last-call lookup instead of indexing under
  noUncheckedIndexedAccess, with an explicit failure message.
- membersStore.setState replaces whole state, so the presence-sender mocks
  must supply typingUsers.

prettier: reformat the five files this batch touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* chore(ledger): record the 2026-08-19 hunt and its fixes

Adds the 41 findings confirmed by the 2026-08-19 hunt and marks the 40
fixed on this branch, each with its commit, the test that pins it, and
revertProof "pass".

"pass" means an independent check, not the fixing agent's self-report:
every commit had its source diff reverted against the working tree, its
own test re-run and required to FAIL, then the source restored and the
test required to PASS. Commits whose tests live inline in Rust
#[cfg(test)] blocks were proven the same way at hunk level, splicing the
pre-fix source onto the post-fix test module.

OC-0159 is recorded as a duplicate of OC-0152: the flow-reconnect and
flow-message lenses independently found the same unbounded handshake
write and proposed the same helper over the same call sites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* test(e2e): make the voice-roster join fixture self-consistent

The voice-widget join test emitted a voice_state for user_id 4 claiming
username "newvoiceuser", but id 4 is "member2" in MOCK_MEMBERS_MULTI_ROLE.
A real server never sends a voice_state whose username disagrees with the
member record for that id, and the same file's VOICE_STATE_EVENT already
pairs id 1 with "testuser" correctly — this one event was the outlier.

The contradiction was invisible while the roster rendered the payload's
raw username. OC-0177 makes it resolve identity through membersStore so a
nickname shows the same in voice as everywhere else, at which point the
fixture's own inconsistency surfaced as a failure.

Send id 4's real username and assert on it. The test still covers what it
did before — a genuine join by a user not previously in voice, asserted by
name and by roster count.

Verified against the app unchanged: with the old fixture the spec fails
1/5 (matching CI), with this one it passes 5/5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-19 16:32:07 +02:00

2359 lines
76 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,
): void {
voiceStore.setState((prev) => {
const peerVerifications = new Map(prev.peerVerifications ?? []);
peerVerifications.set(userId, { userId, status, safetyNumber });
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 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();
});
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();
});
});