@dszp/netsapiens-lib 0.6.0 → 0.8.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.
@@ -1,5 +1,5 @@
1
1
  /** Offline test for the domain inventory counter. pnpm test:inventory */
2
- import { countDomainInventory, listDomainInventory, itemsFor, itemLabel, destinationOf, usersByExt } from './inventory.js';
2
+ import { countDomainInventory, listDomainInventory, itemsFor, itemLabel, destinationOf, usersByExt, DEFAULT_DEVICE_SUFFIXES } from './inventory.js';
3
3
  import type { Rec, Snapshot } from './model.js';
4
4
 
5
5
  let pass = 0, fail = 0;
@@ -60,6 +60,7 @@ ok(inv.transcriptionEnabled === 2, 'yes and a provider name both count; no and a
60
60
  ok(inv.dids.total === 4, 'four phone numbers');
61
61
  ok(inv.dids.tollFree === 2, '800 and 833 are toll-free');
62
62
  ok(inv.dids.local === 2, 'and the rest are local');
63
+ ok(inv.dids.fax === 0 && inv.dids.all === 4, 'no fax-server hosts were supplied, so nothing is a fax line and all equals total');
63
64
  ok(inv.e911Addresses === 2, 'two address records');
64
65
  ok(inv.smsNumbers === 1, 'one SMS number');
65
66
  ok(inv.devices.total === 5, 'five devices across all extensions');
@@ -71,6 +72,7 @@ ok(inv.devices.byModel['(unknown)'] === 1, 'a device with no model is counted un
71
72
  const empty = countDomainInventory({ meta: { domain: 'empty.example' } } as Snapshot);
72
73
  ok(empty.extensions.total === 0, 'empty snapshot: no extensions');
73
74
  ok(empty.dids.total === 0 && empty.dids.tollFree === 0 && empty.dids.local === 0, 'empty snapshot: no numbers');
75
+ ok(empty.dids.fax === 0 && empty.dids.all === 0, 'empty snapshot: no fax lines either');
74
76
  ok(empty.devices.total === 0, 'empty snapshot: no devices');
75
77
  ok(empty.smsNumbers === 0 && empty.e911Addresses === 0, 'empty snapshot: no addresses and no SMS numbers');
76
78
 
@@ -96,9 +98,9 @@ ok(sysDev.devices.total === 0, 'a system user device is not counted');
96
98
  const e101 = d.extensions.find((x) => x.ext === '101')!;
97
99
  ok(
98
100
  JSON.stringify(e101.devices) === JSON.stringify([
99
- { name: '101a', model: 'Yealink T54W', teams: false },
100
- { name: '101b', model: 'Yealink T31P', teams: false },
101
- { name: '101c', model: '(unknown)', teams: false },
101
+ { name: '101a', model: 'Yealink T54W', teams: false, suffix: 'a', kind: '' },
102
+ { name: '101b', model: 'Yealink T31P', teams: false, suffix: 'b', kind: '' },
103
+ { name: '101c', model: '(unknown)', teams: false, suffix: 'c', kind: '' },
102
104
  ]),
103
105
  '[list] devices lists every device with its name, model and teams flag, in order',
104
106
  );
@@ -106,7 +108,7 @@ ok(sysDev.devices.total === 0, 'a system user device is not counted');
106
108
  ok(e103.teams === true, '[list] a device whose aor local part is <ext>t marks the extension Teams-connected');
107
109
  ok(e103.deviceCount === 0 && e103.deviceModels.length === 0, '[list] and that connector is not counted as a device');
108
110
  ok(
109
- JSON.stringify(e103.devices) === JSON.stringify([{ name: '103t', model: '', teams: true }]),
111
+ JSON.stringify(e103.devices) === JSON.stringify([{ name: '103t', model: '', teams: true, suffix: 't', kind: 'Teams' }]),
110
112
  '[list] devices includes the Teams connector with an empty model, even though deviceCount excludes it',
111
113
  );
112
114
  ok(d.extensions.find((x) => x.ext === '104')!.devices.length === 0, '[list] no devices means an empty devices list, not a throw');
@@ -141,6 +143,7 @@ ok(sysDev.devices.total === 0, 'a system user device is not counted');
141
143
  ok(c.extensions.withAnyDevice + c.extensions.withNoDevice === c.extensions.total, '[fold] the two presence leaves partition the total');
142
144
  ok(c.dids.total === d.dids.length, '[fold] dids.total equals the number list length');
143
145
  ok(c.dids.tollFree === d.dids.filter((n) => n.kind === 'tollFree').length, '[fold] dids.tollFree equals the toll-free items');
146
+ ok(c.dids.all === d.dids.length && c.dids.fax === d.dids.filter((n) => n.fax).length, '[fold] dids.all is the whole list and dids.fax the fax lines in it');
144
147
  ok(c.e911Addresses === d.e911Addresses.length && c.smsNumbers === d.smsNumbers.length, '[fold] address and SMS counts equal the lists');
145
148
  }
146
149
 
@@ -173,6 +176,129 @@ ok(sysDev.devices.total === 0, 'a system user device is not counted');
173
176
  ok(itemLabel(d.smsNumbers[0]!) === '13175550100', '[label] SMS is its number');
174
177
  }
