import { beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest"; const mocks = vi.hoisted(() => ({ autoCancellation: vi.fn(), authWithOAuth2: vi.fn(), create: vi.fn(), getFullList: vi.fn(), getList: vi.fn(), update: vi.fn(), })); vi.mock("pocketbase", () => { class MockPocketBase { public readonly authStore = { record: null as Record | null, isValid: false, save: (_token: string, record: Record) => { this.authStore.record = record; }, }; public collection() { return mocks; } public autoCancellation(enabled: boolean) { mocks.autoCancellation(enabled); return this; } public filter(template: string, values: Record) { return template.replaceAll(/\{:(\w+)\}/gu, (_match, key: string) => `'${values[key]}'`); } } return { default: MockPocketBase, ClientResponseError: class ClientResponseError extends Error { public constructor(public readonly status = 0) { super("PocketBase request failed"); } }, }; }); import { ClientResponseError } from "pocketbase"; import type { RecordService } from "pocketbase"; import { WorkshopBackend } from "./backend"; import type { WorkshopSubmissionRecord } from "./backend"; import * as workshopClient from "./client"; describe("WorkshopBackend", () => { test("resolves the advertised client module", () => { expect(workshopClient.WorkshopBackend).toBe(WorkshopBackend); expect(workshopClient.validateWorkshopContent).toBeTypeOf("function"); }); beforeEach(() => { mocks.autoCancellation.mockReset(); mocks.authWithOAuth2.mockReset(); mocks.create.mockReset(); mocks.getFullList.mockReset(); mocks.getList.mockReset(); mocks.update.mockReset(); }); test("lets PocketBase own the complete GitHub popup flow", async () => { mocks.authWithOAuth2.mockResolvedValue({ token: "token", record: { id: "user-id", githubUsername: "octocat", }, meta: { username: "octocat" }, }); const client = new WorkshopBackend("https://workshop.example.com"); const user = await client.signInWithGitHub(); expect(mocks.authWithOAuth2).toHaveBeenCalledWith({ provider: "github" }); expect(mocks.authWithOAuth2.mock.calls[0]?.[0]).not.toHaveProperty("requestKey"); expect(mocks.authWithOAuth2.mock.calls[0]?.[0]).not.toHaveProperty("urlCallback"); expect(mocks.update).not.toHaveBeenCalled(); expect(user).toMatchObject({ githubUsername: "octocat" }); }); test("exposes the typed submission collection service", () => { const client = new WorkshopBackend("https://workshop.example.com"); expectTypeOf(client.pocketBase.collection("submissions")).toEqualTypeOf>(); expect(mocks.autoCancellation).toHaveBeenCalledWith(false); }); test("sends the exported schema while leaving initial revision ownership to PocketBase", async () => { const client = new WorkshopBackend("https://workshop.example.com"); client.pocketBase.authStore.save("token", { id: "author-id" } as never); mocks.create.mockResolvedValue({ id: "submission-id" }); vi.spyOn(client, "get").mockResolvedValue(listingRecord() as never); await client.create({ type: "customCss", title: "Dashboard theme", description: "A theme", content: ".dashboard { color: red; }", changelog: "", outdated: false, }); const payload = mocks.create.mock.calls[0]?.[0] as FormData; expect(payload.get("author")).toBe("author-id"); expect(payload.get("widgetSchema")).toBe("homarr-custom-css-v1"); expect(payload.get("revision")).toBeNull(); }); test("appends and removes screenshots without replacing retained files", async () => { const client = new WorkshopBackend("https://workshop.example.com"); const current = { ...listingRecord(), type: "customCss" as const, content: ".dashboard { color: red; }", screenshots: ["keep.png", "remove.png"], revision: 2, }; vi.spyOn(client, "get").mockResolvedValue(current as never); mocks.update.mockResolvedValue(current); const addition = new File(["image"], "new.png", { type: "image/png" }); await client.update( current.id, { type: "customCss", title: current.title, description: current.description, content: current.content, changelog: "Updated screenshots", outdated: false, }, { additions: [addition], removals: ["remove.png"] }, ); expect(mocks.update).toHaveBeenCalledWith( current.id, expect.objectContaining({ "screenshots+": [addition], "screenshots-": ["remove.png"], expectedRevision: 2, }), ); const payload = mocks.update.mock.calls[0]?.[1]; expect(payload).not.toHaveProperty("revision"); expect(payload).not.toHaveProperty("widgetSchema"); }); test("uses the current revision when toggling submission status", async () => { const client = new WorkshopBackend("https://workshop.example.com"); const current = { ...listingRecord(), type: "customCss" as const, content: ".dashboard { color: red; }", revision: 4, outdated: false, }; const updated = { ...current, revision: 5, outdated: true }; vi.spyOn(client, "get") .mockResolvedValueOnce(current as never) .mockResolvedValueOnce(updated as never); mocks.update.mockResolvedValue(updated); await expect(client.toggleOutdated(current.id, true)).resolves.toMatchObject({ revision: 5, outdated: true, }); expect(mocks.update).toHaveBeenCalledWith(current.id, { outdated: true, expectedRevision: 4, }); }); test("uses PocketBase filtering and pagination when the listing view is current", async () => { mocks.getList.mockResolvedValue({ page: 1, perPage: 12, totalPages: 1, totalItems: 1, items: [listingRecord()], }); const client = new WorkshopBackend("https://workshop.example.com"); const result = await client.list({ type: "customWidget", sort: "top", page: 1, perPage: 12 }); expect(result.items).toHaveLength(1); expect(result.items[0]).toMatchObject({ type: "customWidget", score: 0, revision: 1 }); expect(mocks.getList).toHaveBeenCalledWith( 1, 12, expect.objectContaining({ filter: "type = 'customWidget'", sort: "-score,-created" }), ); expect(mocks.getFullList).not.toHaveBeenCalled(); }); test("searches listing titles, descriptions, and authors", async () => { mocks.getList.mockResolvedValue({ page: 1, perPage: 12, totalPages: 1, totalItems: 1, items: [listingRecord()], }); const client = new WorkshopBackend("https://workshop.example.com"); await client.list({ search: "active streams", page: 1, perPage: 12 }); expect(mocks.getList).toHaveBeenCalledWith( 1, 12, expect.objectContaining({ filter: "(title ~ 'active streams' || description ~ 'active streams' || authorGithubUsername ~ 'active streams')", }), ); }); test("retries an incomplete listing view without missing-field filters", async () => { mocks.getList.mockRejectedValueOnce(new ClientResponseError(400)); mocks.getFullList.mockResolvedValueOnce([listingRecord()]); const client = new WorkshopBackend("https://workshop.example.com"); const result = await client.list({ type: "customWidget" }); expect(result.items).toHaveLength(1); expect(mocks.getFullList).toHaveBeenCalledWith( expect.not.objectContaining({ filter: expect.anything(), sort: expect.anything() }), ); }); test("falls back to base submissions when an old listing view itself returns 400", async () => { mocks.getList.mockRejectedValueOnce(new ClientResponseError(400)); mocks.getFullList.mockRejectedValueOnce(new ClientResponseError(400)).mockResolvedValueOnce([listingRecord()]); const client = new WorkshopBackend("https://workshop.example.com"); const result = await client.list({ type: "customWidget" }); expect(result.items).toHaveLength(1); expect(mocks.getFullList).toHaveBeenCalledTimes(2); }); test("replaces low-level network errors with an actionable Workshop outage message", async () => { mocks.getList.mockRejectedValueOnce(new Error("NetworkError when attempting to fetch resource.")); const client = new WorkshopBackend("https://workshop.example.com"); await expect(client.list({ type: "customWidget" })).rejects.toThrow( "Homarr Workshop seems to be unavailable right now. Try again in a moment.", ); }); }); function listingRecord() { return { id: "submission-id", title: "Tautulli Activity", description: "Shows active streams", widgetSchema: "homarr-custom-widget-v2", screenshots: [], author: "author-id", created: "2026-07-21 14:19:25.790Z", updated: "2026-07-21 14:19:25.790Z", }; }