More web tests

This commit is contained in:
binwiederhier
2026-06-24 05:56:48 -04:00
parent c5e5be0746
commit b0411e5c92
3 changed files with 345 additions and 4 deletions
+207
View File
@@ -0,0 +1,207 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import session from "./Session";
import prefs from "./Prefs";
import subscriptionManager from "./SubscriptionManager";
import accountApi from "./AccountApi";
// AccountApi.js leans on several singletons; mock them so we can drive token() and assert the
// side effects of sync(), and so importing it doesn't drag in the component/Dexie/i18n trees.
// (vi.mock is hoisted above the imports by vitest, so those imports receive the mocks.)
vi.mock("./Session", () => ({
default: { token: vi.fn(() => "test-token"), setLastExtendedAtAsync: vi.fn(), resetAndRedirect: vi.fn() },
}));
vi.mock("./SubscriptionManager", () => ({ default: { syncFromRemote: vi.fn() } }));
vi.mock("./Prefs", () => ({
default: { setSound: vi.fn(), setDeleteAfter: vi.fn(), setMinPriority: vi.fn() },
THEME: { DARK: "dark", LIGHT: "light", SYSTEM: "system" },
}));
vi.mock("i18next", () => ({ default: { changeLanguage: vi.fn() } }));
vi.mock("../components/routes", () => ({ default: { login: "/login" } }));
let fetchMock;
// fetchOrThrow treats anything other than HTTP 200 as an error, so a fake response just needs a
// status and a json() method.
const ok = (body = {}) => ({ status: 200, json: async () => body });
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "log").mockImplementation(() => {});
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
session.token.mockReturnValue("test-token");
accountApi.tiers = null; // reset the billing-tiers cache between tests
accountApi.listener = null;
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("AccountApi.login", () => {
it("POSTs basic auth to the token URL and returns the token", async () => {
fetchMock.mockResolvedValue(ok({ token: "tk_returned" }));
const token = await accountApi.login({ username: "phil", password: "secret" });
expect(token).toBe("tk_returned");
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe("https://ntfy.sh/v1/account/token");
expect(options.method).toBe("POST");
expect(options.headers.Authorization).toBe(`Basic ${btoa("phil:secret")}`);
});
it("throws when the server response has no token", async () => {
fetchMock.mockResolvedValue(ok({}));
await expect(accountApi.login({ username: "phil", password: "secret" })).rejects.toThrow("Cannot find token");
});
});
describe("AccountApi.create", () => {
it("POSTs username/password and defaults email to an empty string", async () => {
fetchMock.mockResolvedValue(ok());
await accountApi.create("phil", "pw");
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/v1/account",
expect.objectContaining({ method: "POST", body: JSON.stringify({ username: "phil", password: "pw", email: "" }) })
);
});
});
describe("AccountApi.get", () => {
it("returns the parsed account and notifies the listener", async () => {
fetchMock.mockResolvedValue(ok({ username: "phil" }));
const listener = vi.fn();
accountApi.registerListener(listener);
const account = await accountApi.get();
expect(account).toEqual({ username: "phil" });
expect(listener).toHaveBeenCalledWith({ username: "phil" });
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe("https://ntfy.sh/v1/account");
expect(options.headers.Authorization).toBe("Bearer test-token");
});
});
describe("AccountApi.changePassword", () => {
it("POSTs the current and new passwords", async () => {
fetchMock.mockResolvedValue(ok());
await accountApi.changePassword("old", "new");
const [, options] = fetchMock.mock.calls[0];
expect(options.body).toBe(JSON.stringify({ password: "old", new_password: "new" }));
});
});
describe("AccountApi.createToken", () => {
it("converts a positive expiry into an absolute unix timestamp", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); // 1767225600 seconds
fetchMock.mockResolvedValue(ok());
await accountApi.createToken("my label", 3600);
const [, options] = fetchMock.mock.calls[0];
expect(JSON.parse(options.body)).toEqual({ label: "my label", expires: 1767225600 + 3600 });
});
it("sends expires=0 for a non-expiring token", async () => {
fetchMock.mockResolvedValue(ok());
await accountApi.createToken("forever", 0);
const [, options] = fetchMock.mock.calls[0];
expect(JSON.parse(options.body).expires).toBe(0);
});
});
describe("AccountApi.deleteToken", () => {
it("passes the target token in the X-Token header alongside the bearer token", async () => {
fetchMock.mockResolvedValue(ok());
await accountApi.deleteToken("tk_target");
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe("DELETE");
expect(options.headers["X-Token"]).toBe("tk_target");
expect(options.headers.Authorization).toBe("Bearer test-token");
});
});
describe("AccountApi subscriptions", () => {
it("addSubscription POSTs base_url/topic and returns the parsed subscription", async () => {
fetchMock.mockResolvedValue(ok({ id: "sub_1" }));
const subscription = await accountApi.addSubscription("https://ntfy.sh", "mytopic");
expect(subscription).toEqual({ id: "sub_1" });
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe("https://ntfy.sh/v1/account/subscription");
expect(options.method).toBe("POST");
expect(JSON.parse(options.body)).toEqual({ base_url: "https://ntfy.sh", topic: "mytopic" });
});
it("deleteSubscription passes baseUrl/topic via headers", async () => {
fetchMock.mockResolvedValue(ok());
await accountApi.deleteSubscription("https://ntfy.sh", "mytopic");
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe("DELETE");
expect(options.headers["X-BaseURL"]).toBe("https://ntfy.sh");
expect(options.headers["X-Topic"]).toBe("mytopic");
});
});
describe("AccountApi.billingTiers", () => {
it("caches the tiers and only fetches once", async () => {
fetchMock.mockResolvedValue(ok([{ code: "pro" }]));
const first = await accountApi.billingTiers();
const second = await accountApi.billingTiers();
expect(first).toEqual([{ code: "pro" }]);
expect(second).toBe(first);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe("AccountApi.requestPasswordReset", () => {
it("POSTs the identifier without any auth header", async () => {
fetchMock.mockResolvedValue(ok());
await accountApi.requestPasswordReset("phil");
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe("https://ntfy.sh/v1/account/password/reset/request");
expect(options.body).toBe(JSON.stringify({ identifier: "phil" }));
expect(options.headers).toBeUndefined();
});
});
describe("AccountApi.sync", () => {
it("returns null and makes no request when there is no token", async () => {
session.token.mockReturnValue(null);
expect(await accountApi.sync()).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("resets the session and redirects to login on an unauthorized response", async () => {
fetchMock.mockResolvedValue({ status: 401, json: async () => ({}) });
const result = await accountApi.sync();
expect(result).toBeUndefined();
expect(session.resetAndRedirect).toHaveBeenCalledWith("/login");
});
it("applies language, notification prefs and subscriptions from the fetched account", async () => {
fetchMock.mockResolvedValue(
ok({
language: "de",
notification: { sound: "ding", delete_after: 3600, min_priority: 3 },
subscriptions: [{ topic: "t" }],
reservations: [{ topic: "t" }],
})
);
await accountApi.sync();
expect(i18n.changeLanguage).toHaveBeenCalledWith("de");
expect(prefs.setSound).toHaveBeenCalledWith("ding");
expect(prefs.setDeleteAfter).toHaveBeenCalledWith(3600);
expect(prefs.setMinPriority).toHaveBeenCalledWith(3);
expect(subscriptionManager.syncFromRemote).toHaveBeenCalledWith([{ topic: "t" }], [{ topic: "t" }]);
});
});
+127
View File
@@ -0,0 +1,127 @@
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" }),
})
);
});
});
+11 -4
View File
@@ -1,13 +1,20 @@
/* eslint-env node, es2021 */
// Minimal window stub so the pure-logic modules import cleanly under the node environment,
// without pulling in jsdom. utils.js -> config.js does `const { config } = window;` at import
// time, and config.js falls back to window.location.origin when base_url is empty.
// Minimal browser-global stubs so the pure-logic and API modules import + run under the node
// environment, without pulling in jsdom. utils.js -> config.js does `const { config } = window;`
// at import time, and config.js falls back to window.location.origin when base_url is empty.
const config = { base_url: "https://ntfy.sh", disallowed_topics: ["app", "account", "settings"] };
globalThis.window = {
location: { origin: "https://ntfy.sh" },
config: { base_url: "", disallowed_topics: ["app", "account", "settings"] },
config,
atob: globalThis.atob, // urlB64ToUint8Array uses window.atob; Node provides global atob
};
// In the browser, `window` IS the global object, so `window.config` is also reachable as a bare
// `config` identifier -- Api.js/AccountApi.js/UserManager.js/SubscriptionManager.js rely on that.
// Node's `window` is just a plain object, so expose the same object as a real global too.
globalThis.config = config;
// utils.js -> Prefs.js -> db.js -> Session.username() reads localStorage at module load.
// Node has no localStorage; an in-memory stand-in is enough for the pure-logic tests.
const store = new Map();