@7365admin1/core 3.80.0 → 3.82.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 +67 -0
- package/dist/index.d.ts +953 -4
- package/dist/index.js +5799 -4728
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5309 -4288
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/e2e/robot-scope.e2e.test.mjs +57 -33
- package/test/lift-contract.test.mjs +271 -0
- package/test/robot-apps.test.mjs +154 -0
- package/test/robot-cloud.test.mjs +222 -0
- package/test/robot-command.test.mjs +140 -0
- package/test/robot-permission.test.mjs +133 -0
- package/test/robot-tracking.test.mjs +231 -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.82.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/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/robot-cloud.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/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
|
},
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
// 1. THE LIST HANDS OUT CREDENTIALS. `robot.repo.ts` projects `appKey` and
|
|
13
13
|
// `appToken` into the response. Any signed-in account on the platform could
|
|
14
14
|
// name any organisation and site in the URL and read that estate's robot
|
|
15
|
-
// credentials.
|
|
16
|
-
//
|
|
15
|
+
// credentials. Cases 1, 2 and 5 show the estate is now out of reach, and case
|
|
16
|
+
// 0 shows the credentials are no longer in the payload AT ALL - not even for a
|
|
17
|
+
// caller standing at the robot's own estate.
|
|
17
18
|
//
|
|
18
19
|
// 2. THE WRITE COULD BE AIMED ANYWHERE. `metadata.siteId` came off the body, so a
|
|
19
20
|
// robot could be aimed at another client's estate (case 3), and it is
|
|
@@ -24,18 +25,17 @@
|
|
|
24
25
|
// the service looked the display name up from it. It now comes from the
|
|
25
26
|
// session.
|
|
26
27
|
//
|
|
27
|
-
// A STRAIGHT WORD ABOUT THE WRITE PATH
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// `
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
28
|
+
// A STRAIGHT WORD ABOUT THE WRITE PATH: `POST /api/robots` never worked. The
|
|
29
|
+
// service overwrote `value.createdBy` with `createdBy._id`, an `ObjectId`, and
|
|
30
|
+
// `MRobot` then re-validated against `robotSchema`, where `createdBy` is
|
|
31
|
+
// `Joi.string()`. Every create ended in `400 "createdBy" must be a string` and
|
|
32
|
+
// no robot was ever stored, by anybody, through either app. It is fixed here,
|
|
33
|
+
// because the cleaning robot integration cannot begin without the ability to
|
|
34
|
+
// register a robot.
|
|
34
35
|
//
|
|
35
|
-
// So
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// 3 and 6 are that pair. The spec does not pretend a robot is ever stored.
|
|
36
|
+
// So the write is now proved BOTH ways: an unauthorised caller is stopped by the
|
|
37
|
+
// guard (404, before the service), and an authorised one is stored (201). Cases
|
|
38
|
+
// 3 and 6 are that pair.
|
|
39
39
|
//
|
|
40
40
|
// The fix is `requireSiteReach`, the rule `/api/attendances`, `/api/site-cameras`
|
|
41
41
|
// and `/api/documents` already use, so the contracted agency still reaches its
|
|
@@ -66,6 +66,10 @@ const COLL = "robots";
|
|
|
66
66
|
|
|
67
67
|
// What an authorised create fails with today: the pre-existing model defect,
|
|
68
68
|
// reached only AFTER the guard has let the caller through.
|
|
69
|
+
// The refusal every create used to end in, kept so case 3 can assert the guard
|
|
70
|
+
// stopped the request BEFORE the model rather than at it. Creates now succeed:
|
|
71
|
+
// the service handed `MRobot` an ObjectId where the schema wanted a string, so
|
|
72
|
+
// no robot could ever be registered through either app.
|
|
69
73
|
const MODEL_REFUSAL = '"createdBy" must be a string';
|
|
70
74
|
|
|
71
75
|
describe("robots and their credentials are scoped to their estate", { concurrency: 1 }, () => {
|
|
@@ -127,20 +131,30 @@ describe("robots and their credentials are scoped to their estate", { concurrenc
|
|
|
127
131
|
assert.equal(list.status, 200);
|
|
128
132
|
assert.equal(list.body?.items?.length, 1);
|
|
129
133
|
|
|
130
|
-
//
|
|
131
|
-
// robot's
|
|
134
|
+
// The projection no longer carries the robot's credentials AT ALL - not even
|
|
135
|
+
// to a caller standing at the robot's own estate. The tenancy guard decided
|
|
136
|
+
// WHO could read them; this decides that nobody reads them over HTTP. The
|
|
137
|
+
// connector reads them server-side.
|
|
132
138
|
const row = list.body.items[0];
|
|
133
|
-
assert.equal("appKey" in row,
|
|
134
|
-
assert.equal("appToken" in row,
|
|
135
|
-
|
|
136
|
-
|
|
139
|
+
assert.equal("appKey" in row, false);
|
|
140
|
+
assert.equal("appToken" in row, false);
|
|
141
|
+
// and the row is still a usable listing.
|
|
142
|
+
assert.equal(typeof row.name, "string");
|
|
143
|
+
|
|
144
|
+
// And the write route answers - and now SUCCEEDS. It never did before: the
|
|
145
|
+
// service handed `MRobot` an ObjectId where the schema wanted a string, so
|
|
146
|
+
// every create ended in `400 "createdBy" must be a string`.
|
|
137
147
|
const create = await h.api("/robots", {
|
|
138
148
|
method: "POST",
|
|
139
149
|
sid: outsiderSid,
|
|
140
150
|
body: newRobot(id.siteA, id.orgA, "rb-baseline"),
|
|
141
151
|
});
|
|
142
|
-
assert.equal(create.status,
|
|
143
|
-
|
|
152
|
+
assert.equal(create.status, 201);
|
|
153
|
+
|
|
154
|
+
// Stored against the SESSION's account, and defaulted to a cleaning robot.
|
|
155
|
+
const stored = await byName("rb-baseline");
|
|
156
|
+
assert.equal(String(stored.metadata.siteId), String(id.siteA));
|
|
157
|
+
assert.equal(stored.type, "cleaning");
|
|
144
158
|
});
|
|
145
159
|
|
|
146
160
|
// ---- refusals -----------------------------------------------------------
|
|
@@ -167,8 +181,9 @@ describe("robots and their credentials are scoped to their estate", { concurrenc
|
|
|
167
181
|
body: newRobot(id.siteB, id.orgB, "rb-planted"),
|
|
168
182
|
});
|
|
169
183
|
|
|
170
|
-
// 404 from the guard
|
|
171
|
-
//
|
|
184
|
+
// 404 from the guard - the request never reached the service, so nothing was
|
|
185
|
+
// stored. Case 6 is the same request from someone who may make it, and it
|
|
186
|
+
// now succeeds, which is what makes this refusal meaningful.
|
|
172
187
|
assert.equal(res.status, 404);
|
|
173
188
|
assert.notEqual(res.body?.message, MODEL_REFUSAL);
|
|
174
189
|
assert.equal(await countAtSite(id.siteB), before);
|
|
@@ -195,21 +210,28 @@ describe("robots and their credentials are scoped to their estate", { concurrenc
|
|
|
195
210
|
|
|
196
211
|
// ---- still works: the people who are supposed to reach it ---------------
|
|
197
212
|
|
|
198
|
-
record("6. an authorised create
|
|
213
|
+
record("6. an authorised create gets past the guard AND is stored", async () => {
|
|
199
214
|
const res = await h.api("/robots", {
|
|
200
215
|
method: "POST",
|
|
201
216
|
sid: managerSid,
|
|
202
217
|
body: newRobot(id.siteB, id.orgB, "rb-allowed"),
|
|
203
218
|
});
|
|
204
219
|
|
|
205
|
-
assert.equal(res.status,
|
|
206
|
-
|
|
220
|
+
assert.equal(res.status, 201);
|
|
221
|
+
const stored = await byName("rb-allowed");
|
|
222
|
+
assert.equal(String(stored.metadata.siteId), String(id.siteB));
|
|
207
223
|
});
|
|
208
224
|
|
|
209
225
|
record("7. the site B client still LISTS site B", async () => {
|
|
210
226
|
const res = await h.api(listUrl(id.orgB, id.siteB), { sid: managerSid });
|
|
211
227
|
assert.equal(res.status, 200);
|
|
212
|
-
|
|
228
|
+
|
|
229
|
+
// A count, not an exact one: case 6 now genuinely stores a robot at site B,
|
|
230
|
+
// so pinning this to 1 would only be asserting the order the cases run in.
|
|
231
|
+
// What matters is that site B's own robots are here and site A's are not.
|
|
232
|
+
const names = (res.body?.items ?? []).map((r) => r.name);
|
|
233
|
+
assert.ok(names.length >= 1);
|
|
234
|
+
assert.equal(names.includes("rb-baseline"), false, "site A's robot");
|
|
213
235
|
});
|
|
214
236
|
|
|
215
237
|
record("8. the contracted agency still LISTS its client's site (customer.sites branch)", async () => {
|
|
@@ -217,18 +239,21 @@ describe("robots and their credentials are scoped to their estate", { concurrenc
|
|
|
217
239
|
// ACTIVE `customer.sites` engagement.
|
|
218
240
|
const res = await h.api(listUrl(id.orgB, id.siteB), { sid: cleanerSid });
|
|
219
241
|
assert.equal(res.status, 200);
|
|
220
|
-
|
|
242
|
+
|
|
243
|
+
const names = (res.body?.items ?? []).map((r) => r.name);
|
|
244
|
+
assert.ok(names.length >= 1);
|
|
245
|
+
assert.equal(names.includes("rb-baseline"), false, "site A's robot");
|
|
221
246
|
});
|
|
222
247
|
|
|
223
|
-
record("9. the contracted agency's create
|
|
248
|
+
record("9. the contracted agency's create gets past the guard too", async () => {
|
|
224
249
|
const res = await h.api("/robots", {
|
|
225
250
|
method: "POST",
|
|
226
251
|
sid: cleanerSid,
|
|
227
252
|
body: newRobot(id.siteB, id.orgB, "rb-agency"),
|
|
228
253
|
});
|
|
229
254
|
|
|
230
|
-
assert.equal(res.status,
|
|
231
|
-
assert.
|
|
255
|
+
assert.equal(res.status, 201);
|
|
256
|
+
assert.notEqual(await byName("rb-agency"), null);
|
|
232
257
|
});
|
|
233
258
|
|
|
234
259
|
record("10. Seven365 platform staff still reach any estate on both routes", async () => {
|
|
@@ -240,8 +265,7 @@ describe("robots and their credentials are scoped to their estate", { concurrenc
|
|
|
240
265
|
sid: staffSid,
|
|
241
266
|
body: newRobot(id.siteA, id.orgA, "rb-staff"),
|
|
242
267
|
});
|
|
243
|
-
assert.equal(create.status,
|
|
244
|
-
assert.equal(create.body?.message, MODEL_REFUSAL);
|
|
268
|
+
assert.equal(create.status, 201);
|
|
245
269
|
});
|
|
246
270
|
|
|
247
271
|
// ---- the refusal leaks nothing ------------------------------------------
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
LIFT_DOOR,
|
|
6
|
+
LIFT_MODE,
|
|
7
|
+
LIFT_MOTION,
|
|
8
|
+
LIFT_REQUEST,
|
|
9
|
+
createLiftSimulator,
|
|
10
|
+
holdsLift,
|
|
11
|
+
isLiftAvailable,
|
|
12
|
+
isSafeToCross,
|
|
13
|
+
servesFloor,
|
|
14
|
+
} from "./.build/utils/lift-contract.util.mjs";
|
|
15
|
+
|
|
16
|
+
/* --------------------------------------------------------------------------
|
|
17
|
+
* The lift is the one part of this system that can injure somebody, and the
|
|
18
|
+
* Inovance register map has not arrived, so every decision here is made against
|
|
19
|
+
* a simulator rather than a real car. These assert the decisions that would
|
|
20
|
+
* otherwise only be discovered on a live lift.
|
|
21
|
+
*
|
|
22
|
+
* Enum values match rmf_lift_msgs verbatim (open-rmf/rmf_internal_msgs).
|
|
23
|
+
* ----------------------------------------------------------------------- */
|
|
24
|
+
|
|
25
|
+
const FLOORS = ["L1", "L2", "L3", "L4"];
|
|
26
|
+
const sim = (o = {}) => createLiftSimulator({ name: "LIFT-A", floors: FLOORS, ...o });
|
|
27
|
+
|
|
28
|
+
const stateWith = (over = {}) => ({
|
|
29
|
+
liftName: "LIFT-A",
|
|
30
|
+
liftTime: 0,
|
|
31
|
+
availableFloors: [...FLOORS],
|
|
32
|
+
currentFloor: "L1",
|
|
33
|
+
destinationFloor: "L1",
|
|
34
|
+
doorState: LIFT_DOOR.OPEN,
|
|
35
|
+
motionState: LIFT_MOTION.STOPPED,
|
|
36
|
+
currentMode: LIFT_MODE.HUMAN,
|
|
37
|
+
sessionId: "",
|
|
38
|
+
...over,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/* ------------------------------------------------- the enums match RMF */
|
|
42
|
+
|
|
43
|
+
test("the enum values are rmf_lift_msgs values, not our own numbering", () => {
|
|
44
|
+
assert.deepEqual(LIFT_DOOR, { CLOSED: 0, MOVING: 1, OPEN: 2 });
|
|
45
|
+
assert.deepEqual(LIFT_MOTION, { STOPPED: 0, UP: 1, DOWN: 2, UNKNOWN: 3 });
|
|
46
|
+
assert.deepEqual(LIFT_MODE, {
|
|
47
|
+
UNKNOWN: 0, HUMAN: 1, AGV: 2, FIRE: 3, OFFLINE: 4, EMERGENCY: 5,
|
|
48
|
+
});
|
|
49
|
+
assert.deepEqual(LIFT_REQUEST, { END_SESSION: 0, AGV_MODE: 1, HUMAN_MODE: 2 });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/* ------------------------------------------------------------ availability */
|
|
53
|
+
|
|
54
|
+
test("a lift in FIRE or EMERGENCY mode is never available", () => {
|
|
55
|
+
// During an evacuation the lift belongs to the fire service. A robot must not
|
|
56
|
+
// be queued for it, whatever else is true.
|
|
57
|
+
for (const mode of [LIFT_MODE.FIRE, LIFT_MODE.EMERGENCY, LIFT_MODE.OFFLINE, LIFT_MODE.UNKNOWN]) {
|
|
58
|
+
const s = stateWith({ currentMode: mode });
|
|
59
|
+
assert.equal(isLiftAvailable(s), false, `mode ${mode}`);
|
|
60
|
+
assert.equal(isSafeToCross(s, "L1"), false, `mode ${mode}`);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a lift that has never reported is not available", () => {
|
|
65
|
+
assert.equal(isLiftAvailable(null), false);
|
|
66
|
+
assert.equal(isSafeToCross(null, "L1"), false);
|
|
67
|
+
assert.equal(servesFloor(null, "L1"), false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/* -------------------------------------------------------- crossing the door */
|
|
71
|
+
|
|
72
|
+
test("a MOVING door is not an open door", () => {
|
|
73
|
+
// The branch that matters most: telling a robot to cross a closing door plane
|
|
74
|
+
// is how a robot gets struck.
|
|
75
|
+
assert.equal(isSafeToCross(stateWith({ doorState: LIFT_DOOR.MOVING }), "L1"), false);
|
|
76
|
+
assert.equal(isSafeToCross(stateWith({ doorState: LIFT_DOOR.CLOSED }), "L1"), false);
|
|
77
|
+
assert.equal(isSafeToCross(stateWith({ doorState: LIFT_DOOR.OPEN }), "L1"), true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a car still in motion is never safe to cross, doors notwithstanding", () => {
|
|
81
|
+
for (const motion of [LIFT_MOTION.UP, LIFT_MOTION.DOWN, LIFT_MOTION.UNKNOWN]) {
|
|
82
|
+
assert.equal(
|
|
83
|
+
isSafeToCross(stateWith({ motionState: motion, doorState: LIFT_DOOR.OPEN }), "L1"),
|
|
84
|
+
false,
|
|
85
|
+
`motion ${motion}`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a car at the WRONG floor is not safe to cross", () => {
|
|
91
|
+
// A passenger redirecting the car mid-journey is exactly this case.
|
|
92
|
+
assert.equal(isSafeToCross(stateWith({ currentFloor: "L3" }), "L1"), false);
|
|
93
|
+
assert.equal(isSafeToCross(stateWith({ currentFloor: "L3" }), "L3"), true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/* ------------------------------------------------------------- the session */
|
|
97
|
+
|
|
98
|
+
test("an empty session id never holds the lift", () => {
|
|
99
|
+
// Otherwise a caller that forgot to set one is handed a lift somebody is riding.
|
|
100
|
+
assert.equal(holdsLift(stateWith({ sessionId: "" }), ""), false);
|
|
101
|
+
assert.equal(holdsLift(stateWith({ sessionId: "j-1" }), ""), false);
|
|
102
|
+
assert.equal(holdsLift(stateWith({ sessionId: "" }), "j-1"), false);
|
|
103
|
+
assert.equal(holdsLift(stateWith({ sessionId: "j-1" }), "j-1"), true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a floor the lift does not serve is not a destination", () => {
|
|
107
|
+
assert.equal(servesFloor(stateWith(), "L9"), false);
|
|
108
|
+
assert.equal(servesFloor(stateWith(), ""), false);
|
|
109
|
+
assert.equal(servesFloor(stateWith(), "L3"), true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/* ------------------------------------------------------------- simulator */
|
|
113
|
+
|
|
114
|
+
test("a requested lift travels, arrives, and opens its doors", async () => {
|
|
115
|
+
const lift = sim({ msPerFloor: 1_000 });
|
|
116
|
+
|
|
117
|
+
await lift.request({
|
|
118
|
+
liftName: "LIFT-A", sessionId: "j-1",
|
|
119
|
+
requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L3",
|
|
120
|
+
doorState: LIFT_DOOR.OPEN,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
let s = await lift.read();
|
|
124
|
+
assert.equal(s.motionState, LIFT_MOTION.UP);
|
|
125
|
+
assert.equal(s.doorState, LIFT_DOOR.CLOSED, "doors shut while travelling");
|
|
126
|
+
assert.equal(isSafeToCross(s, "L3"), false);
|
|
127
|
+
|
|
128
|
+
lift.tick(2_000); // two floors
|
|
129
|
+
s = await lift.read();
|
|
130
|
+
assert.equal(s.currentFloor, "L3");
|
|
131
|
+
assert.equal(s.motionState, LIFT_MOTION.STOPPED);
|
|
132
|
+
assert.equal(s.doorState, LIFT_DOOR.OPEN);
|
|
133
|
+
assert.equal(isSafeToCross(s, "L3"), true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("the doors close themselves — this is why the hold must be refreshed", async () => {
|
|
137
|
+
// The tightest timing in the whole design: a passenger lift times its doors
|
|
138
|
+
// out while the robot is still crossing.
|
|
139
|
+
const lift = sim({ msPerFloor: 1_000, doorDwellMs: 4_000 });
|
|
140
|
+
await lift.request({
|
|
141
|
+
liftName: "LIFT-A", sessionId: "j-1",
|
|
142
|
+
requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L1",
|
|
143
|
+
doorState: LIFT_DOOR.OPEN,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
assert.equal((await lift.read()).doorState, LIFT_DOOR.OPEN);
|
|
147
|
+
lift.tick(4_000);
|
|
148
|
+
assert.equal((await lift.read()).doorState, LIFT_DOOR.CLOSED, "closed on its own");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("refreshing the hold keeps the doors open", async () => {
|
|
152
|
+
const lift = sim({ doorDwellMs: 4_000 });
|
|
153
|
+
await lift.request({
|
|
154
|
+
liftName: "LIFT-A", sessionId: "j-1",
|
|
155
|
+
requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L1",
|
|
156
|
+
doorState: LIFT_DOOR.OPEN,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
for (let i = 0; i < 5; i++) {
|
|
160
|
+
lift.tick(3_000);
|
|
161
|
+
await lift.refresh("j-1");
|
|
162
|
+
}
|
|
163
|
+
assert.equal((await lift.read()).doorState, LIFT_DOOR.OPEN);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("refreshing somebody else's session does NOT hold the doors", async () => {
|
|
167
|
+
const lift = sim({ doorDwellMs: 4_000 });
|
|
168
|
+
await lift.request({
|
|
169
|
+
liftName: "LIFT-A", sessionId: "j-1",
|
|
170
|
+
requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L1",
|
|
171
|
+
doorState: LIFT_DOOR.OPEN,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
lift.tick(3_000);
|
|
175
|
+
await lift.refresh("j-2"); // not the holder
|
|
176
|
+
lift.tick(2_000);
|
|
177
|
+
assert.equal((await lift.read()).doorState, LIFT_DOOR.CLOSED);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("a lift held by one journey is refused to another, not stolen", async () => {
|
|
181
|
+
const lift = sim();
|
|
182
|
+
await lift.request({
|
|
183
|
+
liftName: "LIFT-A", sessionId: "j-1",
|
|
184
|
+
requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L2",
|
|
185
|
+
doorState: LIFT_DOOR.OPEN,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await assert.rejects(
|
|
189
|
+
() => lift.request({
|
|
190
|
+
liftName: "LIFT-A", sessionId: "j-2",
|
|
191
|
+
requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L4",
|
|
192
|
+
doorState: LIFT_DOOR.OPEN,
|
|
193
|
+
}),
|
|
194
|
+
/held by j-1/
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("release is safe to call twice, and frees the lift for the next journey", async () => {
|
|
199
|
+
const lift = sim();
|
|
200
|
+
await lift.request({
|
|
201
|
+
liftName: "LIFT-A", sessionId: "j-1",
|
|
202
|
+
requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L2",
|
|
203
|
+
doorState: LIFT_DOOR.OPEN,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await lift.release("j-1");
|
|
207
|
+
await lift.release("j-1"); // the watchdog fires unconditionally
|
|
208
|
+
const s = await lift.read();
|
|
209
|
+
assert.equal(s.sessionId, "");
|
|
210
|
+
assert.equal(s.currentMode, LIFT_MODE.HUMAN, "handed back to passengers");
|
|
211
|
+
|
|
212
|
+
await lift.request({
|
|
213
|
+
liftName: "LIFT-A", sessionId: "j-2",
|
|
214
|
+
requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L4",
|
|
215
|
+
doorState: LIFT_DOOR.OPEN,
|
|
216
|
+
});
|
|
217
|
+
assert.equal((await lift.read()).sessionId, "j-2");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("releasing a lift somebody else holds does nothing", async () => {
|
|
221
|
+
const lift = sim();
|
|
222
|
+
await lift.request({
|
|
223
|
+
liftName: "LIFT-A", sessionId: "j-1",
|
|
224
|
+
requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L2",
|
|
225
|
+
doorState: LIFT_DOOR.OPEN,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await lift.release("j-2");
|
|
229
|
+
assert.equal((await lift.read()).sessionId, "j-1", "still held by j-1");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("a floor the lift does not serve is refused", async () => {
|
|
233
|
+
const lift = sim();
|
|
234
|
+
await assert.rejects(
|
|
235
|
+
() => lift.request({
|
|
236
|
+
liftName: "LIFT-A", sessionId: "j-1",
|
|
237
|
+
requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "B2",
|
|
238
|
+
doorState: LIFT_DOOR.OPEN,
|
|
239
|
+
}),
|
|
240
|
+
/does not serve B2/
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("END_SESSION hands the lift back to passengers", async () => {
|
|
245
|
+
const lift = sim();
|
|
246
|
+
await lift.request({
|
|
247
|
+
liftName: "LIFT-A", sessionId: "j-1",
|
|
248
|
+
requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L2",
|
|
249
|
+
doorState: LIFT_DOOR.OPEN,
|
|
250
|
+
});
|
|
251
|
+
assert.equal((await lift.read()).currentMode, LIFT_MODE.AGV);
|
|
252
|
+
|
|
253
|
+
await lift.request({
|
|
254
|
+
liftName: "LIFT-A", sessionId: "j-1",
|
|
255
|
+
requestType: LIFT_REQUEST.END_SESSION, destinationFloor: "L2",
|
|
256
|
+
doorState: LIFT_DOOR.CLOSED,
|
|
257
|
+
});
|
|
258
|
+
const s = await lift.read();
|
|
259
|
+
assert.equal(s.sessionId, "");
|
|
260
|
+
assert.equal(s.currentMode, LIFT_MODE.HUMAN);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("read() returns a copy — a caller cannot mutate the lift", async () => {
|
|
264
|
+
const lift = sim();
|
|
265
|
+
const s = await lift.read();
|
|
266
|
+
s.availableFloors.push("L99");
|
|
267
|
+
s.currentFloor = "L4";
|
|
268
|
+
const again = await lift.read();
|
|
269
|
+
assert.equal(again.availableFloors.includes("L99"), false);
|
|
270
|
+
assert.equal(again.currentFloor, "L1");
|
|
271
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ROBOT_APP_SCOPES,
|
|
6
|
+
canMonitorRobotType,
|
|
7
|
+
canSetUpRobotType,
|
|
8
|
+
robotScopeForApp,
|
|
9
|
+
robotTypesVisibleTo,
|
|
10
|
+
} from "./.build/utils/robot-apps.util.mjs";
|
|
11
|
+
|
|
12
|
+
/* --------------------------------------------------------------------------
|
|
13
|
+
* The owner's rule, 2026-09-22: iService365 is the fleet manager for EVERY
|
|
14
|
+
* robot on a property. A cleaning robot is set up in the cleaning apps, a
|
|
15
|
+
* security robot in the security apps, Property Management does both because
|
|
16
|
+
* the property owns the fleet, the Seven365 console does everything everywhere,
|
|
17
|
+
* and the managing agent watches without configuring.
|
|
18
|
+
*
|
|
19
|
+
* These assert that table, and the two ways it can fail quietly: an app nobody
|
|
20
|
+
* declared getting everything instead of nothing, and a refusal being read as
|
|
21
|
+
* "no filter".
|
|
22
|
+
* ----------------------------------------------------------------------- */
|
|
23
|
+
|
|
24
|
+
const HYGIENE_WEB = "iservice365-web-app-hygiene";
|
|
25
|
+
const ICLEAN = "iservice365-hygiene-mobile-app";
|
|
26
|
+
const SECURITY_WEB = "iservice365-web-app-security";
|
|
27
|
+
const ISECURE = "iservice365-security-mobile-app";
|
|
28
|
+
const PM_WEB = "iservice365-web-app-property-management";
|
|
29
|
+
const IMA = "iservice365-ma-mobile-app";
|
|
30
|
+
const CONSOLE = "iservice365-web-app-admin";
|
|
31
|
+
|
|
32
|
+
/* ------------------------------------------------------- the owner's rule */
|
|
33
|
+
|
|
34
|
+
test("cleaning apps see cleaning robots and NOT security patrols", () => {
|
|
35
|
+
for (const app of [HYGIENE_WEB, ICLEAN]) {
|
|
36
|
+
assert.equal(canSetUpRobotType(app, "cleaning"), true, app);
|
|
37
|
+
assert.equal(canMonitorRobotType(app, "cleaning"), true, app);
|
|
38
|
+
|
|
39
|
+
// A cleaner must never be shown where a security patrol is.
|
|
40
|
+
assert.equal(canSetUpRobotType(app, "security"), false, app);
|
|
41
|
+
assert.equal(canMonitorRobotType(app, "security"), false, app);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("security apps see security robots and NOT cleaning robots", () => {
|
|
46
|
+
for (const app of [SECURITY_WEB, ISECURE]) {
|
|
47
|
+
assert.equal(canSetUpRobotType(app, "security"), true, app);
|
|
48
|
+
assert.equal(canMonitorRobotType(app, "security"), true, app);
|
|
49
|
+
assert.equal(canSetUpRobotType(app, "cleaning"), false, app);
|
|
50
|
+
assert.equal(canMonitorRobotType(app, "cleaning"), false, app);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("Property Management sets up and monitors BOTH", () => {
|
|
55
|
+
for (const type of ["cleaning", "security"]) {
|
|
56
|
+
assert.equal(canSetUpRobotType(PM_WEB, type), true, type);
|
|
57
|
+
assert.equal(canMonitorRobotType(PM_WEB, type), true, type);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("the Seven365 console does everything, for every robot type", () => {
|
|
62
|
+
for (const type of ["cleaning", "security"]) {
|
|
63
|
+
assert.equal(canSetUpRobotType(CONSOLE, type), true, type);
|
|
64
|
+
assert.equal(canMonitorRobotType(CONSOLE, type), true, type);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("the managing agent watches the whole fleet but configures nothing", () => {
|
|
69
|
+
assert.equal(canMonitorRobotType(IMA, "cleaning"), true);
|
|
70
|
+
assert.equal(canMonitorRobotType(IMA, "security"), true);
|
|
71
|
+
|
|
72
|
+
// Setup is a different job from monitoring - iMA365 must not be able to
|
|
73
|
+
// re-point a robot at a different cloud.
|
|
74
|
+
assert.equal(canSetUpRobotType(IMA, "cleaning"), false);
|
|
75
|
+
assert.equal(canSetUpRobotType(IMA, "security"), false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/* ------------------------------------------------------------ fails closed */
|
|
79
|
+
|
|
80
|
+
test("an app nobody declared gets NO robots, not all of them", () => {
|
|
81
|
+
// A new service app, a renamed deployment, a typo in an env var. The wrong
|
|
82
|
+
// default here is a landscaping app listing a building's security patrols.
|
|
83
|
+
for (const unknown of [
|
|
84
|
+
"iservice365-landscaping-mobile-app",
|
|
85
|
+
"iservice365-web-app-pest-control",
|
|
86
|
+
"not-an-app",
|
|
87
|
+
"",
|
|
88
|
+
null,
|
|
89
|
+
undefined,
|
|
90
|
+
42,
|
|
91
|
+
{},
|
|
92
|
+
]) {
|
|
93
|
+
assert.deepEqual(
|
|
94
|
+
robotScopeForApp(unknown),
|
|
95
|
+
{ setup: [], monitor: [] },
|
|
96
|
+
String(unknown)
|
|
97
|
+
);
|
|
98
|
+
assert.equal(canMonitorRobotType(unknown, "cleaning"), false);
|
|
99
|
+
assert.equal(canSetUpRobotType(unknown, "security"), false);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("an unknown robot type is refused everywhere, including the console", () => {
|
|
104
|
+
for (const app of Object.keys(ROBOT_APP_SCOPES)) {
|
|
105
|
+
assert.equal(canSetUpRobotType(app, "drone"), false, app);
|
|
106
|
+
assert.equal(canMonitorRobotType(app, undefined), false, app);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/* --------------------------------------------------- the listing filter */
|
|
111
|
+
|
|
112
|
+
test("no type requested means every type the app may monitor", () => {
|
|
113
|
+
assert.deepEqual(robotTypesVisibleTo(PM_WEB), ["cleaning", "security"]);
|
|
114
|
+
assert.deepEqual(robotTypesVisibleTo(ICLEAN), ["cleaning"]);
|
|
115
|
+
assert.deepEqual(robotTypesVisibleTo(ISECURE, ""), ["security"]);
|
|
116
|
+
assert.deepEqual(robotTypesVisibleTo(PM_WEB, null), ["cleaning", "security"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("a permitted type narrows the listing to that type", () => {
|
|
120
|
+
assert.deepEqual(robotTypesVisibleTo(PM_WEB, "security"), ["security"]);
|
|
121
|
+
assert.deepEqual(robotTypesVisibleTo(CONSOLE, "cleaning"), ["cleaning"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("asking for a type the app may not see returns [] - list NOTHING", () => {
|
|
125
|
+
// The case that must never be read as "no filter": if a caller treats the
|
|
126
|
+
// empty array as "no constraint", a refusal becomes a listing of every robot
|
|
127
|
+
// on the estate.
|
|
128
|
+
assert.deepEqual(robotTypesVisibleTo(ICLEAN, "security"), []);
|
|
129
|
+
assert.deepEqual(robotTypesVisibleTo(ISECURE, "cleaning"), []);
|
|
130
|
+
assert.deepEqual(robotTypesVisibleTo(IMA, "drone"), []);
|
|
131
|
+
assert.deepEqual(robotTypesVisibleTo("not-an-app", "cleaning"), []);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("an app that monitors nothing is never widened by an empty request", () => {
|
|
135
|
+
// iMA365 configures nothing, so its SETUP list is empty - but that must not
|
|
136
|
+
// make `robotTypesVisibleTo` return everything for an unlisted app.
|
|
137
|
+
assert.deepEqual(robotTypesVisibleTo("not-an-app"), []);
|
|
138
|
+
assert.deepEqual(robotTypesVisibleTo("not-an-app", undefined), []);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/* ------------------------------------------------------------ the table */
|
|
142
|
+
|
|
143
|
+
test("every declared app names only known robot types", () => {
|
|
144
|
+
const known = new Set(["cleaning", "security"]);
|
|
145
|
+
for (const [app, scope] of Object.entries(ROBOT_APP_SCOPES)) {
|
|
146
|
+
for (const t of [...scope.setup, ...scope.monitor]) {
|
|
147
|
+
assert.equal(known.has(t), true, `${app} declares unknown type ${t}`);
|
|
148
|
+
}
|
|
149
|
+
// Anything an app can set up, it must also be able to see.
|
|
150
|
+
for (const t of scope.setup) {
|
|
151
|
+
assert.equal(scope.monitor.includes(t), true, `${app} sets up ${t} blind`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|