@7365admin1/core 3.85.0 → 3.87.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/core",
3
3
  "license": "MIT",
4
- "version": "3.85.0",
4
+ "version": "3.87.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/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/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
  },
@@ -0,0 +1,197 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { createDemoAdapter, DEMO_CAPABILITIES } from "./.build/services/demo.adapter.mjs";
5
+ import { robotTier, commandSupported } from "./.build/utils/robot-adapter.util.mjs";
6
+ import { encodeGreyscalePng } from "./.build/utils/png-encoder.util.mjs";
7
+ import { wallsFromOccupancyGrid } from "./.build/utils/building-map.util.mjs";
8
+
9
+ /* --------------------------------------------------------------------------
10
+ * The demo brand has two jobs, and these guard both.
11
+ *
12
+ * It makes the work visible: until a vendor supplies credentials, every robot
13
+ * screen in the platform is empty and no button can be pressed.
14
+ *
15
+ * And it proves the vendor-neutral design. It is a second brand written against
16
+ * the same contract as Ounitech, declaring DIFFERENT capabilities. If the
17
+ * screens behave differently for the two without being told which is which,
18
+ * the platform really is brand-agnostic.
19
+ * ----------------------------------------------------------------------- */
20
+
21
+ const at = (ms) => createDemoAdapter({ now: () => ms });
22
+
23
+ test("the demo brand reaches Full Control, where Ounitech reaches none", () => {
24
+ // The contrast IS the demonstration: same contract, different capabilities,
25
+ // and the screens draw different controls without being told to.
26
+ assert.equal(robotTier(DEMO_CAPABILITIES), "full-control");
27
+
28
+ const ounitech = { readLocation: true, sendToDock: true, startProcess: true };
29
+ assert.equal(robotTier(ounitech), "no-interface");
30
+
31
+ // So the demo offers pause; Ounitech does not.
32
+ assert.equal(commandSupported(DEMO_CAPABILITIES, "pause"), true);
33
+ assert.equal(commandSupported(ounitech, "pause"), false);
34
+ });
35
+
36
+ test("it lists robots across more than one floor", () => {
37
+ return at(0).listRobots().then((robots) => {
38
+ assert.ok(robots.length >= 3);
39
+ assert.ok(new Set(robots.map((r) => r.floorNo)).size >= 2, "more than one floor");
40
+ });
41
+ });
42
+
43
+ test("every demo robot says it is a demo, in its own name", async () => {
44
+ // A simulated robot appearing in a real estate's list without saying so would
45
+ // be worse than no demo at all.
46
+ const robots = await at(0).listRobots();
47
+ for (const r of robots) {
48
+ assert.match(r.name, /demo/i, r.name);
49
+ assert.match(r.robotSn, /^DEMO-/, r.robotSn);
50
+ }
51
+ assert.match(at(0).displayName, /demo/i);
52
+ });
53
+
54
+ test("it reports a KNOWN floor and a real position — unlike Ounitech", async () => {
55
+ // Which is what lets the 3D layout actually draw the robot.
56
+ const [robot] = await at(0).listRobots();
57
+ assert.equal(robot.floorKnown, true);
58
+ assert.equal(typeof robot.floorNo, "number");
59
+ assert.ok(robot.position);
60
+ assert.equal(typeof robot.position.x, "number");
61
+ assert.equal(typeof robot.position.theta, "number");
62
+ });
63
+
64
+ test("the robots move as the clock advances", async () => {
65
+ const [early] = await at(0).listRobots();
66
+ const [later] = await at(30_000).listRobots();
67
+ assert.notDeepEqual(early.position, later.position, "a demo robot that never moves is a picture");
68
+ });
69
+
70
+ test("battery changes over time and stays in range", async () => {
71
+ const seen = new Set();
72
+ for (const ms of [0, 600_000, 1_800_000, 3_000_000]) {
73
+ const [robot] = await at(ms).listRobots();
74
+ assert.ok(robot.batteryPercent >= 0 && robot.batteryPercent <= 100, String(robot.batteryPercent));
75
+ seen.add(robot.batteryPercent);
76
+ }
77
+ assert.ok(seen.size > 1, "the battery never changed");
78
+ });
79
+
80
+ test("a command changes what the robot reports, and then wears off", async () => {
81
+ const adapter = at(1_000_000);
82
+ const before = await adapter.getRobot("DEMO-0001");
83
+
84
+ await adapter.sendCommand("DEMO-0001", { kind: "full" });
85
+ const during = await at(1_000_000).getRobot("DEMO-0001");
86
+ assert.equal(during.mode, "WORKING", `was ${before.mode}`);
87
+
88
+ // It returns to idle by itself, so the demo needs no resetting between
89
+ // viewings.
90
+ const after = await at(1_000_000 + 11 * 60_000).getRobot("DEMO-0001");
91
+ assert.notEqual(after.mode, "WORKING");
92
+
93
+ await adapter.sendCommand("DEMO-0001", { kind: "cancel" });
94
+ });
95
+
96
+ test("commanding a robot that does not exist is refused by name", async () => {
97
+ await assert.rejects(
98
+ () => at(0).sendCommand("NOT-A-ROBOT", { kind: "full" }),
99
+ /No demo robot with serial NOT-A-ROBOT/,
100
+ );
101
+ });
102
+
103
+ test("an unknown serial returns null rather than a stranger's robot", async () => {
104
+ assert.equal(await at(0).getRobot("DEMO-9999"), null);
105
+ });
106
+
107
+ /* --------------------------------------------------------- the floor plans */
108
+
109
+ test("the plans carry the scale and areas the map screens need", async () => {
110
+ const maps = await at(0).getMaps();
111
+ assert.ok(maps.length >= 2, "more than one level");
112
+ for (const m of maps) {
113
+ // 1px = 5cm, traffic_editor's default and Ounitech's own resolution.
114
+ assert.equal(m.resolution, 0.05);
115
+ assert.ok(m.areaList.length >= 1);
116
+ assert.ok(m.backgroundFileKey);
117
+ }
118
+ });
119
+
120
+ test("the generated plan is a real PNG a browser will accept", async () => {
121
+ const files = await at(0).getMapImages("DEMO-0001", ["demo/L1.png"]);
122
+ const url = files[0].url;
123
+ assert.match(url, /^data:image\/png;base64,/);
124
+
125
+ // The 8-byte PNG signature, so this is decodable rather than merely base64.
126
+ const bytes = Buffer.from(url.split(",")[1], "base64");
127
+ assert.deepEqual([...bytes.subarray(0, 8)], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
128
+ assert.equal(bytes.subarray(12, 16).toString("ascii"), "IHDR");
129
+ assert.equal(bytes.subarray(bytes.length - 8, bytes.length - 4).toString("ascii"), "IEND");
130
+ });
131
+
132
+ test("a data URI is used so the canvas is not tainted", async () => {
133
+ // The layout is built by reading these pixels BACK. A cross-origin image
134
+ // would throw on getImageData, which looks like a broken floor plan.
135
+ const files = await at(0).getMapImages("DEMO-0001", ["demo/L1.png", "demo/L2.png"]);
136
+ assert.equal(files.length, 2);
137
+ for (const f of files) assert.ok(f.url.startsWith("data:"));
138
+ });
139
+
140
+ test("the two levels are genuinely different plans", async () => {
141
+ const [l1, l2] = await at(0).getMapImages("DEMO-0001", ["demo/L1.png", "demo/L2.png"]);
142
+ assert.notEqual(l1.url, l2.url, "both floors rendered the same picture");
143
+ });
144
+
145
+ test("THE POINT: the generated plan yields a 3D layout", () => {
146
+ // End to end, with no vendor and no network: a plan becomes walls.
147
+ const width = 60, height = 40;
148
+ const px = new Uint8Array(width * height).fill(255);
149
+ for (let x = 0; x < width; x++) { px[x] = 0; px[(height - 1) * width + x] = 0; }
150
+ for (let y = 0; y < height; y++) { px[y * width] = 0; px[y * width + width - 1] = 0; }
151
+
152
+ const png = encodeGreyscalePng(px, width, height);
153
+ assert.ok(png.length > 0);
154
+
155
+ const { walls } = wallsFromOccupancyGrid({ data: px, width, height }, { minRunPx: 5 });
156
+ assert.equal(walls.length, 4, "a closed room is four walls");
157
+ });
158
+
159
+ /* -------------------------------------------------------- the PNG encoder */
160
+
161
+ test("the encoder refuses an impossible image rather than emitting a broken one", () => {
162
+ assert.throws(() => encodeGreyscalePng(new Uint8Array(4), 0, 4), /positive size/);
163
+ assert.throws(() => encodeGreyscalePng(new Uint8Array(2), 10, 10), /one byte per pixel/);
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
+ });
@@ -874,10 +874,12 @@ 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);
881
883
  }
