361 lines
12 KiB
TypeScript
361 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,
|
|
PERIOD_MS,
|
|
sendDueDigests,
|
|
syncDigestSchedules,
|
|
} from "../../services/digest-service.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) });
|
|
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);
|
|
}
|
|
});
|