175
178
 
179
+ // ── a device is named by `device`, and only falls back to `aor` ──────────────────────────────────────
180
+ // A LIVE /users/<ext>/devices record names the device in `device` and frequently carries no `aor` at all.
181
+ // Reading `aor` alone blanked every device name and — worse — broke the `<ext>t` Teams test, so a Teams
182
+ // connector read as a handset and inflated deviceCount, devices.total and byDeviceCount while
183
+ // teamsConnected read zero. That is the shape this block pins.
184
+ {
185
+ const live = {
186
+ meta: { domain: 'live.example' },
187
+ users: [
188
+ { user: '1001', 'user-scope': 'Basic User', 'service-code': '' },
189
+ { user: '1002', 'user-scope': 'Basic User', 'service-code': '' },
190
+ { user: '1003', 'user-scope': 'Basic User', 'service-code': '' },
191
+ ],
192
+ devicesByUser: {
193
+ // `device` only — the live shape.
194
+ '1001': [
195
+ { device: 'sip:1001a@live.example', 'device-models-model': 'Yealink T54W' },
196
+ { device: 'sip:1001t@live.example', 'device-models-model': 'Teams' },
197
+ ],
198
+ // BOTH, disagreeing: `device` wins, being the field the system names the device by.
199
+ '1002': [{ device: 'sip:1002a@live.example', aor: 'sip:wrong@live.example', 'device-models-model': 'Yealink T31P' }],
200
+ // NEITHER: the name is empty rather than guessed, and an empty name is not a `t`.
201
+ '1003': [{ 'device-models-model': 'Yealink T31P' }],
202
+ },
203
+ } as Snapshot;
204
+
205
+ const d = listDomainInventory(live);
206
+ const e1 = d.extensions.find((x) => x.ext === '1001')!;
207
+ ok(e1.devices[0]!.name === '1001a', '[device] a record with `device` and no `aor` is still named');
208
+ ok(e1.teams === true, '[device] and its <ext>t connector is detected — reading `aor` alone made this false on every live domain');
209
+ ok(e1.devices[1]!.teams === true && e1.devices[1]!.model === '', '[device] the connector row is marked and prints no model');
210
+ ok(e1.deviceCount === 1 && JSON.stringify(e1.deviceModels) === JSON.stringify(['Yealink T54W']),
211
+ '[device] so the connector is excluded from the handset count rather than inflating it');
212
+
213
+ const e2 = d.extensions.find((x) => x.ext === '1002')!;
214
+ ok(e2.devices[0]!.name === '1002a', '[device] where a record carries both, `device` wins');
215
+
216
+ const e3 = d.extensions.find((x) => x.ext === '1003')!;
217
+ ok(e3.devices[0]!.name === '', '[device] a record with neither field has an empty name, not "undefined"');
218
+ ok(e3.teams === false && e3.deviceCount === 1, '[device] and an unnamed device is a handset, never a connector');
219
+
220
+ const c = countDomainInventory(live);
221
+ ok(c.teamsConnected === 1, '[device] teamsConnected counts the one connector');
222
+ ok(c.devices.total === 3, '[device] and devices.total is the three handsets, the connector excluded');
223
+
224
+ // The `aor`-only fixture at the top of this file still behaves exactly as it did — the fallback is a
225
+ // fallback, not a replacement.
226
+ ok(listDomainInventory(snap).extensions.find((x) => x.ext === '103')!.teams === true,
227
+ '[device] a record carrying only `aor` is unchanged');
228
+ }
229
+
230
+ // ── fax lines ────────────────────────────────────────────────────────────────────────────────────────
231
+ // The portal's "Fax Server" treatment is an ordinary phone number whose dial rule hands it to a host:
232
+ // to-connection, plus dial-rule-translation-destination-host. There is no fax endpoint in the API and
233
+ // the ATA is not a device on any user, so the host is the only thing that says "fax line" — and the host
234
+ // belongs to the deployment, not to this library, which is why it arrives as an option.
235
+ {
236
+ const faxSnap = {
237
+ meta: { domain: 'fax.example' },
238
+ users: [{ user: '100', 'user-scope': 'Basic User', 'service-code': '', 'name-first-name': 'Ann', 'name-last-name': 'Lee' }],
239
+ phonenumbers: [
240
+ { phonenumber: '13175550100', 'dial-rule-translation-destination-user': '100' },
241
+ {
242
+ phonenumber: '13175550199',
243
+ 'dial-rule-application': 'to-connection',
244
+ 'dial-rule-translation-destination-host': '203.0.113.7',
245
+ 'dial-rule-description': 'Portal Created: Phonenumber -> FaxServer',
246
+ },
247
+ ],
248
+ } as Snapshot;
249
+ const HOSTS = { faxServerHosts: ['203.0.113.7'] };
250
+
251
+ const d = listDomainInventory(faxSnap, HOSTS);
252
+ const fx = d.dids[1]!;
253
+ ok(fx.fax === true, '[fax] a number whose destination host is the fax server is a fax line');
254
+ ok(d.dids[0]!.fax === false, '[fax] and the one routed to a user is not');
255
+ ok(fx.kind === 'local', '[fax] a fax line still has a kind — it is a local or toll-free number like any other');
256
+ ok(fx.destination === 'to fax server', '[fax] its destination reads "to fax server" — never the bare host');
257
+ ok(!fx.destination.includes('203.0.113.7'), '[fax] the fax server address does not reach a reader');
258
+ ok(fx.description === 'Portal Created: Phonenumber -> FaxServer', '[fax] the portal note is carried as written; it is not what the test reads');
259
+
260
+ const c = countDomainInventory(faxSnap, HOSTS);
261
+ ok(c.dids.fax === 1, '[fax] dids.fax counts it');
262
+ ok(c.dids.total === 1 && c.dids.local === 1 && c.dids.tollFree === 0, '[fax] and dids.total/local/tollFree leave it out — it is billed as a fax line, not as a DID');
263
+ ok(c.dids.all === 2, '[fax] dids.all is every number, fax lines included');
264
+ ok(c.dids.total + c.dids.fax === c.dids.all, '[fax] total + fax partitions all');
265
+
266
+ const keys = (p: string) => (itemsFor(d, p) ?? []).map((x) => x.key).join(',');
267
+ ok(keys('dids.fax') === 'did:13175550199', '[fax] itemsFor dids.fax is the fax lines');
268
+ ok(keys('dids.total') === 'did:13175550100', '[fax] itemsFor dids.total excludes them, exactly as the count does');
269
+ ok(keys('dids.local') === 'did:13175550100', '[fax] and so does dids.local');
270
+ ok(keys('dids.all') === 'did:13175550100,did:13175550199', '[fax] dids.all is everything');
271
+ ok(itemLabel(fx) === '13175550199', '[fax] the label is unchanged — a fax line is named by its number like any other');
272
+
273
+ // The SAME snapshot with no hosts supplied. A library that guessed a fax server would be wrong on
274
+ // every deployment but the one it was written against, so it guesses nothing.
275
+ const bare = listDomainInventory(faxSnap);
276
+ ok(bare.dids[1]!.fax === false, '[fax] no hosts supplied, nothing is a fax line');
277
+ ok(bare.dids[1]!.destination === 'to connection', '[fax] and the destination falls back to the application');
278
+ const bareCounts = countDomainInventory(faxSnap);
279
+ ok(bareCounts.dids.fax === 0 && bareCounts.dids.total === 2 && bareCounts.dids.local === 2,
280
+ '[fax] the fax line is counted as the local DID it otherwise looks like');
281
+ ok(bareCounts.dids.all === bareCounts.dids.total, '[fax] all equals total when nothing is a fax line');
282
+ ok(countDomainInventory(faxSnap, { faxServerHosts: [] }).dids.fax === 0, '[fax] an empty host list is the same as none');
283
+ ok(countDomainInventory(faxSnap, { faxServerHosts: ['', ' '] }).dids.fax === 0, '[fax] and so is a list of blanks — a blank host must not match a number with no host');
284
+
285
+ // Host matching is trimmed and case-insensitive on BOTH sides: a hostname is not case-sensitive, and
286
+ // a comma-separated setting arrives with spaces around its entries.
287
+ const named = {
288
+ meta: { domain: 'fax2.example' },
289
+ phonenumbers: [{ phonenumber: '13175550198', 'dial-rule-application': 'to-connection', 'dial-rule-translation-destination-host': ' Fax.Example.COM ' }],
290
+ } as Snapshot;
291
+ ok(countDomainInventory(named, { faxServerHosts: [' fax.example.com '] }).dids.fax === 1, '[fax] host matching trims and lower-cases both sides');
292
+ ok(countDomainInventory(named, { faxServerHosts: ['other.example.com'] }).dids.fax === 0, '[fax] a host that is not on the list is not a fax line');
293
+
294
+ // The description is a note the portal writes and an operator can edit, so it is never the test.
295
+ const noteOnly = {
296
+ meta: { domain: 'fax3.example' },
297
+ phonenumbers: [{ phonenumber: '13175550197', 'dial-rule-application': 'to-connection', 'dial-rule-description': 'Portal Created: Phonenumber -> FaxServer' }],
298
+ } as Snapshot;
299
+ ok(countDomainInventory(noteOnly, { faxServerHosts: ['203.0.113.7'] }).dids.fax === 0, '[fax] the "-> FaxServer" description alone does not make a fax line');
300
+ }
301
+
176
302
  // ── blank identity fields never collide onto one key ─────────────────────────────────────────────────
