b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
205 lines
6.9 KiB
TypeScript
205 lines
6.9 KiB
TypeScript
import type { Kysely } from "kysely";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { handleReactionToggle } from "../../../src/api/handlers/comment-reactions.js";
|
|
import { getCommentsWithDb } from "../../../src/comments/query.js";
|
|
import { CommentReactionRepository } from "../../../src/database/repositories/comment-reaction.js";
|
|
import { CommentRepository } from "../../../src/database/repositories/comment.js";
|
|
import type { Database } from "../../../src/database/types.js";
|
|
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
|
|
|
describe("CommentReactionRepository", () => {
|
|
let db: Kysely<Database>;
|
|
let comments: CommentRepository;
|
|
let reactions: CommentReactionRepository;
|
|
|
|
beforeEach(async () => {
|
|
db = await setupTestDatabase();
|
|
comments = new CommentRepository(db);
|
|
reactions = new CommentReactionRepository(db);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await teardownTestDatabase(db);
|
|
});
|
|
|
|
function approvedComment(overrides: Record<string, unknown> = {}) {
|
|
return comments.create({
|
|
collection: "post",
|
|
contentId: "content-1",
|
|
authorName: "Jane",
|
|
authorEmail: "jane@example.com",
|
|
body: "Great post!",
|
|
status: "approved",
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
describe("toggle", () => {
|
|
it("adds a reaction, then removes it on a second toggle by the same voter", async () => {
|
|
const c = await approvedComment();
|
|
expect(await reactions.toggle({ commentId: c.id, reaction: "like", voterHash: "a" })).toEqual(
|
|
{
|
|
reacted: true,
|
|
},
|
|
);
|
|
expect(await reactions.toggle({ commentId: c.id, reaction: "like", voterHash: "a" })).toEqual(
|
|
{
|
|
reacted: false,
|
|
},
|
|
);
|
|
});
|
|
|
|
it("counts each distinct voter once", async () => {
|
|
const c = await approvedComment();
|
|
for (const v of ["a", "b", "c"]) {
|
|
await reactions.toggle({ commentId: c.id, reaction: "like", voterHash: v });
|
|
}
|
|
const counts = await reactions.countsForComments([c.id]);
|
|
expect(counts.get(c.id)).toEqual({ like: 3 });
|
|
});
|
|
});
|
|
|
|
describe("countsForComments", () => {
|
|
it("groups counts by reaction and omits comments with none", async () => {
|
|
const c1 = await approvedComment();
|
|
const c2 = await approvedComment();
|
|
await reactions.toggle({ commentId: c1.id, reaction: "like", voterHash: "a" });
|
|
await reactions.toggle({ commentId: c1.id, reaction: "love", voterHash: "b" });
|
|
|
|
const counts = await reactions.countsForComments([c1.id, c2.id]);
|
|
expect(counts.get(c1.id)).toEqual({ like: 1, love: 1 });
|
|
expect(counts.has(c2.id)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("viewerReactions", () => {
|
|
it("returns only the given voter's reactions", async () => {
|
|
const c = await approvedComment();
|
|
await reactions.toggle({ commentId: c.id, reaction: "like", voterHash: "a" });
|
|
await reactions.toggle({ commentId: c.id, reaction: "like", voterHash: "b" });
|
|
|
|
expect((await reactions.viewerReactions([c.id], "a")).get(c.id)).toEqual(["like"]);
|
|
expect((await reactions.viewerReactions([c.id], "z")).has(c.id)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("countRecentByVoter", () => {
|
|
it("counts a voter's recent reactions", async () => {
|
|
const c = await approvedComment();
|
|
await reactions.toggle({ commentId: c.id, reaction: "like", voterHash: "a" });
|
|
await reactions.toggle({ commentId: c.id, reaction: "love", voterHash: "a" });
|
|
expect(await reactions.countRecentByVoter("a")).toBe(2);
|
|
expect(await reactions.countRecentByVoter("b")).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("getCommentsWithDb best-sort", () => {
|
|
it("orders top-level comments by reaction score and attaches counts", async () => {
|
|
const low = await approvedComment({ body: "low" });
|
|
const high = await approvedComment({ body: "high" });
|
|
const mid = await approvedComment({ body: "mid" });
|
|
|
|
for (const v of ["a", "b", "c", "d", "e"]) {
|
|
await reactions.toggle({ commentId: high.id, reaction: "like", voterHash: v });
|
|
}
|
|
for (const v of ["a", "b"]) {
|
|
await reactions.toggle({ commentId: mid.id, reaction: "like", voterHash: v });
|
|
}
|
|
void low;
|
|
|
|
const { items } = await getCommentsWithDb(db, {
|
|
collection: "post",
|
|
contentId: "content-1",
|
|
reactions: true,
|
|
sort: "best",
|
|
});
|
|
|
|
expect(items.map((c) => c.body)).toEqual(["high", "mid", "low"]);
|
|
expect(items[0]?.reactions).toEqual({ like: 5 });
|
|
});
|
|
|
|
it("attaches counts without reordering under the default sort", async () => {
|
|
const c = await approvedComment({ body: "only" });
|
|
await reactions.toggle({ commentId: c.id, reaction: "like", voterHash: "a" });
|
|
|
|
const { items } = await getCommentsWithDb(db, {
|
|
collection: "post",
|
|
contentId: "content-1",
|
|
reactions: true,
|
|
});
|
|
|
|
expect(items[0]?.reactions).toEqual({ like: 1 });
|
|
});
|
|
});
|
|
|
|
describe("handleReactionToggle", () => {
|
|
const base = { collection: "post", contentId: "content-1" };
|
|
|
|
it("toggles a reaction and returns updated counts", async () => {
|
|
const c = await approvedComment();
|
|
const r = await handleReactionToggle(db, {
|
|
...base,
|
|
commentId: c.id,
|
|
reaction: "like",
|
|
voterHash: "v1",
|
|
});
|
|
expect(r.success).toBe(true);
|
|
if (r.success) {
|
|
expect(r.data.reacted).toBe(true);
|
|
expect(r.data.counts).toEqual({ like: 1 });
|
|
}
|
|
});
|
|
|
|
it("rejects an unsupported reaction with VALIDATION_ERROR", async () => {
|
|
const c = await approvedComment();
|
|
const r = await handleReactionToggle(db, {
|
|
...base,
|
|
commentId: c.id,
|
|
reaction: "spam",
|
|
voterHash: "v1",
|
|
});
|
|
expect(r.success).toBe(false);
|
|
if (!r.success) expect(r.error.code).toBe("VALIDATION_ERROR");
|
|
});
|
|
|
|
it("rejects reacting to a non-approved comment", async () => {
|
|
const c = await approvedComment({ status: "pending" });
|
|
const r = await handleReactionToggle(db, {
|
|
...base,
|
|
commentId: c.id,
|
|
reaction: "like",
|
|
voterHash: "v1",
|
|
});
|
|
expect(r.success).toBe(false);
|
|
if (!r.success) expect(r.error.code).toBe("COMMENT_NOT_APPROVED");
|
|
});
|
|
|
|
it("rejects reacting to a missing comment", async () => {
|
|
const r = await handleReactionToggle(db, {
|
|
...base,
|
|
commentId: "does-not-exist",
|
|
reaction: "like",
|
|
voterHash: "v1",
|
|
});
|
|
expect(r.success).toBe(false);
|
|
if (!r.success) expect(r.error.code).toBe("NOT_FOUND");
|
|
});
|
|
|
|
it("does not error when two toggles for the same voter race (idempotent add)", async () => {
|
|
const c = await approvedComment();
|
|
// Fire both concurrently — the old read-then-write path could throw a
|
|
// unique-constraint 500 here; ON CONFLICT makes it safe.
|
|
const [a, b] = await Promise.all([
|
|
handleReactionToggle(db, { ...base, commentId: c.id, reaction: "like", voterHash: "v1" }),
|
|
handleReactionToggle(db, { ...base, commentId: c.id, reaction: "like", voterHash: "v1" }),
|
|
]);
|
|
expect(a.success).toBe(true);
|
|
expect(b.success).toBe(true);
|
|
// Invariant: never more than one row for the same (comment, voter, reaction).
|
|
const counts = await reactions.countsForComments([c.id]);
|
|
expect((counts.get(c.id)?.like ?? 0) <= 1).toBe(true);
|
|
});
|
|
});
|
|
});
|