All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
9.6 KiB
TypeScript
313 lines
9.6 KiB
TypeScript
// 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<PaginatedData<Dump>>): 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<FollowStatus>("/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<FollowStatus>("/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<PaginatedData<PublicUser>>(
|
|
`/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<PaginatedData<Playlist>>(
|
|
`/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<FollowStatus>("/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<PaginatedData<Dump>>("/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<PaginatedData<Dump>>("/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<PaginatedData<Dump>>("/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<PaginatedData<Dump>>("/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<PaginatedData<Notification>>("/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<PaginatedData<Notification>>("/api/notifications", {
|
|
auth: other.auth,
|
|
})).data
|
|
.items[0].id;
|
|
assertEquals(
|
|
(await patch(`/api/notifications/${theirs}/read`, { auth: me.auth }))
|
|
.status,
|
|
204,
|
|
);
|
|
assertEquals(
|
|
(await get<PaginatedData<Notification>>("/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<PaginatedData<Notification>>("/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<Comment>(`/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<InviteResponse>("/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);
|
|
});
|