Files
OwnCord/Client/tauri-client/tests/unit/overlay-managers.test.ts
T
J3vbandClaude Fable 5 8787b9066d fix: batch of 34 correctness fixes across server and client (#1372)
* fix(client): 3 defect(s) (OC-0037, OC-0063, OC-0116)

Route the tray Status submenu through saveUserStatus() (mapping the legacy
"offline" to "invisible") so notifications, autoIdle, and reconnect presence
restore all agree with the tray's choice; build the connected overlay from
the auth_ok payload instead of a pre-dispatch authStore snapshot; keep the
TOTP overlay open across a rejected verify (totpPending latch) and retain
the partial token for the retry instead of clearing it in finally.

Hand-applied combined cluster preserved from the previous fix run's
overlap-guard block (both clusters edit main.ts).

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

* fix(voice): 2 defect(s) (OC-0010, OC-0011)

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

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

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

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

* fix(auth): 1 defect(s) (OC-0061)

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

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

* fix(service): 1 defect(s) (OC-0070)

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

* fix(service): 2 defect(s) (OC-0075, OC-0120)

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

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

* fix(client): 2 defect(s) (OC-0085, OC-0094)

Scope collapsed-category persistence to the connected host instead of the
server display name, and stop the DM back button from jumping to the first
text channel when DM mode was entered without recording channelBeforeDm.

* fix(service): 1 defect(s) (OC-0087)

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

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

* fix(api): 1 defect(s) (OC-0093)

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

* fix(dm): 1 defect(s) (OC-0119)

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

* fix(api): 1 defect(s) (OC-0137)

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

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

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

* fix(updater): 1 defect(s) (OC-0146)

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

* fix(mentions): 1 defect(s) (OC-0131)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 18:48:10 +02:00

1438 lines
41 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Mock } from "vitest";
// ---------------------------------------------------------------------------
// Mocks (vi.hoisted so they're available in vi.mock factories)
// ---------------------------------------------------------------------------
const {
mockLogError,
mockInviteManagerMount,
mockInviteManagerDestroy,
mockPinnedMessagesMount,
mockPinnedMessagesDestroy,
mockShowToast,
mockQuickSwitcherMount,
mockQuickSwitcherDestroy,
mockSearchOverlayMount,
mockSearchOverlayDestroy,
mockSetActiveChannel,
mockSetMessagePinned,
} = vi.hoisted(() => ({
mockLogError: vi.fn(),
mockInviteManagerMount: vi.fn(),
mockInviteManagerDestroy: vi.fn(),
mockPinnedMessagesMount: vi.fn(),
mockPinnedMessagesDestroy: vi.fn(),
mockShowToast: vi.fn(),
mockQuickSwitcherMount: vi.fn(),
mockQuickSwitcherDestroy: vi.fn(),
mockSearchOverlayMount: vi.fn(),
mockSearchOverlayDestroy: vi.fn(),
mockSetActiveChannel: vi.fn(),
mockSetMessagePinned: vi.fn(),
}));
vi.mock("@lib/logger", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: mockLogError,
}),
}));
vi.mock("@components/QuickSwitcher", () => ({
createQuickSwitcher: vi.fn(() => ({
mount: mockQuickSwitcherMount,
destroy: mockQuickSwitcherDestroy,
})),
}));
vi.mock("@components/InviteManager", () => ({
createInviteManager: vi.fn(() => ({
mount: mockInviteManagerMount,
destroy: mockInviteManagerDestroy,
})),
}));
vi.mock("@components/PinnedMessages", () => ({
createPinnedMessages: vi.fn(() => ({
mount: mockPinnedMessagesMount,
destroy: mockPinnedMessagesDestroy,
})),
}));
vi.mock("@components/SearchOverlay", () => ({
createSearchOverlay: vi.fn(() => ({
mount: mockSearchOverlayMount,
destroy: mockSearchOverlayDestroy,
})),
}));
vi.mock("@stores/channels.store", () => ({
setActiveChannel: mockSetActiveChannel,
}));
vi.mock("@stores/messages.store", () => ({
setMessagePinned: mockSetMessagePinned,
}));
vi.mock("@lib/toast", () => ({
initToast: vi.fn(),
teardownToast: vi.fn(),
showToast: mockShowToast,
}));
// ---------------------------------------------------------------------------
// Imports (after mocks)
// ---------------------------------------------------------------------------
import { createQuickSwitcher } from "@components/QuickSwitcher";
import { createInviteManager } from "@components/InviteManager";
import { createPinnedMessages } from "@components/PinnedMessages";
import { createSearchOverlay } from "@components/SearchOverlay";
import {
mapInviteResponse,
mapToPinnedMessage,
createQuickSwitcherManager,
createInviteManagerController,
createPinnedPanelController,
createSearchOverlayController,
} from "@pages/main-page/OverlayManagers";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeInviteResponse(overrides: Record<string, unknown> = {}) {
return {
id: 1,
code: "abc123xyz",
url: "https://example.com/abc123xyz",
max_uses: 10,
use_count: 3,
expires_at: null,
...overrides,
};
}
function makeMockApi(overrides: Record<string, unknown> = {}) {
return {
getInvites: vi.fn().mockResolvedValue([makeInviteResponse()]),
createInvite: vi.fn().mockResolvedValue(makeInviteResponse({ code: "new123" })),
revokeInvite: vi.fn().mockResolvedValue(undefined),
getPins: vi.fn().mockResolvedValue({
messages: [
{ id: 1, user: { username: "Alice" }, content: "Pinned msg", created_at: "2024-01-01" },
],
}),
unpinMessage: vi.fn().mockResolvedValue(undefined),
search: vi
.fn()
.mockResolvedValue({ results: [{ channel_id: 1, message_id: 10, content: "hello" }] }),
...overrides,
};
}
function makeMockToast() {
return { show: vi.fn() };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("createInviteManagerController", () => {
let root: HTMLDivElement;
beforeEach(() => {
root = document.createElement("div");
document.body.appendChild(root);
vi.clearAllMocks();
});
afterEach(() => {
root.remove();
});
it("opens invite manager and mounts to root", async () => {
const api = makeMockApi();
const toast = makeMockToast();
const controller = createInviteManagerController({
api: api as never,
getRoot: () => root,
});
await controller.open();
expect(createInviteManager).toHaveBeenCalledOnce();
expect(mockInviteManagerMount).toHaveBeenCalledWith(root);
});
it("filters out revoked invites before handing the list to InviteManager", async () => {
// Redemption enforces `revoked = 0` server-side (UseInviteAtomic), so a
// revoked invite rendered like a live one hands out a code that always
// fails. The list endpoint deliberately still includes revoked invites
// (for admin visibility elsewhere), so the client must filter here.
const api = makeMockApi({
getInvites: vi
.fn()
.mockResolvedValue([
makeInviteResponse({ code: "live1" }),
makeInviteResponse({ code: "dead1", revoked: true }),
]),
});
const controller = createInviteManagerController({
api: api as never,
getRoot: () => root,
});
await controller.open();
const opts = (createInviteManager as Mock).mock.calls[0]![0] as {
invites: Array<{ code: string }>;
};
expect(opts.invites.map((i) => i.code)).toEqual(["live1"]);
});
it("onRevokeInvite catches API error and re-throws for component handling", async () => {
const api = makeMockApi({
revokeInvite: vi.fn().mockRejectedValue(new Error("network error")),
});
const toast = makeMockToast();
const controller = createInviteManagerController({
api: api as never,
getRoot: () => root,
});
await controller.open();
// Extract the onRevokeInvite callback passed to InviteManager
const opts = (createInviteManager as Mock).mock.calls[0]![0] as {
onRevokeInvite: (code: string) => Promise<void>;
};
// The callback should re-throw so InviteManager's catch prevents optimistic removal
await expect(opts.onRevokeInvite("abc123xyz")).rejects.toThrow("network error");
// Controller should log the error with context
expect(mockLogError).toHaveBeenCalled();
});
it("onRevokeInvite succeeds normally when API works", async () => {
const api = makeMockApi();
const toast = makeMockToast();
const controller = createInviteManagerController({
api: api as never,
getRoot: () => root,
});
await controller.open();
const opts = (createInviteManager as Mock).mock.calls[0]![0] as {
onRevokeInvite: (code: string) => Promise<void>;
};
await expect(opts.onRevokeInvite("abc123xyz")).resolves.toBeUndefined();
expect(mockLogError).not.toHaveBeenCalled();
});
it("shows toast when open fails to load invites", async () => {
const api = makeMockApi({
getInvites: vi.fn().mockRejectedValue(new Error("load failed")),
});
const toast = makeMockToast();
const controller = createInviteManagerController({
api: api as never,
getRoot: () => root,
});
await controller.open();
expect(mockShowToast).toHaveBeenCalledWith("Failed to load invites", "error");
});
});
describe("createPinnedPanelController", () => {
let root: HTMLDivElement;
beforeEach(() => {
root = document.createElement("div");
document.body.appendChild(root);
vi.clearAllMocks();
});
afterEach(() => {
root.remove();
});
it("toggles pinned panel open and mounts to root", async () => {
const api = makeMockApi();
const toast = makeMockToast();
const controller = createPinnedPanelController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 42,
});
await controller.toggle();
expect(createPinnedMessages).toHaveBeenCalledOnce();
expect(mockPinnedMessagesMount).toHaveBeenCalledWith(root);
});
it("onUnpin catches API error, shows toast, and does NOT close the panel", async () => {
const api = makeMockApi({
unpinMessage: vi.fn().mockRejectedValue(new Error("unpin failed")),
});
const toast = makeMockToast();
const controller = createPinnedPanelController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 42,
});
await controller.toggle();
// Extract onUnpin callback passed to PinnedMessages
const opts = (createPinnedMessages as Mock).mock.calls[0]![0] as {
onUnpin: (msgId: number) => void;
};
// Call onUnpin — it should handle the error internally
opts.onUnpin(1);
// Wait for the async error handling to complete
await vi.waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith("Failed to unpin message", "error");
});
// Panel should NOT have been destroyed (still open)
expect(mockPinnedMessagesDestroy).not.toHaveBeenCalled();
});
it("onUnpin closes panel on success", async () => {
const api = makeMockApi();
const toast = makeMockToast();
const controller = createPinnedPanelController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 42,
});
await controller.toggle();
const opts = (createPinnedMessages as Mock).mock.calls[0]![0] as {
onUnpin: (msgId: number) => void;
};
opts.onUnpin(1);
// Wait for the async success handling to complete
await vi.waitFor(() => {
expect(mockPinnedMessagesDestroy).toHaveBeenCalled();
});
// The store's `pinned` flag is the row's only local authority (there is
// no server pin/unpin broadcast) — without this the row still says
// "Unpin" after a panel unpin, and re-clicking it calls unpinMessage on
// an already-unpinned message.
expect(mockSetMessagePinned).toHaveBeenCalledWith(42, 1, false);
// No error toast should be shown
expect(mockShowToast).not.toHaveBeenCalled();
});
it("onJumpToMessage forwards the panel's own channel id (captured at open time), not just the message id", async () => {
const api = makeMockApi();
const toast = makeMockToast();
const mockScrollToMessage = vi.fn().mockReturnValue(true);
const controller = createPinnedPanelController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 42,
onJumpToMessage: mockScrollToMessage,
});
await controller.toggle();
const opts = (createPinnedMessages as Mock).mock.calls[0]![0] as {
onJumpToMessage: (msgId: number) => void;
};
opts.onJumpToMessage(1);
// A caller juggling several channels (ChatArea re-reads currentChannelId
// live at click time) needs the panel's own channel, not whatever
// channel happens to be active when the click lands.
expect(mockScrollToMessage).toHaveBeenCalledWith(42, 1);
expect(mockPinnedMessagesDestroy).toHaveBeenCalled();
});
it("closes and delegates even for a message outside the loaded window", async () => {
const api = makeMockApi();
const toast = makeMockToast();
const mockJump = vi.fn();
const controller = createPinnedPanelController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 42,
onJumpToMessage: mockJump,
});
await controller.toggle();
const opts = (createPinnedMessages as Mock).mock.calls[0]![0] as {
onJumpToMessage: (msgId: number) => void;
};
opts.onJumpToMessage(999);
// The jumper fetches the around-window for an unloaded target and reports
// its own failures, so the panel no longer second-guesses it with a
// "not in loaded window" toast — it just gets out of the way.
expect(mockJump).toHaveBeenCalledWith(42, 999);
expect(mockPinnedMessagesDestroy).toHaveBeenCalled();
expect(mockShowToast).not.toHaveBeenCalled();
});
it("shows toast when toggle fails to load pins", async () => {
const api = makeMockApi({
getPins: vi.fn().mockRejectedValue(new Error("load failed")),
});
const toast = makeMockToast();
const controller = createPinnedPanelController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 42,
});
await controller.toggle();
expect(mockShowToast).toHaveBeenCalledWith("Failed to load pinned messages", "error");
});
it("does nothing when root is null", async () => {
const api = makeMockApi();
const controller = createPinnedPanelController({
api: api as never,
getRoot: () => null,
getCurrentChannelId: () => 42,
});
await controller.toggle();
expect(createPinnedMessages).not.toHaveBeenCalled();
});
it("does nothing when channelId is null", async () => {
const api = makeMockApi();
const controller = createPinnedPanelController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => null,
});
await controller.toggle();
expect(createPinnedMessages).not.toHaveBeenCalled();
});
it("toggle closes panel when already open", async () => {
const api = makeMockApi();
const controller = createPinnedPanelController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 42,
});
await controller.toggle();
expect(mockPinnedMessagesMount).toHaveBeenCalledOnce();
// Toggle again should close
await controller.toggle();
expect(mockPinnedMessagesDestroy).toHaveBeenCalled();
});
it("cleanup closes panel if open", async () => {
const api = makeMockApi();
const controller = createPinnedPanelController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 42,
});
await controller.toggle();
controller.cleanup();
expect(mockPinnedMessagesDestroy).toHaveBeenCalled();
});
it("does not mount a second panel from a double-click while getPins is in flight", async () => {
let resolvePins: (value: { messages: unknown[] }) => void;
const pending = new Promise<{ messages: unknown[] }>((resolve) => {
resolvePins = resolve;
});
const api = makeMockApi({ getPins: vi.fn().mockReturnValue(pending) });
const controller = createPinnedPanelController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 42,
});
const first = controller.toggle();
const second = controller.toggle(); // fires while getPins is still pending
resolvePins!({ messages: [] });
await Promise.all([first, second]);
expect(mockPinnedMessagesMount).toHaveBeenCalledOnce();
expect(api.getPins).toHaveBeenCalledOnce();
});
it("re-checks getRoot after the getPins() await and does not mount on a torn-down page", async () => {
// Same teardown-during-fetch race as InviteManagerController.open (OC-0055):
// toggle() must not mount on the pre-await root once the page has torn
// down while getPins() was in flight.
const api = makeMockApi();
let calls = 0;
const getRoot = vi.fn(() => {
calls++;
return calls === 1 ? root : null;
});
const controller = createPinnedPanelController({
api: api as never,
getRoot,
getCurrentChannelId: () => 42,
});
await controller.toggle();
expect(createPinnedMessages).not.toHaveBeenCalled();
expect(mockPinnedMessagesMount).not.toHaveBeenCalled();
});
it("cleanup is safe when no panel is open", () => {
const api = makeMockApi();
const controller = createPinnedPanelController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 42,
});
// Should not throw
controller.cleanup();
expect(mockPinnedMessagesDestroy).not.toHaveBeenCalled();
});
it("onJumpToMessage closes panel when no callback is provided", async () => {
const api = makeMockApi();
const controller = createPinnedPanelController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 42,
// no onJumpToMessage provided
});
await controller.toggle();
const opts = (createPinnedMessages as Mock).mock.calls[0]![0] as {
onJumpToMessage: (msgId: number) => void;
};
opts.onJumpToMessage(1);
// Without a callback, should just close the panel
expect(mockPinnedMessagesDestroy).toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// mapInviteResponse
// ---------------------------------------------------------------------------
describe("mapInviteResponse", () => {
it("maps a basic invite response with use_count", () => {
const result = mapInviteResponse({
id: 1,
code: "abc123",
url: "https://example.com/abc123",
max_uses: 10,
use_count: 3,
expires_at: "2024-12-31",
});
expect(result.code).toBe("abc123");
expect(result.uses).toBe(3);
expect(result.maxUses).toBe(10);
expect(result.expiresAt).toBe("2024-12-31");
expect(result.createdBy).toBe("unknown");
expect(result.createdAt).toBe("2024-12-31");
});
it("extracts created_by username from extra field", () => {
const raw = {
id: 1,
code: "abc",
url: "https://example.com/abc",
max_uses: 5,
use_count: 0,
expires_at: null,
created_by: { username: "Alice" },
};
const result = mapInviteResponse(raw as never);
expect(result.createdBy).toBe("Alice");
});
it("falls back to 'unknown' when created_by has no username", () => {
const raw = {
id: 1,
code: "abc",
url: "https://example.com/abc",
max_uses: 5,
use_count: 0,
expires_at: null,
created_by: {},
};
const result = mapInviteResponse(raw as never);
expect(result.createdBy).toBe("unknown");
});
it("falls back to 'unknown' when created_by is not an object", () => {
const raw = {
id: 1,
code: "abc",
url: "https://example.com/abc",
max_uses: 5,
use_count: 0,
expires_at: null,
created_by: "string-value",
};
const result = mapInviteResponse(raw as never);
expect(result.createdBy).toBe("unknown");
});
it("uses 'uses' extra field when use_count is undefined", () => {
const raw = {
id: 1,
code: "abc",
url: "https://example.com/abc",
max_uses: 5,
use_count: undefined,
expires_at: null,
uses: 7,
};
const result = mapInviteResponse(raw as never);
expect(result.uses).toBe(7);
});
it("defaults uses to 0 when neither use_count nor uses is present", () => {
const raw = {
id: 1,
code: "abc",
url: "https://example.com/abc",
max_uses: 5,
use_count: undefined,
expires_at: null,
};
const result = mapInviteResponse(raw as never);
expect(result.uses).toBe(0);
});
it("uses empty string for createdAt when expires_at is null", () => {
const raw = {
id: 1,
code: "abc",
url: "https://example.com/abc",
max_uses: 5,
use_count: 0,
expires_at: null,
};
const result = mapInviteResponse(raw as never);
expect(result.createdAt).toBe("");
});
});
// ---------------------------------------------------------------------------
// mapToPinnedMessage
// ---------------------------------------------------------------------------
describe("mapToPinnedMessage", () => {
it("maps a pinned message with created_at", () => {
const result = mapToPinnedMessage({
id: 1,
user: { username: "Alice" },
content: "Hello",
created_at: "2024-01-01",
});
expect(result.id).toBe(1);
expect(result.author).toBe("Alice");
expect(result.content).toBe("Hello");
expect(result.timestamp).toBe("2024-01-01");
expect(result.avatarColor).toMatch(/^hsl\(\d+, 55%, 55%\)$/);
});
it("falls back to timestamp when created_at is undefined", () => {
const result = mapToPinnedMessage({
id: 2,
user: { username: "Bob" },
content: "World",
timestamp: "2024-02-15",
});
expect(result.timestamp).toBe("2024-02-15");
});
it("falls back to empty string when neither created_at nor timestamp is set", () => {
const result = mapToPinnedMessage({
id: 3,
user: { username: "Charlie" },
content: "No timestamp",
});
expect(result.timestamp).toBe("");
});
it("generates deterministic avatar color for same username", () => {
const a = mapToPinnedMessage({ id: 1, user: { username: "Alice" }, content: "" });
const b = mapToPinnedMessage({ id: 2, user: { username: "Alice" }, content: "" });
expect(a.avatarColor).toBe(b.avatarColor);
});
it("generates different colors for different usernames", () => {
const a = mapToPinnedMessage({ id: 1, user: { username: "Alice" }, content: "" });
const b = mapToPinnedMessage({ id: 2, user: { username: "Bob" }, content: "" });
// Not guaranteed to be different in theory, but these specific names will differ
expect(a.avatarColor).not.toBe(b.avatarColor);
});
});
// ---------------------------------------------------------------------------
// createQuickSwitcherManager
// ---------------------------------------------------------------------------
describe("createQuickSwitcherManager", () => {
let root: HTMLDivElement;
beforeEach(() => {
root = document.createElement("div");
document.body.appendChild(root);
vi.clearAllMocks();
});
afterEach(() => {
root.remove();
});
it("opens quick switcher on Ctrl+K", () => {
const manager = createQuickSwitcherManager(() => root);
const cleanup = manager.attach();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
expect(createQuickSwitcher).toHaveBeenCalledOnce();
expect(mockQuickSwitcherMount).toHaveBeenCalledWith(root);
cleanup();
});
it("closes quick switcher on second Ctrl+K", () => {
const manager = createQuickSwitcherManager(() => root);
const cleanup = manager.attach();
// Open
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
expect(createQuickSwitcher).toHaveBeenCalledOnce();
// Close
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
expect(mockQuickSwitcherDestroy).toHaveBeenCalled();
cleanup();
});
it("opens quick switcher on Meta+K (macOS)", () => {
const manager = createQuickSwitcherManager(() => root);
const cleanup = manager.attach();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }));
expect(createQuickSwitcher).toHaveBeenCalledOnce();
cleanup();
});
it("does not open on plain K key without modifier", () => {
const manager = createQuickSwitcherManager(() => root);
const cleanup = manager.attach();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k" }));
expect(createQuickSwitcher).not.toHaveBeenCalled();
cleanup();
});
it("does nothing when root is null", () => {
const manager = createQuickSwitcherManager(() => null);
const cleanup = manager.attach();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
expect(createQuickSwitcher).not.toHaveBeenCalled();
cleanup();
});
it("cleanup removes the keydown listener and closes", () => {
const manager = createQuickSwitcherManager(() => root);
const cleanup = manager.attach();
// Open first
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
expect(createQuickSwitcher).toHaveBeenCalledOnce();
// Cleanup
cleanup();
// Verify destroy was called
expect(mockQuickSwitcherDestroy).toHaveBeenCalled();
// After cleanup, Ctrl+K should not open a new instance
vi.clearAllMocks();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
expect(createQuickSwitcher).not.toHaveBeenCalled();
});
it("onSelectChannel callback sets active channel", () => {
const manager = createQuickSwitcherManager(() => root);
const cleanup = manager.attach();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
// Extract the onSelectChannel callback
const opts = (createQuickSwitcher as Mock).mock.calls[0]![0] as {
onSelectChannel: (channelId: number) => void;
};
opts.onSelectChannel(42);
expect(mockSetActiveChannel).toHaveBeenCalledWith(42);
cleanup();
});
it("onClose callback resets instance so re-open works", () => {
const manager = createQuickSwitcherManager(() => root);
const cleanup = manager.attach();
// Open
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
expect(createQuickSwitcher).toHaveBeenCalledOnce();
// Simulate component calling onClose directly
const opts = (createQuickSwitcher as Mock).mock.calls[0]![0] as {
onClose: () => void;
};
opts.onClose();
// Re-open should work
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
expect(createQuickSwitcher).toHaveBeenCalledTimes(2);
cleanup();
});
it("does not open a second instance if already open", () => {
const manager = createQuickSwitcherManager(() => root);
const cleanup = manager.attach();
// Open
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
// Simulate trying to open from external code — the instance check should prevent it
// The attach() function only exposes Ctrl+K, and the toggle logic handles this
expect(createQuickSwitcher).toHaveBeenCalledOnce();
cleanup();
});
it("opens on Ctrl+K with CapsLock on (KeyboardEvent.key reports uppercase 'K')", () => {
// OC-0150: `e.key` reflects CapsLock/Shift state. A case-sensitive `=== "k"`
// check means CapsLock (or Ctrl+Shift+K) silently does nothing.
const manager = createQuickSwitcherManager(() => root);
const cleanup = manager.attach();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "K", ctrlKey: true }));
expect(createQuickSwitcher).toHaveBeenCalledOnce();
cleanup();
});
it("does not open on AltGr+K (Windows reports AltGr as ctrlKey+altKey)", () => {
// OC-0150: without an altKey exclusion, AltGr-produced characters on
// non-US layouts get swallowed by an unwanted preventDefault().
const manager = createQuickSwitcherManager(() => root);
const cleanup = manager.attach();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true, altKey: true }));
expect(createQuickSwitcher).not.toHaveBeenCalled();
cleanup();
});
it("does not open while isSuspended() reports true (e.g. settings overlay open)", () => {
// OC-0150: every other global shortcut honours isSuspended; the quick
// switcher never got the guard, so it could stack on top of Settings.
const manager = createQuickSwitcherManager(
() => root,
() => true,
);
const cleanup = manager.attach();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
expect(createQuickSwitcher).not.toHaveBeenCalled();
cleanup();
});
});
// ---------------------------------------------------------------------------
// createInviteManagerController (additional coverage)
// ---------------------------------------------------------------------------
describe("createInviteManagerController (additional)", () => {
let root: HTMLDivElement;
beforeEach(() => {
root = document.createElement("div");
document.body.appendChild(root);
vi.clearAllMocks();
});
afterEach(() => {
root.remove();
});
it("does nothing when root is null", async () => {
const api = makeMockApi();
const controller = createInviteManagerController({
api: api as never,
getRoot: () => null,
});
await controller.open();
expect(createInviteManager).not.toHaveBeenCalled();
});
it("does not open a second time if already open", async () => {
const api = makeMockApi();
const controller = createInviteManagerController({
api: api as never,
getRoot: () => root,
});
await controller.open();
await controller.open();
expect(createInviteManager).toHaveBeenCalledOnce();
});
it("does not mount a second overlay from a double-click while getInvites is in flight", async () => {
// `instance` is only assigned after the await, so a synchronous
// `instance !== null` guard alone lets a second call in while the first
// is still fetching — this is the concurrent case the sequential test
// above does not exercise.
let resolveInvites: (value: unknown[]) => void;
const pending = new Promise<unknown[]>((resolve) => {
resolveInvites = resolve;
});
const api = makeMockApi({ getInvites: vi.fn().mockReturnValue(pending) });
const controller = createInviteManagerController({
api: api as never,
getRoot: () => root,
});
const first = controller.open();
const second = controller.open(); // fires while getInvites is still pending
resolveInvites!([makeInviteResponse()]);
await Promise.all([first, second]);
expect(createInviteManager).toHaveBeenCalledOnce();
expect(api.getInvites).toHaveBeenCalledOnce();
});
it("cleanup destroys instance when open", async () => {
const api = makeMockApi();
const controller = createInviteManagerController({
api: api as never,
getRoot: () => root,
});
await controller.open();
controller.cleanup();
expect(mockInviteManagerDestroy).toHaveBeenCalled();
});
it("cleanup is safe when not open", () => {
const api = makeMockApi();
const controller = createInviteManagerController({
api: api as never,
getRoot: () => root,
});
// Should not throw
controller.cleanup();
expect(mockInviteManagerDestroy).not.toHaveBeenCalled();
});
it("onCreateInvite callback creates and maps an invite", async () => {
const api = makeMockApi();
const controller = createInviteManagerController({
api: api as never,
getRoot: () => root,
});
await controller.open();
const opts = (createInviteManager as Mock).mock.calls[0]![0] as {
onCreateInvite: () => Promise<unknown>;
};
const result = await opts.onCreateInvite();
expect(api.createInvite).toHaveBeenCalledWith({});
expect((result as { code: string }).code).toBe("new123");
});
it("onCopyLink copies code to clipboard", async () => {
const api = makeMockApi();
const mockWriteText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, {
clipboard: { writeText: mockWriteText },
});
const controller = createInviteManagerController({
api: api as never,
getRoot: () => root,
});
await controller.open();
const opts = (createInviteManager as Mock).mock.calls[0]![0] as {
onCopyLink: (code: string) => void;
};
opts.onCopyLink("test-code");
expect(mockWriteText).toHaveBeenCalledWith("test-code");
});
it("onClose callback destroys instance and allows re-open", async () => {
const api = makeMockApi();
const controller = createInviteManagerController({
api: api as never,
getRoot: () => root,
});
await controller.open();
const opts = (createInviteManager as Mock).mock.calls[0]![0] as {
onClose: () => void;
};
opts.onClose();
expect(mockInviteManagerDestroy).toHaveBeenCalled();
// Should be able to re-open
vi.clearAllMocks();
await controller.open();
expect(createInviteManager).toHaveBeenCalledOnce();
});
it("onError logs and shows toast", async () => {
const api = makeMockApi();
const controller = createInviteManagerController({
api: api as never,
getRoot: () => root,
});
await controller.open();
const opts = (createInviteManager as Mock).mock.calls[0]![0] as {
onError: (message: string) => void;
};
opts.onError("Something went wrong");
expect(mockLogError).toHaveBeenCalledWith("Something went wrong");
expect(mockShowToast).toHaveBeenCalledWith("Something went wrong", "error");
});
it("re-checks getRoot after the getInvites() await and does not mount on a torn-down page", async () => {
// Root is live when open() starts but the page tears down (MainPage.destroy
// nulls its root) while getInvites() is still in flight. open() must not
// resurrect the overlay on the stale, detached root it captured before the
// await — that leaves a document-level keydown listener with no teardown
// path (OC-0055).
const api = makeMockApi();
let calls = 0;
const getRoot = vi.fn(() => {
calls++;
return calls === 1 ? root : null;
});
const controller = createInviteManagerController({
api: api as never,
getRoot,
});
await controller.open();
expect(createInviteManager).not.toHaveBeenCalled();
expect(mockInviteManagerMount).not.toHaveBeenCalled();
});
it("onRevokeInvite passes invite code directly to API", async () => {
const api = makeMockApi();
const controller = createInviteManagerController({
api: api as never,
getRoot: () => root,
});
await controller.open();
const opts = (createInviteManager as Mock).mock.calls[0]![0] as {
onRevokeInvite: (code: string) => Promise<void>;
};
await expect(opts.onRevokeInvite("abc123xyz")).resolves.toBeUndefined();
expect(api.revokeInvite).toHaveBeenCalledWith("abc123xyz");
});
});
// ---------------------------------------------------------------------------
// createSearchOverlayController
// ---------------------------------------------------------------------------
describe("createSearchOverlayController", () => {
let root: HTMLDivElement;
beforeEach(() => {
root = document.createElement("div");
document.body.appendChild(root);
vi.clearAllMocks();
});
afterEach(() => {
root.remove();
});
it("opens search overlay and mounts to root", () => {
const api = makeMockApi();
const controller = createSearchOverlayController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 5,
});
controller.open();
expect(createSearchOverlay).toHaveBeenCalledOnce();
expect(mockSearchOverlayMount).toHaveBeenCalledWith(root);
});
it("does nothing when root is null", () => {
const api = makeMockApi();
const controller = createSearchOverlayController({
api: api as never,
getRoot: () => null,
getCurrentChannelId: () => 5,
});
controller.open();
expect(createSearchOverlay).not.toHaveBeenCalled();
});
it("does not open a second instance if already open", () => {
const api = makeMockApi();
const controller = createSearchOverlayController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 5,
});
controller.open();
controller.open();
expect(createSearchOverlay).toHaveBeenCalledOnce();
});
it("passes currentChannelId as undefined when null", () => {
const api = makeMockApi();
const controller = createSearchOverlayController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => null,
});
controller.open();
const opts = (createSearchOverlay as Mock).mock.calls[0]![0] as {
currentChannelId: number | undefined;
};
expect(opts.currentChannelId).toBeUndefined();
});
it("onSearch calls api.search and returns results", async () => {
const api = makeMockApi();
const controller = createSearchOverlayController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 5,
});
controller.open();
const opts = (createSearchOverlay as Mock).mock.calls[0]![0] as {
onSearch: (
query: string,
chId: number | undefined,
signal?: AbortSignal,
) => Promise<unknown[]>;
};
const results = await opts.onSearch("hello", 5);
expect(api.search).toHaveBeenCalledWith("hello", { channelId: 5 }, undefined);
expect(results).toEqual([{ channel_id: 1, message_id: 10, content: "hello" }]);
});
it("onSearch re-throws AbortError", async () => {
const abortError = new DOMException("Aborted", "AbortError");
const api = makeMockApi({
search: vi.fn().mockRejectedValue(abortError),
});
const controller = createSearchOverlayController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 5,
});
controller.open();
const opts = (createSearchOverlay as Mock).mock.calls[0]![0] as {
onSearch: (
query: string,
chId: number | undefined,
signal?: AbortSignal,
) => Promise<unknown[]>;
};
await expect(opts.onSearch("test", 5)).rejects.toThrow("Aborted");
// Should NOT show toast for abort errors
expect(mockShowToast).not.toHaveBeenCalled();
});
it("onSearch shows toast and re-throws on non-abort error", async () => {
const api = makeMockApi({
search: vi.fn().mockRejectedValue(new Error("network failure")),
});
const controller = createSearchOverlayController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 5,
});
controller.open();
const opts = (createSearchOverlay as Mock).mock.calls[0]![0] as {
onSearch: (
query: string,
chId: number | undefined,
signal?: AbortSignal,
) => Promise<unknown[]>;
};
await expect(opts.onSearch("test", 5)).rejects.toThrow("network failure");
expect(mockShowToast).toHaveBeenCalledWith("Search failed", "error");
expect(mockLogError).toHaveBeenCalled();
});
it("onSelectResult hands the whole jump to onJumpToMessage", () => {
const api = makeMockApi();
const mockJump = vi.fn();
const controller = createSearchOverlayController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 5,
onJumpToMessage: mockJump,
});
controller.open();
const opts = (createSearchOverlay as Mock).mock.calls[0]![0] as {
onSelectResult: (result: { channel_id: number; message_id: number }) => void;
};
opts.onSelectResult({ channel_id: 3, message_id: 42 });
expect(mockJump).toHaveBeenCalledWith(3, 42);
// The jumper owns the channel switch too, so the around-window fetch it
// may need is sequenced after the switch instead of racing it.
expect(mockSetActiveChannel).not.toHaveBeenCalled();
});
it("onSelectResult does not second-guess the jumper with a toast", () => {
const api = makeMockApi();
const mockJump = vi.fn();
const controller = createSearchOverlayController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 5,
onJumpToMessage: mockJump,
});
controller.open();
const opts = (createSearchOverlay as Mock).mock.calls[0]![0] as {
onSelectResult: (result: { channel_id: number; message_id: number }) => void;
};
opts.onSelectResult({ channel_id: 3, message_id: 999 });
// A hit outside the loaded page is now a fetch, not a dead end.
expect(mockShowToast).not.toHaveBeenCalled();
});
it("onSelectResult works without onJumpToMessage callback", () => {
const api = makeMockApi();
const controller = createSearchOverlayController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 5,
// no onJumpToMessage
});
controller.open();
const opts = (createSearchOverlay as Mock).mock.calls[0]![0] as {
onSelectResult: (result: { channel_id: number; message_id: number }) => void;
};
// Should not throw
opts.onSelectResult({ channel_id: 3, message_id: 42 });
expect(mockSetActiveChannel).toHaveBeenCalledWith(3);
});
it("onClose closes overlay and allows re-open", () => {
const api = makeMockApi();
const controller = createSearchOverlayController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 5,
});
controller.open();
const opts = (createSearchOverlay as Mock).mock.calls[0]![0] as {
onClose: () => void;
};
opts.onClose();
expect(mockSearchOverlayDestroy).toHaveBeenCalled();
// Re-open should work
vi.clearAllMocks();
controller.open();
expect(createSearchOverlay).toHaveBeenCalledOnce();
});
it("cleanup destroys instance when open", () => {
const api = makeMockApi();
const controller = createSearchOverlayController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 5,
});
controller.open();
controller.cleanup();
expect(mockSearchOverlayDestroy).toHaveBeenCalled();
});
it("cleanup is safe when not open", () => {
const api = makeMockApi();
const controller = createSearchOverlayController({
api: api as never,
getRoot: () => root,
getCurrentChannelId: () => 5,
});
// Should not throw
controller.cleanup();
expect(mockSearchOverlayDestroy).not.toHaveBeenCalled();
});
});