Files
ntfy/web/src/app/Api.test.js
T
2026-07-08 22:42:42 +02:00

128 lines
4.6 KiB
JavaScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import userManager from "./UserManager";
import api from "./Api";
// Api.js talks to the server through the global fetch and looks up credentials via userManager.
// (vi.mock is hoisted above the imports by vitest, so the import above receives the mock.)
vi.mock("./UserManager", () => ({ default: { get: vi.fn() } }));
let fetchMock;
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "log").mockImplementation(() => {});
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
userManager.get.mockResolvedValue(undefined); // anonymous by default
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("Api.poll", () => {
it("parses newline-delimited JSON and skips lines without an id", async () => {
const body = [
JSON.stringify({ id: "1", message: "a" }),
JSON.stringify({ event: "keepalive" }), // no id -> skipped
JSON.stringify({ id: "2", message: "b" }),
].join("\n");
fetchMock.mockResolvedValue(new Response(body));
const messages = await api.poll("https://ntfy.sh", "mytopic");
expect(messages.map((m) => m.id)).toEqual(["1", "2"]);
});
it("uses the plain poll URL without a since cursor", async () => {
fetchMock.mockResolvedValue(new Response(""));
await api.poll("https://ntfy.sh", "mytopic");
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/mytopic/json?poll=1", expect.anything());
});
it("uses the since URL when a cursor is given", async () => {
fetchMock.mockResolvedValue(new Response(""));
await api.poll("https://ntfy.sh", "mytopic", 12345);
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/mytopic/json?poll=1&since=12345", expect.anything());
});
});
describe("Api.publish", () => {
it("PUTs the message body to the base URL", async () => {
fetchMock.mockResolvedValue({ status: 200 });
await api.publish("https://ntfy.sh", "mytopic", "Hello", { priority: 5, tags: ["warning"] });
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh",
expect.objectContaining({
method: "PUT",
body: JSON.stringify({ topic: "mytopic", message: "Hello", priority: 5, tags: ["warning"] }),
}),
);
});
it("attaches basic auth when the user has a password", async () => {
userManager.get.mockResolvedValue({ username: "phil", password: "secret" });
fetchMock.mockResolvedValue({ status: 200 });
await api.publish("https://ntfy.sh", "mytopic", "Hi");
const [, options] = fetchMock.mock.calls[0];
expect(options.headers.Authorization).toBe(`Basic ${btoa("phil:secret")}`);
});
it("attaches bearer auth when the user has a token", async () => {
userManager.get.mockResolvedValue({ token: "tk_abc" });
fetchMock.mockResolvedValue({ status: 200 });
await api.publish("https://ntfy.sh", "mytopic", "Hi");
const [, options] = fetchMock.mock.calls[0];
expect(options.headers.Authorization).toBe("Bearer tk_abc");
});
});
describe("Api.topicAuth", () => {
it("returns true for a 2xx response", async () => {
fetchMock.mockResolvedValue({ status: 200 });
expect(await api.topicAuth("https://ntfy.sh", "mytopic")).toBe(true);
});
it("returns false for 401/403", async () => {
fetchMock.mockResolvedValue({ status: 403 });
expect(await api.topicAuth("https://ntfy.sh", "mytopic")).toBe(false);
});
it("throws for any other status", async () => {
fetchMock.mockResolvedValue({ status: 500 });
await expect(api.topicAuth("https://ntfy.sh", "mytopic")).rejects.toThrow("Unexpected server response 500");
});
});
describe("Api web push", () => {
const pushSubscription = {
endpoint: "https://push.example/abc",
keys: { auth: "AUTH", p256dh: "P256" },
};
it("updateWebPush POSTs endpoint, keys and topics to the web push URL", async () => {
fetchMock.mockResolvedValue({ status: 200 });
await api.updateWebPush(pushSubscription, ["topicA", "topicB"]);
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/v1/webpush",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ endpoint: "https://push.example/abc", auth: "AUTH", p256dh: "P256", topics: ["topicA", "topicB"] }),
}),
);
});
it("deleteWebPush DELETEs just the endpoint", async () => {
fetchMock.mockResolvedValue({ status: 200 });
await api.deleteWebPush(pushSubscription);
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/v1/webpush",
expect.objectContaining({
method: "DELETE",
body: JSON.stringify({ endpoint: "https://push.example/abc" }),
}),
);
});
});