Files
homarr/packages/workshop/src/client.spec.ts
T

270 lines
8.9 KiB
TypeScript

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<string, unknown> | null,
isValid: false,
save: (_token: string, record: Record<string, unknown>) => {
this.authStore.record = record;
},
};
public collection() {
return mocks;
}
public autoCancellation(enabled: boolean) {
mocks.autoCancellation(enabled);
return this;
}
public filter(template: string, values: Record<string, string>) {
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<RecordService<WorkshopSubmissionRecord>>();
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",
};
}