import { captureEmails, createUser, db, insertComment, insertDump, insertPlaylist, post, test, } from "../helpers.ts"; import { assert, assertEquals, assertFalse } from "@std/assert"; import { buildDigest, PERIOD_MS, sendDueDigests, syncDigestSchedules, } from "../../services/digest-service.ts"; import { getSettings, updateSettings, } from "../../services/settings-service.ts"; const DAY = 86_400_000; const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); const week = () => [new Date(Date.now() - 7 * DAY), new Date(Date.now() + 60_000)] as const; function notify( userId: string, type: string, data: object, createdAt = ago(DAY), ) { db.prepare( `INSERT INTO notifications (id, user_id, type, data, read, created_at) VALUES (?, ?, ?, ?, 0, ?);`, ).run(crypto.randomUUID(), userId, type, JSON.stringify(data), createdAt); } function follow( followerId: string, target: { user?: string; playlist?: string }, ) { db.prepare( `INSERT INTO follows (id, follower_id, followed_user_id, followed_playlist_id, created_at) VALUES (?, ?, ?, ?, ?);`, ).run( crypto.randomUUID(), followerId, target.user ?? null, target.playlist ?? null, ago(DAY), ); } function vote(dumpId: string, n: number) { db.prepare(`UPDATE dumps SET vote_count = ? WHERE id = ?;`).run(n, dumpId); } function schedule(userId: string, cadence: string, windowStart: string) { db.prepare( `INSERT OR REPLACE INTO digest_schedule (user_id, cadence, window_start) VALUES (?, ?, ?);`, ).run(userId, cadence, windowStart); } const windowOf = (userId: string, cadence: string) => (db.prepare( `SELECT window_start FROM digest_schedule WHERE user_id = ? AND cadence = ?;`, ).get(userId, cadence) as { window_start: string } | undefined)?.window_start; test("buildDigest: the weekly digest gathers activity, network, top and community", async () => { const reader = await createUser(); const friend = await createUser({ invitedBy: reader.id }); const stranger = await createUser(); const mine = insertDump({ userId: reader.id, title: "My dump" }); vote(mine.id, 3); const commentId = insertComment({ dumpId: mine.id, userId: friend.id, body: "Loved **this** [link](https://x.test)", }); notify(reader.id, "dump_commented", { commenterId: friend.id, commenterUsername: friend.username, commentId, dumpId: mine.id, dumpTitle: "My dump", }); notify(reader.id, "user_followed", { followerId: friend.id, followerUsername: friend.username, }); for (let i = 0; i < 3; i++) { notify(reader.id, "dump_upvoted", { voterId: friend.id, voterUsername: friend.username, dumpId: mine.id, dumpTitle: "My dump", }); } // Before the period: not counted. notify(reader.id, "user_followed", { followerUsername: "old" }, ago(9 * DAY)); follow(reader.id, { user: friend.id }); const posted = insertDump({ userId: friend.id, title: "Friend's post" }); vote(posted.id, 5); insertDump({ userId: friend.id, title: "Private", isPrivate: true }); insertDump({ userId: friend.id, title: "Too old", createdAt: ago(10 * DAY) }); const mix = insertPlaylist({ userId: stranger.id, title: "Mix" }); follow(reader.id, { playlist: mix.id }); const added = insertDump({ userId: stranger.id, title: "Added " }); db.prepare( `INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, 0, ?);`, ).run(mix.id, added.id, ago(DAY)); // Posted by someone followed and added to a followed playlist: listed once. db.prepare( `INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, 1, ?);`, ).run(mix.id, posted.id, ago(DAY)); const [from, to] = week(); const digest = (buildDigest(reader.id, reader.username, "weekly", from, to, "tok"))!; assertEquals(digest.activity?.counts, { comments: 1, followers: 1, upvotes: 3, }); assertEquals(digest.activity?.total, 5); assertEquals(digest.activity?.highlights.map((h) => h.kind), [ "comment", "userFollow", ]); assertEquals(digest.activity?.highlights[0].quote, "Loved this link"); assertEquals(digest.network?.dumps.map((d) => [d.title, d.addedTo]), [ ["Friend's post", undefined], ["Added ", "Mix"], ]); assertEquals(digest.top.map((d) => [d.title, d.mine]), [ ["Friend's post", false], ["My dump", true], ]); assertEquals( digest.community?.members.map((m) => [m.username, m.invitedBy]), [ [friend.username, reader.username], [stranger.username, null], ], ); assertEquals(digest.community?.totals.dumps, 3); assert(digest.links.unsubscribe.includes("/unsubscribe?token=")); }); test("buildDigest: the monthly recap has no personal sections, and nothing makes no digest", async () => { const reader = await createUser({ createdAt: ago(40 * DAY) }); const [from, to] = [ new Date(Date.now() - 30 * DAY), new Date(Date.now() + 60_000), ]; assertEquals( buildDigest(reader.id, reader.username, "monthly", from, to, "tok"), null, ); const other = await createUser({ createdAt: ago(40 * DAY) }); notify(reader.id, "user_followed", { followerUsername: other.username }); const d = insertDump({ userId: other.id, title: "Hit" }); vote(d.id, 2); insertDump({ userId: other.id, title: "No votes" }); const recap = (buildDigest(reader.id, reader.username, "monthly", from, to, "tok"))!; assertEquals(recap.activity, null); assertEquals(recap.network, null); assertEquals(recap.top.map((t) => t.title), ["Hit"]); }); test("sendDueDigests: sends what's due in the reader's style, once, and moves the period on", async () => { using mail = captureEmails(); const reader = await createUser({ createdAt: ago(20 * DAY) }); const other = await createUser({ createdAt: ago(20 * DAY) }); updateSettings(reader.id, { style: "geocities", colorScheme: "dark" }); const d = insertDump({ userId: other.id, title: "Worth a look", createdAt: ago(3 * DAY), }); vote(d.id, 1); const start = ago(PERIOD_MS.weekly + DAY); schedule(reader.id, "weekly", start); schedule(reader.id, "monthly", ago(DAY)); schedule(other.id, "weekly", ago(DAY)); schedule(other.id, "monthly", ago(DAY)); assertEquals(await sendDueDigests(), 1); assertEquals(mail.sent.length, 1); const email = mail.sent[0]; assertEquals(email.to, reader.email); assert(email.subject.startsWith("Your week on gerbeur")); assert(email.html.includes("Worth a look")); assert(email.html.includes("#1b1b46"), "GeoCities' dark card"); assertFalse(email.html.includes("prefers-color-scheme")); assertEquals( email.headers?.["List-Unsubscribe-Post"], "List-Unsubscribe=One-Click", ); assertEquals( windowOf(reader.id, "weekly"), new Date(new Date(start).getTime() + PERIOD_MS.weekly).toISOString(), ); // Nothing is due any more. assertEquals(await sendDueDigests(), 0); // The header's link turns the weekly digest off, in one click. const link = email.headers!["List-Unsubscribe"].slice(1, -1); const res = await post(new URL(link).pathname + new URL(link).search, { headers: { "Content-Type": "application/x-www-form-urlencoded" }, }); assertEquals(res.status, 200); assertEquals(getSettings(reader.id).digestWeekly, false); assertEquals(getSettings(reader.id).digestMonthly, true); syncDigestSchedules(new Date()); assertEquals(windowOf(reader.id, "weekly"), undefined); }); test("sendDueDigests: an empty digest isn't sent, a late one covers the last period, and admin@localhost is skipped", async () => { using mail = captureEmails(); const quiet = await createUser({ createdAt: ago(60 * DAY) }); schedule(quiet.id, "weekly", ago(PERIOD_MS.weekly + DAY)); assertEquals(await sendDueDigests(), 0); assert(windowOf(quiet.id, "weekly")! > ago(2 * DAY)); const late = await createUser({ createdAt: ago(60 * DAY) }); const d = insertDump({ userId: quiet.id, title: "Recent" }); vote(d.id, 1); schedule(late.id, "weekly", ago(5 * PERIOD_MS.weekly)); const local = await createUser({ email: "admin@localhost", createdAt: ago(60 * DAY), }); schedule(local.id, "weekly", ago(PERIOD_MS.weekly + DAY)); const before = Date.now(); // Quiet's next period has only begun. assertEquals(await sendDueDigests(), 1); assertEquals(mail.sent.map((m) => m.to), [late.email]); // The late one's period is the last week, so the next starts now. assert(new Date(windowOf(late.id, "weekly")!).getTime() >= before); assert(windowOf(local.id, "weekly")! > ago(2 * DAY)); }); test("syncDigestSchedules: new users start now, a turned-off cadence loses its row", async () => { const user = await createUser(); const now = new Date(); syncDigestSchedules(now); assertEquals(windowOf(user.id, "weekly"), now.toISOString()); assertEquals(windowOf(user.id, "monthly"), now.toISOString()); updateSettings(user.id, { digestMonthly: false }); syncDigestSchedules(new Date(now.getTime() + 1000)); assertEquals(windowOf(user.id, "weekly"), now.toISOString()); assertEquals(windowOf(user.id, "monthly"), undefined); }); test("unsubscribe: a bad or other kind of token is refused", async () => { const user = await createUser(); assertEquals((await post("/api/users/unsubscribe?token=nope")).status, 400); assertEquals( (await post(`/api/users/unsubscribe?token=${user.token}`)).status, 400, ); assertEquals((await post("/api/users/unsubscribe")).status, 400); });