fix: sequence PWA and push onboarding

This commit is contained in:
Eduard Polonskyi
2026-08-03 15:31:17 +02:00
parent 573fab8b83
commit e036e10004
6 changed files with 309 additions and 12 deletions
+142 -2
View File
@@ -12,6 +12,18 @@ import { NextRequest, NextResponse } from 'next/server';
const ACCOUNT_ID = 'dev-account-001';
const scheduledSubmissions: Array<{ id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled' }> = [];
const emailCreationIds = new Map<string, string>();
const DEFAULT_PUSH_RELAY_ORIGIN = 'https://notifications.relay.bulwarkmail.org';
interface MockPushSubscription {
id: string;
deviceClientId: string;
expires: string | null;
types: string[] | null;
expectedVerificationCode: string;
verified: boolean;
}
const pushSubscriptions: MockPushSubscription[] = [];
// ---------------------------------------------------------------------------
// Mailboxes
@@ -1501,6 +1513,10 @@ function buildThreads() {
type MethodArgs = Record<string, unknown>;
type MethodResult = [string, Record<string, unknown>, string];
type MethodHandler = (
args: MethodArgs,
callId: string,
) => MethodResult | Promise<MethodResult>;
function handleCoreEcho(args: MethodArgs, callId: string): MethodResult {
return ['Core/echo', args, callId];
@@ -1934,12 +1950,134 @@ function handleSieveScriptGet(_args: MethodArgs, callId: string): MethodResult {
return ['SieveScript/get', { accountId: ACCOUNT_ID, state: nextState(), list: [], notFound: [] }, callId];
}
function handlePushSubscriptionGet(args: MethodArgs, callId: string): MethodResult {
const ids = Array.isArray(args.ids) ? args.ids as string[] : null;
const selected = ids
? pushSubscriptions.filter((subscription) => ids.includes(subscription.id))
: pushSubscriptions;
const foundIds = new Set(selected.map((subscription) => subscription.id));
return ['PushSubscription/get', {
state: nextState(),
list: selected.map(({ id, deviceClientId, expires, types }) => ({
id,
deviceClientId,
expires,
types,
})),
notFound: ids?.filter((id) => !foundIds.has(id)) ?? [],
}, callId];
}
async function handlePushSubscriptionSet(args: MethodArgs, callId: string): Promise<MethodResult> {
const created: Record<string, { id: string; expires: string | null; types: string[] | null }> = {};
const notCreated: Record<string, { type: string; description: string }> = {};
const updated: Record<string, null> = {};
const notUpdated: Record<string, { type: string; description: string }> = {};
const destroyed: string[] = [];
const notDestroyed: Record<string, { type: string }> = {};
const create = args.create as Record<string, Record<string, unknown>> | undefined;
if (create) {
for (const [creationId, data] of Object.entries(create)) {
try {
const url = new URL(String(data.url ?? ''));
const allowedOrigin = new URL(
process.env.NEXT_PUBLIC_PUSH_RELAY_URL || DEFAULT_PUSH_RELAY_ORIGIN,
).origin;
if (url.origin !== allowedOrigin || !url.pathname.startsWith('/api/push/jmap/')) {
throw new Error('The dev mock only sends verification to the configured push relay');
}
const id = `push-${crypto.randomUUID()}`;
const verificationCode = crypto.randomUUID().replace(/-/g, '');
const subscription: MockPushSubscription = {
id,
deviceClientId: String(data.deviceClientId ?? ''),
expires: typeof data.expires === 'string' ? data.expires : null,
types: Array.isArray(data.types) ? data.types as string[] : null,
expectedVerificationCode: verificationCode,
verified: false,
};
const verificationResponse = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
'@type': 'PushVerification',
pushSubscriptionId: id,
verificationCode,
}),
});
if (!verificationResponse.ok) {
throw new Error(`Push relay rejected verification (${verificationResponse.status})`);
}
pushSubscriptions.push(subscription);
created[creationId] = {
id,
expires: subscription.expires,
types: subscription.types,
};
} catch (error) {
notCreated[creationId] = {
type: 'invalidProperties',
description: error instanceof Error ? error.message : 'Invalid push subscription',
};
}
}
}
const update = args.update as Record<string, Record<string, unknown>> | undefined;
if (update) {
for (const [id, patch] of Object.entries(update)) {
const subscription = pushSubscriptions.find((candidate) => candidate.id === id);
if (!subscription) {
notUpdated[id] = { type: 'notFound', description: 'Push subscription not found' };
continue;
}
if (
typeof patch.verificationCode === 'string'
&& patch.verificationCode !== subscription.expectedVerificationCode
) {
notUpdated[id] = { type: 'invalidProperties', description: 'Invalid verification code' };
continue;
}
if (typeof patch.verificationCode === 'string') subscription.verified = true;
if (typeof patch.expires === 'string') subscription.expires = patch.expires;
if (Array.isArray(patch.types)) subscription.types = patch.types as string[];
updated[id] = null;
}
}
if (Array.isArray(args.destroy)) {
for (const id of args.destroy as string[]) {
const index = pushSubscriptions.findIndex((subscription) => subscription.id === id);
if (index === -1) {
notDestroyed[id] = { type: 'notFound' };
continue;
}
pushSubscriptions.splice(index, 1);
destroyed.push(id);
}
}
return ['PushSubscription/set', {
created,
notCreated,
updated,
notUpdated,
destroyed,
notDestroyed,
}, callId];
}
// Catch-all for unknown methods
function handleUnknown(method: string, _args: MethodArgs, callId: string): MethodResult {
return ['error', { type: 'unknownMethod', description: `Mock server does not implement ${method}` }, callId];
}
const METHOD_HANDLERS: Record<string, (args: MethodArgs, callId: string) => MethodResult> = {
const METHOD_HANDLERS: Record<string, MethodHandler> = {
'Core/echo': handleCoreEcho,
'Mailbox/get': handleMailboxGet,
'Mailbox/set': handleMailboxSet,
@@ -2009,6 +2147,8 @@ const METHOD_HANDLERS: Record<string, (args: MethodArgs, callId: string) => Meth
'CalendarEvent/set': (_args, callId) => ['CalendarEvent/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: null, updated: null, destroyed: null }, callId],
'SieveScript/get': handleSieveScriptGet,
'SieveScript/set': (_args, callId) => ['SieveScript/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: null, updated: null, destroyed: null }, callId],
'PushSubscription/get': handlePushSubscriptionGet,
'PushSubscription/set': handlePushSubscriptionSet,
};
// ---------------------------------------------------------------------------
@@ -2270,7 +2410,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
if (tooLarge) {
responses.push(tooLarge);
} else if (handler) {
const result = handler(args, callId);
const result = await handler(args, callId);
responses.push(result);
} else {
responses.push(handleUnknown(method, args, callId));
@@ -152,7 +152,7 @@ describe("PushNotificationPrompt", () => {
expect(mocks.isWebPushEnabled).not.toHaveBeenCalled();
});
it("defers push onboarding when the PWA install prompt was shown", async () => {
it("defers push onboarding until the PWA install prompt is closed", async () => {
render(<PushNotificationPrompt />);
act(() => {
window.dispatchEvent(
@@ -165,5 +165,17 @@ describe("PushNotificationPrompt", () => {
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();
});
});
@@ -0,0 +1,69 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
PWA_INSTALL_PROMPT_VISIBILITY_EVENT,
PWAInstallPrompt,
} from "../pwa-install-prompt";
vi.mock("@/hooks/use-config", () => ({
useConfig: () => ({
appName: "Bulwark",
faviconUrl: "",
appLogoLightUrl: "",
appLogoDarkUrl: "",
}),
}));
describe("PWAInstallPrompt", () => {
beforeEach(() => {
localStorage.clear();
});
it("closes after the browser install prompt is dismissed", async () => {
const prompt = vi.fn().mockResolvedValue(undefined);
const event = new Event("beforeinstallprompt");
Object.assign(event, {
prompt,
userChoice: Promise.resolve({ outcome: "dismissed" }),
});
render(<PWAInstallPrompt />);
act(() => window.dispatchEvent(event));
expect(screen.getByText("title")).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByText("install"));
await Promise.resolve();
});
expect(prompt).toHaveBeenCalledOnce();
expect(screen.queryByText("title")).not.toBeInTheDocument();
});
it("announces that it is hidden after Not now", () => {
const visibility: boolean[] = [];
const listener = (event: Event) => {
visibility.push(
(event as CustomEvent<{ visible?: boolean }>).detail?.visible === true,
);
};
window.addEventListener(PWA_INSTALL_PROMPT_VISIBILITY_EVENT, listener);
try {
const event = new Event("beforeinstallprompt");
Object.assign(event, {
prompt: vi.fn(),
userChoice: Promise.resolve({ outcome: "dismissed" }),
});
render(<PWAInstallPrompt />);
act(() => window.dispatchEvent(event));
fireEvent.click(screen.getByText("not_now"));
expect(visibility).toContain(true);
expect(visibility.at(-1)).toBe(false);
} finally {
window.removeEventListener(PWA_INSTALL_PROMPT_VISIBILITY_EVENT, listener);
}
});
});
+4 -4
View File
@@ -67,7 +67,7 @@ export function PushNotificationPrompt() {
const [error, setError] = useState<string | null>(null);
const [sessionDismissedAccountId, setSessionDismissedAccountId] =
useState<string | null>(null);
const [pwaPromptWasShown, setPwaPromptWasShown] = useState(false);
const [pwaPromptVisible, setPwaPromptVisible] = useState(false);
const accountId = client?.getAccountId() ?? null;
const relayBaseUrl = (adminPushRelayUrl ?? "").trim()
@@ -76,7 +76,7 @@ export function PushNotificationPrompt() {
useEffect(() => {
const handlePwaPromptVisibility = (event: Event) => {
const visible = (event as CustomEvent<{ visible?: boolean }>).detail?.visible;
if (visible) setPwaPromptWasShown(true);
setPwaPromptVisible(visible === true);
};
window.addEventListener(
@@ -107,7 +107,7 @@ export function PushNotificationPrompt() {
|| !emailNotificationsEnabled
|| isExcludedPath(pathname)
|| sessionDismissedAccountId === accountId
|| pwaPromptWasShown
|| pwaPromptVisible
|| wasDismissed(accountId)
|| !isWebPushSupported()
|| Notification.permission === "denied"
@@ -138,7 +138,7 @@ export function PushNotificationPrompt() {
isDemoMode,
pathname,
policyLoaded,
pwaPromptWasShown,
pwaPromptVisible,
sessionDismissedAccountId,
]);
+6 -5
View File
@@ -50,12 +50,13 @@ export function PWAInstallPrompt() {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
await deferredPrompt.userChoice;
if (outcome === "accepted") {
setDeferredPrompt(null);
setShowPrompt(false);
}
// A beforeinstallprompt event can only be consumed once. Close the card
// whether the browser prompt was accepted or dismissed so the next
// onboarding step can continue in the same session.
setDeferredPrompt(null);
setShowPrompt(false);
};
const handleDismiss = () => {
+75
View File
@@ -199,6 +199,81 @@ describe('dev-jmap mock server', () => {
});
});
describe('POST /api - PushSubscription lifecycle', () => {
it('creates, verifies, lists, and destroys a push subscription', async () => {
const relayFetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(null, { status: 202 }),
);
try {
const createReq = makeRequest('http://localhost:3000/api/dev-jmap/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
body: JSON.stringify({
methodCalls: [['PushSubscription/set', {
create: {
new: {
deviceClientId: 'browser-device-1',
url: 'https://notifications.relay.bulwarkmail.org/api/push/jmap/browser-device-1',
types: ['Email'],
expires: '2030-01-01T00:00:00.000Z',
},
},
}, 'c0']],
}),
});
const createRes = await POST(createReq, { params: Promise.resolve({ path: ['api'] }) });
const createData = await createRes.json();
const id = createData.methodResponses[0][1].created.new.id as string;
const verificationBody = JSON.parse(
String(relayFetch.mock.calls[0]?.[1]?.body),
) as { verificationCode: string };
expect(id).toMatch(/^push-/);
expect(relayFetch).toHaveBeenCalledOnce();
const verifyReq = makeRequest('http://localhost:3000/api/dev-jmap/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
body: JSON.stringify({
methodCalls: [['PushSubscription/set', {
update: { [id]: { verificationCode: verificationBody.verificationCode } },
}, 'v0']],
}),
});
const verifyRes = await POST(verifyReq, { params: Promise.resolve({ path: ['api'] }) });
const verifyData = await verifyRes.json();
expect(verifyData.methodResponses[0][1].updated[id]).toBeNull();
const getReq = makeRequest('http://localhost:3000/api/dev-jmap/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
body: JSON.stringify({
methodCalls: [['PushSubscription/get', { ids: [id] }, 'g0']],
}),
});
const getRes = await POST(getReq, { params: Promise.resolve({ path: ['api'] }) });
const getData = await getRes.json();
expect(getData.methodResponses[0][1].list).toEqual([
expect.objectContaining({ id, deviceClientId: 'browser-device-1' }),
]);
const destroyReq = makeRequest('http://localhost:3000/api/dev-jmap/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
body: JSON.stringify({
methodCalls: [['PushSubscription/set', { destroy: [id] }, 'd0']],
}),
});
const destroyRes = await POST(destroyReq, { params: Promise.resolve({ path: ['api'] }) });
const destroyData = await destroyRes.json();
expect(destroyData.methodResponses[0][1].destroyed).toEqual([id]);
} finally {
relayFetch.mockRestore();
}
});
});
describe('POST /api - back-references', () => {
it('should resolve #ids from Email/query result', async () => {
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {