2026-05-16 15:39:41 +00:00
|
|
|
// 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.
|
2026-05-16 16:28:42 +00:00
|
|
|
const sendCalls: Array<{ endpoint: string; payload: string }> = [];
|
2026-05-16 15:39:41 +00:00
|
|
|
let nextThrow: { statusCode: number } | null = null;
|
|
|
|
|
|
|
|
|
|
mock.module("web-push", {
|
|
|
|
|
defaultExport: {
|
|
|
|
|
setVapidDetails: () => {},
|
|
|
|
|
generateVAPIDKeys: () => ({
|
|
|
|
|
publicKey: "B".padEnd(87, "A"),
|
|
|
|
|
privateKey: "C".padEnd(43, "A"),
|
|
|
|
|
}),
|
2026-05-16 16:28:42 +00:00
|
|
|
sendNotification: async (sub: { endpoint: string }, payload: string) => {
|
|
|
|
|
sendCalls.push({ endpoint: sub.endpoint, payload });
|
2026-05-16 15:39:41 +00:00
|
|
|
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",
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-05-16 16:28:42 +00:00
|
|
|
|
|
|
|
|
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, "أرسل لك ملاحظة");
|
|
|
|
|
});
|