Refactor user and instance manager (#837)

* Refactor UserManager to use Datastore

* Rename lib.User to UserDetails also adds to/fromUserDetails

* Fix existing tests

* Add user class tests

* Add tests for finaliseUsers

* Move HostDetails to own file with tests

* Move InstanceDetails to its own file with tests

* Rename HostInfo and add tests

* Rename InstanceInfo and add tests

- Also changes the names of the host properties related to instances
- Also removes the need to specify the location of InstanceInfo

* Make json round trip test validate schema

* Add InstanceManager

* Fix lint errors

* Extra tests

* Add final test coverage

* Add one last test to InstanceManager

* Response to comments

* Use generator rather than return anonumous generator

Co-authored-by: Hornwitser <github@hornwitser.no>

* Fix lint issue with generator

---------

Co-authored-by: Hornwitser <github@hornwitser.no>
This commit is contained in:
Cooldude2606andHornwitser authored and GitHub committed 2026-03-01 23:04:37 +00:00
1 parent 5bea930d05
commit 2235294c59
60 files changed
+3371 -1227

No files matched your search

+145
View File
@@ -0,0 +1,145 @@
"use strict";
const assert = require("assert").strict;
const { HostDetails } = require("@clusterio/lib");
const { testMatrix, testRoundTripJsonSerialisable } = require("../../common");
describe("lib/data/HostDetails", function () {
it("should be round trip serialisable", function () {
testRoundTripJsonSerialisable(HostDetails, testMatrix(
["1.0.0"], // version (required)
["TestHost"], // name (required)
[1], // id (required)
[true, false], // connected (required)
[undefined, "10.0.0.1"], // remoteAddress
[undefined, "203.0.113.1"], // publicAddress
[undefined, 123456], // tokenValidAfter (sec)
[undefined, 999999], // updatedAtMs (ms)
[undefined, true], // isDeleted
));
});
describe("constructor", function () {
it("should set defaults correctly", function () {
const host = new HostDetails("1.0.0", "TestHost", 1, true);
assert.equal(host.version, "1.0.0");
assert.equal(host.name, "TestHost");
assert.equal(host.id, 1);
assert.equal(host.connected, true);
assert.equal(host.remoteAddress, "");
assert.equal(host.publicAddress, "");
assert.equal(host.tokenValidAfter, 0);
assert.equal(host.updatedAtMs, 0);
assert.equal(host.isDeleted, false);
});
it("should accept all constructor parameters", function () {
const host = new HostDetails(
"2.0.0", "FullHost", 42,
false, "10.1.1.1", "198.51.100.5",
123, 999, true
);
assert.equal(host.version, "2.0.0");
assert.equal(host.name, "FullHost");
assert.equal(host.id, 42);
assert.equal(host.connected, false);
assert.equal(host.remoteAddress, "10.1.1.1");
assert.equal(host.publicAddress, "198.51.100.5");
assert.equal(host.tokenValidAfter, 123);
assert.equal(host.updatedAtMs, 999);
assert.equal(host.isDeleted, true);
});
});
describe(".toJSON()", function () {
it("should only include required fields when optional fields are not given", function () {
const host = new HostDetails("1.0.0", "TestHost", 1, false);
assert.deepEqual(host.toJSON(), {
version: "1.0.0",
name: "TestHost",
id: 1,
connected: false,
});
});
it("should include all optional fields when set", function () {
const host = new HostDetails(
"2.0.0", "FullHost", 99,
true, "10.0.0.5", "203.0.113.5",
500, 1000, true
);
assert.deepEqual(host.toJSON(), {
version: "2.0.0",
name: "FullHost",
id: 99,
connected: true,
remoteAddress: "10.0.0.5",
publicAddress: "203.0.113.5",
tokenValidAfter: 500,
updatedAtMs: 1000,
isDeleted: true,
});
});
it("should omit optional fields when equal to defaults", function () {
const host = new HostDetails(
"1.0.0", "TestHost", 1,
true, "", "",
0, 0, false
);
assert.deepEqual(host.toJSON(), {
version: "1.0.0",
name: "TestHost",
id: 1,
connected: true,
});
});
});
describe("static fromJSON()", function () {
it("should construct minimal object", function () {
const host = HostDetails.fromJSON({
version: "1.0.0",
name: "TestHost",
id: 1,
connected: true,
});
assert.equal(host.version, "1.0.0");
assert.equal(host.name, "TestHost");
assert.equal(host.id, 1);
assert.equal(host.connected, true);
assert.equal(host.remoteAddress, "");
assert.equal(host.publicAddress, "");
assert.equal(host.tokenValidAfter, 0);
assert.equal(host.updatedAtMs, 0);
assert.equal(host.isDeleted, false);
});
it("should preserve optional fields exactly when provided", function () {
const host = HostDetails.fromJSON({
version: "3.0.0",
name: "RemoteHost",
id: 7,
connected: false,
remoteAddress: "192.168.1.5",
publicAddress: "198.51.100.10",
tokenValidAfter: 100,
updatedAtMs: 2000,
isDeleted: true,
});
assert.equal(host.remoteAddress, "192.168.1.5");
assert.equal(host.publicAddress, "198.51.100.10");
assert.equal(host.tokenValidAfter, 100);
assert.equal(host.updatedAtMs, 2000);
assert.equal(host.isDeleted, true);
});
});
});
+153
View File
@@ -0,0 +1,153 @@
"use strict";
const assert = require("assert").strict;
const { InstanceDetails } = require("@clusterio/lib");
const { testMatrix, testRoundTripJsonSerialisable } = require("../../common");
describe("lib/data/InstanceDetails", function () {
it("should be round trip serialisable", function () {
testRoundTripJsonSerialisable(InstanceDetails, testMatrix(
["TestInstance"], // name (required)
[1], // id (required)
[undefined, 5], // assignedHost
[undefined, 34197], // gamePort
["running", "stopped"], // status (required)
[undefined, "1.0.0"], // factorioVersion
[undefined, 1234], // startedAtMs
[undefined, 9999], // updatedAtMs
[undefined, true], // excludeFromStartAll
));
});
describe("constructor", function () {
it("should set defaults correctly", function () {
const instance = new InstanceDetails("TestInstance", 1, undefined, undefined, "running");
assert.equal(instance.name, "TestInstance");
assert.equal(instance.id, 1);
assert.equal(instance.assignedHost, undefined);
assert.equal(instance.gamePort, undefined);
assert.equal(instance.status, "running");
assert.equal(instance.factorioVersion, undefined);
assert.equal(instance.startedAtMs, 0);
assert.equal(instance.updatedAtMs, 0);
assert.equal(instance.excludeFromStartAll, false);
});
it("should accept all constructor parameters", function () {
const instance = new InstanceDetails(
"FullInstance", 42, 10, 34197,
"starting", "1.0.0", 1000, 2000, true
);
assert.equal(instance.name, "FullInstance");
assert.equal(instance.id, 42);
assert.equal(instance.assignedHost, 10);
assert.equal(instance.gamePort, 34197);
assert.equal(instance.status, "starting");
assert.equal(instance.factorioVersion, "1.0.0");
assert.equal(instance.startedAtMs, 1000);
assert.equal(instance.updatedAtMs, 2000);
assert.equal(instance.excludeFromStartAll, true);
});
});
describe(".toJSON()", function () {
it("should only include required fields when optional fields are not given", function () {
const instance = new InstanceDetails("TestInstance", 1, undefined, undefined, "running");
assert.deepEqual(instance.toJSON(), {
name: "TestInstance",
id: 1,
status: "running",
});
});
it("should include all optional fields when set", function () {
const instance = new InstanceDetails(
"FullInstance", 99, 5, 34197,
"running", "1.0.0", 500, 1000, true
);
assert.deepEqual(instance.toJSON(), {
name: "FullInstance",
id: 99,
status: "running",
assignedHost: 5,
gamePort: 34197,
factorioVersion: "1.0.0",
startedAtMs: 500,
updatedAtMs: 1000,
excludeFromStartAll: true,
});
});
it("should omit optional fields when equal to defaults", function () {
const instance = new InstanceDetails(
"TestInstance", 1, undefined, undefined,
"stopped", undefined, 0, 0, false
);
assert.deepEqual(instance.toJSON(), {
name: "TestInstance",
id: 1,
status: "stopped",
});
});
});
describe("static fromJSON()", function () {
it("should construct minimal object and apply constructor defaults", function () {
const instance = InstanceDetails.fromJSON({
name: "TestInstance",
id: 1,
status: "running",
});
assert.equal(instance.name, "TestInstance");
assert.equal(instance.id, 1);
assert.equal(instance.assignedHost, undefined);
assert.equal(instance.gamePort, undefined);
assert.equal(instance.factorioVersion, undefined);
assert.equal(instance.startedAtMs, 0);
assert.equal(instance.updatedAtMs, 0);
assert.equal(instance.excludeFromStartAll, false);
});
it("should preserve optional fields exactly when provided", function () {
const instance = InstanceDetails.fromJSON({
name: "RemoteInstance",
id: 7,
status: "starting",
assignedHost: 2,
gamePort: 34197,
factorioVersion: "1.0.0",
startedAtMs: 100,
updatedAtMs: 200,
excludeFromStartAll: true,
});
assert.equal(instance.assignedHost, 2);
assert.equal(instance.gamePort, 34197);
assert.equal(instance.factorioVersion, "1.0.0");
assert.equal(instance.startedAtMs, 100);
assert.equal(instance.updatedAtMs, 200);
assert.equal(instance.excludeFromStartAll, true);
});
});
describe("get .isDeleted", function () {
it("should return true when status is 'deleted'", function () {
const instance = new InstanceDetails("TestInstance", 1, undefined, undefined, "deleted");
assert.equal(instance.isDeleted, true);
});
it("should return false when status is not 'deleted'", function () {
const instance = new InstanceDetails("TestInstance", 1, undefined, undefined, "running");
assert.equal(instance.isDeleted, false);
});
});
});
-71
View File
@@ -1,71 +0,0 @@
"use strict";
const assert = require("assert").strict;
const lib = require("@clusterio/lib");
describe("lib/data/User", function() {
describe("class User", function() {
it("should round trip serialize", function() {
function test_roundtrip(serialized) {
let user = lib.User.fromJSON(serialized);
let user_serialized = user.toJSON();
assert.deepEqual(user_serialized, serialized);
let user_deserialized = lib.User.fromJSON(user_serialized);
assert.deepEqual(user_deserialized, user);
}
test_roundtrip({ name: "admin", roles: [1] });
test_roundtrip({ name: "user", is_admin: true, is_whitelisted: true });
test_roundtrip({ name: "user", is_banned: true, ban_reason: "Bad user" });
test_roundtrip({ name: "user", instance_stats: [[1, { join_count: 1 }]]});
});
it("should keep invalid roles", function() {
let user = lib.User.fromJSON({ name: "test", roles: [1, 4, 55] });
assert.equal(user.roleIds.size, 3, "Unexpected count of roles");
});
it("should calculate playerStats", function() {
let user = lib.User.fromJSON({ name: "test", roles: [1], instance_stats: [
[1, {
join_count: 1,
online_time_ms: 60e3,
last_join_at_ms: new Date("2020-05T12:00Z").getTime(),
last_leave_at_ms: new Date("2020-05T12:01Z").getTime(),
last_leave_reason: "quit",
}],
[2, {
join_count: 1,
online_time_ms: 0,
first_join_at_ms: new Date("2020-05T12:00Z").getTime(),
last_join_at_ms: new Date("2020-05T12:02Z").getTime(),
}],
]});
assert.equal(user.playerStats.onlineTimeMs, 60e3);
assert.equal(user.playerStats.joinCount, 2);
assert.deepEqual(user.playerStats.firstJoinAt, new Date("2020-05T12:00Z"));
assert.deepEqual(user.playerStats.lastJoinAt, new Date("2020-05T12:02Z"));
assert.deepEqual(user.playerStats.lastLeaveAt, new Date("2020-05T12:01Z"));
assert.equal(user.playerStats.lastLeaveReason, "quit");
user.instanceStats.set(
3,
lib.PlayerStats.fromJSON({
join_count: 2,
online_time_ms: 120e3,
first_join_at_ms: new Date("2020-05T11:00Z").getTime(),
last_join_at_ms: new Date("2020-05T13:00Z").getTime(),
last_leave_at_ms: new Date("2020-05T13:02Z").getTime(),
last_leave_reason: "afk",
}),
);
user.recalculatePlayerStats();
assert.equal(user.playerStats.onlineTimeMs, 180e3);
assert.equal(user.playerStats.joinCount, 4);
assert.deepEqual(user.playerStats.firstJoinAt, new Date("2020-05T11:00Z"));
assert.deepEqual(user.playerStats.lastJoinAt, new Date("2020-05T13:00Z"));
assert.deepEqual(user.playerStats.lastLeaveAt, new Date("2020-05T13:02Z"));
assert.equal(user.playerStats.lastLeaveReason, "afk");
});
});
});
+267
View File
@@ -0,0 +1,267 @@
"use strict";
const assert = require("assert").strict;
const { UserDetails, PlayerStats } = require("@clusterio/lib");
const { testMatrix, testRoundTripJsonSerialisable } = require("../../common");
describe("lib/data/UserDetails", function () {
it("should be round trip serialisable", function () {
testRoundTripJsonSerialisable(UserDetails, testMatrix(
["TestUser"], // name (required)
[undefined, new Set([1])], // roleIds
[undefined, new Set([2])], // instances
[undefined, true], // isAdmin
[undefined, true], // isBanned
[undefined, true], // isWhitelisted
[undefined, "reason"], // banReason
[undefined, 123], // updatedAtMs
[undefined, true], // isDeleted
[ // instanceStats
undefined,
new Map([
[1, PlayerStats.fromJSON({ join_count: 1, online_time_ms: 100 })],
]),
],
));
});
describe("constructor", function () {
it("should set defaults correctly", function () {
const user = new UserDetails("TestUser");
assert.equal(user.name, "TestUser");
assert.equal(user.roleIds.size, 0);
assert.equal(user.instances.size, 0);
assert.equal(user.isAdmin, false);
assert.equal(user.isBanned, false);
assert.equal(user.isWhitelisted, false);
assert.equal(user.banReason, "");
assert.equal(user.updatedAtMs, 0);
assert.equal(user.isDeleted, false);
assert(user.playerStats instanceof PlayerStats);
assert(user.instanceStats instanceof Map);
});
it("should accept all constructor parameters", function () {
const user = new UserDetails(
"TestUser", new Set([1]), new Set([2]),
true, true, true, "reason",
100, true,
new Map()
);
assert.equal(user.name, "TestUser");
assert.deepEqual(user.roleIds, new Set([1]));
assert.deepEqual(user.instances, new Set([2]));
assert.equal(user.isAdmin, true);
assert.equal(user.isBanned, true);
assert.equal(user.isWhitelisted, true);
assert.equal(user.banReason, "reason");
assert.equal(user.updatedAtMs, 100);
assert.equal(user.isDeleted, true);
});
});
describe("get .id", function () {
it("should return lowercase name", function () {
const user = new UserDetails("AdMiN");
assert.equal(user.id, "admin");
});
it("should reflect name changes dynamically", function () {
const user = new UserDetails("User");
user.name = "NEWNAME";
assert.equal(user.id, "newname");
});
});
describe(".toJSON()", function () {
it("should only include required fields when optional fields are not given", function () {
const user = new UserDetails("Test");
assert.deepEqual(user.toJSON(), { name: "Test" });
});
it("should include all optional fields when set", function () {
const stats = new PlayerStats({ join_count: 1 });
const user = new UserDetails(
"Test", new Set([1, 2]), new Set([10]),
true, true, true, "reason",
123, true,
new Map([[1, stats]])
);
assert.deepEqual(user.toJSON(), {
name: "Test",
roles: [1, 2],
instances: [10],
is_admin: true,
is_banned: true,
is_whitelisted: true,
ban_reason: "reason",
updated_at_ms: 123,
is_deleted: true,
instance_stats: [[1, stats.toJSON()]],
});
});
it("should omit optional fields when equal to defaults", function () {
const user = new UserDetails(
"Test", new Set(), new Set(),
false, false, false, "",
0, false,
new Map()
);
assert.deepEqual(user.toJSON(), { name: "Test" });
});
});
describe("static fromJSON()", function () {
it("should construct minimal object", function () {
const user = UserDetails.fromJSON({ name: "Test" });
assert.equal(user.name, "Test");
assert.deepEqual(user.roleIds, new Set());
assert.deepEqual(user.instances, new Set());
assert.deepEqual(user.isAdmin, false);
assert.equal(user.isBanned, false);
assert.equal(user.isWhitelisted, false);
assert.equal(user.banReason, "");
assert.equal(user.updatedAtMs, 0);
assert.equal(user.isDeleted, false);
assert.deepEqual(user.instanceStats, new Map());
});
it("should preserve optional fields exactly when provided", function () {
const stats = new PlayerStats({ join_count: 1 });
const user = UserDetails.fromJSON({
name: "Test",
roles: [1, 2],
instances: [10],
is_admin: true,
is_banned: true,
is_whitelisted: true,
ban_reason: "reason",
updated_at_ms: 123,
is_deleted: true,
instance_stats: [[1, stats.toJSON()]],
});
assert.equal(user.name, "Test");
assert.deepEqual(user.roleIds, new Set([1, 2]));
assert.deepEqual(user.instances, new Set([10]));
assert.deepEqual(user.isAdmin, true);
assert.equal(user.isBanned, true);
assert.equal(user.isWhitelisted, true);
assert.equal(user.banReason, "reason");
assert.equal(user.updatedAtMs, 123);
assert.equal(user.isDeleted, true);
assert.deepEqual(user.instanceStats, new Map([[1, stats]]));
});
it("should preserve roles exactly (including invalid ones)", function () {
const user = UserDetails.fromJSON({
name: "Test",
roles: [1, 4, 55],
});
assert.equal(user.roleIds.size, 3);
assert(user.roleIds.has(55));
});
it("should reconstruct instanceStats and calculate playerStats", function () {
const json = {
name: "Test",
instance_stats: [
[1, { join_count: 1, online_time_ms: 100 }],
[2, { join_count: 2, online_time_ms: 200 }],
],
};
const user = UserDetails.fromJSON(json);
assert.equal(user.instanceStats.size, 2);
assert.equal(user.playerStats.joinCount, 3);
assert.equal(user.playerStats.onlineTimeMs, 300);
});
});
describe(".recalculatePlayerStats()", function () {
it("should aggregate stats correctly across multiple instances", function () {
const user = UserDetails.fromJSON({
name: "test",
instance_stats: [
[1, {
join_count: 1,
online_time_ms: 60e3,
last_join_at_ms: new Date("2020-05-05T12:00Z").getTime(),
last_leave_at_ms: new Date("2020-05-05T12:01Z").getTime(),
last_leave_reason: "quit",
}],
[2, {
join_count: 1,
online_time_ms: 0,
first_join_at_ms: new Date("2020-05-05T12:00Z").getTime(),
last_join_at_ms: new Date("2020-05-05T12:02Z").getTime(),
}],
],
});
assert.equal(user.playerStats.joinCount, 2);
assert.equal(user.playerStats.onlineTimeMs, 60e3);
user.instanceStats.set(3, PlayerStats.fromJSON({
join_count: 2,
online_time_ms: 120e3,
first_join_at_ms: new Date("2020-05-05T11:00Z").getTime(),
last_join_at_ms: new Date("2020-05-05T13:00Z").getTime(),
last_leave_at_ms: new Date("2020-05-05T13:02Z").getTime(),
last_leave_reason: "afk",
}));
user.recalculatePlayerStats();
assert.equal(user.playerStats.joinCount, 4);
assert.equal(user.playerStats.onlineTimeMs, 180e3);
});
});
describe("static _calculatePlayerStats()", function () {
it("should return empty stats for empty map", function () {
const stats = UserDetails._calculatePlayerStats(new Map());
assert.equal(stats.joinCount, 0);
assert.equal(stats.onlineTimeMs, 0);
});
it("should pick earliest firstJoin and latest joins/leaves", function () {
const a = PlayerStats.fromJSON({
first_join_at_ms: new Date("2020-01-01").getTime(),
last_join_at_ms: new Date("2020-01-02").getTime(),
last_leave_at_ms: new Date("2020-01-02").getTime(),
last_leave_reason: "quit",
join_count: 1,
online_time_ms: 100,
});
const b = PlayerStats.fromJSON({
first_join_at_ms: new Date("2019-01-01").getTime(),
last_join_at_ms: new Date("2021-01-01").getTime(),
last_leave_at_ms: new Date("2021-01-02").getTime(),
last_leave_reason: "afk",
join_count: 2,
online_time_ms: 200,
});
const stats = UserDetails._calculatePlayerStats(
new Map([[1, a], [2, b]])
);
assert.equal(stats.joinCount, 3);
assert.equal(stats.onlineTimeMs, 300);
assert.deepEqual(stats.firstJoinAt, new Date("2019-01-01"));
assert.deepEqual(stats.lastJoinAt, new Date("2021-01-01"));
assert.deepEqual(stats.lastLeaveAt, new Date("2021-01-02"));
assert.equal(stats.lastLeaveReason, "afk");
});
});
});