mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
# 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
477 lines
17 KiB
TypeScript
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();
|
|
});
|
|
});
|