Files
factorio-expcluster/exp_reports/test/controller.test.js
T
bbassieandClaude Opus 5.5 44ae3a5f0e Add a watch list and read state to exp_reports
Players on the watch list are announced to every online admin on every
instance when they join, and the join is posted to the Discord and JSON
webhooks. The Reports page has a second table for the watch list with an
Add button and a Delete button per row.

Reports can be marked read or unread from the web interface. Each report
row also has an Add to watch list button which opens the add form filled
in with the player and reason. Setting exp_reports.watch_unread_reports
treats players with unread reports as watched.

Reports now store created_at_ms because marking them read changes
updated_at_ms. Old reports.json files migrate on load with updated_at_ms
as the creation time. The report webhook moved from the datastore update
listener to the create handler so reading a report is not posted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:36:22 +00:00

330 lines
14 KiB
JavaScript

import t from "tap";
import * as lib from "@clusterio/lib";
import { Controller, InstanceRecord } from "@clusterio/controller";
import entrypoint from "../dist/node/controller.js";
import * as messages from "../dist/node/messages.js";
import { plugin as pluginDeclaration } from "../dist/node/index.js";
// The controller validates message classes against the link registry, and the
// plugin's config fields must be defined before a ControllerConfig can set them
for (const Message of pluginDeclaration.messages) {
lib.Link.register(Message);
}
lib.addPluginConfigFields([pluginDeclaration]);
const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} };
// Silence the singleton logger to avoid polluting the test output
lib.logger.silent = true;
t.after(() => {
lib.logger.silent = false;
});
const fromInstance = lib.Address.fromShorthand({ instanceId: 1 });
const fromUnknownInstance = lib.Address.fromShorthand({ instanceId: 9 });
const fromControl = lib.Address.fromShorthand({ controlId: 7 });
/** Run the plugin on a real controller, which is side effect free while not started, returns the handlers it registered. */
async function startPlugin(t2, { config = {}, files = {} } = {}) {
const controllerConfig = new lib.ControllerConfig("controller", {
"controller.database_directory": t2.testdir(files),
...config,
});
const controller = new Controller(logger, [], controllerConfig);
const instanceConfig = new lib.InstanceConfig("controller");
instanceConfig.set("instance.id", 1);
instanceConfig.set("instance.name", "EXP");
const instanceRecord = new InstanceRecord(instanceConfig, "running");
controller.instances.records.set(instanceRecord);
controller.wsServer.controlConnections.set(7, { user: { name: "admin" } });
// Spies which record and then defer to the real behaviour
const state = { broadcasts: [], posts: [], warnings: [], sent: [] };
controller.sendTo = (dst, message) => state.sent.push({ dst, message });
const broadcast = controller.subscriptions.broadcast.bind(controller.subscriptions);
controller.subscriptions.broadcast = event => {
state.broadcasts.push(event);
broadcast(event);
};
const realFetch = globalThis.fetch;
globalThis.fetch = async (url, init) => {
state.posts.push({ url, body: JSON.parse(init.body) });
return state.fetchResult();
};
state.fetchResult = () => ({ ok: true, status: 200 });
t2.teardown(() => { globalThis.fetch = realFetch; });
const handlers = new Map();
const handle = controller.handle.bind(controller);
controller.handle = (Class, handler) => {
handlers.set(Class, handler);
handle(Class, handler);
};
const subscriptionHandle = controller.subscriptions.handle.bind(controller.subscriptions);
controller.subscriptions.handle = (Class, handler) => {
handlers.set(Class, handler);
subscriptionHandle(Class, handler);
};
const pluginLogger = { ...logger, warn: message => state.warnings.push(message) };
await entrypoint({ plugin: { name: "exp_reports" }, controller, logger: pluginLogger });
const request = (Request, ...args) => handlers.get(Request)(new Request(...args), fromInstance);
return {
controller,
state,
create: (playerName, reason, byPlayerName, src = fromInstance) => handlers.get(messages.ReportCreateRequest)(
new messages.ReportCreateRequest(playerName, reason, byPlayerName), src,
),
list: playerName => request(messages.ReportListRequest, playerName),
get: id => request(messages.ReportGetRequest, id),
remove: id => request(messages.ReportDeleteRequest, id),
setRead: (id, isRead) => request(messages.ReportSetReadRequest, id, isRead),
subscription: lastRequestTimeMs => handlers.get(messages.ReportUpdatedEvent)({ lastRequestTimeMs }),
watch: (playerName, reason) => handlers.get(messages.WatchListAddRequest)(
new messages.WatchListAddRequest(playerName, reason), fromControl,
),
unwatch: playerName => request(messages.WatchListRemoveRequest, playerName),
watchSubscription: lastRequestTimeMs => handlers.get(messages.WatchListUpdatedEvent)({ lastRequestTimeMs }),
join: name => controller.hooks.playerEvent.invoke(instanceRecord, { type: "join", name, stats: {} }),
};
}
/** Let the webhook posts which are not awaited finish. */
const settle = () => new Promise(resolve => setImmediate(resolve));
t.test("entrypoint", async t2 => {
const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir() });
const controller = new Controller(logger, [], controllerConfig);
await entrypoint({ controller, logger, plugin: { name: "exp_reports" }, metrics: undefined });
for (const hook of ["playerEvent", "shutdown"]) {
t2.ok([...controller.hooks[hook].attached].includes("exp_reports"), `attached to ${hook}`);
}
await controller.hooks.shutdown.invoke();
});
t.test("controller plugin", t2 => {
t2.test("ReportCreateRequest from an instance records the instance and the reporter", async t3 => {
const { create, list } = await startPlugin(t3);
const report = await create("bob", " griefing ", "alice", fromInstance);
t3.strictSame(report.playerName, "bob");
t3.strictSame(report.byPlayerName, "alice", "the reporter comes from the request");
t3.strictSame(report.reason, "griefing", "the reason is trimmed");
t3.strictSame(report.instanceName, "EXP", "the instance is named");
t3.ok(report.createdAtMs > 0, "the report is timestamped");
t3.strictSame(report.isRead, false, "new reports are unread");
t3.strictSame(await list(), [report], "the report is stored");
const unknown = await create("carol", "spam", "alice", fromUnknownInstance);
t3.strictSame(unknown.instanceName, "9", "an unknown instance is named by its id");
});
t2.test("ReportCreateRequest from the web ui uses the user of the connection", async t3 => {
const { create } = await startPlugin(t3);
const report = await create("bob", "griefing", "someone-else", fromControl);
t3.strictSame(report.byPlayerName, "admin", "the reporter is the connected user");
t3.strictSame(report.instanceName, "", "there is no instance");
});
t2.test("ReportCreateRequest rejects reports which can not be stored", async t3 => {
const { create, list } = await startPlugin(t3);
await create("bob", "griefing", "alice", fromInstance);
await t3.rejects(
create("bob", "again", "alice", fromInstance),
{ message: "alice has already reported bob" },
"a player can only report another once",
);
await t3.rejects(
create("bob", " ", "carol", fromInstance),
{ message: "A report needs a reason" },
"a reason is required",
);
await t3.rejects(
create("bob", "spam", "", fromInstance),
{ message: "A report needs the name of the player making it" },
"an instance must name the reporter",
);
t3.strictSame((await list()).length, 1, "nothing else was stored");
});
t2.test("ReportListRequest and ReportGetRequest find reports", async t3 => {
const { create, list, get } = await startPlugin(t3);
const first = await create("bob", "griefing", "alice", fromInstance);
await create("carol", "spam", "alice", fromInstance);
t3.strictSame((await list()).length, 2, "everything is listed");
t3.strictSame(
await list("bob"), [first],
"a player's reports are listed",
);
t3.strictSame(await get(first.id), first);
await t3.rejects(
get(123),
{ message: "Report with ID 123 does not exist" },
);
});
t2.test("ReportDeleteRequest removes the report and broadcasts the deletion", async t3 => {
const { create, list, remove, state } = await startPlugin(t3);
const report = await create("bob", "griefing", "alice", fromInstance);
state.broadcasts.length = 0;
await remove(report.id);
t3.strictSame(await list(), [], "the report is gone");
t3.ok(
state.broadcasts.some(event => event instanceof messages.ReportUpdatedEvent && event.updates[0].isDeleted),
"subscribers are told it was deleted",
);
await t3.rejects(
remove(report.id),
{ message: `Report with ID ${report.id} does not exist` },
);
});
t2.test("ReportSetReadRequest marks a report read or unread without changing when it was made", async t3 => {
const { create, get, setRead, state } = await startPlugin(t3, { config: {
"exp_reports.json_webhook_url": "https://json.example/hook",
} });
const report = await create("bob", "griefing", "alice");
const createdAtMs = report.createdAtMs;
state.posts.length = 0;
state.broadcasts.length = 0;
await setRead(report.id, true);
t3.strictSame((await get(report.id)).isRead, true);
t3.strictSame((await get(report.id)).createdAtMs, createdAtMs, "the creation time is kept");
t3.ok(state.broadcasts.some(event => event.updates?.[0]?.isRead), "subscribers are told");
t3.strictSame(state.posts, [], "reading a report is not posted");
await setRead(report.id, false);
t3.strictSame((await get(report.id)).isRead, false);
await t3.rejects(setRead(123, true), { message: "Report with ID 123 does not exist" });
});
t2.test("reports saved before the read state load with updated_at_ms as the creation time", async t3 => {
const { get } = await startPlugin(t3, { files: { exp_reports: { "reports.json": JSON.stringify([{
id: 5, player_name: "bob", by_player_name: "alice", reason: "griefing", instance_name: "EXP", updated_at_ms: 1000,
}]) } } });
const report = await get(5);
t3.strictSame([report.createdAtMs, report.isRead], [1000, false]);
});
t2.test("WatchListAddRequest and WatchListRemoveRequest edit the watch list", async t3 => {
const { watch, unwatch, watchSubscription, state } = await startPlugin(t3);
const entry = await watch(" bob ", " griefing ");
t3.strictSame(
[entry.playerName, entry.reason, entry.byPlayerName], ["bob", "griefing", "admin"],
"the entry is trimmed and added by the connected user",
);
t3.strictSame((await watchSubscription(0)).updates, [entry], "subscribers get the entry");
await t3.rejects(watch("bob", "again"), { message: "bob is already on the watch list" });
await t3.rejects(watch(" ", "spam"), { message: "A player name is required" });
await t3.rejects(watch("carol", " "), { message: "A reason is required" });
await unwatch("bob");
t3.ok(
state.broadcasts.some(event => event instanceof messages.WatchListUpdatedEvent && event.updates[0].isDeleted),
"subscribers are told it was removed",
);
t3.strictSame(await watchSubscription(0), null, "nothing is left to replay");
await t3.rejects(unwatch("bob"), { message: "bob is not on the watch list" });
});
t2.test("a watched player joining is sent to every instance and posted", async t3 => {
const { watch, join, state } = await startPlugin(t3, { config: {
"exp_reports.discord_webhook_url": "https://discord.example/hook",
"exp_reports.json_webhook_url": "https://json.example/hook",
} });
const entry = await watch("bob", "griefing");
await join("carol");
t3.strictSame(state.sent, [], "players not on the list are ignored");
t3.strictSame(state.posts, []);
await join("bob");
t3.strictSame(state.sent.length, 1);
const { dst, message } = state.sent[0];
t3.strictSame(dst, "allInstances");
t3.strictSame(message.toJSON(), new messages.WatchedPlayerJoinedEvent("bob", "EXP", entry, []).toJSON());
const embed = state.posts[0].body.embeds[0];
t3.strictSame(
embed.fields.map(field => [field.name, field.value]),
[["Player", "bob"], ["Instance", "EXP"], ["Watch list, added by admin", "griefing"]],
);
t3.strictSame(state.posts[1].body, { type: "watched_player_joined", ...message.toJSON() });
});
t2.test("unread reports count as the watch list only when configured", async t3 => {
const { controller, create, setRead, join, state } = await startPlugin(t3);
const read = await create("bob", "spam", "alice");
const unread = await create("bob", "griefing", "carol");
await setRead(read.id, true);
await join("bob");
t3.strictSame(state.sent, [], "reports are ignored by default");
controller.config.set("exp_reports.watch_unread_reports", true);
await join("bob");
const { message } = state.sent[0];
t3.strictSame(message.watchListEntry, undefined, "bob is not on the watch list");
t3.strictSame(message.unreadReports.map(report => report.id), [unread.id], "only the unread report is sent");
});
t2.test("subscribing replays only newer records", async t3 => {
const { create, subscription } = await startPlugin(t3);
const report = await create("bob", "griefing", "alice", fromInstance);
const all = await subscription(0);
t3.strictSame(all.updates, [report], "everything is replayed from the start");
const none = await subscription(report.updatedAtMs);
t3.strictSame(none, null, "nothing is replayed when up to date");
});
t2.test("new reports are posted to the configured hooks", async t3 => {
const { create, remove, state } = await startPlugin(t3, { config: {
"exp_reports.discord_webhook_url": "https://discord.example/hook",
"exp_reports.json_webhook_url": "https://json.example/hook",
} });
const report = await create("bob", "griefing", "alice", fromInstance);
t3.strictSame(state.posts.map(post => post.url), ["https://discord.example/hook", "https://json.example/hook"]);
const embed = state.posts[0].body.embeds[0];
t3.strictSame(embed.fields.map(field => field.value), ["bob", "alice", "EXP", "griefing"], "the embed names the report");
t3.strictSame(state.posts[1].body, { type: "report_created", report: report.toJSON() }, "the json hook gets the record");
state.posts.length = 0;
await remove(report.id);
t3.strictSame(state.posts, [], "deletions are not posted");
});
t2.test("nothing is posted without hooks", async t3 => {
const { create, state } = await startPlugin(t3);
await create("bob", "griefing", "alice", fromInstance);
t3.strictSame(state.posts, []);
});
t2.test("webhooks log rather than throw when the hook fails", async t3 => {
const { create, state } = await startPlugin(t3, { config: {
"exp_reports.json_webhook_url": "https://down.example/hook",
} });
state.fetchResult = () => { throw new Error("connection refused"); };
await create("bob", "griefing", "alice");
await settle();
t3.strictSame(state.warnings, ["Webhook https://down.example/hook failed: connection refused"]);
state.fetchResult = () => ({ ok: false, status: 404 });
await create("bob", "griefing", "carol");
await settle();
t3.strictSame(state.warnings[1], "Webhook https://down.example/hook responded with 404");
});
t2.end();
});