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
This commit is contained in:
jevb
2026-03-17 04:11:04 +01:00
parent 1b596367c4
commit 9c1d99683c
58 changed files with 4158 additions and 321 deletions
+7 -13
View File
@@ -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<string, unknown>) => Promise<unknown>) | 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<void> {
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<void> {
* Load the previously saved window state.
*/
async function loadState(): Promise<WindowState | null> {
const invoke = await getInvoke();
const invoke = await invokePromise;
if (!invoke) return null;
try {
const all = (await invoke("get_settings")) as Record<string, unknown>;
@@ -82,8 +77,7 @@ async function loadState(): Promise<WindowState | null> {
* 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 {
@@ -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<MemberContextMenuOptions>) {
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<ChannelContextMenuOptions>) {
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();
});
});
});
@@ -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");
});
});
@@ -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<typeof import("../../src/stores/ui.store")>(
"../../src/stores/ui.store",
);
return {
...actual,
openSettings: vi.fn(),
closeSettings: vi.fn(),
};
});
function makeCallbacks(overrides: Partial<ConnectPageCallbacks> = {}): 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<void>((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();
});
});
@@ -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> = {}): 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();
});
});
@@ -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<EmojiPickerOptions>) {
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();
});
});
@@ -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<FileUploadOptions>): 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();
});
});
@@ -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> = {}): InviteItem {
return {
code: "abc123xyz",
createdBy: "admin",
createdAt: "2025-01-01T00:00:00Z",
uses: 3,
maxUses: 10,
expiresAt: null,
...overrides,
};
}
function makeOptions(overrides: Partial<InviteManagerOptions> = {}): 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?.();
});
});
@@ -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<Member> & { id: number; username: string }): Member {
return {
avatar: null,
role: "member",
status: "online" as UserStatus,
...overrides,
};
}
function setTestMembers(members: Member[]): void {
const map = new Map<number, Member>();
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<typeof createMemberList>;
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);
});
});
@@ -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> = {}): 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);
});
});
@@ -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> = {}): 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?.();
});
});
@@ -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<Message> & { 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<typeof createMessageList>;
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);
});
});
@@ -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<PinnedMessagesOptions>) {
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();
});
});
@@ -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<typeof createQuickSwitcher>;
let onSelectChannel: ReturnType<typeof vi.fn>;
let onSearch: ReturnType<typeof vi.fn>;
let onClose: ReturnType<typeof vi.fn>;
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();
});
});
@@ -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);
});
});
@@ -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> = {}): 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> = {}): 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();
});
});
});
@@ -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);
});
});
@@ -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<typeof createServerStrip>;
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();
});
});
@@ -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");
}
});
});
});
@@ -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<HTMLButtonElement>;
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<HTMLButtonElement>;
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<HTMLButtonElement>;
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<HTMLButtonElement>;
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();
});
});
@@ -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<typeof createTypingIndicator>;
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);
});
});
@@ -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<typeof createUserBar>;
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();
});
});
@@ -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<number, VoiceUser>();
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();
});
});
@@ -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<number, VoiceUser>();
for (const u of users) {
userMap.set(u.userId, u);
}
const voiceUsers = new Map<number, ReadonlyMap<number, VoiceUser>>();
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();
});
});
+9 -1
View File
@@ -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,
+8
View File
@@ -0,0 +1,8 @@
version: "2"
linters:
settings:
staticcheck:
checks:
- "all"
- "-SA1019" # suppress deprecated usage warnings (websocket library migration tracked separately)
+1 -1
View File
@@ -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)))
+32 -43
View File
@@ -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
}
+9 -3
View File
@@ -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")
+1 -1
View File
@@ -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")
+4 -4
View File
@@ -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()
+6 -6
View File
@@ -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)),
+6 -6
View File
@@ -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))
}
+15 -15
View File
@@ -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))
}
+6 -6
View File
@@ -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")
}
+16 -39
View File
@@ -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
}
+4 -4
View File
@@ -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"
+5 -5
View File
@@ -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)
}
+1 -1
View File
@@ -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{
+18 -18
View File
@@ -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")
}
+6 -6
View File
@@ -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
+1 -1
View File
@@ -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)
+4 -4
View File
@@ -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},
+2 -2
View File
@@ -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")
}
+2 -2
View File
@@ -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)}
}()
+2 -2
View File
@@ -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)
+6 -4
View File
@@ -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(
+4 -4
View File
@@ -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)
}
-15
View File
@@ -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,
+2 -2
View File
@@ -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.
+6 -6
View File
@@ -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")
}
+18 -18
View File
@@ -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
}
+9 -9
View File
@@ -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
-22
View File
@@ -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{
+2 -2
View File
@@ -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
+14 -20
View File
@@ -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")
+6 -6
View File
@@ -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)
}
+30 -30
View File
@@ -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)