Cases are one queue for what someone has to decide, each with a thread (internal notes for those deciding), an assignee and a decision. Reports about a product go to the teams of the catalogs hosting it and to moderators, and only the reporter and moderators know who reported. A creator lowering a rating opens a rating review; a catalog's owner asking to delete it opens a deletion request; a catalog's team can ask for a product's removal everywhere; the person an action affected can appeal it once, decided by another moderator (an admin may decide their own, flagged). Resolving carries the case out: the rating is lowered, the product removed, the catalog deleted. Notifications reach people on the website (a bell with the unread count) and their proxies (realtime): moderators' edits to their products, removals and restores, decisions and replies on their cases, membership decisions. Also: moderators can restore a removed product, and a product's creator and moderators can see it when it's adult without an adult account (getting and wearing it still need one). Co-Authored-By: Claude Opus 5.5 <[email protected]>
307 lines
19 KiB
TypeScript
307 lines
19 KiB
TypeScript
// Cases (docs/console.md): reports, rating reviews, deletion and removal requests, appeals; their threads, who sees
|
|
// and decides them, what deciding does; and notifications.
|
|
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
import { openDatabase, type Database } from "@t5de/db";
|
|
import { createApp } from "../src/app";
|
|
import { createAuth } from "../src/auth";
|
|
import { ImvuClient, type ImvuUser } from "../src/imvu";
|
|
|
|
const webURL = "http://web.test";
|
|
const apiURL = "http://api.test";
|
|
const accounts = new Map<number, ImvuUser>();
|
|
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
|
|
const url = new URL(String(input));
|
|
if (url.pathname.startsWith("/product/")) return Response.json({ status: "failure" }, { status: 404 });
|
|
const u = [...accounts.values()].find(
|
|
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
|
|
);
|
|
return Response.json({
|
|
denormalized: u
|
|
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
|
|
: {},
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
// Catalog addresses that answer /health (for address changes).
|
|
const healthy = new Set<string>(["https://moved.example.test"]);
|
|
|
|
let db: Database;
|
|
let close: () => Promise<void>;
|
|
let base: string;
|
|
let stop: () => void;
|
|
|
|
beforeAll(async () => {
|
|
({ db, close } = await openDatabase("memory"));
|
|
const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
|
|
const hub = createApp({
|
|
db,
|
|
auth,
|
|
imvu: new ImvuClient("http://imvu.test", fakeIMVU),
|
|
apiURL,
|
|
webURL,
|
|
catalogHealthy: async (url) => healthy.has(url),
|
|
});
|
|
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
|
|
const server = hub.app.server as unknown as { port: number; stop: () => void };
|
|
stop = () => server.stop();
|
|
base = `http://127.0.0.1:${server.port}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
stop();
|
|
await close();
|
|
});
|
|
|
|
type Person = { cookie: string; name: string };
|
|
async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
|
|
const res = await fetch(base + path, {
|
|
method,
|
|
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers },
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
const text = await res.text();
|
|
let json: any;
|
|
try {
|
|
json = JSON.parse(text);
|
|
} catch {
|
|
json = text;
|
|
}
|
|
return { status: res.status, json };
|
|
}
|
|
|
|
async function person(name: string): Promise<Person> {
|
|
const res = await fetch(base + "/api/auth/sign-up/email", {
|
|
method: "POST",
|
|
headers: { Origin: webURL, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
|
|
});
|
|
return { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "), name };
|
|
}
|
|
|
|
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
|
|
|
|
/** A person's catalog, registered and approved; its key's Authorization header. */
|
|
async function catalogOf(owner: Person, admin: Person, name: string, membership = "open") {
|
|
const reg = await call(owner, "POST", "/api/v1/catalogs", {
|
|
name,
|
|
url: `https://${name.toLowerCase().replace(/\W/g, "")}.example.test`,
|
|
contact: "[email protected]",
|
|
visibility: "public",
|
|
membership,
|
|
});
|
|
expect(reg.status).toBe(201);
|
|
const id = reg.json.catalog.id as string;
|
|
expect((await call(admin, "POST", `/api/v1/console/catalogs/${id}/approve`)).status).toBe(200);
|
|
return { id, auth: { Authorization: `Catalog ${id}:${reg.json.key}` } };
|
|
}
|
|
|
|
/** A product uploaded by a person to a catalog; its ID. */
|
|
async function upload(maker: Person, c: { id: string; auth: Record<string, string> }, name: string, n: number, rating = "general") {
|
|
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
|
|
expect(tok.status).toBe(200);
|
|
const r = await call(
|
|
undefined,
|
|
"POST",
|
|
"/api/v1/catalog/revisions",
|
|
{ uploadToken: tok.json.token, files: [file("index.xml", n)], rating, parent: 0, name, notes: "" },
|
|
c.auth,
|
|
);
|
|
expect(r.status).toBe(201);
|
|
expect((await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`)).status).toBe(200);
|
|
return r.json.product as string;
|
|
}
|
|
|
|
|
|
let admin: Person;
|
|
let mod: Person;
|
|
|
|
const notes = async (p: Person) => (await call(p, "GET", "/api/v1/notifications")).json;
|
|
|
|
test("the first account is the admin; a moderator", async () => {
|
|
admin = await person("admin1");
|
|
mod = await person("warden");
|
|
const id = (await call(mod, "GET", "/api/v1/me")).json.person.id;
|
|
expect((await call(admin, "POST", `/api/v1/console/people/${id}/role`, { role: "moderator" })).status).toBe(200);
|
|
});
|
|
|
|
test("reports: anyone signed in reports; the hosting catalog's team and moderators decide; who reported stays private", async () => {
|
|
const owner = await person("shelfowner");
|
|
const maker = await person("maker");
|
|
const reporter = await person("reporter");
|
|
const outsider = await person("outsider");
|
|
const c = await catalogOf(owner, admin, "Report Shelf");
|
|
const hat = await upload(maker, c, "Odd Hat", 1);
|
|
|
|
expect((await call(undefined, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: "x" })).status).toBe(401);
|
|
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: " " })).json.error).toBe("reason_needed");
|
|
const made = await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: "Copied from another creator" });
|
|
expect(made.status).toBe(201);
|
|
const id = made.json.case.id;
|
|
expect(made.json.case).toMatchObject({ kind: "report", status: "open", tags: { product: hat }, tagNames: { [hat]: "Odd Hat" } });
|
|
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: "Again" })).json.error).toBe("already_reported");
|
|
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { person: "reporter" }, reason: "Me" })).status).toBe(400);
|
|
|
|
// Who sees it: the reporter, the hosting catalog's team, moderators. Not its creator, not others.
|
|
const ids = async (p: Person) => (await call(p, "GET", "/api/v1/cases")).json.cases.map((x: any) => x.id);
|
|
expect(await ids(reporter)).toEqual([id]);
|
|
expect(await ids(owner)).toContain(id);
|
|
expect(await ids(mod)).toContain(id);
|
|
expect(await ids(maker)).not.toContain(id);
|
|
expect((await call(maker, "GET", `/api/v1/cases/${id}`)).status).toBe(404);
|
|
expect((await call(outsider, "GET", `/api/v1/cases/${id}`)).status).toBe(404);
|
|
expect((await call(owner, "GET", `/api/v1/cases/${id}`)).json).toMatchObject({ canDecide: true, canAssign: false });
|
|
expect((await call(reporter, "GET", `/api/v1/cases/${id}`)).json).toMatchObject({ canDecide: false, case: { openedBy: "reporter" } });
|
|
// The catalog's team decides it without knowing who reported; moderators know.
|
|
expect((await call(owner, "GET", `/api/v1/cases/${id}`)).json.case.openedBy).toBe("");
|
|
expect((await call(mod, "GET", `/api/v1/cases/${id}`)).json.case.openedBy).toBe("reporter");
|
|
expect((await call(owner, "GET", "/api/v1/console")).json.todo.cases).toBe(1);
|
|
expect((await call(owner, "GET", "/api/v1/console")).json.sections).toContain("cases");
|
|
|
|
// A thread: replies reach the reporter; internal notes stay with those deciding.
|
|
expect((await call(owner, "POST", `/api/v1/cases/${id}/notes`, { body: "Looking into it" })).status).toBe(200);
|
|
expect((await call(owner, "POST", `/api/v1/cases/${id}/notes`, { body: "Probably fine", internal: true })).status).toBe(200);
|
|
expect((await call(reporter, "GET", `/api/v1/cases/${id}`)).json.notes.map((n: any) => n.body)).toEqual(["Looking into it"]);
|
|
expect((await call(owner, "GET", `/api/v1/cases/${id}`)).json.notes.map((n: any) => [n.body, n.internal])).toEqual([
|
|
["Looking into it", false],
|
|
["Probably fine", true],
|
|
]);
|
|
expect((await notes(reporter)).notifications[0]).toMatchObject({ type: "case_note", data: { case: id, by: "shelfowner" } });
|
|
|
|
// Assigning is for moderators, to moderators.
|
|
expect((await call(owner, "POST", `/api/v1/cases/${id}/assign`, { to: "shelfowner" })).status).toBe(403);
|
|
expect((await call(admin, "POST", `/api/v1/cases/${id}/assign`, { to: "outsider" })).json.error).toBe("invalid");
|
|
expect((await call(admin, "POST", `/api/v1/cases/${id}/assign`, { to: "warden" })).json.case.assignee).toBe("warden");
|
|
|
|
// Deciding: the catalog's owner declines it; the reporter is told; it's done.
|
|
expect((await call(outsider, "POST", `/api/v1/cases/${id}/decide`, { decision: "resolve", resolution: "" })).status).toBe(404);
|
|
const decided = await call(owner, "POST", `/api/v1/cases/${id}/decide`, { decision: "decline", resolution: "It's an original." });
|
|
expect(decided.json.case).toMatchObject({ status: "declined", resolution: "It's an original.", decidedBy: "shelfowner" });
|
|
expect((await call(owner, "POST", `/api/v1/cases/${id}/decide`, { decision: "resolve", resolution: "" })).json.error).toBe("decided");
|
|
expect((await notes(reporter)).notifications[0]).toMatchObject({ type: "case_decided", data: { case: id, status: "declined" } });
|
|
expect((await call(reporter, "GET", "/api/v1/me")).json.person.unread).toBe(2);
|
|
expect((await call(reporter, "POST", "/api/v1/notifications/read", {})).json.unread).toBe(0);
|
|
// The audit log keeps who reported out of what the product's creator can read.
|
|
const audit = (await call(maker, "GET", `/api/v1/console/audit?product=${hat}`)).json.entries.map((e: any) => e.action);
|
|
expect(audit).not.toContain("case.open");
|
|
|
|
// Reports are limited: ten an hour.
|
|
for (let i = 0; i < 9; i++) {
|
|
const p = await person(`spam${i}`);
|
|
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { person: p.name }, reason: "Spam" })).status).toBe(201);
|
|
}
|
|
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { catalog: c.id }, reason: "One more" })).status).toBe(429);
|
|
});
|
|
|
|
test("rating reviews: a creator lowering a rating asks moderators; resolving lowers it", async () => {
|
|
const owner = await person("reviewshelf");
|
|
const maker = await person("rater");
|
|
const c = await catalogOf(owner, admin, "Review Shelf");
|
|
const top = await upload(maker, c, "Plain Top", 2);
|
|
expect((await call(maker, "PATCH", `/api/v1/products/${top}`, { rating: "adult" })).json.review).toBeNull();
|
|
|
|
// Lowering it opens a case (one at a time); other fields in the same edit still change.
|
|
const asked = await call(maker, "PATCH", `/api/v1/products/${top}`, { rating: "general", name: "Plain Tee", reason: "Nothing adult about it" });
|
|
expect(asked.status).toBe(200);
|
|
expect(asked.json.product).toMatchObject({ rating: "adult", name: "Plain Tee" });
|
|
const review = asked.json.review as number;
|
|
expect(review).toBeGreaterThan(0);
|
|
expect((await call(maker, "PATCH", `/api/v1/products/${top}`, { rating: "general" })).json.review).toBe(review);
|
|
const kase = (await call(maker, "GET", `/api/v1/cases/${review}`)).json;
|
|
expect(kase.case).toMatchObject({ kind: "rating-review", reason: "Nothing adult about it", openedBy: "rater" });
|
|
expect(kase.canDecide).toBe(false);
|
|
// Its catalog's team doesn't see or decide ratings; moderators do.
|
|
expect((await call(owner, "POST", `/api/v1/cases/${review}/decide`, { decision: "resolve", resolution: "" })).status).toBe(404);
|
|
expect((await call(mod, "POST", `/api/v1/cases/${review}/decide`, { decision: "resolve", resolution: "Agreed" })).json.case.status).toBe("resolved");
|
|
const history = (await call(maker, "GET", `/api/v1/products/${top}/history`)).json.entries;
|
|
expect(history[0]).toMatchObject({ by: "warden", reason: "Agreed", changes: { rating: { from: "adult", to: "general" } } });
|
|
const types = (await notes(maker)).notifications.map((n: any) => n.type);
|
|
expect(types).toContain("case_decided");
|
|
expect(types).toContain("product_edited");
|
|
|
|
// Files rated adult can't be lowered by anyone: the creator is told at once.
|
|
const lace = await upload(maker, c, "Lace", 3, "adult");
|
|
expect((await call(maker, "PATCH", `/api/v1/products/${lace}`, { rating: "general" })).json.error).toBe("files_adult");
|
|
});
|
|
|
|
test("a catalog's deletion goes to moderators; resolving it deletes the catalog", async () => {
|
|
const owner = await person("leaver");
|
|
const operator = await person("leaverop");
|
|
const c = await catalogOf(owner, admin, "Leaving Shelf");
|
|
expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/team/leaverop?role=catalog-operator`, { role: "catalog-operator" })).status).toBe(200);
|
|
expect((await call(operator, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Done" })).json.missing.permission).toBe("catalog:delete-request");
|
|
const first = (await call(owner, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Closing shop" })).json.case;
|
|
expect((await call(owner, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Again" })).json).toMatchObject({ error: "requested", case: first });
|
|
// Its team sees the case; a decline leaves the catalog as it was.
|
|
expect((await call(operator, "GET", `/api/v1/cases/${first}`)).json.case.kind).toBe("catalog-deletion");
|
|
expect((await call(mod, "POST", `/api/v1/cases/${first}/decide`, { decision: "decline", resolution: "Move your products first" })).status).toBe(200);
|
|
expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}`)).json.catalog.status).toBe("approved");
|
|
const second = (await call(owner, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Moved them" })).json.case;
|
|
expect((await call(mod, "POST", `/api/v1/cases/${second}/decide`, { decision: "resolve", resolution: "" })).status).toBe(200);
|
|
expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}`)).json.catalog.status).toBe("deleted");
|
|
expect((await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth)).status).toBe(401);
|
|
});
|
|
|
|
test("removal requests: a catalog's team asks; moderators remove the product everywhere", async () => {
|
|
const owner = await person("cautious");
|
|
const maker = await person("copier");
|
|
const c = await catalogOf(owner, admin, "Careful Shelf");
|
|
const shoes = await upload(maker, c, "Copied Shoes", 4);
|
|
expect((await call(maker, "POST", "/api/v1/cases", { kind: "removal-request", about: { product: shoes }, catalog: c.id, reason: "x" })).status).toBe(403);
|
|
const asked = await call(owner, "POST", "/api/v1/cases", { kind: "removal-request", about: { product: shoes }, catalog: c.id, reason: "Stolen work" });
|
|
expect(asked.status).toBe(201);
|
|
expect((await call(owner, "POST", `/api/v1/cases/${asked.json.case.id}/decide`, { decision: "resolve", resolution: "" })).status).toBe(403);
|
|
expect((await call(mod, "POST", `/api/v1/cases/${asked.json.case.id}/decide`, { decision: "resolve", resolution: "Confirmed" })).status).toBe(200);
|
|
expect((await call(undefined, "GET", `/api/v1/products/${shoes}`)).status).toBe(410);
|
|
expect((await notes(maker)).notifications[0]).toMatchObject({ type: "product_removed", data: { product: shoes, reason: "Confirmed" } });
|
|
});
|
|
|
|
test("appeals: the person affected appeals an action once; another moderator decides", async () => {
|
|
const owner = await person("appealshelf");
|
|
const maker = await person("appealer");
|
|
const c = await catalogOf(owner, admin, "Appeal Shelf");
|
|
const coat = await upload(maker, c, "Fine Coat", 5);
|
|
expect((await call(mod, "POST", `/api/v1/products/${coat}/remove`, { reason: "Looks stolen" })).status).toBe(200);
|
|
expect((await notes(maker)).notifications[0]).toMatchObject({ type: "product_removed", data: { name: "Fine Coat", reason: "Looks stolen" } });
|
|
|
|
// The creator's audit log offers the removal for appeal; others can't appeal it.
|
|
const entry = (await call(maker, "GET", `/api/v1/console/audit?product=${coat}`)).json.entries.find((e: any) => e.action === "product.remove");
|
|
expect(entry.appealable).toBe(true);
|
|
expect((await call(owner, "POST", "/api/v1/cases", { kind: "appeal", audit: entry.id, reason: "x" })).status).toBe(404);
|
|
const appeal = await call(maker, "POST", "/api/v1/cases", { kind: "appeal", audit: entry.id, reason: "I made it from scratch" });
|
|
expect(appeal.json.case).toMatchObject({ kind: "appeal", data: { action: "product.remove" } });
|
|
expect((await call(maker, "POST", "/api/v1/cases", { kind: "appeal", audit: entry.id, reason: "Again" })).json.error).toBe("appealed");
|
|
|
|
// The moderator who removed it doesn't decide the appeal; an admin may (flagged in the audit log when it was theirs).
|
|
const id = appeal.json.case.id;
|
|
expect((await call(mod, "GET", `/api/v1/cases/${id}`)).json.canDecide).toBe(false);
|
|
expect((await call(mod, "POST", `/api/v1/cases/${id}/decide`, { decision: "decline", resolution: "" })).json.message).toContain("Another moderator");
|
|
expect((await call(admin, "POST", `/api/v1/cases/${id}/decide`, { decision: "resolve", resolution: "Restoring it" })).json.case.status).toBe("resolved");
|
|
// Upholding it records the decision; the moderator restores the product (only moderators can).
|
|
expect((await call(maker, "POST", `/api/v1/products/${coat}/unremove`, {})).status).toBe(403);
|
|
expect((await call(admin, "POST", `/api/v1/products/${coat}/unremove`, { reason: "Appeal upheld" })).status).toBe(200);
|
|
expect((await call(undefined, "GET", `/api/v1/products/${coat}`)).status).toBe(200);
|
|
expect((await call(admin, "POST", `/api/v1/products/${coat}/unremove`, {})).status).toBe(404);
|
|
expect((await notes(maker)).notifications[0]).toMatchObject({ type: "product_restored", data: { reason: "Appeal upheld" } });
|
|
});
|
|
|
|
test("membership decisions and moderators' edits reach people as notifications", async () => {
|
|
const owner = await person("gatekeeper");
|
|
const joiner = await person("joiner");
|
|
const c = await catalogOf(owner, admin, "Gated Shelf", "approval");
|
|
expect((await call(joiner, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("requested");
|
|
expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/joiner`, { status: "member" })).status).toBe(200);
|
|
expect((await notes(joiner)).notifications[0]).toMatchObject({ type: "membership_decided", data: { catalog: c.id, status: "member" } });
|
|
|
|
const maker = await person("edited");
|
|
const p = await upload(maker, c, "Wrong Name", 6);
|
|
expect((await call(mod, "PATCH", `/api/v1/products/${p}`, { name: "Right Name", reason: "Typo" })).status).toBe(200);
|
|
expect((await notes(maker)).notifications[0]).toMatchObject({
|
|
type: "product_edited",
|
|
data: { product: p, name: "Right Name", fields: ["name"], reason: "Typo", by: "warden" },
|
|
});
|
|
// Their own edits don't notify them.
|
|
const before = (await notes(maker)).notifications.length;
|
|
await call(maker, "PATCH", `/api/v1/products/${p}`, { tags: ["mine"] });
|
|
expect((await notes(maker)).notifications.length).toBe(before);
|
|
});
|