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,
);
});

125
tests/sql/community.sql Normal file
View File

@@ -0,0 +1,125 @@
-- Transactional fixtures; uses existing accounts on a configured project.
begin;
do $$
declare
owner uuid; member uuid; fixture uuid; comment uuid; report uuid;
table_name text; client_role text; permission text; totals record;
begin
if not exists(select 1 from auth.users) then
insert into auth.users(id) select gen_random_uuid() from generate_series(1, 2);
end if;
select id into owner from auth.users order by id limit 1;
select id into member from auth.users where id <> owner order by id limit 1;
if member is null then raise exception 'Two fixture accounts are required'; end if;
insert into public.profiles(id) values(owner),(member) on conflict(id) do nothing;
insert into public.datasets(owner_id,name,source,bounds,state)
values(owner,'Community regression','plugin','{"minX":0,"minZ":0,"maxX":64,"maxZ":64}','published')
returning id into fixture;
select * into totals from public.map_reaction_summary(fixture);
if totals.likes <> 0 or totals.dislikes <> 0 then raise exception 'Empty totals are incorrect'; end if;
insert into public.map_reactions(dataset_id,user_id,value) values(fixture,owner,1),(fixture,member,-1);
begin
insert into public.map_reactions(dataset_id,user_id,value) values(fixture,owner,-1);
raise exception 'Duplicate reaction accepted';
exception when unique_violation then null; end;
begin
update public.map_reactions set value=0 where dataset_id=fixture and user_id=owner;
raise exception 'Invalid reaction accepted';
exception when check_violation then null; end;
select * into totals from public.map_reaction_summary(fixture);
if totals.likes <> 1 or totals.dislikes <> 1 then raise exception 'Mixed totals are incorrect'; end if;
update public.map_reactions set value=1 where dataset_id=fixture and user_id=member;
select * into totals from public.map_reaction_summary(fixture);
if totals.likes <> 2 or totals.dislikes <> 0 then raise exception 'Changed reaction counted incorrectly'; end if;
delete from public.map_reactions where dataset_id=fixture and user_id=member;
select * into totals from public.map_reaction_summary(fixture);
if totals.likes <> 1 then raise exception 'Reaction withdrawal counted incorrectly'; end if;
insert into public.map_comments(dataset_id,author_id,body) values(fixture,member,'A useful map.') returning id into comment;
begin
insert into public.map_comments(dataset_id,author_id,body) values(fixture,member,' ');
raise exception 'Blank comment accepted';
exception when check_violation then null; end;
begin
update public.map_comments set body=repeat('x',2001) where id=comment;
raise exception 'Oversized comment accepted';
exception when check_violation then null; end;
begin
update public.map_comments set body=' padded ' where id=comment;
raise exception 'Untrimmed comment accepted';
exception when check_violation then null; end;
update public.map_comments set removed_at=now(),removed_by=owner where id=comment;
if not exists(select 1 from public.map_comments where id=comment and body='A useful map.' and removed_at is not null and removed_by=owner)
then raise exception 'Comment removal lost its audit record'; end if;
insert into public.community_reports(reporter_id,target,comment_id,category,reason)
values(owner,'comment',comment,'spam','Repeated unrelated messages.') returning id into report;
begin
insert into public.community_reports(reporter_id,target,comment_id,category,reason)
values(owner,'comment',comment,'advertising','Repeated advertising messages.');
raise exception 'Duplicate open comment report accepted';
exception when unique_violation then null; end;
update public.community_reports set state='dismissed',reviewer_id=member,reviewed_at=now(),review_reason='Reviewed the context.' where id=report;
insert into public.community_reports(reporter_id,target,comment_id,category,reason)
values(owner,'comment',comment,'advertising','New advertising in this comment.');
insert into public.community_reports(reporter_id,target,account_id,category,reason)
values(owner,'account',member,'advertising','Profile contains repeated advertising.');
begin
insert into public.community_reports(reporter_id,target,account_id,category,reason)
values(owner,'account',member,'spam','Repeated unsolicited messages.');
raise exception 'Duplicate open account report accepted';
exception when unique_violation then null; end;
begin
insert into public.community_reports(reporter_id,target,account_id,category,reason)
values(owner,'comment',member,'other','A mismatched report target.');
raise exception 'Mismatched report target accepted';
exception when check_violation then null; end;
begin
insert into public.community_reports(reporter_id,target,account_id,category,reason)
values(member,'account',owner,'unknown','An invalid report category.');
raise exception 'Invalid category accepted';
exception when check_violation then null; end;
begin
insert into public.community_reports(reporter_id,target,account_id,category,reason)
values(member,'account',owner,'other','short');
raise exception 'Short report accepted';
exception when check_violation then null; end;
begin
update public.community_reports set reason=repeat('x',2001) where id=report;
raise exception 'Oversized report accepted';
exception when check_violation then null; end;
begin
update public.community_reports set review_reason=repeat('x',2001) where id=report;
raise exception 'Oversized review accepted';
exception when check_violation then null; end;
begin
update public.community_reports set state='unknown' where id=report;
raise exception 'Invalid report state accepted';
exception when check_violation then null; end;
foreach table_name in array array['map_reactions','map_comments','community_reports'] loop
if not (select relrowsecurity from pg_class where oid=('public.'||table_name)::regclass)
then raise exception 'RLS disabled for %', table_name; end if;
foreach client_role in array array['anon','authenticated'] loop
foreach permission in array array['SELECT','INSERT','UPDATE','DELETE'] loop
if has_table_privilege(client_role,'public.'||table_name,permission)
then raise exception 'Client permission exposed: % % %',client_role,table_name,permission; end if;
end loop;
end loop;
if not has_table_privilege('service_role','public.'||table_name,'SELECT,INSERT,UPDATE,DELETE')
then raise exception 'Service role missing permissions on %',table_name; end if;
end loop;
if has_function_privilege('anon','public.map_reaction_summary(uuid)','EXECUTE')
or has_function_privilege('authenticated','public.map_reaction_summary(uuid)','EXECUTE')
or not has_function_privilege('service_role','public.map_reaction_summary(uuid)','EXECUTE')
then raise exception 'Reaction RPC grants incorrect'; end if;
if (select prosecdef from pg_proc where oid='public.map_reaction_summary(uuid)'::regprocedure)
then raise exception 'Reaction RPC must use caller privileges'; end if;
delete from public.datasets where id=fixture;
if exists(select 1 from public.map_comments where id=comment)
or exists(select 1 from public.map_reactions where dataset_id=fixture)
or exists(select 1 from public.community_reports where comment_id=comment)
then raise exception 'Map deletion left community content behind'; end if;
end; $$;
rollback;