Error statuses follow one rule: APIException takes a status and derives the code from it (BAD_REQUEST 400, UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, CONFLICT 409, RATE_LIMITED 429, SERVER_ERROR 500, BAD_GATEWAY 502), so a code can't contradict its status. That fixed real bugs: a wrong current password on change-password answered 401, on which authFetch signs the user out; editing or deleting someone else's comment or chat message answered 401 rather than 403; one invalid dump edit answered 422. A taken username is 409, like a taken email. Along the way: the database is created from schema.sql by connection.ts when the file doesn't exist, so every task works on a fresh checkout (dev and serve never ran db/init.ts, which is gone); the startup purge compared ISO dates with SQLite's datetime() as text and kept anything from the current UTC day, and now compares ISO cutoffs; the seeded admin's hash comes from lib/password.ts (split out of jwt.ts, which needs the JWT secret) rather than a copy of its format; avatar, thumbnail and playlist-cover uploads go through replaceUploadedFile, which writes beside the old file and renames over it before the record announces it (a failed avatar write lost the old avatar; a thumbnail was announced before it existed); upload.ts derives its size message from MAX_IMAGE_SIZE_BYTES and stops re-exporting the upload folders; ALLOWED_IMAGE_MIMES, which duplicated detectImageMime, is gone; the SPA fallback no longer calls next() after answering; a reply must answer a comment on the same dump (a foreign or unknown parent was a 500 or a cross-dump thread). 211 backend and 628 frontend tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
320 lines
9.2 KiB
TypeScript
320 lines
9.2 KiB
TypeScript
import {
|
|
count,
|
|
createUser,
|
|
db,
|
|
del,
|
|
get,
|
|
html,
|
|
insertComment,
|
|
insertDump,
|
|
patch,
|
|
post,
|
|
put,
|
|
stubNetwork,
|
|
test,
|
|
} from "../helpers.ts";
|
|
import { assertEquals, assertExists, assertFalse } from "@std/assert";
|
|
import {
|
|
castCommentLike,
|
|
removeCommentLike,
|
|
} from "../../services/comment-like-service.ts";
|
|
import { APIException } from "../../lib/api-exception.ts";
|
|
import type { Comment } from "../../../shared/comment.ts";
|
|
import type { Dump } from "../../../shared/dump.ts";
|
|
import type { PaginatedData } from "../../../shared/api.ts";
|
|
import type { PublicUser } from "../../../shared/user.ts";
|
|
|
|
test("comments: post, list in order, count on the dump", async () => {
|
|
const owner = await createUser();
|
|
const other = await createUser();
|
|
const { id } = insertDump({ userId: owner.id });
|
|
const first = await post<Comment>(`/api/dumps/${id}/comments`, {
|
|
auth: other.auth,
|
|
json: { body: " first " },
|
|
});
|
|
assertEquals(first.status, 201);
|
|
assertEquals(first.data.body, "first");
|
|
assertEquals(first.data.authorUsername, other.username);
|
|
const reply = await post<Comment>(`/api/dumps/${id}/comments`, {
|
|
auth: owner.auth,
|
|
json: { body: "reply", parentId: first.data.id },
|
|
});
|
|
assertEquals(reply.data.parentId, first.data.id);
|
|
const list = await get<Comment[]>(`/api/dumps/${id}/comments`);
|
|
assertEquals(list.data.map((c) => c.body), [
|
|
"first",
|
|
"reply",
|
|
]);
|
|
assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.commentCount, 2);
|
|
});
|
|
|
|
test("comments: validation, auth and unknown dumps", async () => {
|
|
const user = await createUser();
|
|
const { id } = insertDump({ userId: user.id });
|
|
for (const body of ["", " ", "x".repeat(5001)]) {
|
|
assertEquals(
|
|
(await post(`/api/dumps/${id}/comments`, {
|
|
auth: user.auth,
|
|
json: { body },
|
|
}))
|
|
.status,
|
|
400,
|
|
);
|
|
}
|
|
assertEquals(
|
|
(await post(`/api/dumps/${id}/comments`, { json: { body: "hi" } })).status,
|
|
401,
|
|
);
|
|
assertEquals(
|
|
(await post(`/api/dumps/nope/comments`, {
|
|
auth: user.auth,
|
|
json: { body: "hi" },
|
|
}))
|
|
.status,
|
|
404,
|
|
);
|
|
});
|
|
|
|
test("comments on a private dump: owner only", async () => {
|
|
const owner = await createUser();
|
|
const other = await createUser();
|
|
const { id } = insertDump({ userId: owner.id, isPrivate: true });
|
|
assertEquals(
|
|
(await post(`/api/dumps/${id}/comments`, {
|
|
auth: other.auth,
|
|
json: { body: "x" },
|
|
}))
|
|
.status,
|
|
404,
|
|
);
|
|
assertEquals(
|
|
(await post(`/api/dumps/${id}/comments`, {
|
|
auth: owner.auth,
|
|
json: { body: "x" },
|
|
}))
|
|
.status,
|
|
201,
|
|
);
|
|
assertEquals(
|
|
(await get(`/api/dumps/${id}/comments`, { auth: other.auth })).status,
|
|
404,
|
|
);
|
|
});
|
|
|
|
test("comments: the dump's owner is notified, the commenter isn't", async () => {
|
|
const owner = await createUser();
|
|
const other = await createUser();
|
|
const { id } = insertDump({ userId: owner.id, title: "Mine" });
|
|
await post(`/api/dumps/${id}/comments`, {
|
|
auth: owner.auth,
|
|
json: { body: "self" },
|
|
});
|
|
assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0);
|
|
const c = await post<Comment>(`/api/dumps/${id}/comments`, {
|
|
auth: other.auth,
|
|
json: { body: "nice" },
|
|
});
|
|
const note = db.prepare(`SELECT user_id, type, data FROM notifications;`)
|
|
.get() as {
|
|
user_id: string;
|
|
type: string;
|
|
data: string;
|
|
};
|
|
assertEquals(note.user_id, owner.id);
|
|
assertEquals(note.type, "dump_commented");
|
|
assertEquals(JSON.parse(note.data).commentId, c.data.id);
|
|
assertEquals(JSON.parse(note.data).dumpTitle, "Mine");
|
|
});
|
|
|
|
test("PATCH /api/comments/:id: author and moderator edit, others can't", async () => {
|
|
const author = await createUser();
|
|
const other = await createUser();
|
|
const mod = await createUser({ role: "moderator" });
|
|
const { id: dumpId } = insertDump({ userId: author.id });
|
|
const commentId = insertComment({ dumpId, userId: author.id, body: "orig" });
|
|
assertEquals(
|
|
(await patch(`/api/comments/${commentId}`, {
|
|
auth: other.auth,
|
|
json: { body: "x" },
|
|
}))
|
|
.status,
|
|
403,
|
|
);
|
|
const edited = await patch<Comment>(`/api/comments/${commentId}`, {
|
|
auth: author.auth,
|
|
json: { body: " edited " },
|
|
});
|
|
assertEquals(edited.status, 200);
|
|
assertEquals(edited.data.body, "edited");
|
|
assertExists(edited.data.updatedAt);
|
|
assertEquals(
|
|
(await patch(`/api/comments/${commentId}`, {
|
|
auth: mod.auth,
|
|
json: { body: "m" },
|
|
}))
|
|
.status,
|
|
200,
|
|
);
|
|
assertEquals(
|
|
(await patch(`/api/comments/${commentId}`, {
|
|
auth: author.auth,
|
|
json: { body: "" },
|
|
}))
|
|
.status,
|
|
400,
|
|
);
|
|
assertEquals(
|
|
(await patch(`/api/comments/${crypto.randomUUID()}`, {
|
|
auth: author.auth,
|
|
json: { body: "x" },
|
|
})).status,
|
|
404,
|
|
);
|
|
});
|
|
|
|
test("DELETE /api/comments/:id soft-deletes, and a deleted comment can't be edited", async () => {
|
|
const author = await createUser();
|
|
const other = await createUser();
|
|
const { id: dumpId } = insertDump({ userId: author.id });
|
|
const commentId = insertComment({
|
|
dumpId,
|
|
userId: author.id,
|
|
body: "gone soon",
|
|
});
|
|
assertEquals(
|
|
(await del(`/api/comments/${commentId}`, { auth: other.auth })).status,
|
|
403,
|
|
);
|
|
assertEquals(
|
|
(await del(`/api/comments/${commentId}`, { auth: author.auth })).status,
|
|
200,
|
|
);
|
|
const [c] = (await get<Comment[]>(`/api/dumps/${dumpId}/comments`)).data;
|
|
assertEquals(c.deleted, true);
|
|
assertEquals(c.body, "");
|
|
assertEquals((await get<Dump>(`/api/dumps/${dumpId}`)).data.commentCount, 0);
|
|
assertEquals(
|
|
(await patch(`/api/comments/${commentId}`, {
|
|
auth: author.auth,
|
|
json: { body: "back" },
|
|
}))
|
|
.status,
|
|
400,
|
|
);
|
|
});
|
|
|
|
test("comment likes: count, conflicts, likers list and notifications", async () => {
|
|
const author = await createUser();
|
|
const liker = await createUser();
|
|
const { id: dumpId } = insertDump({ userId: author.id });
|
|
const commentId = insertComment({ dumpId, userId: author.id });
|
|
|
|
assertEquals(castCommentLike(commentId, liker.id).likeCount, 1);
|
|
assertEquals(castCommentLike(commentId, author.id).likeCount, 2);
|
|
try {
|
|
castCommentLike(commentId, liker.id);
|
|
throw new Error("expected a conflict");
|
|
} catch (err) {
|
|
assertEquals((err as APIException).status, 409);
|
|
}
|
|
// Only the other user's like notifies the author.
|
|
assertEquals(
|
|
count(
|
|
`SELECT COUNT(*) as n FROM notifications WHERE type = 'comment_liked';`,
|
|
),
|
|
1,
|
|
);
|
|
|
|
const likers = await get<PaginatedData<PublicUser>>(
|
|
`/api/comments/${commentId}/likers`,
|
|
);
|
|
assertEquals(likers.data.items.length, 2);
|
|
assertFalse("email" in likers.data.items[0]);
|
|
|
|
assertEquals(removeCommentLike(commentId, liker.id).likeCount, 1);
|
|
try {
|
|
removeCommentLike(commentId, liker.id);
|
|
throw new Error("expected a 404");
|
|
} catch (err) {
|
|
assertEquals((err as APIException).status, 404);
|
|
}
|
|
assertEquals(
|
|
(db.prepare(`SELECT like_count FROM comments WHERE id = ?;`).get(
|
|
commentId,
|
|
) as {
|
|
like_count: number;
|
|
}).like_count,
|
|
1,
|
|
);
|
|
});
|
|
|
|
test("comment likes on a private dump: owner only", async () => {
|
|
const owner = await createUser();
|
|
const other = await createUser();
|
|
const { id: dumpId } = insertDump({ userId: owner.id, isPrivate: true });
|
|
const commentId = insertComment({ dumpId, userId: owner.id });
|
|
try {
|
|
castCommentLike(commentId, other.id);
|
|
throw new Error("expected a 404");
|
|
} catch (err) {
|
|
assertEquals((err as APIException).status, 404);
|
|
}
|
|
assertEquals(castCommentLike(commentId, owner.id).isPrivate, true);
|
|
assertEquals((await get(`/api/comments/${commentId}/likers`)).status, 404);
|
|
assertEquals(
|
|
(await get(`/api/comments/${commentId}/likers`, { auth: owner.auth }))
|
|
.status,
|
|
200,
|
|
);
|
|
});
|
|
|
|
test("comments: a reply must answer a comment on the same dump", async () => {
|
|
const user = await createUser();
|
|
const { id: a } = insertDump({ userId: user.id });
|
|
const { id: b } = insertDump({ userId: user.id });
|
|
const onB = insertComment({ dumpId: b, userId: user.id, body: "on b" });
|
|
for (const parentId of [onB, crypto.randomUUID()]) {
|
|
assertEquals(
|
|
(await post(`/api/dumps/${a}/comments`, {
|
|
auth: user.auth,
|
|
json: { body: "reply", parentId },
|
|
})).status,
|
|
400,
|
|
);
|
|
}
|
|
assertEquals(
|
|
count(`SELECT COUNT(*) as n FROM comments WHERE dump_id = ?;`, a),
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("mentions in a private dump or its comments wait for it to go public", async () => {
|
|
using _net = stubNetwork(() => html("<title>Note</title>"));
|
|
const owner = await createUser();
|
|
const friend = await createUser();
|
|
const mentions = () =>
|
|
count(
|
|
`SELECT COUNT(*) as n FROM notifications WHERE type = 'user_mentioned' AND user_id = ?;`,
|
|
friend.id,
|
|
);
|
|
const dump = await post<Dump>("/api/dumps", {
|
|
auth: owner.auth,
|
|
json: {
|
|
url: "https://example.com/private-note",
|
|
comment: `for @${friend.username}`,
|
|
isPrivate: true,
|
|
},
|
|
});
|
|
assertEquals(dump.status, 201);
|
|
await post(`/api/dumps/${dump.data.id}/comments`, {
|
|
auth: owner.auth,
|
|
json: { body: `hey @${friend.username}` },
|
|
});
|
|
assertEquals(mentions(), 0);
|
|
await put(`/api/dumps/${dump.data.id}`, {
|
|
auth: owner.auth,
|
|
json: { isPrivate: false },
|
|
});
|
|
assertEquals(mentions(), 1);
|
|
});
|