0740971a96
Task #554. Adds a full Web Push stack on top of the existing Socket.IO notification fan-out so the iPad PWA (and any installed browser) receives system notifications when Tx OS is closed or backgrounded. Backend - New `push_subscriptions` table (userId + unique endpoint + p256dh/auth keys + ua + timestamps), exported from `@workspace/db`. - `artifacts/api-server/src/lib/push.ts`: - VAPID bootstrap from env, else cached file at LOCAL_STORAGE_ROOT (Docker volume) with /tmp fallback for Replit dev, else ephemeral. - `sendPushToUser()` honours `notificationsMuted` + per-channel prefs (orders/meetings/notes), prunes 404/410 endpoints, truncates payload bodies to ~3500 bytes. - **De-dup gate:** skips push when the user has any active Socket.IO connection (uses `io.in(\`user:\${uid}\`).fetchSockets()`), so a connected user only gets the in-app chime, never a duplicate system notification. - `upsertSubscription()` deletes a stale row first when the same browser endpoint flips to a different user (shared device) so the previous user's notifications can't leak. - Four new routes: `GET /api/push/vapid-public-key`, `POST /api/push/subscribe`, `POST /api/push/unsubscribe`, and a spec-aligned alias `DELETE /api/push/subscribe?endpoint=...` (auth-gated, Zod-validated). - Push hooked into 4 existing emit sites: service-orders `notifyUser`, notes new-note + reply, executive-meeting broadcast. Frontend - `artifacts/tx-os/public/sw.js`: push + notificationclick only (no asset caching). All URLs (icon, badge, navigation target) resolved against `self.registration.scope`, so the SW works at root or under a subpath without code changes. - SW registration in `main.tsx` scoped to `BASE_URL`, gated on `serviceWorker` AND `PushManager` support so older browsers no-op cleanly. - `use-push-subscription` hook (enable/disable/refresh + status). - New `PushEnablePrompt` card mounted in `App` — appears on first launch when supported + permission still "default", one-tap enable, dismiss persists for 14 days. Silent on unsupported devices. - `PushToggleRow` added inside Notification Settings. - ar/en strings: `notifSettings.push.*` and `common.later`. Plumbing - OpenAPI: 3 new operations under `notifications`; orval codegen run. - `docker-compose.yml` passes VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT through to the api service. - `pnpm --filter @workspace/db run push` applied the schema. - web-push + @types/web-push installed in api-server. Verification - API restarts clean; `/api/push/vapid-public-key` returns the key; subscribe endpoint 401s without auth. - New `artifacts/api-server/tests/push.test.mjs` — 7/7 passing — covers VAPID endpoint, auth gating on subscribe/unsubscribe, row persistence + removal, idempotent re-subscribe with key rotation, account-switch endpoint reassignment, and malformed-input rejection. - New `artifacts/api-server/tests/push-410-unit.test.ts` — 3/3 passing — true in-process unit test (`node --import tsx --experimental-test-module-mocks`) that mocks `web-push` to verify the prune path: 410 deletes the row, 404 deletes the row, and a transient 500 leaves the row intact. `tsx` added as a devDep and the test:run script picks up `*.test.ts` alongside the existing `.mjs` suites. - OpenAPI updated: `DELETE /push/subscribe` documented with an `endpoint` query parameter alongside the existing POST routes; orval codegen re-run so the generated client + zod schemas stay in sync. - Restored the unrelated serial tests (`executive-meetings-notifications.test.mjs`, `setup-wizard.test.mjs`) that an earlier cleanup pass dropped — they are unchanged from prior green state. - Architect rounds addressed: 1. race + payload size. 2. dedup gate + first-launch UX + SW base-path + initial tests. 3. DELETE alias + PushManager check + restored serial tests + real 410/404 cleanup test + OpenAPI parity.
185 lines
5.6 KiB
TypeScript
185 lines
5.6 KiB
TypeScript
// True unit test for the 404/410 cleanup path in sendPushToUser.
|
|
//
|
|
// Runs in-process via `tsx` so we can `mock.module('web-push', ...)` to
|
|
// stub the actual delivery call. We:
|
|
// 1. Insert a real row in `push_subscriptions` for a freshly-created
|
|
// throwaway user.
|
|
// 2. Force the mocked `webpush.sendNotification` to throw a 410 once
|
|
// (and 404 once) the way the FCM/APNs bridges do.
|
|
// 3. Call `sendPushToUser` directly and assert the row was pruned.
|
|
//
|
|
// This is the closest we can get to exercising the production prune
|
|
// path without standing up a real push gateway, and it exercises the
|
|
// real DB + real lib code (only `web-push` and the socket-presence
|
|
// check are stubbed).
|
|
//
|
|
// Run with:
|
|
// pnpm --filter @workspace/api-server exec \
|
|
// node --import tsx --test tests/push-410-unit.test.ts
|
|
import { test, before, after, mock } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import pg from "pg";
|
|
|
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
if (!DATABASE_URL) {
|
|
throw new Error("DATABASE_URL must be set to run these tests");
|
|
}
|
|
|
|
// Track sendNotification calls and let each test rig the next response.
|
|
const sendCalls: Array<{ endpoint: string }> = [];
|
|
let nextThrow: { statusCode: number } | null = null;
|
|
|
|
mock.module("web-push", {
|
|
defaultExport: {
|
|
setVapidDetails: () => {},
|
|
generateVAPIDKeys: () => ({
|
|
publicKey: "B".padEnd(87, "A"),
|
|
privateKey: "C".padEnd(43, "A"),
|
|
}),
|
|
sendNotification: async (sub: { endpoint: string }) => {
|
|
sendCalls.push({ endpoint: sub.endpoint });
|
|
if (nextThrow) {
|
|
const err: Error & { statusCode?: number } = new Error("stubbed");
|
|
err.statusCode = nextThrow.statusCode;
|
|
nextThrow = null;
|
|
throw err;
|
|
}
|
|
return { statusCode: 201, body: "", headers: {} };
|
|
},
|
|
},
|
|
});
|
|
|
|
// Block the socket-presence dedup gate from short-circuiting the send.
|
|
// `sendPushToUser` lazy-imports `../index.js` to read `io`; throwing
|
|
// here makes `isUserConnected` swallow the error and return false.
|
|
mock.module("../src/index.js", {
|
|
namedExports: {
|
|
get io() {
|
|
throw new Error("io not available in unit test");
|
|
},
|
|
},
|
|
});
|
|
|
|
// Ensure VAPID is configured (the lib has its own fallback, but being
|
|
// explicit avoids surprise re-keying between runs).
|
|
process.env.VAPID_PUBLIC_KEY =
|
|
process.env.VAPID_PUBLIC_KEY ?? "B".padEnd(87, "A");
|
|
process.env.VAPID_PRIVATE_KEY =
|
|
process.env.VAPID_PRIVATE_KEY ?? "C".padEnd(43, "A");
|
|
process.env.VAPID_SUBJECT = process.env.VAPID_SUBJECT ?? "mailto:test@example.com";
|
|
|
|
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
|
const createdUserIds: number[] = [];
|
|
|
|
async function createUser(prefix: string): Promise<number> {
|
|
const username = `${prefix}_${Date.now().toString(36)}_${Math.random()
|
|
.toString(36)
|
|
.slice(2, 8)}`;
|
|
const { rows } = await pool.query(
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
|
VALUES ($1, $2, '$2b$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $3, 'en', true)
|
|
RETURNING id`,
|
|
[username, `${username}@example.com`, prefix],
|
|
);
|
|
const id = rows[0].id as number;
|
|
createdUserIds.push(id);
|
|
return id;
|
|
}
|
|
|
|
async function insertSub(userId: number, endpoint: string): Promise<void> {
|
|
await pool.query(
|
|
`INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth)
|
|
VALUES ($1, $2, 'p256dh_test', 'auth_test')`,
|
|
[userId, endpoint],
|
|
);
|
|
}
|
|
|
|
async function rowExists(endpoint: string): Promise<boolean> {
|
|
const r = await pool.query(
|
|
`SELECT 1 FROM push_subscriptions WHERE endpoint = $1`,
|
|
[endpoint],
|
|
);
|
|
return (r.rowCount ?? 0) > 0;
|
|
}
|
|
|
|
// Import AFTER mocks are registered.
|
|
const { sendPushToUser } = await import("../src/lib/push.js");
|
|
|
|
before(() => {
|
|
// Touch sendCalls so the import is exercised.
|
|
sendCalls.length = 0;
|
|
});
|
|
|
|
after(async () => {
|
|
if (createdUserIds.length > 0) {
|
|
await pool.query(
|
|
`DELETE FROM push_subscriptions WHERE user_id = ANY($1::int[])`,
|
|
[createdUserIds],
|
|
);
|
|
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
|
createdUserIds,
|
|
]);
|
|
}
|
|
await pool.end();
|
|
});
|
|
|
|
test("410 Gone response prunes the stale push subscription", async () => {
|
|
const userId = await createUser("p410");
|
|
const endpoint = `https://push.example.test/410-${Date.now()}`;
|
|
await insertSub(userId, endpoint);
|
|
assert.equal(await rowExists(endpoint), true);
|
|
|
|
nextThrow = { statusCode: 410 };
|
|
await sendPushToUser(userId, {
|
|
title: "t",
|
|
body: "b",
|
|
type: "note",
|
|
});
|
|
|
|
assert.equal(sendCalls.at(-1)?.endpoint, endpoint, "stub should have been called");
|
|
assert.equal(
|
|
await rowExists(endpoint),
|
|
false,
|
|
"row must be pruned after a 410 response",
|
|
);
|
|
});
|
|
|
|
test("404 Not Found response prunes the stale push subscription", async () => {
|
|
const userId = await createUser("p404");
|
|
const endpoint = `https://push.example.test/404-${Date.now()}`;
|
|
await insertSub(userId, endpoint);
|
|
assert.equal(await rowExists(endpoint), true);
|
|
|
|
nextThrow = { statusCode: 404 };
|
|
await sendPushToUser(userId, {
|
|
title: "t",
|
|
body: "b",
|
|
type: "note",
|
|
});
|
|
|
|
assert.equal(
|
|
await rowExists(endpoint),
|
|
false,
|
|
"row must be pruned after a 404 response",
|
|
);
|
|
});
|
|
|
|
test("non-410/404 errors leave the subscription intact", async () => {
|
|
const userId = await createUser("p500");
|
|
const endpoint = `https://push.example.test/500-${Date.now()}`;
|
|
await insertSub(userId, endpoint);
|
|
|
|
nextThrow = { statusCode: 500 };
|
|
await sendPushToUser(userId, {
|
|
title: "t",
|
|
body: "b",
|
|
type: "note",
|
|
});
|
|
|
|
assert.equal(
|
|
await rowExists(endpoint),
|
|
true,
|
|
"transient 5xx must NOT delete the subscription",
|
|
);
|
|
});
|