Files
webmail/components/__tests__/push-notification-prompt.test.tsx
T
Linus Rath cf2c27157d Merge branch 'main' into feat/stalwart-1.0
# Conflicts:
#	components/contacts/contacts-app.tsx
#	components/email/__tests__/composer-discard.test.tsx
#	components/email/calendar-invitation-banner.tsx
#	components/email/email-composer.tsx
#	components/mail/mail-app.tsx
#	components/pro/pro-compose-tab-body.tsx
#	components/pro/pro-email-tab-body.tsx
#	components/ui/__tests__/avatar.test.tsx
#	components/ui/avatar.tsx
#	hooks/use-mailbox-drop.ts
#	lib/__tests__/calendar-alerts.test.ts
#	lib/__tests__/jmap-calendar-tasks.test.ts
#	lib/__tests__/jmap-send-threading.test.ts
#	lib/demo/demo-client.ts
#	lib/demo/fixtures/calendars.ts
#	lib/jmap/client-interface.ts
#	lib/jmap/client.ts
#	lib/web-push.ts
#	stores/__tests__/contact-store.test.ts
#	stores/__tests__/email-store-thread-key.test.ts
#	stores/calendar-store.ts
#	stores/contact-store.ts
#	stores/email-store.ts
2026-10-26 10:14:12 +01:00

477 lines
17 KiB
TypeScript

import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PUSH_NOTIFICATION_PROMPT_DELAY_MS, PushNotificationPrompt } from "../push-notification-prompt";
import { PWA_INSTALL_PROMPT_VISIBILITY_EVENT } from "../pwa-install-prompt";
import { jmapSetError } from "@/lib/jmap/jmap-error";
const mocks = vi.hoisted(() => ({
pathname: "/en",
enabled: false,
supported: true,
enableWebPush: vi.fn(),
isWebPushEnabled: vi.fn(),
resyncWebPush: vi.fn(),
waitForWebPushResync: vi.fn(),
authState: {
client: { getAccountId: () => "account-1" } as {
getAccountId: () => string;
},
username: "alice@example.com",
isAuthenticated: true,
isDemoMode: false,
connectedAccountsRevision: 0,
// Login id -> client, as the auth store keeps it for the account switcher.
connected: new Map<string, { getAccountId: () => string }>(),
getAllConnectedClients: () => new Map(mocks.authState.connected),
},
accounts: [] as { id: string; username: string }[],
settingsState: {
emailNotificationsEnabled: true,
pushRelayUrl: "",
pushNotifyInboxOnly: false,
notificationMutes: { accounts: [] as string[], mailboxes: {} as Record<string, boolean> },
},
policyState: {
loaded: true,
policy: {
pushRelayUrl: "https://notifications.relay.bulwarkmail.org",
pushRelays: [] as { label: string; url: string }[],
pushRelayUrlLocked: false,
},
},
}));
vi.mock("next/navigation", () => ({
usePathname: () => mocks.pathname,
}));
vi.mock("@/stores/auth-store", () => ({
useAuthStore: Object.assign(
(selector: (state: typeof mocks.authState) => unknown) =>
selector(mocks.authState),
{ getState: () => mocks.authState },
),
}));
vi.mock("@/stores/account-store", () => ({
useAccountStore: { getState: () => ({ accounts: mocks.accounts }) },
}));
vi.mock("@/stores/settings-store", () => ({
useSettingsStore: Object.assign(
(selector: (state: typeof mocks.settingsState) => unknown) =>
selector(mocks.settingsState),
{ getState: () => mocks.settingsState },
),
}));
vi.mock("@/stores/policy-store", () => ({
usePolicyStore: (selector: (state: typeof mocks.policyState) => unknown) =>
selector(mocks.policyState),
}));
vi.mock("@/lib/web-push", () => ({
DEFAULT_RELAY_BASE_URL: "https://notifications.relay.bulwarkmail.org",
enableWebPush: mocks.enableWebPush,
isWebPushSupported: () => mocks.supported,
isWebPushEnabled: mocks.isWebPushEnabled,
resyncWebPush: mocks.resyncWebPush,
waitForWebPushResync: mocks.waitForWebPushResync,
}));
function setNotificationPermission(permission: NotificationPermission) {
Object.defineProperty(window, "Notification", {
configurable: true,
value: { permission },
});
Object.defineProperty(globalThis, "Notification", {
configurable: true,
value: { permission },
});
}
async function advancePromptDelay() {
await act(async () => {
await vi.advanceTimersByTimeAsync(PUSH_NOTIFICATION_PROMPT_DELAY_MS);
});
}
describe("PushNotificationPrompt", () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
mocks.pathname = "/en";
mocks.supported = true;
mocks.enabled = false;
mocks.authState.client = { getAccountId: () => "account-1" };
mocks.authState.connectedAccountsRevision = 0;
mocks.authState.connected = new Map([["alice@mail.example.com", mocks.authState.client]]);
mocks.accounts = [{ id: "alice@mail.example.com", username: "alice@example.com" }];
mocks.authState.username = "alice@example.com";
mocks.authState.isAuthenticated = true;
mocks.authState.isDemoMode = false;
mocks.settingsState.emailNotificationsEnabled = true;
mocks.settingsState.pushRelayUrl = "";
mocks.settingsState.notificationMutes = { accounts: [], mailboxes: {} };
mocks.policyState.loaded = true;
mocks.policyState.policy.pushRelayUrl =
"https://notifications.relay.bulwarkmail.org";
mocks.policyState.policy.pushRelays = [];
mocks.policyState.policy.pushRelayUrlLocked = false;
mocks.enableWebPush.mockReset().mockResolvedValue({ subscriptionId: "sub-1" });
mocks.isWebPushEnabled.mockReset().mockImplementation(async () => mocks.enabled);
mocks.resyncWebPush.mockReset().mockResolvedValue(false);
mocks.waitForWebPushResync.mockReset().mockResolvedValue(undefined);
setNotificationPermission("default");
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
Reflect.deleteProperty(navigator, "serviceWorker");
});
it("offers onboarding when the current account has no push subscription", async () => {
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(screen.getByText("title")).toBeInTheDocument();
expect(mocks.isWebPushEnabled).toHaveBeenCalledWith("account-1");
});
it("enables push through the existing web-push flow", async () => {
render(<PushNotificationPrompt />);
await advancePromptDelay();
await act(async () => {
fireEvent.click(screen.getByText("enable"));
await Promise.resolve();
});
expect(mocks.enableWebPush).toHaveBeenCalledWith({
client: mocks.authState.client,
relayBaseUrl: "https://notifications.relay.bulwarkmail.org",
accountLabel: "alice@example.com",
inboxOnly: false,
mutes: { accounts: [], mailboxes: {} },
});
expect(screen.queryByText("title")).not.toBeInTheDocument();
});
it("registers with the relay the user picked from the admin list", async () => {
mocks.policyState.policy.pushRelays = [
{ label: "Company", url: "https://push.company.example" },
];
mocks.settingsState.pushRelayUrl = "https://push.company.example";
render(<PushNotificationPrompt />);
await advancePromptDelay();
await act(async () => {
fireEvent.click(screen.getByText("enable"));
await Promise.resolve();
});
expect(mocks.enableWebPush).toHaveBeenCalledWith(
expect.objectContaining({ relayBaseUrl: "https://push.company.example" }),
);
});
it("falls back to the default relay when the user's pick is no longer offered", async () => {
mocks.settingsState.pushRelayUrl = "https://push.removed.example";
render(<PushNotificationPrompt />);
await advancePromptDelay();
await act(async () => {
fireEvent.click(screen.getByText("enable"));
await Promise.resolve();
});
expect(mocks.enableWebPush).toHaveBeenCalledWith(
expect.objectContaining({
relayBaseUrl: "https://notifications.relay.bulwarkmail.org",
}),
);
});
it.each([
[
"forbidden",
{ type: "forbidden", description: "No access to one of the accounts in the emailPush map." },
"error_forbidden",
],
[
"overQuota",
{ type: "overQuota", description: "There are too many subscriptions, please delete some before adding a new one." },
"error_over_quota",
],
])("explains a %s refusal instead of showing the raw server error", async (_type, setError, key) => {
mocks.enableWebPush.mockRejectedValue(
jmapSetError(`PushSubscription/set create failed: ${setError.description}`, setError),
);
render(<PushNotificationPrompt />);
await advancePromptDelay();
await act(async () => {
fireEvent.click(screen.getByText("enable"));
await Promise.resolve();
});
expect(screen.getByRole("alert")).toHaveTextContent(key);
expect(screen.getByRole("alert")).not.toHaveTextContent("emailPush");
});
it("does not render when push is already enabled", async () => {
mocks.enabled = true;
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(screen.queryByText("title")).not.toBeInTheDocument();
});
it("waits for the background pass before deciding on onboarding (#1321)", async () => {
// Safari dropped the browser subscription with the site data; the saved
// opt-in survived, so the pass on load is recreating it right now.
let restored!: () => void;
const restoring = new Promise<void>((resolve) => { restored = resolve; });
mocks.resyncWebPush.mockReturnValue(restoring.then(() => true));
mocks.waitForWebPushResync.mockReturnValue(restoring);
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.isWebPushEnabled).not.toHaveBeenCalled();
expect(screen.queryByText("title")).not.toBeInTheDocument();
// The subscription is back: nothing to ask the user.
mocks.enabled = true;
await act(async () => {
restored();
await vi.advanceTimersByTimeAsync(0);
});
expect(mocks.isWebPushEnabled).toHaveBeenCalledWith("account-1");
expect(screen.queryByText("title")).not.toBeInTheDocument();
});
it("offers onboarding when push is still off after the background pass", async () => {
let settled!: () => void;
const pass = new Promise<void>((resolve) => { settled = resolve; });
mocks.resyncWebPush.mockReturnValue(pass.then(() => false));
mocks.waitForWebPushResync.mockReturnValue(pass);
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(screen.queryByText("title")).not.toBeInTheDocument();
await act(async () => {
settled();
await vi.advanceTimersByTimeAsync(0);
});
expect(screen.getByText("title")).toBeInTheDocument();
});
it("re-registers every login when the service worker reports a replaced subscription", async () => {
const serviceWorker = new EventTarget();
Object.defineProperty(navigator, "serviceWorker", { configurable: true, value: serviceWorker });
const bob = { getAccountId: () => "account-2" };
mocks.authState.connected.set("bob@mail.example.com", bob);
render(<PushNotificationPrompt />);
await advancePromptDelay();
mocks.resyncWebPush.mockClear();
await act(async () => {
serviceWorker.dispatchEvent(
new MessageEvent("message", { data: { kind: "pushsubscriptionchange" } }),
);
await vi.advanceTimersByTimeAsync(0);
});
expect(mocks.resyncWebPush.mock.calls.map(([p]) => [p.client, p.force])).toEqual([
[mocks.authState.client, true],
[bob, true],
]);
});
it("re-syncs an existing registration in the background on mount", async () => {
mocks.enabled = true;
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.resyncWebPush).toHaveBeenCalledTimes(1);
expect(mocks.resyncWebPush).toHaveBeenCalledWith(
expect.objectContaining({
relayBaseUrl: "https://notifications.relay.bulwarkmail.org",
}),
);
// Only the explicit Enable button may start the interactive flow.
expect(mocks.enableWebPush).not.toHaveBeenCalled();
});
it("renews push for every connected login, not only the active one", async () => {
// Push for bob was enabled from the all-accounts panel; he never becomes
// the active account, so only this background pass keeps his
// registration from hitting the server's 7-day expiry.
const bob = { getAccountId: () => "account-2" };
mocks.authState.connected.set("bob@mail.example.com", bob);
mocks.accounts.push({ id: "bob@mail.example.com", username: "bob@example.com" });
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.resyncWebPush).toHaveBeenCalledTimes(2);
expect(mocks.resyncWebPush.mock.calls[0][0]).toMatchObject({
client: mocks.authState.client,
accountLabel: "alice@example.com",
});
expect(mocks.resyncWebPush.mock.calls[1][0]).toMatchObject({
client: bob,
accountLabel: "bob@example.com",
relayBaseUrl: "https://notifications.relay.bulwarkmail.org",
});
});
it("renews the other logins again when the tab returns to the foreground", async () => {
const bob = { getAccountId: () => "account-2" };
mocks.authState.connected.set("bob@mail.example.com", bob);
render(<PushNotificationPrompt />);
await advancePromptDelay();
mocks.resyncWebPush.mockClear();
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.resyncWebPush.mock.calls.map(([p]) => p.client)).toEqual([
mocks.authState.client,
bob,
]);
});
it("reaches logins that finish connecting after the first pass", async () => {
const { rerender } = render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.resyncWebPush).toHaveBeenCalledTimes(1);
// The background restore connects bob and bumps the revision.
const bob = { getAccountId: () => "account-2" };
mocks.authState.connected.set("bob@mail.example.com", bob);
mocks.authState.connectedAccountsRevision = 1;
rerender(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.resyncWebPush.mock.calls.map(([p]) => p.client)).toContain(bob);
});
it("sends a changed mute to every login right away, past the daily limit", async () => {
const bob = { getAccountId: () => "account-2" };
mocks.authState.connected.set("bob@mail.example.com", bob);
const { rerender } = render(<PushNotificationPrompt />);
await advancePromptDelay();
mocks.resyncWebPush.mockClear();
// An equal object from a settings sync is no change.
mocks.settingsState.notificationMutes = { accounts: [], mailboxes: {} };
rerender(<PushNotificationPrompt />);
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
expect(mocks.resyncWebPush).not.toHaveBeenCalled();
const muted = { accounts: ["account-2"], mailboxes: {} };
mocks.settingsState.notificationMutes = muted;
rerender(<PushNotificationPrompt />);
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
expect(mocks.resyncWebPush).toHaveBeenCalledTimes(2);
for (const [params] of mocks.resyncWebPush.mock.calls) {
expect(params).toMatchObject({ mutes: muted, force: true });
}
expect(mocks.resyncWebPush.mock.calls.map(([p]) => p.client)).toContain(bob);
});
it("does not touch push in demo mode", async () => {
mocks.authState.isDemoMode = true;
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.resyncWebPush).not.toHaveBeenCalled();
});
it("does not render when browser notifications are blocked", async () => {
setNotificationPermission("denied");
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.isWebPushEnabled).not.toHaveBeenCalled();
expect(screen.queryByText("title")).not.toBeInTheDocument();
});
it("does not render on the settings route", async () => {
mocks.pathname = "/en/settings";
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.isWebPushEnabled).not.toHaveBeenCalled();
});
it("persists the per-account don't-remind choice", async () => {
const { unmount } = render(<PushNotificationPrompt />);
await advancePromptDelay();
fireEvent.click(screen.getByText("dont_remind"));
expect(
localStorage.getItem("bulwark.push.onboarding.dismissed.v1.account-1"),
).toBe("1");
unmount();
mocks.isWebPushEnabled.mockClear();
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.isWebPushEnabled).not.toHaveBeenCalled();
});
it("keeps the session dismissal of every account that was dismissed", async () => {
const { rerender } = render(<PushNotificationPrompt />);
await advancePromptDelay();
fireEvent.click(screen.getByText("not_now"));
mocks.authState.client = { getAccountId: () => "account-2" };
rerender(<PushNotificationPrompt />);
await advancePromptDelay();
expect(screen.getByText("title")).toBeInTheDocument();
fireEvent.click(screen.getByText("not_now"));
mocks.authState.client = { getAccountId: () => "account-1" };
rerender(<PushNotificationPrompt />);
await advancePromptDelay();
expect(screen.queryByText("title")).not.toBeInTheDocument();
});
it("defers push onboarding until the PWA install prompt is closed", async () => {
render(<PushNotificationPrompt />);
act(() => {
window.dispatchEvent(
new CustomEvent(PWA_INSTALL_PROMPT_VISIBILITY_EVENT, {
detail: { visible: true },
}),
);
});
await advancePromptDelay();
expect(mocks.isWebPushEnabled).not.toHaveBeenCalled();
expect(screen.queryByText("title")).not.toBeInTheDocument();
act(() => {
window.dispatchEvent(
new CustomEvent(PWA_INSTALL_PROMPT_VISIBILITY_EVENT, {
detail: { visible: false },
}),
);
});
await advancePromptDelay();
expect(mocks.isWebPushEnabled).toHaveBeenCalledWith("account-1");
expect(screen.getByText("title")).toBeInTheDocument();
});
});