// 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; payload: 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 }, payload: string) => { sendCalls.push({ endpoint: sub.endpoint, payload }); 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 { 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 { 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 { 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", ); }); test("notificationsMuted=true silences every channel", async () => { const userId = await createUser("pmute"); const endpoint = `https://push.example.test/mute-${Date.now()}`; await insertSub(userId, endpoint); await pool.query( `UPDATE users SET notifications_muted = true WHERE id = $1`, [userId], ); const before = sendCalls.length; await sendPushToUser(userId, { title: "t", body: "b", type: "note", }); assert.equal( sendCalls.length, before, "muted user must not trigger sendNotification at all", ); assert.equal( await rowExists(endpoint), true, "muting must not delete the subscription", ); }); test("per-channel flag off skips that channel only", async () => { const userId = await createUser("pchan"); const endpoint = `https://push.example.test/chan-${Date.now()}`; await insertSub(userId, endpoint); await pool.query( `UPDATE users SET notify_notes_enabled = false WHERE id = $1`, [userId], ); const before = sendCalls.length; await sendPushToUser(userId, { title: "t", body: "b", type: "note", }); assert.equal( sendCalls.length, before, "notes-disabled user must not receive a note push", ); // Same user, different channel — order push must still fire. await sendPushToUser(userId, { title: "t", body: "b", type: "order", }); assert.equal( sendCalls.length, before + 1, "order channel must still fire when only notes is off", ); }); test("oversized body is truncated so the wire payload stays under 4096 bytes", async () => { const userId = await createUser("ptrunc"); const endpoint = `https://push.example.test/trunc-${Date.now()}`; await insertSub(userId, endpoint); // 8000 bytes of ASCII — comfortably above the 3500-byte budget and // way past the 4096-byte web-push cap, so truncation MUST kick in. const huge = "x".repeat(8000); await sendPushToUser(userId, { title: "t", body: huge, type: "note", }); const sent = sendCalls.at(-1); assert.ok(sent, "send stub should have been called"); const size = Buffer.byteLength(sent.payload, "utf8"); assert.ok( size < 4096, `truncated payload must fit under 4096 bytes, got ${size}`, ); const parsed = JSON.parse(sent.payload) as { body: string }; assert.ok( parsed.body.length < huge.length, "body must have been shortened", ); }); test("preferredLanguage=en picks the English title/body", async () => { const userId = await createUser("plangen"); await pool.query( `UPDATE users SET preferred_language = 'en' WHERE id = $1`, [userId], ); const endpoint = `https://push.example.test/langen-${Date.now()}`; await insertSub(userId, endpoint); await sendPushToUser(userId, { title: "ar-fallback", body: "ar-fallback", titleAr: "ملاحظة جديدة", titleEn: "New note", bodyAr: "أرسل لك ملاحظة", bodyEn: "sent you a note", type: "note", }); const sent = sendCalls.at(-1); assert.ok(sent, "send stub should have been called"); const parsed = JSON.parse(sent.payload) as { title: string; body: string }; assert.equal(parsed.title, "New note"); assert.equal(parsed.body, "sent you a note"); }); test("preferredLanguage=ar picks the Arabic title/body", async () => { const userId = await createUser("plangar"); // Default is already 'ar' but be explicit so the test documents intent. await pool.query( `UPDATE users SET preferred_language = 'ar' WHERE id = $1`, [userId], ); const endpoint = `https://push.example.test/langar-${Date.now()}`; await insertSub(userId, endpoint); await sendPushToUser(userId, { title: "ignored", body: "ignored", titleAr: "ملاحظة جديدة", titleEn: "New note", bodyAr: "أرسل لك ملاحظة", bodyEn: "sent you a note", type: "note", }); const sent = sendCalls.at(-1); assert.ok(sent, "send stub should have been called"); const parsed = JSON.parse(sent.payload) as { title: string; body: string }; assert.equal(parsed.title, "ملاحظة جديدة"); assert.equal(parsed.body, "أرسل لك ملاحظة"); });