@7365admin1/module-hygiene 4.29.1-staging.12 → 4.29.1-staging.13

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/module-hygiene",
3
3
  "license": "MIT",
4
- "version": "4.29.1-staging.12",
4
+ "version": "4.29.1-staging.13",
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",
14
14
  "release": "yarn run build && changeset publish",
15
15
  "lint": "tsc",
16
- "test": "tsup src/utils/hygiene-dashboard-metrics.util.ts src/utils/hygiene-checkout-decision.util.ts --format esm --out-dir test/.build --no-dts --silent && node --test \"test/*.test.mjs\""
16
+ "test": "tsup src/utils/hygiene-dashboard-metrics.util.ts src/utils/hygiene-checkout-decision.util.ts src/utils/completion-photo.util.ts --format esm --out-dir test/.build --no-dts --silent && node --test \"test/*.test.mjs\""
17
17
  },
18
18
  "devDependencies": {
19
19
  "@changesets/cli": "^2.26.0",
@@ -1,160 +1,204 @@
1
1
  /*
2
- * A PHOTO IS REQUIRED TO COMPLETE A TASK.
2
+ * A PHOTO IS REQUIRED TO COMPLETE A TASK — and only to COMPLETE one.
3
3
  *
4
4
  * Owner requirement, 2026-09-10: "once task was completed they required to take
5
5
  * photo before completing the task ... all the services that has this module
6
6
  * must be the same process."
7
7
  *
8
- * This endpoint is where every service and every client meets. The five field
9
- * apps M&E, Cleaning, Landscape, Pest, Pool all PATCH
10
- * `/api/hygiene-area-checklist/id/:id/set/:set/units/:unit/:decision` with a
11
- * `serviceType`, and the web checklist posts here too. Enforcing it here is what
12
- * makes "the same process" true, rather than five client guards that drift.
8
+ * The first version of this rule sat in the Joi schema and demanded a photo on
9
+ * every approve. A task in this product is a SET, not a unit: both clients tick
10
+ * units one at a time and open the photographs dialog on the tick that finishes
11
+ * the set. So that version broke every intermediate tick on both platforms
12
+ * the owner hit it on the web M&E checklist, and `meareaunits.tsx` makes the
13
+ * same call with `attachment: []`.
13
14
  *
14
- * The rule is exercised against the SHIPPED Joi schema, lifted out of the
15
- * controller — not a re-typed copy, so a change to the real validation fails
16
- * this file.
15
+ * This exercises the SHIPPED decision function, compiled from source by the
16
+ * test script — not a re-typed copy.
17
17
  */
18
18
  import test from "node:test";
19
19
  import assert from "node:assert/strict";
20
- import { readFileSync } from "node:fs";
21
- import Joi from "joi";
22
-
23
- const SRC = readFileSync(
24
- new URL("../src/controllers/hygiene-area-checklist.controller.ts", import.meta.url),
25
- "utf8",
26
- );
27
-
28
- /** The validation object from `updateAreaChecklistUnits`, evaluated for real. */
29
- function schema() {
30
- const fn = SRC.slice(SRC.indexOf("async function updateAreaChecklistUnits("));
31
- const start = fn.indexOf("const validation = Joi.object({");
32
- assert.notEqual(start, -1, "the validation block is gone");
33
-
34
- // Walk braces from the opening `{` of Joi.object( ... ) to its match.
35
- const open = fn.indexOf("{", start + "const validation = Joi.object(".length);
36
- let depth = 0;
37
- let end = -1;
38
- for (let i = open; i < fn.length; i++) {
39
- if (fn[i] === "{") depth++;
40
- else if (fn[i] === "}") {
41
- depth--;
42
- if (depth === 0) {
43
- end = i;
44
- break;
45
- }
46
- }
47
- }
48
- assert.notEqual(end, -1, "could not read the validation block");
49
-
50
- const body = fn
51
- .slice(open, end + 1)
52
- .replace(/\/\*[\s\S]*?\*\//g, "")
53
- .replace(/\/\/[^\n]*/g, "");
54
- return new Function("Joi", `return Joi.object(${body});`)(Joi);
55
- }
56
-
57
- const base = {
58
- id: "6923d6664150ca6a69b9f2b2",
59
- set: 1,
60
- unit: "69251faf8c8936c9e6ebf095",
61
- completedBy: "6925a31630abdbb211ecd9a8",
62
- remarks: "done",
20
+ import {
21
+ COMPLETION_PHOTO_MESSAGE,
22
+ readCompletion,
23
+ requiresCompletionPhoto,
24
+ } from "./.build/completion-photo.util.mjs";
25
+
26
+ /** Approving a unit that still has un-actioned siblings — the common tick. */
27
+ const midSet = {
28
+ decision: "approve",
29
+ completesSet: false,
30
+ hasEvidence: false,
63
31
  };
64
32
 
65
- const validate = (payload) => schema().validate(payload);
33
+ /** Approving the unit that finishes the set — the act evidence is for. */
34
+ const completing = {
35
+ decision: "approve",
36
+ completesSet: true,
37
+ hasEvidence: false,
38
+ };
39
+
40
+ /* ── the regression the owner reported ─────────────────────────────────── */
41
+
42
+ test("ticking a unit mid-set needs NO photo", () => {
43
+ // This is what broke: the web posts it with nothing attached, and
44
+ // `meareaunits.tsx` posts it with `attachment: []`.
45
+ assert.equal(requiresCompletionPhoto(midSet), false);
46
+ assert.equal(requiresCompletionPhoto({ ...midSet, attachment: [] }), false);
47
+ assert.equal(
48
+ requiresCompletionPhoto({ ...midSet, attachment: undefined }),
49
+ false
50
+ );
51
+ });
66
52
 
67
- /* ── completing requires evidence ──────────────────────────────────────── */
53
+ /* ── completing still requires evidence ────────────────────────────────── */
68
54
 
69
- test("approving WITHOUT a photo is refused", () => {
70
- const { error } = validate({ ...base, decision: "approve", approve: true });
71
- assert.ok(error, "a task was completed with no photo");
72
- assert.match(error.message, /photo is required/i);
55
+ test("completing the set WITHOUT a photo is refused", () => {
56
+ assert.equal(requiresCompletionPhoto(completing), true);
73
57
  });
74
58
 
75
- test("an EMPTY attachment array is refused too", () => {
76
- // This is the shape a client sends when its uploader failed — it satisfies
77
- // `.optional()` while carrying no evidence at all.
78
- const { error } = validate({
79
- ...base,
80
- decision: "approve",
81
- approve: true,
82
- attachment: [],
83
- });
84
- assert.ok(error, "an empty attachment list was accepted as evidence");
85
- assert.match(error.message, /photo is required/i);
59
+ test("an EMPTY attachment array is not evidence", () => {
60
+ // The shape a client sends when its uploader failed.
61
+ assert.equal(
62
+ requiresCompletionPhoto({ ...completing, attachment: [] }),
63
+ true
64
+ );
86
65
  });
87
66
 
88
67
  test("a BLANK id is not a photo", () => {
89
- const { error } = validate({
90
- ...base,
91
- decision: "approve",
92
- approve: true,
93
- attachment: [""],
94
- });
95
- assert.ok(error, "a blank attachment id counted as a photo");
68
+ assert.equal(
69
+ requiresCompletionPhoto({ ...completing, attachment: ["", " "] }),
70
+ true
71
+ );
96
72
  });
97
73
 
98
- test("approving WITH a photo is accepted", () => {
99
- const { error } = validate({
100
- ...base,
101
- decision: "approve",
102
- approve: true,
103
- attachment: ["68f05fbe0c11062938dadad9"],
104
- });
105
- assert.equal(error, undefined, error?.message);
74
+ test("completing WITH a photo is allowed", () => {
75
+ assert.equal(
76
+ requiresCompletionPhoto({
77
+ ...completing,
78
+ attachment: ["68f05fbe0c11062938dadad9"],
79
+ }),
80
+ false
81
+ );
106
82
  });
107
83
 
108
- test("MULTIPLE photos are accepted — that is the point", () => {
109
- const { error } = validate({
110
- ...base,
111
- decision: "approve",
112
- approve: true,
113
- attachment: ["a1", "b2", "c3", "d4"],
114
- });
115
- assert.equal(error, undefined, error?.message);
84
+ test("MULTIPLE photos are allowed — that is the point", () => {
85
+ assert.equal(
86
+ requiresCompletionPhoto({ ...completing, attachment: ["a1", "b2", "c3"] }),
87
+ false
88
+ );
89
+ });
90
+
91
+ /* ── the web posts a set's approves in a loop ──────────────────────────── */
92
+
93
+ test("a set already carrying a photo on another unit is not blocked", () => {
94
+ /*
95
+ * `ManageChecklistMain` loops over the set's approved items and puts the ids
96
+ * on only ONE of them, so the approve that completes the set is not reliably
97
+ * the one holding the evidence. Asking only "does THIS request have a photo"
98
+ * would reject a set that is in fact fully documented.
99
+ */
100
+ assert.equal(
101
+ requiresCompletionPhoto({ ...completing, hasEvidence: true }),
102
+ false
103
+ );
104
+ assert.equal(
105
+ requiresCompletionPhoto({
106
+ ...completing,
107
+ hasEvidence: true,
108
+ attachment: [],
109
+ }),
110
+ false
111
+ );
116
112
  });
117
113
 
118
114
  /* ── rejecting must NOT be blocked ─────────────────────────────────────── */
119
115
 
120
- test("REJECTING without a photo is still allowed", () => {
116
+ test("REJECTING is never blocked, even completing the set", () => {
121
117
  /*
122
118
  * A reject is the technician reporting a problem. Demanding a photograph
123
119
  * before somebody can report one would stop them reporting it.
124
120
  */
125
- const { error } = validate({ ...base, decision: "reject", reject: true });
126
- assert.equal(error, undefined, error?.message);
121
+ assert.equal(
122
+ requiresCompletionPhoto({
123
+ decision: "reject",
124
+ completesSet: true,
125
+ hasEvidence: false,
126
+ }),
127
+ false
128
+ );
129
+ });
130
+
131
+ /* ── which approve finishes the set: ONE definition with the clients ───── */
132
+
133
+ test("an approve finishes the set when every other unit is approved or rejected", () => {
134
+ const units = [
135
+ { unit: "u1", approve: true, reject: false, status: "completed" },
136
+ { unit: "u2", approve: false, reject: true, status: "completed" },
137
+ { unit: "u3", approve: false, reject: false, status: "open" },
138
+ ];
139
+ assert.equal(readCompletion(units, "u3").completesSet, true);
140
+ // Intermediate: another unit is still untouched.
141
+ assert.equal(readCompletion(units, "u1").completesSet, false);
127
142
  });
128
143
 
129
- test("rejecting WITH photos is still allowed", () => {
130
- const { error } = validate({
131
- ...base,
132
- decision: "reject",
133
- reject: true,
134
- attachment: ["a1", "b2"],
144
+ test("actioned means approve/reject, NOT status — status at rest is open/closed", () => {
145
+ /*
146
+ * Staging holds units that are `closed` while approved or rejected. The units
147
+ * list (`isFullyActioned`) and both clients count those as done, so the photo
148
+ * rule must too, or the server and the screen disagree about the last tick.
149
+ */
150
+ const units = [
151
+ { unit: "u1", approve: true, reject: false, status: "closed" },
152
+ { unit: "u2", approve: false, reject: false, status: "open" },
153
+ ];
154
+ assert.equal(readCompletion(units, "u2").completesSet, true);
155
+
156
+ // And a status alone, with neither flag, is not an action.
157
+ const statusOnly = [
158
+ { unit: "u1", approve: false, reject: false, status: "completed" },
159
+ { unit: "u2", approve: false, reject: false, status: "open" },
160
+ ];
161
+ assert.equal(readCompletion(statusOnly, "u2").completesSet, false);
162
+ });
163
+
164
+ test("a one-unit set: its only tick finishes it; an empty set finishes nothing", () => {
165
+ assert.equal(readCompletion([{ unit: "u1" }], "u1").completesSet, true);
166
+ assert.deepEqual(readCompletion([], "u1"), {
167
+ completesSet: false,
168
+ hasEvidence: false,
135
169
  });
136
- assert.equal(error, undefined, error?.message);
170
+ });
171
+
172
+ test("ids compare as strings (ObjectId vs the route param)", () => {
173
+ const oid = { toString: () => "68f05fbe0c11062938dadad9" };
174
+ const units = [
175
+ { unit: oid, approve: false, reject: false },
176
+ { unit: "u2", approve: true, reject: false },
177
+ ];
178
+ assert.equal(
179
+ readCompletion(units, "68f05fbe0c11062938dadad9").completesSet,
180
+ true
181
+ );
182
+ });
183
+
184
+ test("evidence counts only OTHER units' photos", () => {
185
+ const units = [
186
+ { unit: "u1", approve: false, reject: true, attachment: ["f1"] },
187
+ { unit: "u2", approve: false, reject: false, attachment: [] },
188
+ ];
189
+ assert.equal(readCompletion(units, "u2").hasEvidence, true);
190
+ assert.equal(readCompletion(units, "u1").hasEvidence, false);
137
191
  });
138
192
 
139
193
  /* ── controls ──────────────────────────────────────────────────────────── */
140
194
 
141
- test("CONTROL: the other required fields still are", () => {
142
- // Without this, the assertions above could pass against a schema that
143
- // validates nothing.
144
- for (const missing of ["id", "unit", "completedBy"]) {
145
- const payload = {
146
- ...base,
147
- decision: "approve",
148
- approve: true,
149
- attachment: ["a1"],
150
- };
151
- delete payload[missing];
152
- assert.ok(validate(payload).error, `${missing} is no longer required`);
153
- }
195
+ test("CONTROL: the rule can actually say yes", () => {
196
+ // Without this, every assertion above could be passing against a function
197
+ // that returns false unconditionally.
198
+ assert.equal(requiresCompletionPhoto(completing), true);
154
199
  });
155
200
 
156
201
  test("CONTROL: the message names the action a person must take", () => {
157
202
  // "Invalid payload" tells a technician standing in a plant room nothing.
158
- const { error } = validate({ ...base, decision: "approve", approve: true });
159
- assert.match(error.message, /take or upload at least one photo/i);
203
+ assert.match(COMPLETION_PHOTO_MESSAGE, /take or upload at least one photo/i);
160
204
  });