Add map community reactions, comments, and moderation
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m22s

This commit is contained in:
2026-10-17 12:45:18 +05:00
parent 0bd05d6e0b
commit e1a6ab5730
17 changed files with 1747 additions and 0 deletions

410
tests/community.test.ts Normal file
View File

@@ -0,0 +1,410 @@
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<string, Result[]>();
const rates: string[] = [];
const db = {
from(table: string) {
const query: Record<string, unknown> = {};
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<Database>;
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,
);
});