Files
TX/artifacts/api-server/tests/push-410-unit.test.ts
T
riyadhafraa 0740971a96 feat(push): add Web Push (VAPID) for lock-screen notifications on iPad PWA
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.
2026-05-16 15:39:41 +00:00

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",
);
});