import assert from "node:assert/strict"; import { test } from "node:test"; import { NextRequest } from "next/server"; import type { SupabaseClient } from "@supabase/supabase-js"; import type { Database } from "../shared/database"; import { communityApi } from "../lib/community-api"; import { commentInput, communityReportInput, reactionInput, } from "../shared/community"; import { ServiceError } from "../lib/service-error"; const map = "11111111-1111-4111-8111-111111111111"; const member = "22222222-2222-4222-8222-222222222222"; const comment = "33333333-3333-4333-8333-333333333333"; const other = "44444444-4444-4444-8444-444444444444"; test("community cursors advance by timestamp and ID even if earlier rows disappear", async () => { const created = "2026-10-17T00:00:00.000Z"; const cursor = encodeURIComponent(`${created}|${comment}`); const f = fixture({ signedIn: false }); f.reply("datasets", { id: map }); f.reply("map_reaction_summary", [{ likes: 0, dislikes: 0 }]); f.reply("map_comments", []); await f.run(`maps/${map}/community?cursor=${cursor}`); assert.ok( f.calls.some( (c) => c.method === "or" && c.args[0] === `created_at.lt.${created},and(created_at.eq.${created},id.lt.${comment})`, ), ); assert.ok(!f.calls.some((c) => c.method === "range")); const staff = fixture({ role: "moderator" }); staff.reply("community_reports", []); await staff.run(`moderation/community-reports?cursor=${cursor}`); assert.ok( staff.calls.some( (c) => c.method === "or" && c.args[0] === `created_at.gt.${created},and(created_at.eq.${created},id.gt.${comment})`, ), ); const invalid = fixture({ signedIn: false }); invalid.reply("datasets", { id: map }); await assert.rejects( invalid.run(`maps/${map}/community?cursor=invalid|${comment}`), ); assert.ok(!invalid.calls.some((c) => c.method === "or")); }); type Result = { data: unknown; error: { message: string; code?: string } | null; }; function fixture( options: { signedIn?: boolean; role?: string; inactive?: boolean } = {}, ) { const calls: { table: string; method: string; args: unknown[] }[] = []; const replies = new Map(); const rates: string[] = []; const db = { from(table: string) { const query: Record = {}; for (const method of [ "select", "insert", "update", "delete", "upsert", "eq", "is", "in", "order", "range", "or", "limit", "single", "maybeSingle", ]) query[method] = (...args: unknown[]) => { calls.push({ table, method, args }); return query; }; query.then = (resolve: (value: Result) => unknown) => Promise.resolve( replies.get(table)?.shift() ?? { data: null, error: null }, ).then(resolve); return query; }, rpc(name: string, args: unknown) { calls.push({ table: name, method: "rpc", args: [args] }); return Promise.resolve( replies.get(name)?.shift() ?? { data: [], error: null }, ); }, } as unknown as SupabaseClient; return { calls, rates, reply(table: string, data: unknown, error: Result["error"] = null) { const list = replies.get(table) ?? []; list.push({ data, error }); replies.set(table, list); }, run(path: string, method = "GET", body?: unknown, authorization?: string) { const headers = new Headers(); if (authorization !== undefined) headers.set("authorization", authorization); return communityApi( new NextRequest("https://minegeo.test/api/v1/" + path, { method, headers, }), path.split("?")[0].split("/"), { db, getUser: async () => { if (options.inactive) throw new ServiceError("Account banned", 403); return options.signedIn === false ? null : { id: member }; }, getProfile: async () => ({ id: member, role: options.role ?? "contributor", }), jsonBody: async () => body, limit: async (key) => { rates.push(key); }, }, ); }, }; } test("community validation rejects blank/oversized comments, forged authors, and invalid reactions", () => { assert.equal( commentInput.parse({ body: " A lovely map! " }).body, "A lovely map!", ); for (const body of [" ", "x".repeat(2001)]) assert.equal(commentInput.safeParse({ body }).success, false); assert.equal( commentInput.safeParse({ body: "Hello", author_id: other }).success, false, ); for (const value of [-1, 0, 1]) assert.equal(reactionInput.safeParse({ value }).success, true); for (const value of [2, "1", null]) assert.equal(reactionInput.safeParse({ value }).success, false); for (const category of [ "spam", "advertising", "inappropriate", "harassment", "other", ]) assert.equal( communityReportInput.safeParse({ target: "account", targetId: other, category, reason: "Enough detail for staff.", }).success, true, ); for (const extra of [ { reporter_id: other }, { state: "resolved" }, { target: "map" }, { category: "unknown" }, { reason: "short" }, ]) assert.equal( communityReportInput.safeParse({ target: "comment", targetId: comment, category: "spam", reason: "Enough detail for staff.", ...extra, }).success, false, ); }); test("guests, device tokens, and restricted accounts cannot create community content", async () => { for (const [path, method, body] of [ [`maps/${map}/comments`, "POST", { body: "Hello" }], [`maps/${map}/reaction`, "POST", { value: 1 }], [ "community/reports", "POST", { target: "account", targetId: other, category: "spam", reason: "Enough detail for staff.", }, ], ] as const) { const guest = fixture({ signedIn: false }); await assert.rejects( guest.run(path, method, body), (e: unknown) => e instanceof ServiceError && e.status === 401, ); assert.equal(guest.calls.length, 0); const device = fixture(); await assert.rejects( device.run(path, method, body, "Bearer upload-token"), (e: unknown) => e instanceof ServiceError && e.status === 403, ); assert.equal(device.calls.length, 0); await assert.rejects( fixture({ inactive: true }).run(path, method, body), /banned/, ); } }); test("regular users cannot remove comments, read reports, or review reports", async () => { for (const [path, method] of [ [`community/comments/${comment}`, "DELETE"], ["moderation/community-reports", "GET"], [`moderation/community-reports/${comment}`, "PATCH"], ]) { const f = fixture(); await assert.rejects( f.run(path, method), (e: unknown) => e instanceof ServiceError && e.status === 403, ); assert.equal(f.calls.length, 0); } }); test("reaction switches use an account-bound upsert and clearing affects only that user's map reaction", async () => { for (const value of [1, -1, 0]) { const f = fixture(); f.reply("datasets", { id: map }); f.reply("map_reactions", null); assert.equal( (await f.run(`maps/${map}/reaction`, "POST", { value })).status, 200, ); assert.deepEqual(f.rates, ["community:reaction:" + member]); if (value === 0) { assert.ok( f.calls.some( (c) => c.table === "map_reactions" && c.method === "eq" && c.args[0] === "user_id" && c.args[1] === member, ), ); } else assert.deepEqual(f.calls.find((c) => c.method === "upsert")?.args, [ { dataset_id: map, user_id: member, value }, { onConflict: "dataset_id,user_id" }, ]); } }); test("comment publishing derives the author from the session and rejects unavailable maps", async () => { const f = fixture(); f.reply("datasets", { id: map }); f.reply("map_comments", { id: comment }); assert.equal( (await f.run(`maps/${map}/comments`, "POST", { body: " Great map " })) .status, 201, ); assert.deepEqual(f.calls.find((c) => c.method === "insert")?.args, [ { dataset_id: map, author_id: member, body: "Great map" }, ]); assert.deepEqual(f.rates, ["community:comment:" + member]); const missing = fixture(); await assert.rejects( missing.run(`maps/${map}/comments`, "POST", { body: "Hello" }), /not found/, ); assert.ok(!missing.calls.some((c) => c.method === "insert")); }); test("public comment reads filter removed content and return no reviewer or reporter data", async () => { const f = fixture({ signedIn: false }); f.reply("datasets", { id: map }); f.reply("map_reaction_summary", [{ likes: 5, dislikes: 2 }]); f.reply("map_comments", [ { id: comment, author_id: null, body: "Still here", created_at: "2026-10-17T00:00:00Z", }, ]); const data = await (await f.run(`maps/${map}/community`)).json(); assert.equal(data.viewer, null); assert.equal(data.reaction, 0); assert.equal(data.likes, 5); assert.equal(data.dislikes, 2); assert.equal(data.comments[0].author, "Deleted player"); assert.ok( f.calls.some( (c) => c.method === "is" && c.args[0] === "removed_at" && c.args[1] === null, ), ); assert.ok(!f.calls.some((c) => c.table === "community_reports")); }); test("staff removal records reviewer and timestamp without destroying evidence", async () => { for (const role of ["moderator", "admin"]) { const f = fixture({ role }); f.reply("map_comments", { id: comment }); assert.equal( (await f.run(`community/comments/${comment}`, "DELETE")).status, 200, ); const update = f.calls.find((c) => c.method === "update")?.args[0] as { removed_by: string; removed_at: string; }; assert.equal(update.removed_by, member); assert.ok(Number.isFinite(Date.parse(update.removed_at))); assert.ok(!f.calls.some((c) => c.method === "delete")); } }); test("account reports preserve reasons, block self reports, and translate duplicate errors", async () => { const body = { target: "account", targetId: other, category: "advertising", reason: "Repeated unsolicited adverts.", }; const f = fixture(); f.reply("profiles", { id: other }); f.reply("community_reports", null); assert.equal((await f.run("community/reports", "POST", body)).status, 201); assert.deepEqual(f.calls.find((c) => c.method === "insert")?.args, [ { reporter_id: member, target: "account", comment_id: null, account_id: other, category: "advertising", reason: body.reason, }, ]); await assert.rejects( fixture().run("community/reports", "POST", { ...body, targetId: member }), /own account/, ); const duplicate = fixture(); duplicate.reply("profiles", { id: other }); duplicate.reply("community_reports", null, { code: "23505", message: "duplicate", }); await assert.rejects( duplicate.run("community/reports", "POST", body), (e: unknown) => e instanceof ServiceError && e.status === 409, ); }); test("comment reports require visible published content; staff reviews use an open-state compare and set", async () => { const hidden = fixture(); await assert.rejects( hidden.run("community/reports", "POST", { target: "comment", targetId: comment, category: "spam", reason: "Repeated unsolicited adverts.", }), /not found/, ); assert.ok(!hidden.calls.some((c) => c.method === "insert")); const f = fixture({ role: "moderator" }); f.reply("community_reports", { id: comment }); assert.equal( ( await f.run(`moderation/community-reports/${comment}`, "PATCH", { decision: "dismissed", reason: "The comment follows the rules.", }) ).status, 200, ); assert.ok( f.calls.some( (c) => c.method === "eq" && c.args[0] === "state" && c.args[1] === "open", ), ); const update = f.calls.find((c) => c.method === "update")?.args[0] as { reviewer_id: string; state: string; }; assert.equal(update.reviewer_id, member); assert.equal(update.state, "dismissed"); const stale = fixture({ role: "admin" }); await assert.rejects( stale.run(`moderation/community-reports/${comment}`, "PATCH", { decision: "resolved", reason: "The issue was addressed by staff.", }), (e: unknown) => e instanceof ServiceError && e.status === 409, ); });