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,
|
||||
);
|
||||
});
|
||||
125
tests/sql/community.sql
Normal file
125
tests/sql/community.sql
Normal 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;
|
||||
Reference in New Issue
Block a user