882
884
 
883
885
  // mirrors iservice365-API-core/src/routes/attendance-settings.route.ts
@@ -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
 
@@ -3,12 +3,13 @@ import { test } from "node:test";
3
3
 
4
4
  import {
5
5
  ROBOT_CAPABILITIES,
6
- clearRobotAdapters,
6
+ adapterForRobot,
7
+ clearRobotBrands,
7
8
  commandSupported,
8
9
  missingForTier,
9
- registerRobotAdapter,
10
+ registerRobotBrand,
10
11
  registeredRobotBrands,
11
- robotAdapterFor,
12
+ robotBrandFor,
12
13
  robotTier,
13
14
  supports,
14
15
  uncontrollableFleetWarning,
@@ -128,6 +129,15 @@ test("each command maps to the capability it actually needs", () => {
128
129
  assert.equal(commandSupported(caps("navigateToPoint"), "navigate"), true);
129
130
  });
130
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
+
131
141
  test("an unknown command kind is refused, not defaulted", () => {
132
142
  for (const kind of ["selfDestruct", "", null, undefined, "constructor"]) {
133
143
  assert.equal(commandSupported(ALL, kind), false, String(kind));
@@ -136,44 +146,94 @@ test("an unknown command kind is refused, not defaulted", () => {
136
146
 
137
147
  /* --------------------------------------------------------------- registry */
138
148
 
139
- test("an adapter is found by brand, and never by fallback", () => {
140
- clearRobotAdapters();
141
- const a = { brand: "brand-a", displayName: "Brand A", capabilities: ALL, listRobots: async () => [], getRobot: async () => null };
142
- registerRobotAdapter(a);
149
+ const brand = (over = {}) => ({
150
+ brand: "brand-a",
151
+ displayName: "Brand A",
152
+ capabilities: ALL,
153
+ create: () => ({ brand: "brand-a", displayName: "Brand A", capabilities: ALL,
154
+ listRobots: async () => [], getRobot: async () => null }),
155
+ ...over,
156
+ });
143
157
 
144
- assert.equal(robotAdapterFor("brand-a"), a);
145
- // Falling back to the only registered adapter would send one brand's robot a
158
+ const robot = (over = {}) => ({
159
+ name: "Cleaner 02", brand: "brand-a", appKey: "k", appToken: "s", ...over,
160
+ });
161
+
162
+ test("a brand is found by name, and never by fallback", () => {
163
+ clearRobotBrands();
164
+ registerRobotBrand(brand());
165
+
166
+ assert.equal(robotBrandFor("brand-a")?.brand, "brand-a");
167
+ // Falling back to the only registered brand would send one brand's robot a
146
168
  // command in a protocol it does not speak.
147
- assert.equal(robotAdapterFor("brand-b"), null);
148
- assert.equal(robotAdapterFor(""), null);
149
- assert.equal(robotAdapterFor(null), null);
150
- assert.equal(robotAdapterFor(undefined), null);
151
- assert.equal(robotAdapterFor(42), null);
169
+ assert.equal(robotBrandFor("brand-b"), null);
170
+ for (const bad of ["", null, undefined, 42]) {
171
+ assert.equal(robotBrandFor(bad), null, String(bad));
172
+ }
152
173
  });
153
174
 
154
- test("an adapter without a brand is refused at registration", () => {
155
- clearRobotAdapters();
156
- assert.throws(() => registerRobotAdapter({ displayName: "x" }), /must name its brand/);
157
- assert.throws(() => registerRobotAdapter(null), /must name its brand/);
175
+ test("a brand without a name or a factory is refused at registration", () => {
176
+ clearRobotBrands();
177
+ assert.throws(() => registerRobotBrand({ displayName: "x" }), /must name itself/);
178
+ assert.throws(() => registerRobotBrand(null), /must name itself/);
179
+ // A brand registered with no way to build an adapter would fail later, at the
180
+ // moment somebody tried to use a robot - not here, where it is fixable.
181
+ assert.throws(
182
+ () => registerRobotBrand({ brand: "x", displayName: "X", capabilities: {} }),
183
+ /no way to build an adapter/,
184
+ );
158
185
  });
159
186
 
160
- test("the brand list reports each brand's tier, for a settings picker", () => {
161
- clearRobotAdapters();
162
- registerRobotAdapter({ brand: "good", displayName: "Good", capabilities: ALL, listRobots: async () => [], getRobot: async () => null });
163
- registerRobotAdapter({ brand: "poor", displayName: "Poor", capabilities: caps("readLocation"), listRobots: async () => [], getRobot: async () => null });
187
+ test("an adapter is built per robot, from THAT robot's credentials", () => {
188
+ clearRobotBrands();
189
+ let seen = null;
190
+ registerRobotBrand(brand({ create: (c) => { seen = c; return { brand: "brand-a",
191
+ displayName: "Brand A", capabilities: ALL, listRobots: async () => [],
192
+ getRobot: async () => null }; } }));
164
193
 
165
- assert.deepEqual(registeredRobotBrands().sort((x, y) => x.brand.localeCompare(y.brand)), [
166
- { brand: "good", displayName: "Good", tier: "full-control" },
167
- { brand: "poor", displayName: "Poor", tier: "no-interface" },
168
- ]);
194
+ adapterForRobot(robot({ appKey: "estate-1-id", appToken: "estate-1-secret" }));
195
+ assert.deepEqual(seen, { appid: "estate-1-id", secret: "estate-1-secret" });
196
+
197
+ // One estate's credentials must never sign another estate's request, which is
198
+ // why the adapter is constructed per robot rather than per brand.
199
+ adapterForRobot(robot({ appKey: "estate-2-id", appToken: "estate-2-secret" }));
200
+ assert.deepEqual(seen, { appid: "estate-2-id", secret: "estate-2-secret" });
169
201
  });
170
202
 
171
- test("re-registering a brand replaces it rather than duplicating", () => {
172
- clearRobotAdapters();
173
- const base = { brand: "x", displayName: "One", capabilities: {}, listRobots: async () => [], getRobot: async () => null };
174
- registerRobotAdapter(base);
175
- registerRobotAdapter({ ...base, displayName: "Two" });
203
+ test("an unknown brand is refused BY NAME, not with a null dereference", () => {
204
+ clearRobotBrands();
205
+ registerRobotBrand(brand());
206
+ assert.throws(() => adapterForRobot(robot({ brand: "unregistered" })),
207
+ /No robot adapter is registered for the brand "unregistered"/);
208
+ assert.throws(() => adapterForRobot(robot({ brand: "" })),
209
+ /does not say which brand it is/);
210
+ });
211
+
212
+ test("a robot with no credentials is refused before any call is attempted", () => {
213
+ clearRobotBrands();
214
+ registerRobotBrand(brand());
215
+ assert.throws(() => adapterForRobot(robot({ appKey: "" })), /no cloud credentials/);
216
+ assert.throws(() => adapterForRobot(robot({ appToken: " " })), /no cloud credentials/);
217
+ });
176
218
 
219
+ test("the brand list reports each brand's tier, for a settings picker", () => {
220
+ clearRobotBrands();
221
+ registerRobotBrand(brand({ brand: "good", displayName: "Good", capabilities: ALL }));
222
+ registerRobotBrand(brand({ brand: "poor", displayName: "Poor", capabilities: caps("readLocation") }));
223
+
224
+ assert.deepEqual(
225
+ registeredRobotBrands().sort((x, y) => x.brand.localeCompare(y.brand)),
226
+ [
227
+ { brand: "good", displayName: "Good", tier: "full-control" },
228
+ { brand: "poor", displayName: "Poor", tier: "no-interface" },
229
+ ],
230
+ );
231
+ });
232
+
233
+ test("re-registering a brand replaces it rather than duplicating", () => {
234
+ clearRobotBrands();
235
+ registerRobotBrand(brand({ displayName: "One" }));
236
+ registerRobotBrand(brand({ displayName: "Two" }));
177
237
  assert.equal(registeredRobotBrands().length, 1);
178
- assert.equal(robotAdapterFor("x").displayName, "Two");
238
+ assert.equal(robotBrandFor("brand-a").displayName, "Two");
179
239
  });
@@ -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
+ });