Files
TX/artifacts/api-server/tests/push-410-unit.test.ts
T
riyadhafraa 55d19298e7 Add bilingual support for notifications and automatic HTTPS
Implement bilingual text for push notifications, allowing users to receive them in their preferred language. Automatically configure HTTPS with Let's Encrypt for custom domains to ensure persistent subscriptions.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 94f06aee-a5cf-45df-979e-f940cce77214
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/yEFtMKN
Replit-Helium-Checkpoint-Created: true
2026-05-16 16:28:42 +00:00

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