177
303
  {
178
304
  const blank = {
@@ -251,6 +377,101 @@ ok(sysDev.devices.total === 0, 'a system user device is not counted');
251
377
  );
252
378
  ok(destinationOf({ 'dial-rule-application': 'to-connection' }, byExt) === 'to connection', '[destinationOf] no destination, application only — to- stripped so it does not read "to to-connection"');
253
379
  ok(destinationOf({}, byExt) === '', '[destinationOf] neither field set is empty, not "to undefined"');
380
+ const faxRule: Rec = { 'dial-rule-application': 'to-connection', 'dial-rule-translation-destination-host': '203.0.113.7' };
381
+ ok(destinationOf(faxRule, byExt) === 'to connection', '[destinationOf] with no host list a fax rule is just a connection');
382
+ ok(destinationOf(faxRule, byExt, ['203.0.113.7']) === 'to fax server', '[destinationOf] with the host on the list it reads "to fax server"');
383
+ ok(
384
+ destinationOf({ ...faxRule, 'dial-rule-translation-destination-user': '100' }, byExt, ['203.0.113.7']) === 'to fax server',
385
+ '[destinationOf] the fax test wins over a destination user, so no reader is shown a bare IP',
386
+ );
387
+ }
388
+
389
+ // ── the device-suffix legend ────────────────────────────────────────────────────────────────────────
390
+ // A device's SUFFIX is what its name carries after the extension number, and the legend says what that
391
+ // suffix IS. The default is the three NetSapiens ships; a supplied legend replaces it wholesale, which
392
+ // is how a deployment without TeamMate turns Teams detection off and how one with its own app names it.
393
+ {
394
+ const sufSnap = (devices: Rec[]): Snapshot => ({
395
+ meta: { domain: 'suffix.example' },
396
+ users: [{ user: '1001', 'user-scope': 'Basic User', 'service-code': '' }],
397
+ devicesByUser: { '1001': devices },
398
+ } as Snapshot);
399
+ const dev = (name: string, model = ''): Rec => ({ device: `sip:${name}@suffix.example`, 'device-models-model': model });
400
+ const one = (snapshot: Snapshot, opts?: Parameters<typeof listDomainInventory>[1]) =>
401
+ listDomainInventory(snapshot, opts).extensions[0]!;
402
+
403
+ // The default legend, one device at a time.
404
+ {
405
+ const x = one(sufSnap([dev('1001wp'), dev('1001m'), dev('1001t'), dev('1001b'), dev('1001', 'Yealink T54W')]));
406
+ const by = (name: string) => x.devices.find((d) => d.name === name)!;
407
+ ok(by('1001wp').suffix === 'wp' && by('1001wp').kind === 'SNAPmobile Web', '[suffix] wp is SNAPmobile Web');
408
+ ok(by('1001m').suffix === 'm' && by('1001m').kind === 'SNAPmobile', '[suffix] m is SNAPmobile');
409
+ ok(by('1001t').suffix === 't' && by('1001t').kind === 'Teams', '[suffix] t is Teams');
410
+ ok(by('1001t').teams === true, '[suffix] and the t entry carries teams: true');
411
+ ok(by('1001b').suffix === 'b' && by('1001b').kind === '', '[suffix] a suffix the legend does not carry has no kind, rather than a guessed one');
412
+ ok(by('1001b').teams === false, '[suffix] and it is a handset');
413
+ ok(by('1001').suffix === '' && by('1001').kind === '', '[suffix] a bare extension name has no suffix and no kind');
414
+ ok(x.teams === true, '[suffix] the extension is Teams-connected');
415
+ ok(x.deviceCount === 4 && x.devices.length === 5, '[suffix] the connector is in the display list and out of the count');
416
+ ok(x.anyDevice === true, '[suffix] anyDevice is unchanged');
417
+ }
418
+
419
+ // A legend WITHOUT `t` — a deployment with no TeamMate. Teams detection is off entirely, so the
420
+ // device that used to be a connector is a handset like any other and is counted as one.
421
+ {
422
+ const x = one(sufSnap([dev('1001t', 'Yealink T54W')]), { deviceSuffixes: { wp: { label: 'SNAPmobile Web' } } });
423
+ ok(x.teams === false, '[suffix] a legend without t means no Teams connectors at all');
424
+ ok(x.devices[0]!.teams === false && x.devices[0]!.kind === '', '[suffix] that device is a plain handset with no kind');
425
+ ok(x.deviceCount === 1 && x.deviceModels[0] === 'Yealink T54W', '[suffix] and it is counted, model and all');
426
+ ok(x.devices[0]!.model === 'Yealink T54W', '[suffix] a handset keeps its model — the blank is only for a connector');
427
+ }
428
+
429
+ // A supplied legend REPLACES the default rather than merging into it.
430
+ {
431
+ const x = one(sufSnap([dev('1001r'), dev('1001wp')]), { deviceSuffixes: { r: { label: 'Acme App' } } });
432
+ ok(x.devices.find((d) => d.name === '1001r')!.kind === 'Acme App', '[suffix] a supplied suffix is labelled from the supplied legend');
433
+ ok(x.devices.find((d) => d.name === '1001wp')!.kind === '', '[suffix] and wp is unknown again, because the supplied legend replaced the default wholesale');
434
+ }
435
+
436
+ // Case-insensitive on both sides of the comparison.
437
+ {
438
+ const x = one(sufSnap([dev('1001WP'), dev('1001T')]));
439
+ ok(x.devices[0]!.suffix === 'wp' && x.devices[0]!.kind === 'SNAPmobile Web', '[suffix] an upper-case device suffix matches the legend');
440
+ ok(x.devices[1]!.teams === true, '[suffix] including the Teams test');
441
+ const y = one(sufSnap([dev('1001r')]), { deviceSuffixes: { R: { label: 'Acme App' } } });
442
+ ok(y.devices[0]!.kind === 'Acme App', '[suffix] and an upper-case LEGEND key matches a lower-case device suffix');
443
+ }
444
+
445
+ // A name that does not start with the extension has no suffix — `sales1` is a differently-named
446
+ // device, not a device of kind `sales1`.
447
+ {
448
+ const x = one(sufSnap([dev('sales1')]));
449
+ ok(x.devices[0]!.suffix === '' && x.devices[0]!.kind === '', '[suffix] a name that does not start with the extension has no suffix');
450
+ }
451
+
452
+ // A user with NO extension number. The suffix is the whole of the Teams test now, and a suffix needs
453
+ // an extension to come after — so a device NAMED a bare `t` is a handset, counted as one.
454
+ {
455
+ const x = listDomainInventory({
456
+ meta: { domain: 'suffix.example' },
457
+ users: [{ user: '', 'user-scope': 'Basic User', 'service-code': '' }],
458
+ devicesByUser: { '': [dev('t', 'Yealink T54W')] },
459
+ } as Snapshot).extensions[0]!;
460
+ ok(x.devices[0]!.suffix === '' && x.devices[0]!.kind === '', '[suffix] a device named a bare t on a blank extension has no suffix');
461
+ ok(x.teams === false && x.devices[0]!.teams === false, '[suffix] so it is not a Teams connector');
462
+ ok(x.deviceCount === 1 && x.deviceModels[0] === 'Yealink T54W', '[suffix] and it is counted as the handset it reads as');
463
+ }
464
+
465
+ // The legend is keyed by a device-name suffix off a snapshot, so an inherited Object name is a
466
+ // reachable key. On a plain object `legend['constructor']` answers a function rather than undefined.
467
+ for (const evil of ['constructor', 'toString', 'hasOwnProperty']) {
468
+ const x = one(sufSnap([dev(`1001${evil}`)]));
469
+ ok(x.devices[0]!.kind === '' && x.devices[0]!.teams === false,
470
+ `[suffix] a suffix named ${evil} reads as unknown rather than picking up an inherited value`);
471
+ }
472
+
473
+ ok(DEFAULT_DEVICE_SUFFIXES.t!.teams === true && DEFAULT_DEVICE_SUFFIXES.wp!.teams === undefined,
474
+ '[suffix] the exported default marks only t as the Teams connector');
254
475
  }
255
476
 
256
477
  console.log(`\n${pass} passed, ${fail} failed`);