@7365admin1/core 3.53.9 → 3.55.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 +36 -0
- package/dist/index.d.ts +246 -12
- package/dist/index.js +275 -30
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +269 -30
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/e2e/harness.mjs +9 -0
- package/test/e2e/notification-matrix.e2e.test.mjs +420 -0
- package/test/e2e/notification-preference.e2e.test.mjs +21 -8
- package/test/e2e/personal-emergency-chain-scope.e2e.test.mjs +373 -0
- package/test/e2e/safety-notification.e2e.test.mjs +1 -1
- package/test/notification-category.util.test.mjs +29 -11
- package/test/personal-emergency-chain.test.mjs +261 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ordering rules of a person's own emergency chain, run as the shipped
|
|
3
|
+
* module -- not a description of it.
|
|
4
|
+
*
|
|
5
|
+
* Two things decide whether "ring my wife, then my son, then my doctor" works
|
|
6
|
+
* at all, and both are enforced here rather than in a screen:
|
|
7
|
+
*
|
|
8
|
+
* 1. no two contacts may claim the same position -- otherwise "who is second"
|
|
9
|
+
* has two answers and the dialer picks one at random;
|
|
10
|
+
* 2. a chain that breaks rule 1 is refused BEFORE anything is written, which is
|
|
11
|
+
* what makes a reorder atomic. There is no partial order to clean up,
|
|
12
|
+
* because a rejected reorder never reaches the database at all.
|
|
13
|
+
*
|
|
14
|
+
* The E.164 rule is checked here too: a number that is not dialable from
|
|
15
|
+
* outside the country it was typed in is not an emergency contact.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here opens a socket or a database.
|
|
18
|
+
*/
|
|
19
|
+
import test from "node:test";
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { ObjectId } from "mongodb";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
MAX_PERSONAL_EMERGENCY_CONTACTS,
|
|
27
|
+
DEFAULT_RING_SECONDS,
|
|
28
|
+
MPersonalEmergencyChain,
|
|
29
|
+
emptyPersonalEmergencyChain,
|
|
30
|
+
schemaUpdatePersonalEmergencyChain,
|
|
31
|
+
} from "./.build/models/personal-emergency-chain.model.mjs";
|
|
32
|
+
|
|
33
|
+
const USER = new ObjectId().toString();
|
|
34
|
+
|
|
35
|
+
const contact = (over = {}) => ({
|
|
36
|
+
name: "Wife",
|
|
37
|
+
phone: "+6591234567",
|
|
38
|
+
relationship: "Spouse",
|
|
39
|
+
order: 1,
|
|
40
|
+
active: true,
|
|
41
|
+
...over,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const validate = (body) =>
|
|
45
|
+
schemaUpdatePersonalEmergencyChain.validate(body, { convert: true });
|
|
46
|
+
|
|
47
|
+
const chain = (contacts) => ({
|
|
48
|
+
user: USER,
|
|
49
|
+
contacts,
|
|
50
|
+
ringSeconds: DEFAULT_RING_SECONDS,
|
|
51
|
+
siteDirectoryFallback: true,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------- positions
|
|
55
|
+
|
|
56
|
+
test("a chain with distinct positions is accepted -- the positive control", () => {
|
|
57
|
+
const { error, value } = validate({
|
|
58
|
+
contacts: [
|
|
59
|
+
contact({ name: "Wife", order: 1 }),
|
|
60
|
+
contact({ name: "Son", phone: "+6598765432", order: 2 }),
|
|
61
|
+
contact({ name: "Dr Tan", phone: "+6567001122", order: 3 }),
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
assert.equal(error, undefined);
|
|
66
|
+
// A positive control that returned nothing would prove nothing.
|
|
67
|
+
assert.equal(value.contacts.length, 3);
|
|
68
|
+
assert.deepEqual(
|
|
69
|
+
value.contacts.map((c) => c.order),
|
|
70
|
+
[1, 2, 3],
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("two contacts cannot share a position", () => {
|
|
75
|
+
const { error } = validate({
|
|
76
|
+
contacts: [contact({ order: 2 }), contact({ name: "Son", order: 2 })],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
assert.notEqual(error, undefined);
|
|
80
|
+
assert.match(error.message, /same position/i);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("the model refuses a duplicate position even if validation is bypassed", () => {
|
|
84
|
+
// The repository calls the model, so this is the last gate before the write.
|
|
85
|
+
assert.throws(
|
|
86
|
+
() =>
|
|
87
|
+
MPersonalEmergencyChain(
|
|
88
|
+
chain([contact({ order: 1 }), contact({ name: "Son", order: 1 })]),
|
|
89
|
+
),
|
|
90
|
+
/same position/i,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a stored chain comes back in dialling order, whatever order it was sent in", () => {
|
|
95
|
+
const doc = MPersonalEmergencyChain(
|
|
96
|
+
chain([
|
|
97
|
+
contact({ name: "Dr Tan", phone: "+6567001122", order: 3 }),
|
|
98
|
+
contact({ name: "Wife", order: 1 }),
|
|
99
|
+
contact({ name: "Son", phone: "+6598765432", order: 2 }),
|
|
100
|
+
]),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
assert.deepEqual(
|
|
104
|
+
doc.contacts.map((c) => c.name),
|
|
105
|
+
["Wife", "Son", "Dr Tan"],
|
|
106
|
+
);
|
|
107
|
+
assert.deepEqual(
|
|
108
|
+
doc.contacts.map((c) => c.order),
|
|
109
|
+
[1, 2, 3],
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a reorder is a whole new chain, and an invalid one changes nothing", () => {
|
|
114
|
+
const wife = contact({ name: "Wife", order: 1 });
|
|
115
|
+
const son = contact({ name: "Son", phone: "+6598765432", order: 2 });
|
|
116
|
+
|
|
117
|
+
const before = MPersonalEmergencyChain(chain([wife, son]));
|
|
118
|
+
assert.deepEqual(
|
|
119
|
+
before.contacts.map((c) => c.name),
|
|
120
|
+
["Wife", "Son"],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Swapping them is valid and lands complete.
|
|
124
|
+
const after = MPersonalEmergencyChain(
|
|
125
|
+
chain([
|
|
126
|
+
contact({ name: "Wife", order: 2 }),
|
|
127
|
+
contact({ name: "Son", phone: "+6598765432", order: 1 }),
|
|
128
|
+
]),
|
|
129
|
+
);
|
|
130
|
+
assert.deepEqual(
|
|
131
|
+
after.contacts.map((c) => c.name),
|
|
132
|
+
["Son", "Wife"],
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// A half-typed reorder -- both at position 1 -- is refused outright, so the
|
|
136
|
+
// caller still holds the chain it had.
|
|
137
|
+
assert.throws(
|
|
138
|
+
() =>
|
|
139
|
+
MPersonalEmergencyChain(
|
|
140
|
+
chain([
|
|
141
|
+
contact({ name: "Wife", order: 1 }),
|
|
142
|
+
contact({ name: "Son", phone: "+6598765432", order: 1 }),
|
|
143
|
+
]),
|
|
144
|
+
),
|
|
145
|
+
/same position/i,
|
|
146
|
+
);
|
|
147
|
+
assert.deepEqual(
|
|
148
|
+
before.contacts.map((c) => c.name),
|
|
149
|
+
["Wife", "Son"],
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// -------------------------------------------------------------------- phone
|
|
154
|
+
|
|
155
|
+
test("a phone number must be dialable from anywhere", () => {
|
|
156
|
+
for (const phone of [
|
|
157
|
+
"91234567",
|
|
158
|
+
"+0591234567",
|
|
159
|
+
"6591234567",
|
|
160
|
+
"+65 9123 4567",
|
|
161
|
+
"",
|
|
162
|
+
"+",
|
|
163
|
+
]) {
|
|
164
|
+
const { error } = validate({ contacts: [contact({ phone })] });
|
|
165
|
+
assert.notEqual(error, undefined, JSON.stringify(phone) + " was accepted");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Positive control across three countries, so the rule is not "Singapore only".
|
|
169
|
+
for (const phone of ["+6591234567", "+442071234567", "+14155550123"]) {
|
|
170
|
+
const { error } = validate({ contacts: [contact({ phone })] });
|
|
171
|
+
assert.equal(error, undefined, phone + " was refused");
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ----------------------------------------------------------------- settings
|
|
176
|
+
|
|
177
|
+
test("chain settings have working defaults and real bounds", () => {
|
|
178
|
+
const { error, value } = validate({ contacts: [] });
|
|
179
|
+
|
|
180
|
+
assert.equal(error, undefined);
|
|
181
|
+
assert.equal(value.ringSeconds, DEFAULT_RING_SECONDS);
|
|
182
|
+
assert.equal(value.siteDirectoryFallback, true);
|
|
183
|
+
|
|
184
|
+
assert.notEqual(validate({ contacts: [], ringSeconds: 1 }).error, undefined);
|
|
185
|
+
assert.notEqual(validate({ contacts: [], ringSeconds: 600 }).error, undefined);
|
|
186
|
+
assert.equal(validate({ contacts: [], ringSeconds: 45 }).error, undefined);
|
|
187
|
+
|
|
188
|
+
assert.equal(
|
|
189
|
+
validate({ contacts: [], siteDirectoryFallback: false }).value
|
|
190
|
+
.siteDirectoryFallback,
|
|
191
|
+
false,
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("the chain is bounded, so a body cannot be padded into an expensive write", () => {
|
|
196
|
+
const many = Array.from(
|
|
197
|
+
{ length: MAX_PERSONAL_EMERGENCY_CONTACTS + 1 },
|
|
198
|
+
(_, i) => contact({ order: i + 1 }),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
assert.notEqual(validate({ contacts: many }).error, undefined);
|
|
202
|
+
assert.equal(
|
|
203
|
+
validate({ contacts: many.slice(0, MAX_PERSONAL_EMERGENCY_CONTACTS) }).error,
|
|
204
|
+
undefined,
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("a contact can be kept but taken out of the chain", () => {
|
|
209
|
+
const { error, value } = validate({
|
|
210
|
+
contacts: [contact({ order: 1, active: false })],
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
assert.equal(error, undefined);
|
|
214
|
+
assert.equal(value.contacts[0].active, false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("no user id is accepted from the body -- the URL and the gate decide that", () => {
|
|
218
|
+
const { error } = validate({ contacts: [], user: new ObjectId().toString() });
|
|
219
|
+
|
|
220
|
+
assert.notEqual(error, undefined, "a user id in the body was accepted");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ------------------------------------------------------------------- absent
|
|
224
|
+
|
|
225
|
+
test("somebody who has never saved a chain reads an empty one, not an error", () => {
|
|
226
|
+
const empty = emptyPersonalEmergencyChain(USER);
|
|
227
|
+
|
|
228
|
+
assert.deepEqual(empty.contacts, []);
|
|
229
|
+
assert.equal(empty.ringSeconds, DEFAULT_RING_SECONDS);
|
|
230
|
+
assert.equal(empty.siteDirectoryFallback, true);
|
|
231
|
+
assert.equal(empty.user, USER);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("nothing in the model names a telephony vendor", () => {
|
|
235
|
+
// The chain has to outlive whatever places the call. If a provider name ever
|
|
236
|
+
// appears here, the data model has been tied to one dialer.
|
|
237
|
+
const text = readFileSync(
|
|
238
|
+
fileURLToPath(
|
|
239
|
+
new URL(
|
|
240
|
+
"../src/models/personal-emergency-chain.model.ts",
|
|
241
|
+
import.meta.url,
|
|
242
|
+
),
|
|
243
|
+
),
|
|
244
|
+
"utf8",
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
for (const vendor of [
|
|
248
|
+
"twilio",
|
|
249
|
+
"vonage",
|
|
250
|
+
"nexmo",
|
|
251
|
+
"asterisk",
|
|
252
|
+
"plivo",
|
|
253
|
+
"sinch",
|
|
254
|
+
"telnyx",
|
|
255
|
+
]) {
|
|
256
|
+
assert.ok(
|
|
257
|
+
!new RegExp(vendor, "i").test(text),
|
|
258
|
+
vendor + " is named in the model",
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
});
|