@7365admin1/core 3.88.0 → 3.89.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 +6 -0
- package/dist/index.d.ts +77 -1
- package/dist/index.js +5468 -5266
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4917 -4716
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/e2e/harness.mjs +12 -0
- package/test/e2e/robot-network.e2e.test.mjs +269 -0
- package/test/robot-network.test.mjs +93 -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.89.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-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",
|
|
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/robot-network.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
|
},
|
package/test/e2e/harness.mjs
CHANGED
|
@@ -876,6 +876,12 @@ function buildApp(nsu, core) {
|
|
|
876
876
|
{
|
|
877
877
|
const { createRobot, getRobots, updateRobot, deleteRobot } = core.useRobotController();
|
|
878
878
|
|
|
879
|
+
// Seven365 console: every robot at every client's site. Mounted before the
|
|
880
|
+
// id routes, as in API-core.
|
|
881
|
+
const n = core.useRobotNetworkController();
|
|
882
|
+
robots.get("/network", requireAuth, n.listNetworkRobots);
|
|
883
|
+
robots.get("/network/summary", requireAuth, n.getNetworkSummary);
|
|
884
|
+
|
|
879
885
|
robots.post("/", requireAuth, createRobot);
|
|
880
886
|
robots.get("/organization/:organization/site/:site", requireAuth, getRobots);
|
|
881
887
|
robots.patch("/:id([0-9a-fA-F]{24})", requireAuth, updateRobot);
|
|
@@ -894,6 +900,12 @@ function buildApp(nsu, core) {
|
|
|
894
900
|
robots.post(`${SESSION}/refresh`, requireAuth, m.refreshLiftSession);
|
|
895
901
|
robots.post(`${SESSION}/release`, requireAuth, m.releaseLiftSession);
|
|
896
902
|
robots.get("/:id([0-9a-fA-F]{24})/lift-session", requireAuth, m.getRobotLiftSession);
|
|
903
|
+
|
|
904
|
+
// State and command, for DEMO-brand robots only in these tests: the demo
|
|
905
|
+
// adapter reaches no network, so no vendor cloud is ever contacted.
|
|
906
|
+
const f = core.useRobotFleetController();
|
|
907
|
+
robots.get("/:id([0-9a-fA-F]{24})/state", requireAuth, f.getState);
|
|
908
|
+
robots.post("/:id([0-9a-fA-F]{24})/command", requireAuth, f.sendCommand);
|
|
897
909
|
}
|
|
898
910
|
|
|
899
911
|
// mirrors iservice365-API-core/src/routes/attendance-settings.route.ts
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// End-to-end proof of the Seven365 console's all-robots view (owner, 2026-09-23:
|
|
2
|
+
// "Seven365 Super Admin can see ALL the robots deployed to ALL its clients'
|
|
3
|
+
// sites"), and that Super Admin holds every robot right at ANY client's site.
|
|
4
|
+
//
|
|
5
|
+
// Who may list: the super-admin MEMBERSHIP (a `members` row of type "admin"
|
|
6
|
+
// whose role is a live role of type "admin") with the cross-client grant -
|
|
7
|
+
// never an email, never a role NAME. Case 3 is an ordinary organisation role
|
|
8
|
+
// merely NAMED "Super Admin", and it is refused.
|
|
9
|
+
//
|
|
10
|
+
// Everything runs against the throwaway harness (in-process MongoDB, loopback
|
|
11
|
+
// Redis and mail sink). Robots are the DEMO brand, which reaches no network, so
|
|
12
|
+
// no vendor cloud is contacted. Credential values are obvious fakes and are only
|
|
13
|
+
// asserted ABSENT.
|
|
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 STAFF = "net-staff@e2e.example.com"; // Seven365 super admin (seeded shape: permissions [])
|
|
25
|
+
const STAR = "net-star@e2e.example.com"; // Seven365 staff, role holds "*"
|
|
26
|
+
const PARTIAL = "net-partial@e2e.example.com"; // Seven365 staff ticked for promo codes only
|
|
27
|
+
const MANAGER = "net-manager@e2e.example.com"; // client A's manager, role holds "*"
|
|
28
|
+
const NAMED = "net-named@e2e.example.com"; // an org role merely NAMED "Super Admin"
|
|
29
|
+
|
|
30
|
+
describe("robot network view - Seven365 super admin only", { concurrency: 1 }, () => {
|
|
31
|
+
let h;
|
|
32
|
+
const id = {};
|
|
33
|
+
const sid = {};
|
|
34
|
+
|
|
35
|
+
before(async () => {
|
|
36
|
+
h = await startHarness();
|
|
37
|
+
Object.assign(id, await seed(h));
|
|
38
|
+
for (const [k, email] of Object.entries({ STAFF, STAR, PARTIAL, MANAGER, NAMED })) {
|
|
39
|
+
sid[k] = await h.login(email, PASSWORD);
|
|
40
|
+
}
|
|
41
|
+
}, { timeout: 300000 });
|
|
42
|
+
|
|
43
|
+
after(async () => {
|
|
44
|
+
if (h) await h.stop();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const list = (qs = "", who = "STAFF") => h.api(`/robots/network${qs}`, { sid: sid[who] });
|
|
48
|
+
|
|
49
|
+
it("1. no session is a 401 on both routes", async () => {
|
|
50
|
+
assert.equal((await h.api("/robots/network")).status, 401);
|
|
51
|
+
assert.equal((await h.api("/robots/network/summary")).status, 401);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("2. a client's own manager (role '*') is refused - it is not a platform membership", async () => {
|
|
55
|
+
assert.equal((await list("", "MANAGER")).status, 401);
|
|
56
|
+
assert.equal((await h.api("/robots/network/summary", { sid: sid.MANAGER })).status, 401);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("3. an organisation role merely NAMED 'Super Admin' is refused", async () => {
|
|
60
|
+
assert.equal((await list("", "NAMED")).status, 401);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("4. staff without the cross-client grant are refused", async () => {
|
|
64
|
+
assert.equal((await list("", "PARTIAL")).status, 401);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("5. super admin sees every client's robots, named, without credentials or removed ones", async () => {
|
|
68
|
+
const res = await list("?limit=50");
|
|
69
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
70
|
+
const names = res.body.items.map((r) => r.name).sort();
|
|
71
|
+
assert.deepEqual(names, ["A demo 1", "A legacy", "B guard", "B scrubber"]);
|
|
72
|
+
assert.equal(res.body.total, 4);
|
|
73
|
+
assert.equal(names.includes("A removed"), false);
|
|
74
|
+
|
|
75
|
+
const legacy = res.body.items.find((r) => r.name === "A legacy");
|
|
76
|
+
assert.equal(legacy.client.name, "Net Client A");
|
|
77
|
+
assert.equal(legacy.site.name, "Net Site A");
|
|
78
|
+
// written before `type` / `brand` existed: shown as the model's defaults
|
|
79
|
+
assert.equal(legacy.type, "cleaning");
|
|
80
|
+
assert.equal(legacy.brand, "ounitech");
|
|
81
|
+
assert.equal(res.body.items.find((r) => r.name === "A demo 1").demo, true);
|
|
82
|
+
assert.equal(legacy.demo, false);
|
|
83
|
+
|
|
84
|
+
const raw = JSON.stringify(res.body);
|
|
85
|
+
assert.equal(raw.includes("appKey") || raw.includes("appToken") || raw.includes("e2e-fake"), false);
|
|
86
|
+
// default order: client, then site
|
|
87
|
+
assert.deepEqual(res.body.items.map((r) => r.client.name), [
|
|
88
|
+
"Net Client A", "Net Client A", "Net Client B", "Net Client B",
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("6. a staff role holding '*' sees the same", async () => {
|
|
93
|
+
const res = await list("?limit=50", "STAR");
|
|
94
|
+
assert.equal(res.status, 200);
|
|
95
|
+
assert.equal(res.body.total, 4);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("7. filters: client, site, class (incl. legacy rows), brand, search by client name", async () => {
|
|
99
|
+
const byOrg = await list(`?org=${id.orgB}`);
|
|
100
|
+
assert.deepEqual(byOrg.body.items.map((r) => r.name).sort(), ["B guard", "B scrubber"]);
|
|
101
|
+
|
|
102
|
+
const bySite = await list(`?site=${id.siteA}`);
|
|
103
|
+
assert.equal(bySite.body.total, 2);
|
|
104
|
+
|
|
105
|
+
const cleaning = await list("?type=cleaning&limit=50");
|
|
106
|
+
assert.deepEqual(cleaning.body.items.map((r) => r.name).sort(), ["A demo 1", "A legacy", "B scrubber"]);
|
|
107
|
+
const security = await list("?type=security");
|
|
108
|
+
assert.deepEqual(security.body.items.map((r) => r.name), ["B guard"]);
|
|
109
|
+
|
|
110
|
+
const ounitech = await list("?brand=ounitech&limit=50");
|
|
111
|
+
assert.deepEqual(ounitech.body.items.map((r) => r.name).sort(), ["A legacy", "B guard", "B scrubber"]);
|
|
112
|
+
|
|
113
|
+
const search = await list("?search=client%20b");
|
|
114
|
+
assert.equal(search.body.total, 2);
|
|
115
|
+
const pattern = await list("?search=.*");
|
|
116
|
+
assert.equal(pattern.body.total, 0, "a search is text, not a regex");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("8. sort and pagination", async () => {
|
|
120
|
+
const page1 = await list("?sort=name&order=desc&limit=3&page=1");
|
|
121
|
+
assert.deepEqual(page1.body.items.map((r) => r.name), ["B scrubber", "B guard", "A legacy"]);
|
|
122
|
+
assert.equal(page1.body.pages, 2);
|
|
123
|
+
const page2 = await list("?sort=name&order=desc&limit=3&page=2");
|
|
124
|
+
assert.deepEqual(page2.body.items.map((r) => r.name), ["A demo 1"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("9. bad input is a 400 for staff", async () => {
|
|
128
|
+
assert.equal((await list("?type=drone")).status, 400);
|
|
129
|
+
assert.equal((await list("?org=nope")).status, 400);
|
|
130
|
+
assert.equal((await list("?limit=1000")).status, 400);
|
|
131
|
+
assert.equal((await list("?sort=appKey")).status, 400);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("10. the summary counts stored data and claims no fault count", async () => {
|
|
135
|
+
const res = await h.api("/robots/network/summary", { sid: sid.STAFF });
|
|
136
|
+
assert.equal(res.status, 200);
|
|
137
|
+
assert.equal(res.body.total, 4);
|
|
138
|
+
assert.equal(res.body.sites, 2);
|
|
139
|
+
assert.deepEqual(res.body.byType, [{ key: "cleaning", count: 3 }, { key: "security", count: 1 }]);
|
|
140
|
+
assert.equal(res.body.demo, 1);
|
|
141
|
+
assert.deepEqual(
|
|
142
|
+
res.body.byClient.map((c) => [c.name, c.count, c.sites]),
|
|
143
|
+
[["Net Client A", 2, 1], ["Net Client B", 2, 1]],
|
|
144
|
+
);
|
|
145
|
+
assert.deepEqual(res.body.faults, { live: false, count: null });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---- Super Admin holds every robot right at ANY client's site ----------
|
|
149
|
+
|
|
150
|
+
it("11. super admin reads live state and commands a robot at a client it is not a member of", async () => {
|
|
151
|
+
const state = await h.api(`/robots/${id.demoA}/state`, { sid: sid.STAFF });
|
|
152
|
+
assert.equal(state.status, 200, JSON.stringify(state.body));
|
|
153
|
+
const cmd = await h.api(`/robots/${id.demoA}/command`, {
|
|
154
|
+
method: "POST", sid: sid.STAFF, body: { kind: "return" },
|
|
155
|
+
});
|
|
156
|
+
assert.equal(cmd.status, 202, JSON.stringify(cmd.body));
|
|
157
|
+
// and a client's manager still cannot reach another client's robot
|
|
158
|
+
assert.equal((await h.api(`/robots/${id.scrubberB}/state`, { sid: sid.MANAGER })).status, 404);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("12. super admin saves a building map and asks for the lift at another client's site", async () => {
|
|
162
|
+
const map = await h.api("/robots/building-maps", {
|
|
163
|
+
method: "POST",
|
|
164
|
+
sid: sid.STAFF,
|
|
165
|
+
body: {
|
|
166
|
+
robotId: String(id.demoA),
|
|
167
|
+
name: "Net tower",
|
|
168
|
+
levels: [{ floorNo: 1, elevationM: 0 }, { floorNo: 2 }],
|
|
169
|
+
lifts: [{ name: "Lift A", x: 1, y: 1, floors: [1, 2] }],
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
assert.equal(map.status, 201, JSON.stringify(map.body));
|
|
173
|
+
const mapId = map.body.id ?? map.body._id ?? map.body.map?._id;
|
|
174
|
+
assert.ok(mapId, JSON.stringify(map.body));
|
|
175
|
+
|
|
176
|
+
const lift = await h.api(`/robots/building-maps/${mapId}/lifts/Lift%20A/sessions`, {
|
|
177
|
+
method: "POST", sid: sid.STAFF, body: { robotId: String(id.demoA), fromFloor: 1, toFloor: 2 },
|
|
178
|
+
});
|
|
179
|
+
assert.equal(lift.status, 201, JSON.stringify(lift.body));
|
|
180
|
+
const queue = await h.api(`/robots/building-maps/${mapId}/lifts`, { sid: sid.STAFF });
|
|
181
|
+
assert.equal(queue.status, 200);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("13. super admin edits and removes a robot at another client's site; it leaves the network list", async () => {
|
|
185
|
+
const edit = await h.api(`/robots/${id.scrubberB}`, {
|
|
186
|
+
method: "PATCH", sid: sid.STAFF, body: { name: "B scrubber (renamed)" },
|
|
187
|
+
});
|
|
188
|
+
assert.equal(edit.status, 200);
|
|
189
|
+
const remove = await h.api(`/robots/${id.scrubberB}`, { method: "DELETE", sid: sid.STAFF });
|
|
190
|
+
assert.equal(remove.status, 200);
|
|
191
|
+
const res = await list("?limit=50");
|
|
192
|
+
assert.equal(res.body.items.some((r) => r.name.startsWith("B scrubber")), false);
|
|
193
|
+
assert.equal(res.body.total, 3);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
async function seed(h) {
|
|
198
|
+
const now = new Date().toISOString();
|
|
199
|
+
const orgA = new ObjectId();
|
|
200
|
+
const orgB = new ObjectId();
|
|
201
|
+
const siteA = new ObjectId();
|
|
202
|
+
const siteB = new ObjectId();
|
|
203
|
+
const staffRole = new ObjectId();
|
|
204
|
+
const starRole = new ObjectId();
|
|
205
|
+
const partialRole = new ObjectId();
|
|
206
|
+
const managerRole = new ObjectId();
|
|
207
|
+
const namedRole = new ObjectId();
|
|
208
|
+
|
|
209
|
+
await h.db.collection("organizations").insertMany([
|
|
210
|
+
{ _id: orgA, name: "Net Client A", email: "neta@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
|
|
211
|
+
{ _id: orgB, name: "Net Client B", email: "netb@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
|
|
212
|
+
]);
|
|
213
|
+
await h.db.collection("sites").insertMany([
|
|
214
|
+
{ _id: siteA, name: "Net Site A", org: orgA, orgId: orgA, status: "active" },
|
|
215
|
+
{ _id: siteB, name: "Net Site B", org: orgB, orgId: orgB, status: "active" },
|
|
216
|
+
]);
|
|
217
|
+
await h.db.collection("roles").insertMany([
|
|
218
|
+
{ _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" },
|
|
219
|
+
{ _id: starRole, name: "Ops", type: "admin", permissions: ["*"], status: "active" },
|
|
220
|
+
{ _id: partialRole, name: "Promo desk", type: "admin", permissions: ["promo-codes:see-all-promo-codes"], status: "active" },
|
|
221
|
+
{ _id: managerRole, name: "Manager A", org: orgA, type: "organization", permissions: ["*"], status: "active" },
|
|
222
|
+
{ _id: namedRole, name: "Super Admin", org: orgA, type: "organization", permissions: ["*"], status: "active" },
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
const emails = [STAFF, STAR, PARTIAL, MANAGER, NAMED];
|
|
226
|
+
const users = await h.db.collection("users").insertMany(
|
|
227
|
+
emails.map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
|
|
228
|
+
);
|
|
229
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
230
|
+
await h.db.collection("users").updateMany({ email: { $in: emails } }, { $set: { password: hashed } });
|
|
231
|
+
const [staff, star, partial, manager, named] = [0, 1, 2, 3, 4].map((i) => users.insertedIds[i]);
|
|
232
|
+
|
|
233
|
+
await h.db.collection("members").insertMany([
|
|
234
|
+
{ user: staff, type: "admin", role: staffRole, status: "active" },
|
|
235
|
+
{ user: star, type: "admin", role: starRole, status: "active" },
|
|
236
|
+
{ user: partial, type: "admin", role: partialRole, status: "active" },
|
|
237
|
+
{ user: manager, org: orgA, type: "organization", role: managerRole, status: "active" },
|
|
238
|
+
{ user: named, org: orgA, type: "organization", role: namedRole, status: "active" },
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
const robot = (name, org, site, extra = {}) => ({
|
|
242
|
+
name,
|
|
243
|
+
appKey: "e2e-fake-key",
|
|
244
|
+
appToken: "e2e-fake-token",
|
|
245
|
+
createdBy: manager,
|
|
246
|
+
createdByName: "net-manager",
|
|
247
|
+
robotSn: "",
|
|
248
|
+
metadata: { orgId: org, siteId: site, administratorId: "" },
|
|
249
|
+
status: "",
|
|
250
|
+
createdAt: new Date(),
|
|
251
|
+
deletedAt: "",
|
|
252
|
+
...extra,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const res = await h.db.collection("robots").insertMany([
|
|
256
|
+
// written before `type` / `brand` existed, and before `deletedAt` was always set
|
|
257
|
+
(() => { const r = robot("A legacy", orgA, siteA); delete r.deletedAt; return r; })(),
|
|
258
|
+
robot("A demo 1", orgA, siteA, { type: "cleaning", brand: "demo", robotSn: "DEMO-0001" }),
|
|
259
|
+
robot("A removed", orgA, siteA, { type: "cleaning", brand: "ounitech", deletedAt: new Date() }),
|
|
260
|
+
robot("B scrubber", orgB, siteB, { type: "cleaning", brand: "ounitech" }),
|
|
261
|
+
robot("B guard", orgB, siteB, { type: "security", brand: "ounitech" }),
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
orgA, orgB, siteA, siteB,
|
|
266
|
+
demoA: res.insertedIds[1],
|
|
267
|
+
scrubberB: res.insertedIds[3],
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
NETWORK_PROJECTION,
|
|
6
|
+
escapeRegex,
|
|
7
|
+
networkListPipeline,
|
|
8
|
+
networkMatch,
|
|
9
|
+
networkSummaryPipeline,
|
|
10
|
+
shapeNetworkSummary,
|
|
11
|
+
} from "./.build/utils/robot-network.util.mjs";
|
|
12
|
+
|
|
13
|
+
/* --------------------------------------------------------------------------
|
|
14
|
+
* The Seven365 console's all-robots list (owner, 2026-09-23). The pipeline is
|
|
15
|
+
* where it can go wrong quietly: a removed robot listed, a credential
|
|
16
|
+
* projected, a filter on the default class that loses every row written before
|
|
17
|
+
* the field existed, or a search string treated as a pattern.
|
|
18
|
+
* ------------------------------------------------------------------------ */
|
|
19
|
+
|
|
20
|
+
const ORG = "64b7f0c2a1b2c3d4e5f60718";
|
|
21
|
+
const SITE = "64b7f0c2a1b2c3d4e5f60719";
|
|
22
|
+
|
|
23
|
+
test("a removed robot never lists: the soft-delete test is always first", () => {
|
|
24
|
+
const [first] = networkListPipeline({});
|
|
25
|
+
assert.deepEqual(first.$match.deletedAt, { $in: ["", null] });
|
|
26
|
+
const [summaryFirst] = networkSummaryPipeline();
|
|
27
|
+
assert.deepEqual(summaryFirst.$match.deletedAt, { $in: ["", null] });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("the credentials can never leave: the projection is an allow-list without them", () => {
|
|
31
|
+
assert.equal("appKey" in NETWORK_PROJECTION, false);
|
|
32
|
+
assert.equal("appToken" in NETWORK_PROJECTION, false);
|
|
33
|
+
const facet = networkListPipeline({}).at(-1).$facet.items;
|
|
34
|
+
assert.deepEqual(facet.at(-1).$project, NETWORK_PROJECTION);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("client and site filters are ObjectIds on the indexed metadata fields", () => {
|
|
38
|
+
const m = networkMatch({ org: ORG, site: SITE });
|
|
39
|
+
assert.equal(String(m["metadata.orgId"]), ORG);
|
|
40
|
+
assert.equal(String(m["metadata.siteId"]), SITE);
|
|
41
|
+
assert.equal(typeof m["metadata.orgId"], "object");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("filtering on the DEFAULT class/brand still finds rows written before the field existed", () => {
|
|
45
|
+
assert.deepEqual(networkMatch({ type: "cleaning" }).type, { $in: ["cleaning", null, ""] });
|
|
46
|
+
assert.deepEqual(networkMatch({ brand: "ounitech" }).brand, { $in: ["ounitech", null, ""] });
|
|
47
|
+
// A non-default value is matched exactly.
|
|
48
|
+
assert.equal(networkMatch({ type: "security" }).type, "security");
|
|
49
|
+
assert.equal(networkMatch({ brand: "demo" }).brand, "demo");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a search string is data, not a pattern", () => {
|
|
53
|
+
assert.equal(escapeRegex("a.b*(c)"), String.raw`a\.b\*\(c\)`);
|
|
54
|
+
const search = networkListPipeline({ search: "Tower (1)" }).find((s) => s.$match?.$or);
|
|
55
|
+
assert.equal(search.$match.$or[0].name.$regex, String.raw`Tower \(1\)`);
|
|
56
|
+
// and the escaped pattern matches the literal text only
|
|
57
|
+
assert.ok(new RegExp(escapeRegex("a.b*(c)")).test("xa.b*(c)y"));
|
|
58
|
+
assert.equal(new RegExp(escapeRegex("a.b")).test("axb"), false);
|
|
59
|
+
// searches the joined client and site names too
|
|
60
|
+
const fields = search.$match.$or.map((c) => Object.keys(c)[0]);
|
|
61
|
+
assert.deepEqual(fields, ["name", "robotSn", "client.name", "site.name"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("paging is bounded and the default order groups by client then site", () => {
|
|
65
|
+
const p = networkListPipeline({ page: 3, limit: 500 });
|
|
66
|
+
const facet = p.at(-1).$facet.items;
|
|
67
|
+
assert.equal(facet[1].$limit, 100, "limit capped at 100");
|
|
68
|
+
assert.equal(facet[0].$skip, 200);
|
|
69
|
+
const sort = p.find((s) => s.$sort).$sort;
|
|
70
|
+
assert.deepEqual(Object.keys(sort), ["client.name", "site.name", "name", "_id"]);
|
|
71
|
+
const bySite = networkListPipeline({ sort: "site", order: "desc" }).find((s) => s.$sort).$sort;
|
|
72
|
+
assert.deepEqual(bySite, { "site.name": -1, _id: 1 });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("the summary never claims a fault count it did not measure", () => {
|
|
76
|
+
const s = shapeNetworkSummary({
|
|
77
|
+
total: [{ n: 5 }],
|
|
78
|
+
sites: [{ n: 2 }],
|
|
79
|
+
byType: [{ _id: "cleaning", count: 4 }, { _id: "security", count: 1 }],
|
|
80
|
+
byBrand: [{ _id: "demo", count: 3 }, { _id: "ounitech", count: 2 }],
|
|
81
|
+
byClient: [{ _id: ORG, name: "Client A", count: 5, sites: 2 }],
|
|
82
|
+
});
|
|
83
|
+
assert.equal(s.total, 5);
|
|
84
|
+
assert.equal(s.sites, 2);
|
|
85
|
+
assert.deepEqual(s.byType, [{ key: "cleaning", count: 4 }, { key: "security", count: 1 }]);
|
|
86
|
+
assert.equal(s.demo, 3);
|
|
87
|
+
assert.equal(s.byClient[0].name, "Client A");
|
|
88
|
+
assert.deepEqual(s.faults, { live: false, count: null });
|
|
89
|
+
// empty collection: zeros, not a crash
|
|
90
|
+
const empty = shapeNetworkSummary(undefined);
|
|
91
|
+
assert.equal(empty.total, 0);
|
|
92
|
+
assert.deepEqual(empty.byType.map((t) => t.count), [0, 0]);
|
|
93
|
+
});
|