+12








fcd7c979df
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
323 lines
11 KiB
TypeScript
323 lines
11 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(),
|
|
send: vi.fn(),
|
|
update: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("pocketbase", () => {
|
|
class MockPocketBase {
|
|
public readonly authStore = {
|
|
token: "",
|
|
record: null as Record<string, unknown> | null,
|
|
get isValid() {
|
|
try {
|
|
const payload = JSON.parse(atob(this.token.split(".")[1] ?? "")) as { exp?: unknown };
|
|
return typeof payload.exp === "number" && payload.exp > Date.now() / 1000;
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
save: (token: string, record: Record<string, unknown>) => {
|
|
this.authStore.token = token;
|
|
this.authStore.record = record;
|
|
},
|
|
clear: () => {
|
|
this.authStore.token = "";
|
|
this.authStore.record = null;
|
|
},
|
|
};
|
|
|
|
public collection() {
|
|
return mocks;
|
|
}
|
|
|
|
public autoCancellation(enabled: boolean) {
|
|
mocks.autoCancellation(enabled);
|
|
return this;
|
|
}
|
|
|
|
public send(path: string, options: unknown) {
|
|
return mocks.send(path, options);
|
|
}
|
|
|
|
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";
|
|
|
|
const validWorkshopToken = "header.eyJleHAiOjQxMDI0NDQ4MDB9.signature";
|
|
|
|
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.send.mockReset();
|
|
mocks.update.mockReset();
|
|
});
|
|
|
|
test("lets PocketBase own the complete GitHub popup flow", async () => {
|
|
mocks.authWithOAuth2.mockResolvedValue({
|
|
token: validWorkshopToken,
|
|
record: {
|
|
id: "user-id",
|
|
name: "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({ name: "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("reuses a valid Workshop session for the Homarr provider allowance", async () => {
|
|
const client = new WorkshopBackend("https://workshop.example.com");
|
|
expect(client.authToken).toBeNull();
|
|
client.pocketBase.authStore.save(validWorkshopToken, { id: "author-id", name: "octocat" } as never);
|
|
mocks.send.mockResolvedValue({
|
|
limit: 50,
|
|
used: 7,
|
|
remaining: 43,
|
|
resetsAt: "2026-08-12T00:00:00Z",
|
|
});
|
|
|
|
await expect(client.getAssistantUsage()).resolves.toMatchObject({ limit: 50, remaining: 43 });
|
|
expect(client.authToken).toBe(validWorkshopToken);
|
|
expect(mocks.send).toHaveBeenCalledWith(
|
|
"/api/ai/usage",
|
|
expect.objectContaining({ method: "GET", requestKey: null, signal: expect.any(AbortSignal) }),
|
|
);
|
|
});
|
|
|
|
test("fails closed for expired tokens and malformed Workshop user records", () => {
|
|
const client = new WorkshopBackend("https://workshop.example.com");
|
|
client.pocketBase.authStore.save("header.eyJleHAiOjF9.signature", {
|
|
id: "author-id",
|
|
name: "octocat",
|
|
} as never);
|
|
expect(client.currentUser).toBeNull();
|
|
expect(client.authToken).toBeNull();
|
|
|
|
client.pocketBase.authStore.save(validWorkshopToken, { id: 42 } as never);
|
|
expect(client.currentUser).toBeNull();
|
|
expect(client.authToken).toBeNull();
|
|
});
|
|
|
|
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(validWorkshopToken, { 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' || authorName ~ '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",
|
|
};
|
|
}
|