@7365admin1/core 3.86.0 → 3.88.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/index.d.ts +360 -5
- package/dist/index.js +6598 -5526
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6908 -5847
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/demo-adapter.test.mjs +33 -0
- package/test/e2e/harness.mjs +17 -1
- package/test/e2e/robot-map-lift.e2e.test.mjs +297 -0
- package/test/e2e/robot-scope.e2e.test.mjs +70 -0
- package/test/robot-adapter.test.mjs +9 -0
- package/test/robot-fault.test.mjs +39 -0
- package/test/robot-map-lift.test.mjs +141 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7365admin1/core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.88.0",
|
|
5
5
|
"author": "7365admin1",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"module": "dist/index.mjs",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"build": "tsup src/index.ts --format cjs,esm --dts && shx cp -r src/public dist && shx cp -r src/utils/handlebars dist",
|
|
14
14
|
"release": "yarn run build && changeset publish",
|
|
15
15
|
"lint": "tsc",
|
|
16
|
-
"test": "tsup src/config.ts src/utils/building-map.util.ts src/utils/robot-cloud.util.ts src/utils/robot-adapter.util.ts src/utils/png-encoder.util.ts src/services/demo.adapter.ts src/utils/robot-tracking.util.ts src/utils/robot-apps.util.ts src/utils/robot-permission.util.ts src/utils/lift-contract.util.ts src/utils/anpr-revoke.util.ts src/utils/anpr-plate.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/utils/camera-playback.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/occurrence-book-create.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts src/utils/file-owner.util.ts src/utils/console-permission.util.ts src/utils/role-scope.util.ts src/utils/org-default-roles.util.ts src/utils/org-modules.util.ts src/utils/site-modules.util.ts src/utils/resident-app-modules.util.ts src/utils/resident-default-role.util.ts src/utils/invite-role-scope.util.ts src/models/personal-emergency-chain.model.ts src/utils/self-signup-files.util.ts src/models/person.model.ts src/utils/module-gate.util.ts src/utils/member-lookup.util.ts src/utils/notification-access.util.ts src/utils/module-access.util.ts src/services/module-gate.service.ts src/controllers/site.controller.ts src/controllers/organization.controller.ts src/controllers/member.controller.ts src/utils/org-owner-repair.util.ts src/utils/socket-session.util.ts src/utils/people-create-gate.util.ts src/utils/service-app-type.util.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
|
|
16
|
+
"test": "tsup src/config.ts src/utils/building-map.util.ts src/utils/robot-cloud.util.ts src/utils/robot-adapter.util.ts src/utils/png-encoder.util.ts src/services/demo.adapter.ts src/utils/robot-fault.util.ts src/utils/robot-tracking.util.ts src/utils/robot-apps.util.ts src/utils/robot-permission.util.ts src/utils/lift-contract.util.ts src/models/robot-building-map.model.ts src/utils/anpr-revoke.util.ts src/utils/anpr-plate.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/utils/camera-playback.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/occurrence-book-create.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts src/utils/file-owner.util.ts src/utils/console-permission.util.ts src/utils/role-scope.util.ts src/utils/org-default-roles.util.ts src/utils/org-modules.util.ts src/utils/site-modules.util.ts src/utils/resident-app-modules.util.ts src/utils/resident-default-role.util.ts src/utils/invite-role-scope.util.ts src/models/personal-emergency-chain.model.ts src/utils/self-signup-files.util.ts src/models/person.model.ts src/utils/module-gate.util.ts src/utils/member-lookup.util.ts src/utils/notification-access.util.ts src/utils/module-access.util.ts src/services/module-gate.service.ts src/controllers/site.controller.ts src/controllers/organization.controller.ts src/controllers/member.controller.ts src/utils/org-owner-repair.util.ts src/utils/socket-session.util.ts src/utils/people-create-gate.util.ts src/utils/service-app-type.util.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
|
|
17
17
|
"test:camera": "yarn test",
|
|
18
18
|
"test:e2e": "yarn build && node --test --test-concurrency=1 --test-timeout=600000 \"test/e2e/*.test.mjs\""
|
|
19
19
|
},
|
|
@@ -162,3 +162,36 @@ test("the encoder refuses an impossible image rather than emitting a broken one"
|
|
|
162
162
|
assert.throws(() => encodeGreyscalePng(new Uint8Array(4), 0, 4), /positive size/);
|
|
163
163
|
assert.throws(() => encodeGreyscalePng(new Uint8Array(2), 10, 10), /one byte per pixel/);
|
|
164
164
|
});
|
|
165
|
+
|
|
166
|
+
/* ------------------------------------------------ faults and spot cleaning */
|
|
167
|
+
|
|
168
|
+
import { demoFault, DEMO_FAULT_ROBOT } from "./.build/services/demo.adapter.mjs";
|
|
169
|
+
|
|
170
|
+
test("one demo robot faults two minutes in every thirty, keyed by when it started", async () => {
|
|
171
|
+
const windowStart = 30 * 60_000 * 1000;
|
|
172
|
+
const inside = await at(windowStart + 60_000).getRobot(DEMO_FAULT_ROBOT);
|
|
173
|
+
assert.equal(inside.mode, "MAINTENANCE");
|
|
174
|
+
assert.equal(inside.fault.code, "DEMO_BRUSH_JAM");
|
|
175
|
+
assert.equal(inside.fault.since, windowStart);
|
|
176
|
+
assert.match(inside.fault.message, /simulated/);
|
|
177
|
+
|
|
178
|
+
const after = await at(windowStart + 3 * 60_000).getRobot(DEMO_FAULT_ROBOT);
|
|
179
|
+
assert.equal(after.fault, null);
|
|
180
|
+
// Only the one robot, so a fleet screen is not a wall of red.
|
|
181
|
+
assert.equal(demoFault("DEMO-0001", windowStart), null);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("the demo brand supports spot cleaning, and parks the robot on the spot", async () => {
|
|
185
|
+
assert.equal(commandSupported(DEMO_CAPABILITIES, "spot"), true);
|
|
186
|
+
const t = 5 * 60_000; // outside any fault window, battery well above flat
|
|
187
|
+
const demo = at(t);
|
|
188
|
+
await demo.sendCommand("DEMO-0001", {
|
|
189
|
+
kind: "spot",
|
|
190
|
+
points: [{ x: 90, y: 50 }, { x: 110, y: 50 }, { x: 110, y: 70 }, { x: 90, y: 70 }],
|
|
191
|
+
});
|
|
192
|
+
const robot = await demo.getRobot("DEMO-0001");
|
|
193
|
+
assert.equal(robot.mode, "WORKING");
|
|
194
|
+
// Pixels to metres at 0.05 m/px, y flipped (180 px tall plan).
|
|
195
|
+
assert.ok(Math.abs(robot.position.x - 5) < 1e-9);
|
|
196
|
+
assert.ok(Math.abs(robot.position.y - (180 - 60) * 0.05) < 1e-9);
|
|
197
|
+
});
|
package/test/e2e/harness.mjs
CHANGED
|
@@ -874,10 +874,26 @@ function buildApp(nsu, core) {
|
|
|
874
874
|
// mirrors iservice365-API-core/src/routes/robot.route.ts
|
|
875
875
|
const robots = express.Router();
|
|
876
876
|
{
|
|
877
|
-
const { createRobot, getRobots } = core.useRobotController();
|
|
877
|
+
const { createRobot, getRobots, updateRobot, deleteRobot } = core.useRobotController();
|
|
878
878
|
|
|
879
879
|
robots.post("/", requireAuth, createRobot);
|
|
880
880
|
robots.get("/organization/:organization/site/:site", requireAuth, getRobots);
|
|
881
|
+
robots.patch("/:id([0-9a-fA-F]{24})", requireAuth, updateRobot);
|
|
882
|
+
robots.delete("/:id([0-9a-fA-F]{24})", requireAuth, deleteRobot);
|
|
883
|
+
|
|
884
|
+
const m = core.useRobotMapController();
|
|
885
|
+
const MAP = "/building-maps/:mapId([0-9a-fA-F]{24})";
|
|
886
|
+
const SESSION = "/lift-sessions/:sessionId([0-9a-fA-F]{24})";
|
|
887
|
+
robots.get("/building-maps", requireAuth, m.listMaps);
|
|
888
|
+
robots.post("/building-maps", requireAuth, m.createMap);
|
|
889
|
+
robots.get(MAP, requireAuth, m.getMap);
|
|
890
|
+
robots.patch(MAP, requireAuth, m.updateMap);
|
|
891
|
+
robots.delete(MAP, requireAuth, m.deleteMap);
|
|
892
|
+
robots.get(`${MAP}/lifts`, requireAuth, m.getLiftQueue);
|
|
893
|
+
robots.post(`${MAP}/lifts/:liftName/sessions`, requireAuth, m.requestLift);
|
|
894
|
+
robots.post(`${SESSION}/refresh`, requireAuth, m.refreshLiftSession);
|
|
895
|
+
robots.post(`${SESSION}/release`, requireAuth, m.releaseLiftSession);
|
|
896
|
+
robots.get("/:id([0-9a-fA-F]{24})/lift-session", requireAuth, m.getRobotLiftSession);
|
|
881
897
|
}
|
|
882
898
|
|
|
883
899
|
// mirrors iservice365-API-core/src/routes/attendance-settings.route.ts
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// End-to-end proof for building maps and the lift queue.
|
|
2
|
+
//
|
|
3
|
+
// 1. A map belongs to its robot's estate. The estate is copied from the STORED
|
|
4
|
+
// robot and every later call checks the STORED map - a caller elsewhere gets
|
|
5
|
+
// 404 by id, and cannot send a siteId to aim a map somewhere else.
|
|
6
|
+
// 2. A stale editor cannot overwrite a newer save (version check, 409).
|
|
7
|
+
// 3. The lift queue is the collection: one active holder per lift (enforced by a
|
|
8
|
+
// unique partial index, so racing promotions cannot both win), one open
|
|
9
|
+
// request per robot, timeouts that hand the lift on, release that hands it on.
|
|
10
|
+
// 4. Every write leaves a console-audit row.
|
|
11
|
+
//
|
|
12
|
+
// In-process MongoDB replica set, loopback Redis and mail sink only. No staging
|
|
13
|
+
// or production system, robot or lift is touched.
|
|
14
|
+
//
|
|
15
|
+
// Run with: yarn test:e2e
|
|
16
|
+
|
|
17
|
+
import { after, before, describe, it } from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { ObjectId } from "mongodb";
|
|
20
|
+
|
|
21
|
+
import { startHarness } from "./harness.mjs";
|
|
22
|
+
|
|
23
|
+
const PASSWORD = "Robot-Passw0rd!";
|
|
24
|
+
const OUTSIDER = "ml-outsider@e2e.example.com"; // org A, site A only
|
|
25
|
+
const MANAGER = "ml-manager@e2e.example.com"; // org B
|
|
26
|
+
|
|
27
|
+
describe("building maps and the lift queue", { concurrency: 1 }, () => {
|
|
28
|
+
let h;
|
|
29
|
+
const id = {};
|
|
30
|
+
let outsiderSid;
|
|
31
|
+
let managerSid;
|
|
32
|
+
let mapId;
|
|
33
|
+
|
|
34
|
+
before(async () => {
|
|
35
|
+
h = await startHarness();
|
|
36
|
+
Object.assign(id, await seed(h));
|
|
37
|
+
await h.core.useRobotBuildingMapRepo().createIndex();
|
|
38
|
+
await h.core.useRobotLiftSessionRepo().createIndex();
|
|
39
|
+
outsiderSid = await h.login(OUTSIDER, PASSWORD);
|
|
40
|
+
managerSid = await h.login(MANAGER, PASSWORD);
|
|
41
|
+
}, { timeout: 300000 });
|
|
42
|
+
|
|
43
|
+
after(async () => {
|
|
44
|
+
if (h) await h.stop();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const body = {
|
|
48
|
+
name: "Tower B",
|
|
49
|
+
levels: [{ floorNo: 1, elevationM: 0 }, { floorNo: 2, elevationM: 4.2 }, { floorNo: 3 }],
|
|
50
|
+
doors: [{ name: "Lobby", floorNo: 1, start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }],
|
|
51
|
+
lifts: [{ name: "Lift A", x: 3, y: 3, floors: [1, 2, 3] }],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
it("unauthenticated is 401", async () => {
|
|
55
|
+
const res = await h.api(`/robots/building-maps?robot=${id.demo1}`);
|
|
56
|
+
assert.equal(res.status, 401);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("the estate cannot be sent in the body", async () => {
|
|
60
|
+
const res = await h.api("/robots/building-maps", {
|
|
61
|
+
method: "POST",
|
|
62
|
+
sid: managerSid,
|
|
63
|
+
body: { ...body, robotId: String(id.demo1), siteId: String(id.siteA) },
|
|
64
|
+
});
|
|
65
|
+
assert.equal(res.status, 400);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("a caller at another estate cannot create a map on this robot", async () => {
|
|
69
|
+
const res = await h.api("/robots/building-maps", {
|
|
70
|
+
method: "POST",
|
|
71
|
+
sid: outsiderSid,
|
|
72
|
+
body: { ...body, robotId: String(id.demo1) },
|
|
73
|
+
});
|
|
74
|
+
assert.equal(res.status, 404);
|
|
75
|
+
assert.equal(await h.db.collection("robot-building-maps").countDocuments(), 0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("the estate's own manager saves a map; the estate is the robot's; audited", async () => {
|
|
79
|
+
const res = await h.api("/robots/building-maps", {
|
|
80
|
+
method: "POST",
|
|
81
|
+
sid: managerSid,
|
|
82
|
+
body: { ...body, robotId: String(id.demo1) },
|
|
83
|
+
});
|
|
84
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
85
|
+
mapId = res.body.id;
|
|
86
|
+
const stored = await h.db.collection("robot-building-maps").findOne({ _id: new ObjectId(mapId) });
|
|
87
|
+
assert.equal(String(stored.siteId), String(id.siteB));
|
|
88
|
+
assert.equal(String(stored.orgId), String(id.orgB));
|
|
89
|
+
assert.equal(stored.version, 1);
|
|
90
|
+
assert.ok(await findAudit(h, "robot.map-saved"), "audit row written");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("list is paginated and scoped; another estate gets 404 by id", async () => {
|
|
94
|
+
const list = await h.api(`/robots/building-maps?robot=${id.demo1}&page=1&limit=5`, { sid: managerSid });
|
|
95
|
+
assert.equal(list.status, 200);
|
|
96
|
+
assert.equal(list.body.items.length, 1);
|
|
97
|
+
assert.equal(list.body.items[0].liftCount, 1);
|
|
98
|
+
|
|
99
|
+
assert.equal((await h.api(`/robots/building-maps?robot=${id.demo1}`, { sid: outsiderSid })).status, 404);
|
|
100
|
+
assert.equal((await h.api(`/robots/building-maps/${mapId}`, { sid: outsiderSid })).status, 404);
|
|
101
|
+
const patch = await h.api(`/robots/building-maps/${mapId}`, {
|
|
102
|
+
method: "PATCH",
|
|
103
|
+
sid: outsiderSid,
|
|
104
|
+
body: { ...body, version: 1 },
|
|
105
|
+
});
|
|
106
|
+
assert.equal(patch.status, 404);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("a save against an old version is refused with 409; the current one succeeds", async () => {
|
|
110
|
+
const ok = await h.api(`/robots/building-maps/${mapId}`, {
|
|
111
|
+
method: "PATCH",
|
|
112
|
+
sid: managerSid,
|
|
113
|
+
body: { ...body, name: "Tower B (edited)", version: 1 },
|
|
114
|
+
});
|
|
115
|
+
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
|
116
|
+
assert.equal(ok.body.version, 2);
|
|
117
|
+
|
|
118
|
+
const stale = await h.api(`/robots/building-maps/${mapId}`, {
|
|
119
|
+
method: "PATCH",
|
|
120
|
+
sid: managerSid,
|
|
121
|
+
body: { ...body, version: 1 },
|
|
122
|
+
});
|
|
123
|
+
assert.equal(stale.status, 409);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const ask = (robot, over = {}) =>
|
|
127
|
+
h.api(`/robots/building-maps/${mapId}/lifts/${encodeURIComponent("Lift A")}/sessions`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
sid: managerSid,
|
|
130
|
+
body: { robotId: String(robot), fromFloor: 1, toFloor: 3, ...over },
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("a non-demo robot cannot ride the simulated lift", async () => {
|
|
134
|
+
const res = await ask(id.ounitech);
|
|
135
|
+
assert.equal(res.status, 400);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("a floor the lift does not serve is refused", async () => {
|
|
139
|
+
assert.equal((await ask(id.demo1, { toFloor: 7 })).status, 400);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("first robot holds the lift, second queues, a robot cannot ask twice", async () => {
|
|
143
|
+
const first = await ask(id.demo1);
|
|
144
|
+
assert.equal(first.status, 201, JSON.stringify(first.body));
|
|
145
|
+
assert.equal(first.body.session.status, "active");
|
|
146
|
+
|
|
147
|
+
const second = await ask(id.demo2);
|
|
148
|
+
assert.equal(second.status, 201);
|
|
149
|
+
assert.equal(second.body.session.status, "queued");
|
|
150
|
+
|
|
151
|
+
assert.equal((await ask(id.demo1)).status, 409);
|
|
152
|
+
|
|
153
|
+
const queue = await h.api(`/robots/building-maps/${mapId}/lifts`, { sid: managerSid });
|
|
154
|
+
assert.equal(queue.status, 200);
|
|
155
|
+
const lift = queue.body.items[0];
|
|
156
|
+
assert.equal(lift.simulated, true);
|
|
157
|
+
assert.equal(lift.active.robotName, "Demo 1");
|
|
158
|
+
assert.equal(lift.queue.length, 1);
|
|
159
|
+
assert.equal(lift.state.sessionId, String(lift.active._id));
|
|
160
|
+
|
|
161
|
+
const mine = await h.api(`/robots/${id.demo2}/lift-session`, { sid: managerSid });
|
|
162
|
+
assert.equal(mine.body.session.position, 1);
|
|
163
|
+
|
|
164
|
+
assert.equal((await h.api(`/robots/building-maps/${mapId}/lifts`, { sid: outsiderSid })).status, 404);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("releasing hands the lift to the next robot; release is audited", async () => {
|
|
168
|
+
const holder = await h.db.collection("robot-lift-sessions").findOne({ status: "active" });
|
|
169
|
+
assert.equal((await h.api(`/robots/lift-sessions/${holder._id}/release`, { method: "POST", sid: outsiderSid })).status, 404);
|
|
170
|
+
|
|
171
|
+
const rel = await h.api(`/robots/lift-sessions/${holder._id}/release`, { method: "POST", sid: managerSid });
|
|
172
|
+
assert.equal(rel.status, 200);
|
|
173
|
+
const now = await h.db.collection("robot-lift-sessions").findOne({ status: "active" });
|
|
174
|
+
assert.equal(String(now.robotId), String(id.demo2));
|
|
175
|
+
assert.equal((await h.api(`/robots/lift-sessions/${holder._id}/release`, { method: "POST", sid: managerSid })).status, 409);
|
|
176
|
+
assert.ok(await findAudit(h, "robot.lift-released"));
|
|
177
|
+
assert.ok(await findAudit(h, "robot.lift-requested"));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("a holder that stops refreshing loses the lift when its lease runs out", async () => {
|
|
181
|
+
const holder = await h.db.collection("robot-lift-sessions").findOne({ status: "active" });
|
|
182
|
+
assert.equal((await h.api(`/robots/lift-sessions/${holder._id}/refresh`, { method: "POST", sid: managerSid })).status, 200);
|
|
183
|
+
|
|
184
|
+
await ask(id.demo1); // queues behind demo2
|
|
185
|
+
await h.db.collection("robot-lift-sessions").updateOne(
|
|
186
|
+
{ _id: holder._id },
|
|
187
|
+
{ $set: { expiresAt: new Date(Date.now() - 1000) } },
|
|
188
|
+
);
|
|
189
|
+
const queue = await h.api(`/robots/building-maps/${mapId}/lifts`, { sid: managerSid });
|
|
190
|
+
assert.equal(queue.body.items[0].active.robotName, "Demo 1");
|
|
191
|
+
const expired = await h.db.collection("robot-lift-sessions").findOne({ _id: holder._id });
|
|
192
|
+
assert.equal(expired.status, "expired");
|
|
193
|
+
assert.equal((await h.api(`/robots/lift-sessions/${holder._id}/refresh`, { method: "POST", sid: managerSid })).status, 409);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("racing promotions on many instances never produce two holders", async () => {
|
|
197
|
+
const repo = h.core.useRobotLiftSessionRepo();
|
|
198
|
+
const coll = h.db.collection("robot-lift-sessions");
|
|
199
|
+
await coll.updateMany({ open: true }, { $set: { status: "cancelled", open: false } });
|
|
200
|
+
const key = `race:${new ObjectId()}`;
|
|
201
|
+
const t = Date.now();
|
|
202
|
+
await coll.insertMany(
|
|
203
|
+
[0, 1, 2, 3, 4].map((i) => ({
|
|
204
|
+
liftKey: key,
|
|
205
|
+
liftName: "race",
|
|
206
|
+
mapId: new ObjectId(),
|
|
207
|
+
robotId: new ObjectId(),
|
|
208
|
+
status: "queued",
|
|
209
|
+
open: true,
|
|
210
|
+
requestedAt: new Date(t + i),
|
|
211
|
+
expiresAt: new Date(t + 600000),
|
|
212
|
+
})),
|
|
213
|
+
);
|
|
214
|
+
await Promise.all(Array.from({ length: 20 }, () => repo.advance(key)));
|
|
215
|
+
assert.equal(await coll.countDocuments({ liftKey: key, status: "active" }), 1);
|
|
216
|
+
const active = await coll.findOne({ liftKey: key, status: "active" });
|
|
217
|
+
assert.equal(active.requestedAt.getTime(), t, "the oldest request wins");
|
|
218
|
+
|
|
219
|
+
// And the database itself refuses a second holder, whatever the code does.
|
|
220
|
+
await assert.rejects(
|
|
221
|
+
coll.updateOne({ liftKey: key, status: "queued" }, { $set: { status: "active" } }),
|
|
222
|
+
(e) => e.code === 11000,
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("removing the map closes its lift queue and the map 404s", async () => {
|
|
227
|
+
await ask(id.demo2);
|
|
228
|
+
const del = await h.api(`/robots/building-maps/${mapId}`, { method: "DELETE", sid: managerSid });
|
|
229
|
+
assert.equal(del.status, 200);
|
|
230
|
+
assert.equal(await h.db.collection("robot-lift-sessions").countDocuments({ mapId: new ObjectId(mapId), open: true }), 0);
|
|
231
|
+
assert.equal((await h.api(`/robots/building-maps/${mapId}`, { sid: managerSid })).status, 404);
|
|
232
|
+
assert.ok(await findAudit(h, "robot.map-removed"));
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
async function findAudit(h, action) {
|
|
237
|
+
const names = (await h.db.listCollections().toArray()).map((c) => c.name);
|
|
238
|
+
for (const name of names.filter((n) => /audit/i.test(n))) {
|
|
239
|
+
const row = await h.db.collection(name).findOne({ action });
|
|
240
|
+
if (row) return row;
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function seed(h) {
|
|
246
|
+
const now = new Date().toISOString();
|
|
247
|
+
const orgA = new ObjectId();
|
|
248
|
+
const orgB = new ObjectId();
|
|
249
|
+
const siteA = new ObjectId();
|
|
250
|
+
const siteB = new ObjectId();
|
|
251
|
+
const roleA = new ObjectId();
|
|
252
|
+
const roleB = new ObjectId();
|
|
253
|
+
|
|
254
|
+
await h.db.collection("organizations").insertMany([
|
|
255
|
+
{ _id: orgA, name: "ML Org A", email: "mla@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
|
|
256
|
+
{ _id: orgB, name: "ML Org B", email: "mlb@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
|
|
257
|
+
]);
|
|
258
|
+
await h.db.collection("sites").insertMany([
|
|
259
|
+
{ _id: siteA, name: "ML Site A", org: orgA, orgId: orgA, status: "active" },
|
|
260
|
+
{ _id: siteB, name: "ML Site B", org: orgB, orgId: orgB, status: "active" },
|
|
261
|
+
]);
|
|
262
|
+
await h.db.collection("roles").insertMany([
|
|
263
|
+
{ _id: roleA, name: "Manager A", org: orgA, permissions: ["*"], status: "active" },
|
|
264
|
+
{ _id: roleB, name: "Manager B", org: orgB, permissions: ["*"], status: "active" },
|
|
265
|
+
]);
|
|
266
|
+
const emails = [OUTSIDER, MANAGER];
|
|
267
|
+
const users = await h.db.collection("users").insertMany(
|
|
268
|
+
emails.map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
|
|
269
|
+
);
|
|
270
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
271
|
+
await h.db.collection("users").updateMany({ email: { $in: emails } }, { $set: { password: hashed } });
|
|
272
|
+
const [outsider, manager] = [0, 1].map((i) => users.insertedIds[i]);
|
|
273
|
+
await h.db.collection("members").insertMany([
|
|
274
|
+
{ user: outsider, org: orgA, siteId: siteA, type: "organization", role: roleA, status: "active" },
|
|
275
|
+
{ user: manager, org: orgB, type: "organization", role: roleB, status: "active" },
|
|
276
|
+
]);
|
|
277
|
+
|
|
278
|
+
const robot = (name, brand) => ({
|
|
279
|
+
name,
|
|
280
|
+
brand,
|
|
281
|
+
type: "cleaning",
|
|
282
|
+
appKey: "e2e-fake-key",
|
|
283
|
+
appToken: "e2e-fake-token",
|
|
284
|
+
createdBy: manager,
|
|
285
|
+
createdByName: "ml-manager",
|
|
286
|
+
metadata: { orgId: orgB, siteId: siteB, administratorId: "" },
|
|
287
|
+
status: "",
|
|
288
|
+
deletedAt: "",
|
|
289
|
+
createdAt: new Date(),
|
|
290
|
+
});
|
|
291
|
+
const r = await h.db.collection("robots").insertMany([
|
|
292
|
+
robot("Demo 1", "demo"),
|
|
293
|
+
robot("Demo 2", "demo"),
|
|
294
|
+
robot("Real one", "ounitech"),
|
|
295
|
+
]);
|
|
296
|
+
return { orgA, orgB, siteA, siteB, demo1: r.insertedIds[0], demo2: r.insertedIds[1], ounitech: r.insertedIds[2] };
|
|
297
|
+
}
|
|
@@ -290,6 +290,73 @@ describe("robots and their credentials are scoped to their estate", { concurrenc
|
|
|
290
290
|
assert.deepEqual(stranger.body, insider.body);
|
|
291
291
|
});
|
|
292
292
|
|
|
293
|
+
// ---- edit and remove: the estate comes off the STORED robot ---------------
|
|
294
|
+
|
|
295
|
+
const seeded = async (name) => (await byName(name))._id;
|
|
296
|
+
const audits = async (action, target) =>
|
|
297
|
+
await h.db.collection("console-audit").find({ action, target: String(target) }).toArray();
|
|
298
|
+
|
|
299
|
+
record("14. an outsider cannot EDIT or REMOVE another estate's robot by quoting its id", async () => {
|
|
300
|
+
const robotB = await seeded("rb-seeded-b");
|
|
301
|
+
const edit = await h.api(`/robots/${robotB}`, {
|
|
302
|
+
method: "PATCH", sid: outsiderSid, body: { name: "rb-hijacked" },
|
|
303
|
+
});
|
|
304
|
+
const remove = await h.api(`/robots/${robotB}`, { method: "DELETE", sid: outsiderSid });
|
|
305
|
+
|
|
306
|
+
assert.equal(edit.status, 404);
|
|
307
|
+
assert.equal(remove.status, 404);
|
|
308
|
+
const stored = await h.db.collection(COLL).findOne({ _id: robotB });
|
|
309
|
+
assert.equal(stored.name, "rb-seeded-b");
|
|
310
|
+
assert.ok(!stored.deletedAt);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
record("15. the estate's own manager edits a robot, and it is audited without credentials", async () => {
|
|
314
|
+
const robotB = await seeded("rb-seeded-b");
|
|
315
|
+
const res = await h.api(`/robots/${robotB}`, {
|
|
316
|
+
method: "PATCH", sid: managerSid, body: { name: "rb-renamed-b", robotSn: "SN-1" },
|
|
317
|
+
});
|
|
318
|
+
assert.equal(res.status, 200);
|
|
319
|
+
|
|
320
|
+
const stored = await h.db.collection(COLL).findOne({ _id: robotB });
|
|
321
|
+
assert.equal(stored.name, "rb-renamed-b");
|
|
322
|
+
assert.equal(stored.robotSn, "SN-1");
|
|
323
|
+
|
|
324
|
+
const [row] = await audits("robot.updated", robotB);
|
|
325
|
+
assert.equal(row?.after?.name, "rb-renamed-b");
|
|
326
|
+
assert.equal(row?.before?.name, "rb-seeded-b");
|
|
327
|
+
assert.equal(JSON.stringify(row).includes("e2e-fake"), false);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
record("16. an empty edit is a 400", async () => {
|
|
331
|
+
const robotB = await seeded("rb-renamed-b");
|
|
332
|
+
const res = await h.api(`/robots/${robotB}`, { method: "PATCH", sid: managerSid, body: {} });
|
|
333
|
+
assert.equal(res.status, 400);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
record("17. a removed robot stops listing and answers as not found, and the row stays", async () => {
|
|
337
|
+
const robotB = await seeded("rb-renamed-b");
|
|
338
|
+
const remove = await h.api(`/robots/${robotB}`, { method: "DELETE", sid: managerSid });
|
|
339
|
+
assert.equal(remove.status, 200);
|
|
340
|
+
|
|
341
|
+
const list = await h.api(listUrl(id.orgB, id.siteB), { sid: managerSid });
|
|
342
|
+
assert.equal((list.body?.items ?? []).some((r) => r.name === "rb-renamed-b"), false);
|
|
343
|
+
|
|
344
|
+
const again = await h.api(`/robots/${robotB}`, { method: "DELETE", sid: managerSid });
|
|
345
|
+
assert.equal(again.status, 404);
|
|
346
|
+
|
|
347
|
+
const stored = await h.db.collection(COLL).findOne({ _id: robotB });
|
|
348
|
+
assert.ok(stored.deletedAt, "soft delete keeps the row");
|
|
349
|
+
assert.equal((await audits("robot.removed", robotB)).length, 1);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
record("18. creating a robot writes an audit row, without credentials", async () => {
|
|
353
|
+
const robot = await byName("rb-allowed");
|
|
354
|
+
const [row] = await audits("robot.added", robot._id);
|
|
355
|
+
assert.equal(row?.after?.name, "rb-allowed");
|
|
356
|
+
assert.equal(String(row?.org), String(id.orgB));
|
|
357
|
+
assert.equal(JSON.stringify(row).includes("e2e-fake"), false);
|
|
358
|
+
});
|
|
359
|
+
|
|
293
360
|
record("13. no session is still a 401 on both routes", async () => {
|
|
294
361
|
const list = await h.api(listUrl(id.orgB, id.siteB));
|
|
295
362
|
const create = await h.api("/robots", {
|
|
@@ -299,6 +366,9 @@ describe("robots and their credentials are scoped to their estate", { concurrenc
|
|
|
299
366
|
|
|
300
367
|
assert.equal(list.status, 401);
|
|
301
368
|
assert.equal(create.status, 401);
|
|
369
|
+
const robotA = await seeded("rb-seeded-a");
|
|
370
|
+
assert.equal((await h.api(`/robots/${robotA}`, { method: "PATCH", body: { name: "x" } })).status, 401);
|
|
371
|
+
assert.equal((await h.api(`/robots/${robotA}`, { method: "DELETE" })).status, 401);
|
|
302
372
|
});
|
|
303
373
|
});
|
|
304
374
|
|
|
@@ -129,6 +129,15 @@ test("each command maps to the capability it actually needs", () => {
|
|
|
129
129
|
assert.equal(commandSupported(caps("navigateToPoint"), "navigate"), true);
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
test("a spot clean needs spotClean, not just startProcess - a click needs a verified frame", () => {
|
|
133
|
+
assert.equal(commandSupported(caps("startProcess"), "spot"), false);
|
|
134
|
+
assert.equal(commandSupported(caps("spotClean"), "spot"), true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("spotClean is ours, not Open-RMF's, and moves no brand's tier", () => {
|
|
138
|
+
assert.equal(robotTier(caps("readLocation", "spotClean")), robotTier(caps("readLocation")));
|
|
139
|
+
});
|
|
140
|
+
|
|
132
141
|
test("an unknown command kind is refused, not defaulted", () => {
|
|
133
142
|
for (const kind of ["selfDestruct", "", null, undefined, "constructor"]) {
|
|
134
143
|
assert.equal(commandSupported(ALL, kind), false, String(kind));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
claimFaultOnce,
|
|
6
|
+
robotFaultKey,
|
|
7
|
+
robotFaultNotifyEnabled,
|
|
8
|
+
robotFaultText,
|
|
9
|
+
} from "./.build/utils/robot-fault.util.mjs";
|
|
10
|
+
|
|
11
|
+
test("the same fault occurrence is claimed once per process", async () => {
|
|
12
|
+
const local = new Set();
|
|
13
|
+
const key = robotFaultKey("r1", { code: "JAM", since: 10 });
|
|
14
|
+
assert.equal(await claimFaultOnce(key, { local }), true);
|
|
15
|
+
assert.equal(await claimFaultOnce(key, { local }), false);
|
|
16
|
+
// A second occurrence of the same fault notifies again.
|
|
17
|
+
assert.equal(await claimFaultOnce(robotFaultKey("r1", { code: "JAM", since: 20 }), { local }), true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("another instance holding the claim suppresses it; an unreachable store does not", async () => {
|
|
21
|
+
assert.equal(await claimFaultOnce("k1", { local: new Set(), claimShared: async () => false }), false);
|
|
22
|
+
assert.equal(await claimFaultOnce("k2", { local: new Set(), claimShared: async () => null }), true);
|
|
23
|
+
assert.equal(
|
|
24
|
+
await claimFaultOnce("k3", { local: new Set(), claimShared: async () => { throw new Error("down"); } }),
|
|
25
|
+
true,
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("ROBOT_FAULT_NOTIFY=off silences it; anything else leaves it on", () => {
|
|
30
|
+
assert.equal(robotFaultNotifyEnabled({}), true);
|
|
31
|
+
assert.equal(robotFaultNotifyEnabled({ ROBOT_FAULT_NOTIFY: " OFF " }), false);
|
|
32
|
+
assert.equal(robotFaultNotifyEnabled({ ROBOT_FAULT_NOTIFY: "on" }), true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("the text names the robot and the fault", () => {
|
|
36
|
+
const { body } = robotFaultText("Cleaner 02", { code: "JAM", message: "Side brush jammed" });
|
|
37
|
+
assert.match(body, /Cleaner 02/);
|
|
38
|
+
assert.match(body, /Side brush jammed/);
|
|
39
|
+
});
|