mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Merge pull request #2 from J3vb/tauri-migration
feat: server enhancements, client test selectors, and UI polish
This commit is contained in:
@@ -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
@@ -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,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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
@@ -17,7 +17,15 @@ export default defineConfig({
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/main.ts", "src/**/*.d.ts"],
|
||||
exclude: [
|
||||
"src/main.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/lib/window-state.ts",
|
||||
"src/lib/credentials.ts",
|
||||
"src/lib/audio.ts",
|
||||
"src/lib/vad.ts",
|
||||
"src/lib/webrtc.ts",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 80,
|
||||
branches: 80,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
settings:
|
||||
staticcheck:
|
||||
checks:
|
||||
- "all"
|
||||
- "-SA1019" # suppress deprecated usage warnings (websocket library migration tracked separately)
|
||||
@@ -51,7 +51,7 @@ func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.
|
||||
// global CSP (default-src 'self') to allow them.
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
|
||||
w.Write(indexHTML)
|
||||
_, _ = w.Write(indexHTML)
|
||||
})
|
||||
r.Handle("/*", http.FileServer(http.FS(staticFS)))
|
||||
|
||||
|
||||
+2
-1
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -31,7 +31,7 @@ func handleCheckUpdate(u *updater.Updater) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// handleApplyUpdate downloads and applies a server update.
|
||||
func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, currentVersion string) http.Handler {
|
||||
func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if u == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "UPDATE_UNAVAILABLE", "update checking is not configured")
|
||||
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
func TestAdminAPI_CheckUpdate_OK(t *testing.T) {
|
||||
// Mock GitHub API
|
||||
mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"tag_name": "v2.0.0",
|
||||
"body": "New release",
|
||||
"html_url": "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0",
|
||||
"assets": []map[string]interface{}{
|
||||
"assets": []map[string]any{
|
||||
{"name": "chatserver.exe", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver.exe"},
|
||||
{"name": "checksums.sha256", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/checksums.sha256"},
|
||||
},
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestContract_Messages_HasRequiredFields(t *testing.T) {
|
||||
}
|
||||
|
||||
// Parse the first message and verify all API.md fields are present.
|
||||
var msg map[string]interface{}
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(resp.Messages[0], &msg); err != nil {
|
||||
t.Fatalf("decode message: %v", err)
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func TestContract_Messages_HasRequiredFields(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify 'user' is an object with id, username.
|
||||
userObj, ok := msg["user"].(map[string]interface{})
|
||||
userObj, ok := msg["user"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("'user' is not an object")
|
||||
}
|
||||
@@ -69,12 +69,12 @@ func TestContract_Messages_HasRequiredFields(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify 'attachments' is an array (even if empty).
|
||||
if _, ok := msg["attachments"].([]interface{}); !ok {
|
||||
if _, ok := msg["attachments"].([]any); !ok {
|
||||
t.Error("'attachments' is not an array")
|
||||
}
|
||||
|
||||
// Verify 'reactions' is an array (even if empty).
|
||||
if _, ok := msg["reactions"].([]interface{}); !ok {
|
||||
if _, ok := msg["reactions"].([]any); !ok {
|
||||
t.Error("'reactions' is not an array")
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func TestContract_Search_HasRequiredFields(t *testing.T) {
|
||||
t.Fatal("expected at least 1 search result")
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(resp.Results[0], &result); err != nil {
|
||||
t.Fatalf("decode result: %v", err)
|
||||
}
|
||||
@@ -169,7 +169,7 @@ func TestContract_Search_HasRequiredFields(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify 'user' is an object with id and username.
|
||||
userObj, ok := result["user"].(map[string]interface{})
|
||||
userObj, ok := result["user"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("search result 'user' is not an object")
|
||||
}
|
||||
|
||||
@@ -21,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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)}
|
||||
}()
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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{
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -61,7 +61,7 @@ func (d *SpeakerDetector) UpdateLevel(userID int64, level uint8) {
|
||||
|
||||
// Recalculate average over collected samples.
|
||||
var sum int
|
||||
for i := 0; i < sl.count; i++ {
|
||||
for i := range sl.count {
|
||||
sum += int(sl.levels[i])
|
||||
}
|
||||
sl.average = float64(sum) / float64(sl.count)
|
||||
@@ -102,7 +102,7 @@ func (d *SpeakerDetector) TopSpeakers() []int64 {
|
||||
}
|
||||
|
||||
result := make([]int64, n)
|
||||
for i := 0; i < n; i++ {
|
||||
for i := range n {
|
||||
result[i] = candidates[i].userID
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ws_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -24,7 +25,7 @@ func TestSpeakerDetector_UpdateLevel(t *testing.T) {
|
||||
sd := ws.NewSpeakerDetector(3)
|
||||
|
||||
// Feed several level samples for a single user.
|
||||
for i := 0; i < 5; i++ {
|
||||
for range 5 {
|
||||
sd.UpdateLevel(1, 30) // relatively loud
|
||||
}
|
||||
|
||||
@@ -58,7 +59,7 @@ func TestSpeakerDetector_TopSpeakers_RankedByLoudest(t *testing.T) {
|
||||
{50, 100},
|
||||
}
|
||||
for _, u := range users {
|
||||
for i := 0; i < 5; i++ {
|
||||
for range 5 {
|
||||
sd.UpdateLevel(u.id, u.level)
|
||||
}
|
||||
}
|
||||
@@ -81,11 +82,11 @@ func TestSpeakerDetector_TopSpeakers_SilentExcluded(t *testing.T) {
|
||||
sd := ws.NewSpeakerDetector(3)
|
||||
|
||||
// User 1: loud
|
||||
for i := 0; i < 5; i++ {
|
||||
for range 5 {
|
||||
sd.UpdateLevel(1, 20)
|
||||
}
|
||||
// User 2: completely silent (127 = digital silence in RFC 6464)
|
||||
for i := 0; i < 5; i++ {
|
||||
for range 5 {
|
||||
sd.UpdateLevel(2, 127)
|
||||
}
|
||||
|
||||
@@ -103,25 +104,18 @@ func TestSpeakerDetector_TopSpeakers_HoldoffKeepsSpeaker(t *testing.T) {
|
||||
sd := ws.NewSpeakerDetectorWithHoldoff(3, 50*time.Millisecond)
|
||||
|
||||
// User 1 speaks loudly.
|
||||
for i := 0; i < 5; i++ {
|
||||
for range 5 {
|
||||
sd.UpdateLevel(1, 20)
|
||||
}
|
||||
|
||||
// User 1 goes silent.
|
||||
for i := 0; i < 10; i++ {
|
||||
for range 10 {
|
||||
sd.UpdateLevel(1, 127)
|
||||
}
|
||||
|
||||
// Immediately check — holdoff should keep user 1 in top speakers.
|
||||
top := sd.TopSpeakers()
|
||||
found := false
|
||||
for _, id := range top {
|
||||
if id == int64(1) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if !slices.Contains(top, int64(1)) {
|
||||
t.Fatalf("expected user 1 to remain in top speakers during holdoff, got %v", top)
|
||||
}
|
||||
}
|
||||
@@ -131,12 +125,12 @@ func TestSpeakerDetector_TopSpeakers_HoldoffExpires(t *testing.T) {
|
||||
sd := ws.NewSpeakerDetectorWithHoldoff(3, 50*time.Millisecond)
|
||||
|
||||
// User 1 speaks loudly.
|
||||
for i := 0; i < 5; i++ {
|
||||
for range 5 {
|
||||
sd.UpdateLevel(1, 20)
|
||||
}
|
||||
|
||||
// User 1 goes silent — fill ring buffer with silence.
|
||||
for i := 0; i < 10; i++ {
|
||||
for range 10 {
|
||||
sd.UpdateLevel(1, 127)
|
||||
}
|
||||
|
||||
@@ -155,7 +149,7 @@ func TestSpeakerDetector_RemoveSpeaker(t *testing.T) {
|
||||
t.Parallel()
|
||||
sd := ws.NewSpeakerDetector(3)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
for range 5 {
|
||||
sd.UpdateLevel(1, 20)
|
||||
sd.UpdateLevel(2, 30)
|
||||
}
|
||||
@@ -182,7 +176,7 @@ func TestParseAudioLevel_Valid(t *testing.T) {
|
||||
// RFC 5285 one-byte header format: 4-bit ID | 4-bit length-1
|
||||
// For extensionID=1, length=1 byte: header = 0x10
|
||||
extensionID := uint8(1)
|
||||
buf := []byte{(extensionID << 4) | 0x00, extByte} // ID=1, L=0 (meaning 1 byte), then the data byte
|
||||
buf := []byte{extensionID << 4, extByte} // ID=1, L=0 (meaning 1 byte), then the data byte
|
||||
|
||||
level, voice, ok := ws.ParseAudioLevel(buf, extensionID)
|
||||
if !ok {
|
||||
@@ -197,7 +191,7 @@ func TestParseAudioLevel_Valid(t *testing.T) {
|
||||
|
||||
// Test with voice=false, level=10 → binary: 0_0001010 → 0x0A
|
||||
extByte2 := byte(10) // voice=0, level=10
|
||||
buf2 := []byte{(extensionID << 4) | 0x00, extByte2}
|
||||
buf2 := []byte{extensionID << 4, extByte2}
|
||||
|
||||
level2, voice2, ok2 := ws.ParseAudioLevel(buf2, extensionID)
|
||||
if !ok2 {
|
||||
@@ -226,7 +220,7 @@ func TestParseAudioLevel_NotFound(t *testing.T) {
|
||||
}
|
||||
|
||||
// Wrong extension ID — buffer has ID=2 but we ask for ID=1.
|
||||
buf := []byte{(2 << 4) | 0x00, 0x80}
|
||||
buf := []byte{2 << 4, 0x80}
|
||||
_, _, ok = ws.ParseAudioLevel(buf, 1)
|
||||
if ok {
|
||||
t.Error("expected ok=false for wrong extension ID")
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestVoiceRoom_UpdateSpeakerLevel(t *testing.T) {
|
||||
_ = room.AddParticipant(30)
|
||||
|
||||
// User 10 is loudest (lowest dBov = 10), user 30 quietest (90).
|
||||
for i := 0; i < 5; i++ {
|
||||
for range 5 {
|
||||
room.UpdateSpeakerLevel(10, 10)
|
||||
room.UpdateSpeakerLevel(20, 50)
|
||||
room.UpdateSpeakerLevel(30, 90)
|
||||
@@ -62,7 +62,7 @@ func TestVoiceRoom_RemoveParticipant_RemovesFromDetector(t *testing.T) {
|
||||
_ = room.AddParticipant(200)
|
||||
|
||||
// Feed audio so both appear in top speakers.
|
||||
for i := 0; i < 5; i++ {
|
||||
for range 5 {
|
||||
room.UpdateSpeakerLevel(100, 20)
|
||||
room.UpdateSpeakerLevel(200, 30)
|
||||
}
|
||||
@@ -191,7 +191,7 @@ func TestSpeakerBroadcast_Integration(t *testing.T) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Feed audio levels into the room — make user 1 a speaker.
|
||||
for i := 0; i < 5; i++ {
|
||||
for range 5 {
|
||||
room.UpdateSpeakerLevel(1, 20) // level=20 (dBov), well below silence threshold
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ func TestSpeakerBroadcast_NoBroadcastWhenNoChange(t *testing.T) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Feed levels to produce a stable speaker list.
|
||||
for i := 0; i < 5; i++ {
|
||||
for range 5 {
|
||||
room.UpdateSpeakerLevel(2, 20)
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ func TestSpeakerBroadcast_RoomCleanup(t *testing.T) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Feed levels so the ticker broadcasts at least once.
|
||||
for i := 0; i < 5; i++ {
|
||||
for range 5 {
|
||||
room.UpdateSpeakerLevel(3, 20)
|
||||
}
|
||||
|
||||
@@ -338,7 +338,7 @@ func TestSpeakerBroadcast_RoomCleanup(t *testing.T) {
|
||||
// Re-create the room and feed a new speaker — the ticker should broadcast
|
||||
// again because prevSpeakers[chanID] was deleted when the room was removed.
|
||||
newRoom := hub.GetOrCreateVoiceRoom(chanID, cfg)
|
||||
for i := 0; i < 5; i++ {
|
||||
for range 5 {
|
||||
newRoom.UpdateSpeakerLevel(3, 20)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user