Files
gerbeur/api/test/services/community-service.test.ts
khannurien 51439f475d
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s
v4: the GeoCities marquee scrolls the community feed, what the site's members are doing — it crawled three canned lines, a welcome, "best viewed in Netscape Navigator" and "under construction", which said nothing of the site. The feed is a list any style may show (CommunityFeed.tsx), which GeoCities lays out along its marquee: who is online, the week's dumps, upvotes, comments and chat messages, linking to /community, its most upvoted dump, then the 12 latest things done in public, newest first, each ending on how long ago it was: a public dump posted, a public playlist created, a comment or an upvote on a public dump, someone joining and who invited them. Every name and title links to its page. A dump has one comment line and one upvote line at most, its latest, so that a thread or a popular dump doesn't fill the feed. GET /api/community/feed serves it, signed in only (community-service.ts): the events are read from the tables on each request, the week's figures are those /community already keeps, as of its last refresh. A signed-out visitor has no marquee. CommunityFeedProvider holds the feed around every page, so that it outlives the header each page renders anew, fetches it only while a component shows it, and fetches it again two seconds after the socket last told of a dump, a comment, a vote, a playlist, a rename, a deletion or a reconnection: the socket's messages don't carry a feed's lines, and the server's query stays the one place that decides what the feed holds. Someone joining comes with no message, and shows at the next of those. The crawl goes at 50 pixels a second however much the feed holds, Marquee.tsx measuring one copy for the stylesheet to work the loop's length out from, where a fixed 36 seconds would have sped up with every line. A loop shows what the feed held when it began: text that changed mid-crawl would jump, so what came meanwhile waits for the next loop, which also brings the times up to date. The next page's marquee carries on from where the last one's had got to, rather than from the start. The marquee was hidden from assistive technology as decoration; its first copy is now a labelled list of links, the second, which the seamless loop needs, hidden and out of the tab order. A link that takes the focus stops the crawl and the line scrolls to it, the pointer pauses it as before, and with reduced motion the line shows once, still, follows the feed as it changes and scrolls by hand. The sentences a notification also says, "posted", "upvoted", "commented on", share its translations. The tests' DOM gets an AnimationEvent before React loads (src/test/before-react.ts): finding none, React listened for the animation events under their WebKit names, and a test's "animationiteration" reached no handler.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:38:44 +00:00

213 lines
6.7 KiB
TypeScript

