Files
TX/artifacts/api-server/tests/push-410-unit.test.ts
T

328 lines
9.8 KiB
TypeScript
Raw Normal View History

// 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<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",
);
});
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, "أرسل لك ملاحظة");
});