mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 15:24:52 +09:00
Add notes to hosts, instances, users and other resources (#1000)
This commit is contained in:
1 parent
ee39945c83
commit
cbf2b10782
25 files changed
+830
-52
No files matched your search
@@ -1841,6 +1841,53 @@ describe("Integration of Clusterio", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("note set", function() {
|
||||
it("should set the note of the given resource", async function() {
|
||||
await execCtl("note set host 4 basement-box");
|
||||
const notes = await getControl().send(new lib.NoteListRequest());
|
||||
const note = notes.find(n => n.id === "host/4");
|
||||
assert.equal(note.content, "basement-box");
|
||||
assert.equal(note.updatedBy, "test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("note list", function() {
|
||||
it("runs", async function() {
|
||||
await execCtl("note list");
|
||||
});
|
||||
it("runs with --type", async function() {
|
||||
await execCtl("note list --type host");
|
||||
});
|
||||
});
|
||||
|
||||
describe("note get", function() {
|
||||
it("should print the note of the given resource", async function() {
|
||||
const lines = [];
|
||||
const originalLog = console.log; // eslint-disable-line no-console
|
||||
console.log = (...args) => lines.push(args.join(" ")); // eslint-disable-line no-console
|
||||
try {
|
||||
await execCtl("note get host 4");
|
||||
} finally {
|
||||
console.log = originalLog; // eslint-disable-line no-console
|
||||
}
|
||||
assert.deepEqual(lines, ["basement-box"]);
|
||||
});
|
||||
it("should fail for a resource without a note", async function() {
|
||||
await assert.rejects(
|
||||
execCtl("note get host 5"),
|
||||
new lib.CommandError("No note for host 5")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("note delete", function() {
|
||||
it("should remove the note of the given resource", async function() {
|
||||
await execCtl("note delete host 4");
|
||||
const notes = await getControl().send(new lib.NoteListRequest());
|
||||
assert(!notes.find(n => n.id === "host/4"), "Note was not deleted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("user list", function() {
|
||||
it("runs", async function() {
|
||||
await execCtl("user list");
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { testRoundTripJsonSerialisable } from "../../common.js";
|
||||
|
||||
describe("lib/data/Note", function() {
|
||||
describe("class Note", function() {
|
||||
it("should derive its id from the resource type and id", function() {
|
||||
const note = new lib.Note("host", "12", "Runs on the basement box");
|
||||
assert.equal(note.id, "host/12");
|
||||
assert.equal(lib.Note.idFor("instance", 4), "instance/4");
|
||||
});
|
||||
it("should round trip serialize", function() {
|
||||
testRoundTripJsonSerialisable(lib.Note, [
|
||||
["host", "1", "text"],
|
||||
["user", "player", "multi\nline", "admin"],
|
||||
["mod", "mod_1.0.0", "text", "admin", 1234],
|
||||
["save", "1/world.zip", "text", "", 1234, true],
|
||||
]);
|
||||
});
|
||||
it("should omit default optional fields from json", function() {
|
||||
const note = new lib.Note("role", "5", "text");
|
||||
assert.deepEqual(note.toJSON(), { resourceType: "role", resourceId: "5", content: "text" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
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.RequestError("resourceType and resourceId must not be empty"),
|
||||
);
|
||||
await assert.rejects(
|
||||
controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "", "text")),
|
||||
new lib.RequestError("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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -220,6 +220,8 @@ export class MockController {
|
||||
this.handles.set(eventClass, handler);
|
||||
}
|
||||
|
||||
deleteNote() { }
|
||||
|
||||
getControllerUrl() {
|
||||
return "http://controller.example/";
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user