import { captureEmails, createUser, db, insertComment, insertDump, insertPlaylist, post, test, } from "../helpers.ts"; import { assert, assertEquals, assertFalse } from "@std/assert"; import { buildDigest, sendDueDigests, syncDigestSchedules, } from "../../services/digest-service.ts"; import { PERIOD_MS } from "../../../shared/settings.ts"; import { getSettings, updateSettings, } from "../../services/settings-service.ts"; import { createUnsubscribeToken } from "../../lib/jwt.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=")); // Every page link has a v4 shape, and none names anything by its id. const pageLinks = [ ...digest.activity!.highlights.flatMap((h) => h.target?.url ? [h.target.url] : [] ), ...digest.network!.dumps.map((d) => d.url), ...digest.top.map((d) => d.url), ...digest.community!.playlists.map((p) => p.url), digest.links.followed, digest.links.settings, ]; assertEquals(pageLinks.length, 8); for (const link of pageLinks) { assert( /^\/@\w+\/(dumps|playlists)\/[a-z0-9-]+$|^\/follow$|^\/settings$/.test( new URL(link).pathname, ), link, ); for (const id of [reader.id, mine.id, posted.id, added.id, mix.id]) { assertFalse(link.includes(id), link); } } assertEquals( new URL(digest.top[1].url).pathname, `/@${reader.username}/dumps/my-dump`, ); }); test("buildDigest: names and links follow renames, and a deleted dump is unlinked", async () => { const reader = await createUser(); const friend = await createUser(); const kept = insertDump({ userId: reader.id, title: "Kept" }); const gone = insertDump({ userId: reader.id, title: "Gone" }); for (const d of [kept, gone]) { notify(reader.id, "dump_upvoted", { voterId: friend.id, voterUsername: friend.username, dumpId: d.id, dumpTitle: d.id === kept.id ? "Kept" : "Gone", }); notify(reader.id, "dump_commented", { commenterId: friend.id, commenterUsername: friend.username, commentId: crypto.randomUUID(), dumpId: d.id, dumpTitle: d.id === kept.id ? "Kept" : "Gone", }); } db.prepare(`UPDATE users SET username = 'renamed_friend' WHERE id = ?;`) .run(friend.id); db.prepare(`UPDATE dumps SET slug = 'still-kept' WHERE id = ?;`) .run(kept.id); db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(gone.id); const [from, to] = week(); const digest = (buildDigest(reader.id, reader.username, "weekly", from, to, "tok"))!; const targets = digest.activity!.highlights.map((h) => [ h.who, h.target?.title, h.target?.url && new URL(h.target.url).pathname, ]); assertEquals( targets.sort((a, b) => String(a[1]).localeCompare(String(b[1]))), [ ["renamed_friend", "Gone", null], ["renamed_friend", "Kept", `/@${reader.username}/dumps/still-kept`], ], ); }); 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(); for (const path of ["/api/unsubscribe", "/api/users/unsubscribe"]) { assertEquals((await post(`${path}?token=nope`)).status, 400, path); assertEquals( (await post(`${path}?token=${user.token}`)).status, 400, path, ); assertEquals((await post(path)).status, 400, path); } }); test("unsubscribe: the v4 path and the v3 one sent emails carry both work", async () => { const user = await createUser(); for ( const [path, cadence, setting] of [ ["/api/unsubscribe", "weekly", "digestWeekly"], ["/api/users/unsubscribe", "monthly", "digestMonthly"], ] as const ) { const token = await createUnsubscribeToken(user.id, cadence); const res = await post<{ cadence: string }>(`${path}?token=${token}`); assertEquals(res.data.cadence, cadence); assertEquals(getSettings(user.id)[setting], false, path); } });