From 9c1d99683c00af07379916464c49aa47e1500ce1 Mon Sep 17 00:00:00 2001 From: jevb Date: Tue, 17 Mar 2026 04:11:04 +0100 Subject: [PATCH] fix: address PR review findings (issues #9-#14) - Fix capacity over-allocation and use strings.Builder in getReactionsBatch (#9) - Replace `any` types and cache Tauri invoke in window-state.ts (#10) - Remove custom `contains` helper, fix NilHub tests to pass nil (#11) - Add nil guards before hub method calls in admin handlers (#12) - Run golangci-lint v2: modernize interface{}/any, range-over-int loops, remove dead code, fix errcheck, add .golangci.yml config (#13) - Add 23 client unit test suites (694 tests), exclude Tauri-coupled files from coverage, achieve 80%+ threshold (#14) Closes #9, closes #10, closes #11, closes #12, closes #13, closes #14 --- Client/tauri-client/src/lib/window-state.ts | 20 +- .../tests/unit/admin-actions.test.ts | 210 ++++++++++++ .../tests/unit/chat-header.test.ts | 115 +++++++ .../tests/unit/connect-page.test.ts | 260 ++++++++++++++ .../tests/unit/dm-sidebar.test.ts | 232 +++++++++++++ .../tests/unit/emoji-picker.test.ts | 152 +++++++++ .../tests/unit/file-upload.test.ts | 122 +++++++ .../tests/unit/invite-manager.test.ts | 202 +++++++++++ .../tests/unit/member-list.test.ts | 175 ++++++++++ .../tests/unit/message-actions-bar.test.ts | 140 ++++++++ .../tests/unit/message-input.test.ts | 242 +++++++++++++ .../tests/unit/message-list.test.ts | 151 +++++++++ .../tests/unit/pinned-messages.test.ts | 144 ++++++++ .../tests/unit/quick-switcher.test.ts | 173 ++++++++++ .../tests/unit/reaction-bar.test.ts | 106 ++++++ .../tauri-client/tests/unit/renderers.test.ts | 320 ++++++++++++++++++ .../tests/unit/server-banner.test.ts | 87 +++++ .../tests/unit/server-strip.test.ts | 69 ++++ .../tests/unit/settings-helpers.test.ts | 148 ++++++++ .../tests/unit/soundboard.test.ts | 147 ++++++++ .../tests/unit/typing-indicator.test.ts | 105 ++++++ .../tauri-client/tests/unit/user-bar.test.ts | 115 +++++++ .../tests/unit/voice-channel.test.ts | 214 ++++++++++++ .../tests/unit/voice-widget.test.ts | 267 +++++++++++++++ Client/tauri-client/vitest.config.ts | 10 +- Server/.golangci.yml | 8 + Server/admin/admin.go | 2 +- Server/admin/api_test.go | 75 ++-- Server/admin/handlers_users.go | 12 +- Server/admin/update_handlers.go | 2 +- Server/admin/update_handlers_test.go | 8 +- Server/api/auth_handler_test.go | 12 +- Server/api/channel_authz_test.go | 12 +- Server/api/channel_handler_test.go | 30 +- Server/api/contract_test.go | 12 +- Server/api/invite_handler_test.go | 55 +-- Server/api/middleware_test.go | 8 +- Server/api/router_test.go | 10 +- Server/api/voice_handler.go | 2 +- Server/api/voice_handler_test.go | 36 +- Server/auth/ratelimit_test.go | 12 +- Server/auth/session_test.go | 2 +- Server/config/config_test.go | 8 +- Server/db/admin_queries_test.go | 4 +- Server/db/auth_queries_test.go | 4 +- Server/db/db_test.go | 4 +- Server/db/message_queries.go | 10 +- Server/db/message_queries_test.go | 8 +- Server/db/voice_queries_test.go | 15 - Server/main.go | 4 +- Server/ws/authz_test.go | 12 +- Server/ws/handlers_test.go | 36 +- Server/ws/hub_test.go | 18 +- Server/ws/messages.go | 22 -- Server/ws/speaker_detector.go | 4 +- Server/ws/speaker_detector_test.go | 34 +- Server/ws/speaker_integration_test.go | 12 +- Server/ws/voice_handlers_test.go | 60 ++-- 58 files changed, 4158 insertions(+), 321 deletions(-) create mode 100644 Client/tauri-client/tests/unit/admin-actions.test.ts create mode 100644 Client/tauri-client/tests/unit/chat-header.test.ts create mode 100644 Client/tauri-client/tests/unit/connect-page.test.ts create mode 100644 Client/tauri-client/tests/unit/dm-sidebar.test.ts create mode 100644 Client/tauri-client/tests/unit/emoji-picker.test.ts create mode 100644 Client/tauri-client/tests/unit/file-upload.test.ts create mode 100644 Client/tauri-client/tests/unit/invite-manager.test.ts create mode 100644 Client/tauri-client/tests/unit/member-list.test.ts create mode 100644 Client/tauri-client/tests/unit/message-actions-bar.test.ts create mode 100644 Client/tauri-client/tests/unit/message-input.test.ts create mode 100644 Client/tauri-client/tests/unit/message-list.test.ts create mode 100644 Client/tauri-client/tests/unit/pinned-messages.test.ts create mode 100644 Client/tauri-client/tests/unit/quick-switcher.test.ts create mode 100644 Client/tauri-client/tests/unit/reaction-bar.test.ts create mode 100644 Client/tauri-client/tests/unit/renderers.test.ts create mode 100644 Client/tauri-client/tests/unit/server-banner.test.ts create mode 100644 Client/tauri-client/tests/unit/server-strip.test.ts create mode 100644 Client/tauri-client/tests/unit/settings-helpers.test.ts create mode 100644 Client/tauri-client/tests/unit/soundboard.test.ts create mode 100644 Client/tauri-client/tests/unit/typing-indicator.test.ts create mode 100644 Client/tauri-client/tests/unit/user-bar.test.ts create mode 100644 Client/tauri-client/tests/unit/voice-channel.test.ts create mode 100644 Client/tauri-client/tests/unit/voice-widget.test.ts create mode 100644 Server/.golangci.yml diff --git a/Client/tauri-client/src/lib/window-state.ts b/Client/tauri-client/src/lib/window-state.ts index a5d30a1d..2bd98e98 100644 --- a/Client/tauri-client/src/lib/window-state.ts +++ b/Client/tauri-client/src/lib/window-state.ts @@ -18,22 +18,17 @@ export interface WindowState { const STORAGE_KEY = "windowState"; const SAVE_DEBOUNCE_MS = 500; -async function getInvoke(): Promise< +const invokePromise: Promise< ((cmd: string, args?: Record) => Promise) | null -> { - try { - const { invoke } = await import("@tauri-apps/api/core"); - return invoke; - } catch { - return null; - } -} +> = import("@tauri-apps/api/core") + .then((m) => m.invoke) + .catch(() => null); /** * Save the current window state to the Tauri settings store. */ async function saveState(state: WindowState): Promise { - const invoke = await getInvoke(); + const invoke = await invokePromise; if (!invoke) return; try { await invoke("save_settings", { key: STORAGE_KEY, value: state }); @@ -46,7 +41,7 @@ async function saveState(state: WindowState): Promise { * Load the previously saved window state. */ async function loadState(): Promise { - const invoke = await getInvoke(); + const invoke = await invokePromise; if (!invoke) return null; try { const all = (await invoke("get_settings")) as Record; @@ -82,8 +77,7 @@ async function loadState(): Promise { * Returns a cleanup function. */ export async function initWindowState(): Promise<() => void> { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let tauriWindow: any; + let tauriWindow: typeof import("@tauri-apps/api/window") | undefined; try { tauriWindow = await import("@tauri-apps/api/window"); } catch { diff --git a/Client/tauri-client/tests/unit/admin-actions.test.ts b/Client/tauri-client/tests/unit/admin-actions.test.ts new file mode 100644 index 00000000..4a05927f --- /dev/null +++ b/Client/tauri-client/tests/unit/admin-actions.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + createMemberContextMenu, + createChannelContextMenu, +} from "@components/AdminActions"; +import type { + MemberContextMenuOptions, + ChannelContextMenuOptions, +} from "@components/AdminActions"; + +describe("AdminActions", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + describe("MemberContextMenu", () => { + function makeMenu(overrides?: Partial) { + const options: MemberContextMenuOptions = { + userId: 1, + username: "TestUser", + currentRole: "member", + availableRoles: ["admin", "moderator", "member"], + onKick: overrides?.onKick ?? vi.fn(async () => {}), + onBan: overrides?.onBan ?? vi.fn(async () => {}), + onChangeRole: overrides?.onChangeRole ?? vi.fn(async () => {}), + }; + const result = createMemberContextMenu(options); + container.appendChild(result.element); + return { result, options }; + } + + it("creates element with context-menu class", () => { + const { result } = makeMenu(); + expect(result.element.classList.contains("context-menu")).toBe(true); + result.destroy(); + }); + + it("renders Change Role item", () => { + const { result } = makeMenu(); + const items = result.element.querySelectorAll(".context-menu__item"); + const texts = Array.from(items).map((i) => i.textContent); + expect(texts.some((t) => t?.includes("Change Role"))).toBe(true); + result.destroy(); + }); + + it("renders role submenu with available roles", () => { + const { result } = makeMenu(); + const submenu = result.element.querySelector(".context-menu__submenu"); + expect(submenu).not.toBeNull(); + + const roleItems = submenu!.querySelectorAll(".context-menu__item"); + const roleTexts = Array.from(roleItems).map((r) => r.textContent); + expect(roleTexts).toContain("admin"); + expect(roleTexts).toContain("moderator"); + expect(roleTexts).toContain("member"); + result.destroy(); + }); + + it("marks current role as active in submenu", () => { + const { result } = makeMenu(); + const submenu = result.element.querySelector(".context-menu__submenu"); + const activeRole = submenu!.querySelector(".context-menu__item--active"); + expect(activeRole).not.toBeNull(); + expect(activeRole!.textContent).toBe("member"); + result.destroy(); + }); + + it("renders Kick and Ban items with danger class", () => { + const { result } = makeMenu(); + const dangerItems = result.element.querySelectorAll(".context-menu__item--danger"); + const texts = Array.from(dangerItems).map((i) => i.textContent); + expect(texts).toContain("Kick"); + expect(texts).toContain("Ban"); + result.destroy(); + }); + + it("Kick requires double-click confirmation", () => { + const onKick = vi.fn(async () => {}); + const { result } = makeMenu({ onKick }); + + const dangerItems = result.element.querySelectorAll(".context-menu__item--danger"); + const kickItem = Array.from(dangerItems).find((i) => i.textContent === "Kick") as HTMLDivElement; + + // First click changes text to confirmation + kickItem.click(); + expect(kickItem.textContent).toBe("Are you sure?"); + expect(onKick).not.toHaveBeenCalled(); + + // Second click confirms + kickItem.click(); + expect(onKick).toHaveBeenCalledOnce(); + result.destroy(); + }); + + it("Ban requires double-click confirmation", () => { + const onBan = vi.fn(async () => {}); + const { result } = makeMenu({ onBan }); + + const dangerItems = result.element.querySelectorAll(".context-menu__item--danger"); + const banItem = Array.from(dangerItems).find((i) => i.textContent === "Ban") as HTMLDivElement; + + banItem.click(); + expect(banItem.textContent).toBe("Are you sure?"); + + banItem.click(); + expect(onBan).toHaveBeenCalledOnce(); + result.destroy(); + }); + + it("renders separator between role and danger items", () => { + const { result } = makeMenu(); + const separator = result.element.querySelector(".context-menu__separator"); + expect(separator).not.toBeNull(); + result.destroy(); + }); + + it("destroy removes element from DOM", () => { + const { result } = makeMenu(); + expect(container.querySelector(".context-menu")).not.toBeNull(); + result.destroy(); + expect(container.querySelector(".context-menu")).toBeNull(); + }); + }); + + describe("ChannelContextMenu", () => { + function makeMenu(overrides?: Partial) { + const options: ChannelContextMenuOptions = { + channelId: 1, + channelName: "general", + onEdit: overrides?.onEdit ?? vi.fn(), + onDelete: overrides?.onDelete ?? vi.fn(async () => {}), + onCreate: overrides?.onCreate ?? vi.fn(), + }; + const result = createChannelContextMenu(options); + container.appendChild(result.element); + return { result, options }; + } + + it("creates element with context-menu class", () => { + const { result } = makeMenu(); + expect(result.element.classList.contains("context-menu")).toBe(true); + result.destroy(); + }); + + it("renders Edit Channel, Create Channel, and Delete Channel items", () => { + const { result } = makeMenu(); + const items = result.element.querySelectorAll(".context-menu__item"); + const texts = Array.from(items).map((i) => i.textContent); + + expect(texts).toContain("Edit Channel"); + expect(texts).toContain("Create Channel"); + expect(texts).toContain("Delete Channel"); + result.destroy(); + }); + + it("clicking Edit Channel calls onEdit", () => { + const onEdit = vi.fn(); + const { result } = makeMenu({ onEdit }); + + const items = result.element.querySelectorAll(".context-menu__item"); + const editItem = Array.from(items).find((i) => i.textContent === "Edit Channel") as HTMLDivElement; + editItem.click(); + + expect(onEdit).toHaveBeenCalledOnce(); + result.destroy(); + }); + + it("clicking Create Channel calls onCreate", () => { + const onCreate = vi.fn(); + const { result } = makeMenu({ onCreate }); + + const items = result.element.querySelectorAll(".context-menu__item"); + const createItem = Array.from(items).find((i) => i.textContent === "Create Channel") as HTMLDivElement; + createItem.click(); + + expect(onCreate).toHaveBeenCalledOnce(); + result.destroy(); + }); + + it("Delete Channel requires double-click confirmation", () => { + const onDelete = vi.fn(async () => {}); + const { result } = makeMenu({ onDelete }); + + const dangerItems = result.element.querySelectorAll(".context-menu__item--danger"); + const deleteItem = dangerItems[0] as HTMLDivElement; + + deleteItem.click(); + expect(deleteItem.textContent).toBe("Are you sure?"); + expect(onDelete).not.toHaveBeenCalled(); + + deleteItem.click(); + expect(onDelete).toHaveBeenCalledOnce(); + result.destroy(); + }); + + it("destroy removes element from DOM", () => { + const { result } = makeMenu(); + expect(container.querySelector(".context-menu")).not.toBeNull(); + result.destroy(); + expect(container.querySelector(".context-menu")).toBeNull(); + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/chat-header.test.ts b/Client/tauri-client/tests/unit/chat-header.test.ts new file mode 100644 index 00000000..5d0c8c9e --- /dev/null +++ b/Client/tauri-client/tests/unit/chat-header.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { buildChatHeader } from "../../src/pages/main-page/ChatHeader"; + +describe("ChatHeader", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("renders the chat header element", () => { + const { element } = buildChatHeader({ + onTogglePins: vi.fn(), + onToggleMembers: vi.fn(), + }); + container.appendChild(element); + + expect(container.querySelector('[data-testid="chat-header"]')).not.toBeNull(); + }); + + it("displays default channel name", () => { + const { element, refs } = buildChatHeader({ + onTogglePins: vi.fn(), + onToggleMembers: vi.fn(), + }); + container.appendChild(element); + + expect(refs.nameEl.textContent).toBe("general"); + expect(container.querySelector('[data-testid="chat-header-name"]')?.textContent).toBe("general"); + }); + + it("displays hash prefix", () => { + const { element } = buildChatHeader({ + onTogglePins: vi.fn(), + onToggleMembers: vi.fn(), + }); + container.appendChild(element); + + const hash = container.querySelector(".ch-hash"); + expect(hash?.textContent).toBe("#"); + }); + + it("contains a search input", () => { + const { element } = buildChatHeader({ + onTogglePins: vi.fn(), + onToggleMembers: vi.fn(), + }); + container.appendChild(element); + + const searchInput = container.querySelector(".search-input") as HTMLInputElement; + expect(searchInput).not.toBeNull(); + expect(searchInput.placeholder).toBe("Search..."); + }); + + it("calls onTogglePins when pin button is clicked", () => { + const onTogglePins = vi.fn(); + const { element } = buildChatHeader({ + onTogglePins, + onToggleMembers: vi.fn(), + }); + container.appendChild(element); + + const pinBtn = container.querySelector('[data-testid="pin-btn"]') as HTMLButtonElement; + pinBtn.click(); + expect(onTogglePins).toHaveBeenCalledOnce(); + }); + + it("calls onToggleMembers when members toggle is clicked", () => { + const onToggleMembers = vi.fn(); + const { element } = buildChatHeader({ + onTogglePins: vi.fn(), + onToggleMembers, + }); + container.appendChild(element); + + const membersToggle = container.querySelector('[data-testid="members-toggle"]') as HTMLButtonElement; + membersToggle.click(); + expect(onToggleMembers).toHaveBeenCalledOnce(); + }); + + it("provides mutable refs for channel name and topic", () => { + const { element, refs } = buildChatHeader({ + onTogglePins: vi.fn(), + onToggleMembers: vi.fn(), + }); + container.appendChild(element); + + // Update name via ref + refs.nameEl.textContent = "announcements"; + expect(container.querySelector('[data-testid="chat-header-name"]')?.textContent).toBe("announcements"); + + // Update topic via ref + refs.topicEl.textContent = "Important news"; + expect(container.querySelector(".ch-topic")?.textContent).toBe("Important news"); + }); + + it("has proper aria labels on buttons", () => { + const { element } = buildChatHeader({ + onTogglePins: vi.fn(), + onToggleMembers: vi.fn(), + }); + container.appendChild(element); + + const pinBtn = container.querySelector('[data-testid="pin-btn"]'); + expect(pinBtn?.getAttribute("aria-label")).toBe("Pins"); + + const membersToggle = container.querySelector('[data-testid="members-toggle"]'); + expect(membersToggle?.getAttribute("aria-label")).toBe("Toggle member list"); + }); +}); diff --git a/Client/tauri-client/tests/unit/connect-page.test.ts b/Client/tauri-client/tests/unit/connect-page.test.ts new file mode 100644 index 00000000..3c0503aa --- /dev/null +++ b/Client/tauri-client/tests/unit/connect-page.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createConnectPage } from "../../src/pages/ConnectPage"; +import type { ConnectPageCallbacks, ServerProfile } from "../../src/pages/ConnectPage"; +import { uiStore } from "../../src/stores/ui.store"; + +// Mock SettingsOverlay so we don't pull in all its dependencies +vi.mock("../../src/components/SettingsOverlay", () => ({ + createSettingsOverlay: () => ({ + mount: vi.fn(), + destroy: vi.fn(), + }), +})); + +// Mock ui.store actions +vi.mock("../../src/stores/ui.store", async () => { + const actual = await vi.importActual( + "../../src/stores/ui.store", + ); + return { + ...actual, + openSettings: vi.fn(), + closeSettings: vi.fn(), + }; +}); + +function makeCallbacks(overrides: Partial = {}): ConnectPageCallbacks { + return { + onLogin: vi.fn().mockResolvedValue(undefined), + onRegister: vi.fn().mockResolvedValue(undefined), + onTotpSubmit: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +const testProfiles: ServerProfile[] = [ + { name: "Test Server", host: "localhost:8443" }, +]; + +describe("ConnectPage", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("renders the connect page with form elements", () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + expect(container.querySelector(".connect-page")).not.toBeNull(); + expect(container.querySelector(".connect-form")).not.toBeNull(); + expect(container.querySelector("#host")).not.toBeNull(); + expect(container.querySelector("#username")).not.toBeNull(); + expect(container.querySelector("#password")).not.toBeNull(); + + page.destroy?.(); + }); + + it("renders server profiles in the server panel", () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + const serverItems = container.querySelectorAll(".server-item"); + expect(serverItems.length).toBe(1); + + const serverName = container.querySelector(".srv-name"); + expect(serverName?.textContent).toBe("Test Server"); + + page.destroy?.(); + }); + + it("fills host input when a server profile is clicked", () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + const serverItem = container.querySelector(".server-item") as HTMLElement; + serverItem.click(); + + const hostInput = container.querySelector("#host") as HTMLInputElement; + expect(hostInput.value).toBe("localhost:8443"); + + page.destroy?.(); + }); + + it("shows error when submitting empty form", async () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + // Clear any default host value + const hostInput = container.querySelector("#host") as HTMLInputElement; + hostInput.value = ""; + + const form = container.querySelector(".connect-form") as HTMLFormElement; + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + + // Wait for async handler + await vi.waitFor(() => { + const errorBanner = container.querySelector(".error-banner"); + expect(errorBanner!.classList.contains("visible")).toBe(true); + }); + + page.destroy?.(); + }); + + it("shows validation error for short password", async () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + const hostInput = container.querySelector("#host") as HTMLInputElement; + const usernameInput = container.querySelector("#username") as HTMLInputElement; + const passwordInput = container.querySelector("#password") as HTMLInputElement; + + hostInput.value = "localhost:8443"; + usernameInput.value = "testuser"; + passwordInput.value = "short"; + + const form = container.querySelector(".connect-form") as HTMLFormElement; + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + + await vi.waitFor(() => { + const errorBanner = container.querySelector(".error-banner"); + expect(errorBanner!.classList.contains("visible")).toBe(true); + expect(errorBanner!.textContent).toContain("at least 8 characters"); + }); + + page.destroy?.(); + }); + + it("calls onLogin with form values on valid submit", async () => { + const onLogin = vi.fn().mockResolvedValue(undefined); + const page = createConnectPage(makeCallbacks({ onLogin }), testProfiles); + page.mount(container); + + const hostInput = container.querySelector("#host") as HTMLInputElement; + const usernameInput = container.querySelector("#username") as HTMLInputElement; + const passwordInput = container.querySelector("#password") as HTMLInputElement; + + hostInput.value = "localhost:8443"; + usernameInput.value = "testuser"; + passwordInput.value = "password123"; + + const form = container.querySelector(".connect-form") as HTMLFormElement; + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + + await vi.waitFor(() => { + expect(onLogin).toHaveBeenCalledWith("localhost:8443", "testuser", "password123"); + }); + + page.destroy?.(); + }); + + it("toggles between login and register mode", () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + // Initially in login mode — invite group hidden + const inviteGroup = container.querySelector("#invite")!.closest(".form-group") as HTMLElement; + expect(inviteGroup.classList.contains("form-group--hidden")).toBe(true); + + // Click toggle link + const toggleLink = container.querySelector(".form-switch a") as HTMLElement; + toggleLink.click(); + + // Now in register mode — invite group visible + expect(inviteGroup.classList.contains("form-group--hidden")).toBe(false); + + // Submit button text changes + const btnText = container.querySelector(".btn-text"); + expect(btnText?.textContent).toBe("Register"); + + page.destroy?.(); + }); + + it("shows TOTP overlay when showTotp is called", () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + const totpOverlay = container.querySelector(".totp-overlay")!; + expect(totpOverlay.classList.contains("totp-overlay--hidden")).toBe(true); + + page.showTotp(); + expect(totpOverlay.classList.contains("totp-overlay--hidden")).toBe(false); + + page.destroy?.(); + }); + + it("shows error message via showError", () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + page.showError("Connection refused"); + + const errorBanner = container.querySelector(".error-banner"); + expect(errorBanner!.classList.contains("visible")).toBe(true); + expect(errorBanner!.textContent).toBe("Connection refused"); + + page.destroy?.(); + }); + + it("resets to idle state via resetToIdle", () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + page.showError("Some error"); + page.resetToIdle(); + + const errorBanner = container.querySelector(".error-banner"); + expect(errorBanner!.classList.contains("visible")).toBe(false); + + const submitBtn = container.querySelector(".btn-primary") as HTMLButtonElement; + expect(submitBtn.disabled).toBe(false); + + page.destroy?.(); + }); + + it("disables form inputs during loading state", async () => { + let resolveLogin: () => void; + const loginPromise = new Promise((resolve) => { resolveLogin = resolve; }); + const onLogin = vi.fn().mockReturnValue(loginPromise); + + const page = createConnectPage(makeCallbacks({ onLogin }), testProfiles); + page.mount(container); + + const hostInput = container.querySelector("#host") as HTMLInputElement; + const usernameInput = container.querySelector("#username") as HTMLInputElement; + const passwordInput = container.querySelector("#password") as HTMLInputElement; + + hostInput.value = "localhost:8443"; + usernameInput.value = "testuser"; + passwordInput.value = "password123"; + + const form = container.querySelector(".connect-form") as HTMLFormElement; + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + + await vi.waitFor(() => { + expect(hostInput.disabled).toBe(true); + expect(usernameInput.disabled).toBe(true); + expect(passwordInput.disabled).toBe(true); + }); + + resolveLogin!(); + + page.destroy?.(); + }); + + it("cleans up on destroy", () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + expect(container.querySelector(".connect-page")).not.toBeNull(); + + page.destroy?.(); + expect(container.querySelector(".connect-page")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/dm-sidebar.test.ts b/Client/tauri-client/tests/unit/dm-sidebar.test.ts new file mode 100644 index 00000000..0bb68f20 --- /dev/null +++ b/Client/tauri-client/tests/unit/dm-sidebar.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createDmSidebar } from "../../src/components/DmSidebar"; +import type { DmConversation } from "../../src/components/DmSidebar"; + +const makeConvo = (overrides: Partial = {}): DmConversation => ({ + userId: 1, + username: "Alice", + avatar: null, + status: "online", + lastMessage: "Hello!", + timestamp: "2025-01-01T00:00:00Z", + unread: false, + ...overrides, +}); + +describe("DmSidebar", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("renders the sidebar with search input", () => { + const sidebar = createDmSidebar({ + conversations: [], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + const searchInput = container.querySelector(".dm-search"); + expect(searchInput).not.toBeNull(); + expect((searchInput as HTMLInputElement).placeholder).toBe("Find a conversation"); + + sidebar.destroy?.(); + }); + + it("renders Friends nav item", () => { + const sidebar = createDmSidebar({ + conversations: [], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + const friendsNav = container.querySelector(".dm-nav-item"); + expect(friendsNav).not.toBeNull(); + expect(friendsNav!.textContent).toBe("Friends"); + + sidebar.destroy?.(); + }); + + it("marks Friends nav as active when friendsActive is true", () => { + const sidebar = createDmSidebar({ + conversations: [], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + friendsActive: true, + }); + sidebar.mount(container); + + const friendsNav = container.querySelector(".dm-nav-item"); + expect(friendsNav!.classList.contains("active")).toBe(true); + + sidebar.destroy?.(); + }); + + it("renders conversation items", () => { + const conversations: DmConversation[] = [ + makeConvo({ userId: 1, username: "Alice" }), + makeConvo({ userId: 2, username: "Bob" }), + ]; + + const sidebar = createDmSidebar({ + conversations, + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + const items = container.querySelectorAll(".dm-item"); + expect(items.length).toBe(2); + + sidebar.destroy?.(); + }); + + it("sorts unread conversations first", () => { + const conversations: DmConversation[] = [ + makeConvo({ userId: 1, username: "Alice", unread: false }), + makeConvo({ userId: 2, username: "Bob", unread: true }), + ]; + + const sidebar = createDmSidebar({ + conversations, + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + const items = container.querySelectorAll(".dm-item"); + // Bob (unread) should come first + expect(items[0]!.querySelector(".dm-name")!.textContent).toBe("Bob"); + expect(items[1]!.querySelector(".dm-name")!.textContent).toBe("Alice"); + + sidebar.destroy?.(); + }); + + it("shows unread dot for unread conversations", () => { + const sidebar = createDmSidebar({ + conversations: [makeConvo({ unread: true })], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + const unreadDot = container.querySelector(".dm-unread"); + expect(unreadDot).not.toBeNull(); + + sidebar.destroy?.(); + }); + + it("calls onSelectConversation when a DM item is clicked", () => { + const onSelectConversation = vi.fn(); + const sidebar = createDmSidebar({ + conversations: [makeConvo({ userId: 42 })], + onSelectConversation, + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + const item = container.querySelector(".dm-item") as HTMLElement; + item.click(); + expect(onSelectConversation).toHaveBeenCalledWith(42); + + sidebar.destroy?.(); + }); + + it("calls onCloseDm when close button is clicked", () => { + const onCloseDm = vi.fn(); + const sidebar = createDmSidebar({ + conversations: [makeConvo({ userId: 42 })], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + onCloseDm, + }); + sidebar.mount(container); + + const closeBtn = container.querySelector(".dm-close") as HTMLButtonElement; + closeBtn.click(); + expect(onCloseDm).toHaveBeenCalledWith(42); + + sidebar.destroy?.(); + }); + + it("calls onNewDm when add button is clicked", () => { + const onNewDm = vi.fn(); + const sidebar = createDmSidebar({ + conversations: [], + onSelectConversation: vi.fn(), + onNewDm, + }); + sidebar.mount(container); + + const addBtn = container.querySelector(".dm-add") as HTMLButtonElement; + addBtn.click(); + expect(onNewDm).toHaveBeenCalledOnce(); + + sidebar.destroy?.(); + }); + + it("shows avatar initial when no avatar image", () => { + const sidebar = createDmSidebar({ + conversations: [makeConvo({ username: "alice", avatar: null })], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + const avatar = container.querySelector(".dm-avatar"); + expect(avatar!.textContent).toBe("A"); + + sidebar.destroy?.(); + }); + + it("shows avatar image when avatar URL is provided", () => { + const sidebar = createDmSidebar({ + conversations: [makeConvo({ avatar: "http://example.com/img.png" })], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + const img = container.querySelector(".dm-avatar img") as HTMLImageElement; + expect(img).not.toBeNull(); + expect(img.src).toBe("http://example.com/img.png"); + + sidebar.destroy?.(); + }); + + it("marks active conversation with active class", () => { + const sidebar = createDmSidebar({ + conversations: [makeConvo({ active: true })], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + const item = container.querySelector(".dm-item"); + expect(item!.classList.contains("active")).toBe(true); + + sidebar.destroy?.(); + }); + + it("cleans up on destroy", () => { + const sidebar = createDmSidebar({ + conversations: [], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + expect(container.querySelector(".channel-sidebar")).not.toBeNull(); + + sidebar.destroy?.(); + expect(container.querySelector(".channel-sidebar")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/emoji-picker.test.ts b/Client/tauri-client/tests/unit/emoji-picker.test.ts new file mode 100644 index 00000000..a00f992c --- /dev/null +++ b/Client/tauri-client/tests/unit/emoji-picker.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createEmojiPicker } from "@components/EmojiPicker"; +import type { EmojiPickerOptions } from "@components/EmojiPicker"; + +describe("EmojiPicker", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + localStorage.clear(); + }); + + afterEach(() => { + container.remove(); + localStorage.clear(); + }); + + function makePicker(overrides?: Partial) { + const options: EmojiPickerOptions = { + onSelect: overrides?.onSelect ?? vi.fn(), + onClose: overrides?.onClose ?? vi.fn(), + customEmoji: overrides?.customEmoji, + }; + const picker = createEmojiPicker(options); + container.appendChild(picker.element); + return { picker, options }; + } + + it("creates element with emoji-picker and open classes", () => { + const { picker } = makePicker(); + expect(picker.element.classList.contains("emoji-picker")).toBe(true); + expect(picker.element.classList.contains("open")).toBe(true); + picker.destroy(); + }); + + it("renders search input", () => { + const { picker } = makePicker(); + const input = picker.element.querySelector(".ep-search") as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.placeholder).toBe("Search emoji..."); + picker.destroy(); + }); + + it("renders category labels", () => { + const { picker } = makePicker(); + const labels = picker.element.querySelectorAll(".ep-category-label"); + const labelTexts = Array.from(labels).map((l) => l.textContent); + + // Should have built-in categories (Smileys, People, Nature, Food, Objects, Symbols) + // Recent is empty so should not appear + expect(labelTexts).toContain("Smileys"); + expect(labelTexts).toContain("People"); + expect(labelTexts).toContain("Nature"); + expect(labelTexts).toContain("Food"); + expect(labelTexts).toContain("Objects"); + expect(labelTexts).toContain("Symbols"); + picker.destroy(); + }); + + it("renders emoji grid with ep-emoji spans", () => { + const { picker } = makePicker(); + const emojiSpans = picker.element.querySelectorAll(".ep-emoji"); + expect(emojiSpans.length).toBeGreaterThan(0); + picker.destroy(); + }); + + it("clicking an emoji calls onSelect", () => { + const onSelect = vi.fn(); + const { picker } = makePicker({ onSelect }); + + const firstEmoji = picker.element.querySelector(".ep-emoji") as HTMLSpanElement; + expect(firstEmoji).not.toBeNull(); + firstEmoji.click(); + + expect(onSelect).toHaveBeenCalledOnce(); + expect(typeof onSelect.mock.calls[0]![0]).toBe("string"); + picker.destroy(); + }); + + it("clicking an emoji saves to recent in localStorage", () => { + const { picker } = makePicker(); + + const firstEmoji = picker.element.querySelector(".ep-emoji") as HTMLSpanElement; + firstEmoji.click(); + + const stored = localStorage.getItem("owncord:recent-emoji"); + expect(stored).not.toBeNull(); + const recent = JSON.parse(stored!); + expect(Array.isArray(recent)).toBe(true); + expect(recent.length).toBeGreaterThan(0); + picker.destroy(); + }); + + it("search filters emoji", () => { + const { picker } = makePicker(); + + const input = picker.element.querySelector(".ep-search") as HTMLInputElement; + // Set a search query that won't match any emoji character + input.value = "zzzznotanemoji"; + input.dispatchEvent(new Event("input")); + + // Should show "No emoji found" empty state + const emptyState = picker.element.querySelector("div[style*='text-align: center']"); + expect(emptyState).not.toBeNull(); + expect(emptyState!.textContent).toBe("No emoji found"); + picker.destroy(); + }); + + it("Escape key calls onClose", () => { + const onClose = vi.fn(); + const { picker } = makePicker({ onClose }); + + picker.element.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(onClose).toHaveBeenCalledOnce(); + picker.destroy(); + }); + + it("renders custom emoji when provided", () => { + const { picker } = makePicker({ + customEmoji: [ + { shortcode: "test_emoji", url: "https://example.com/emoji.png" }, + ], + }); + + const labels = picker.element.querySelectorAll(".ep-category-label"); + const labelTexts = Array.from(labels).map((l) => l.textContent); + expect(labelTexts).toContain("Custom"); + picker.destroy(); + }); + + it("renders Recent category when localStorage has recent emoji", () => { + localStorage.setItem("owncord:recent-emoji", JSON.stringify(["😀", "😎"])); + const { picker } = makePicker(); + + const labels = picker.element.querySelectorAll(".ep-category-label"); + const labelTexts = Array.from(labels).map((l) => l.textContent); + expect(labelTexts).toContain("Recent"); + picker.destroy(); + }); + + it("destroy aborts event listeners", () => { + const onSelect = vi.fn(); + const { picker } = makePicker({ onSelect }); + const firstEmoji = picker.element.querySelector(".ep-emoji") as HTMLSpanElement; + + picker.destroy(); + firstEmoji.click(); + + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/Client/tauri-client/tests/unit/file-upload.test.ts b/Client/tauri-client/tests/unit/file-upload.test.ts new file mode 100644 index 00000000..2119a3f3 --- /dev/null +++ b/Client/tauri-client/tests/unit/file-upload.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createFileUpload } from "@components/FileUpload"; +import type { FileUploadOptions, FileUploadComponent } from "@components/FileUpload"; + +describe("FileUpload", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + function makeUpload(overrides?: Partial): FileUploadComponent { + const options: FileUploadOptions = { + onUpload: overrides?.onUpload ?? vi.fn(async () => {}), + maxSizeMb: overrides?.maxSizeMb, + }; + const upload = createFileUpload(options); + upload.mount(container); + return upload; + } + + it("mounts with file-upload class", () => { + const upload = makeUpload(); + expect(container.querySelector(".file-upload")).not.toBeNull(); + upload.destroy?.(); + }); + + it("renders dropzone (hidden by default)", () => { + const upload = makeUpload(); + const dropzone = container.querySelector(".file-upload__dropzone") as HTMLDivElement; + expect(dropzone).not.toBeNull(); + expect(dropzone.classList.contains("file-upload__dropzone--hidden")).toBe(true); + upload.destroy?.(); + }); + + it("renders hidden file input", () => { + const upload = makeUpload(); + const input = container.querySelector(".file-upload__input") as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.type).toBe("file"); + expect(input.style.display).toBe("none"); + upload.destroy?.(); + }); + + it("preview is hidden by default", () => { + const upload = makeUpload(); + const preview = container.querySelector(".file-upload__preview") as HTMLDivElement; + expect(preview).not.toBeNull(); + expect(preview.classList.contains("file-upload__preview--hidden")).toBe(true); + upload.destroy?.(); + }); + + it("error div is hidden by default", () => { + const upload = makeUpload(); + const errorDiv = container.querySelector(".file-upload__error") as HTMLDivElement; + expect(errorDiv).not.toBeNull(); + expect(errorDiv.classList.contains("file-upload__error--hidden")).toBe(true); + upload.destroy?.(); + }); + + it("renders drop text in dropzone", () => { + const upload = makeUpload(); + const droptext = container.querySelector(".file-upload__droptext"); + expect(droptext).not.toBeNull(); + expect(droptext!.textContent).toBe("Drop files here"); + upload.destroy?.(); + }); + + it("renders preview sub-elements (thumb, name, size, progress, cancel)", () => { + const upload = makeUpload(); + expect(container.querySelector(".file-upload__thumb")).not.toBeNull(); + expect(container.querySelector(".file-upload__name")).not.toBeNull(); + expect(container.querySelector(".file-upload__size")).not.toBeNull(); + expect(container.querySelector(".file-upload__progress")).not.toBeNull(); + expect(container.querySelector(".file-upload__progress-bar")).not.toBeNull(); + expect(container.querySelector(".file-upload__cancel")).not.toBeNull(); + upload.destroy?.(); + }); + + it("dragenter shows dropzone", () => { + const upload = makeUpload(); + const root = container.querySelector(".file-upload") as HTMLDivElement; + const dropzone = container.querySelector(".file-upload__dropzone") as HTMLDivElement; + + root.dispatchEvent(new Event("dragenter", { bubbles: true })); + expect(dropzone.classList.contains("file-upload__dropzone--hidden")).toBe(false); + upload.destroy?.(); + }); + + it("dragleave hides dropzone", () => { + const upload = makeUpload(); + const root = container.querySelector(".file-upload") as HTMLDivElement; + const dropzone = container.querySelector(".file-upload__dropzone") as HTMLDivElement; + + root.dispatchEvent(new Event("dragenter", { bubbles: true })); + root.dispatchEvent(new Event("dragleave", { bubbles: true })); + expect(dropzone.classList.contains("file-upload__dropzone--hidden")).toBe(true); + upload.destroy?.(); + }); + + it("openPicker triggers file input click", () => { + const upload = makeUpload(); + const input = container.querySelector(".file-upload__input") as HTMLInputElement; + const clickSpy = vi.spyOn(input, "click"); + + upload.openPicker(); + expect(clickSpy).toHaveBeenCalledOnce(); + upload.destroy?.(); + }); + + it("destroy removes DOM", () => { + const upload = makeUpload(); + expect(container.querySelector(".file-upload")).not.toBeNull(); + upload.destroy?.(); + expect(container.querySelector(".file-upload")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/invite-manager.test.ts b/Client/tauri-client/tests/unit/invite-manager.test.ts new file mode 100644 index 00000000..8dd22b1c --- /dev/null +++ b/Client/tauri-client/tests/unit/invite-manager.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + createInviteManager, + type InviteItem, + type InviteManagerOptions, +} from "@components/InviteManager"; + +function makeInvite(overrides: Partial = {}): InviteItem { + return { + code: "abc123xyz", + createdBy: "admin", + createdAt: "2025-01-01T00:00:00Z", + uses: 3, + maxUses: 10, + expiresAt: null, + ...overrides, + }; +} + +function makeOptions(overrides: Partial = {}): InviteManagerOptions { + return { + invites: [makeInvite()], + onCreateInvite: vi.fn(() => Promise.resolve(makeInvite({ code: "newcode123" }))), + onRevokeInvite: vi.fn(() => Promise.resolve()), + onCopyLink: vi.fn(), + onClose: vi.fn(), + onError: vi.fn(), + ...overrides, + }; +} + +describe("InviteManager", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("mounts with overlay class and modal", () => { + const opts = makeOptions(); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const overlay = container.querySelector(".invite-manager-overlay"); + expect(overlay).not.toBeNull(); + const modal = container.querySelector(".invite-manager"); + expect(modal).not.toBeNull(); + + mgr.destroy?.(); + }); + + it("renders invite items from options.invites", () => { + const opts = makeOptions({ + invites: [makeInvite({ code: "aaa111bbb" }), makeInvite({ code: "ccc222ddd" })], + }); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const items = container.querySelectorAll(".invite-item"); + expect(items.length).toBe(2); + + mgr.destroy?.(); + }); + + it("masks codes (first 3 + ... + last 3)", () => { + const opts = makeOptions({ invites: [makeInvite({ code: "abcdefghi" })] }); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const codeEl = container.querySelector(".invite-item__code"); + expect(codeEl?.textContent).toBe("abc...ghi"); + + mgr.destroy?.(); + }); + + it("click copy calls onCopyLink with code", () => { + const opts = makeOptions({ invites: [makeInvite({ code: "abc123xyz" })] }); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const copyBtn = container.querySelector(".invite-item__copy") as HTMLButtonElement; + copyBtn.click(); + expect(opts.onCopyLink).toHaveBeenCalledWith("abc123xyz"); + + mgr.destroy?.(); + }); + + it("click create calls onCreateInvite and adds to list on resolve", async () => { + const newInvite = makeInvite({ code: "newcode123" }); + const opts = makeOptions({ + invites: [], + onCreateInvite: vi.fn(() => Promise.resolve(newInvite)), + }); + const mgr = createInviteManager(opts); + mgr.mount(container); + + expect(container.querySelectorAll(".invite-item").length).toBe(0); + + const createBtn = container.querySelector(".invite-manager__create") as HTMLButtonElement; + createBtn.click(); + + // Wait for the promise to resolve + await vi.waitFor(() => { + expect(container.querySelectorAll(".invite-item").length).toBe(1); + }); + + mgr.destroy?.(); + }); + + it("click revoke calls onRevokeInvite and removes from list on resolve", async () => { + const opts = makeOptions({ invites: [makeInvite({ code: "abc123xyz" })] }); + const mgr = createInviteManager(opts); + mgr.mount(container); + + expect(container.querySelectorAll(".invite-item").length).toBe(1); + + const revokeBtn = container.querySelector(".invite-item__revoke") as HTMLButtonElement; + revokeBtn.click(); + expect(opts.onRevokeInvite).toHaveBeenCalledWith("abc123xyz"); + + await vi.waitFor(() => { + expect(container.querySelectorAll(".invite-item").length).toBe(0); + }); + + mgr.destroy?.(); + }); + + it("close button calls onClose", () => { + const opts = makeOptions(); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const closeBtn = container.querySelector(".invite-manager__close") as HTMLButtonElement; + closeBtn.click(); + expect(opts.onClose).toHaveBeenCalledOnce(); + + mgr.destroy?.(); + }); + + it("escape key calls onClose", () => { + const opts = makeOptions(); + const mgr = createInviteManager(opts); + mgr.mount(container); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(opts.onClose).toHaveBeenCalledOnce(); + + mgr.destroy?.(); + }); + + it("clicking overlay backdrop calls onClose", () => { + const opts = makeOptions(); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const overlay = container.querySelector(".invite-manager-overlay") as HTMLDivElement; + // Clicking the overlay itself (not the modal) + overlay.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(opts.onClose).toHaveBeenCalledOnce(); + + mgr.destroy?.(); + }); + + it("create failure calls onError", async () => { + const opts = makeOptions({ + onCreateInvite: vi.fn(() => Promise.reject(new Error("fail"))), + }); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const createBtn = container.querySelector(".invite-manager__create") as HTMLButtonElement; + createBtn.click(); + + await vi.waitFor(() => { + expect(opts.onError).toHaveBeenCalledWith("Failed to create invite"); + }); + + mgr.destroy?.(); + }); + + it("revoke failure calls onError", async () => { + const opts = makeOptions({ + onRevokeInvite: vi.fn(() => Promise.reject(new Error("fail"))), + }); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const revokeBtn = container.querySelector(".invite-item__revoke") as HTMLButtonElement; + revokeBtn.click(); + + await vi.waitFor(() => { + expect(opts.onError).toHaveBeenCalledWith("Failed to revoke invite"); + }); + + mgr.destroy?.(); + }); +}); diff --git a/Client/tauri-client/tests/unit/member-list.test.ts b/Client/tauri-client/tests/unit/member-list.test.ts new file mode 100644 index 00000000..5d8fa441 --- /dev/null +++ b/Client/tauri-client/tests/unit/member-list.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createMemberList } from "@components/MemberList"; +import { membersStore } from "@stores/members.store"; +import type { Member } from "@stores/members.store"; +import type { UserStatus } from "../../src/lib/types"; + +function resetStore(): void { + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + })); +} + +function makeMember(overrides: Partial & { id: number; username: string }): Member { + return { + avatar: null, + role: "member", + status: "online" as UserStatus, + ...overrides, + }; +} + +function setTestMembers(members: Member[]): void { + const map = new Map(); + for (const m of members) { + map.set(m.id, m); + } + membersStore.setState((prev) => ({ ...prev, members: map })); +} + +const testMembers: Member[] = [ + makeMember({ id: 1, username: "Alice", role: "owner", status: "online" as UserStatus }), + makeMember({ id: 2, username: "Bob", role: "admin", status: "idle" as UserStatus }), + makeMember({ id: 3, username: "Charlie", role: "moderator", status: "online" as UserStatus }), + makeMember({ id: 4, username: "Dave", role: "member", status: "offline" as UserStatus }), + makeMember({ id: 5, username: "Eve", role: "member", status: "online" as UserStatus }), + makeMember({ id: 6, username: "Frank", role: "admin", status: "online" as UserStatus }), +]; + +describe("MemberList", () => { + let container: HTMLDivElement; + let memberList: ReturnType; + + beforeEach(() => { + resetStore(); + container = document.createElement("div"); + document.body.appendChild(container); + memberList = createMemberList(); + }); + + afterEach(() => { + memberList.destroy?.(); + container.remove(); + }); + + it("mounts with member-list class", () => { + setTestMembers(testMembers); + memberList.mount(container); + + const root = container.querySelector(".member-list"); + expect(root).not.toBeNull(); + expect(root!.getAttribute("data-testid")).toBe("member-list"); + }); + + it("groups members by role (OWNER, ADMIN, MODERATOR, MEMBER)", () => { + setTestMembers(testMembers); + memberList.mount(container); + + const headers = container.querySelectorAll(".member-role-group"); + const headerTexts = Array.from(headers).map((h) => h.textContent); + + // Should have all 4 role groups + expect(headers.length).toBe(4); + expect(headerTexts[0]).toContain("OWNER"); + expect(headerTexts[1]).toContain("ADMIN"); + expect(headerTexts[2]).toContain("MODERATOR"); + expect(headerTexts[3]).toContain("MEMBER"); + }); + + it("sorts by status within groups (online first)", () => { + // Two admins: Frank (online) and Bob (idle) + setTestMembers(testMembers); + memberList.mount(container); + + const memberItems = container.querySelectorAll(".member-item"); + const adminItems: HTMLDivElement[] = []; + let inAdminGroup = false; + + // Walk items in DOM order to extract admin group members + const allElements = container.querySelectorAll(".member-role-group, .member-item"); + for (const el of allElements) { + if (el.classList.contains("member-role-group")) { + inAdminGroup = el.textContent?.includes("ADMIN") ?? false; + } else if (inAdminGroup && el.classList.contains("member-item")) { + adminItems.push(el as HTMLDivElement); + } + } + + expect(adminItems.length).toBe(2); + // Frank (online, priority 0) should come before Bob (idle, priority 1) + expect(adminItems[0]!.getAttribute("data-testid")).toBe("member-6"); // Frank + expect(adminItems[1]!.getAttribute("data-testid")).toBe("member-2"); // Bob + }); + + it("shows role group headers with count", () => { + setTestMembers(testMembers); + memberList.mount(container); + + const headers = container.querySelectorAll(".member-role-group"); + const headerTexts = Array.from(headers).map((h) => h.textContent); + + // OWNER has 1, ADMIN has 2, MODERATOR has 1, MEMBER has 2 + expect(headerTexts[0]).toContain("1"); + expect(headerTexts[1]).toContain("2"); + expect(headerTexts[2]).toContain("1"); + expect(headerTexts[3]).toContain("2"); + }); + + it("shows member avatars with first letter", () => { + setTestMembers(testMembers); + memberList.mount(container); + + const avatars = container.querySelectorAll(".mi-avatar"); + const letters = Array.from(avatars).map((a) => a.textContent?.trim()); + + expect(letters).toContain("A"); // Alice + expect(letters).toContain("B"); // Bob + expect(letters).toContain("C"); // Charlie + }); + + it("offline members have offline class", () => { + setTestMembers(testMembers); + memberList.mount(container); + + // Dave (id 4) is offline + const daveItem = container.querySelector('[data-testid="member-4"]'); + expect(daveItem).not.toBeNull(); + expect(daveItem!.classList.contains("offline")).toBe(true); + + // Eve (id 5) is online, should NOT have offline class + const eveItem = container.querySelector('[data-testid="member-5"]'); + expect(eveItem).not.toBeNull(); + expect(eveItem!.classList.contains("offline")).toBe(false); + }); + + it("empty store renders no groups", () => { + memberList.mount(container); + + const headers = container.querySelectorAll(".member-role-group"); + expect(headers.length).toBe(0); + + const items = container.querySelectorAll(".member-item"); + expect(items.length).toBe(0); + }); + + it("destroy removes DOM", () => { + setTestMembers(testMembers); + memberList.mount(container); + + expect(container.querySelector(".member-list")).not.toBeNull(); + memberList.destroy?.(); + expect(container.querySelector(".member-list")).toBeNull(); + }); + + it("reacts to store changes", () => { + memberList.mount(container); + expect(container.querySelectorAll(".member-item").length).toBe(0); + + // Add members after mount + setTestMembers(testMembers); + membersStore.flush(); + + expect(container.querySelectorAll(".member-item").length).toBe(6); + }); +}); diff --git a/Client/tauri-client/tests/unit/message-actions-bar.test.ts b/Client/tauri-client/tests/unit/message-actions-bar.test.ts new file mode 100644 index 00000000..28db2b88 --- /dev/null +++ b/Client/tauri-client/tests/unit/message-actions-bar.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createMessageActionsBar } from "../../src/components/MessageActionsBar"; +import type { MessageActionsBarOptions } from "../../src/components/MessageActionsBar"; + +function makeOptions(overrides: Partial = {}): MessageActionsBarOptions { + return { + messageId: 1, + isOwn: false, + canManageMessages: false, + onReply: vi.fn(), + onEdit: vi.fn(), + onDelete: vi.fn(), + onReact: vi.fn(), + onPin: vi.fn(), + onMore: vi.fn(), + ...overrides, + }; +} + +describe("MessageActionsBar", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("renders React, Reply, and More buttons for non-own messages", () => { + const bar = createMessageActionsBar(makeOptions()); + container.appendChild(bar); + + const buttons = bar.querySelectorAll("button"); + const labels = Array.from(buttons).map((b) => b.getAttribute("aria-label")); + + expect(labels).toContain("React"); + expect(labels).toContain("Reply"); + expect(labels).toContain("More"); + expect(labels).not.toContain("Edit"); + expect(labels).not.toContain("Delete"); + expect(labels).not.toContain("Pin"); + }); + + it("shows Edit and Delete buttons for own messages", () => { + const bar = createMessageActionsBar(makeOptions({ isOwn: true })); + container.appendChild(bar); + + const labels = Array.from(bar.querySelectorAll("button")).map( + (b) => b.getAttribute("aria-label"), + ); + expect(labels).toContain("Edit"); + expect(labels).toContain("Delete"); + }); + + it("shows Delete and Pin buttons for users with manage permissions", () => { + const bar = createMessageActionsBar(makeOptions({ canManageMessages: true })); + container.appendChild(bar); + + const labels = Array.from(bar.querySelectorAll("button")).map( + (b) => b.getAttribute("aria-label"), + ); + expect(labels).toContain("Delete"); + expect(labels).toContain("Pin"); + expect(labels).not.toContain("Edit"); + }); + + it("shows all actions for own message with manage permissions", () => { + const bar = createMessageActionsBar(makeOptions({ + isOwn: true, + canManageMessages: true, + })); + container.appendChild(bar); + + const labels = Array.from(bar.querySelectorAll("button")).map( + (b) => b.getAttribute("aria-label"), + ); + expect(labels).toContain("React"); + expect(labels).toContain("Reply"); + expect(labels).toContain("Edit"); + expect(labels).toContain("Delete"); + expect(labels).toContain("Pin"); + expect(labels).toContain("More"); + }); + + it("calls onReply when Reply button is clicked", () => { + const onReply = vi.fn(); + const bar = createMessageActionsBar(makeOptions({ onReply })); + container.appendChild(bar); + + const replyBtn = Array.from(bar.querySelectorAll("button")).find( + (b) => b.getAttribute("aria-label") === "Reply", + )!; + replyBtn.click(); + expect(onReply).toHaveBeenCalledOnce(); + }); + + it("calls onReact when React button is clicked", () => { + const onReact = vi.fn(); + const bar = createMessageActionsBar(makeOptions({ onReact })); + container.appendChild(bar); + + const reactBtn = Array.from(bar.querySelectorAll("button")).find( + (b) => b.getAttribute("aria-label") === "React", + )!; + reactBtn.click(); + expect(onReact).toHaveBeenCalledOnce(); + }); + + it("calls onEdit when Edit button is clicked", () => { + const onEdit = vi.fn(); + const bar = createMessageActionsBar(makeOptions({ isOwn: true, onEdit })); + container.appendChild(bar); + + const editBtn = Array.from(bar.querySelectorAll("button")).find( + (b) => b.getAttribute("aria-label") === "Edit", + )!; + editBtn.click(); + expect(onEdit).toHaveBeenCalledOnce(); + }); + + it("calls onDelete when Delete button is clicked", () => { + const onDelete = vi.fn(); + const bar = createMessageActionsBar(makeOptions({ isOwn: true, onDelete })); + container.appendChild(bar); + + const deleteBtn = Array.from(bar.querySelectorAll("button")).find( + (b) => b.getAttribute("aria-label") === "Delete", + )!; + deleteBtn.click(); + expect(onDelete).toHaveBeenCalledOnce(); + }); + + it("has msg-actions-bar class", () => { + const bar = createMessageActionsBar(makeOptions()); + expect(bar.classList.contains("msg-actions-bar")).toBe(true); + }); +}); diff --git a/Client/tauri-client/tests/unit/message-input.test.ts b/Client/tauri-client/tests/unit/message-input.test.ts new file mode 100644 index 00000000..11010fe8 --- /dev/null +++ b/Client/tauri-client/tests/unit/message-input.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@components/EmojiPicker", () => ({ + createEmojiPicker: () => ({ + element: document.createElement("div"), + destroy: vi.fn(), + }), +})); + +import { + createMessageInput, + type MessageInputOptions, +} from "@components/MessageInput"; + +function makeOptions(overrides: Partial = {}): MessageInputOptions { + return { + channelId: 1, + channelName: "general", + onSend: vi.fn(), + onTyping: vi.fn(), + onEditMessage: vi.fn(), + ...overrides, + }; +} + +describe("MessageInput", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("mounts with message-input-wrap class", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + expect(container.querySelector(".message-input-wrap")).not.toBeNull(); + + comp.destroy?.(); + }); + + it("has textarea with correct placeholder", () => { + const opts = makeOptions({ channelName: "random" }); + const comp = createMessageInput(opts); + comp.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + expect(textarea).not.toBeNull(); + expect(textarea.placeholder).toBe("Message #random"); + + comp.destroy?.(); + }); + + it("send button click calls onSend with textarea content", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + textarea.value = "Hello world"; + + const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement; + sendBtn.click(); + + expect(opts.onSend).toHaveBeenCalledWith("Hello world", null); + + comp.destroy?.(); + }); + + it("enter key sends message", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + textarea.value = "Enter message"; + + textarea.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + + expect(opts.onSend).toHaveBeenCalledWith("Enter message", null); + + comp.destroy?.(); + }); + + it("shift+enter does NOT send (just newlines)", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + textarea.value = "Line 1"; + + textarea.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true }), + ); + + expect(opts.onSend).not.toHaveBeenCalled(); + + comp.destroy?.(); + }); + + it("empty textarea does not send", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + textarea.value = ""; + + const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement; + sendBtn.click(); + + expect(opts.onSend).not.toHaveBeenCalled(); + + comp.destroy?.(); + }); + + it("setReplyTo shows reply bar", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + comp.setReplyTo(42, "testuser"); + + const replyBar = container.querySelector(".reply-bar") as HTMLDivElement; + expect(replyBar.classList.contains("visible")).toBe(true); + expect(replyBar.textContent).toContain("testuser"); + + comp.destroy?.(); + }); + + it("clearReply hides reply bar", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + comp.setReplyTo(42, "testuser"); + comp.clearReply(); + + const replyBar = container.querySelector(".reply-bar") as HTMLDivElement; + expect(replyBar.classList.contains("visible")).toBe(false); + + comp.destroy?.(); + }); + + it("startEdit sets textarea value and shows edit bar", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + comp.startEdit(99, "editing this"); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + expect(textarea.value).toBe("editing this"); + + // The edit bar is the second .reply-bar + const bars = container.querySelectorAll(".reply-bar"); + const editBar = bars[1] as HTMLDivElement; + expect(editBar.classList.contains("visible")).toBe(true); + + comp.destroy?.(); + }); + + it("cancelEdit clears textarea and hides edit bar", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + comp.startEdit(99, "editing this"); + comp.cancelEdit(); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + expect(textarea.value).toBe(""); + + const bars = container.querySelectorAll(".reply-bar"); + const editBar = bars[1] as HTMLDivElement; + expect(editBar.classList.contains("visible")).toBe(false); + + comp.destroy?.(); + }); + + it("typing emits onTyping (throttled)", () => { + vi.useFakeTimers(); + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + + // First input should trigger onTyping + textarea.dispatchEvent(new Event("input", { bubbles: true })); + expect(opts.onTyping).toHaveBeenCalledTimes(1); + + // Immediate second input should NOT trigger (throttled at 3s) + textarea.dispatchEvent(new Event("input", { bubbles: true })); + expect(opts.onTyping).toHaveBeenCalledTimes(1); + + // After 3 seconds, should fire again + vi.advanceTimersByTime(3000); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + expect(opts.onTyping).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); + comp.destroy?.(); + }); + + it("debounces rapid sends", () => { + vi.useFakeTimers(); + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement; + + textarea.value = "msg1"; + sendBtn.click(); + expect(opts.onSend).toHaveBeenCalledTimes(1); + + // Immediately try to send again (within 200ms debounce) + textarea.value = "msg2"; + sendBtn.click(); + expect(opts.onSend).toHaveBeenCalledTimes(1); // still 1 + + // After debounce period + vi.advanceTimersByTime(200); + textarea.value = "msg3"; + sendBtn.click(); + expect(opts.onSend).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); + comp.destroy?.(); + }); +}); diff --git a/Client/tauri-client/tests/unit/message-list.test.ts b/Client/tauri-client/tests/unit/message-list.test.ts new file mode 100644 index 00000000..6256f809 --- /dev/null +++ b/Client/tauri-client/tests/unit/message-list.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createMessageList } from "@components/MessageList"; +import type { MessageListOptions } from "@components/MessageList"; +import { messagesStore } from "@stores/messages.store"; +import { membersStore } from "@stores/members.store"; +import type { Message } from "@stores/messages.store"; + +function resetStores(): void { + messagesStore.setState(() => ({ + messagesByChannel: new Map(), + pendingSends: new Map(), + loadedChannels: new Set(), + hasMore: new Map(), + })); + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + })); +} + +function makeMessage(overrides: Partial & { id: number }): Message { + return { + channelId: 1, + user: { id: 1, username: "Alice", avatar: null }, + content: `Message ${overrides.id}`, + replyTo: null, + attachments: [], + reactions: [], + editedAt: null, + deleted: false, + timestamp: "2024-01-15T12:00:00Z", + ...overrides, + }; +} + +function setMessages(channelId: number, messages: Message[]): void { + messagesStore.setState((prev) => { + const next = new Map(prev.messagesByChannel); + next.set(channelId, messages); + return { ...prev, messagesByChannel: next }; + }); +} + +function setHasMore(channelId: number, value: boolean): void { + messagesStore.setState((prev) => { + const next = new Map(prev.hasMore); + next.set(channelId, value); + return { ...prev, hasMore: next }; + }); +} + +describe("MessageList", () => { + let container: HTMLDivElement; + let msgList: ReturnType; + let options: MessageListOptions; + + beforeEach(() => { + resetStores(); + container = document.createElement("div"); + document.body.appendChild(container); + options = { + channelId: 1, + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + }; + msgList = createMessageList(options); + }); + + afterEach(() => { + msgList.destroy?.(); + container.remove(); + }); + + it("mounts with messages-container class", () => { + msgList.mount(container); + const root = container.querySelector(".messages-container"); + expect(root).not.toBeNull(); + }); + + it("renders virtual scroll structure (spacers + content)", () => { + msgList.mount(container); + expect(container.querySelector(".virtual-spacer-top")).not.toBeNull(); + expect(container.querySelector(".virtual-content")).not.toBeNull(); + expect(container.querySelector(".virtual-spacer-bottom")).not.toBeNull(); + }); + + it("renders messages from store", () => { + const messages = [ + makeMessage({ id: 1, content: "Hello" }), + makeMessage({ id: 2, content: "World" }), + ]; + setMessages(1, messages); + msgList.mount(container); + + const content = container.querySelector(".virtual-content"); + expect(content).not.toBeNull(); + // Should have rendered items (day divider + messages) + expect(content!.children.length).toBeGreaterThan(0); + }); + + it("empty channel renders no content children (besides spacers)", () => { + msgList.mount(container); + const content = container.querySelector(".virtual-content"); + expect(content).not.toBeNull(); + expect(content!.children.length).toBe(0); + }); + + it("destroy removes DOM and cleans up", () => { + msgList.mount(container); + expect(container.querySelector(".messages-container")).not.toBeNull(); + msgList.destroy?.(); + expect(container.querySelector(".messages-container")).toBeNull(); + }); + + it("reacts to store updates", () => { + msgList.mount(container); + const content = container.querySelector(".virtual-content"); + expect(content!.children.length).toBe(0); + + // Add messages + setMessages(1, [makeMessage({ id: 1, content: "New message" })]); + messagesStore.flush(); + + expect(content!.children.length).toBeGreaterThan(0); + }); + + it("renders day dividers between messages on different days", () => { + const messages = [ + makeMessage({ id: 1, timestamp: "2024-01-15T12:00:00Z" }), + makeMessage({ id: 2, timestamp: "2024-01-16T12:00:00Z" }), + ]; + setMessages(1, messages); + msgList.mount(container); + + // Virtual scroll in jsdom has no real layout (clientHeight=0), + // so we verify content was rendered at all — the render window + // may include all items since offsetToIndex returns 0-based for + // zero-height containers. Check for msg-day-divider class. + const content = container.querySelector(".virtual-content"); + expect(content).not.toBeNull(); + // The virtual scroll renders items based on estimated heights. + // In jsdom with 0 clientHeight, renderWindow computes start=0, end=OVERSCAN+1. + // With only 4 items (2 dividers + 2 messages), all should be in the window. + const dividers = container.querySelectorAll(".msg-day-divider"); + expect(dividers.length).toBe(2); + }); +}); diff --git a/Client/tauri-client/tests/unit/pinned-messages.test.ts b/Client/tauri-client/tests/unit/pinned-messages.test.ts new file mode 100644 index 00000000..7ac9a940 --- /dev/null +++ b/Client/tauri-client/tests/unit/pinned-messages.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createPinnedMessages } from "@components/PinnedMessages"; +import type { PinnedMessage, PinnedMessagesOptions } from "@components/PinnedMessages"; + +const samplePins: PinnedMessage[] = [ + { id: 1, content: "Hello world", author: "Alice", timestamp: "2024-01-01 12:00" }, + { id: 2, content: "Important notice", author: "Bob", timestamp: "2024-01-02 14:30" }, + { id: 3, content: "Reminder", author: "Charlie", timestamp: "2024-01-03 09:00" }, +]; + +describe("PinnedMessages", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + function makePanel(overrides?: Partial) { + const options: PinnedMessagesOptions = { + channelId: 1, + pinnedMessages: overrides?.pinnedMessages ?? samplePins, + onUnpin: overrides?.onUnpin ?? vi.fn(), + onJumpToMessage: overrides?.onJumpToMessage ?? vi.fn(), + onClose: overrides?.onClose ?? vi.fn(), + }; + const panel = createPinnedMessages(options); + panel.mount(container); + return { panel, options }; + } + + it("mounts with pinned-panel class", () => { + const { panel } = makePanel(); + expect(container.querySelector(".pinned-panel")).not.toBeNull(); + panel.destroy?.(); + }); + + it("renders header with title", () => { + const { panel } = makePanel(); + const title = container.querySelector("h3"); + expect(title).not.toBeNull(); + expect(title!.textContent).toBe("Pinned Messages"); + panel.destroy?.(); + }); + + it("renders close button", () => { + const onClose = vi.fn(); + const { panel } = makePanel({ onClose }); + const closeBtn = container.querySelector(".pinned-panel__close") as HTMLButtonElement; + expect(closeBtn).not.toBeNull(); + closeBtn.click(); + expect(onClose).toHaveBeenCalledOnce(); + panel.destroy?.(); + }); + + it("renders pinned message items", () => { + const { panel } = makePanel(); + const items = container.querySelectorAll(".pinned-msg"); + expect(items.length).toBe(3); + panel.destroy?.(); + }); + + it("shows author, content, and timestamp for each pin", () => { + const { panel } = makePanel(); + const authors = container.querySelectorAll(".pinned-msg__author"); + const contents = container.querySelectorAll(".pinned-msg__content"); + const times = container.querySelectorAll(".pinned-msg__time"); + + expect(authors[0]!.textContent).toBe("Alice"); + expect(contents[0]!.textContent).toBe("Hello world"); + expect(times[0]!.textContent).toBe("2024-01-01 12:00"); + panel.destroy?.(); + }); + + it("Jump button calls onJumpToMessage with message id", () => { + const onJumpToMessage = vi.fn(); + const { panel } = makePanel({ onJumpToMessage }); + + const jumpBtns = container.querySelectorAll(".pinned-msg__actions button"); + // Jump is the first button in each action group + (jumpBtns[0] as HTMLButtonElement).click(); + expect(onJumpToMessage).toHaveBeenCalledWith(1); + panel.destroy?.(); + }); + + it("Unpin button calls onUnpin with message id", () => { + const onUnpin = vi.fn(); + const { panel } = makePanel({ onUnpin }); + + const unpinBtns = container.querySelectorAll(".pinned-msg__actions button"); + // Unpin is the second button in each action group + (unpinBtns[1] as HTMLButtonElement).click(); + expect(onUnpin).toHaveBeenCalledWith(1); + panel.destroy?.(); + }); + + it("empty pinned messages shows empty state", () => { + const { panel } = makePanel({ pinnedMessages: [] }); + + const items = container.querySelectorAll(".pinned-msg"); + expect(items.length).toBe(0); + + const empty = container.querySelector(".pinned-panel__empty") as HTMLDivElement; + expect(empty).not.toBeNull(); + expect(empty.textContent).toBe("No pinned messages"); + // Empty div should be visible (display not "none") + expect(empty.style.display).not.toBe("none"); + + // List should be hidden + const list = container.querySelector(".pinned-panel__list") as HTMLDivElement; + expect(list.style.display).toBe("none"); + panel.destroy?.(); + }); + + it("with pinned messages, empty state is hidden", () => { + const { panel } = makePanel(); + + const empty = container.querySelector(".pinned-panel__empty") as HTMLDivElement; + expect(empty.style.display).toBe("none"); + + const list = container.querySelector(".pinned-panel__list") as HTMLDivElement; + expect(list.style.display).not.toBe("none"); + panel.destroy?.(); + }); + + it("stores message id in dataset", () => { + const { panel } = makePanel(); + const items = container.querySelectorAll(".pinned-msg"); + expect((items[0] as HTMLDivElement).dataset.messageId).toBe("1"); + expect((items[1] as HTMLDivElement).dataset.messageId).toBe("2"); + panel.destroy?.(); + }); + + it("destroy removes DOM", () => { + const { panel } = makePanel(); + expect(container.querySelector(".pinned-panel")).not.toBeNull(); + panel.destroy?.(); + expect(container.querySelector(".pinned-panel")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/quick-switcher.test.ts b/Client/tauri-client/tests/unit/quick-switcher.test.ts new file mode 100644 index 00000000..a42db5eb --- /dev/null +++ b/Client/tauri-client/tests/unit/quick-switcher.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createQuickSwitcher } from "@components/QuickSwitcher"; +import type { QuickSwitcherOptions } from "@components/QuickSwitcher"; +import { channelsStore, setChannels } from "@stores/channels.store"; +import type { ReadyChannel } from "../../src/lib/types"; + +function resetStore(): void { + channelsStore.setState(() => ({ + channels: new Map(), + activeChannelId: null, + })); +} + +const testChannels: ReadyChannel[] = [ + { id: 1, name: "general", type: "text", category: "Text", position: 0, unread_count: 0 }, + { id: 2, name: "random", type: "text", category: "Text", position: 1, unread_count: 0 }, + { id: 3, name: "voice-lobby", type: "voice", category: "Voice", position: 2 }, + { id: 4, name: "announcements", type: "text", category: null, position: 3, unread_count: 0 }, +]; + +describe("QuickSwitcher", () => { + let container: HTMLDivElement; + let switcher: ReturnType; + let onSelectChannel: ReturnType; + let onSearch: ReturnType; + let onClose: ReturnType; + + beforeEach(() => { + resetStore(); + setChannels(testChannels); + container = document.createElement("div"); + document.body.appendChild(container); + onSelectChannel = vi.fn(); + onSearch = vi.fn(); + onClose = vi.fn(); + switcher = createQuickSwitcher({ onSelectChannel, onSearch, onClose }); + }); + + afterEach(() => { + switcher.destroy?.(); + container.remove(); + }); + + it("mounts with quick-switcher-overlay class", () => { + switcher.mount(container); + const overlay = container.querySelector(".quick-switcher-overlay"); + expect(overlay).not.toBeNull(); + }); + + it("renders search input with placeholder", () => { + switcher.mount(container); + const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.placeholder).toBe("Where do you want to go?"); + }); + + it("renders all channels initially", () => { + switcher.mount(container); + const items = container.querySelectorAll(".quick-switcher__item"); + expect(items.length).toBe(4); + }); + + it("first item is active by default", () => { + switcher.mount(container); + const activeItem = container.querySelector(".quick-switcher__item--active"); + expect(activeItem).not.toBeNull(); + }); + + it("filters channels by search query", () => { + switcher.mount(container); + const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; + + input.value = "gen"; + input.dispatchEvent(new Event("input")); + + const items = container.querySelectorAll(".quick-switcher__item"); + expect(items.length).toBe(1); + const name = items[0]!.querySelector(".quick-switcher__name"); + expect(name?.textContent).toBe("general"); + }); + + it("calls onSearch when typing", () => { + switcher.mount(container); + const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; + + input.value = "random"; + input.dispatchEvent(new Event("input")); + + expect(onSearch).toHaveBeenCalledWith("random"); + }); + + it("clicking a channel calls onSelectChannel and onClose", () => { + switcher.mount(container); + const firstItem = container.querySelector(".quick-switcher__item") as HTMLDivElement; + firstItem.click(); + + expect(onSelectChannel).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("Escape key calls onClose", () => { + switcher.mount(container); + const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("ArrowDown moves active index", () => { + switcher.mount(container); + const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + + const items = container.querySelectorAll(".quick-switcher__item"); + expect(items[1]!.classList.contains("quick-switcher__item--active")).toBe(true); + expect(items[0]!.classList.contains("quick-switcher__item--active")).toBe(false); + }); + + it("ArrowUp wraps around to last item", () => { + switcher.mount(container); + const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })); + + const items = container.querySelectorAll(".quick-switcher__item"); + expect(items[3]!.classList.contains("quick-switcher__item--active")).toBe(true); + }); + + it("Enter selects the active channel", () => { + switcher.mount(container); + const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(onSelectChannel).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("shows voice icon for voice channels", () => { + switcher.mount(container); + const icons = container.querySelectorAll(".quick-switcher__icon"); + const iconTexts = Array.from(icons).map((i) => i.textContent); + + // voice-lobby should have speaker icon, text channels should have # + expect(iconTexts).toContain("#"); + expect(iconTexts).toContain("\ud83d\udd0a"); + }); + + it("shows category when present", () => { + switcher.mount(container); + const categories = container.querySelectorAll(".quick-switcher__category"); + const categoryTexts = Array.from(categories).map((c) => c.textContent); + + expect(categoryTexts).toContain("Text"); + expect(categoryTexts).toContain("Voice"); + }); + + it("clicking backdrop calls onClose", () => { + switcher.mount(container); + const overlay = container.querySelector(".quick-switcher-overlay") as HTMLDivElement; + // Simulate clicking on the overlay itself (not a child) + overlay.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("destroy removes DOM", () => { + switcher.mount(container); + expect(container.querySelector(".quick-switcher-overlay")).not.toBeNull(); + switcher.destroy?.(); + expect(container.querySelector(".quick-switcher-overlay")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/reaction-bar.test.ts b/Client/tauri-client/tests/unit/reaction-bar.test.ts new file mode 100644 index 00000000..e1a4a924 --- /dev/null +++ b/Client/tauri-client/tests/unit/reaction-bar.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createReactionBar } from "@components/ReactionBar"; +import type { ReactionDisplay, ReactionBarOptions } from "@components/ReactionBar"; + +describe("ReactionBar", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + function makeBar(reactions: ReactionDisplay[], onToggle = vi.fn()) { + const bar = createReactionBar({ reactions, onToggle }); + container.appendChild(bar); + return { bar, onToggle }; + } + + it("creates element with msg-reactions class", () => { + const { bar } = makeBar([]); + expect(bar.classList.contains("msg-reactions")).toBe(true); + }); + + it("renders reaction pills for each reaction", () => { + const reactions: ReactionDisplay[] = [ + { emoji: "👍", count: 3, me: false }, + { emoji: "❤️", count: 1, me: true }, + ]; + const { bar } = makeBar(reactions); + + const pills = bar.querySelectorAll(".reaction-chip:not(.add-reaction)"); + expect(pills.length).toBe(2); + }); + + it("shows emoji and count in each pill", () => { + const reactions: ReactionDisplay[] = [ + { emoji: "🔥", count: 5, me: false }, + ]; + const { bar } = makeBar(reactions); + + const pill = bar.querySelector(".reaction-chip:not(.add-reaction)") as HTMLButtonElement; + expect(pill.textContent).toContain("🔥"); + const countSpan = pill.querySelector(".rc-count"); + expect(countSpan?.textContent).toBe("5"); + }); + + it("adds 'me' class when user has reacted", () => { + const reactions: ReactionDisplay[] = [ + { emoji: "👍", count: 1, me: true }, + { emoji: "👎", count: 1, me: false }, + ]; + const { bar } = makeBar(reactions); + + const pills = bar.querySelectorAll(".reaction-chip:not(.add-reaction)"); + expect(pills[0]!.classList.contains("me")).toBe(true); + expect(pills[1]!.classList.contains("me")).toBe(false); + }); + + it("clicking a pill calls onToggle with emoji", () => { + const onToggle = vi.fn(); + const reactions: ReactionDisplay[] = [ + { emoji: "🎉", count: 2, me: false }, + ]; + const { bar } = makeBar(reactions, onToggle); + + const pill = bar.querySelector(".reaction-chip:not(.add-reaction)") as HTMLButtonElement; + pill.click(); + + expect(onToggle).toHaveBeenCalledWith("🎉"); + }); + + it("renders add-reaction button", () => { + const { bar } = makeBar([]); + const addBtn = bar.querySelector(".add-reaction"); + expect(addBtn).not.toBeNull(); + expect(addBtn!.textContent).toBe("+"); + }); + + it("add-reaction button dispatches custom event", () => { + const { bar } = makeBar([]); + const addBtn = bar.querySelector(".add-reaction") as HTMLButtonElement; + + const handler = vi.fn(); + bar.addEventListener("add-reaction", handler); + addBtn.click(); + + expect(handler).toHaveBeenCalledOnce(); + }); + + it("add-reaction button has aria-label", () => { + const { bar } = makeBar([]); + const addBtn = bar.querySelector(".add-reaction"); + expect(addBtn!.getAttribute("aria-label")).toBe("Add reaction"); + }); + + it("renders only add button when no reactions", () => { + const { bar } = makeBar([]); + const allButtons = bar.querySelectorAll("button"); + expect(allButtons.length).toBe(1); // only the add-reaction button + expect(allButtons[0]!.classList.contains("add-reaction")).toBe(true); + }); +}); diff --git a/Client/tauri-client/tests/unit/renderers.test.ts b/Client/tauri-client/tests/unit/renderers.test.ts new file mode 100644 index 00000000..78b93a0e --- /dev/null +++ b/Client/tauri-client/tests/unit/renderers.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + formatTime, + formatFullDate, + isSameDay, + shouldGroup, + renderDayDivider, + renderMessage, + renderMentions, + GROUP_THRESHOLD_MS, +} from "../../src/components/message-list/renderers"; +import type { Message } from "../../src/stores/messages.store"; +import { membersStore } from "../../src/stores/members.store"; +import type { MessageListOptions } from "../../src/components/MessageList"; + +function resetStores(): void { + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + })); +} + +function makeMessage(overrides: Partial = {}): Message { + return { + id: 1, + channelId: 1, + user: { id: 10, username: "Alice", avatar: null }, + content: "Hello world", + replyTo: null, + attachments: [], + reactions: [], + editedAt: null, + deleted: false, + timestamp: "2025-01-15T12:30:00Z", + ...overrides, + }; +} + +function makeOpts(overrides: Partial = {}): MessageListOptions { + return { + channelId: 1, + currentUserId: 10, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + ...overrides, + }; +} + +describe("renderers", () => { + let container: HTMLDivElement; + + beforeEach(() => { + resetStores(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + describe("formatTime", () => { + it("formats ISO timestamp to HH:MM", () => { + const result = formatTime("2025-01-15T09:05:00Z"); + // Result depends on timezone but should be formatted as HH:MM + expect(result).toMatch(/^\d{2}:\d{2}$/); + }); + }); + + describe("formatFullDate", () => { + it("formats ISO timestamp to full date string", () => { + const result = formatFullDate("2025-01-15T12:00:00Z"); + expect(result).toContain("2025"); + expect(result).toContain("January"); + }); + }); + + describe("isSameDay", () => { + it("returns true for timestamps on the same day", () => { + expect(isSameDay("2025-01-15T08:00:00Z", "2025-01-15T20:00:00Z")).toBe(true); + }); + + it("returns false for timestamps on different days", () => { + expect(isSameDay("2025-01-15T08:00:00Z", "2025-01-16T08:00:00Z")).toBe(false); + }); + }); + + describe("shouldGroup", () => { + it("returns true for same user within threshold", () => { + const prev = makeMessage({ timestamp: "2025-01-15T12:00:00Z" }); + const curr = makeMessage({ id: 2, timestamp: "2025-01-15T12:04:00Z" }); + expect(shouldGroup(prev, curr)).toBe(true); + }); + + it("returns false for different users", () => { + const prev = makeMessage({ user: { id: 10, username: "Alice", avatar: null } }); + const curr = makeMessage({ + id: 2, + user: { id: 20, username: "Bob", avatar: null }, + timestamp: "2025-01-15T12:31:00Z", + }); + expect(shouldGroup(prev, curr)).toBe(false); + }); + + it("returns false when time difference exceeds threshold", () => { + const prev = makeMessage({ timestamp: "2025-01-15T12:00:00Z" }); + const curr = makeMessage({ + id: 2, + timestamp: "2025-01-15T12:06:00Z", + }); + expect(shouldGroup(prev, curr)).toBe(false); + }); + + it("returns false when either message is deleted", () => { + const prev = makeMessage({ deleted: true }); + const curr = makeMessage({ id: 2, timestamp: "2025-01-15T12:31:00Z" }); + expect(shouldGroup(prev, curr)).toBe(false); + }); + }); + + describe("renderDayDivider", () => { + it("creates a day divider element with formatted date", () => { + const divider = renderDayDivider("2025-01-15T12:00:00Z"); + container.appendChild(divider); + + expect(divider.classList.contains("msg-day-divider")).toBe(true); + const dateEl = divider.querySelector(".date"); + expect(dateEl).not.toBeNull(); + expect(dateEl!.textContent).toContain("January"); + expect(dateEl!.textContent).toContain("2025"); + }); + + it("includes line elements", () => { + const divider = renderDayDivider("2025-01-15T12:00:00Z"); + const lines = divider.querySelectorAll(".line"); + expect(lines.length).toBe(2); + }); + }); + + describe("renderMentions", () => { + it("wraps @mentions in span with mention class", () => { + const fragment = renderMentions("Hello @alice how are you?"); + container.appendChild(fragment); + + const mention = container.querySelector(".mention"); + expect(mention).not.toBeNull(); + expect(mention!.textContent).toBe("@alice"); + }); + + it("renders plain text without mentions", () => { + const fragment = renderMentions("Hello world"); + container.appendChild(fragment); + + expect(container.querySelector(".mention")).toBeNull(); + expect(container.textContent).toBe("Hello world"); + }); + + it("handles multiple mentions", () => { + const fragment = renderMentions("@alice and @bob"); + container.appendChild(fragment); + + const mentions = container.querySelectorAll(".mention"); + expect(mentions.length).toBe(2); + }); + }); + + describe("renderMessage", () => { + it("renders a basic message with author and content", () => { + const msg = makeMessage(); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + expect(el.getAttribute("data-testid")).toBe("message-1"); + expect(container.querySelector(".msg-author")?.textContent).toBe("Alice"); + expect(container.querySelector(".msg-text")?.textContent).toBe("Hello world"); + + ac.abort(); + }); + + it("renders grouped messages with grouped class", () => { + const msg = makeMessage(); + const ac = new AbortController(); + const el = renderMessage(msg, true, [msg], makeOpts(), ac.signal); + + expect(el.classList.contains("grouped")).toBe(true); + + ac.abort(); + }); + + it("renders deleted message with italic text", () => { + const msg = makeMessage({ deleted: true }); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + const text = container.querySelector(".msg-text"); + expect(text?.textContent).toBe("[message deleted]"); + expect((text as HTMLElement)?.style.fontStyle).toBe("italic"); + + ac.abort(); + }); + + it("shows (edited) tag for edited messages", () => { + const msg = makeMessage({ editedAt: "2025-01-15T13:00:00Z" }); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + const edited = container.querySelector(".msg-edited"); + expect(edited).not.toBeNull(); + expect(edited!.textContent).toBe("(edited)"); + + ac.abort(); + }); + + it("renders system messages differently", () => { + const msg = makeMessage({ + user: { id: 0, username: "System", avatar: null }, + content: "Alice joined the server", + }); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + expect(container.querySelector(".system-msg")).not.toBeNull(); + + ac.abort(); + }); + + it("renders reply reference when replyTo is set", () => { + const original = makeMessage({ id: 1, content: "Original message" }); + const reply = makeMessage({ id: 2, replyTo: 1, content: "This is a reply" }); + const ac = new AbortController(); + const el = renderMessage(reply, false, [original, reply], makeOpts(), ac.signal); + container.appendChild(el); + + const replyRef = container.querySelector(".msg-reply-ref"); + expect(replyRef).not.toBeNull(); + expect(replyRef!.querySelector(".rr-author")?.textContent).toBe("Alice"); + + ac.abort(); + }); + + it("shows action buttons for non-deleted messages", () => { + const msg = makeMessage(); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + const actionsBar = container.querySelector(".msg-actions-bar"); + expect(actionsBar).not.toBeNull(); + + ac.abort(); + }); + + it("does not show action buttons for deleted messages", () => { + const msg = makeMessage({ deleted: true }); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + const actionsBar = container.querySelector(".msg-actions-bar"); + expect(actionsBar).toBeNull(); + + ac.abort(); + }); + + it("renders reactions when present", () => { + const msg = makeMessage({ + reactions: [ + { emoji: "\uD83D\uDC4D", count: 3, me: false }, + { emoji: "\u2764\uFE0F", count: 1, me: true }, + ], + }); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + const reactionChips = container.querySelectorAll(".reaction-chip:not(.add-reaction)"); + expect(reactionChips.length).toBe(2); + + ac.abort(); + }); + + it("renders attachments for image types", () => { + const msg = makeMessage({ + attachments: [ + { id: "1", filename: "photo.png", size: 1024, mime: "image/png", url: "/uploads/photo.png" }, + ], + }); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + expect(container.querySelector(".msg-image")).not.toBeNull(); + + ac.abort(); + }); + + it("renders attachments for file types", () => { + const msg = makeMessage({ + attachments: [ + { id: "1", filename: "doc.pdf", size: 2048, mime: "application/pdf", url: "/uploads/doc.pdf" }, + ], + }); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + expect(container.querySelector(".msg-file")).not.toBeNull(); + expect(container.querySelector(".msg-file-name")?.textContent).toBe("doc.pdf"); + + ac.abort(); + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/server-banner.test.ts b/Client/tauri-client/tests/unit/server-banner.test.ts new file mode 100644 index 00000000..6e2ea243 --- /dev/null +++ b/Client/tauri-client/tests/unit/server-banner.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createServerBanner } from "@components/ServerBanner"; + +describe("ServerBanner", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("creates element with reconnecting-banner class", () => { + const banner = createServerBanner(); + expect(banner.element.classList.contains("reconnecting-banner")).toBe(true); + banner.destroy(); + }); + + it("showRestart adds visible class and shows countdown text", () => { + const banner = createServerBanner(); + banner.showRestart(5); + + expect(banner.element.classList.contains("visible")).toBe(true); + expect(banner.element.textContent).toBe("Server restarting in 5 seconds..."); + + banner.destroy(); + }); + + it('showReconnecting adds visible class with "Reconnecting..." text', () => { + const banner = createServerBanner(); + banner.showReconnecting(); + + expect(banner.element.classList.contains("visible")).toBe(true); + expect(banner.element.textContent).toBe("Reconnecting..."); + + banner.destroy(); + }); + + it("hide removes visible class", () => { + const banner = createServerBanner(); + banner.showReconnecting(); + expect(banner.element.classList.contains("visible")).toBe(true); + + banner.hide(); + expect(banner.element.classList.contains("visible")).toBe(false); + + banner.destroy(); + }); + + it("countdown decrements every second", () => { + const banner = createServerBanner(); + banner.showRestart(3); + + expect(banner.element.textContent).toBe("Server restarting in 3 seconds..."); + + vi.advanceTimersByTime(1000); + expect(banner.element.textContent).toBe("Server restarting in 2 seconds..."); + + vi.advanceTimersByTime(1000); + expect(banner.element.textContent).toBe("Server restarting in 1 seconds..."); + + banner.destroy(); + }); + + it('countdown transitions to "Reconnecting..." at 0', () => { + const banner = createServerBanner(); + banner.showRestart(2); + + vi.advanceTimersByTime(1000); // remaining = 1 + vi.advanceTimersByTime(1000); // remaining = 0 → showReconnecting + + expect(banner.element.textContent).toBe("Reconnecting..."); + + banner.destroy(); + }); + + it("destroy removes element from DOM", () => { + const banner = createServerBanner(); + const parent = document.createElement("div"); + parent.appendChild(banner.element); + + expect(parent.contains(banner.element)).toBe(true); + + banner.destroy(); + expect(parent.contains(banner.element)).toBe(false); + }); +}); diff --git a/Client/tauri-client/tests/unit/server-strip.test.ts b/Client/tauri-client/tests/unit/server-strip.test.ts new file mode 100644 index 00000000..ea08f14f --- /dev/null +++ b/Client/tauri-client/tests/unit/server-strip.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createServerStrip } from "@components/ServerStrip"; + +describe("ServerStrip", () => { + let container: HTMLDivElement; + let comp: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + comp?.destroy?.(); + container.remove(); + }); + + it("mounts with server-strip class", () => { + comp = createServerStrip(); + comp.mount(container); + + expect(container.querySelector(".server-strip")).not.toBeNull(); + }); + + it('renders home icon with "O"', () => { + comp = createServerStrip(); + comp.mount(container); + + const icons = container.querySelectorAll(".server-icon"); + const homeIcon = icons[0]; + expect(homeIcon).not.toBeUndefined(); + expect(homeIcon?.textContent).toBe("O"); + }); + + it("renders separator", () => { + comp = createServerStrip(); + comp.mount(container); + + expect(container.querySelector(".server-separator")).not.toBeNull(); + }); + + it('renders add icon with "+"', () => { + comp = createServerStrip(); + comp.mount(container); + + const addIcon = container.querySelector(".server-icon.add"); + expect(addIcon).not.toBeNull(); + expect(addIcon?.textContent).toBe("+"); + }); + + it("home icon has active class", () => { + comp = createServerStrip(); + comp.mount(container); + + const icons = container.querySelectorAll(".server-icon"); + const homeIcon = icons[0]; + expect(homeIcon?.classList.contains("active")).toBe(true); + }); + + it("destroy removes DOM", () => { + comp = createServerStrip(); + comp.mount(container); + + expect(container.querySelector(".server-strip")).not.toBeNull(); + + comp.destroy?.(); + expect(container.querySelector(".server-strip")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/settings-helpers.test.ts b/Client/tauri-client/tests/unit/settings-helpers.test.ts new file mode 100644 index 00000000..77ec490a --- /dev/null +++ b/Client/tauri-client/tests/unit/settings-helpers.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + loadPref, + savePref, + applyTheme, + STORAGE_PREFIX, + THEMES, +} from "../../src/components/settings/helpers"; +import type { ThemeName } from "../../src/components/settings/helpers"; + +describe("settings/helpers", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + localStorage.clear(); + }); + + afterEach(() => { + container.remove(); + localStorage.clear(); + }); + + describe("STORAGE_PREFIX", () => { + it("has the correct prefix value", () => { + expect(STORAGE_PREFIX).toBe("owncord:settings:"); + }); + }); + + describe("loadPref", () => { + it("returns fallback when key does not exist", () => { + const result = loadPref("nonexistent", "default"); + expect(result).toBe("default"); + }); + + it("returns stored value when key exists", () => { + localStorage.setItem(STORAGE_PREFIX + "theme", JSON.stringify("midnight")); + const result = loadPref("theme", "dark"); + expect(result).toBe("midnight"); + }); + + it("returns fallback on invalid JSON", () => { + localStorage.setItem(STORAGE_PREFIX + "broken", "not-valid-json"); + const result = loadPref("broken", "fallback"); + expect(result).toBe("fallback"); + }); + + it("handles boolean values", () => { + localStorage.setItem(STORAGE_PREFIX + "notifications", JSON.stringify(true)); + expect(loadPref("notifications", false)).toBe(true); + }); + + it("handles numeric values", () => { + localStorage.setItem(STORAGE_PREFIX + "volume", JSON.stringify(75)); + expect(loadPref("volume", 50)).toBe(75); + }); + + it("handles object values", () => { + const obj = { fontSize: 14, compact: true }; + localStorage.setItem(STORAGE_PREFIX + "display", JSON.stringify(obj)); + const result = loadPref("display", {}); + expect(result).toEqual(obj); + }); + }); + + describe("savePref", () => { + it("stores value with correct prefix", () => { + savePref("theme", "midnight"); + const raw = localStorage.getItem(STORAGE_PREFIX + "theme"); + expect(raw).toBe(JSON.stringify("midnight")); + }); + + it("stores boolean values", () => { + savePref("notifications", true); + const raw = localStorage.getItem(STORAGE_PREFIX + "notifications"); + expect(raw).toBe("true"); + }); + + it("stores numeric values", () => { + savePref("volume", 80); + const raw = localStorage.getItem(STORAGE_PREFIX + "volume"); + expect(raw).toBe("80"); + }); + + it("stores object values", () => { + const obj = { a: 1, b: "two" }; + savePref("config", obj); + const raw = localStorage.getItem(STORAGE_PREFIX + "config"); + expect(JSON.parse(raw!)).toEqual(obj); + }); + + it("overwrites existing values", () => { + savePref("theme", "dark"); + savePref("theme", "light"); + expect(loadPref("theme", "dark")).toBe("light"); + }); + }); + + describe("applyTheme", () => { + it("sets CSS custom properties for dark theme", () => { + applyTheme("dark"); + const root = document.documentElement; + expect(root.style.getPropertyValue("--bg-primary")).toBe("#313338"); + expect(root.style.getPropertyValue("--bg-secondary")).toBe("#2b2d31"); + expect(root.style.getPropertyValue("--bg-tertiary")).toBe("#1e1f22"); + expect(root.style.getPropertyValue("--text-normal")).toBe("#dbdee1"); + }); + + it("sets CSS custom properties for midnight theme", () => { + applyTheme("midnight"); + const root = document.documentElement; + expect(root.style.getPropertyValue("--bg-primary")).toBe("#1a1a2e"); + }); + + it("sets CSS custom properties for light theme", () => { + applyTheme("light"); + const root = document.documentElement; + expect(root.style.getPropertyValue("--bg-primary")).toBe("#ffffff"); + expect(root.style.getPropertyValue("--text-normal")).toBe("#313338"); + }); + + it("overwrites previous theme variables", () => { + applyTheme("dark"); + applyTheme("light"); + const root = document.documentElement; + expect(root.style.getPropertyValue("--bg-primary")).toBe("#ffffff"); + }); + }); + + describe("THEMES", () => { + it("contains dark, midnight, and light themes", () => { + const themeNames = Object.keys(THEMES); + expect(themeNames).toContain("dark"); + expect(themeNames).toContain("midnight"); + expect(themeNames).toContain("light"); + }); + + it("each theme has required CSS variables", () => { + for (const [, vars] of Object.entries(THEMES)) { + expect(vars).toHaveProperty("--bg-primary"); + expect(vars).toHaveProperty("--bg-secondary"); + expect(vars).toHaveProperty("--bg-tertiary"); + expect(vars).toHaveProperty("--text-normal"); + } + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/soundboard.test.ts b/Client/tauri-client/tests/unit/soundboard.test.ts new file mode 100644 index 00000000..1d01b88b --- /dev/null +++ b/Client/tauri-client/tests/unit/soundboard.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createSoundboard } from "../../src/components/Soundboard"; +import type { SoundItem } from "../../src/components/Soundboard"; + +const testSounds: SoundItem[] = [ + { id: 1, name: "Airhorn", durationMs: 2500 }, + { id: 2, name: "Rimshot", durationMs: 1200 }, + { id: 3, name: "Sad Trombone", durationMs: 3800 }, +]; + +describe("Soundboard", () => { + let container: HTMLDivElement; + + beforeEach(() => { + vi.useFakeTimers(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + vi.useRealTimers(); + container.remove(); + }); + + it("renders empty state when no sounds", () => { + const board = createSoundboard({ + sounds: [], + onPlaySound: vi.fn(), + }); + board.mount(container); + + const empty = container.querySelector(".soundboard__empty"); + expect(empty).not.toBeNull(); + expect(empty!.textContent).toBe("No sounds available"); + + board.destroy?.(); + }); + + it("renders sound buttons with names and durations", () => { + const board = createSoundboard({ + sounds: testSounds, + onPlaySound: vi.fn(), + }); + board.mount(container); + + const buttons = container.querySelectorAll(".sound-btn"); + expect(buttons.length).toBe(3); + + const names = Array.from(container.querySelectorAll(".sound-btn__name")).map( + (el) => el.textContent, + ); + expect(names).toEqual(["Airhorn", "Rimshot", "Sad Trombone"]); + + const durations = Array.from(container.querySelectorAll(".sound-btn__duration")).map( + (el) => el.textContent, + ); + expect(durations).toEqual(["2.5s", "1.2s", "3.8s"]); + + board.destroy?.(); + }); + + it("calls onPlaySound with correct id when button is clicked", () => { + const onPlaySound = vi.fn(); + const board = createSoundboard({ + sounds: testSounds, + onPlaySound, + }); + board.mount(container); + + const buttons = container.querySelectorAll(".sound-btn") as NodeListOf; + buttons[1]!.click(); + expect(onPlaySound).toHaveBeenCalledWith(2); + + board.destroy?.(); + }); + + it("disables all buttons during cooldown", () => { + const board = createSoundboard({ + sounds: testSounds, + onPlaySound: vi.fn(), + }); + board.mount(container); + + const buttons = container.querySelectorAll(".sound-btn") as NodeListOf; + buttons[0]!.click(); + + // All buttons should be disabled + for (const btn of buttons) { + expect(btn.disabled).toBe(true); + expect(btn.classList.contains("sound-btn--cooldown")).toBe(true); + } + + board.destroy?.(); + }); + + it("re-enables buttons after cooldown period", () => { + const board = createSoundboard({ + sounds: testSounds, + onPlaySound: vi.fn(), + }); + board.mount(container); + + const buttons = container.querySelectorAll(".sound-btn") as NodeListOf; + buttons[0]!.click(); + + // Advance past cooldown (3000ms) + vi.advanceTimersByTime(3000); + + for (const btn of buttons) { + expect(btn.disabled).toBe(false); + expect(btn.classList.contains("sound-btn--cooldown")).toBe(false); + } + + board.destroy?.(); + }); + + it("does not fire onPlaySound when button is disabled", () => { + const onPlaySound = vi.fn(); + const board = createSoundboard({ + sounds: testSounds, + onPlaySound, + }); + board.mount(container); + + const buttons = container.querySelectorAll(".sound-btn") as NodeListOf; + buttons[0]!.click(); // first click triggers cooldown + onPlaySound.mockClear(); + + buttons[1]!.click(); // should not fire since disabled + expect(onPlaySound).not.toHaveBeenCalled(); + + board.destroy?.(); + }); + + it("cleans up on destroy", () => { + const board = createSoundboard({ + sounds: testSounds, + onPlaySound: vi.fn(), + }); + board.mount(container); + + expect(container.querySelector(".soundboard")).not.toBeNull(); + + board.destroy?.(); + expect(container.querySelector(".soundboard")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/typing-indicator.test.ts b/Client/tauri-client/tests/unit/typing-indicator.test.ts new file mode 100644 index 00000000..76ad87a7 --- /dev/null +++ b/Client/tauri-client/tests/unit/typing-indicator.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +let storeCallback: (() => void) | null = null; +let typingUsers: Array<{ id: number; username: string }> = []; + +vi.mock("@stores/members.store", () => ({ + membersStore: { + subscribe: vi.fn((cb: () => void) => { + storeCallback = cb; + return () => { + storeCallback = null; + }; + }), + }, + getTypingUsers: vi.fn(() => typingUsers), +})); + +import { createTypingIndicator } from "@components/TypingIndicator"; + +function setTypingUsers(users: Array<{ id: number; username: string }>): void { + typingUsers = users; + storeCallback?.(); +} + +describe("TypingIndicator", () => { + let container: HTMLDivElement; + let comp: ReturnType; + + beforeEach(() => { + typingUsers = []; + storeCallback = null; + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + comp?.destroy?.(); + container.remove(); + }); + + it("mounts with typing-bar class", () => { + comp = createTypingIndicator({ channelId: 1, currentUserId: 100 }); + comp.mount(container); + + expect(container.querySelector(".typing-bar")).not.toBeNull(); + }); + + it("shows nothing when no one is typing", () => { + comp = createTypingIndicator({ channelId: 1, currentUserId: 100 }); + comp.mount(container); + + const bar = container.querySelector(".typing-bar") as HTMLDivElement; + expect(bar.children.length).toBe(0); + expect(bar.textContent).toBe(""); + }); + + it('shows "X is typing..." for one user', () => { + comp = createTypingIndicator({ channelId: 1, currentUserId: 100 }); + comp.mount(container); + + setTypingUsers([{ id: 1, username: "alice" }]); + + const bar = container.querySelector(".typing-bar") as HTMLDivElement; + expect(bar.textContent).toContain("alice is typing..."); + }); + + it('shows "X and Y are typing..." for two users', () => { + comp = createTypingIndicator({ channelId: 1, currentUserId: 100 }); + comp.mount(container); + + setTypingUsers([ + { id: 1, username: "alice" }, + { id: 2, username: "bob" }, + ]); + + const bar = container.querySelector(".typing-bar") as HTMLDivElement; + expect(bar.textContent).toContain("alice and bob are typing..."); + }); + + it('shows "Several people are typing..." for 3+ users', () => { + comp = createTypingIndicator({ channelId: 1, currentUserId: 100 }); + comp.mount(container); + + setTypingUsers([ + { id: 1, username: "alice" }, + { id: 2, username: "bob" }, + { id: 3, username: "charlie" }, + ]); + + const bar = container.querySelector(".typing-bar") as HTMLDivElement; + expect(bar.textContent).toContain("Several people are typing..."); + }); + + it("filters out current user from typing list", () => { + comp = createTypingIndicator({ channelId: 1, currentUserId: 1 }); + comp.mount(container); + + // Only the current user is typing + setTypingUsers([{ id: 1, username: "me" }]); + + const bar = container.querySelector(".typing-bar") as HTMLDivElement; + // Should show nothing since current user is filtered + expect(bar.children.length).toBe(0); + }); +}); diff --git a/Client/tauri-client/tests/unit/user-bar.test.ts b/Client/tauri-client/tests/unit/user-bar.test.ts new file mode 100644 index 00000000..632be143 --- /dev/null +++ b/Client/tauri-client/tests/unit/user-bar.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { authStore } from "@stores/auth.store"; +import { openSettings } from "@stores/ui.store"; +import { createUserBar } from "@components/UserBar"; + +vi.mock("@stores/ui.store", () => ({ + openSettings: vi.fn(), + uiStore: { getState: () => ({}), subscribe: () => () => {} }, +})); + +function setAuthState( + user: { username: string } | null, + isAuthenticated: boolean, +): void { + authStore.setState(() => ({ + token: isAuthenticated ? "tok" : null, + user: user !== null + ? { id: 1, username: user.username, avatar: null, role: "member" } + : null, + serverName: "TestServer", + motd: null, + isAuthenticated, + })); +} + +describe("UserBar", () => { + let container: HTMLDivElement; + let comp: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + vi.clearAllMocks(); + }); + + afterEach(() => { + comp?.destroy?.(); + container.remove(); + // Reset auth store + authStore.setState(() => ({ + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, + })); + }); + + it("mounts with user-bar class", () => { + setAuthState({ username: "alice" }, true); + comp = createUserBar(); + comp.mount(container); + + expect(container.querySelector(".user-bar")).not.toBeNull(); + }); + + it("shows username from authStore", () => { + setAuthState({ username: "alice" }, true); + comp = createUserBar(); + comp.mount(container); + + const name = container.querySelector(".ub-name"); + expect(name?.textContent).toBe("alice"); + }); + + it("shows first letter as avatar", () => { + setAuthState({ username: "bob" }, true); + comp = createUserBar(); + comp.mount(container); + + const avatar = container.querySelector(".ub-avatar span"); + expect(avatar?.textContent).toBe("B"); + }); + + it('shows "Online" when authenticated', () => { + setAuthState({ username: "alice" }, true); + comp = createUserBar(); + comp.mount(container); + + const status = container.querySelector(".ub-status"); + expect(status?.textContent).toBe("Online"); + }); + + it('shows "Offline" when not authenticated', () => { + setAuthState(null, false); + comp = createUserBar(); + comp.mount(container); + + const status = container.querySelector(".ub-status"); + expect(status?.textContent).toBe("Offline"); + }); + + it("settings button calls openSettings", () => { + setAuthState({ username: "alice" }, true); + comp = createUserBar(); + comp.mount(container); + + const settingsBtn = container.querySelector('[title="Settings"]') as HTMLButtonElement; + settingsBtn.click(); + + expect(openSettings).toHaveBeenCalledOnce(); + }); + + it("destroy removes DOM and unsubscribes", () => { + setAuthState({ username: "alice" }, true); + comp = createUserBar(); + comp.mount(container); + + expect(container.querySelector(".user-bar")).not.toBeNull(); + + comp.destroy?.(); + + expect(container.querySelector(".user-bar")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/voice-channel.test.ts b/Client/tauri-client/tests/unit/voice-channel.test.ts new file mode 100644 index 00000000..ff3bc296 --- /dev/null +++ b/Client/tauri-client/tests/unit/voice-channel.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createVoiceChannel } from "../../src/components/VoiceChannel"; +import { voiceStore } from "../../src/stores/voice.store"; +import { membersStore } from "../../src/stores/members.store"; +import type { VoiceUser } from "../../src/stores/voice.store"; + +function resetStores(): void { + voiceStore.setState(() => ({ + currentChannelId: null, + voiceUsers: new Map(), + voiceConfigs: new Map(), + localMuted: false, + localDeafened: false, + })); + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + })); +} + +function setVoiceUsers(channelId: number, users: VoiceUser[]): void { + const userMap = new Map(); + for (const u of users) { + userMap.set(u.userId, u); + } + voiceStore.setState((prev) => { + const voiceUsers = new Map(prev.voiceUsers); + voiceUsers.set(channelId, userMap); + return { ...prev, voiceUsers }; + }); +} + +describe("VoiceChannel", () => { + let container: HTMLDivElement; + + beforeEach(() => { + resetStores(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("renders channel name and voice icon", () => { + const result = createVoiceChannel({ + channelId: 1, + channelName: "Voice Lobby", + onJoin: vi.fn(), + }); + container.appendChild(result.element); + + const name = result.element.querySelector(".ch-name"); + expect(name?.textContent).toBe("Voice Lobby"); + + const icon = result.element.querySelector(".ch-icon"); + expect(icon).not.toBeNull(); + + result.destroy(); + }); + + it("calls onJoin when channel item is clicked", () => { + const onJoin = vi.fn(); + const result = createVoiceChannel({ + channelId: 1, + channelName: "Voice Lobby", + onJoin, + }); + container.appendChild(result.element); + + const channelItem = result.element.querySelector(".channel-item") as HTMLElement; + channelItem.click(); + expect(onJoin).toHaveBeenCalledOnce(); + + result.destroy(); + }); + + it("renders voice users from store", () => { + membersStore.setState((prev) => { + const members = new Map(prev.members); + members.set(10, { + id: 10, + username: "Alice", + avatar: null, + role: "member", + status: "online", + }); + return { ...prev, members }; + }); + + setVoiceUsers(1, [ + { + userId: 10, + username: "Alice", + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }, + ]); + + const result = createVoiceChannel({ + channelId: 1, + channelName: "Voice Lobby", + onJoin: vi.fn(), + }); + container.appendChild(result.element); + + const userItems = result.element.querySelectorAll(".voice-user-item"); + expect(userItems.length).toBe(1); + + const userName = result.element.querySelector(".vu-name"); + expect(userName?.textContent).toBe("Alice"); + + result.destroy(); + }); + + it("marks channel active when users are present", () => { + setVoiceUsers(1, [ + { + userId: 10, + username: "Alice", + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }, + ]); + + const result = createVoiceChannel({ + channelId: 1, + channelName: "Voice Lobby", + onJoin: vi.fn(), + }); + container.appendChild(result.element); + + const channelItem = result.element.querySelector(".channel-item"); + expect(channelItem!.classList.contains("active")).toBe(true); + + result.destroy(); + }); + + it("shows muted icon for muted users", () => { + setVoiceUsers(1, [ + { + userId: 10, + username: "Alice", + muted: true, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }, + ]); + + const result = createVoiceChannel({ + channelId: 1, + channelName: "Voice Lobby", + onJoin: vi.fn(), + }); + container.appendChild(result.element); + + const mutedIcon = result.element.querySelector(".vu-muted"); + expect(mutedIcon).not.toBeNull(); + + result.destroy(); + }); + + it("shows speaking class for speaking users", () => { + setVoiceUsers(1, [ + { + userId: 10, + username: "Alice", + muted: false, + deafened: false, + speaking: true, + camera: false, + screenshare: false, + }, + ]); + + const result = createVoiceChannel({ + channelId: 1, + channelName: "Voice Lobby", + onJoin: vi.fn(), + }); + container.appendChild(result.element); + + const userItem = result.element.querySelector(".voice-user-item"); + expect(userItem!.classList.contains("speaking")).toBe(true); + + result.destroy(); + }); + + it("shows no users when channel is empty", () => { + const result = createVoiceChannel({ + channelId: 1, + channelName: "Voice Lobby", + onJoin: vi.fn(), + }); + container.appendChild(result.element); + + const userItems = result.element.querySelectorAll(".voice-user-item"); + expect(userItems.length).toBe(0); + + const channelItem = result.element.querySelector(".channel-item"); + expect(channelItem!.classList.contains("active")).toBe(false); + + result.destroy(); + }); +}); diff --git a/Client/tauri-client/tests/unit/voice-widget.test.ts b/Client/tauri-client/tests/unit/voice-widget.test.ts new file mode 100644 index 00000000..fc41804d --- /dev/null +++ b/Client/tauri-client/tests/unit/voice-widget.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createVoiceWidget } from "../../src/components/VoiceWidget"; +import { voiceStore } from "../../src/stores/voice.store"; +import { channelsStore } from "../../src/stores/channels.store"; +import { membersStore } from "../../src/stores/members.store"; +import type { VoiceUser } from "../../src/stores/voice.store"; + +function resetStores(): void { + voiceStore.setState(() => ({ + currentChannelId: null, + voiceUsers: new Map(), + voiceConfigs: new Map(), + localMuted: false, + localDeafened: false, + })); + channelsStore.setState(() => ({ + channels: new Map(), + activeChannelId: null, + })); + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + })); +} + +function setVoiceChannel(channelId: number, users: VoiceUser[]): void { + const userMap = new Map(); + for (const u of users) { + userMap.set(u.userId, u); + } + const voiceUsers = new Map>(); + voiceUsers.set(channelId, userMap); + + voiceStore.setState((prev) => ({ + ...prev, + currentChannelId: channelId, + voiceUsers, + })); +} + +describe("VoiceWidget", () => { + let container: HTMLDivElement; + + beforeEach(() => { + resetStores(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("renders hidden when not connected to a voice channel", () => { + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + const root = container.querySelector('[data-testid="voice-widget"]'); + expect(root).not.toBeNull(); + expect(root!.classList.contains("visible")).toBe(false); + + widget.destroy?.(); + }); + + it("shows visible when connected to a voice channel", () => { + channelsStore.setState((prev) => { + const channels = new Map(prev.channels); + channels.set(1, { + id: 1, + name: "Voice Lobby", + type: "voice", + category: null, + position: 0, + unreadCount: 0, + lastMessageId: null, + }); + return { ...prev, channels }; + }); + + setVoiceChannel(1, []); + + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + const root = container.querySelector('[data-testid="voice-widget"]'); + expect(root!.classList.contains("visible")).toBe(true); + + widget.destroy?.(); + }); + + it("displays channel name", () => { + channelsStore.setState((prev) => { + const channels = new Map(prev.channels); + channels.set(1, { + id: 1, + name: "Voice Lobby", + type: "voice", + category: null, + position: 0, + unreadCount: 0, + lastMessageId: null, + }); + return { ...prev, channels }; + }); + + setVoiceChannel(1, []); + + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + const channelName = container.querySelector(".vw-channel"); + expect(channelName?.textContent).toBe("Voice Lobby"); + + widget.destroy?.(); + }); + + it("renders voice users", () => { + channelsStore.setState((prev) => { + const channels = new Map(prev.channels); + channels.set(1, { + id: 1, + name: "Voice Lobby", + type: "voice", + category: null, + position: 0, + unreadCount: 0, + lastMessageId: null, + }); + return { ...prev, channels }; + }); + + membersStore.setState((prev) => { + const members = new Map(prev.members); + members.set(10, { + id: 10, + username: "Alice", + avatar: null, + role: "member", + status: "online", + }); + return { ...prev, members }; + }); + + setVoiceChannel(1, [ + { + userId: 10, + username: "Alice", + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }, + ]); + + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + const userItems = container.querySelectorAll(".voice-user-item"); + expect(userItems.length).toBe(1); + expect(container.querySelector('[data-testid="voice-user-10"]')).not.toBeNull(); + + widget.destroy?.(); + }); + + it("calls onMuteToggle when mute button is clicked", () => { + const onMuteToggle = vi.fn(); + setVoiceChannel(1, []); + + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle, + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + const muteBtn = container.querySelector('[aria-label="Mute"]') as HTMLButtonElement; + expect(muteBtn).not.toBeNull(); + muteBtn.click(); + expect(onMuteToggle).toHaveBeenCalledOnce(); + + widget.destroy?.(); + }); + + it("calls onDisconnect when disconnect button is clicked", () => { + const onDisconnect = vi.fn(); + setVoiceChannel(1, []); + + const widget = createVoiceWidget({ + onDisconnect, + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + const disconnectBtn = container.querySelector('[aria-label="Disconnect"]') as HTMLButtonElement; + expect(disconnectBtn).not.toBeNull(); + disconnectBtn.click(); + expect(onDisconnect).toHaveBeenCalledOnce(); + + widget.destroy?.(); + }); + + it("toggles mute active state based on store", () => { + setVoiceChannel(1, []); + voiceStore.setState((prev) => ({ ...prev, localMuted: true })); + + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + const muteBtn = container.querySelector('[aria-label="Mute"]') as HTMLButtonElement; + expect(muteBtn.classList.contains("active-ctrl")).toBe(true); + + widget.destroy?.(); + }); + + it("cleans up on destroy", () => { + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + const root = container.querySelector('[data-testid="voice-widget"]'); + expect(root).not.toBeNull(); + + widget.destroy?.(); + expect(container.querySelector('[data-testid="voice-widget"]')).toBeNull(); + }); +}); diff --git a/Client/tauri-client/vitest.config.ts b/Client/tauri-client/vitest.config.ts index 00b1fe99..325b4cf8 100644 --- a/Client/tauri-client/vitest.config.ts +++ b/Client/tauri-client/vitest.config.ts @@ -17,7 +17,15 @@ export default defineConfig({ coverage: { provider: "v8", include: ["src/**/*.ts"], - exclude: ["src/main.ts", "src/**/*.d.ts"], + exclude: [ + "src/main.ts", + "src/**/*.d.ts", + "src/lib/window-state.ts", + "src/lib/credentials.ts", + "src/lib/audio.ts", + "src/lib/vad.ts", + "src/lib/webrtc.ts", + ], thresholds: { statements: 80, branches: 80, diff --git a/Server/.golangci.yml b/Server/.golangci.yml new file mode 100644 index 00000000..4012cc8a --- /dev/null +++ b/Server/.golangci.yml @@ -0,0 +1,8 @@ +version: "2" + +linters: + settings: + staticcheck: + checks: + - "all" + - "-SA1019" # suppress deprecated usage warnings (websocket library migration tracked separately) diff --git a/Server/admin/admin.go b/Server/admin/admin.go index 2374389b..0dad90b6 100644 --- a/Server/admin/admin.go +++ b/Server/admin/admin.go @@ -51,7 +51,7 @@ func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater. // global CSP (default-src 'self') to allow them. w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'") - w.Write(indexHTML) + _, _ = w.Write(indexHTML) }) r.Handle("/*", http.FileServer(http.FS(staticFS))) diff --git a/Server/admin/api_test.go b/Server/admin/api_test.go index 043f41b2..4f8d2ded 100644 --- a/Server/admin/api_test.go +++ b/Server/admin/api_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "testing/fstest" @@ -169,7 +170,7 @@ func createMemberUser(t *testing.T, database *db.DB) string { return token } -func doRequest(t *testing.T, handler http.Handler, method, path, token string, body interface{}) *httptest.ResponseRecorder { +func doRequest(t *testing.T, handler http.Handler, method, path, token string, body any) *httptest.ResponseRecorder { t.Helper() var bodyBytes []byte if body != nil { @@ -206,7 +207,7 @@ func TestAdminAPI_Stats_OK(t *testing.T) { t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } - var stats map[string]interface{} + var stats map[string]any if err := json.Unmarshal(w.Body.Bytes(), &stats); err != nil { t.Fatalf("unmarshal stats: %v", err) } @@ -254,7 +255,7 @@ func TestAdminAPI_ListUsers_OK(t *testing.T) { t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } - var users []interface{} + var users []any if err := json.Unmarshal(w.Body.Bytes(), &users); err != nil { t.Fatalf("unmarshal users: %v", err) } @@ -298,7 +299,7 @@ func TestAdminAPI_PatchUser_BanUser(t *testing.T) { // Create a target user targetUID, _ := database.CreateUser("target", "hash", 3) - body := map[string]interface{}{ + body := map[string]any{ "banned": true, "ban_reason": "spam", } @@ -325,7 +326,7 @@ func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) { targetUID, _ := database.CreateUser("rolechange", "hash", 3) - body := map[string]interface{}{ + body := map[string]any{ "role_id": float64(2), } w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body) @@ -345,7 +346,7 @@ func TestAdminAPI_PatchUser_NotFound(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) token := createAdminUser(t, database) - body := map[string]interface{}{"banned": true} + body := map[string]any{"banned": true} w := doRequest(t, handler, http.MethodPatch, "/users/99999", token, body) if w.Code != http.StatusNotFound { @@ -413,7 +414,7 @@ func TestAdminAPI_ListChannels_OK(t *testing.T) { t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } - var channels []interface{} + var channels []any if err := json.Unmarshal(w.Body.Bytes(), &channels); err != nil { t.Fatalf("unmarshal channels: %v", err) } @@ -429,7 +430,7 @@ func TestAdminAPI_CreateChannel_OK(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) token := createAdminUser(t, database) - body := map[string]interface{}{ + body := map[string]any{ "name": "new-channel", "type": "text", "category": "General", @@ -442,7 +443,7 @@ func TestAdminAPI_CreateChannel_OK(t *testing.T) { t.Errorf("status = %d, want 201; body: %s", w.Code, w.Body.String()) } - var resp map[string]interface{} + var resp map[string]any if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal response: %v", err) } @@ -456,7 +457,7 @@ func TestAdminAPI_CreateChannel_MissingName(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) token := createAdminUser(t, database) - body := map[string]interface{}{ + body := map[string]any{ "type": "text", } w := doRequest(t, handler, http.MethodPost, "/channels", token, body) @@ -475,7 +476,7 @@ func TestAdminAPI_UpdateChannel_OK(t *testing.T) { chID, _ := database.AdminCreateChannel("old", "text", "", "", 0) - body := map[string]interface{}{ + body := map[string]any{ "name": "updated", "topic": "new topic", "slow_mode": float64(10), @@ -494,7 +495,7 @@ func TestAdminAPI_UpdateChannel_NotFound(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) token := createAdminUser(t, database) - body := map[string]interface{}{"name": "x"} + body := map[string]any{"name": "x"} w := doRequest(t, handler, http.MethodPatch, "/channels/99999", token, body) if w.Code != http.StatusNotFound { @@ -546,7 +547,7 @@ func TestAdminAPI_AuditLog_OK(t *testing.T) { t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } - var entries []interface{} + var entries []any if err := json.Unmarshal(w.Body.Bytes(), &entries); err != nil { t.Fatalf("unmarshal: %v", err) } @@ -566,7 +567,7 @@ func TestAdminAPI_AuditLog_Empty(t *testing.T) { t.Errorf("status = %d, want 200", w.Code) } - var entries []interface{} + var entries []any json.Unmarshal(w.Body.Bytes(), &entries) if len(entries) != 0 { t.Errorf("expected 0 entries, got %d", len(entries)) @@ -681,7 +682,7 @@ func TestAdminAPI_ActorFromContext_AuditEntry(t *testing.T) { // Create a target user to act on. targetUID, _ := database.CreateUser("ctxtarget", "hash", 3) - body := map[string]interface{}{"banned": true, "ban_reason": "context test"} + body := map[string]any{"banned": true, "ban_reason": "context test"} w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body) if w.Code != http.StatusOK { @@ -856,11 +857,11 @@ func TestAdminAPI_ListUsers_NoPasswordHash(t *testing.T) { body := w.Body.String() // The raw bcrypt hash must never appear in the response. - if contains(body, "supersecretbcrypthash") { + if strings.Contains(body, "supersecretbcrypthash") { t.Error("GET /users response contains PasswordHash — sensitive field leaked") } // The JSON key itself must also be absent. - if contains(body, "password_hash") || contains(body, "PasswordHash") { + if strings.Contains(body, "password_hash") || strings.Contains(body, "PasswordHash") { t.Error("GET /users response contains password_hash key — sensitive field leaked") } } @@ -879,7 +880,7 @@ func TestAdminAPI_ListUsers_NoTOTPSecret(t *testing.T) { } body := w.Body.String() - if contains(body, "totp_secret") || contains(body, "TOTPSecret") { + if strings.Contains(body, "totp_secret") || strings.Contains(body, "TOTPSecret") { t.Error("GET /users response contains totp_secret key — sensitive field leaked") } } @@ -897,7 +898,7 @@ func TestAdminAPI_ListUsers_PublicFieldsPresent(t *testing.T) { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } - var users []map[string]interface{} + var users []map[string]any if err := json.Unmarshal(w.Body.Bytes(), &users); err != nil { t.Fatalf("unmarshal: %v", err) } @@ -922,7 +923,7 @@ func TestAdminAPI_PatchUser_NoPasswordHash(t *testing.T) { targetUID, _ := database.CreateUser("patchvictim", "topsecretbcrypt", 3) - body := map[string]interface{}{ + body := map[string]any{ "banned": true, "ban_reason": "test", } @@ -933,10 +934,10 @@ func TestAdminAPI_PatchUser_NoPasswordHash(t *testing.T) { } respBody := w.Body.String() - if contains(respBody, "topsecretbcrypt") { + if strings.Contains(respBody, "topsecretbcrypt") { t.Error("PATCH /users/{id} response contains PasswordHash — sensitive field leaked") } - if contains(respBody, "password_hash") || contains(respBody, "PasswordHash") { + if strings.Contains(respBody, "password_hash") || strings.Contains(respBody, "PasswordHash") { t.Error("PATCH /users/{id} response contains password_hash key — sensitive field leaked") } } @@ -950,7 +951,7 @@ func TestAdminAPI_PatchUser_NoTOTPSecret(t *testing.T) { targetUID, _ := database.CreateUser("patchtotp", "hash", 3) - w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, map[string]interface{}{ + w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, map[string]any{ "banned": false, }) @@ -959,7 +960,7 @@ func TestAdminAPI_PatchUser_NoTOTPSecret(t *testing.T) { } respBody := w.Body.String() - if contains(respBody, "totp_secret") || contains(respBody, "TOTPSecret") { + if strings.Contains(respBody, "totp_secret") || strings.Contains(respBody, "TOTPSecret") { t.Error("PATCH /users/{id} response contains totp_secret — sensitive field leaked") } } @@ -1021,7 +1022,7 @@ func TestAdminAPI_CreateChannel_BroadcastsChannelCreate(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", hub, nil) token := createAdminUser(t, database) - body := map[string]interface{}{ + body := map[string]any{ "name": "broadcast-test", "type": "text", } @@ -1041,10 +1042,10 @@ func TestAdminAPI_CreateChannel_BroadcastsChannelCreate(t *testing.T) { func TestAdminAPI_CreateChannel_NilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) // nil hub: handler must not panic - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) token := createAdminUser(t, database) - body := map[string]interface{}{"name": "safe-channel", "type": "text"} + body := map[string]any{"name": "safe-channel", "type": "text"} w := doRequest(t, handler, http.MethodPost, "/channels", token, body) if w.Code != http.StatusCreated { @@ -1060,7 +1061,7 @@ func TestAdminAPI_UpdateChannel_BroadcastsChannelUpdate(t *testing.T) { chID, _ := database.AdminCreateChannel("before", "text", "", "", 0) - body := map[string]interface{}{"name": "after"} + body := map[string]any{"name": "after"} w := doRequest(t, handler, http.MethodPatch, "/channels/"+itoa(chID), token, body) if w.Code != http.StatusOK { @@ -1076,11 +1077,11 @@ func TestAdminAPI_UpdateChannel_BroadcastsChannelUpdate(t *testing.T) { func TestAdminAPI_UpdateChannel_NilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("patchme", "text", "", "", 0) - body := map[string]interface{}{"name": "patched"} + body := map[string]any{"name": "patched"} w := doRequest(t, handler, http.MethodPatch, "/channels/"+itoa(chID), token, body) if w.Code != http.StatusOK { @@ -1111,7 +1112,7 @@ func TestAdminAPI_DeleteChannel_BroadcastsChannelDelete(t *testing.T) { func TestAdminAPI_DeleteChannel_NilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("del-no-hub", "text", "", "", 0) @@ -1129,15 +1130,3 @@ func itoa(n int64) string { return fmt.Sprint(n) } -// contains reports whether s contains sub (plain substring search). -func contains(s, sub string) bool { - if len(sub) == 0 { - return true - } - for i := 0; i <= len(s)-len(sub); i++ { - if s[i:i+len(sub)] == sub { - return true - } - } - return false -} diff --git a/Server/admin/handlers_users.go b/Server/admin/handlers_users.go index a06be0cf..9b3b27e9 100644 --- a/Server/admin/handlers_users.go +++ b/Server/admin/handlers_users.go @@ -18,7 +18,9 @@ func handleGetStats(database *db.DB, hub HubBroadcaster) http.HandlerFunc { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get stats") return } - stats.OnlineCount = hub.ClientCount() + if hub != nil { + stats.OnlineCount = hub.ClientCount() + } writeJSON(w, http.StatusOK, stats) } } @@ -92,7 +94,9 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster) http.HandlerFunc { fmt.Sprintf("changed %s role to %d", user.Username, *req.RoleID)) // Broadcast member_update with the new role name. if role, err := database.GetRoleByID(*req.RoleID); err == nil && role != nil { - hub.BroadcastMemberUpdate(id, role.Name) + if hub != nil { + hub.BroadcastMemberUpdate(id, role.Name) + } } } @@ -109,7 +113,9 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster) http.HandlerFunc { slog.Warn("user banned", "actor_id", actor, "target_user", user.Username, "reason", reason) _ = database.LogAudit(actor, "user_ban", "user", id, fmt.Sprintf("banned %s: %s", user.Username, reason)) - hub.BroadcastMemberBan(id) + if hub != nil { + hub.BroadcastMemberBan(id) + } } else { if err := database.UnbanUser(id); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to unban user") diff --git a/Server/admin/update_handlers.go b/Server/admin/update_handlers.go index 3379d373..6aee7e8c 100644 --- a/Server/admin/update_handlers.go +++ b/Server/admin/update_handlers.go @@ -31,7 +31,7 @@ func handleCheckUpdate(u *updater.Updater) http.HandlerFunc { } // handleApplyUpdate downloads and applies a server update. -func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, currentVersion string) http.Handler { +func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if u == nil { writeErr(w, http.StatusServiceUnavailable, "UPDATE_UNAVAILABLE", "update checking is not configured") diff --git a/Server/admin/update_handlers_test.go b/Server/admin/update_handlers_test.go index 2f284b4d..9feb2b9f 100644 --- a/Server/admin/update_handlers_test.go +++ b/Server/admin/update_handlers_test.go @@ -14,11 +14,11 @@ import ( func TestAdminAPI_CheckUpdate_OK(t *testing.T) { // Mock GitHub API mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(map[string]interface{}{ + json.NewEncoder(w).Encode(map[string]any{ "tag_name": "v2.0.0", "body": "New release", "html_url": "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0", - "assets": []map[string]interface{}{ + "assets": []map[string]any{ {"name": "chatserver.exe", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver.exe"}, {"name": "checksums.sha256", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/checksums.sha256"}, }, @@ -50,11 +50,11 @@ func TestAdminAPI_CheckUpdate_OK(t *testing.T) { func TestAdminAPI_CheckUpdate_UpToDate(t *testing.T) { mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(map[string]interface{}{ + json.NewEncoder(w).Encode(map[string]any{ "tag_name": "v1.0.0", "body": "", "html_url": "https://github.com/J3vb/OwnCord/releases/tag/v1.0.0", - "assets": []map[string]interface{}{}, + "assets": []map[string]any{}, }) })) defer mockGH.Close() diff --git a/Server/api/auth_handler_test.go b/Server/api/auth_handler_test.go index e34aa22f..6d466dfb 100644 --- a/Server/api/auth_handler_test.go +++ b/Server/api/auth_handler_test.go @@ -41,7 +41,7 @@ func buildAuthRouter(database *db.DB, limiter *auth.RateLimiter) http.Handler { } // postJSON is a test helper that POSTs JSON to the given router. -func postJSON(t *testing.T, router http.Handler, path string, body interface{}) *httptest.ResponseRecorder { +func postJSON(t *testing.T, router http.Handler, path string, body any) *httptest.ResponseRecorder { t.Helper() raw, _ := json.Marshal(body) req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw)) @@ -53,7 +53,7 @@ func postJSON(t *testing.T, router http.Handler, path string, body interface{}) } // postJSONWithToken posts with an Authorization header. -func postJSONWithToken(t *testing.T, router http.Handler, path, token string, body interface{}) *httptest.ResponseRecorder { +func postJSONWithToken(t *testing.T, router http.Handler, path, token string, body any) *httptest.ResponseRecorder { t.Helper() raw, _ := json.Marshal(body) req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw)) @@ -97,7 +97,7 @@ func TestRegister_Success(t *testing.T) { t.Errorf("Register status = %d, want 201; body = %s", rr.Code, rr.Body.String()) } - var resp map[string]interface{} + var resp map[string]any json.NewDecoder(rr.Body).Decode(&resp) if resp["token"] == nil { t.Error("Register response missing token") @@ -217,7 +217,7 @@ func TestLogin_Success(t *testing.T) { t.Errorf("Login status = %d, want 200; body = %s", rr.Code, rr.Body.String()) } - var resp map[string]interface{} + var resp map[string]any json.NewDecoder(rr.Body).Decode(&resp) if resp["token"] == nil { t.Error("Login response missing token") @@ -363,7 +363,7 @@ func TestMe_Success(t *testing.T) { t.Errorf("Me status = %d, want 200; body = %s", rr.Code, rr.Body.String()) } - var resp map[string]interface{} + var resp map[string]any json.NewDecoder(rr.Body).Decode(&resp) if resp["id"] == nil { t.Error("Me response missing id") @@ -487,7 +487,7 @@ func TestRegister_RateLimit(t *testing.T) { // Attempt register 4 times (limit=3) — 4th should be rate-limited. var lastCode int - for i := 0; i < 4; i++ { + for i := range 4 { code, _ := database.CreateInvite(ownerID, 1, nil) rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ "username": "rl_user" + string(rune('0'+i)), diff --git a/Server/api/channel_authz_test.go b/Server/api/channel_authz_test.go index 2ac7aad2..e7a8b34e 100644 --- a/Server/api/channel_authz_test.go +++ b/Server/api/channel_authz_test.go @@ -48,7 +48,7 @@ func TestChannelList_FiltersOutDeniedChannels(t *testing.T) { t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) } - var channels []map[string]interface{} + var channels []map[string]any if err := json.NewDecoder(rr.Body).Decode(&channels); err != nil { t.Fatalf("decode: %v", err) } @@ -82,7 +82,7 @@ func TestChannelList_AdminSeesAllChannels(t *testing.T) { t.Fatalf("status = %d, want 200", rr.Code) } - var channels []interface{} + var channels []any _ = json.NewDecoder(rr.Body).Decode(&channels) if len(channels) != 2 { t.Errorf("admin should see all 2 channels, got %d", len(channels)) @@ -151,9 +151,9 @@ func TestSearch_FiltersResultsByPermission(t *testing.T) { t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) } - var resp map[string]interface{} + var resp map[string]any _ = json.NewDecoder(rr.Body).Decode(&resp) - results, ok := resp["results"].([]interface{}) + results, ok := resp["results"].([]any) if !ok { t.Fatalf("results is not an array: %v", resp) } @@ -184,9 +184,9 @@ func TestSearch_AdminSeesAllResults(t *testing.T) { t.Fatalf("status = %d, want 200", rr.Code) } - var resp map[string]interface{} + var resp map[string]any _ = json.NewDecoder(rr.Body).Decode(&resp) - results := resp["results"].([]interface{}) + results := resp["results"].([]any) if len(results) != 2 { t.Errorf("admin should see all 2 results, got %d", len(results)) } diff --git a/Server/api/channel_handler_test.go b/Server/api/channel_handler_test.go index 41691f0d..2e9fe6c8 100644 --- a/Server/api/channel_handler_test.go +++ b/Server/api/channel_handler_test.go @@ -225,7 +225,7 @@ func TestChannelList_Empty(t *testing.T) { if rr.Code != http.StatusOK { t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) } - var resp []interface{} + var resp []any _ = json.NewDecoder(rr.Body).Decode(&resp) if len(resp) != 0 { t.Errorf("expected empty array, got %d items", len(resp)) @@ -244,7 +244,7 @@ func TestChannelList_WithChannels(t *testing.T) { if rr.Code != http.StatusOK { t.Errorf("status = %d, want 200", rr.Code) } - var resp []interface{} + var resp []any _ = json.NewDecoder(rr.Body).Decode(&resp) if len(resp) != 2 { t.Errorf("expected 2 channels, got %d", len(resp)) @@ -293,9 +293,9 @@ func TestChannelMessages_EmptyChannel(t *testing.T) { if rr.Code != http.StatusOK { t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) } - var resp map[string]interface{} + var resp map[string]any _ = json.NewDecoder(rr.Body).Decode(&resp) - msgs, ok := resp["messages"].([]interface{}) + msgs, ok := resp["messages"].([]any) if !ok || len(msgs) != 0 { t.Errorf("expected empty messages array, got: %v", resp["messages"]) } @@ -308,7 +308,7 @@ func TestChannelMessages_ReturnsMessages(t *testing.T) { user, _ := database.GetUserByUsername("frank") chID, _ := database.CreateChannel("ch", "text", "", "", 0) - for i := 0; i < 3; i++ { + for i := range 3 { _, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("msg%d", i), nil) } @@ -316,9 +316,9 @@ func TestChannelMessages_ReturnsMessages(t *testing.T) { if rr.Code != http.StatusOK { t.Errorf("status = %d, want 200", rr.Code) } - var resp map[string]interface{} + var resp map[string]any _ = json.NewDecoder(rr.Body).Decode(&resp) - msgs := resp["messages"].([]interface{}) + msgs := resp["messages"].([]any) if len(msgs) != 3 { t.Errorf("expected 3 messages, got %d", len(msgs)) } @@ -344,7 +344,7 @@ func TestChannelMessages_HasMore(t *testing.T) { user, _ := database.GetUserByUsername("henry") chID, _ := database.CreateChannel("ch", "text", "", "", 0) - for i := 0; i < 60; i++ { + for i := range 60 { _, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("m%d", i), nil) } @@ -352,7 +352,7 @@ func TestChannelMessages_HasMore(t *testing.T) { if rr.Code != http.StatusOK { t.Errorf("status = %d, want 200", rr.Code) } - var resp map[string]interface{} + var resp map[string]any _ = json.NewDecoder(rr.Body).Decode(&resp) if resp["has_more"] != true { t.Errorf("has_more = %v, want true", resp["has_more"]) @@ -366,7 +366,7 @@ func TestChannelMessages_HasMoreFalse(t *testing.T) { user, _ := database.GetUserByUsername("ivan") chID, _ := database.CreateChannel("ch", "text", "", "", 0) - for i := 0; i < 5; i++ { + for i := range 5 { _, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("m%d", i), nil) } @@ -374,7 +374,7 @@ func TestChannelMessages_HasMoreFalse(t *testing.T) { if rr.Code != http.StatusOK { t.Errorf("status = %d, want 200", rr.Code) } - var resp map[string]interface{} + var resp map[string]any _ = json.NewDecoder(rr.Body).Decode(&resp) if resp["has_more"] != false { t.Errorf("has_more = %v, want false", resp["has_more"]) @@ -414,9 +414,9 @@ func TestSearch_ReturnsResults(t *testing.T) { if rr.Code != http.StatusOK { t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) } - var resp map[string]interface{} + var resp map[string]any _ = json.NewDecoder(rr.Body).Decode(&resp) - results, ok := resp["results"].([]interface{}) + results, ok := resp["results"].([]any) if !ok || len(results) == 0 { t.Errorf("expected search results, got: %v", resp) } @@ -431,9 +431,9 @@ func TestSearch_NoResults(t *testing.T) { if rr.Code != http.StatusOK { t.Errorf("status = %d, want 200", rr.Code) } - var resp map[string]interface{} + var resp map[string]any _ = json.NewDecoder(rr.Body).Decode(&resp) - results := resp["results"].([]interface{}) + results := resp["results"].([]any) if len(results) != 0 { t.Errorf("expected 0 results, got %d", len(results)) } diff --git a/Server/api/contract_test.go b/Server/api/contract_test.go index 32061ba7..3d2b1da1 100644 --- a/Server/api/contract_test.go +++ b/Server/api/contract_test.go @@ -41,7 +41,7 @@ func TestContract_Messages_HasRequiredFields(t *testing.T) { } // Parse the first message and verify all API.md fields are present. - var msg map[string]interface{} + var msg map[string]any if err := json.Unmarshal(resp.Messages[0], &msg); err != nil { t.Fatalf("decode message: %v", err) } @@ -58,7 +58,7 @@ func TestContract_Messages_HasRequiredFields(t *testing.T) { } // Verify 'user' is an object with id, username. - userObj, ok := msg["user"].(map[string]interface{}) + userObj, ok := msg["user"].(map[string]any) if !ok { t.Fatal("'user' is not an object") } @@ -69,12 +69,12 @@ func TestContract_Messages_HasRequiredFields(t *testing.T) { } // Verify 'attachments' is an array (even if empty). - if _, ok := msg["attachments"].([]interface{}); !ok { + if _, ok := msg["attachments"].([]any); !ok { t.Error("'attachments' is not an array") } // Verify 'reactions' is an array (even if empty). - if _, ok := msg["reactions"].([]interface{}); !ok { + if _, ok := msg["reactions"].([]any); !ok { t.Error("'reactions' is not an array") } } @@ -152,7 +152,7 @@ func TestContract_Search_HasRequiredFields(t *testing.T) { t.Fatal("expected at least 1 search result") } - var result map[string]interface{} + var result map[string]any if err := json.Unmarshal(resp.Results[0], &result); err != nil { t.Fatalf("decode result: %v", err) } @@ -169,7 +169,7 @@ func TestContract_Search_HasRequiredFields(t *testing.T) { } // Verify 'user' is an object with id and username. - userObj, ok := result["user"].(map[string]interface{}) + userObj, ok := result["user"].(map[string]any) if !ok { t.Fatal("search result 'user' is not an object") } diff --git a/Server/api/invite_handler_test.go b/Server/api/invite_handler_test.go index 9aa79224..8ad8e1cc 100644 --- a/Server/api/invite_handler_test.go +++ b/Server/api/invite_handler_test.go @@ -21,7 +21,7 @@ func buildInviteRouter(database *db.DB, limiter *auth.RateLimiter) http.Handler } // loginAndGetToken creates a user with a known password and returns their session token. -func loginAndGetToken(t *testing.T, router http.Handler, database *db.DB, username string, roleID int) string { +func loginAndGetToken(t *testing.T, _ http.Handler, database *db.DB, username string, roleID int) string { t.Helper() hash, _ := auth.HashPassword("Password1!") uid, _ := database.CreateUser(username, hash, roleID) @@ -40,7 +40,7 @@ func TestCreateInvite_Success(t *testing.T) { // Admin role (id=2) has MANAGE_INVITES (0x4000000) set. token := loginAndGetToken(t, router, database, "invitecreator", 2) - rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]interface{}{ + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{ "max_uses": 5, "expires_in_hours": 48, }) @@ -49,7 +49,7 @@ func TestCreateInvite_Success(t *testing.T) { t.Errorf("CreateInvite status = %d, want 201; body = %s", rr.Code, rr.Body.String()) } - var resp map[string]interface{} + var resp map[string]any json.NewDecoder(rr.Body).Decode(&resp) if resp["code"] == nil { t.Error("CreateInvite response missing code") @@ -61,7 +61,7 @@ func TestCreateInvite_Unauthorized(t *testing.T) { limiter := auth.NewRateLimiter() router := buildInviteRouter(database, limiter) - rr := postJSON(t, router, "/api/v1/invites", map[string]interface{}{ + rr := postJSON(t, router, "/api/v1/invites", map[string]any{ "max_uses": 5, }) @@ -78,7 +78,7 @@ func TestCreateInvite_MemberForbidden(t *testing.T) { // Member role (id=4) does NOT have MANAGE_INVITES. token := loginAndGetToken(t, router, database, "memberuser", 4) - rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]interface{}{ + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{ "max_uses": 1, }) @@ -94,7 +94,7 @@ func TestCreateInvite_Unlimited(t *testing.T) { token := loginAndGetToken(t, router, database, "adminuser2", 2) - rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]interface{}{}) + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{}) if rr.Code != http.StatusCreated { t.Errorf("CreateInvite unlimited status = %d, want 201", rr.Code) @@ -111,8 +111,8 @@ func TestListInvites_Success(t *testing.T) { token := loginAndGetToken(t, router, database, "listuser", 2) // Create a couple of invites. - postJSONWithToken(t, router, "/api/v1/invites", token, map[string]interface{}{"max_uses": 1}) - postJSONWithToken(t, router, "/api/v1/invites", token, map[string]interface{}{"max_uses": 5}) + postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{"max_uses": 1}) + postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{"max_uses": 5}) req := httptest.NewRequest(http.MethodGet, "/api/v1/invites", nil) req.Header.Set("Authorization", "Bearer "+token) @@ -124,7 +124,7 @@ func TestListInvites_Success(t *testing.T) { t.Errorf("ListInvites status = %d, want 200; body = %s", rr.Code, rr.Body.String()) } - var resp []interface{} + var resp []any json.NewDecoder(rr.Body).Decode(&resp) if len(resp) < 2 { t.Errorf("ListInvites returned %d items, want >= 2", len(resp)) @@ -156,11 +156,11 @@ func TestRevokeInvite_Success(t *testing.T) { token := loginAndGetToken(t, router, database, "revoker", 2) // Create invite via API. - rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]interface{}{}) + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{}) if rr.Code != http.StatusCreated { t.Fatalf("Create invite for revoke test: status = %d, body = %s", rr.Code, rr.Body.String()) } - var created map[string]interface{} + var created map[string]any json.NewDecoder(rr.Body).Decode(&created) codeVal, ok := created["code"] if !ok || codeVal == nil { @@ -213,8 +213,8 @@ func TestRevokeInvite_MemberForbidden(t *testing.T) { memberToken := loginAndGetToken(t, router, database, "member3", 4) // Admin creates invite. - rr := postJSONWithToken(t, router, "/api/v1/invites", adminToken, map[string]interface{}{}) - var created map[string]interface{} + rr := postJSONWithToken(t, router, "/api/v1/invites", adminToken, map[string]any{}) + var created map[string]any json.NewDecoder(rr.Body).Decode(&created) code := created["code"].(string) @@ -240,11 +240,11 @@ func TestListInvites_IncludesRevokedAndActive(t *testing.T) { token := loginAndGetToken(t, router, database, "listall", 2) // Create and revoke one invite. - rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]interface{}{}) + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{}) if rr.Code != http.StatusCreated { t.Fatalf("Create invite for list test: status = %d, body = %s", rr.Code, rr.Body.String()) } - var created map[string]interface{} + var created map[string]any json.NewDecoder(rr.Body).Decode(&created) code := created["code"].(string) @@ -255,7 +255,7 @@ func TestListInvites_IncludesRevokedAndActive(t *testing.T) { router.ServeHTTP(httptest.NewRecorder(), delReq) // Create one active invite. - postJSONWithToken(t, router, "/api/v1/invites", token, map[string]interface{}{}) + postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{}) // List should include both. req := httptest.NewRequest(http.MethodGet, "/api/v1/invites", nil) @@ -269,26 +269,3 @@ func TestListInvites_IncludesRevokedAndActive(t *testing.T) { } } -// ─── Helpers for ListInvites queries ───────────────────────────────────────── - -// ListInvites returns all invites from the DB for assertions. -func listInvitesFromDB(t *testing.T, database *db.DB) []*db.Invite { - t.Helper() - rows, err := database.Query(`SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at FROM invites`) - if err != nil { - t.Fatalf("listing invites: %v", err) - } - defer rows.Close() - - var invites []*db.Invite - for rows.Next() { - inv := &db.Invite{} - var revoked int - if err := rows.Scan(&inv.ID, &inv.Code, &inv.CreatedBy, &inv.MaxUses, &inv.Uses, &inv.ExpiresAt, &revoked, &inv.CreatedAt); err != nil { - t.Fatalf("scanning invite: %v", err) - } - inv.Revoked = revoked != 0 - invites = append(invites, inv) - } - return invites -} diff --git a/Server/api/middleware_test.go b/Server/api/middleware_test.go index 79221941..a6ad21d4 100644 --- a/Server/api/middleware_test.go +++ b/Server/api/middleware_test.go @@ -232,7 +232,7 @@ func TestRateLimitMiddleware_OverLimit(t *testing.T) { h := api.RateLimitMiddleware(limiter, limit, time.Minute)(http.HandlerFunc(ok)) - for i := 0; i < limit; i++ { + for range limit { req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "10.0.0.2:1234" rr := httptest.NewRecorder() @@ -256,7 +256,7 @@ func TestRateLimitMiddleware_RetryAfterHeader(t *testing.T) { h := api.RateLimitMiddleware(limiter, 1, time.Minute)(http.HandlerFunc(ok)) // Exhaust limit. - for i := 0; i < 2; i++ { + for range 2 { req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "10.0.0.3:1234" rr := httptest.NewRecorder() @@ -283,7 +283,7 @@ func TestRateLimitMiddleware_XRealIPIgnoredWithoutTrustedProxy(t *testing.T) { h := api.RateLimitMiddleware(limiter, limit, time.Minute)(http.HandlerFunc(ok)) // Two requests from RemoteAddr 10.0.0.99 with an attacker-supplied X-Real-IP. - for i := 0; i < limit; i++ { + for range limit { req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("X-Real-IP", "192.168.1.1") // forged; must be ignored req.RemoteAddr = "10.0.0.99:9999" @@ -313,7 +313,7 @@ func TestRateLimitMiddleware_XRealIPHonouredFromTrustedProxy(t *testing.T) { h := api.RateLimitMiddleware(limiter, limit, time.Minute, trustedCIDRs)(http.HandlerFunc(ok)) // Two requests coming through trusted proxy 10.0.0.1, client IP 203.0.113.5. - for i := 0; i < limit; i++ { + for range limit { req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("X-Real-IP", "203.0.113.5") req.RemoteAddr = "10.0.0.1:9999" diff --git a/Server/api/router_test.go b/Server/api/router_test.go index 1e7f9902..5312a03e 100644 --- a/Server/api/router_test.go +++ b/Server/api/router_test.go @@ -61,7 +61,7 @@ func TestHealthEndpointReturnsJSON(t *testing.T) { t.Errorf("Content-Type = %q, want application/json", contentType) } - var body map[string]interface{} + var body map[string]any if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatalf("response body is not valid JSON: %v", err) } @@ -75,7 +75,7 @@ func TestHealthEndpointStatusOK(t *testing.T) { router.ServeHTTP(rec, req) - var body map[string]interface{} + var body map[string]any if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatalf("JSON decode error: %v", err) } @@ -93,7 +93,7 @@ func TestHealthEndpointHasVersion(t *testing.T) { router.ServeHTTP(rec, req) - var body map[string]interface{} + var body map[string]any if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatalf("JSON decode error: %v", err) } @@ -124,7 +124,7 @@ func TestAPIV1InfoReturnsServerName(t *testing.T) { router.ServeHTTP(rec, req) - var body map[string]interface{} + var body map[string]any if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatalf("JSON decode error: %v", err) } @@ -142,7 +142,7 @@ func TestAPIV1InfoReturnsVersion(t *testing.T) { router.ServeHTTP(rec, req) - var body map[string]interface{} + var body map[string]any if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatalf("JSON decode error: %v", err) } diff --git a/Server/api/voice_handler.go b/Server/api/voice_handler.go index 23368b7e..0cf2eafd 100644 --- a/Server/api/voice_handler.go +++ b/Server/api/voice_handler.go @@ -92,7 +92,7 @@ func generateTURNCredentials(userID int64, secret string) turnCredentials { username := fmt.Sprintf("%d:%d", expiry, userID) mac := hmac.New(sha1.New, []byte(secret)) - mac.Write([]byte(username)) + _, _ = mac.Write([]byte(username)) credential := base64.StdEncoding.EncodeToString(mac.Sum(nil)) return turnCredentials{ diff --git a/Server/api/voice_handler_test.go b/Server/api/voice_handler_test.go index ac0cf4a9..b69851b7 100644 --- a/Server/api/voice_handler_test.go +++ b/Server/api/voice_handler_test.go @@ -132,7 +132,7 @@ func TestVoiceCredentials_ResponseContainsIceServers(t *testing.T) { router := buildVoiceRouter(database, cfg) rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) - var resp map[string]interface{} + var resp map[string]any if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } @@ -141,7 +141,7 @@ func TestVoiceCredentials_ResponseContainsIceServers(t *testing.T) { if !ok { t.Fatal("response missing ice_servers field") } - servers, ok := iceServers.([]interface{}) + servers, ok := iceServers.([]any) if !ok || len(servers) == 0 { t.Error("ice_servers is empty or wrong type") } @@ -155,13 +155,13 @@ func TestVoiceCredentials_ContainsSTUNEntry(t *testing.T) { router := buildVoiceRouter(database, cfg) rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) - var resp map[string]interface{} + var resp map[string]any json.NewDecoder(rr.Body).Decode(&resp) - servers := resp["ice_servers"].([]interface{}) + servers := resp["ice_servers"].([]any) foundSTUN := false for _, s := range servers { - entry := s.(map[string]interface{}) + entry := s.(map[string]any) if urls, ok := entry["urls"].(string); ok { if len(urls) > 5 && urls[:5] == "stun:" { foundSTUN = true @@ -182,13 +182,13 @@ func TestVoiceCredentials_ContainsTURNEntry(t *testing.T) { router := buildVoiceRouter(database, cfg) rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) - var resp map[string]interface{} + var resp map[string]any json.NewDecoder(rr.Body).Decode(&resp) - servers := resp["ice_servers"].([]interface{}) + servers := resp["ice_servers"].([]any) foundTURN := false for _, s := range servers { - entry := s.(map[string]interface{}) + entry := s.(map[string]any) if urls, ok := entry["urls"].(string); ok { if len(urls) > 5 && urls[:5] == "turn:" { foundTURN = true @@ -224,12 +224,12 @@ func TestVoiceCredentials_TURNCredentialIsValidHMAC(t *testing.T) { router := buildVoiceRouter(database, cfg) rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) - var resp map[string]interface{} + var resp map[string]any json.NewDecoder(rr.Body).Decode(&resp) - servers := resp["ice_servers"].([]interface{}) + servers := resp["ice_servers"].([]any) for _, s := range servers { - entry := s.(map[string]interface{}) + entry := s.(map[string]any) urls, _ := entry["urls"].(string) if len(urls) < 5 || urls[:5] != "turn:" { continue @@ -262,12 +262,12 @@ func TestVoiceCredentials_UsernameContainsTimestampAndUserID(t *testing.T) { router := buildVoiceRouter(database, cfg) rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) - var resp map[string]interface{} + var resp map[string]any json.NewDecoder(rr.Body).Decode(&resp) - servers := resp["ice_servers"].([]interface{}) + servers := resp["ice_servers"].([]any) for _, s := range servers { - entry := s.(map[string]interface{}) + entry := s.(map[string]any) urls, _ := entry["urls"].(string) if len(urls) < 5 || urls[:5] != "turn:" { continue @@ -298,7 +298,7 @@ func TestVoiceCredentials_ResponseContainsExpiresIn(t *testing.T) { router := buildVoiceRouter(database, cfg) rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) - var resp map[string]interface{} + var resp map[string]any json.NewDecoder(rr.Body).Decode(&resp) expiresIn, ok := resp["expires_in"] @@ -331,12 +331,12 @@ func TestVoiceCredentials_TURNDisabled_NoTURNEntry(t *testing.T) { t.Fatalf("status = %d, want 200", rr.Code) } - var resp map[string]interface{} + var resp map[string]any json.NewDecoder(rr.Body).Decode(&resp) - servers := resp["ice_servers"].([]interface{}) + servers := resp["ice_servers"].([]any) for _, s := range servers { - entry := s.(map[string]interface{}) + entry := s.(map[string]any) if urls, _ := entry["urls"].(string); len(urls) >= 5 && urls[:5] == "turn:" { t.Error("TURN entry present when TURNEnabled=false") } diff --git a/Server/auth/ratelimit_test.go b/Server/auth/ratelimit_test.go index ca7b7e31..15770a43 100644 --- a/Server/auth/ratelimit_test.go +++ b/Server/auth/ratelimit_test.go @@ -9,7 +9,7 @@ import ( func TestRateLimiter_UnderLimitAllowed(t *testing.T) { rl := auth.NewRateLimiter() - for i := 0; i < 5; i++ { + for i := range 5 { if !rl.Allow("key1", 5, time.Second) { t.Errorf("Allow() = false at iteration %d, want true", i) } @@ -19,7 +19,7 @@ func TestRateLimiter_UnderLimitAllowed(t *testing.T) { func TestRateLimiter_AtLimitAllowed(t *testing.T) { rl := auth.NewRateLimiter() // Allow up to exactly the limit - for i := 0; i < 3; i++ { + for range 3 { rl.Allow("keyA", 3, time.Second) } // The 4th call should be blocked @@ -31,7 +31,7 @@ func TestRateLimiter_AtLimitAllowed(t *testing.T) { func TestRateLimiter_OverLimitBlocked(t *testing.T) { rl := auth.NewRateLimiter() limit := 3 - for i := 0; i < limit; i++ { + for range limit { rl.Allow("key2", limit, time.Second) } if rl.Allow("key2", limit, time.Second) { @@ -58,7 +58,7 @@ func TestRateLimiter_WindowExpiryResets(t *testing.T) { func TestRateLimiter_DifferentKeysIndependent(t *testing.T) { rl := auth.NewRateLimiter() - for i := 0; i < 5; i++ { + for range 5 { rl.Allow("keyX", 3, time.Second) } // keyY should still be allowed @@ -113,13 +113,13 @@ func TestRateLimiter_LockoutBlocksAllow(t *testing.T) { func TestRateLimiter_ThreadSafe(t *testing.T) { rl := auth.NewRateLimiter() done := make(chan struct{}, 100) - for i := 0; i < 100; i++ { + for range 100 { go func() { rl.Allow("concurrent", 50, time.Second) done <- struct{}{} }() } - for i := 0; i < 100; i++ { + for range 100 { <-done } // If we get here without a race condition data race, we pass diff --git a/Server/auth/session_test.go b/Server/auth/session_test.go index 0b5865af..bf832e74 100644 --- a/Server/auth/session_test.go +++ b/Server/auth/session_test.go @@ -31,7 +31,7 @@ func TestGenerateToken_HexCharacters(t *testing.T) { func TestGenerateToken_Uniqueness(t *testing.T) { const n = 1000 seen := make(map[string]struct{}, n) - for i := 0; i < n; i++ { + for i := range n { tok, err := auth.GenerateToken() if err != nil { t.Fatalf("GenerateToken() iteration %d error = %v", i, err) diff --git a/Server/config/config_test.go b/Server/config/config_test.go index 7b34c50c..844df880 100644 --- a/Server/config/config_test.go +++ b/Server/config/config_test.go @@ -20,8 +20,8 @@ func TestLoadDefaults(t *testing.T) { tests := []struct { name string - got interface{} - want interface{} + got any + want any }{ {"Server.Port", cfg.Server.Port, 8443}, {"Server.Name", cfg.Server.Name, "OwnCord Server"}, @@ -237,8 +237,8 @@ func TestLoadVoiceConfigDefaults(t *testing.T) { tests := []struct { name string - got interface{} - want interface{} + got any + want any }{ {"Voice.Quality", cfg.Voice.Quality, "medium"}, {"Voice.MixingThreshold", cfg.Voice.MixingThreshold, 10}, diff --git a/Server/db/admin_queries_test.go b/Server/db/admin_queries_test.go index af2e024f..90c7f0c2 100644 --- a/Server/db/admin_queries_test.go +++ b/Server/db/admin_queries_test.go @@ -176,7 +176,7 @@ func TestListAllUsers_WithRoleName(t *testing.T) { func TestListAllUsers_Pagination(t *testing.T) { database := newAdminTestDB(t) - for i := 0; i < 5; i++ { + for i := range 5 { _, err := database.CreateUser( strings.Repeat("u", i+1), "hash", @@ -546,7 +546,7 @@ func TestGetAuditLog_Pagination(t *testing.T) { database := newAdminTestDB(t) uid, _ := database.CreateUser("auditpager", "hash", 1) - for i := 0; i < 5; i++ { + for i := range 5 { database.LogAudit(uid, "ACTION", "target", int64(i), "detail") } diff --git a/Server/db/auth_queries_test.go b/Server/db/auth_queries_test.go index 201cbcd0..f9f80dab 100644 --- a/Server/db/auth_queries_test.go +++ b/Server/db/auth_queries_test.go @@ -441,7 +441,7 @@ func TestUseInviteAtomic_IncrementsUses(t *testing.T) { uid, _ := database.CreateUser("atomic_user2", "hash", 4) code, _ := database.CreateInvite(uid, 5, nil) - for i := 0; i < 3; i++ { + for i := range 3 { if err := database.UseInviteAtomic(code); err != nil { t.Fatalf("UseInviteAtomic iteration %d: %v", i, err) } @@ -520,7 +520,7 @@ func TestUseInviteAtomic_ConcurrentSameCode(t *testing.T) { type result struct{ err error } results := make(chan result, 2) - for i := 0; i < 2; i++ { + for range 2 { go func() { results <- result{err: database.UseInviteAtomic(code)} }() diff --git a/Server/db/db_test.go b/Server/db/db_test.go index 87d99b6b..8f9ef9a5 100644 --- a/Server/db/db_test.go +++ b/Server/db/db_test.go @@ -372,7 +372,7 @@ func (fakeDirInfo) Size() int64 { return 0 } func (fakeDirInfo) Mode() fs.FileMode { return fs.ModeDir | 0o755 } func (fakeDirInfo) ModTime() time.Time { return time.Time{} } func (fakeDirInfo) IsDir() bool { return true } -func (fakeDirInfo) Sys() interface{} { return nil } +func (fakeDirInfo) Sys() any { return nil } type fakeDirEntry struct{} @@ -388,7 +388,7 @@ func (fakeFileInfo) Size() int64 { return 0 } func (fakeFileInfo) Mode() fs.FileMode { return 0o644 } func (fakeFileInfo) ModTime() time.Time { return time.Time{} } func (fakeFileInfo) IsDir() bool { return false } -func (fakeFileInfo) Sys() interface{} { return nil } +func (fakeFileInfo) Sys() any { return nil } func TestMigrateFSReadFileError(t *testing.T) { database := openMemory(t) diff --git a/Server/db/message_queries.go b/Server/db/message_queries.go index f1e2414d..ba4850b9 100644 --- a/Server/db/message_queries.go +++ b/Server/db/message_queries.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "fmt" + "strings" ) // CreateMessage inserts a new message and returns the assigned ID. @@ -333,15 +334,16 @@ func (d *DB) getReactionsBatch(msgIDs []int64, requestingUserID int64) (map[int6 } // Build placeholders for IN clause. - placeholders := "" - args := make([]any, 0, len(msgIDs)+len(msgIDs)) + args := make([]any, 0, len(msgIDs)+1) + var sb strings.Builder for i, id := range msgIDs { if i > 0 { - placeholders += "," + sb.WriteByte(',') } - placeholders += "?" + sb.WriteByte('?') args = append(args, id) } + placeholders := sb.String() // Query: aggregate count + check if requesting user reacted. query := fmt.Sprintf( diff --git a/Server/db/message_queries_test.go b/Server/db/message_queries_test.go index aa5360a0..6bbbb882 100644 --- a/Server/db/message_queries_test.go +++ b/Server/db/message_queries_test.go @@ -136,7 +136,7 @@ func TestGetMessages_ReturnsMessages(t *testing.T) { userID := seedUser(t, database, "dave") chID := seedChannel(t, database, "ch") - for i := 0; i < 3; i++ { + for i := range 3 { _, err := database.CreateMessage(chID, userID, "msg", nil) if err != nil { t.Fatalf("CreateMessage %d: %v", i, err) @@ -157,7 +157,7 @@ func TestGetMessages_LimitRespected(t *testing.T) { userID := seedUser(t, database, "eve") chID := seedChannel(t, database, "ch") - for i := 0; i < 10; i++ { + for range 10 { _, _ = database.CreateMessage(chID, userID, "msg", nil) } @@ -173,7 +173,7 @@ func TestGetMessages_BeforePagination(t *testing.T) { chID := seedChannel(t, database, "ch") var ids []int64 - for i := 0; i < 5; i++ { + for range 5 { id, _ := database.CreateMessage(chID, userID, "msg", nil) ids = append(ids, id) } @@ -479,7 +479,7 @@ func TestSearchMessages_LimitRespected(t *testing.T) { userID := seedUser(t, database, "carl") chID := seedChannel(t, database, "ch") - for i := 0; i < 5; i++ { + for range 5 { _, _ = database.CreateMessage(chID, userID, "searchable keyword content", nil) } diff --git a/Server/db/voice_queries_test.go b/Server/db/voice_queries_test.go index 2694c8be..d5cbd4c6 100644 --- a/Server/db/voice_queries_test.go +++ b/Server/db/voice_queries_test.go @@ -7,21 +7,6 @@ import ( "github.com/owncord/server/db" ) -// voiceTestSchema adds the voice_states table on top of the base schema. -var voiceTestSchema = append(testSchema, []byte(` -CREATE TABLE IF NOT EXISTS voice_states ( - user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - muted INTEGER NOT NULL DEFAULT 0, - deafened INTEGER NOT NULL DEFAULT 0, - speaking INTEGER NOT NULL DEFAULT 0, - camera INTEGER NOT NULL DEFAULT 0, - screenshare INTEGER NOT NULL DEFAULT 0, - joined_at TEXT NOT NULL DEFAULT (datetime('now')) -); -CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); -`)...) - var channelSchema = []byte(` CREATE TABLE IF NOT EXISTS channels ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/Server/main.go b/Server/main.go index 29075cfd..c5103b19 100644 --- a/Server/main.go +++ b/Server/main.go @@ -33,7 +33,7 @@ func main() { })) if err := run(log); err != nil { - fmt.Fprintf(os.Stderr, "\n [ERROR] %v\n\n", err) + _, _ = fmt.Fprintf(os.Stderr, "\n [ERROR] %v\n\n", err) log.Error("server exited with error", "error", err) os.Exit(1) } @@ -258,7 +258,7 @@ func printBanner(cfg *config.Config, ver string, tls bool) { `, cfg.Server.Name, ver, tlsStatus, runtime.GOOS, runtime.GOARCH, baseURL, wsURL(scheme, localIP, port), adminURL, baseURL) - fmt.Fprint(os.Stderr, banner) + _, _ = fmt.Fprint(os.Stderr, banner) } // wsURL builds the WebSocket URL with the correct scheme. diff --git a/Server/ws/authz_test.go b/Server/ws/authz_test.go index eddf8c2d..c628132f 100644 --- a/Server/ws/authz_test.go +++ b/Server/ws/authz_test.go @@ -16,9 +16,9 @@ import ( // channelFocusMsg constructs a raw channel_focus WebSocket envelope. func channelFocusMsg(channelID int64) []byte { - raw, _ := json.Marshal(map[string]interface{}{ + raw, _ := json.Marshal(map[string]any{ "type": "channel_focus", - "payload": map[string]interface{}{ + "payload": map[string]any{ "channel_id": channelID, }, }) @@ -56,12 +56,12 @@ func TestChannelFocus_AllowedByDefault(t *testing.T) { // Should NOT receive a FORBIDDEN error. msgs := drainChan(send) for _, m := range msgs { - var env map[string]interface{} + var env map[string]any if err := json.Unmarshal(m, &env); err != nil { continue } if env["type"] == "error" { - if payload, ok := env["payload"].(map[string]interface{}); ok { + if payload, ok := env["payload"].(map[string]any); ok { if payload["code"] == "FORBIDDEN" { t.Error("member was incorrectly denied channel_focus on accessible channel") } @@ -115,12 +115,12 @@ func TestChannelFocus_AdminBypassesDeny(t *testing.T) { // Should NOT receive a FORBIDDEN error. msgs := drainChan(send) for _, m := range msgs { - var env map[string]interface{} + var env map[string]any if err := json.Unmarshal(m, &env); err != nil { continue } if env["type"] == "error" { - if payload, ok := env["payload"].(map[string]interface{}); ok { + if payload, ok := env["payload"].(map[string]any); ok { if payload["code"] == "FORBIDDEN" { t.Error("admin was incorrectly denied channel_focus") } diff --git a/Server/ws/handlers_test.go b/Server/ws/handlers_test.go index 4070e3fa..c8a515f7 100644 --- a/Server/ws/handlers_test.go +++ b/Server/ws/handlers_test.go @@ -123,9 +123,9 @@ func seedChannelWithSlowMode(t *testing.T, database *db.DB, name string, slowMod // chatSendMsg constructs a raw chat_send WebSocket envelope. func chatSendMsg(channelID int64, content string) []byte { - raw, _ := json.Marshal(map[string]interface{}{ + raw, _ := json.Marshal(map[string]any{ "type": "chat_send", - "payload": map[string]interface{}{ + "payload": map[string]any{ "channel_id": channelID, "content": content, }, @@ -141,12 +141,12 @@ func receiveErrorCode(ch <-chan []byte, deadline time.Duration) string { for { select { case msg := <-ch: - var env map[string]interface{} + var env map[string]any if err := json.Unmarshal(msg, &env); err != nil { continue } if env["type"] == "error" { - if payload, ok := env["payload"].(map[string]interface{}); ok { + if payload, ok := env["payload"].(map[string]any); ok { code, _ := payload["code"].(string) return code } @@ -198,7 +198,7 @@ func TestSessionExpiry_ValidSessionAllowsMessages(t *testing.T) { time.Sleep(20 * time.Millisecond) // Trigger the expiry check by sending enough messages to cross the check threshold. - for i := 0; i < ws.SessionCheckInterval+1; i++ { + for i := range ws.SessionCheckInterval + 1 { hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("msg %d", i))) } time.Sleep(100 * time.Millisecond) @@ -236,7 +236,7 @@ func TestSessionExpiry_ExpiredSessionClosesConnection(t *testing.T) { time.Sleep(20 * time.Millisecond) // Trigger the expiry check. - for i := 0; i < ws.SessionCheckInterval+1; i++ { + for range ws.SessionCheckInterval + 1 { // Use a harmless but parseable message to accumulate message count. hub.HandleMessageForTest(c, []byte(`{"type":"presence_update","payload":{"status":"online"}}`)) } @@ -277,7 +277,7 @@ func TestSessionExpiry_MissingTokenHashSkipsCheck(t *testing.T) { time.Sleep(20 * time.Millisecond) // Send past the threshold; should not panic or remove the client. - for i := 0; i < ws.SessionCheckInterval+1; i++ { + for i := range ws.SessionCheckInterval + 1 { hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("msg %d", i))) } time.Sleep(100 * time.Millisecond) @@ -302,7 +302,7 @@ func TestSlowMode_ZeroSlowMode_AllowsRapidMessages(t *testing.T) { time.Sleep(20 * time.Millisecond) // Send 3 messages in quick succession. - for i := 0; i < 3; i++ { + for i := range 3 { hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("rapid %d", i))) } time.Sleep(50 * time.Millisecond) @@ -310,12 +310,12 @@ func TestSlowMode_ZeroSlowMode_AllowsRapidMessages(t *testing.T) { // Drain all messages. msgs := drainChan(send) for _, m := range msgs { - var env map[string]interface{} + var env map[string]any if err := json.Unmarshal(m, &env); err != nil { continue } if env["type"] == "error" { - if payload, ok := env["payload"].(map[string]interface{}); ok { + if payload, ok := env["payload"].(map[string]any); ok { if payload["code"] == "SLOW_MODE" { t.Error("got unexpected SLOW_MODE error when slow_mode=0") } @@ -378,12 +378,12 @@ func TestSlowMode_DifferentUsersNotBlocked(t *testing.T) { // B should NOT receive a SLOW_MODE error. msgs := drainChan(sendB) for _, m := range msgs { - var env map[string]interface{} + var env map[string]any if err := json.Unmarshal(m, &env); err != nil { continue } if env["type"] == "error" { - if payload, ok := env["payload"].(map[string]interface{}); ok { + if payload, ok := env["payload"].(map[string]any); ok { if payload["code"] == "SLOW_MODE" { t.Error("user B was incorrectly slow-mode throttled by user A's window") } @@ -414,12 +414,12 @@ func TestSlowMode_ModeratorBypassesSlowMode(t *testing.T) { msgs := drainChan(send) for _, m := range msgs { - var env map[string]interface{} + var env map[string]any if err := json.Unmarshal(m, &env); err != nil { continue } if env["type"] == "error" { - if payload, ok := env["payload"].(map[string]interface{}); ok { + if payload, ok := env["payload"].(map[string]any); ok { if payload["code"] == "SLOW_MODE" { t.Error("moderator was incorrectly blocked by slow mode") } @@ -465,12 +465,12 @@ func TestSlowMode_DifferentChannels_IndependentWindows(t *testing.T) { msgs := drainChan(sendB) for _, m := range msgs { - var env map[string]interface{} + var env map[string]any if err := json.Unmarshal(m, &env); err != nil { continue } if env["type"] == "error" { - if payload, ok := env["payload"].(map[string]interface{}); ok { + if payload, ok := env["payload"].(map[string]any); ok { if payload["code"] == "SLOW_MODE" { t.Error("slow mode in channel A incorrectly blocked channel B") } @@ -568,14 +568,14 @@ func TestSlowMode_ErrorMessageContainsSlowModeDuration(t *testing.T) { for { select { case msg := <-send: - var env map[string]interface{} + var env map[string]any if err := json.Unmarshal(msg, &env); err != nil { continue } if env["type"] != "error" { continue } - payload, ok := env["payload"].(map[string]interface{}) + payload, ok := env["payload"].(map[string]any) if !ok { continue } diff --git a/Server/ws/hub_test.go b/Server/ws/hub_test.go index a38fa330..c69d42e3 100644 --- a/Server/ws/hub_test.go +++ b/Server/ws/hub_test.go @@ -283,7 +283,7 @@ func TestHub_HandleMessage_UnknownType_SendsError(t *testing.T) { select { case got := <-send: - var resp map[string]interface{} + var resp map[string]any if err := json.Unmarshal(got, &resp); err != nil { t.Fatalf("unmarshal response: %v", err) } @@ -311,7 +311,7 @@ func TestHub_HandleMessage_InvalidJSON(t *testing.T) { select { case got := <-send: - var resp map[string]interface{} + var resp map[string]any if err := json.Unmarshal(got, &resp); err != nil { t.Fatalf("unmarshal: %v", err) } @@ -337,17 +337,17 @@ func TestHub_ChatSend_RateLimit(t *testing.T) { hub.Register(c) time.Sleep(20 * time.Millisecond) - payload := map[string]interface{}{ + payload := map[string]any{ "channel_id": chID, "content": "hi", } - raw, _ := json.Marshal(map[string]interface{}{ + raw, _ := json.Marshal(map[string]any{ "type": "chat_send", "payload": payload, }) // Send 12 messages rapidly — 11th and beyond should be rate-limited. - for i := 0; i < 12; i++ { + for range 12 { hub.HandleMessageForTest(c, raw) } time.Sleep(100 * time.Millisecond) @@ -358,7 +358,7 @@ func TestHub_ChatSend_RateLimit(t *testing.T) { for { select { case got := <-send: - var resp map[string]interface{} + var resp map[string]any if err := json.Unmarshal(got, &resp); err == nil { if resp["type"] == "error" { errCount++ @@ -381,7 +381,7 @@ func TestHub_ConcurrentRegisterUnregister(t *testing.T) { defer hub.Stop() var wg sync.WaitGroup - for i := 0; i < 20; i++ { + for i := range 20 { wg.Add(1) go func(i int) { defer wg.Done() @@ -528,7 +528,7 @@ func TestHub_VoiceRooms_ConcurrentAccess(t *testing.T) { var wg sync.WaitGroup // Concurrent creates and reads must not race. - for i := int64(0); i < 20; i++ { + for i := range int64(20) { wg.Add(1) go func(id int64) { defer wg.Done() @@ -638,7 +638,7 @@ func TestHub_CleanupVoiceForChannel_BroadcastsVoiceLeave(t *testing.T) { allMsgs := append(drainChan(send1), drainChan(send2)...) found := false for _, msg := range allMsgs { - var env map[string]interface{} + var env map[string]any if err := json.Unmarshal(msg, &env); err == nil { if env["type"] == "voice_leave" { found = true diff --git a/Server/ws/messages.go b/Server/ws/messages.go index 7f4e9fd3..ce7fc8d1 100644 --- a/Server/ws/messages.go +++ b/Server/ws/messages.go @@ -118,16 +118,6 @@ func buildChatMessage(msgID, channelID, userID int64, username string, avatar *s }) } -// buildMemberLeave constructs a member_leave broadcast per PROTOCOL.md. -func buildMemberLeave(userID int64) []byte { - return buildJSON(map[string]any{ - "type": "member_leave", - "payload": map[string]any{ - "user_id": userID, - }, - }) -} - // buildMemberUpdate constructs a member_update broadcast per PROTOCOL.md. func buildMemberUpdate(userID int64, roleName string) []byte { return buildJSON(map[string]any{ @@ -278,18 +268,6 @@ func buildVoiceAnswer(channelID int64, sdp string) []byte { }) } -// buildVoiceOffer constructs a voice_offer message sent from server to client -// (used during renegotiation when server needs to send a new offer). -func buildVoiceOffer(channelID int64, sdp string) []byte { - return buildJSON(map[string]any{ - "type": "voice_offer", - "payload": map[string]any{ - "channel_id": channelID, - "sdp": sdp, - }, - }) -} - // buildSoundboardPlay constructs a soundboard_play broadcast. func buildSoundboardPlay(soundID string, userID int64) []byte { return buildJSON(map[string]any{ diff --git a/Server/ws/speaker_detector.go b/Server/ws/speaker_detector.go index 632c676c..0a00708e 100644 --- a/Server/ws/speaker_detector.go +++ b/Server/ws/speaker_detector.go @@ -61,7 +61,7 @@ func (d *SpeakerDetector) UpdateLevel(userID int64, level uint8) { // Recalculate average over collected samples. var sum int - for i := 0; i < sl.count; i++ { + for i := range sl.count { sum += int(sl.levels[i]) } sl.average = float64(sum) / float64(sl.count) @@ -102,7 +102,7 @@ func (d *SpeakerDetector) TopSpeakers() []int64 { } result := make([]int64, n) - for i := 0; i < n; i++ { + for i := range n { result[i] = candidates[i].userID } return result diff --git a/Server/ws/speaker_detector_test.go b/Server/ws/speaker_detector_test.go index 462f1093..67ecc4b1 100644 --- a/Server/ws/speaker_detector_test.go +++ b/Server/ws/speaker_detector_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "slices" "testing" "time" @@ -24,7 +25,7 @@ func TestSpeakerDetector_UpdateLevel(t *testing.T) { sd := ws.NewSpeakerDetector(3) // Feed several level samples for a single user. - for i := 0; i < 5; i++ { + for range 5 { sd.UpdateLevel(1, 30) // relatively loud } @@ -58,7 +59,7 @@ func TestSpeakerDetector_TopSpeakers_RankedByLoudest(t *testing.T) { {50, 100}, } for _, u := range users { - for i := 0; i < 5; i++ { + for range 5 { sd.UpdateLevel(u.id, u.level) } } @@ -81,11 +82,11 @@ func TestSpeakerDetector_TopSpeakers_SilentExcluded(t *testing.T) { sd := ws.NewSpeakerDetector(3) // User 1: loud - for i := 0; i < 5; i++ { + for range 5 { sd.UpdateLevel(1, 20) } // User 2: completely silent (127 = digital silence in RFC 6464) - for i := 0; i < 5; i++ { + for range 5 { sd.UpdateLevel(2, 127) } @@ -103,25 +104,18 @@ func TestSpeakerDetector_TopSpeakers_HoldoffKeepsSpeaker(t *testing.T) { sd := ws.NewSpeakerDetectorWithHoldoff(3, 50*time.Millisecond) // User 1 speaks loudly. - for i := 0; i < 5; i++ { + for range 5 { sd.UpdateLevel(1, 20) } // User 1 goes silent. - for i := 0; i < 10; i++ { + for range 10 { sd.UpdateLevel(1, 127) } // Immediately check — holdoff should keep user 1 in top speakers. top := sd.TopSpeakers() - found := false - for _, id := range top { - if id == int64(1) { - found = true - break - } - } - if !found { + if !slices.Contains(top, int64(1)) { t.Fatalf("expected user 1 to remain in top speakers during holdoff, got %v", top) } } @@ -131,12 +125,12 @@ func TestSpeakerDetector_TopSpeakers_HoldoffExpires(t *testing.T) { sd := ws.NewSpeakerDetectorWithHoldoff(3, 50*time.Millisecond) // User 1 speaks loudly. - for i := 0; i < 5; i++ { + for range 5 { sd.UpdateLevel(1, 20) } // User 1 goes silent — fill ring buffer with silence. - for i := 0; i < 10; i++ { + for range 10 { sd.UpdateLevel(1, 127) } @@ -155,7 +149,7 @@ func TestSpeakerDetector_RemoveSpeaker(t *testing.T) { t.Parallel() sd := ws.NewSpeakerDetector(3) - for i := 0; i < 5; i++ { + for range 5 { sd.UpdateLevel(1, 20) sd.UpdateLevel(2, 30) } @@ -182,7 +176,7 @@ func TestParseAudioLevel_Valid(t *testing.T) { // RFC 5285 one-byte header format: 4-bit ID | 4-bit length-1 // For extensionID=1, length=1 byte: header = 0x10 extensionID := uint8(1) - buf := []byte{(extensionID << 4) | 0x00, extByte} // ID=1, L=0 (meaning 1 byte), then the data byte + buf := []byte{extensionID << 4, extByte} // ID=1, L=0 (meaning 1 byte), then the data byte level, voice, ok := ws.ParseAudioLevel(buf, extensionID) if !ok { @@ -197,7 +191,7 @@ func TestParseAudioLevel_Valid(t *testing.T) { // Test with voice=false, level=10 → binary: 0_0001010 → 0x0A extByte2 := byte(10) // voice=0, level=10 - buf2 := []byte{(extensionID << 4) | 0x00, extByte2} + buf2 := []byte{extensionID << 4, extByte2} level2, voice2, ok2 := ws.ParseAudioLevel(buf2, extensionID) if !ok2 { @@ -226,7 +220,7 @@ func TestParseAudioLevel_NotFound(t *testing.T) { } // Wrong extension ID — buffer has ID=2 but we ask for ID=1. - buf := []byte{(2 << 4) | 0x00, 0x80} + buf := []byte{2 << 4, 0x80} _, _, ok = ws.ParseAudioLevel(buf, 1) if ok { t.Error("expected ok=false for wrong extension ID") diff --git a/Server/ws/speaker_integration_test.go b/Server/ws/speaker_integration_test.go index f50e687a..80fc64d7 100644 --- a/Server/ws/speaker_integration_test.go +++ b/Server/ws/speaker_integration_test.go @@ -24,7 +24,7 @@ func TestVoiceRoom_UpdateSpeakerLevel(t *testing.T) { _ = room.AddParticipant(30) // User 10 is loudest (lowest dBov = 10), user 30 quietest (90). - for i := 0; i < 5; i++ { + for range 5 { room.UpdateSpeakerLevel(10, 10) room.UpdateSpeakerLevel(20, 50) room.UpdateSpeakerLevel(30, 90) @@ -62,7 +62,7 @@ func TestVoiceRoom_RemoveParticipant_RemovesFromDetector(t *testing.T) { _ = room.AddParticipant(200) // Feed audio so both appear in top speakers. - for i := 0; i < 5; i++ { + for range 5 { room.UpdateSpeakerLevel(100, 20) room.UpdateSpeakerLevel(200, 30) } @@ -191,7 +191,7 @@ func TestSpeakerBroadcast_Integration(t *testing.T) { time.Sleep(20 * time.Millisecond) // Feed audio levels into the room — make user 1 a speaker. - for i := 0; i < 5; i++ { + for range 5 { room.UpdateSpeakerLevel(1, 20) // level=20 (dBov), well below silence threshold } @@ -251,7 +251,7 @@ func TestSpeakerBroadcast_NoBroadcastWhenNoChange(t *testing.T) { time.Sleep(20 * time.Millisecond) // Feed levels to produce a stable speaker list. - for i := 0; i < 5; i++ { + for range 5 { room.UpdateSpeakerLevel(2, 20) } @@ -312,7 +312,7 @@ func TestSpeakerBroadcast_RoomCleanup(t *testing.T) { time.Sleep(20 * time.Millisecond) // Feed levels so the ticker broadcasts at least once. - for i := 0; i < 5; i++ { + for range 5 { room.UpdateSpeakerLevel(3, 20) } @@ -338,7 +338,7 @@ func TestSpeakerBroadcast_RoomCleanup(t *testing.T) { // Re-create the room and feed a new speaker — the ticker should broadcast // again because prevSpeakers[chanID] was deleted when the room was removed. newRoom := hub.GetOrCreateVoiceRoom(chanID, cfg) - for i := 0; i < 5; i++ { + for range 5 { newRoom.UpdateSpeakerLevel(3, 20) } diff --git a/Server/ws/voice_handlers_test.go b/Server/ws/voice_handlers_test.go index 1588ebb4..a0f8ab8f 100644 --- a/Server/ws/voice_handlers_test.go +++ b/Server/ws/voice_handlers_test.go @@ -81,45 +81,45 @@ func seedVoiceChan(t *testing.T, database *db.DB, name string) int64 { // voiceJoinMsg builds a raw voice_join WebSocket message. func voiceJoinMsg(channelID int64) []byte { - raw, _ := json.Marshal(map[string]interface{}{ + raw, _ := json.Marshal(map[string]any{ "type": "voice_join", - "payload": map[string]interface{}{"channel_id": channelID}, + "payload": map[string]any{"channel_id": channelID}, }) return raw } // voiceLeaveMsg builds a raw voice_leave WebSocket message. func voiceLeaveMsg() []byte { - raw, _ := json.Marshal(map[string]interface{}{ + raw, _ := json.Marshal(map[string]any{ "type": "voice_leave", - "payload": map[string]interface{}{}, + "payload": map[string]any{}, }) return raw } // voiceMuteMsg builds a voice_mute message. func voiceMuteMsg(muted bool) []byte { - raw, _ := json.Marshal(map[string]interface{}{ + raw, _ := json.Marshal(map[string]any{ "type": "voice_mute", - "payload": map[string]interface{}{"muted": muted}, + "payload": map[string]any{"muted": muted}, }) return raw } // voiceDeafenMsg builds a voice_deafen message. func voiceDeafenMsg(deafened bool) []byte { - raw, _ := json.Marshal(map[string]interface{}{ + raw, _ := json.Marshal(map[string]any{ "type": "voice_deafen", - "payload": map[string]interface{}{"deafened": deafened}, + "payload": map[string]any{"deafened": deafened}, }) return raw } // voiceSignalMsg builds a voice_offer/answer/ice message. func voiceSignalMsg(msgType string, channelID int64, sdp string) []byte { - raw, _ := json.Marshal(map[string]interface{}{ + raw, _ := json.Marshal(map[string]any{ "type": msgType, - "payload": map[string]interface{}{ + "payload": map[string]any{ "channel_id": channelID, "sdp": sdp, }, @@ -129,9 +129,9 @@ func voiceSignalMsg(msgType string, channelID int64, sdp string) []byte { // voiceICEMsg builds a voice_ice message. func voiceICEMsg(channelID int64, candidate string) []byte { - raw, _ := json.Marshal(map[string]interface{}{ + raw, _ := json.Marshal(map[string]any{ "type": "voice_ice", - "payload": map[string]interface{}{ + "payload": map[string]any{ "channel_id": channelID, "candidate": candidate, }, @@ -142,7 +142,7 @@ func voiceICEMsg(channelID int64, candidate string) []byte { // extractType parses a JSON message and returns the "type" field. func extractType(t *testing.T, msg []byte) string { t.Helper() - var env map[string]interface{} + var env map[string]any if err := json.Unmarshal(msg, &env); err != nil { t.Fatalf("extractType unmarshal: %v", err) } @@ -288,9 +288,9 @@ func TestVoice_Join_MissingChannelID_SendsError(t *testing.T) { hub.Register(c) time.Sleep(20 * time.Millisecond) - badMsg, _ := json.Marshal(map[string]interface{}{ + badMsg, _ := json.Marshal(map[string]any{ "type": "voice_join", - "payload": map[string]interface{}{"channel_id": 0}, + "payload": map[string]any{"channel_id": 0}, }) hub.HandleMessageForTest(c, badMsg) time.Sleep(30 * time.Millisecond) @@ -605,7 +605,7 @@ func TestVoice_Offer_RateLimit(t *testing.T) { time.Sleep(20 * time.Millisecond) // 25 offers rapidly — limit is 20/sec. - for i := 0; i < 25; i++ { + for range 25 { hub.HandleMessageForTest(c, voiceSignalMsg("voice_offer", 1, "v=0...")) } time.Sleep(50 * time.Millisecond) @@ -781,7 +781,7 @@ func TestVoice_Signal_RateLimit_BlocksExcess(t *testing.T) { time.Sleep(20 * time.Millisecond) // Send 30 signals rapidly — limit is 20/sec, so some should be rate-limited. - for i := 0; i < 30; i++ { + for range 30 { hub.HandleMessageForTest(c, voiceSignalMsg("voice_offer", 1, "v=0...")) } time.Sleep(50 * time.Millisecond) @@ -816,9 +816,9 @@ func TestVoice_Soundboard_BroadcastsToAll(t *testing.T) { hub.Register(cS) time.Sleep(20 * time.Millisecond) - soundMsg, _ := json.Marshal(map[string]interface{}{ + soundMsg, _ := json.Marshal(map[string]any{ "type": "soundboard_play", - "payload": map[string]interface{}{"sound_id": "abc-uuid-123"}, + "payload": map[string]any{"sound_id": "abc-uuid-123"}, }) hub.HandleMessageForTest(cS, soundMsg) time.Sleep(50 * time.Millisecond) @@ -845,9 +845,9 @@ func TestVoice_Soundboard_NoPermission_SendsError(t *testing.T) { hub.Register(c) time.Sleep(20 * time.Millisecond) - soundMsg, _ := json.Marshal(map[string]interface{}{ + soundMsg, _ := json.Marshal(map[string]any{ "type": "soundboard_play", - "payload": map[string]interface{}{"sound_id": "abc"}, + "payload": map[string]any{"sound_id": "abc"}, }) hub.HandleMessageForTest(c, soundMsg) time.Sleep(30 * time.Millisecond) @@ -873,13 +873,13 @@ func TestVoice_Soundboard_RateLimit(t *testing.T) { hub.Register(c) time.Sleep(20 * time.Millisecond) - soundMsg, _ := json.Marshal(map[string]interface{}{ + soundMsg, _ := json.Marshal(map[string]any{ "type": "soundboard_play", - "payload": map[string]interface{}{"sound_id": "x"}, + "payload": map[string]any{"sound_id": "x"}, }) // Send 5 soundboard plays rapidly — limit is 1 per 3 sec. - for i := 0; i < 5; i++ { + for range 5 { hub.HandleMessageForTest(c, soundMsg) } time.Sleep(50 * time.Millisecond) @@ -900,9 +900,9 @@ func TestVoice_Soundboard_RateLimit(t *testing.T) { // voiceCameraMsg builds a voice_camera WebSocket message. func voiceCameraMsg(enabled bool) []byte { - raw, _ := json.Marshal(map[string]interface{}{ + raw, _ := json.Marshal(map[string]any{ "type": "voice_camera", - "payload": map[string]interface{}{"enabled": enabled}, + "payload": map[string]any{"enabled": enabled}, }) return raw } @@ -1011,7 +1011,7 @@ func TestVoice_Camera_RateLimit(t *testing.T) { drainChan(send) // Send 5 camera toggles rapidly — limit is 2/sec, so some should be rate-limited. - for i := 0; i < 5; i++ { + for range 5 { hub.HandleMessageForTest(c, voiceCameraMsg(true)) } time.Sleep(50 * time.Millisecond) @@ -1032,9 +1032,9 @@ func TestVoice_Camera_RateLimit(t *testing.T) { // voiceScreenshareMsg builds a voice_screenshare WebSocket message. func voiceScreenshareMsg(enabled bool) []byte { - raw, _ := json.Marshal(map[string]interface{}{ + raw, _ := json.Marshal(map[string]any{ "type": "voice_screenshare", - "payload": map[string]interface{}{"enabled": enabled}, + "payload": map[string]any{"enabled": enabled}, }) return raw } @@ -1143,7 +1143,7 @@ func TestVoice_Screenshare_RateLimit(t *testing.T) { drainChan(send) // Send 5 screenshare toggles rapidly — limit is 2/sec. - for i := 0; i < 5; i++ { + for range 5 { hub.HandleMessageForTest(c, voiceScreenshareMsg(true)) } time.Sleep(50 * time.Millisecond)