Add map community reactions, comments, and moderation
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m22s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m22s
This commit is contained in:
410
tests/community.test.ts
Normal file
410
tests/community.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user