import {
createUser,
db,
insertComment,
insertDump,
insertPlaylist,
test,
} from "../helpers.ts";
import { assertEquals } from "@std/assert";
import {
communityStatsOf,
getCommunityFeed,
getCommunityStats,
listCommunityEvents,
refreshCommunityStats,
} from "../../services/community-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 vote(dumpId: string, n: number) {
db.prepare(`UPDATE dumps SET vote_count = ? WHERE id = ?;`).run(n, dumpId);
}
test("communityStatsOf: the period's top public dumps, members, public playlists and totals", async () => {
const ann = await createUser({ createdAt: ago(20 * DAY) });
const bob = await createUser({ invitedBy: ann.id, createdAt: ago(2 * DAY) });
const cy = await createUser({ createdAt: ago(DAY) });
const hit = insertDump({ userId: bob.id, title: "Hit" });
vote(hit.id, 5);
const tie = insertDump({ userId: ann.id, title: "Tie", createdAt: ago(DAY) });
vote(tie.id, 2);
const newer = insertDump({ userId: ann.id, title: "Newer" });
vote(newer.id, 2);
insertDump({ userId: ann.id, title: "No votes" });
vote(insertDump({ userId: ann.id, title: "Private", isPrivate: true }).id, 9);
vote(
insertDump({ userId: ann.id, title: "Too old", createdAt: ago(10 * DAY) })
.id,
9,
);
insertComment({ dumpId: hit.id, userId: cy.id, body: "nice" });
insertPlaylist({ userId: ann.id, title: "Small" });
const full = insertPlaylist({ userId: bob.id, title: "Full" });
insertPlaylist({ userId: bob.id, title: "Hidden", isPublic: false });
// Bob's private dump isn't counted: every reader gets the same figures.
const secret = insertDump({
userId: bob.id,
title: "Secret",
isPrivate: true,
});
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, 0, ?), (?, ?, 1, ?), (?, ?, 2, ?);`,
).run(
full.id,
hit.id,
ago(0),
full.id,
tie.id,
ago(0),
full.id,
secret.id,
ago(0),
);
const stats = communityStatsOf(...week());
assertEquals(stats.top.map((d) => d.title), ["Hit", "Newer", "Tie"]);
assertEquals(stats.top[0].ownerUsername, bob.username);
assertEquals(
stats.members.map((m) => [m.username, m.invitedByUsername]),
[[bob.username, ann.username], [cy.username, undefined]],
);
assertEquals(
stats.playlists.map((p) => [p.title, p.dumpCount]),
[["Full", 2], ["Small", 0]],
);
assertEquals(stats.totals, { dumps: 4, upvotes: 0, comments: 1 });
assertEquals(stats.chatMessages, 0);
});
test("getCommunityStats: serves the last refresh, of the week or the month", async () => {
const ann = await createUser({ createdAt: ago(20 * DAY) });
vote(
insertDump({ userId: ann.id, title: "Month", createdAt: ago(20 * DAY) })
.id,
1,
);
refreshCommunityStats();
assertEquals(getCommunityStats("weekly").top, []);
assertEquals(getCommunityStats("monthly").top.map((d) => d.title), [
"Month",
]);
assertEquals(getCommunityStats("monthly").members.length, 1);
vote(
insertDump({ userId: ann.id, title: "Later", createdAt: ago(1000) }).id,
1,
);
assertEquals(getCommunityStats("weekly").top, []);
refreshCommunityStats();
assertEquals(getCommunityStats("weekly").top.map((d) => d.title), [
"Later",
]);
});
function castVote(dumpId: string, userId: string, at: string) {
db.prepare(
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
).run(dumpId, userId, at);
}
test("listCommunityEvents: what was done in public, newest first, a dump's latest comment and upvote alone", async () => {
const ann = await createUser({ createdAt: ago(9 * DAY) });
const bob = await createUser({ invitedBy: ann.id, createdAt: ago(8 * DAY) });
const hit = insertDump({
userId: ann.id,
title: "Hit",
createdAt: ago(7 * DAY),
});
const secret = insertDump({
userId: ann.id,
title: "Secret",
isPrivate: true,
});
insertComment({ dumpId: hit.id, userId: ann.id, createdAt: ago(6 * DAY) });
const last = insertComment({
dumpId: hit.id,
userId: bob.id,
createdAt: ago(5 * DAY),
});
const gone = insertComment({ dumpId: hit.id, userId: ann.id });
db.prepare(`UPDATE comments SET deleted = 1 WHERE id = ?;`).run(gone);
insertComment({ dumpId: secret.id, userId: ann.id });
castVote(hit.id, bob.id, ago(4 * DAY));
castVote(hit.id, ann.id, ago(3 * DAY));
castVote(secret.id, ann.id, ago(0));
const mix = insertPlaylist({ userId: bob.id, title: "Mix" });
insertPlaylist({ userId: bob.id, title: "Hidden", isPublic: false });
const events = listCommunityEvents();
assertEquals(
events.map((e) => [e.type, e.username]),
[
["playlist_created", bob.username],
["dump_upvoted", ann.username],
["dump_commented", bob.username],
["dump_posted", ann.username],
["user_joined", bob.username],
["user_joined", ann.username],
],
);
const [playlist, upvote, comment, , joined] = events;
assertEquals(playlist.type === "playlist_created" && playlist.playlist, {
title: "Mix",
slug: mix.slug,
ownerUsername: bob.username,
});
assertEquals(upvote.type === "dump_upvoted" && upvote.dump, {
title: "Hit",
slug: hit.slug,
ownerUsername: ann.username,
});
assertEquals(upvote.id, `dump_upvoted:${hit.id}`);
assertEquals(comment.type === "dump_commented" && comment.commentId, last);
assertEquals(
joined.type === "user_joined" && joined.invitedByUsername,
ann.username,
);
});
test("listCommunityEvents: keeps the 12 latest", async () => {
const ann = await createUser({ createdAt: ago(DAY) });
for (let i = 0; i < 14; i++) {
insertDump({
userId: ann.id,
title: `Dump ${i}`,
createdAt: ago(i * 1000),
});
}
const events = listCommunityEvents();
assertEquals(events.length, 12);
assertEquals(
events.map((e) => e.type === "dump_posted" && e.dump.title),
Array.from({ length: 12 }, (_, i) => `Dump ${i}`),
);
});
test("getCommunityFeed: the events as they are now, the week's figures as of the last refresh", async () => {
const ann = await createUser({ createdAt: ago(20 * DAY) });
const hit = insertDump({
userId: ann.id,
title: "Hit",
createdAt: ago(1000),
});
vote(hit.id, 3);
refreshCommunityStats();
insertDump({ userId: ann.id, title: "Later" });
const feed = getCommunityFeed();
assertEquals(
feed.events.map((e) => e.type === "dump_posted" && e.dump.title),
["Later", "Hit", false],
);
assertEquals(feed.week.totals.dumps, 1);
assertEquals(feed.week.top, {
title: "Hit",
slug: hit.slug,
ownerUsername: ann.username,
});
});