mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
settings:
|
||||
staticcheck:
|
||||
checks:
|
||||
- "all"
|
||||
- "-SA1019" # suppress deprecated usage warnings (websocket library migration tracked separately)
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
}()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user