Files
OwnCord/Client/tauri-client/tests/unit/overlay-managers.test.ts
T
J3vb 9f74710d91 fix: send invite code instead of numeric ID for revoke endpoint
The client was sending DELETE /api/v1/invites/{id} with a numeric database
ID, but the server expects the invite code string. This caused a 404 since
no invite has a code matching a numeric ID.
2026-04-02 15:41:25 +02:00

1270 lines
35 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,
} = 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(),
}));
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("@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("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();
});
// No error toast should be shown
expect(mockShowToast).not.toHaveBeenCalled();
});
it("onJumpToMessage calls provided scroll callback and closes panel", 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);
expect(mockScrollToMessage).toHaveBeenCalledWith(1);
expect(mockPinnedMessagesDestroy).toHaveBeenCalled();
});
it("onJumpToMessage shows toast when message not in loaded window", async () => {
const api = makeMockApi();
const toast = makeMockToast();
const mockScrollToMessage = vi.fn().mockReturnValue(false);
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(999);
expect(mockScrollToMessage).toHaveBeenCalledWith(999);
expect(mockShowToast).toHaveBeenCalledWith(expect.stringContaining("not in"), "info");
// Panel should NOT close when message not found
expect(mockPinnedMessagesDestroy).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("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();
});
});
// ---------------------------------------------------------------------------
// 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("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("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 sets active channel and calls onJumpToMessage", () => {
const api = makeMockApi();
const mockJump = vi.fn().mockReturnValue(true);
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;
};
// Mock requestAnimationFrame to execute immediately
const origRaf = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => {
cb(0);
return 0;
};
opts.onSelectResult({ channel_id: 3, message_id: 42 });
expect(mockSetActiveChannel).toHaveBeenCalledWith(3);
expect(mockJump).toHaveBeenCalledWith(3, 42);
globalThis.requestAnimationFrame = origRaf;
});
it("onSelectResult shows toast when message not found", () => {
const api = makeMockApi();
const mockJump = vi.fn().mockReturnValue(false);
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;
};
const origRaf = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => {
cb(0);
return 0;
};
opts.onSelectResult({ channel_id: 3, message_id: 999 });
expect(mockShowToast).toHaveBeenCalledWith("Message not in loaded history", "info");
globalThis.requestAnimationFrame = origRaf;
});
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();
});
});