mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-11 22:35:22 +00:00
More web tests
This commit is contained in:
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user