// Follows, feeds, notifications and invites. import { type ApiResponse, count, createUser, db, del, get, insertCategory, insertDump, insertPlaylist, patch, post, test, } from "./helpers.ts"; import type { Comment, Dump, FollowStatus, InviteResponse, Notification, PaginatedData, Playlist, PublicUser, } from "../model/interfaces.ts"; import { assertEquals, assertFalse, assertMatch } from "@std/assert"; function titles(res: ApiResponse>): string[] { return res.data.items.map((d) => d.title); } test("following users: status, idempotence, no self-follow, one notification", async () => { const me = await createUser(); const them = await createUser(); assertEquals( (await post(`/api/follows/users/${them.id}`, { auth: me.auth })).status, 204, ); assertEquals( (await post(`/api/follows/users/${them.id}`, { auth: me.auth })).status, 204, ); assertEquals( (await post(`/api/follows/users/${me.id}`, { auth: me.auth })).status, 400, ); assertEquals( (await get("/api/follows/status", { auth: me.auth })).data, { followedUserIds: [them.id], followedPlaylistIds: [] }, ); // Unfollow and follow again: still just the one notification. await del(`/api/follows/users/${them.id}`, { auth: me.auth }); assertEquals( (await get("/api/follows/status", { auth: me.auth })).data .followedUserIds, [], ); await post(`/api/follows/users/${them.id}`, { auth: me.auth }); assertEquals( count( `SELECT COUNT(*) as n FROM notifications WHERE user_id = ? AND type = 'user_followed';`, them.id, ), 1, ); const list = await get>( `/api/users/${me.username}/followed-users`, ); assertEquals(list.data.items.map((u) => u.id), [ them.id, ]); assertFalse("email" in list.data.items[0]); }); test("following playlists: public ones only, and not your own notification", async () => { const owner = await createUser(); const me = await createUser(); const pub = insertPlaylist({ userId: owner.id }); const priv = insertPlaylist({ userId: owner.id, isPublic: false }); assertEquals( (await post(`/api/follows/playlists/${pub.id}`, { auth: me.auth })).status, 204, ); assertEquals( (await post(`/api/follows/playlists/${priv.id}`, { auth: me.auth })).status, 403, ); assertEquals( (await post(`/api/follows/playlists/${crypto.randomUUID()}`, { auth: me.auth, })).status, 404, ); await post(`/api/follows/playlists/${pub.id}`, { auth: owner.auth }); assertEquals( count( `SELECT COUNT(*) as n FROM notifications WHERE type = 'playlist_followed';`, ), 1, ); assertEquals( (await get>( `/api/users/${me.username}/followed-playlists`, )).data .items[0].id, pub.id, ); await del(`/api/follows/playlists/${pub.id}`, { auth: me.auth }); assertEquals( (await get("/api/follows/status", { auth: me.auth })).data .followedPlaylistIds, [], ); }); test("followed-users feed: public dumps of the people I follow, newest first", async () => { const me = await createUser(); const followed = await createUser(); const stranger = await createUser(); const music = insertCategory({ slug: "music" }); insertDump({ userId: followed.id, title: "old", createdAt: "2024-01-01T00:00:00Z", }); const tagged = insertDump({ userId: followed.id, title: "new", createdAt: "2024-02-01T00:00:00Z", }); db.prepare( `INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`, ) .run(tagged.id, music); insertDump({ userId: followed.id, title: "private", isPrivate: true }); insertDump({ userId: stranger.id, title: "stranger" }); await post(`/api/follows/users/${followed.id}`, { auth: me.auth }); const feed = await get>("/api/follows/feed/users", { auth: me.auth, }); assertEquals(titles(feed), ["new", "old"]); assertEquals(feed.data.items[0].categoryIds, [music]); assertEquals( titles( await get("/api/follows/feed/users?category=music", { auth: me.auth }), ), ["new"], ); // An unknown category filters everything out rather than nothing. assertEquals( (await get>("/api/follows/feed/users?category=nope", { auth: me.auth, })).data, { items: [], hasMore: false }, ); assertEquals((await get("/api/follows/feed/users")).status, 401); }); test("followed-playlists feed: each dump once, public playlists only", async () => { const me = await createUser(); const owner = await createUser(); const p1 = insertPlaylist({ userId: owner.id }); const p2 = insertPlaylist({ userId: owner.id }); const d = insertDump({ userId: owner.id, title: "shared" }); const priv = insertDump({ userId: owner.id, title: "private", isPrivate: true, }); for (const p of [p1, p2]) { await post(`/api/follows/playlists/${p.id}`, { auth: me.auth }); await post(`/api/playlists/${p.id}/dumps/${d.id}`, { auth: owner.auth }); } await post(`/api/playlists/${p1.id}/dumps/${priv.id}`, { auth: owner.auth }); const feed = await get>("/api/follows/feed/playlists", { auth: me.auth, }); assertEquals(titles(feed), ["shared"]); assertEquals(feed.data.items.length, 1); // A playlist gone private drops out of the feed. db.prepare(`UPDATE playlists SET is_public = 0;`).run(); assertEquals( (await get>("/api/follows/feed/playlists", { auth: me.auth, })).data .items.length, 0, ); }); test("notifications: listed newest first, marked read one or all, never someone else's", async () => { const me = await createUser(); const other = await createUser(); const { id: dumpId } = insertDump({ userId: me.id, title: "Mine" }); // Two notifications for me, one for other. await post(`/api/follows/users/${me.id}`, { auth: other.auth }); await post(`/api/dumps/${dumpId}/comments`, { auth: other.auth, json: { body: "hey" }, }); await post(`/api/follows/users/${other.id}`, { auth: me.auth }); const list = await get>("/api/notifications", { auth: me.auth, }); assertEquals(list.data.items.length, 2); assertEquals( list.data.items.map((n) => n.type), ["dump_commented", "user_followed"], ); assertEquals( list.data.items.every((n) => !n.read), true, ); const theirs = (await get>("/api/notifications", { auth: other.auth, })).data .items[0].id; assertEquals( (await patch(`/api/notifications/${theirs}/read`, { auth: me.auth })) .status, 204, ); assertEquals( (await get>("/api/notifications", { auth: other.auth, })).data.items[0] .read, false, ); const first = list.data.items[0].id; await patch(`/api/notifications/${first}/read`, { auth: me.auth }); const after = (await get>("/api/notifications", { auth: me.auth, })).data.items; assertEquals(after.map((n) => n.read), [true, false]); assertEquals( (await post("/api/notifications/read-all", { auth: me.auth })).status, 204, ); assertEquals( count( `SELECT COUNT(*) as n FROM notifications WHERE user_id = ? AND read = 0;`, me.id, ), 0, ); assertEquals((await get("/api/notifications")).status, 401); }); test("mentions: notify the named, once per context, never the author or an email domain", async () => { const author = await createUser(); const named = await createUser(); const { id: dumpId } = insertDump({ userId: author.id, title: "Topic" }); const body = `hi @${named.username.toUpperCase()} and @${named.username}, ` + `me @${author.username}, mail x@${named.username}.com, ` + `[@${named.username}](link) @nobody_known`; const c = await post(`/api/dumps/${dumpId}/comments`, { auth: author.auth, json: { body }, }); const notes = db.prepare( `SELECT user_id, data FROM notifications WHERE type = 'user_mentioned';`, ).all() as { user_id: string; data: string }[]; assertEquals(notes.length, 1); assertEquals(notes[0].user_id, named.id); assertEquals(JSON.parse(notes[0].data), { mentionerId: author.id, mentionerUsername: author.username, contextType: "comment", contextId: c.data.id, contextTitle: "Topic", dumpId, }); // Editing the comment doesn't notify a second time. await patch(`/api/comments/${c.data.id}`, { auth: author.auth, json: { body: `again @${named.username}` }, }); assertEquals( count( `SELECT COUNT(*) as n FROM notifications WHERE type = 'user_mentioned';`, ), 1, ); }); test("invites: anyone signed in can make one; it validates until used or stale", async () => { const user = await createUser(); assertEquals((await post("/api/invites")).status, 401); const res = await post("/api/invites", { auth: user.auth }); assertEquals(res.status, 201); const token = res.data.token; assertMatch(token, /^[A-Za-z0-9_-]{22}$/); assertEquals((await get(`/api/invites/${token}`)).status, 200); assertEquals((await get(`/api/invites/unknown`)).status, 404); db.prepare(`UPDATE invites SET used_at = ? WHERE token = ?;`).run( new Date().toISOString(), token, ); assertEquals((await get(`/api/invites/${token}`)).status, 404); db.prepare( `UPDATE invites SET used_at = NULL, created_at = ? WHERE token = ?;`, ) .run(new Date(Date.now() - 8 * 86400_000).toISOString(), token); assertEquals((await get(`/api/invites/${token}`)).status, 404); });