271 lines
9.3 KiB
TypeScript
271 lines
9.3 KiB
TypeScript
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 <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="));
|
|
});
|
|
|
|
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);
|
|
});
|