Files
gerbeur/api/test/services/digest-service.test.ts

367 lines
12 KiB
TypeScript

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 <b>" });
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 <b>", "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) });
// Dated in the past: a dump posted in the very millisecond the run takes
// as `now` falls outside the window, which ends before it.
const d = insertDump({
userId: quiet.id,
title: "Recent",
createdAt: ago(DAY),
});
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);
}
});