274 lines
9.3 KiB
TypeScript
274 lines
9.3 KiB
TypeScript
/// <reference types="vitest" />
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { refreshAnalytics, refreshOverview, renderPlaylistSortable, savePlaylist } from "../src/admin";
|
|
|
|
// Mock fetch globally
|
|
let fetchCalls: { url: string; options?: RequestInit }[] = [];
|
|
let mockFetchResponse: any = { ok: true, payload: {} };
|
|
|
|
beforeEach(() => {
|
|
fetchCalls = [];
|
|
globalThis.fetch = vi.fn(async (url: string, options?: RequestInit) => {
|
|
fetchCalls.push({ url, options });
|
|
const payload = mockFetchResponse.payload ?? {};
|
|
return Promise.resolve(
|
|
new Response(JSON.stringify(payload), {
|
|
status: mockFetchResponse.ok ? 200 : mockFetchResponse.status || 500,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
}) as any;
|
|
|
|
// Mock localStorage
|
|
const storage = new Map<string, string>();
|
|
Object.defineProperty(window, "localStorage", {
|
|
value: {
|
|
getItem: vi.fn((key: string) => storage.get(key) ?? null),
|
|
setItem: vi.fn((key: string, val: string) => storage.set(key, val)),
|
|
removeItem: vi.fn((key: string) => storage.delete(key)),
|
|
},
|
|
writable: true,
|
|
});
|
|
|
|
// Mock window.confirm/prompt
|
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
|
vi.spyOn(window, "prompt").mockReturnValue(null);
|
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// Import after mocks are set
|
|
import "../src/admin";
|
|
|
|
describe("admin.ts initialization", () => {
|
|
it("loads and exposes tab switching", () => {
|
|
// After import, init() runs on DOMContentLoaded
|
|
// Test that the document has admin page structure
|
|
expect(document.body).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("upload progress helpers", () => {
|
|
it("renders upload progress correctly", async () => {
|
|
// Set up DOM for upload page
|
|
document.body.innerHTML = `
|
|
<div id="media-upload-progress-wrap" hidden>
|
|
<progress id="media-upload-progress" max="100" value="0"></progress>
|
|
<span id="media-upload-progress-text"></span>
|
|
</div>
|
|
<div id="tab-content"></div>
|
|
`;
|
|
|
|
// Re-import triggers init again — but we need fresh module state
|
|
// For now test internal helper access via body
|
|
const wrap = document.getElementById("media-upload-progress-wrap")!;
|
|
const bar = document.getElementById("media-upload-progress") as HTMLProgressElement;
|
|
const text = document.getElementById("media-upload-progress-text")!;
|
|
|
|
expect(wrap.hidden).toBe(true);
|
|
|
|
// Simulate what setUploadProgress would do
|
|
wrap.hidden = false;
|
|
bar.max = 100;
|
|
bar.value = 50;
|
|
text.textContent = "Uploading (50%)";
|
|
|
|
expect(wrap.hidden).toBe(false);
|
|
expect(bar.value).toBe(50);
|
|
expect(text.textContent).toBe("Uploading (50%)");
|
|
});
|
|
});
|
|
|
|
describe("file fingerprint", () => {
|
|
it("generates unique fingerprints for different files", () => {
|
|
const f1 = new File(["a"], "video1.mp4", { lastModified: 1 });
|
|
const f2 = new File(["b"], "video2.mp4", { lastModified: 2 });
|
|
|
|
const fp1 = `${f1.name}:${f1.size}:${f1.lastModified}`;
|
|
const fp2 = `${f2.name}:${f2.size}:${f2.lastModified}`;
|
|
|
|
expect(fp1).not.toBe(fp2);
|
|
expect(fp1).toBe("video1.mp4:1:1");
|
|
expect(fp2).toBe("video2.mp4:1:2");
|
|
});
|
|
});
|
|
|
|
describe("MAX_UPLOAD_BYTES constant", () => {
|
|
it("equals 2GB", () => {
|
|
expect(2 * 1024 * 1024 * 1024).toBe(2147483648);
|
|
});
|
|
});
|
|
|
|
describe("API calls on tab load", () => {
|
|
beforeEach(() => {
|
|
document.body.innerHTML = `
|
|
<div id="tab-content"><div id="overview-content"></div></div>
|
|
<nav class="admin-nav">
|
|
<button data-tab="dashboard">Dashboard</button>
|
|
<button data-tab="analytics">Analytics</button>
|
|
<button data-tab="playlist">Playlist</button>
|
|
<button data-tab="schedule">Schedule</button>
|
|
<button data-tab="system">System</button>
|
|
<button data-tab="upload">Upload</button>
|
|
<button data-tab="users">Users</button>
|
|
<button data-tab="moderation">Moderation</button>
|
|
<button data-tab="chat-log">Chat Log</button>
|
|
</nav>
|
|
<div id="overview-stats"></div>
|
|
<div id="analytics-stats"></div>
|
|
<div id="playlist-editor"></div>
|
|
<div id="schedule-list"></div>
|
|
<div id="system-summary"></div>
|
|
<div id="media-upload-progress-wrap" hidden>
|
|
<progress id="media-upload-progress"></progress>
|
|
<span id="media-upload-progress-text"></span>
|
|
</div>
|
|
<div id="media-table-body"></div>
|
|
<div id="users-table-body"></div>
|
|
<div id="moderation-log-table-body"></div>
|
|
<div id="chat-log-table-body"></div>
|
|
<div id="chat-log-pagination"></div>
|
|
<div id="config-editor"></div>
|
|
<span id="restart-status"></span>
|
|
<div id="schedule-override-list"></div>
|
|
`;
|
|
});
|
|
|
|
it("can be imported without crashing", () => {
|
|
// admin.ts initialization should not throw
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("admin data refresh", () => {
|
|
beforeEach(() => {
|
|
document.body.innerHTML = `
|
|
<div id="overview-status"></div>
|
|
<span id="stat-viewers"></span>
|
|
<span id="stat-stored-messages"></span>
|
|
<span id="stat-moderation-actions"></span>
|
|
<span id="stat-media-runtime"></span>
|
|
<span id="stat-media-count"></span>
|
|
<span id="system-stored-messages"></span>
|
|
<span id="system-redis-clients"></span>
|
|
<span id="system-redis-enabled"></span>
|
|
<span id="system-redis-memory"></span>
|
|
<span id="system-db-driver"></span>
|
|
<span id="system-generated-at"></span>
|
|
<div id="analytics-status"></div>
|
|
<span id="analytics-total-messages"></span>
|
|
<span id="analytics-messages-24h"></span>
|
|
<span id="analytics-distinct-rooms"></span>
|
|
<span id="analytics-moderation-actions"></span>
|
|
<span id="analytics-generated-at"></span>
|
|
<table><tbody id="analytics-table-body"></tbody></table>
|
|
`;
|
|
});
|
|
|
|
it("renders overview stats from the admin overview API", async () => {
|
|
mockFetchResponse = {
|
|
ok: true,
|
|
payload: {
|
|
viewers: 12,
|
|
storedMessages: 345,
|
|
moderationActions: 9,
|
|
mediaTotalRuntime: "01:23:45",
|
|
mediaCount: 7,
|
|
redisClients: 4,
|
|
redisEnabled: true,
|
|
redisMemoryUsed: "18 MB",
|
|
databaseDriver: "sqlite",
|
|
generatedAt: "2026-08-08T12:00:00Z",
|
|
},
|
|
};
|
|
|
|
await refreshOverview();
|
|
|
|
expect(document.getElementById("stat-viewers")?.textContent).toBe("12");
|
|
expect(document.getElementById("stat-stored-messages")?.textContent).toBe("345");
|
|
expect(document.getElementById("system-redis-enabled")?.textContent).toBe("yes");
|
|
expect(document.getElementById("overview-status")?.textContent).toContain("2026-08-08T12:00:00Z");
|
|
});
|
|
|
|
it("renders analytics rows from the analytics API", async () => {
|
|
mockFetchResponse = {
|
|
ok: true,
|
|
payload: {
|
|
totalMessages: 1000,
|
|
messagesLast24Hours: 75,
|
|
distinctRooms: 3,
|
|
moderationActions: 11,
|
|
generatedAt: "2026-08-08T12:30:00Z",
|
|
topRooms: [
|
|
{ room: "main", messageCount: 55 },
|
|
{ room: "lobby", messageCount: 20 },
|
|
],
|
|
},
|
|
};
|
|
|
|
await refreshAnalytics();
|
|
|
|
expect(document.getElementById("analytics-total-messages")?.textContent).toBe("1000");
|
|
expect(document.getElementById("analytics-messages-24h")?.textContent).toBe("75");
|
|
expect(document.getElementById("analytics-table-body")?.textContent).toContain("main");
|
|
expect(document.getElementById("analytics-table-body")?.textContent).toContain("lobby");
|
|
});
|
|
});
|
|
|
|
describe("playlist management", () => {
|
|
beforeEach(() => {
|
|
document.body.innerHTML = `
|
|
<div id="playlist-status"></div>
|
|
<textarea id="playlist-content">file '/app/media/demo1.mp4'
|
|
file '/app/media/demo2.mp4'
|
|
</textarea>
|
|
<ul id="playlist-sortable"></ul>
|
|
<div id="playlist-warnings" hidden></div>
|
|
`;
|
|
});
|
|
|
|
it("saves playlist via PUT request", async () => {
|
|
mockFetchResponse = { ok: true, payload: {} };
|
|
|
|
await savePlaylist();
|
|
|
|
const putCall = fetchCalls.find((c) => c.options?.method === "PUT");
|
|
expect(putCall).toBeDefined();
|
|
expect(putCall?.url).toBe("/api/admin/playlist");
|
|
expect(putCall?.options?.headers).toEqual({ "Content-Type": "application/json" });
|
|
const body = JSON.parse(putCall?.options?.body as string);
|
|
expect(body.content).toContain("demo1.mp4");
|
|
});
|
|
|
|
it("sets error status on save failure", async () => {
|
|
mockFetchResponse = { ok: false, status: 500, payload: {} };
|
|
|
|
await savePlaylist();
|
|
|
|
const status = document.getElementById("playlist-status");
|
|
expect(status?.textContent).toContain("Failed");
|
|
});
|
|
|
|
it("renders sortable playlist with indexes and paths", () => {
|
|
renderPlaylistSortable("file '/app/media/demo1.mp4'\nfile '/app/media/demo2.mp4'\n");
|
|
|
|
const list = document.getElementById("playlist-sortable")!;
|
|
expect(list.children).toHaveLength(2);
|
|
expect(list.innerHTML).toContain("demo1.mp4");
|
|
expect(list.innerHTML).toContain("demo2.mp4");
|
|
expect(list.querySelector(".sortable-index")?.textContent).toBe("1");
|
|
expect(list.querySelector("[data-remove]")).toBeTruthy();
|
|
});
|
|
|
|
it("skips comment and blank lines in sortable", () => {
|
|
renderPlaylistSortable("# comment\n\nfile '/app/media/demo1.mp4'\n");
|
|
|
|
const list = document.getElementById("playlist-sortable")!;
|
|
expect(list.children).toHaveLength(1);
|
|
expect(list.innerHTML).toContain("demo1.mp4");
|
|
});
|
|
});
|