Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
/**
* Notification smoke test — `npm run check:notify`.
*
* Against a throwaway SQLite file. What this guards: fan-out picks exactly the
* right recipients (never the actor, never twice per event), cleanup means a
* notification never outlives its subject, the read path drops rows whose
* target went private, and the sweep keeps the 90-day promise in /terms.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// The database is a lazy singleton keyed off this variable, so it has to be set
// before anything imports it — hence the dynamic imports below.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-notify-"));
process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db");
process.env.COMMUNITY_ENABLED = "1";
const DAY = 24 * 60 * 60 * 1000;
const NOW = new Date("2026-08-01T12:00:00Z");
const ago = (days: number) => new Date(NOW.getTime() - days * DAY);
let failures = 0;
function check(label: string, actual: unknown, expected: unknown) {
const ok = JSON.stringify(actual) === JSON.stringify(expected);
if (!ok) failures += 1;
console.log(
`${ok ? " ok " : "FAIL "}${label}${ok ? "" : `\n got ${JSON.stringify(actual)}\n want ${JSON.stringify(expected)}`}`,
);
}
async function main() {
const { eq } = await import("drizzle-orm");
const { getDb } = await import("../src/lib/community/server/db");
const schema = await import("../src/lib/community/server/schema");
const queries = await import("../src/lib/community/server/queries");
const notify = await import("../src/lib/community/server/notify");
const { sweepRetention } = await import("../src/lib/community/server/retention");
const db = getDb();
// ── Seed ───────────────────────────────────────────────────────────────────
const person = (id: string) => ({
id,
name: id,
email: `${id}@patternflow.local`,
emailVerified: false,
createdAt: ago(30),
updatedAt: ago(30),
username: id,
displayUsername: id,
});
await db.insert(schema.user).values([person("alice"), person("bob"), person("cara")]);
await db.insert(schema.patterns).values([
{
id: "p1",
userId: "alice",
title: "Alice One",
code: "// p1",
license: "CC-BY-SA-4.0",
visibility: "public",
createdAt: ago(10),
updatedAt: ago(10),
},
{
id: "p-bob",
userId: "bob",
title: "Bob Own",
code: "// pb",
license: "CC-BY-SA-4.0",
visibility: "public",
createdAt: ago(10),
updatedAt: ago(10),
},
]);
// Threads live on the map now, so the fixture needs somewhere to put one.
await db.insert(schema.territories).values({
id: "z1",
code: "A1",
title: "Somewhere",
createdAt: ago(20),
});
await db.insert(schema.posts).values({
id: "t1",
territoryId: "z1",
userId: "alice",
title: "Alice Thread",
body: "hello",
createdAt: ago(10),
updatedAt: ago(10),
});
// Mirrors the route: insert the comment, then fan out.
const comment = async (id: string, actor: string, body: string) => {
await db.insert(schema.comments).values({
id,
patternId: "p1",
userId: actor,
body,
createdAt: NOW,
});
await notify.notifyCommentAdded({
on: "pattern",
targetId: "p1",
targetTitle: "Alice One",
ownerId: "alice",
actorId: actor,
commentId: id,
body,
});
};
console.log("\n── comment fan-out on a flat thread ──");
await comment("c1", "bob", "first!");
check("the owner hears about the first comment", await queries.countUnreadNotifications("alice"), 1);
check("the actor hears nothing", await queries.countUnreadNotifications("bob"), 0);
await comment("c2", "cara", "second");
check("the owner hears again", await queries.countUnreadNotifications("alice"), 2);
check("the earlier commenter hears the thread moved", await queries.countUnreadNotifications("bob"), 1);
await comment("c3", "alice", "thanks both");
check("the owner never hears about their own comment", await queries.countUnreadNotifications("alice"), 2);
check("both earlier commenters hear it", [
await queries.countUnreadNotifications("bob"),
await queries.countUnreadNotifications("cara"),
], [2, 1]);
const bobRows = await queries.listNotifications("bob");
check("thread rows say so", bobRows.map((row) => row.type), ["thread", "thread"]);
check("and carry the snippet", bobRows[0]?.snippet, "thanks both");
console.log("\n── post comments use the same path ──");
await db.insert(schema.postComments).values({
id: "pc1",
postId: "t1",
userId: "bob",
body: "post reply",
createdAt: NOW,
});
await notify.notifyCommentAdded({
on: "post",
targetId: "t1",
targetTitle: "Alice Thread",
ownerId: "alice",
actorId: "bob",
commentId: "pc1",
body: "post reply",
});
check("the post's author hears it", await queries.countUnreadNotifications("alice"), 3);
check(
"the row points at the post",
(await queries.listNotifications("alice"))[0]?.targetType,
"post",
);
console.log("\n── forks ──");
// The route inserts the fork before notifying, so the row's target exists —
// the read path drops rows whose target is missing, and that guard is
// exercised separately below.
await db.insert(schema.patterns).values({
id: "p-fork",
userId: "bob",
title: "Alice One remix",
code: "// fork",
license: "CC-BY-SA-4.0",
visibility: "public",
parentId: "p1",
createdAt: NOW,
updatedAt: NOW,
});
await notify.notifyForkPublished({
parentOwnerId: "alice",
parentTitle: "Alice One",
forkId: "p-fork",
forkVisibility: "public",
actorId: "bob",
});
check("a public fork tells the parent's author", await queries.countUnreadNotifications("alice"), 4);
await notify.notifyForkPublished({
parentOwnerId: "alice",
parentTitle: "Alice One",
forkId: "p-fork2",
forkVisibility: "private",
actorId: "bob",
});
check("a private fork tells nobody — its page would 404", await queries.countUnreadNotifications("alice"), 4);
await notify.notifyForkPublished({
parentOwnerId: "alice",
parentTitle: "Alice One",
forkId: "p-fork3",
forkVisibility: "public",
actorId: "alice",
});
check("forking your own work tells nobody", await queries.countUnreadNotifications("alice"), 4);
console.log("\n── deck inclusion ──");
await db.insert(schema.decks).values({
id: "d1",
userId: "bob",
title: "Bob's Shelf",
visibility: "public",
createdAt: NOW,
updatedAt: NOW,
});
const deckPatterns = [
{ id: "p1", title: "Alice One", userId: "alice" },
{ id: "p-bob", title: "Bob Own", userId: "bob" },
];
await notify.notifyDeckInclusion({
deckId: "d1",
deckTitle: "Bob's Shelf",
actorId: "bob",
patterns: deckPatterns,
});
check("the pattern's author hears it", await queries.countUnreadNotifications("alice"), 5);
check("the deck owner's own pattern tells nobody", await queries.countUnreadNotifications("bob"), 2);
await notify.notifyDeckInclusion({
deckId: "d1",
deckTitle: "Bob's Shelf",
actorId: "bob",
patterns: deckPatterns,
});
check("repeating the event while unread is absorbed", await queries.countUnreadNotifications("alice"), 5);
console.log("\n── reading ──");
await queries.markNotificationsRead("alice");
check("opening the page reads everything", await queries.countUnreadNotifications("alice"), 0);
check("the rows themselves stay", (await queries.listNotifications("alice")).length, 5);
console.log("\n── the read path respects visibility ──");
await db.update(schema.patterns).set({ visibility: "private" }).where(eq(schema.patterns.id, "p1"));
check(
"rows about a now-private pattern vanish for others",
(await queries.listNotifications("cara")).length,
0,
);
check(
"but its own author keeps theirs",
(await queries.listNotifications("alice")).some((row) => row.targetId === "p1"),
true,
);
// Every private page opens for a moderator, and after a take-down the
// author's answer in the thread is the row they most need.
check(
"and so does a moderator",
(await queries.listNotifications("cara", { moderator: true })).length,
1,
);
await db.update(schema.patterns).set({ visibility: "public" }).where(eq(schema.patterns.id, "p1"));
check("and they return when it does", (await queries.listNotifications("cara")).length, 1);
await db.update(schema.decks).set({ visibility: "private" }).where(eq(schema.decks.id, "d1"));
check(
"a deck gone private hides its inclusion row",
(await queries.listNotifications("alice")).some((row) => row.targetType === "deck"),
false,
);
await db.update(schema.decks).set({ visibility: "public" }).where(eq(schema.decks.id, "d1"));
console.log("\n── cleanup — a notification never outlives its subject ──");
await notify.clearNotificationsFor({ sourceId: "c3" });
check(
"deleting a comment takes its rows (bob's thread ping)",
(await queries.listNotifications("bob")).map((row) => row.snippet),
["second"],
);
await notify.clearNotificationsFor({ targetType: "deck", targetId: "d1" });
check(
"deleting the deck takes the inclusion row",
(await queries.listNotifications("alice")).some((row) => row.targetType === "deck"),
false,
);
await notify.clearNotificationsFor({ targetType: "pattern", targetId: "p1", sourceId: "p1" });
check(
"deleting the pattern takes everything that pointed at it",
(await queries.listNotifications("cara")).length,
0,
);
console.log("\n── the sweep keeps the 90-day promise ──");
await db.insert(schema.notifications).values([
{
id: "n-old",
userId: "alice",
type: "comment",
actorId: "bob",
targetType: "post",
targetId: "t1",
targetTitle: "Alice Thread",
createdAt: ago(91),
},
{
id: "n-new",
userId: "alice",
type: "comment",
actorId: "bob",
targetType: "post",
targetId: "t1",
targetTitle: "Alice Thread",
createdAt: ago(1),
},
]);
const swept = await sweepRetention(NOW);
check("ninety-one days is over the line", swept.oldNotifications, 1);
const left = await db.select({ id: schema.notifications.id }).from(schema.notifications);
check("yesterday's survives", left.some((row) => row.id === "n-new"), true);
check("no sweep errors", swept.errors, []);
}
main()
.then(() => {
console.log(failures === 0 ? "\nAll notify checks passed.\n" : `\n${failures} check(s) FAILED.\n`);
process.exit(failures === 0 ? 0 : 1);
})
.catch((error: unknown) => {
console.error(error);
process.exit(1);
})
.finally(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
07e931b46This file is pinned to the selected release. Download the original to open it in your design tool.