@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/core",
3
3
  "license": "MIT",
4
- "version": "3.88.0",
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
  },
@@ -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
+ });