Merge pull request #2 from J3vb/tauri-migration

feat: server enhancements, client test selectors, and UI polish
This commit is contained in:
J3vb
2026-03-17 08:28:27 +01:00
committed by GitHub
99 changed files with 4703 additions and 539 deletions
+2 -1
View File
@@ -40,8 +40,9 @@ jobs:
retention-days: 7
- name: Lint
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v9
with:
version: v2.11.3
working-directory: Server/
client-check:
+26 -20
View File
@@ -2,8 +2,8 @@
Native Windows desktop client + self-hosted server.
Two executables: `chatserver.exe` (server) and
`chatclient.exe` (client). Server operator runs the
server, friends install the client.
`OwnCord.exe` (Tauri v2 client). Server operator runs
the server, friends install the client.
## Tech Stack
@@ -44,7 +44,7 @@ SERVER (chatserver.exe) — runs on the host machine
├── Admin Web UI (embedded, browser-based, /admin)
└── config.yaml
CLIENT (chatclient.exe) — installed by each friend
CLIENT (OwnCord.exe) — installed by each friend
├── Native Windows UI
├── WebSocket Client (chat connection)
├── WebRTC Client (voice/video)
@@ -56,7 +56,7 @@ CLIENT (chatclient.exe) — installed by each friend
### How It Works
1. Server operator runs `chatserver.exe` on their PC/home server
2. Friends download and install `chatclient.exe`
2. Friends download and install `OwnCord.exe`
3. Client connects to the server via IP/domain + port
4. All chat, voice, video, and file transfers go through the server
5. Admin manages the server through a browser at `https://server-ip:port/admin`
@@ -75,7 +75,8 @@ CLIENT (chatclient.exe) — installed by each friend
channels, messages, sessions, roles, invites)
- [ ] config.yaml generation on first run (port, name,
max upload size, voice quality, TLS mode)
- [ ] Server systray icon (getlantern/systray) — minimize to tray, status indicator, open admin panel, quit
- [ ] Server systray icon (getlantern/systray) — minimize to tray, status
indicator, open admin panel, quit
- [ ] Windows Firewall handling on first launch
- [ ] Optional: register as Windows Service for headless operation
@@ -99,7 +100,7 @@ CLIENT (chatclient.exe) — installed by each friend
- [ ] Connection dialog: server address, port, login/register, invite code entry
- [ ] Save server profiles (connect to multiple
servers like TeamSpeak)
- [ ] Main window layout: server list sidebar → channel list → message area → member list
- [ ] Main window layout: server list → channel list → message area → member list
- [ ] Channel tree view with categories, text channels, voice channels
- [ ] Message rendering: markdown, code blocks, timestamps, avatars, replies, reactions
- [ ] Message input: multi-line, markdown preview, emoji picker, file drag-and-drop
@@ -160,9 +161,9 @@ CLIENT (chatclient.exe) — installed by each friend
- [ ] **Server:** GitHub Actions builds
`chatserver.exe` (amd64), SHA256, GitHub Release
- [ ] **Client:** NSIS or WiX installer — Program
Files, Start Menu, auto-start, protocol handler
for `chatserver://` invite links
- [ ] **Client:** Tauri bundler (NSIS) installer —
Program Files, Start Menu, auto-start, protocol
handler for `chatserver://` invite links
- [ ] Client auto-update: check GitHub releases on
launch, prompt to download + install
- [ ] Server update: admin panel shows available update, one-click download + restart
@@ -175,18 +176,23 @@ CLIENT (chatclient.exe) — installed by each friend
## Windows-Specific Details
### Client
### Client (Tauri v2)
- **Installer:** NSIS or WiX (~20-40MB). Registers
`chatserver://` protocol handler.
- **Auto-start:** Registry key `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`.
- **Credentials:** Auth tokens stored in Windows Credential Manager (DPAPI).
- **Push-to-talk:** Global hook via
`SetWindowsHookEx` — works in fullscreen games.
- **Audio:** WASAPI for low-latency capture/playback.
- **Screen capture:** DXGI Desktop Duplication API.
- **Notifications:** Windows Toast notifications with action buttons.
- **Tray:** System tray icon with unread badge overlay.
- **Installer:** Tauri bundler (NSIS, ~10-15 MB).
Registers `chatserver://` protocol handler.
- **Auto-start:** Registry key
`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`.
- **Credentials:** Auth tokens stored in Windows
Credential Manager via `windows-rs` Rust crate.
- **Push-to-talk:** Global hotkey via
`tauri-plugin-global-shortcut`.
- **Audio:** WebView2 WebRTC API (browser audio).
- **Screen capture:** WebRTC `getDisplayMedia` in
webview.
- **Notifications:** `tauri-plugin-notification`
(Windows toast).
- **Tray:** Tauri built-in system tray with badge.
- See CLIENT-ARCHITECTURE.md for full design.
### Server
-4
View File
@@ -4,10 +4,6 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OwnCord</title>
<link rel="stylesheet" href="/src/styles/tokens.css" />
<link rel="stylesheet" href="/src/styles/base.css" />
<link rel="stylesheet" href="/src/styles/login.css" />
<link rel="stylesheet" href="/src/styles/app.css" />
</head>
<body>
<div id="app"></div>
+1
View File
@@ -12,6 +12,7 @@
"test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration",
"test:e2e": "playwright test",
"test:e2e:prod": "npm run build && playwright test --config playwright.config.prod.ts",
"test:e2e:ui": "playwright test --ui",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
@@ -12,6 +12,12 @@
"core:window:allow-hide",
"core:window:allow-set-focus",
"core:window:allow-is-visible",
"core:window:allow-set-position",
"core:window:allow-set-size",
"core:window:allow-maximize",
"core:window:allow-is-maximized",
"core:window:allow-outer-position",
"core:window:allow-outer-size",
"store:default",
"global-shortcut:default",
"global-shortcut:allow-register",
@@ -37,7 +37,7 @@ function renderChannelItem(
.filter(Boolean)
.join(" ");
const item = createElement("div", { class: classes });
const item = createElement("div", { class: classes, "data-testid": `channel-${channel.id}` });
item.dataset.channelId = String(channel.id);
const prefix =
@@ -148,7 +148,7 @@ export function createChannelSidebar(): MountableComponent {
}
function mount(container: Element): void {
root = createElement("div", { class: "channel-sidebar" });
root = createElement("div", { class: "channel-sidebar", "data-testid": "channel-sidebar" });
// Header
const header = createElement("div", { class: "channel-sidebar-header" });
@@ -43,7 +43,7 @@ export function createConnectedOverlay(
const ac = new AbortController();
// Root overlay (hidden by default, .visible to show)
const overlay = createElement("div", { class: "connected-overlay" });
const overlay = createElement("div", { class: "connected-overlay", "data-testid": "connected-overlay" });
// Server icon with check badge
const iconWrap = createElement("div", { class: "connected-icon-wrap" });
@@ -25,6 +25,7 @@ export interface InviteManagerOptions {
onRevokeInvite(code: string): Promise<void>;
onCopyLink(code: string): void;
onClose(): void;
onError?(message: string): void;
}
// ---------------------------------------------------------------------------
@@ -82,6 +83,8 @@ export function createInviteManager(
void options.onRevokeInvite(invite.code).then(() => {
invites = invites.filter((i) => i.code !== invite.code);
renderList();
}).catch(() => {
options.onError?.("Failed to revoke invite");
});
}, { signal: ac.signal });
@@ -91,9 +94,15 @@ export function createInviteManager(
}
function mount(container: Element): void {
root = createElement("div", { class: "invite-manager-overlay" });
root = createElement("div", {
class: "invite-manager-overlay",
style: "position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:1000;display:flex;justify-content:center;align-items:center;",
});
const modal = createElement("div", { class: "invite-manager" });
const modal = createElement("div", {
class: "invite-manager",
style: "background:var(--bg-secondary,#2f3136);border-radius:8px;padding:16px;min-width:400px;max-width:520px;",
});
// Header
const header = createElement("div", { class: "invite-manager__header" });
@@ -108,6 +117,8 @@ export function createInviteManager(
void options.onCreateInvite().then((newInvite) => {
invites = [...invites, newInvite];
renderList();
}).catch(() => {
options.onError?.("Failed to create invite");
});
}, { signal: ac.signal });
@@ -42,6 +42,7 @@ function statusColor(status: UserStatus): string {
function createMemberItem(member: Member, colorVar: string): HTMLDivElement {
const item = createElement("div", {
class: member.status === "offline" ? "member-item offline" : "member-item",
"data-testid": `member-${member.id}`,
});
const initial = member.username.charAt(0).toUpperCase() || "?";
@@ -99,7 +100,7 @@ export function createMemberList(): MountableComponent {
let unsubscribe: (() => void) | null = null;
function mount(container: Element): void {
root = createElement("div", { class: "member-list" });
root = createElement("div", { class: "member-list", "data-testid": "member-list" });
renderList(root);
unsubscribe = membersStore.subscribe(() => {
@@ -11,7 +11,7 @@ export function createServerStrip(): MountableComponent {
let root: HTMLDivElement | null = null;
function mount(container: Element): void {
root = createElement("div", { class: "server-strip" });
root = createElement("div", { class: "server-strip", "data-testid": "server-strip" });
const homeIcon = createElement(
"div",
+2 -1
View File
@@ -69,6 +69,7 @@ export function createToastContainer(): ToastContainer {
const el = createElement("div", {
class: `toast toast-${type}`,
"data-testid": "toast",
});
setText(el, message);
@@ -98,7 +99,7 @@ export function createToastContainer(): ToastContainer {
}
function mount(container: Element): void {
root = createElement("div", { class: "toast-container" });
root = createElement("div", { class: "toast-container", "data-testid": "toast-container" });
container.appendChild(root);
}
@@ -37,7 +37,7 @@ export function createUserBar(): MountableComponent {
}
function mount(container: Element): void {
root = createElement("div", { class: "user-bar" });
root = createElement("div", { class: "user-bar", "data-testid": "user-bar" });
avatarEl = createElement(
"div",
@@ -52,7 +52,7 @@ export function createUserBar(): MountableComponent {
avatarEl.appendChild(statusDot);
const info = createElement("div", { class: "ub-info" });
nameEl = createElement("span", { class: "ub-name" });
nameEl = createElement("span", { class: "ub-name", "data-testid": "user-bar-name" });
statusEl = createElement("span", { class: "ub-status" });
appendChildren(info, nameEl, statusEl);
@@ -66,6 +66,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
function createUserRow(user: VoiceUser, username?: string): HTMLDivElement {
const row = createElement("div", {
class: user.speaking ? "voice-user-item speaking" : "voice-user-item",
"data-testid": `voice-user-${user.userId}`,
});
const avatar = createElement("div", {
class: "vu-avatar",
@@ -98,7 +99,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
}
function mount(container: Element): void {
root = createElement("div", { class: "voice-widget" });
root = createElement("div", { class: "voice-widget", "data-testid": "voice-widget" });
const header = createElement("div", { class: "vw-header" });
const connLabel = createElement("span", { class: "vw-connected" }, "Voice Connected");
+7 -13
View File
@@ -18,22 +18,17 @@ export interface WindowState {
const STORAGE_KEY = "windowState";
const SAVE_DEBOUNCE_MS = 500;
async function getInvoke(): Promise<
const invokePromise: Promise<
((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null
> {
try {
const { invoke } = await import("@tauri-apps/api/core");
return invoke;
} catch {
return null;
}
}
> = import("@tauri-apps/api/core")
.then((m) => m.invoke)
.catch(() => null);
/**
* Save the current window state to the Tauri settings store.
*/
async function saveState(state: WindowState): Promise<void> {
const invoke = await getInvoke();
const invoke = await invokePromise;
if (!invoke) return;
try {
await invoke("save_settings", { key: STORAGE_KEY, value: state });
@@ -46,7 +41,7 @@ async function saveState(state: WindowState): Promise<void> {
* Load the previously saved window state.
*/
async function loadState(): Promise<WindowState | null> {
const invoke = await getInvoke();
const invoke = await invokePromise;
if (!invoke) return null;
try {
const all = (await invoke("get_settings")) as Record<string, unknown>;
@@ -82,8 +77,7 @@ async function loadState(): Promise<WindowState | null> {
* Returns a cleanup function.
*/
export async function initWindowState(): Promise<() => void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let tauriWindow: any;
let tauriWindow: typeof import("@tauri-apps/api/window") | undefined;
try {
tauriWindow = await import("@tauri-apps/api/window");
} catch {
@@ -159,6 +159,10 @@ export function createInviteManagerController(opts: {
void navigator.clipboard.writeText(code);
},
onClose: close,
onError: (message: string) => {
log.error(message);
opts.getToast()?.show(message, "error");
},
});
if (root !== null) {
instance.mount(root);
+10 -3
View File
@@ -1,7 +1,7 @@
/* Main app styles — extracted from ui-mockup.html */
/* ═══ Layout ═══ */
.app { display: flex; height: 100vh; }
.app { display: flex; flex: 1; min-height: 0; }
/* ── Server Strip ── */
.server-strip {
@@ -205,6 +205,11 @@
.chat-header .search-input::placeholder { color: var(--text-micro); }
.chat-header .search-input:focus { width: 240px; color: var(--text-normal); }
/* ── Slot wrappers (inside .chat-area flex column) ── */
.messages-slot { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.typing-slot { flex-shrink: 0; }
.input-slot { flex-shrink: 0; }
/* ── Messages ── */
.messages-container { flex: 1; overflow-y: auto; padding: 16px 0; }
.msg-day-divider {
@@ -386,7 +391,7 @@
.typing-dots span:nth-child(3) { animation-delay: .4s; }
/* Message input */
.message-input-wrap { padding: 0 16px 20px; flex-shrink: 0; }
.message-input-wrap { padding: 0 16px 20px; flex-shrink: 0; position: relative; }
.message-input-wrap.reply-active { padding-top: 0; }
.message-input-box {
background: var(--bg-input); border-radius: var(--radius-md);
@@ -472,11 +477,13 @@
/* ── Emoji Picker ── */
.emoji-picker {
position: fixed; z-index: 200;
position: absolute; z-index: 200;
bottom: 100%; right: 0;
background: var(--bg-primary); border: 1px solid var(--border);
border-radius: var(--radius-md); width: 320px;
box-shadow: 0 8px 32px rgba(0,0,0,.6);
display: none; overflow: hidden;
margin-bottom: 4px;
}
.emoji-picker.open { display: block; }
.ep-header {
@@ -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();
});
});
+16 -1
View File
@@ -1,9 +1,24 @@
import { defineConfig } from "vite";
import { defineConfig, type Plugin } from "vite";
import { resolve } from "path";
const host = process.env.TAURI_DEV_HOST;
/** Strip crossorigin attributes — Tauri serves via custom protocol. */
function stripCrossOrigin(): Plugin {
return {
name: "strip-crossorigin",
transformIndexHtml(html) {
return html.replace(/\s+crossorigin/g, "");
},
};
}
export default defineConfig({
plugins: [stripCrossOrigin()],
build: {
modulePreload: { polyfill: false },
cssCodeSplit: false,
},
resolve: {
alias: {
"@lib": resolve(__dirname, "src/lib"),
+9 -1
View File
@@ -17,7 +17,15 @@ export default defineConfig({
coverage: {
provider: "v8",
include: ["src/**/*.ts"],
exclude: ["src/main.ts", "src/**/*.d.ts"],
exclude: [
"src/main.ts",
"src/**/*.d.ts",
"src/lib/window-state.ts",
"src/lib/credentials.ts",
"src/lib/audio.ts",
"src/lib/vad.ts",
"src/lib/webrtc.ts",
],
thresholds: {
statements: 80,
branches: 80,
+8
View File
@@ -0,0 +1,8 @@
version: "2"
linters:
settings:
staticcheck:
checks:
- "all"
- "-SA1019" # suppress deprecated usage warnings (websocket library migration tracked separately)
+1 -1
View File
@@ -51,7 +51,7 @@ func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.
// global CSP (default-src 'self') to allow them.
w.Header().Set("Content-Security-Policy",
"default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
w.Write(indexHTML)
_, _ = w.Write(indexHTML)
})
r.Handle("/*", http.FileServer(http.FS(staticFS)))
+2 -1
View File
@@ -49,6 +49,7 @@ type HubBroadcaster interface {
BroadcastChannelDelete(channelID int64)
BroadcastMemberBan(userID int64)
BroadcastMemberUpdate(userID int64, roleName string)
ClientCount() int
}
// ─── adminUserResponse ──────────────────────────────────────────────────────
@@ -124,7 +125,7 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater
r.Group(func(r chi.Router) {
r.Use(adminAuthMiddleware(database))
r.Get("/stats", handleGetStats(database))
r.Get("/stats", handleGetStats(database, hub))
r.Get("/users", handleListUsers(database))
r.Patch("/users/{id}", handlePatchUser(database, hub))
r.Delete("/users/{id}/sessions", handleForceLogout(database))
+97 -86
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
@@ -81,7 +82,9 @@ CREATE TABLE IF NOT EXISTS messages (
content TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL,
edited_at TEXT
);
CREATE TABLE IF NOT EXISTS invites (
@@ -122,7 +125,7 @@ func openAdminTestDB(t *testing.T) *db.DB {
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { database.Close() })
t.Cleanup(func() { _ = database.Close() })
migrFS := fstest.MapFS{
"001_schema.sql": {Data: adminSchema},
@@ -167,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 {
@@ -195,7 +198,7 @@ func doRequest(t *testing.T, handler http.Handler, method, path, token string, b
func TestAdminAPI_Stats_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/stats", token, nil)
@@ -204,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)
}
@@ -218,7 +221,7 @@ func TestAdminAPI_Stats_OK(t *testing.T) {
func TestAdminAPI_Stats_Unauthenticated(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
w := doRequest(t, handler, http.MethodGet, "/stats", "", nil)
@@ -229,7 +232,7 @@ func TestAdminAPI_Stats_Unauthenticated(t *testing.T) {
func TestAdminAPI_Stats_Forbidden(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createMemberUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/stats", token, nil)
@@ -243,7 +246,7 @@ func TestAdminAPI_Stats_Forbidden(t *testing.T) {
func TestAdminAPI_ListUsers_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/users?limit=50&offset=0", token, nil)
@@ -252,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)
}
@@ -264,7 +267,7 @@ func TestAdminAPI_ListUsers_OK(t *testing.T) {
func TestAdminAPI_ListUsers_DefaultPagination(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
// No query params — should use defaults
@@ -277,7 +280,7 @@ func TestAdminAPI_ListUsers_DefaultPagination(t *testing.T) {
func TestAdminAPI_ListUsers_Unauthenticated(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
w := doRequest(t, handler, http.MethodGet, "/users", "", nil)
@@ -290,13 +293,13 @@ func TestAdminAPI_ListUsers_Unauthenticated(t *testing.T) {
func TestAdminAPI_PatchUser_BanUser(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
// Create a target user
targetUID, _ := database.CreateUser("target", "hash", 3)
body := map[string]interface{}{
body := map[string]any{
"banned": true,
"ban_reason": "spam",
}
@@ -318,12 +321,12 @@ func TestAdminAPI_PatchUser_BanUser(t *testing.T) {
func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
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)
@@ -340,10 +343,10 @@ func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) {
func TestAdminAPI_PatchUser_NotFound(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
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 {
@@ -353,7 +356,7 @@ func TestAdminAPI_PatchUser_NotFound(t *testing.T) {
func TestAdminAPI_PatchUser_InvalidID(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPatch, "/users/abc", token, nil)
@@ -367,11 +370,11 @@ func TestAdminAPI_PatchUser_InvalidID(t *testing.T) {
func TestAdminAPI_ForceLogout_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser("logoutme", "hash", 3)
database.CreateSession(targetUID, "victim-token-hash", "web", "1.2.3.4")
_, _ = database.CreateSession(targetUID, "victim-token-hash", "web", "1.2.3.4")
w := doRequest(t, handler, http.MethodDelete, "/users/"+itoa(targetUID)+"/sessions", token, nil)
@@ -387,7 +390,7 @@ func TestAdminAPI_ForceLogout_OK(t *testing.T) {
func TestAdminAPI_ForceLogout_Unauthenticated(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
w := doRequest(t, handler, http.MethodDelete, "/users/1/sessions", "", nil)
@@ -400,10 +403,10 @@ func TestAdminAPI_ForceLogout_Unauthenticated(t *testing.T) {
func TestAdminAPI_ListChannels_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
database.AdminCreateChannel("general", "text", "", "", 0)
_, _ = database.AdminCreateChannel("general", "text", "", "", 0)
w := doRequest(t, handler, http.MethodGet, "/channels", token, nil)
@@ -411,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)
}
@@ -424,10 +427,10 @@ func TestAdminAPI_ListChannels_OK(t *testing.T) {
func TestAdminAPI_CreateChannel_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
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",
@@ -440,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)
}
@@ -451,10 +454,10 @@ func TestAdminAPI_CreateChannel_OK(t *testing.T) {
func TestAdminAPI_CreateChannel_MissingName(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
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)
@@ -468,12 +471,12 @@ func TestAdminAPI_CreateChannel_MissingName(t *testing.T) {
func TestAdminAPI_UpdateChannel_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
chID, _ := database.AdminCreateChannel("old", "text", "", "", 0)
body := map[string]interface{}{
body := map[string]any{
"name": "updated",
"topic": "new topic",
"slow_mode": float64(10),
@@ -489,10 +492,10 @@ func TestAdminAPI_UpdateChannel_OK(t *testing.T) {
func TestAdminAPI_UpdateChannel_NotFound(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
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 {
@@ -504,7 +507,7 @@ func TestAdminAPI_UpdateChannel_NotFound(t *testing.T) {
func TestAdminAPI_DeleteChannel_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
chID, _ := database.AdminCreateChannel("del-me", "text", "", "", 0)
@@ -518,7 +521,7 @@ func TestAdminAPI_DeleteChannel_OK(t *testing.T) {
func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodDelete, "/channels/99999", token, nil)
@@ -532,11 +535,11 @@ func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) {
func TestAdminAPI_AuditLog_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
uid, _ := database.CreateUser("actor", "hash", 1)
database.LogAudit(uid, "TEST_ACTION", "user", uid, "detail")
_ = database.LogAudit(uid, "TEST_ACTION", "user", uid, "detail")
w := doRequest(t, handler, http.MethodGet, "/audit-log?limit=10&offset=0", token, nil)
@@ -544,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)
}
@@ -555,7 +558,7 @@ func TestAdminAPI_AuditLog_OK(t *testing.T) {
func TestAdminAPI_AuditLog_Empty(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/audit-log", token, nil)
@@ -564,8 +567,8 @@ func TestAdminAPI_AuditLog_Empty(t *testing.T) {
t.Errorf("status = %d, want 200", w.Code)
}
var entries []interface{}
json.Unmarshal(w.Body.Bytes(), &entries)
var entries []any
_ = json.Unmarshal(w.Body.Bytes(), &entries)
if len(entries) != 0 {
t.Errorf("expected 0 entries, got %d", len(entries))
}
@@ -575,7 +578,7 @@ func TestAdminAPI_AuditLog_Empty(t *testing.T) {
func TestAdminAPI_GetSettings_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/settings", token, nil)
@@ -597,7 +600,7 @@ func TestAdminAPI_GetSettings_OK(t *testing.T) {
func TestAdminAPI_PatchSettings_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
body := map[string]string{
@@ -622,7 +625,7 @@ func TestAdminAPI_PatchSettings_OK(t *testing.T) {
func TestAdminAPI_PatchSettings_InvalidBody(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
req := httptest.NewRequest(http.MethodPatch, "/settings", bytes.NewReader([]byte("not-json")))
@@ -639,12 +642,12 @@ func TestAdminAPI_PatchSettings_InvalidBody(t *testing.T) {
func TestAdminAPI_Backup_RequiresOwner(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
// Admin (role 2) can authenticate but is not Owner (role 1, position 100)
adminUID, _ := database.CreateUser("adminonly", "hash", 2)
token := "admin-only-token"
database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1")
_, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1")
w := doRequest(t, handler, http.MethodPost, "/backup", token, nil)
@@ -656,7 +659,7 @@ func TestAdminAPI_Backup_RequiresOwner(t *testing.T) {
func TestAdminAPI_Backup_Unauthenticated(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
w := doRequest(t, handler, http.MethodPost, "/backup", "", nil)
@@ -673,13 +676,13 @@ func TestAdminAPI_Backup_Unauthenticated(t *testing.T) {
// which logs an audit entry containing the actor_id.
func TestAdminAPI_ActorFromContext_AuditEntry(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
// 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 {
@@ -707,11 +710,11 @@ func TestAdminAPI_ActorFromContext_AuditEntry(t *testing.T) {
// DELETE /users/{id}/sessions path.
func TestAdminAPI_ActorFromContext_ForceLogout(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser("logoutctx", "hash", 3)
database.CreateSession(targetUID, "victim-hash-ctx", "web", "1.2.3.4")
_, _ = database.CreateSession(targetUID, "victim-hash-ctx", "web", "1.2.3.4")
w := doRequest(t, handler, http.MethodDelete, "/users/"+itoa(targetUID)+"/sessions", token, nil)
@@ -739,7 +742,7 @@ func TestAdminAPI_ActorFromContext_ForceLogout(t *testing.T) {
// returns 400 without writing anything to the database.
func TestAdminAPI_PatchSettings_RejectsUnknownKey(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
body := map[string]string{
@@ -765,7 +768,7 @@ func TestAdminAPI_PatchSettings_RejectsUnknownKey(t *testing.T) {
// containing both valid and invalid keys is rejected entirely (no partial write).
func TestAdminAPI_PatchSettings_RejectsMixedKeys(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
body := map[string]string{
@@ -806,7 +809,7 @@ func TestAdminAPI_PatchSettings_AcceptsAllWhitelistedKeys(t *testing.T) {
for _, key := range whitelistedKeys {
t.Run(key, func(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
body := map[string]string{key: "testvalue"}
@@ -823,7 +826,7 @@ func TestAdminAPI_PatchSettings_AcceptsAllWhitelistedKeys(t *testing.T) {
// (no-op update) is accepted and returns the current settings.
func TestAdminAPI_PatchSettings_EmptyPayloadIsOK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
body := map[string]string{}
@@ -840,11 +843,11 @@ func TestAdminAPI_PatchSettings_EmptyPayloadIsOK(t *testing.T) {
// expose the PasswordHash field in any returned user object.
func TestAdminAPI_ListUsers_NoPasswordHash(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
// Create a second user so the list is non-trivial.
database.CreateUser("plainuser", "supersecretbcrypthash", 3)
_, _ = database.CreateUser("plainuser", "supersecretbcrypthash", 3)
w := doRequest(t, handler, http.MethodGet, "/users", token, nil)
@@ -854,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")
}
}
@@ -867,7 +870,7 @@ func TestAdminAPI_ListUsers_NoPasswordHash(t *testing.T) {
// expose the TOTPSecret field.
func TestAdminAPI_ListUsers_NoTOTPSecret(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/users", token, nil)
@@ -877,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")
}
}
@@ -886,7 +889,7 @@ func TestAdminAPI_ListUsers_NoTOTPSecret(t *testing.T) {
// are still present after the sensitive-field removal.
func TestAdminAPI_ListUsers_PublicFieldsPresent(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/users", token, nil)
@@ -895,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)
}
@@ -915,12 +918,12 @@ func TestAdminAPI_ListUsers_PublicFieldsPresent(t *testing.T) {
// not expose PasswordHash in the returned user object.
func TestAdminAPI_PatchUser_NoPasswordHash(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser("patchvictim", "topsecretbcrypt", 3)
body := map[string]interface{}{
body := map[string]any{
"banned": true,
"ban_reason": "test",
}
@@ -931,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")
}
}
@@ -943,12 +946,12 @@ func TestAdminAPI_PatchUser_NoPasswordHash(t *testing.T) {
// not expose TOTPSecret in the returned user object.
func TestAdminAPI_PatchUser_NoTOTPSecret(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
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,
})
@@ -957,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")
}
}
@@ -970,6 +973,14 @@ type mockHub struct {
channelCreates []*db.Channel
channelUpdates []*db.Channel
channelDeleteIDs []int64
memberBanIDs []int64
memberUpdates []memberUpdateCall
clientCount int
}
type memberUpdateCall struct {
userID int64
roleName string
}
type restartCall struct {
@@ -993,13 +1004,25 @@ func (m *mockHub) BroadcastChannelDelete(channelID int64) {
m.channelDeleteIDs = append(m.channelDeleteIDs, channelID)
}
func (m *mockHub) BroadcastMemberBan(userID int64) {
m.memberBanIDs = append(m.memberBanIDs, userID)
}
func (m *mockHub) BroadcastMemberUpdate(userID int64, roleName string) {
m.memberUpdates = append(m.memberUpdates, memberUpdateCall{userID, roleName})
}
func (m *mockHub) ClientCount() int {
return m.clientCount
}
func TestAdminAPI_CreateChannel_BroadcastsChannelCreate(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
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",
}
@@ -1022,7 +1045,7 @@ func TestAdminAPI_CreateChannel_NilHubDoesNotPanic(t *testing.T) {
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 {
@@ -1038,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 {
@@ -1058,7 +1081,7 @@ func TestAdminAPI_UpdateChannel_NilHubDoesNotPanic(t *testing.T) {
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 {
@@ -1107,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
}
+10 -3
View File
@@ -11,13 +11,16 @@ import (
// ─── User Handlers ───────────────────────────────────────────────────────────
func handleGetStats(database *db.DB) http.HandlerFunc {
func handleGetStats(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
stats, err := database.GetServerStats()
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get stats")
return
}
if hub != nil {
stats.OnlineCount = hub.ClientCount()
}
writeJSON(w, http.StatusOK, stats)
}
}
@@ -91,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)
}
}
}
@@ -108,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")
+125
View File
@@ -263,6 +263,7 @@
<a href="#" data-section="channels">Channels</a>
<a href="#" data-section="audit-log">Audit Log</a>
<a href="#" data-section="settings">Settings</a>
<a href="#" data-section="backups">Backups</a>
</nav>
</div>
@@ -370,13 +371,71 @@
<label>Server Name</label>
<input id="setting-server_name" type="text">
</div>
<div class="form-group">
<label>Server Icon URL</label>
<input id="setting-server_icon" type="text" placeholder="https://...">
</div>
<div class="form-group">
<label>Message of the Day (MOTD)</label>
<textarea id="setting-motd" rows="3"></textarea>
</div>
<div class="form-group">
<label>Max Upload Size (bytes)</label>
<input id="setting-max_upload_bytes" type="number" min="0">
</div>
<div class="form-group">
<label>Voice Quality</label>
<select id="setting-voice_quality">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div class="form-group">
<label>Require 2FA</label>
<select id="setting-require_2fa">
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</div>
<div class="form-group">
<label>Registration Open</label>
<select id="setting-registration_open">
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
<div class="form-group">
<label>Backup Schedule</label>
<select id="setting-backup_schedule">
<option value="off">Off</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</div>
<div class="form-group">
<label>Backup Retention (days)</label>
<input id="setting-backup_retention" type="number" min="1">
</div>
<button class="btn" id="save-settings-btn">Save Settings</button>
</div>
</section>
<!-- Backups -->
<section id="section-backups" class="hidden">
<h1>Backups</h1>
<div id="backups-alert"></div>
<div class="card" style="padding:24px;margin-bottom:24px">
<button class="btn" id="create-backup-btn">Create Backup Now</button>
</div>
<div class="card" style="padding:24px">
<h2 style="margin-top:0">Backup History</h2>
<table id="backups-table">
<thead><tr><th>Name</th><th>Size</th><th>Date</th><th>Actions</th></tr></thead>
<tbody id="backups-body"></tbody>
</table>
</div>
</section>
</div>
</div>
@@ -549,6 +608,7 @@ function loadSection(name) {
case 'channels': loadChannels(); break;
case 'audit-log': auditPage = 0; loadAuditLog(); break;
case 'settings': loadSettings(); break;
case 'backups': loadBackups(); break;
}
}
@@ -834,6 +894,71 @@ document.getElementById('save-settings-btn').onclick = async () => {
}
};
// ─── Backups ─────────────────────────────────────────────────────────────────
function formatBytes(b) {
if (b < 1024) return b + ' B';
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB';
return (b / 1073741824).toFixed(2) + ' GB';
}
async function loadBackups() {
try {
const backups = await api('GET', '/backups');
const tbody = document.getElementById('backups-body');
if (!backups.length) {
tbody.innerHTML = '<tr><td colspan="4">No backups found</td></tr>';
return;
}
tbody.innerHTML = backups.map(b => `<tr>
<td>${esc(b.name)}</td>
<td>${formatBytes(b.size)}</td>
<td>${new Date(b.date).toLocaleString()}</td>
<td>
<button class="btn btn-sm" onclick="restoreBackup('${esc(b.name)}')">Restore</button>
<button class="btn btn-sm btn-danger" onclick="deleteBackup('${esc(b.name)}')">Delete</button>
</td>
</tr>`).join('');
} catch (e) {
showAlert('backups-alert', 'danger', e.message);
}
}
document.getElementById('create-backup-btn').onclick = async () => {
try {
await api('POST', '/backup');
showAlert('backups-alert', 'success', 'Backup created');
loadBackups();
} catch (e) {
showAlert('backups-alert', 'danger', e.message);
}
};
async function deleteBackup(name) {
if (!confirm('Delete backup ' + name + '?')) return;
try {
await fetch('/admin/api/backups/' + encodeURIComponent(name), {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
});
showAlert('backups-alert', 'success', 'Backup deleted');
loadBackups();
} catch (e) {
showAlert('backups-alert', 'danger', e.message);
}
}
async function restoreBackup(name) {
if (!confirm('Restore from ' + name + '? A pre-restore backup will be created. Server restart recommended after restore.')) return;
try {
await api('POST', '/backups/' + encodeURIComponent(name) + '/restore');
showAlert('backups-alert', 'success', 'Database restored from ' + name + '. Restart the server to apply.');
loadBackups();
} catch (e) {
showAlert('backups-alert', 'danger', e.message);
}
}
// ─── Utilities ────────────────────────────────────────────────────────────────
function esc(s) {
if (s === null || s === undefined) return '';
+1 -1
View File
@@ -31,7 +31,7 @@ func handleCheckUpdate(u *updater.Updater) http.HandlerFunc {
}
// handleApplyUpdate downloads and applies a server update.
func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, currentVersion string) http.Handler {
func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if u == nil {
writeErr(w, http.StatusServiceUnavailable, "UPDATE_UNAVAILABLE", "update checking is not configured")
+7 -7
View File
@@ -14,11 +14,11 @@ import (
func TestAdminAPI_CheckUpdate_OK(t *testing.T) {
// Mock GitHub API
mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
_ = json.NewEncoder(w).Encode(map[string]any{
"tag_name": "v2.0.0",
"body": "New release",
"html_url": "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0",
"assets": []map[string]interface{}{
"assets": []map[string]any{
{"name": "chatserver.exe", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver.exe"},
{"name": "checksums.sha256", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/checksums.sha256"},
},
@@ -39,7 +39,7 @@ func TestAdminAPI_CheckUpdate_OK(t *testing.T) {
}
var info updater.UpdateInfo
json.Unmarshal(w.Body.Bytes(), &info)
_ = json.Unmarshal(w.Body.Bytes(), &info)
if !info.UpdateAvailable {
t.Error("expected update_available = true")
}
@@ -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()
@@ -72,7 +72,7 @@ func TestAdminAPI_CheckUpdate_UpToDate(t *testing.T) {
}
var info updater.UpdateInfo
json.Unmarshal(w.Body.Bytes(), &info)
_ = json.Unmarshal(w.Body.Bytes(), &info)
if info.UpdateAvailable {
t.Error("expected update_available = false")
}
@@ -95,7 +95,7 @@ func TestAdminAPI_ApplyUpdate_RequiresOwner(t *testing.T) {
// Create admin user (not owner - role 2)
adminUID, _ := database.CreateUser("adminonly2", "hash", 2)
token := "admin-role-token"
database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1")
_, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1")
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
if w.Code != http.StatusForbidden {
+19 -19
View File
@@ -22,7 +22,7 @@ func newAuthTestDB(t *testing.T) *db.DB {
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { database.Close() })
t.Cleanup(func() { _ = database.Close() })
migrFS := fstest.MapFS{
"001_schema.sql": {Data: apiTestSchema},
@@ -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,8 +97,8 @@ 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{}
json.NewDecoder(rr.Body).Decode(&resp)
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["token"] == nil {
t.Error("Register response missing token")
}
@@ -206,7 +206,7 @@ func TestLogin_Success(t *testing.T) {
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
database.CreateUser("loginuser", hash, 4)
_, _ = database.CreateUser("loginuser", hash, 4)
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "loginuser",
@@ -217,8 +217,8 @@ 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{}
json.NewDecoder(rr.Body).Decode(&resp)
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["token"] == nil {
t.Error("Login response missing token")
}
@@ -230,7 +230,7 @@ func TestLogin_WrongPassword(t *testing.T) {
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
database.CreateUser("loginuser2", hash, 4)
_, _ = database.CreateUser("loginuser2", hash, 4)
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "loginuser2",
@@ -281,7 +281,7 @@ func TestLogin_BannedUser(t *testing.T) {
hash, _ := auth.HashPassword("correctPass1")
id, _ := database.CreateUser("banned", hash, 4)
database.BanUser(id, "violated rules", nil)
_ = database.BanUser(id, "violated rules", nil)
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "banned",
@@ -315,7 +315,7 @@ func TestLogout_Success(t *testing.T) {
uid, _ := database.CreateUser("logoutuser", hash, 4)
token, _ := auth.GenerateToken()
tokenHash := auth.HashToken(token)
database.CreateSession(uid, tokenHash, "test", "127.0.0.1")
_, _ = database.CreateSession(uid, tokenHash, "test", "127.0.0.1")
rr := postJSONWithToken(t, router, "/api/v1/auth/logout", token, nil)
@@ -355,7 +355,7 @@ func TestMe_Success(t *testing.T) {
hash, _ := auth.HashPassword("correctPass1")
uid, _ := database.CreateUser("meuser", hash, 4)
token, _ := auth.GenerateToken()
database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1")
_, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1")
rr := getWithToken(t, router, "/api/v1/auth/me", token)
@@ -363,8 +363,8 @@ 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{}
json.NewDecoder(rr.Body).Decode(&resp)
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["id"] == nil {
t.Error("Me response missing id")
}
@@ -400,7 +400,7 @@ func TestLogin_PasswordWithLeadingSpaceIsPreserved(t *testing.T) {
// Hash the password WITH the leading space — this is what was registered.
hash, _ := auth.HashPassword(" securePass1")
database.CreateUser("spacepassuser", hash, 4)
_, _ = database.CreateUser("spacepassuser", hash, 4)
// Login with the exact same password (including space) must succeed.
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
@@ -422,7 +422,7 @@ func TestLogin_PasswordWithLeadingSpaceTrimmedFails(t *testing.T) {
// Register with password that has a leading space.
hash, _ := auth.HashPassword(" securePass1")
database.CreateUser("spacepassuser2", hash, 4)
_, _ = database.CreateUser("spacepassuser2", hash, 4)
// Login without the leading space must fail.
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
@@ -443,7 +443,7 @@ func TestLogin_PasswordWithTrailingSpaceIsPreserved(t *testing.T) {
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("securePass1 ")
database.CreateUser("trailingspaceuser", hash, 4)
_, _ = database.CreateUser("trailingspaceuser", hash, 4)
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "trailingspaceuser",
@@ -463,7 +463,7 @@ func TestLogin_UsernameIsStillTrimmed(t *testing.T) {
router := buildAuthRouter(database, limiter)
hash, _ := auth.HashPassword("correctPass1")
database.CreateUser("trimuser", hash, 4)
_, _ = database.CreateUser("trimuser", hash, 4)
// Username with surrounding spaces should resolve to "trimuser".
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
@@ -487,7 +487,7 @@ func TestRegister_RateLimit(t *testing.T) {
// Attempt register 4 times (limit=3) — 4th should be rate-limited.
var lastCode int
for i := 0; i < 4; i++ {
for i := range 4 {
code, _ := database.CreateInvite(ownerID, 1, nil)
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
"username": "rl_user" + string(rune('0'+i)),
+6 -6
View File
@@ -48,7 +48,7 @@ func TestChannelList_FiltersOutDeniedChannels(t *testing.T) {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
var channels []map[string]interface{}
var channels []map[string]any
if err := json.NewDecoder(rr.Body).Decode(&channels); err != nil {
t.Fatalf("decode: %v", err)
}
@@ -82,7 +82,7 @@ func TestChannelList_AdminSeesAllChannels(t *testing.T) {
t.Fatalf("status = %d, want 200", rr.Code)
}
var channels []interface{}
var channels []any
_ = json.NewDecoder(rr.Body).Decode(&channels)
if len(channels) != 2 {
t.Errorf("admin should see all 2 channels, got %d", len(channels))
@@ -151,9 +151,9 @@ func TestSearch_FiltersResultsByPermission(t *testing.T) {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
var resp map[string]interface{}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
results, ok := resp["results"].([]interface{})
results, ok := resp["results"].([]any)
if !ok {
t.Fatalf("results is not an array: %v", resp)
}
@@ -184,9 +184,9 @@ func TestSearch_AdminSeesAllResults(t *testing.T) {
t.Fatalf("status = %d, want 200", rr.Code)
}
var resp map[string]interface{}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
results := resp["results"].([]interface{})
results := resp["results"].([]any)
if len(results) != 2 {
t.Errorf("admin should see all 2 results, got %d", len(results))
}
+16 -16
View File
@@ -160,7 +160,7 @@ func newChannelTestDB(t *testing.T) *db.DB {
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { database.Close() })
t.Cleanup(func() { _ = database.Close() })
migrFS := fstest.MapFS{"001_schema.sql": {Data: channelTestSchema}}
if err := db.MigrateFS(database, migrFS); err != nil {
t.Fatalf("MigrateFS: %v", err)
@@ -225,7 +225,7 @@ func TestChannelList_Empty(t *testing.T) {
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
var resp []interface{}
var resp []any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if len(resp) != 0 {
t.Errorf("expected empty array, got %d items", len(resp))
@@ -244,7 +244,7 @@ func TestChannelList_WithChannels(t *testing.T) {
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rr.Code)
}
var resp []interface{}
var resp []any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if len(resp) != 2 {
t.Errorf("expected 2 channels, got %d", len(resp))
@@ -293,9 +293,9 @@ func TestChannelMessages_EmptyChannel(t *testing.T) {
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
var resp map[string]interface{}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
msgs, ok := resp["messages"].([]interface{})
msgs, ok := resp["messages"].([]any)
if !ok || len(msgs) != 0 {
t.Errorf("expected empty messages array, got: %v", resp["messages"])
}
@@ -308,7 +308,7 @@ func TestChannelMessages_ReturnsMessages(t *testing.T) {
user, _ := database.GetUserByUsername("frank")
chID, _ := database.CreateChannel("ch", "text", "", "", 0)
for i := 0; i < 3; i++ {
for i := range 3 {
_, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("msg%d", i), nil)
}
@@ -316,9 +316,9 @@ func TestChannelMessages_ReturnsMessages(t *testing.T) {
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rr.Code)
}
var resp map[string]interface{}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
msgs := resp["messages"].([]interface{})
msgs := resp["messages"].([]any)
if len(msgs) != 3 {
t.Errorf("expected 3 messages, got %d", len(msgs))
}
@@ -344,7 +344,7 @@ func TestChannelMessages_HasMore(t *testing.T) {
user, _ := database.GetUserByUsername("henry")
chID, _ := database.CreateChannel("ch", "text", "", "", 0)
for i := 0; i < 60; i++ {
for i := range 60 {
_, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("m%d", i), nil)
}
@@ -352,7 +352,7 @@ func TestChannelMessages_HasMore(t *testing.T) {
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rr.Code)
}
var resp map[string]interface{}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["has_more"] != true {
t.Errorf("has_more = %v, want true", resp["has_more"])
@@ -366,7 +366,7 @@ func TestChannelMessages_HasMoreFalse(t *testing.T) {
user, _ := database.GetUserByUsername("ivan")
chID, _ := database.CreateChannel("ch", "text", "", "", 0)
for i := 0; i < 5; i++ {
for i := range 5 {
_, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("m%d", i), nil)
}
@@ -374,7 +374,7 @@ func TestChannelMessages_HasMoreFalse(t *testing.T) {
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rr.Code)
}
var resp map[string]interface{}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["has_more"] != false {
t.Errorf("has_more = %v, want false", resp["has_more"])
@@ -414,9 +414,9 @@ func TestSearch_ReturnsResults(t *testing.T) {
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
var resp map[string]interface{}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
results, ok := resp["results"].([]interface{})
results, ok := resp["results"].([]any)
if !ok || len(results) == 0 {
t.Errorf("expected search results, got: %v", resp)
}
@@ -431,9 +431,9 @@ func TestSearch_NoResults(t *testing.T) {
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rr.Code)
}
var resp map[string]interface{}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
results := resp["results"].([]interface{})
results := resp["results"].([]any)
if len(results) != 0 {
t.Errorf("expected 0 results, got %d", len(results))
}
+6 -6
View File
@@ -41,7 +41,7 @@ func TestContract_Messages_HasRequiredFields(t *testing.T) {
}
// Parse the first message and verify all API.md fields are present.
var msg map[string]interface{}
var msg map[string]any
if err := json.Unmarshal(resp.Messages[0], &msg); err != nil {
t.Fatalf("decode message: %v", err)
}
@@ -58,7 +58,7 @@ func TestContract_Messages_HasRequiredFields(t *testing.T) {
}
// Verify 'user' is an object with id, username.
userObj, ok := msg["user"].(map[string]interface{})
userObj, ok := msg["user"].(map[string]any)
if !ok {
t.Fatal("'user' is not an object")
}
@@ -69,12 +69,12 @@ func TestContract_Messages_HasRequiredFields(t *testing.T) {
}
// Verify 'attachments' is an array (even if empty).
if _, ok := msg["attachments"].([]interface{}); !ok {
if _, ok := msg["attachments"].([]any); !ok {
t.Error("'attachments' is not an array")
}
// Verify 'reactions' is an array (even if empty).
if _, ok := msg["reactions"].([]interface{}); !ok {
if _, ok := msg["reactions"].([]any); !ok {
t.Error("'reactions' is not an array")
}
}
@@ -152,7 +152,7 @@ func TestContract_Search_HasRequiredFields(t *testing.T) {
t.Fatal("expected at least 1 search result")
}
var result map[string]interface{}
var result map[string]any
if err := json.Unmarshal(resp.Results[0], &result); err != nil {
t.Fatalf("decode result: %v", err)
}
@@ -169,7 +169,7 @@ func TestContract_Search_HasRequiredFields(t *testing.T) {
}
// Verify 'user' is an object with id and username.
userObj, ok := result["user"].(map[string]interface{})
userObj, ok := result["user"].(map[string]any)
if !ok {
t.Fatal("search result 'user' is not an object")
}
+22 -45
View File
@@ -21,12 +21,12 @@ 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)
token, _ := auth.GenerateToken()
database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1")
_, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1")
return token
}
@@ -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,8 +49,8 @@ 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{}
json.NewDecoder(rr.Body).Decode(&resp)
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,8 +124,8 @@ func TestListInvites_Success(t *testing.T) {
t.Errorf("ListInvites status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
var resp []interface{}
json.NewDecoder(rr.Body).Decode(&resp)
var resp []any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if len(resp) < 2 {
t.Errorf("ListInvites returned %d items, want >= 2", len(resp))
}
@@ -156,12 +156,12 @@ 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{}
json.NewDecoder(rr.Body).Decode(&created)
var created map[string]any
_ = json.NewDecoder(rr.Body).Decode(&created)
codeVal, ok := created["code"]
if !ok || codeVal == nil {
t.Fatalf("Create invite response missing code field; body parsed as %v", created)
@@ -213,9 +213,9 @@ 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{}
json.NewDecoder(rr.Body).Decode(&created)
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)
// Member tries to revoke.
@@ -240,12 +240,12 @@ 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{}
json.NewDecoder(rr.Body).Decode(&created)
var created map[string]any
_ = json.NewDecoder(rr.Body).Decode(&created)
code := created["code"].(string)
delReq := httptest.NewRequest(http.MethodDelete, "/api/v1/invites/"+code, nil)
@@ -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
}
+16 -16
View File
@@ -21,7 +21,7 @@ func newAPITestDB(t *testing.T) *db.DB {
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { database.Close() })
t.Cleanup(func() { _ = database.Close() })
migrFS := fstest.MapFS{
"001_schema.sql": {Data: apiTestSchema},
@@ -51,7 +51,7 @@ func TestAuthMiddleware_ValidToken(t *testing.T) {
uid, _ := database.CreateUser("alice", "hash", 4)
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
database.CreateSession(uid, hash, "test", "127.0.0.1")
_, _ = database.CreateSession(uid, hash, "test", "127.0.0.1")
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
@@ -102,7 +102,7 @@ func TestAuthMiddleware_ExpiredSession(t *testing.T) {
// Insert an already-expired session.
pastTime := time.Now().Add(-time.Hour).UTC().Format("2006-01-02 15:04:05")
database.Exec(
_, _ = database.Exec(
`INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)`,
uid, hash, "test", "127.0.0.1", pastTime,
)
@@ -147,7 +147,7 @@ func TestRequirePermission_Allowed(t *testing.T) {
uid, _ := database.CreateUser("carol", "hash", 4) // Member role = 0x663
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
database.CreateSession(uid, hash, "test", "127.0.0.1")
_, _ = database.CreateSession(uid, hash, "test", "127.0.0.1")
// SEND_MESSAGES = 0x1 — Member role has this bit
h := api.AuthMiddleware(database)(
@@ -169,7 +169,7 @@ func TestRequirePermission_Forbidden(t *testing.T) {
uid, _ := database.CreateUser("dave", "hash", 4) // Member role = 0x663
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
database.CreateSession(uid, hash, "test", "127.0.0.1")
_, _ = database.CreateSession(uid, hash, "test", "127.0.0.1")
// MANAGE_ROLES = 0x1000000 — Member does not have this
h := api.AuthMiddleware(database)(
@@ -192,7 +192,7 @@ func TestRequirePermission_Administrator_Bypass(t *testing.T) {
uid, _ := database.CreateUser("owner", "hash", 1)
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
database.CreateSession(uid, hash, "test", "127.0.0.1")
_, _ = database.CreateSession(uid, hash, "test", "127.0.0.1")
// Any permission should pass for ADMINISTRATOR
h := api.AuthMiddleware(database)(
@@ -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"
@@ -340,10 +340,10 @@ func TestRateLimitMiddleware_XRealIPHonouredFromTrustedProxy(t *testing.T) {
func TestAuthMiddleware_BannedUserBlocked(t *testing.T) {
database := newAPITestDB(t)
uid, _ := database.CreateUser("banneduser", "hash", 4)
database.BanUser(uid, "rule violation", nil) // permanent ban
_ = database.BanUser(uid, "rule violation", nil) // permanent ban
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
database.CreateSession(uid, hash, "test", "127.0.0.1")
_, _ = database.CreateSession(uid, hash, "test", "127.0.0.1")
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
@@ -365,11 +365,11 @@ func TestAuthMiddleware_ExpiredBanAllowed(t *testing.T) {
// Set ban with an expiry time in the past.
past := time.Now().UTC().Add(-time.Hour)
database.BanUser(uid, "temp ban", &past)
_ = database.BanUser(uid, "temp ban", &past)
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
database.CreateSession(uid, hash, "test", "127.0.0.1")
_, _ = database.CreateSession(uid, hash, "test", "127.0.0.1")
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
@@ -391,11 +391,11 @@ func TestAuthMiddleware_ActiveTemporaryBanBlocked(t *testing.T) {
// Set ban with an expiry time in the future.
future := time.Now().UTC().Add(time.Hour)
database.BanUser(uid, "temp ban", &future)
_ = database.BanUser(uid, "temp ban", &future)
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
database.CreateSession(uid, hash, "test", "127.0.0.1")
_, _ = database.CreateSession(uid, hash, "test", "127.0.0.1")
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
req := httptest.NewRequest(http.MethodGet, "/", nil)
+7
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -65,6 +66,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string) http.Handler {
hub.SetSFU(sfu)
}
ws.InitSettingsCache(database)
go hub.Run()
r.Get("/api/v1/ws", ws.ServeWS(hub, database, cfg.Server.AllowedOrigins))
@@ -75,10 +77,14 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string) http.Handler {
return r
}
// serverStartTime records when the process started; used for uptime in /health.
var serverStartTime = time.Now()
// healthResponse is the JSON shape returned by GET /health.
type healthResponse struct {
Status string `json:"status"`
Version string `json:"version"`
Uptime int64 `json:"uptime"`
}
// infoResponse is the JSON shape returned by GET /api/v1/info.
@@ -92,6 +98,7 @@ func handleHealth(ver string) http.HandlerFunc {
writeJSON(w, http.StatusOK, healthResponse{
Status: "ok",
Version: ver,
Uptime: int64(time.Since(serverStartTime).Seconds()),
})
}
}
+6 -6
View File
@@ -23,7 +23,7 @@ func setupRouter(t *testing.T) http.Handler {
if err := db.Migrate(database); err != nil {
t.Fatalf("db.Migrate error: %v", err)
}
t.Cleanup(func() { database.Close() })
t.Cleanup(func() { _ = database.Close() })
cfg := &config.Config{
Server: config.ServerConfig{
@@ -61,7 +61,7 @@ func TestHealthEndpointReturnsJSON(t *testing.T) {
t.Errorf("Content-Type = %q, want application/json", contentType)
}
var body map[string]interface{}
var body map[string]any
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("response body is not valid JSON: %v", err)
}
@@ -75,7 +75,7 @@ func TestHealthEndpointStatusOK(t *testing.T) {
router.ServeHTTP(rec, req)
var body map[string]interface{}
var body map[string]any
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("JSON decode error: %v", err)
}
@@ -93,7 +93,7 @@ func TestHealthEndpointHasVersion(t *testing.T) {
router.ServeHTTP(rec, req)
var body map[string]interface{}
var body map[string]any
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("JSON decode error: %v", err)
}
@@ -124,7 +124,7 @@ func TestAPIV1InfoReturnsServerName(t *testing.T) {
router.ServeHTTP(rec, req)
var body map[string]interface{}
var body map[string]any
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("JSON decode error: %v", err)
}
@@ -142,7 +142,7 @@ func TestAPIV1InfoReturnsVersion(t *testing.T) {
router.ServeHTTP(rec, req)
var body map[string]interface{}
var body map[string]any
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("JSON decode error: %v", err)
}
+1 -1
View File
@@ -92,7 +92,7 @@ func generateTURNCredentials(userID int64, secret string) turnCredentials {
username := fmt.Sprintf("%d:%d", expiry, userID)
mac := hmac.New(sha1.New, []byte(secret))
mac.Write([]byte(username))
_, _ = mac.Write([]byte(username))
credential := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return turnCredentials{
+25 -25
View File
@@ -28,7 +28,7 @@ func newVoiceAPITestDB(t *testing.T) *db.DB {
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { database.Close() })
t.Cleanup(func() { _ = database.Close() })
migrFS := fstest.MapFS{
"001_schema.sql": {Data: apiTestSchema},
@@ -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{}
json.NewDecoder(rr.Body).Decode(&resp)
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{}
json.NewDecoder(rr.Body).Decode(&resp)
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{}
json.NewDecoder(rr.Body).Decode(&resp)
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{}
json.NewDecoder(rr.Body).Decode(&resp)
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,8 +298,8 @@ func TestVoiceCredentials_ResponseContainsExpiresIn(t *testing.T) {
router := buildVoiceRouter(database, cfg)
rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token)
var resp map[string]interface{}
json.NewDecoder(rr.Body).Decode(&resp)
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
expiresIn, ok := resp["expires_in"]
if !ok {
@@ -331,12 +331,12 @@ func TestVoiceCredentials_TURNDisabled_NoTURNEntry(t *testing.T) {
t.Fatalf("status = %d, want 200", rr.Code)
}
var resp map[string]interface{}
json.NewDecoder(rr.Body).Decode(&resp)
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")
}
-4
View File
@@ -5,10 +5,6 @@ import (
"time"
)
// defaultCleanupMaxWindow is the age beyond which a window entry with no
// recent timestamps is considered stale and eligible for eviction.
const defaultCleanupMaxWindow = 15 * time.Minute
// entry records individual request timestamps for sliding-window limiting.
type entry struct {
timestamps []time.Time
+6 -6
View File
@@ -9,7 +9,7 @@ import (
func TestRateLimiter_UnderLimitAllowed(t *testing.T) {
rl := auth.NewRateLimiter()
for i := 0; i < 5; i++ {
for i := range 5 {
if !rl.Allow("key1", 5, time.Second) {
t.Errorf("Allow() = false at iteration %d, want true", i)
}
@@ -19,7 +19,7 @@ func TestRateLimiter_UnderLimitAllowed(t *testing.T) {
func TestRateLimiter_AtLimitAllowed(t *testing.T) {
rl := auth.NewRateLimiter()
// Allow up to exactly the limit
for i := 0; i < 3; i++ {
for range 3 {
rl.Allow("keyA", 3, time.Second)
}
// The 4th call should be blocked
@@ -31,7 +31,7 @@ func TestRateLimiter_AtLimitAllowed(t *testing.T) {
func TestRateLimiter_OverLimitBlocked(t *testing.T) {
rl := auth.NewRateLimiter()
limit := 3
for i := 0; i < limit; i++ {
for range limit {
rl.Allow("key2", limit, time.Second)
}
if rl.Allow("key2", limit, time.Second) {
@@ -58,7 +58,7 @@ func TestRateLimiter_WindowExpiryResets(t *testing.T) {
func TestRateLimiter_DifferentKeysIndependent(t *testing.T) {
rl := auth.NewRateLimiter()
for i := 0; i < 5; i++ {
for range 5 {
rl.Allow("keyX", 3, time.Second)
}
// keyY should still be allowed
@@ -113,13 +113,13 @@ func TestRateLimiter_LockoutBlocksAllow(t *testing.T) {
func TestRateLimiter_ThreadSafe(t *testing.T) {
rl := auth.NewRateLimiter()
done := make(chan struct{}, 100)
for i := 0; i < 100; i++ {
for range 100 {
go func() {
rl.Allow("concurrent", 50, time.Second)
done <- struct{}{}
}()
}
for i := 0; i < 100; i++ {
for range 100 {
<-done
}
// If we get here without a race condition data race, we pass
+2 -2
View File
@@ -22,7 +22,7 @@ func TestGenerateToken_HexCharacters(t *testing.T) {
t.Fatalf("GenerateToken() error = %v", err)
}
for i, c := range token {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
t.Errorf("GenerateToken() char[%d] = %q, not lowercase hex", i, c)
}
}
@@ -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)
+1 -1
View File
@@ -150,7 +150,7 @@ func writePEM(path, pemType string, data []byte) error {
if err != nil {
return err
}
defer f.Close()
defer f.Close() //nolint:errcheck
return pem.Encode(f, &pem.Block{Type: pemType, Bytes: data})
}
+4 -4
View File
@@ -20,8 +20,8 @@ func TestLoadDefaults(t *testing.T) {
tests := []struct {
name string
got interface{}
want interface{}
got any
want any
}{
{"Server.Port", cfg.Server.Port, 8443},
{"Server.Name", cfg.Server.Name, "OwnCord Server"},
@@ -237,8 +237,8 @@ func TestLoadVoiceConfigDefaults(t *testing.T) {
tests := []struct {
name string
got interface{}
want interface{}
got any
want any
}{
{"Voice.Quality", cfg.Voice.Quality, "medium"},
{"Voice.MixingThreshold", cfg.Voice.MixingThreshold, 10},
+4 -4
View File
@@ -72,7 +72,7 @@ func (d *DB) ListAllUsers(limit, offset int) ([]UserWithRole, error) {
if err != nil {
return nil, fmt.Errorf("ListAllUsers: %w", err)
}
defer rows.Close()
defer rows.Close() //nolint:errcheck
var result []UserWithRole
for rows.Next() {
@@ -130,7 +130,7 @@ func (d *DB) GetUserSessions(userID int64) ([]Session, error) {
if err != nil {
return nil, fmt.Errorf("GetUserSessions: %w", err)
}
defer rows.Close()
defer rows.Close() //nolint:errcheck
var sessions []Session
for rows.Next() {
@@ -224,7 +224,7 @@ func (d *DB) GetAuditLog(limit, offset int) ([]AuditEntry, error) {
if err != nil {
return nil, fmt.Errorf("GetAuditLog: %w", err)
}
defer rows.Close()
defer rows.Close() //nolint:errcheck
var entries []AuditEntry
for rows.Next() {
@@ -281,7 +281,7 @@ func (d *DB) GetAllSettings() (map[string]string, error) {
if err != nil {
return nil, fmt.Errorf("GetAllSettings: %w", err)
}
defer rows.Close()
defer rows.Close() //nolint:errcheck
result := make(map[string]string)
for rows.Next() {
+20 -20
View File
@@ -70,7 +70,7 @@ func newAdminTestDB(t *testing.T) *db.DB {
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { database.Close() })
t.Cleanup(func() { _ = database.Close() })
migrFS := fstest.MapFS{
"001_schema.sql": {Data: adminTestSchema},
@@ -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",
@@ -206,7 +206,7 @@ func TestListAllUsers_Pagination(t *testing.T) {
func TestListAllUsers_ZeroLimit(t *testing.T) {
database := newAdminTestDB(t)
database.CreateUser("zerotest", "hash", 4)
_, _ = database.CreateUser("zerotest", "hash", 4)
users, err := database.ListAllUsers(0, 0)
if err != nil {
@@ -261,8 +261,8 @@ func TestForceLogoutUser_DeletesSessions(t *testing.T) {
t.Fatalf("CreateUser error: %v", err)
}
database.CreateSession(uid, "token1hash", "device1", "127.0.0.1")
database.CreateSession(uid, "token2hash", "device2", "127.0.0.1")
_, _ = database.CreateSession(uid, "token1hash", "device1", "127.0.0.1")
_, _ = database.CreateSession(uid, "token2hash", "device2", "127.0.0.1")
sessions, err := database.GetUserSessions(uid)
if err != nil {
@@ -323,9 +323,9 @@ func TestGetUserSessions_IsolatedByUser(t *testing.T) {
uid1, _ := database.CreateUser("user1sess", "hash", 4)
uid2, _ := database.CreateUser("user2sess", "hash", 4)
database.CreateSession(uid1, "u1t1", "web", "1.2.3.4")
database.CreateSession(uid1, "u1t2", "mobile", "1.2.3.5")
database.CreateSession(uid2, "u2t1", "web", "1.2.3.6")
_, _ = database.CreateSession(uid1, "u1t1", "web", "1.2.3.4")
_, _ = database.CreateSession(uid1, "u1t2", "mobile", "1.2.3.5")
_, _ = database.CreateSession(uid2, "u2t1", "web", "1.2.3.6")
sessions, err := database.GetUserSessions(uid1)
if err != nil {
@@ -437,7 +437,7 @@ func TestAdminUpdateChannel_Unarchive(t *testing.T) {
database := newAdminTestDB(t)
id, _ := database.AdminCreateChannel("arch-ch", "text", "", "", 0)
database.AdminUpdateChannel(id, "arch-ch", "", 0, 0, true)
_ = database.AdminUpdateChannel(id, "arch-ch", "", 0, 0, true)
ch, _ := database.GetChannel(id)
if !ch.Archived {
@@ -445,7 +445,7 @@ func TestAdminUpdateChannel_Unarchive(t *testing.T) {
}
// Unarchive
database.AdminUpdateChannel(id, "arch-ch", "", 0, 0, false)
_ = database.AdminUpdateChannel(id, "arch-ch", "", 0, 0, false)
ch, _ = database.GetChannel(id)
if ch.Archived {
t.Error("Archived = true after unarchiving, want false")
@@ -546,8 +546,8 @@ func TestGetAuditLog_Pagination(t *testing.T) {
database := newAdminTestDB(t)
uid, _ := database.CreateUser("auditpager", "hash", 1)
for i := 0; i < 5; i++ {
database.LogAudit(uid, "ACTION", "target", int64(i), "detail")
for i := range 5 {
_ = database.LogAudit(uid, "ACTION", "target", int64(i), "detail")
}
page1, err := database.GetAuditLog(3, 0)
@@ -571,8 +571,8 @@ func TestGetAuditLog_NewestFirst(t *testing.T) {
database := newAdminTestDB(t)
uid, _ := database.CreateUser("auditorder", "hash", 1)
database.LogAudit(uid, "FIRST", "", 0, "")
database.LogAudit(uid, "SECOND", "", 0, "")
_ = database.LogAudit(uid, "FIRST", "", 0, "")
_ = database.LogAudit(uid, "SECOND", "", 0, "")
entries, err := database.GetAuditLog(10, 0)
if err != nil {
@@ -659,7 +659,7 @@ func TestGetAllSettings_ReturnsMap(t *testing.T) {
func TestGetAllSettings_AfterClearing(t *testing.T) {
database := newAdminTestDB(t)
database.Exec("DELETE FROM settings")
_, _ = database.Exec("DELETE FROM settings")
settings, err := database.GetAllSettings()
if err != nil {
@@ -680,7 +680,7 @@ func TestBackupToSafe_AdminQueries(t *testing.T) {
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { database.Close() })
t.Cleanup(func() { _ = database.Close() })
migrFS := fstest.MapFS{
"001_schema.sql": {Data: adminTestSchema},
@@ -690,7 +690,7 @@ func TestBackupToSafe_AdminQueries(t *testing.T) {
}
backupDir := filepath.Join(tmpDir, "backups")
os.MkdirAll(backupDir, 0o755)
_ = os.MkdirAll(backupDir, 0o755)
backupPath := filepath.Join(backupDir, "backup.db")
if err := database.BackupToSafe(backupPath, backupDir); err != nil {
t.Fatalf("BackupToSafe() error: %v", err)
@@ -713,15 +713,15 @@ func TestBackupToSafe_CreatesDirectoryFile(t *testing.T) {
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { database.Close() })
t.Cleanup(func() { _ = database.Close() })
migrFS := fstest.MapFS{
"001_schema.sql": {Data: adminTestSchema},
}
db.MigrateFS(database, migrFS)
_ = db.MigrateFS(database, migrFS)
backupDir := filepath.Join(tmpDir, "backups")
os.MkdirAll(backupDir, 0o755)
_ = os.MkdirAll(backupDir, 0o755)
backupPath := filepath.Join(backupDir, "chatserver_20260314_120000.db")
if err := database.BackupToSafe(backupPath, backupDir); err != nil {
+1 -1
View File
@@ -79,7 +79,7 @@ func (d *DB) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]AttachmentI
if err != nil {
return nil, fmt.Errorf("GetAttachmentsByMessageIDs: %w", err)
}
defer rows.Close()
defer rows.Close() //nolint:errcheck
result := make(map[int64][]AttachmentInfo)
for rows.Next() {
+1 -1
View File
@@ -308,7 +308,7 @@ func (d *DB) ListMembers() ([]MemberSummary, error) {
if err != nil {
return nil, fmt.Errorf("ListMembers: %w", err)
}
defer rows.Close()
defer rows.Close() //nolint:errcheck
var members []MemberSummary
for rows.Next() {
+10 -10
View File
@@ -16,7 +16,7 @@ func newTestDB(t *testing.T) *db.DB {
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { database.Close() })
t.Cleanup(func() { _ = database.Close() })
// Build a minimal migration FS with the initial schema.
migrFS := fstest.MapFS{
@@ -126,7 +126,7 @@ func TestCreateUser_CaseInsensitiveDuplicate(t *testing.T) {
func TestGetUserByUsername_Found(t *testing.T) {
database := newTestDB(t)
database.CreateUser("dave", "hashDave", 4)
_, _ = database.CreateUser("dave", "hashDave", 4)
user, err := database.GetUserByUsername("dave")
if err != nil {
@@ -142,7 +142,7 @@ func TestGetUserByUsername_Found(t *testing.T) {
func TestGetUserByUsername_CaseInsensitive(t *testing.T) {
database := newTestDB(t)
database.CreateUser("Eve", "hashEve", 4)
_, _ = database.CreateUser("Eve", "hashEve", 4)
user, err := database.GetUserByUsername("EVE")
if err != nil {
@@ -252,7 +252,7 @@ func TestCreateSession_Success(t *testing.T) {
func TestGetSessionByTokenHash_Found(t *testing.T) {
database := newTestDB(t)
uid, _ := database.CreateUser("kate", "hash", 4)
database.CreateSession(uid, "myTokenHash", "GoTest/1.0", "127.0.0.1")
_, _ = database.CreateSession(uid, "myTokenHash", "GoTest/1.0", "127.0.0.1")
sess, err := database.GetSessionByTokenHash("myTokenHash")
if err != nil {
@@ -280,7 +280,7 @@ func TestGetSessionByTokenHash_NotFound(t *testing.T) {
func TestDeleteSession(t *testing.T) {
database := newTestDB(t)
uid, _ := database.CreateUser("leo", "hash", 4)
database.CreateSession(uid, "delToken", "GoTest/1.0", "127.0.0.1")
_, _ = database.CreateSession(uid, "delToken", "GoTest/1.0", "127.0.0.1")
if err := database.DeleteSession("delToken"); err != nil {
t.Fatalf("DeleteSession: %v", err)
@@ -307,7 +307,7 @@ func TestDeleteExpiredSessions(t *testing.T) {
}
// Insert a valid session through the normal path.
database.CreateSession(uid, "validToken", "GoTest/1.0", "127.0.0.1")
_, _ = database.CreateSession(uid, "validToken", "GoTest/1.0", "127.0.0.1")
if err := database.DeleteExpiredSessions(); err != nil {
t.Fatalf("DeleteExpiredSessions: %v", err)
@@ -326,7 +326,7 @@ func TestDeleteExpiredSessions(t *testing.T) {
func TestTouchSession(t *testing.T) {
database := newTestDB(t)
uid, _ := database.CreateUser("noah", "hash", 4)
database.CreateSession(uid, "touchToken", "GoTest/1.0", "127.0.0.1")
_, _ = database.CreateSession(uid, "touchToken", "GoTest/1.0", "127.0.0.1")
sess1, _ := database.GetSessionByTokenHash("touchToken")
time.Sleep(2 * time.Millisecond)
@@ -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)
}
@@ -459,7 +459,7 @@ func TestUseInviteAtomic_Revoked(t *testing.T) {
database := newTestDB(t)
uid, _ := database.CreateUser("atomic_user3", "hash", 4)
code, _ := database.CreateInvite(uid, 0, nil)
database.RevokeInvite(code)
_ = database.RevokeInvite(code)
if err := database.UseInviteAtomic(code); err == nil {
t.Error("UseInviteAtomic returned nil error for revoked invite, want error")
@@ -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)}
}()
+1 -1
View File
@@ -21,7 +21,7 @@ func newBackupFileDB(t *testing.T) (*db.DB, string) {
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { database.Close() })
t.Cleanup(func() { _ = database.Close() })
migrFS := fstest.MapFS{
"001_schema.sql": {Data: adminTestSchema},
+1 -1
View File
@@ -16,7 +16,7 @@ func (d *DB) ListChannels() ([]Channel, error) {
if err != nil {
return nil, fmt.Errorf("ListChannels: %w", err)
}
defer rows.Close()
defer rows.Close() //nolint:errcheck
var channels []Channel
for rows.Next() {
+3 -3
View File
@@ -25,7 +25,7 @@ func Open(path string) (*DB, error) {
// Verify the connection is actually usable.
if err := sqlDB.Ping(); err != nil {
sqlDB.Close()
_ = sqlDB.Close()
return nil, fmt.Errorf("pinging sqlite db: %w", err)
}
@@ -37,13 +37,13 @@ func Open(path string) (*DB, error) {
// Enable WAL mode for better concurrent read performance.
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL;"); err != nil {
sqlDB.Close()
_ = sqlDB.Close()
return nil, fmt.Errorf("enabling WAL mode: %w", err)
}
// Enforce foreign key constraints.
if _, err := sqlDB.Exec("PRAGMA foreign_keys=ON;"); err != nil {
sqlDB.Close()
_ = sqlDB.Close()
return nil, fmt.Errorf("enabling foreign keys: %w", err)
}
+8 -8
View File
@@ -21,7 +21,7 @@ func openMemory(t *testing.T) *db.DB {
if err != nil {
t.Fatalf("Open(':memory:') error: %v", err)
}
t.Cleanup(func() { database.Close() })
t.Cleanup(func() { _ = database.Close() })
return database
}
@@ -40,7 +40,7 @@ func TestOpenCreatesFile(t *testing.T) {
if err != nil {
t.Fatalf("Open() error: %v", err)
}
defer database.Close()
defer database.Close() //nolint:errcheck
if _, statErr := os.Stat(dbPath); os.IsNotExist(statErr) {
t.Error("Open() did not create the database file")
@@ -79,7 +79,7 @@ func TestWALModeEnabledOnFile(t *testing.T) {
if err != nil {
t.Fatalf("Open() error: %v", err)
}
defer database.Close()
defer database.Close() //nolint:errcheck
var journalMode string
if err := database.QueryRow("PRAGMA journal_mode;").Scan(&journalMode); err != nil {
@@ -286,7 +286,7 @@ func TestQuery(t *testing.T) {
if err != nil {
t.Fatalf("Query() error: %v", err)
}
defer rows.Close()
defer rows.Close() //nolint:errcheck
var count int
for rows.Next() {
@@ -315,7 +315,7 @@ func TestBegin(t *testing.T) {
_, err = tx.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('tx_key', 'tx_val')")
if err != nil {
tx.Rollback()
_ = tx.Rollback()
t.Fatalf("tx.Exec error: %v", err)
}
@@ -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)
@@ -456,7 +456,7 @@ func TestMigrateWALAndFKOnFile(t *testing.T) {
if err != nil {
t.Fatalf("Open() error: %v", err)
}
defer database.Close()
defer database.Close() //nolint:errcheck
if err := db.Migrate(database); err != nil {
t.Fatalf("Migrate() error: %v", err)
+1 -1
View File
@@ -11,7 +11,7 @@ func (d *DB) ListInvites() ([]*Invite, error) {
if err != nil {
return nil, fmt.Errorf("ListInvites: %w", err)
}
defer rows.Close()
defer rows.Close() //nolint:errcheck
var invites []*Invite
for rows.Next() {
+65 -9
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
)
// CreateMessage inserts a new message and returns the assigned ID.
@@ -61,7 +62,7 @@ func (d *DB) GetMessages(channelID, before int64, limit int) ([]MessageWithUser,
if err != nil {
return nil, fmt.Errorf("GetMessages: %w", err)
}
defer rows.Close()
defer rows.Close() //nolint:errcheck
var msgs []MessageWithUser
for rows.Next() {
@@ -163,7 +164,7 @@ func (d *DB) GetReactions(messageID int64) ([]ReactionCount, error) {
if err != nil {
return nil, fmt.Errorf("GetReactions: %w", err)
}
defer rows.Close()
defer rows.Close() //nolint:errcheck
var counts []ReactionCount
for rows.Next() {
@@ -186,6 +187,13 @@ func (d *DB) GetReactions(messageID int64) ([]ReactionCount, error) {
// When channelID is non-nil the search is scoped to that channel.
// Deleted messages are excluded from results.
func (d *DB) SearchMessages(query string, channelID *int64, limit int) ([]MessageSearchResult, error) {
if query == "" {
return []MessageSearchResult{}, nil
}
if limit < 1 {
return []MessageSearchResult{}, nil
}
var (
rows *sql.Rows
err error
@@ -217,7 +225,7 @@ func (d *DB) SearchMessages(query string, channelID *int64, limit int) ([]Messag
if err != nil {
return nil, fmt.Errorf("SearchMessages: %w", err)
}
defer rows.Close()
defer rows.Close() //nolint:errcheck
var results []MessageSearchResult
for rows.Next() {
@@ -267,7 +275,7 @@ func (d *DB) GetMessagesForAPI(channelID, before int64, limit int, requestingUse
if err != nil {
return nil, fmt.Errorf("GetMessagesForAPI: %w", err)
}
defer rows.Close()
defer rows.Close() //nolint:errcheck
var msgs []MessageAPIResponse
var msgIDs []int64
@@ -326,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(
@@ -351,7 +360,7 @@ func (d *DB) getReactionsBatch(msgIDs []int64, requestingUserID int64) (map[int6
if err != nil {
return nil, fmt.Errorf("getReactionsBatch: %w", err)
}
defer rows.Close()
defer rows.Close() //nolint:errcheck
result := make(map[int64][]ReactionInfo)
for rows.Next() {
@@ -381,6 +390,53 @@ func (d *DB) UpdateReadState(userID, channelID, lastReadMessageID int64) error {
return nil
}
// GetChannelUnreadCounts returns per-channel unread counts and last message IDs
// for a given user. Only text channels with at least one message are included.
func (d *DB) GetChannelUnreadCounts(userID int64) (map[int64]ChannelUnread, error) {
rows, err := d.sqlDB.Query(
`SELECT c.id,
COALESCE(MAX(m.id), 0) AS last_msg_id,
COUNT(CASE WHEN m.id > COALESCE(rs.last_message_id, 0) AND m.deleted = 0 THEN 1 END) AS unread
FROM channels c
LEFT JOIN messages m ON m.channel_id = c.id AND m.deleted = 0
LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = ?
WHERE c.type = 'text'
GROUP BY c.id`,
userID,
)
if err != nil {
return nil, fmt.Errorf("GetChannelUnreadCounts: %w", err)
}
defer rows.Close() //nolint:errcheck
result := make(map[int64]ChannelUnread)
for rows.Next() {
var chID int64
var cu ChannelUnread
if scanErr := rows.Scan(&chID, &cu.LastMessageID, &cu.UnreadCount); scanErr != nil {
return nil, fmt.Errorf("GetChannelUnreadCounts scan: %w", scanErr)
}
result[chID] = cu
}
if rows.Err() != nil {
return nil, fmt.Errorf("GetChannelUnreadCounts rows: %w", rows.Err())
}
return result, nil
}
// GetLatestMessageID returns the highest message ID in a channel, or 0 if empty.
func (d *DB) GetLatestMessageID(channelID int64) (int64, error) {
var id int64
err := d.sqlDB.QueryRow(
`SELECT COALESCE(MAX(id), 0) FROM messages WHERE channel_id = ? AND deleted = 0`,
channelID,
).Scan(&id)
if err != nil {
return 0, fmt.Errorf("GetLatestMessageID: %w", err)
}
return id, nil
}
// ─── helpers ──────────────────────────────────────────────────────────────────
// scanMessage scans a single message from *sql.Row.
+4 -4
View File
@@ -136,7 +136,7 @@ func TestGetMessages_ReturnsMessages(t *testing.T) {
userID := seedUser(t, database, "dave")
chID := seedChannel(t, database, "ch")
for i := 0; i < 3; i++ {
for i := range 3 {
_, err := database.CreateMessage(chID, userID, "msg", nil)
if err != nil {
t.Fatalf("CreateMessage %d: %v", i, err)
@@ -157,7 +157,7 @@ func TestGetMessages_LimitRespected(t *testing.T) {
userID := seedUser(t, database, "eve")
chID := seedChannel(t, database, "ch")
for i := 0; i < 10; i++ {
for range 10 {
_, _ = database.CreateMessage(chID, userID, "msg", nil)
}
@@ -173,7 +173,7 @@ func TestGetMessages_BeforePagination(t *testing.T) {
chID := seedChannel(t, database, "ch")
var ids []int64
for i := 0; i < 5; i++ {
for range 5 {
id, _ := database.CreateMessage(chID, userID, "msg", nil)
ids = append(ids, id)
}
@@ -479,7 +479,7 @@ func TestSearchMessages_LimitRespected(t *testing.T) {
userID := seedUser(t, database, "carl")
chID := seedChannel(t, database, "ch")
for i := 0; i < 5; i++ {
for range 5 {
_, _ = database.CreateMessage(chID, userID, "searchable keyword content", nil)
}
+7
View File
@@ -157,6 +157,12 @@ type VoiceState struct {
Screenshare bool `json:"screenshare"`
}
// ChannelUnread holds per-user unread data for a single channel.
type ChannelUnread struct {
LastMessageID int64 `json:"last_message_id"`
UnreadCount int `json:"unread_count"`
}
// ServerStats contains aggregate counts for the admin dashboard.
type ServerStats struct {
UserCount int64 `json:"user_count"`
@@ -164,6 +170,7 @@ type ServerStats struct {
ChannelCount int64 `json:"channel_count"`
InviteCount int64 `json:"invite_count"`
DBSizeBytes int64 `json:"db_size_bytes"`
OnlineCount int `json:"online_count"`
}
// UserWithRole extends User with the name of the user's role.
+5 -5
View File
@@ -115,9 +115,9 @@ func TestListInvites_Multiple(t *testing.T) {
database := newTestDB(t)
uid, _ := database.CreateUser("listowner", "hash", 4)
database.CreateInvite(uid, 1, nil)
database.CreateInvite(uid, 5, nil)
database.CreateInvite(uid, 0, nil)
_, _ = database.CreateInvite(uid, 1, nil)
_, _ = database.CreateInvite(uid, 5, nil)
_, _ = database.CreateInvite(uid, 0, nil)
invites, err := database.ListInvites()
if err != nil {
@@ -133,8 +133,8 @@ func TestListInvites_IncludesRevokedInvites(t *testing.T) {
uid, _ := database.CreateUser("revokelistowner", "hash", 4)
code, _ := database.CreateInvite(uid, 1, nil)
database.RevokeInvite(code)
database.CreateInvite(uid, 0, nil) // active
_ = database.RevokeInvite(code)
_, _ = database.CreateInvite(uid, 0, nil) // active
invites, err := database.ListInvites()
if err != nil {
+1 -1
View File
@@ -33,7 +33,7 @@ func (d *DB) ListRoles() ([]*Role, error) {
if err != nil {
return nil, fmt.Errorf("ListRoles: %w", err)
}
defer rows.Close()
defer rows.Close() //nolint:errcheck
var roles []*Role
for rows.Next() {
+1 -1
View File
@@ -70,7 +70,7 @@ func (d *DB) GetChannelVoiceStates(channelID int64) ([]VoiceState, error) {
if err != nil {
return nil, fmt.Errorf("GetChannelVoiceStates: %w", err)
}
defer rows.Close()
defer rows.Close() //nolint:errcheck
var states []VoiceState
for rows.Next() {
+4 -19
View File
@@ -7,21 +7,6 @@ import (
"github.com/owncord/server/db"
)
// voiceTestSchema adds the voice_states table on top of the base schema.
var voiceTestSchema = append(testSchema, []byte(`
CREATE TABLE IF NOT EXISTS voice_states (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
muted INTEGER NOT NULL DEFAULT 0,
deafened INTEGER NOT NULL DEFAULT 0,
speaking INTEGER NOT NULL DEFAULT 0,
camera INTEGER NOT NULL DEFAULT 0,
screenshare INTEGER NOT NULL DEFAULT 0,
joined_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id);
`)...)
var channelSchema = []byte(`
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -47,7 +32,7 @@ func newVoiceTestDB(t *testing.T) *db.DB {
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { database.Close() })
t.Cleanup(func() { _ = database.Close() })
migrFS := fstest.MapFS{
"001_schema.sql": {Data: testSchema},
@@ -659,8 +644,8 @@ func TestVoice_GetVoiceState_IncludesCameraAndScreenshare(t *testing.T) {
}
// Enable both.
database.UpdateVoiceCamera(userID, true)
database.UpdateVoiceScreenshare(userID, true)
_ = database.UpdateVoiceCamera(userID, true)
_ = database.UpdateVoiceScreenshare(userID, true)
state, _ = database.GetVoiceState(userID)
if state == nil {
@@ -684,7 +669,7 @@ func TestVoice_GetChannelVoiceStates_IncludesCameraAndScreenshare(t *testing.T)
if err := database.JoinVoiceChannel(userID, chanID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
database.UpdateVoiceCamera(userID, true)
_ = database.UpdateVoiceCamera(userID, true)
states, err := database.GetChannelVoiceStates(chanID)
if err != nil {
+4 -4
View File
@@ -33,7 +33,7 @@ func main() {
}))
if err := run(log); err != nil {
fmt.Fprintf(os.Stderr, "\n [ERROR] %v\n\n", err)
_, _ = fmt.Fprintf(os.Stderr, "\n [ERROR] %v\n\n", err)
log.Error("server exited with error", "error", err)
os.Exit(1)
}
@@ -69,7 +69,7 @@ func run(log *slog.Logger) error {
if err != nil {
return fmt.Errorf("opening database: %w", err)
}
defer database.Close()
defer database.Close() //nolint:errcheck
if err := db.Migrate(database); err != nil {
return fmt.Errorf("running migrations: %w", err)
@@ -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.
@@ -277,7 +277,7 @@ func getOutboundIP() string {
if err != nil {
return "localhost"
}
defer conn.Close()
defer conn.Close() //nolint:errcheck
addr := conn.LocalAddr().(*net.UDPAddr)
return addr.IP.String()
}
+3 -3
View File
@@ -123,7 +123,7 @@ func (s *Storage) Save(uuid string, r io.Reader) error {
if err != nil {
return fmt.Errorf("creating file %s: %w", dst, err)
}
defer f.Close()
defer f.Close() //nolint:errcheck
// Reconstruct the full stream: header bytes we already read + remainder.
maxBytes := int64(s.maxSizeMB) * 1024 * 1024
@@ -134,8 +134,8 @@ func (s *Storage) Save(uuid string, r io.Reader) error {
}
if written > maxBytes {
// File exceeds limit — remove the partial write and reject.
f.Close()
os.Remove(dst)
_ = f.Close()
_ = os.Remove(dst)
return fmt.Errorf("file exceeds maximum size of %d MB", s.maxSizeMB)
}
return nil
+2 -2
View File
@@ -171,7 +171,7 @@ func TestOpen_ValidUUID(t *testing.T) {
if err != nil {
t.Fatalf("Open: %v", err)
}
defer f.Close()
defer f.Close() //nolint:errcheck
got, err := io.ReadAll(f)
if err != nil {
@@ -235,7 +235,7 @@ func TestSave_RoundTrip(t *testing.T) {
if err != nil {
t.Fatalf("Open: %v", err)
}
defer f.Close()
defer f.Close() //nolint:errcheck
got, _ := io.ReadAll(f)
if !bytes.Equal(got, payload) {
+5 -5
View File
@@ -124,7 +124,7 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateInfo, error) {
if err != nil {
return UpdateInfo{}, fmt.Errorf("fetching latest release: %w", err)
}
defer resp.Body.Close()
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return UpdateInfo{}, fmt.Errorf("github API returned status %d", resp.StatusCode)
@@ -224,7 +224,7 @@ func (u *Updater) VerifyChecksum(filePath, expectedHash string) error {
if err != nil {
return fmt.Errorf("opening file for checksum: %w", err)
}
defer f.Close()
defer f.Close() //nolint:errcheck
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
@@ -271,7 +271,7 @@ func (u *Updater) fetchBody(ctx context.Context, url string) ([]byte, error) {
if err != nil {
return nil, err
}
defer resp.Body.Close()
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, url)
@@ -294,7 +294,7 @@ func (u *Updater) downloadFile(ctx context.Context, url, destPath string) error
if err != nil {
return err
}
defer resp.Body.Close()
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d downloading %s", resp.StatusCode, url)
@@ -304,7 +304,7 @@ func (u *Updater) downloadFile(ctx context.Context, url, destPath string) error
if err != nil {
return fmt.Errorf("creating destination file: %w", err)
}
defer f.Close()
defer f.Close() //nolint:errcheck
if _, err := io.Copy(f, resp.Body); err != nil {
return fmt.Errorf("writing downloaded file: %w", err)
+1 -1
View File
@@ -52,7 +52,7 @@ func newTestServer(t *testing.T, release ghRelease, statusCode int) *httptest.Se
t.Fatalf("encoding release: %v", err)
}
} else {
fmt.Fprint(w, `{"message":"Internal Server Error"}`)
_, _ = fmt.Fprint(w, `{"message":"Internal Server Error"}`)
}
})
return httptest.NewServer(mux)
+6 -6
View File
@@ -16,9 +16,9 @@ import (
// channelFocusMsg constructs a raw channel_focus WebSocket envelope.
func channelFocusMsg(channelID int64) []byte {
raw, _ := json.Marshal(map[string]interface{}{
raw, _ := json.Marshal(map[string]any{
"type": "channel_focus",
"payload": map[string]interface{}{
"payload": map[string]any{
"channel_id": channelID,
},
})
@@ -56,12 +56,12 @@ func TestChannelFocus_AllowedByDefault(t *testing.T) {
// Should NOT receive a FORBIDDEN error.
msgs := drainChan(send)
for _, m := range msgs {
var env map[string]interface{}
var env map[string]any
if err := json.Unmarshal(m, &env); err != nil {
continue
}
if env["type"] == "error" {
if payload, ok := env["payload"].(map[string]interface{}); ok {
if payload, ok := env["payload"].(map[string]any); ok {
if payload["code"] == "FORBIDDEN" {
t.Error("member was incorrectly denied channel_focus on accessible channel")
}
@@ -115,12 +115,12 @@ func TestChannelFocus_AdminBypassesDeny(t *testing.T) {
// Should NOT receive a FORBIDDEN error.
msgs := drainChan(send)
for _, m := range msgs {
var env map[string]interface{}
var env map[string]any
if err := json.Unmarshal(m, &env); err != nil {
continue
}
if env["type"] == "error" {
if payload, ok := env["payload"].(map[string]interface{}); ok {
if payload, ok := env["payload"].(map[string]any); ok {
if payload["code"] == "FORBIDDEN" {
t.Error("admin was incorrectly denied channel_focus")
}
+1
View File
@@ -25,6 +25,7 @@ type Client struct {
channelID int64 // currently viewed channel for channel-scoped broadcasts
voiceChID int64 // voice channel the user is in (0 = not in voice); guarded by voiceMu
pc *webrtc.PeerConnection // SFU peer connection; nil when not in voice; guarded by voiceMu
roleName string // cached role name for chat_message broadcasts
tokenHash string // SHA-256 hex of the session token; used for periodic revalidation
msgCount int // count of messages processed; resets after session check
sendClosed bool // true after the send channel has been closed
+20 -22
View File
@@ -55,7 +55,7 @@ func openHandlerDB(t *testing.T) *db.DB {
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { database.Close() })
t.Cleanup(func() { _ = database.Close() })
migrFS := fstest.MapFS{
"001_schema.sql": {Data: handlerTestSchema},
}
@@ -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"}}`))
}
@@ -247,9 +247,7 @@ func TestSessionExpiry_ExpiredSessionClosesConnection(t *testing.T) {
// which manifests as a zero-value receive without blocking.
select {
case _, open := <-send:
if open {
// A message was delivered instead; drain and check again.
}
_ = open
// closed channel or a message — either way connection was acted on.
default:
// Send channel still open and empty — check hub registration instead.
@@ -277,7 +275,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 +300,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 +308,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 +376,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 +412,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 +463,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 +566,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
}
+15 -5
View File
@@ -10,8 +10,7 @@ import (
// broadcastMsg is an internal message queued for delivery.
type broadcastMsg struct {
channelID int64 // 0 = send to all connected clients
senderID int64 // reserved for future exclude-sender logic
channelID int64 // 0 = send to all connected clients
msg []byte
}
@@ -26,6 +25,7 @@ type Hub struct {
register chan *Client
unregister chan *Client
stop chan struct{}
stopOnce sync.Once
sfu *SFU
voiceRooms map[int64]*VoiceRoom
voiceRoomsMu sync.RWMutex
@@ -149,9 +149,9 @@ func (h *Hub) Run() {
}
}
// Stop signals Run to exit.
// Stop signals Run to exit. Safe to call multiple times.
func (h *Hub) Stop() {
close(h.stop)
h.stopOnce.Do(func() { close(h.stop) })
}
// GracefulStop closes all PeerConnections, voice rooms, and then stops the hub.
@@ -166,7 +166,7 @@ func (h *Hub) GracefulStop() {
h.mu.RUnlock()
h.CloseAllVoiceRooms()
close(h.stop)
h.stopOnce.Do(func() { close(h.stop) })
}
// CleanupVoiceForChannel removes the voice room for the given channel and
@@ -246,6 +246,16 @@ func (h *Hub) BroadcastChannelDelete(channelID int64) {
h.BroadcastToAll(buildChannelDelete(channelID))
}
// BroadcastMemberBan sends a member_ban message to all connected clients.
func (h *Hub) BroadcastMemberBan(userID int64) {
h.BroadcastToAll(buildMemberBan(userID))
}
// BroadcastMemberUpdate sends a member_update message to all connected clients.
func (h *Hub) BroadcastMemberUpdate(userID int64, roleName string) {
h.BroadcastToAll(buildMemberUpdate(userID, roleName))
}
// SendToUser delivers msg directly to the client identified by userID.
// Returns true if the client was found and the message was queued.
func (h *Hub) SendToUser(userID int64, msg []byte) bool {
+10 -10
View File
@@ -21,7 +21,7 @@ func openTestDB(t *testing.T) *db.DB {
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { database.Close() })
t.Cleanup(func() { _ = database.Close() })
migrFS := fstest.MapFS{
"001_schema.sql": {Data: hubTestSchema},
@@ -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
+37 -13
View File
@@ -35,6 +35,18 @@ func buildErrorMsg(code, message string) []byte {
})
}
// buildRateLimitError produces a RATE_LIMITED error with retry_after per PROTOCOL.md.
func buildRateLimitError(message string, retryAfterSeconds float64) []byte {
return buildJSON(map[string]any{
"type": "error",
"payload": map[string]any{
"code": "RATE_LIMITED",
"message": message,
"retry_after": retryAfterSeconds,
},
})
}
// buildAuthError produces an auth_error envelope per PROTOCOL.md.
// The client treats this type as non-recoverable and stops reconnecting.
func buildAuthError(message string) []byte {
@@ -77,7 +89,8 @@ func buildMemberJoin(user *db.User, roleName string) []byte {
}
// buildChatMessage constructs a chat_message broadcast envelope.
func buildChatMessage(msgID, channelID, userID int64, username string, avatar *string, content string, timestamp string, replyTo *int64, attachments []map[string]any) []byte {
// Includes role in user object and empty reactions array for consistency with REST API.
func buildChatMessage(msgID, channelID, userID int64, username string, avatar *string, roleName string, content string, timestamp string, replyTo *int64, attachments []map[string]any) []byte {
var avatarVal any
if avatar != nil {
avatarVal = *avatar
@@ -94,11 +107,34 @@ func buildChatMessage(msgID, channelID, userID int64, username string, avatar *s
"id": userID,
"username": username,
"avatar": avatarVal,
"role": roleName,
},
"content": content,
"reply_to": replyTo,
"timestamp": timestamp,
"attachments": attachments,
"reactions": []any{},
},
})
}
// buildMemberUpdate constructs a member_update broadcast per PROTOCOL.md.
func buildMemberUpdate(userID int64, roleName string) []byte {
return buildJSON(map[string]any{
"type": "member_update",
"payload": map[string]any{
"user_id": userID,
"role": roleName,
},
})
}
// buildMemberBan constructs a member_ban broadcast per PROTOCOL.md.
func buildMemberBan(userID int64) []byte {
return buildJSON(map[string]any{
"type": "member_ban",
"payload": map[string]any{
"user_id": userID,
},
})
}
@@ -232,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{
+1 -3
View File
@@ -3,8 +3,6 @@ package ws_test
import (
"testing"
"nhooyr.io/websocket"
"github.com/owncord/server/ws"
)
@@ -69,5 +67,5 @@ func TestOriginAcceptOptions_MixedWithWildcard(t *testing.T) {
// TestOriginAcceptOptions_ReturnsAcceptOptions ensures the return type is the
// correct websocket.AcceptOptions value (compile-time check via assignment).
func TestOriginAcceptOptions_ReturnsAcceptOptions(t *testing.T) {
var _ *websocket.AcceptOptions = ws.OriginAcceptOptions([]string{"https://example.com"})
_ = ws.OriginAcceptOptions([]string{"https://example.com"})
}
+1 -1
View File
@@ -47,7 +47,7 @@ func NewSFU(cfg *config.VoiceConfig) (*SFU, error) {
}
var se webrtc.SettingEngine
se.SetEphemeralUDPPortRange(uint16(cfg.MediaPortMin), uint16(cfg.MediaPortMax))
_ = se.SetEphemeralUDPPortRange(uint16(cfg.MediaPortMin), uint16(cfg.MediaPortMax))
if cfg.ExternalIP != "" {
if err := se.SetICEAddressRewriteRules(webrtc.ICEAddressRewriteRule{
+2 -2
View File
@@ -61,7 +61,7 @@ func (d *SpeakerDetector) UpdateLevel(userID int64, level uint8) {
// Recalculate average over collected samples.
var sum int
for i := 0; i < sl.count; i++ {
for i := range sl.count {
sum += int(sl.levels[i])
}
sl.average = float64(sum) / float64(sl.count)
@@ -102,7 +102,7 @@ func (d *SpeakerDetector) TopSpeakers() []int64 {
}
result := make([]int64, n)
for i := 0; i < n; i++ {
for i := range n {
result[i] = candidates[i].userID
}
return result
+14 -20
View File
@@ -1,6 +1,7 @@
package ws_test
import (
"slices"
"testing"
"time"
@@ -24,7 +25,7 @@ func TestSpeakerDetector_UpdateLevel(t *testing.T) {
sd := ws.NewSpeakerDetector(3)
// Feed several level samples for a single user.
for i := 0; i < 5; i++ {
for range 5 {
sd.UpdateLevel(1, 30) // relatively loud
}
@@ -58,7 +59,7 @@ func TestSpeakerDetector_TopSpeakers_RankedByLoudest(t *testing.T) {
{50, 100},
}
for _, u := range users {
for i := 0; i < 5; i++ {
for range 5 {
sd.UpdateLevel(u.id, u.level)
}
}
@@ -81,11 +82,11 @@ func TestSpeakerDetector_TopSpeakers_SilentExcluded(t *testing.T) {
sd := ws.NewSpeakerDetector(3)
// User 1: loud
for i := 0; i < 5; i++ {
for range 5 {
sd.UpdateLevel(1, 20)
}
// User 2: completely silent (127 = digital silence in RFC 6464)
for i := 0; i < 5; i++ {
for range 5 {
sd.UpdateLevel(2, 127)
}
@@ -103,25 +104,18 @@ func TestSpeakerDetector_TopSpeakers_HoldoffKeepsSpeaker(t *testing.T) {
sd := ws.NewSpeakerDetectorWithHoldoff(3, 50*time.Millisecond)
// User 1 speaks loudly.
for i := 0; i < 5; i++ {
for range 5 {
sd.UpdateLevel(1, 20)
}
// User 1 goes silent.
for i := 0; i < 10; i++ {
for range 10 {
sd.UpdateLevel(1, 127)
}
// Immediately check — holdoff should keep user 1 in top speakers.
top := sd.TopSpeakers()
found := false
for _, id := range top {
if id == int64(1) {
found = true
break
}
}
if !found {
if !slices.Contains(top, int64(1)) {
t.Fatalf("expected user 1 to remain in top speakers during holdoff, got %v", top)
}
}
@@ -131,12 +125,12 @@ func TestSpeakerDetector_TopSpeakers_HoldoffExpires(t *testing.T) {
sd := ws.NewSpeakerDetectorWithHoldoff(3, 50*time.Millisecond)
// User 1 speaks loudly.
for i := 0; i < 5; i++ {
for range 5 {
sd.UpdateLevel(1, 20)
}
// User 1 goes silent — fill ring buffer with silence.
for i := 0; i < 10; i++ {
for range 10 {
sd.UpdateLevel(1, 127)
}
@@ -155,7 +149,7 @@ func TestSpeakerDetector_RemoveSpeaker(t *testing.T) {
t.Parallel()
sd := ws.NewSpeakerDetector(3)
for i := 0; i < 5; i++ {
for range 5 {
sd.UpdateLevel(1, 20)
sd.UpdateLevel(2, 30)
}
@@ -182,7 +176,7 @@ func TestParseAudioLevel_Valid(t *testing.T) {
// RFC 5285 one-byte header format: 4-bit ID | 4-bit length-1
// For extensionID=1, length=1 byte: header = 0x10
extensionID := uint8(1)
buf := []byte{(extensionID << 4) | 0x00, extByte} // ID=1, L=0 (meaning 1 byte), then the data byte
buf := []byte{extensionID << 4, extByte} // ID=1, L=0 (meaning 1 byte), then the data byte
level, voice, ok := ws.ParseAudioLevel(buf, extensionID)
if !ok {
@@ -197,7 +191,7 @@ func TestParseAudioLevel_Valid(t *testing.T) {
// Test with voice=false, level=10 → binary: 0_0001010 → 0x0A
extByte2 := byte(10) // voice=0, level=10
buf2 := []byte{(extensionID << 4) | 0x00, extByte2}
buf2 := []byte{extensionID << 4, extByte2}
level2, voice2, ok2 := ws.ParseAudioLevel(buf2, extensionID)
if !ok2 {
@@ -226,7 +220,7 @@ func TestParseAudioLevel_NotFound(t *testing.T) {
}
// Wrong extension ID — buffer has ID=2 but we ask for ID=1.
buf := []byte{(2 << 4) | 0x00, 0x80}
buf := []byte{2 << 4, 0x80}
_, _, ok = ws.ParseAudioLevel(buf, 1)
if ok {
t.Error("expected ok=false for wrong extension ID")
+6 -6
View File
@@ -24,7 +24,7 @@ func TestVoiceRoom_UpdateSpeakerLevel(t *testing.T) {
_ = room.AddParticipant(30)
// User 10 is loudest (lowest dBov = 10), user 30 quietest (90).
for i := 0; i < 5; i++ {
for range 5 {
room.UpdateSpeakerLevel(10, 10)
room.UpdateSpeakerLevel(20, 50)
room.UpdateSpeakerLevel(30, 90)
@@ -62,7 +62,7 @@ func TestVoiceRoom_RemoveParticipant_RemovesFromDetector(t *testing.T) {
_ = room.AddParticipant(200)
// Feed audio so both appear in top speakers.
for i := 0; i < 5; i++ {
for range 5 {
room.UpdateSpeakerLevel(100, 20)
room.UpdateSpeakerLevel(200, 30)
}
@@ -191,7 +191,7 @@ func TestSpeakerBroadcast_Integration(t *testing.T) {
time.Sleep(20 * time.Millisecond)
// Feed audio levels into the room — make user 1 a speaker.
for i := 0; i < 5; i++ {
for range 5 {
room.UpdateSpeakerLevel(1, 20) // level=20 (dBov), well below silence threshold
}
@@ -251,7 +251,7 @@ func TestSpeakerBroadcast_NoBroadcastWhenNoChange(t *testing.T) {
time.Sleep(20 * time.Millisecond)
// Feed levels to produce a stable speaker list.
for i := 0; i < 5; i++ {
for range 5 {
room.UpdateSpeakerLevel(2, 20)
}
@@ -312,7 +312,7 @@ func TestSpeakerBroadcast_RoomCleanup(t *testing.T) {
time.Sleep(20 * time.Millisecond)
// Feed levels so the ticker broadcasts at least once.
for i := 0; i < 5; i++ {
for range 5 {
room.UpdateSpeakerLevel(3, 20)
}
@@ -338,7 +338,7 @@ func TestSpeakerBroadcast_RoomCleanup(t *testing.T) {
// Re-create the room and feed a new speaker — the ticker should broadcast
// again because prevSpeakers[chanID] was deleted when the room was removed.
newRoom := hub.GetOrCreateVoiceRoom(chanID, cfg)
for i := 0; i < 5; i++ {
for range 5 {
newRoom.UpdateSpeakerLevel(3, 20)
}
+31 -31
View File
@@ -33,7 +33,7 @@ func openVoiceTestDB(t *testing.T) *db.DB {
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { database.Close() })
t.Cleanup(func() { _ = database.Close() })
migrFS := fstest.MapFS{
"001_schema.sql": {Data: voiceSchema},
@@ -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)
+17 -6
View File
@@ -2,24 +2,35 @@
## Step 1: Download
Get the latest release from the GitHub Releases page. Download `chatserver.exe` and `OwnCord.Client.exe`.
Get the latest release from GitHub Releases.
Download `chatserver.exe` and the `OwnCord`
installer.
## Step 2: Run the Server
Run `chatserver.exe`. On first run it generates `config.yaml` with sensible defaults and a self-signed TLS certificate. The server starts on `https://0.0.0.0:8443`.
Run `chatserver.exe`. On first run it generates
`config.yaml` and a self-signed TLS certificate.
The server starts on `https://0.0.0.0:8443`.
## Step 3: Admin Setup
Open `https://localhost:8443/admin` in a browser. The first registered user with the Owner role can manage the server.
Open `https://localhost:8443/admin` in a browser.
The first registered user with the Owner role can
manage the server.
## Step 4: Create Invites
In the admin panel, go to invite management and generate invite codes for your friends.
In the admin panel, go to invite management and
generate invite codes for your friends.
## Step 5: Connect Clients
Friends run `OwnCord.Client.exe`, enter your server address (IP or domain + port 8443), and redeem their invite code to register.
Friends install OwnCord, enter your server address
(IP or domain + port 8443), and redeem their invite
code to register.
## Networking
If friends are outside your local network, see the [Port Forwarding Guide](port-forwarding.md) or use [Tailscale](tailscale.md) for zero-config networking.
If friends are outside your local network, see the
[Port Forwarding Guide](port-forwarding.md) or use
[Tailscale](tailscale.md) for zero-config networking.