Files
factorio-clusterio/test/messages/note.test.js
bbassieandClaude Opus 5.5 54e9340e61 Add UnavailableError and keep the PermissionError name
Host not connected, host shutting down and instance not running are
now UnavailableError (503) instead of ConflictError. The request error
migration notes move to a new 2.1 guide, since this ships in 2.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:03:13 +00:00

208 lines
9.4 KiB
JavaScript

import assert from "node:assert/strict";
import events from "node:events";
import * as lib from "@clusterio/lib";
import { Controller, ControlConnection } from "@clusterio/controller";
import { testRoundTripJsonSerialisable } from "../common.js";
describe("messages/note", function() {
/** @type {Controller} */
let controller;
/** @type {ControlConnection} */
let controlConnection;
let broadcasts;
beforeEach(function() {
const controllerConfig = new lib.ControllerConfig("controller");
const connection = new lib.VirtualConnector(
lib.Address.fromShorthand("controller"),
lib.Address.fromShorthand({ controlId: 1 }),
);
controller = new Controller(lib.logger, [], controllerConfig);
broadcasts = [];
controller.subscriptions.broadcast = event => {
if (event instanceof lib.NoteUpdatesEvent) {
broadcasts.push(event);
}
};
const user = controller.users.getOrCreateUser("test");
controlConnection = new ControlConnection({ version: "2.0.0" }, connection, controller, user, 1);
});
describe("NoteSetRequest", function() {
it("round trips", function() {
testRoundTripJsonSerialisable(lib.NoteSetRequest, [["host", "1", "text"]]);
});
it("creates a note attributed to the requesting user", async function() {
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "text"));
const note = controller.notes.get("host/1");
assert.equal(note.content, "text");
assert.equal(note.updatedBy, "test");
assert(note.updatedAtMs > 0);
assert.equal(broadcasts.length, 1);
assert.deepEqual(broadcasts[0].updates, [note]);
});
it("updates an existing note in place", async function() {
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "first"));
const first = controller.notes.get("host/1").updatedAtMs;
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "second"));
assert.equal(controller.notes.size, 1);
const note = controller.notes.get("host/1");
assert.equal(note.content, "second");
assert(note.updatedAtMs > first);
});
it("leaves a tombstone when content is empty", async function() {
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "text"));
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", ""));
const note = controller.notes.get("host/1");
assert.equal(note.isDeleted, true);
assert.equal(note.content, "");
assert.equal(broadcasts.length, 2);
assert.equal(broadcasts[1].updates[0].isDeleted, true);
});
it("restores a deleted note", async function() {
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "text"));
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", ""));
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "again"));
const note = controller.notes.get("host/1");
assert.equal(note.isDeleted, false);
assert.equal(note.content, "again");
});
it("ignores empty content for a deleted note", async function() {
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "text"));
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", ""));
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", ""));
assert.equal(broadcasts.length, 2);
});
it("ignores empty content for a resource without a note", async function() {
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", ""));
assert.equal(controller.notes.size, 0);
assert.equal(broadcasts.length, 0);
});
it("rejects an empty resource type or id", async function() {
await assert.rejects(
controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("", "1", "text")),
new lib.ValidationError("resourceType and resourceId must not be empty"),
);
await assert.rejects(
controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "", "text")),
new lib.ValidationError("resourceType and resourceId must not be empty"),
);
});
});
describe("NoteListRequest", function() {
it("returns all notes", async function() {
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "a"));
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("user", "player", "b"));
const notes = await controlConnection.handleNoteListRequest();
assert.deepEqual(notes.map(n => n.id).sort(), ["host/1", "user/player"]);
});
it("leaves out deleted notes", async function() {
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "a"));
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", ""));
assert.deepEqual(await controlConnection.handleNoteListRequest(), []);
});
});
describe("NoteUpdatesEvent", function() {
it("round trips", function() {
testRoundTripJsonSerialisable(lib.NoteUpdatesEvent, [
[[]],
[[new lib.Note("host", "1", "text", "admin", 1234)]],
]);
});
it("only returns notes changed since the last request on subscribe", async function() {
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "a"));
const firstMs = controller.notes.get("host/1").updatedAtMs;
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "2", "b"));
const all = await controller.handleNoteSubscription({ action: "subscribe", lastRequestTimeMs: 0 });
assert.equal(all.updates.length, 2);
const recent = await controller.handleNoteSubscription({
action: "subscribe", lastRequestTimeMs: firstMs,
});
assert.deepEqual(recent.updates.map(n => n.id), ["host/2"]);
const none = await controller.handleNoteSubscription({ action: "unsubscribe", lastRequestTimeMs: 0 });
assert.equal(none, null);
});
it("replays deletions only to clients that may have the note", async function() {
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "a"));
const createdMs = controller.notes.get("host/1").updatedAtMs;
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", ""));
const fresh = await controller.handleNoteSubscription({ action: "subscribe", lastRequestTimeMs: -1 });
assert.equal(fresh, null);
const resumed = await controller.handleNoteSubscription({
action: "subscribe", lastRequestTimeMs: createdMs,
});
assert.deepEqual(resumed.updates.map(n => [n.id, n.isDeleted]), [["host/1", true]]);
});
it("removes a note deleted while a subscriber was unsubscribed", async function() {
let deliver;
let lastRequest;
const link = { sendTo: (address, event) => { deliver(event); } };
const control = {
connector: Object.assign(new events.EventEmitter(), { valid: true }),
handle(Event, handler) { deliver = handler; },
sendTo: (target, request) => {
const src = lib.Address.fromShorthand({ controlId: 2 });
const dst = lib.Address.fromShorthand("controller");
lastRequest = controller.subscriptions.handleRequest(link, request, src, dst);
return lastRequest;
},
};
const subscriber = new lib.MapSubscriber(lib.NoteUpdatesEvent, control);
// Unsubscribing is delayed by a timer, wait for it and the request it sends
const tick = async () => {
await new Promise(resolve => setTimeout(resolve, 10));
await lastRequest;
};
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "text"));
let unsubscribe = subscriber.subscribe(() => {});
await tick();
assert.equal(subscriber.values.get("host/1").content, "text");
unsubscribe();
await tick();
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", ""));
unsubscribe = subscriber.subscribe(() => {});
await tick();
assert.equal(subscriber.synced, true);
assert.equal(subscriber.values.has("host/1"), false);
unsubscribe();
});
});
describe("resource deletion", function() {
it("does nothing for a resource without a note", function() {
controller.deleteNote("host", 1);
assert.equal(broadcasts.length, 0);
});
it("removes the note of a deleted role", async function() {
const id = await controlConnection.handleRoleCreateRequest(new lib.RoleCreateRequest("r", "", []));
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("role", String(id), "text"));
await controlConnection.handleRoleDeleteRequest(new lib.RoleDeleteRequest(id));
assert.equal(controller.notes.get(`role/${id}`).isDeleted, true);
});
it("removes the note of a deleted user", async function() {
controller.users.getOrCreateUser("Player");
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("user", "player", "text"));
controller.sendTo = () => {};
await controlConnection.handleUserDeleteRequest(new lib.UserDeleteRequest("Player"));
assert.equal(controller.notes.get("user/player").isDeleted, true);
});
it("removes the note of a deleted instance", async function() {
const instanceConfig = new lib.InstanceConfig("controller");
await controller.instances.createInstance(instanceConfig);
const id = instanceConfig.get("instance.id");
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("instance", String(id), "text"));
await controlConnection.handleInstanceDeleteRequest(new lib.InstanceDeleteRequest(id));
assert.equal(controller.notes.get(`instance/${id}`).isDeleted, true);
});
});
});