Use topology/links and anynetlinks filters with eq/in operators so store WAN follow-ups return real peer tunnels instead of unscoped dumps. Co-authored-by: Cursor <cursoragent@cursor.com>
1097 lines
41 KiB
JavaScript
1097 lines
41 KiB
JavaScript
// tests/sdwanEnrichment.test.js
|
||
//
|
||
// Split into two flavors:
|
||
// 1) Unit tests on the exported parse* / buildLinkRows helpers —
|
||
// these are pure functions, no HTTP.
|
||
// 2) Integration test of `collectSdwanForStore` end-to-end using a
|
||
// fake Prisma HTTP server. This exercises the site lookup +
|
||
// element fetch + parallel metric composition + `errors[]`
|
||
// preservation on partial failure.
|
||
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import http from 'node:http';
|
||
|
||
import {
|
||
collectSdwanForStore,
|
||
parseHealthscore,
|
||
buildLinkRows,
|
||
parseAlarms,
|
||
summarizeAppSeries,
|
||
resolveVoiceAppConfig,
|
||
annotateTunnelsWithAlarms,
|
||
_resetVoiceAppDeprecationFlag,
|
||
} from '../services/enrichment/sdwanEnrichment.js';
|
||
import { _resetSitesCache } from '../integrations/paloalto/sites.js';
|
||
import { _resetPrismaAuthCache } from '../integrations/paloalto/client.js';
|
||
|
||
// ─── Unit tests: parseHealthscore ───────────────────────────────────
|
||
|
||
test('parseHealthscore: nil / malformed inputs → null', () => {
|
||
assert.equal(parseHealthscore(null), null);
|
||
assert.equal(parseHealthscore({}), null);
|
||
assert.equal(parseHealthscore({ metrics: [] }), null);
|
||
assert.equal(parseHealthscore({ metrics: 'nope' }), null);
|
||
});
|
||
|
||
test('parseHealthscore: LIVE v2.6 shape — metrics[].series[].data[].datapoints[].value (WINNER)', () => {
|
||
// The actual live shape verified 2026-07-09 on tenant. Note the
|
||
// wrapper: data[0] is {statistics: 'max', datapoints: [...]}, NOT
|
||
// the array of samples itself.
|
||
const resp = {
|
||
metrics: [{
|
||
series: [{
|
||
name: 'Healthscore',
|
||
unit: 'gauge',
|
||
interval: '5min',
|
||
view: 'summary',
|
||
data: [{
|
||
statistics: 'max',
|
||
datapoints: [
|
||
{ time: '2026-07-09T12:45:00Z', value: 100 },
|
||
{ time: '2026-07-09T12:50:00Z', value: 98 },
|
||
{ time: '2026-07-09T12:55:00Z', value: 92 },
|
||
{ time: '2026-07-09T13:00:00Z', value: 87 },
|
||
],
|
||
}],
|
||
}],
|
||
}],
|
||
};
|
||
const hs = parseHealthscore(resp, '16158173173100144');
|
||
assert.equal(hs.value, 87, 'must pick the LAST datapoint in the wrapper');
|
||
assert.deepEqual(hs.breakdown, {});
|
||
});
|
||
|
||
test('parseHealthscore: LIVE v2.6 shape — empty datapoints → falls through to next shape', () => {
|
||
const resp = {
|
||
metrics: [{
|
||
series: [{
|
||
name: 'Healthscore',
|
||
data: [{ statistics: 'max', datapoints: [] }],
|
||
}],
|
||
}],
|
||
};
|
||
assert.equal(parseHealthscore(resp, 'target-site'), null,
|
||
'empty datapoints must not silently return a stale/undefined value');
|
||
});
|
||
|
||
test('parseHealthscore: FALLBACK v2.6 shape — metrics[0].sites[].healthscore', () => {
|
||
const resp = {
|
||
metrics: [{
|
||
name: 'Healthscore',
|
||
sites: [
|
||
{ site_id: 'other-1', healthscore: 55 },
|
||
{ site_id: 'target-site', healthscore: 92 },
|
||
],
|
||
}],
|
||
};
|
||
const hs = parseHealthscore(resp, 'target-site');
|
||
assert.equal(hs.value, 92, 'must pick the site entry whose site_id matches');
|
||
});
|
||
|
||
test('parseHealthscore: LIVE v2.6 shape — nested under .data.score', () => {
|
||
const resp = {
|
||
metrics: [{
|
||
name: 'Healthscore',
|
||
sites: [
|
||
{ site_id: 'target-site', data: { score: 87 } },
|
||
],
|
||
}],
|
||
};
|
||
const hs = parseHealthscore(resp, 'target-site');
|
||
assert.equal(hs.value, 87);
|
||
});
|
||
|
||
test('parseHealthscore: LIVE v2.6 shape — fallback picks any 0-100 numeric field', () => {
|
||
// Defensive: if Prisma renames the value field between versions,
|
||
// parseHealthscore falls through to the first numeric in 0-100 range.
|
||
const resp = {
|
||
metrics: [{
|
||
name: 'Healthscore',
|
||
sites: [{ site_id: 'target-site', mystery_key: 73, unrelated_id: 999 }],
|
||
}],
|
||
};
|
||
const hs = parseHealthscore(resp, 'target-site');
|
||
assert.equal(hs.value, 73, 'unrelated_id (999) skipped because outside 0-100');
|
||
});
|
||
|
||
test('parseHealthscore: LEGACY shape — metrics[0].series[].data[].value (pan.dev-documented)', () => {
|
||
// Fallback support for other tenants that DO return the pan.dev
|
||
// shape. This tenant doesn't, but we don't want to break others.
|
||
const resp = {
|
||
metrics: [{
|
||
name: 'Healthscore',
|
||
series: [
|
||
{ view: { site: 'wrong-1' }, data: [{ value: 40 }] },
|
||
{ view: { site: 'target-site' }, data: [{ value: 91 }] },
|
||
],
|
||
}],
|
||
};
|
||
const hs = parseHealthscore(resp, 'target-site');
|
||
assert.equal(hs.value, 91);
|
||
});
|
||
|
||
test('parseHealthscore: no siteId → first available (backwards-compat)', () => {
|
||
const resp = {
|
||
metrics: [{
|
||
name: 'Healthscore',
|
||
sites: [
|
||
{ site_id: 'first', healthscore: 77 },
|
||
{ site_id: 'second', healthscore: 33 },
|
||
],
|
||
}],
|
||
};
|
||
const hs = parseHealthscore(resp);
|
||
assert.equal(hs.value, 77);
|
||
});
|
||
|
||
// ─── Unit tests: buildLinkRows ──────────────────────────────────────
|
||
//
|
||
// The waninterface config list is the source of truth for the row
|
||
// set. LQM metrics are layered in as overlays keyed by
|
||
// `view.waninterface`. `up` comes from the waninterface's admin
|
||
// state (the closest we have to a runtime up/down signal until we
|
||
// wire /waninterfaces/{id}/status per element in a later phase).
|
||
|
||
/**
|
||
* Build a live-shape LQM response (metrics[].sites[].paths[].data.<key>).
|
||
* This is the shape verified against a real tenant on 2026-07-09 via
|
||
* /scripts/prismaProbe.js — not the pan.dev-documented shape.
|
||
*/
|
||
function lqmLiveResp({ siteId = 'site-A', pathId, dataKey, value, completeness = 100 }) {
|
||
return {
|
||
metrics: [{
|
||
name: 'LqmLatencyPointMetric',
|
||
unit: 'milliseconds',
|
||
sites: [{
|
||
site_id: siteId,
|
||
paths: [{
|
||
path_id: pathId,
|
||
remote_site_id: '0',
|
||
data: {
|
||
sample_completeness: completeness,
|
||
[dataKey]: value,
|
||
},
|
||
}],
|
||
}],
|
||
}],
|
||
};
|
||
}
|
||
|
||
// Legacy shape (pan.dev-documented) — kept as a fallback test target
|
||
// so we notice if a future tenant returns this variant.
|
||
function lqmLegacySeries(waninterfaceId, points) {
|
||
return { view: { waninterface: waninterfaceId }, data: points };
|
||
}
|
||
|
||
test('buildLinkRows: LIVE shape — metrics[].sites[].paths[].data.<metricKey>', () => {
|
||
const rows = buildLinkRows({
|
||
wanInterfaces: [
|
||
{ id: 'wi-mpls', name: 'MPLS Circuit', adminUp: true, usedFor: 'primary' },
|
||
{ id: 'wi-bb', name: 'Broadband', adminUp: true, usedFor: 'secondary' },
|
||
],
|
||
metricResponses: {
|
||
latency: lqmLiveResp({ pathId: 'wi-mpls', dataKey: 'rtt_latency', value: 42.3 }),
|
||
jitter: lqmLiveResp({ pathId: 'wi-mpls', dataKey: 'rtt_jitter', value: 8.7 }),
|
||
loss: lqmLiveResp({ pathId: 'wi-mpls', dataKey: 'pkt_loss_pct', value: 0.4 }),
|
||
mos: lqmLiveResp({ pathId: 'wi-mpls', dataKey: 'mos', value: 4.42 }),
|
||
},
|
||
});
|
||
assert.equal(rows.length, 2);
|
||
const mpls = rows.find((r) => r.interfaceId === 'wi-mpls');
|
||
assert.equal(mpls.interfaceName, 'MPLS Circuit');
|
||
assert.equal(mpls.up, true);
|
||
assert.equal(mpls.latencyMs, 42.3);
|
||
assert.equal(mpls.jitterMs, 8.7);
|
||
assert.equal(mpls.lossPct, 0.4);
|
||
assert.equal(mpls.mos, 4.42);
|
||
assert.equal(mpls.transportType, 'primary');
|
||
assert.equal(mpls.sampleCompleteness, 100, 'quality marker preserved for diagnostics');
|
||
|
||
const bb = rows.find((r) => r.interfaceId === 'wi-bb');
|
||
assert.equal(bb.up, true);
|
||
assert.equal(bb.latencyMs, null, 'broadband had no metric samples');
|
||
});
|
||
|
||
test('buildLinkRows: LIVE shape — matches paths by path_id (not view.path)', () => {
|
||
// Regression: initial parser looked for series[].view.path, missing
|
||
// the fact that Prisma's live tenant returns a completely different
|
||
// metrics[].sites[].paths[].path_id shape. Verified live 2026-07-09.
|
||
const rows = buildLinkRows({
|
||
wanInterfaces: [{ id: '16158173176610209', name: 'Inet1', adminUp: null, usedFor: null }],
|
||
metricResponses: {
|
||
latency: lqmLiveResp({
|
||
siteId: '16158173173100144',
|
||
pathId: '16158173176610209',
|
||
dataKey: 'rtt_latency',
|
||
value: 22.0121008,
|
||
}),
|
||
},
|
||
});
|
||
assert.equal(rows.length, 1);
|
||
assert.equal(rows[0].latencyMs, 22, 'rounded to 1 decimal');
|
||
});
|
||
|
||
test('buildLinkRows: LIVE shape — loss is directional (max of downlink + uplink)', () => {
|
||
// Prisma returns packet loss as two separate keys — one per
|
||
// direction — and the fallback scanner would pick whichever
|
||
// came first, silently under-reporting asymmetric loss patterns.
|
||
// We must fold them via max(). Verified live 2026-07-09 via
|
||
// `try-shapes lqm-loss` which returned keys:
|
||
// data.downlink_pkt_loss_avg and data.uplink_pkt_loss_avg
|
||
const rows = buildLinkRows({
|
||
wanInterfaces: [{ id: 'wi-1', name: 'w1', adminUp: true, usedFor: 'primary' }],
|
||
metricResponses: {
|
||
loss: {
|
||
metrics: [{
|
||
name: 'LqmPktLossPointMetric',
|
||
sites: [{
|
||
site_id: 'site-A',
|
||
paths: [{
|
||
path_id: 'wi-1',
|
||
data: {
|
||
sample_completeness: 100,
|
||
downlink_pkt_loss_avg: 0.0,
|
||
uplink_pkt_loss_avg: 2.5,
|
||
},
|
||
}],
|
||
}],
|
||
}],
|
||
},
|
||
},
|
||
});
|
||
assert.equal(rows.length, 1);
|
||
assert.equal(rows[0].lossPct, 2.5,
|
||
'must report the WORSE direction (2.5%) — reporting 0 would hide upstream loss');
|
||
});
|
||
|
||
test('buildLinkRows: LIVE shape — MOS is directional (MIN of downlink + uplink avg)', () => {
|
||
// Prisma returns MOS as 6 keys per path:
|
||
// downlink_mos_{avg,min,max} + uplink_mos_{avg,min,max}
|
||
// Lower MOS = worse audio, so if downlink is 4.5 (great) but
|
||
// uplink is 3.0 (barely usable), the call sounds bad and we must
|
||
// surface 3.0. Picking any single key (like the fallback did)
|
||
// would hide the problem. Verified live via `try-shapes lqm-mos`
|
||
// 2026-07-09.
|
||
const rows = buildLinkRows({
|
||
wanInterfaces: [{ id: 'wi-1', name: 'w1', adminUp: true, usedFor: 'primary' }],
|
||
metricResponses: {
|
||
mos: {
|
||
metrics: [{
|
||
name: 'LqmMosPointMetric',
|
||
sites: [{
|
||
site_id: 'site-A',
|
||
paths: [{
|
||
path_id: 'wi-1',
|
||
data: {
|
||
sample_completeness: 100,
|
||
downlink_mos_avg: 4.5,
|
||
downlink_mos_min: 4.2,
|
||
downlink_mos_max: 4.6,
|
||
uplink_mos_avg: 3.0,
|
||
uplink_mos_min: 2.7,
|
||
uplink_mos_max: 3.3,
|
||
},
|
||
}],
|
||
}],
|
||
}],
|
||
},
|
||
},
|
||
});
|
||
assert.equal(rows.length, 1);
|
||
assert.equal(rows[0].mos, 3.0,
|
||
'must report the WORSE direction avg (3.0) — reporting downlink 4.5 would hide bad-uplink calls');
|
||
});
|
||
|
||
test('buildLinkRows: LIVE shape — MOS with symmetric high values → picks the min', () => {
|
||
const rows = buildLinkRows({
|
||
wanInterfaces: [{ id: 'wi-1', name: 'w1', adminUp: true, usedFor: 'primary' }],
|
||
metricResponses: {
|
||
mos: {
|
||
metrics: [{
|
||
name: 'LqmMosPointMetric',
|
||
sites: [{
|
||
site_id: 'site-A',
|
||
paths: [{
|
||
path_id: 'wi-1',
|
||
data: {
|
||
sample_completeness: 100,
|
||
downlink_mos_avg: 4.39,
|
||
uplink_mos_avg: 4.42,
|
||
},
|
||
}],
|
||
}],
|
||
}],
|
||
},
|
||
},
|
||
});
|
||
assert.equal(rows[0].mos, 4.39,
|
||
'both directions healthy → still take the worse (lower) direction');
|
||
});
|
||
|
||
test('buildLinkRows: LIVE shape — loss with symmetric zero on both directions → 0 (not null)', () => {
|
||
const rows = buildLinkRows({
|
||
wanInterfaces: [{ id: 'wi-1', name: 'w1', adminUp: true, usedFor: 'primary' }],
|
||
metricResponses: {
|
||
loss: {
|
||
metrics: [{
|
||
name: 'LqmPktLossPointMetric',
|
||
sites: [{
|
||
site_id: 'site-A',
|
||
paths: [{
|
||
path_id: 'wi-1',
|
||
data: {
|
||
sample_completeness: 100,
|
||
downlink_pkt_loss_avg: 0,
|
||
uplink_pkt_loss_avg: 0,
|
||
},
|
||
}],
|
||
}],
|
||
}],
|
||
},
|
||
},
|
||
});
|
||
assert.equal(rows[0].lossPct, 0, 'zero loss must render as 0, NOT null');
|
||
});
|
||
|
||
test('buildLinkRows: LIVE shape — extractLqmValue fallback on unknown data key', () => {
|
||
// Defensive: if Prisma renames a data key between tenant versions,
|
||
// extractLqmValue falls back to the first numeric non-completeness
|
||
// field. Simulate that by using an unknown key name.
|
||
const rows = buildLinkRows({
|
||
wanInterfaces: [{ id: 'wi-1', name: 'w1', adminUp: true, usedFor: 'primary' }],
|
||
metricResponses: {
|
||
latency: {
|
||
metrics: [{
|
||
name: 'LqmLatencyPointMetric',
|
||
sites: [{
|
||
site_id: 'site-A',
|
||
paths: [{ path_id: 'wi-1', data: { sample_completeness: 100, mystery_new_key: 55 } }],
|
||
}],
|
||
}],
|
||
},
|
||
},
|
||
});
|
||
assert.equal(rows.length, 1);
|
||
assert.equal(rows[0].latencyMs, 55, 'fallback picked the first numeric non-completeness field');
|
||
});
|
||
|
||
test('buildLinkRows: LEGACY shape (pan.dev) — metrics[].series[].data[].value with view.path', () => {
|
||
// Fallback shape support — should keep working for any tenant that
|
||
// returns the pan.dev-documented response.
|
||
const rows = buildLinkRows({
|
||
wanInterfaces: [{ id: 'wi-mpls', name: 'MPLS', adminUp: true, usedFor: 'primary' }],
|
||
metricResponses: {
|
||
latency: { metrics: [{ series: [{ view: { path: 'wi-mpls' }, data: [{ value: 25 }] }] }] },
|
||
},
|
||
});
|
||
assert.equal(rows.length, 1);
|
||
assert.equal(rows[0].latencyMs, 25);
|
||
});
|
||
|
||
test('buildLinkRows: LEGACY shape — last non-null sample wins', () => {
|
||
const rows = buildLinkRows({
|
||
wanInterfaces: [{ id: 'wi-1', name: 'w1', adminUp: true, usedFor: 'primary' }],
|
||
metricResponses: {
|
||
latency: { metrics: [{ series: [
|
||
lqmLegacySeries('wi-1', [{ value: 10 }, { value: null }, { value: 20 }, { value: null }]),
|
||
]}]},
|
||
},
|
||
});
|
||
assert.equal(rows.length, 1);
|
||
assert.equal(rows[0].latencyMs, 20);
|
||
});
|
||
|
||
test('buildLinkRows: LQM samples for unknown path_id are dropped', () => {
|
||
const rows = buildLinkRows({
|
||
wanInterfaces: [{ id: 'wi-1', name: 'w1', adminUp: true, usedFor: 'primary' }],
|
||
metricResponses: {
|
||
latency: lqmLiveResp({ pathId: 'wi-orphan', dataKey: 'rtt_latency', value: 99 }),
|
||
},
|
||
});
|
||
assert.equal(rows.length, 1);
|
||
assert.equal(rows[0].interfaceId, 'wi-1');
|
||
assert.equal(rows[0].latencyMs, null, 'no sample matched the configured wan interface');
|
||
});
|
||
|
||
test('buildLinkRows: no waninterfaces → empty list even with LQM data', () => {
|
||
const rows = buildLinkRows({
|
||
wanInterfaces: [],
|
||
metricResponses: {
|
||
latency: lqmLiveResp({ pathId: 'wi-1', dataKey: 'rtt_latency', value: 10 }),
|
||
},
|
||
});
|
||
assert.deepEqual(rows, []);
|
||
});
|
||
|
||
test('buildLinkRows: runtime operationalUp preferred over adminUp', () => {
|
||
const rows = buildLinkRows({
|
||
wanInterfaces: [
|
||
{ id: 'wi-1', name: 'Primary', adminUp: true, usedFor: 'primary' },
|
||
],
|
||
statusByInterfaceId: new Map([
|
||
['wi-1', { operationalUp: false, adminUp: true, elementId: 'el-1' }],
|
||
]),
|
||
metricResponses: {},
|
||
});
|
||
assert.equal(rows[0].up, false);
|
||
assert.equal(rows[0].operationalUp, false);
|
||
assert.equal(rows[0].adminUp, true);
|
||
assert.equal(rows[0].elementId, 'el-1');
|
||
});
|
||
|
||
test('annotateTunnelsWithAlarms: marks tunnels cited in alarm vpnLinkIds', () => {
|
||
const tunnels = annotateTunnelsWithAlarms(
|
||
[{ id: 'vpn-link-2', up: true }, { id: 'vpn-link-1', up: true }],
|
||
{ samples: [{ vpnLinkIds: ['vpn-link-2'] }] },
|
||
);
|
||
assert.equal(tunnels[0].recentAlarm, true);
|
||
assert.equal(tunnels[1].recentAlarm, false);
|
||
});
|
||
|
||
test('buildLinkRows: admin-down waninterface reports up:false', () => {
|
||
const rows = buildLinkRows({
|
||
wanInterfaces: [{ id: 'wi-lte', name: 'LTE', adminUp: false, usedFor: 'backup' }],
|
||
metricResponses: {},
|
||
});
|
||
assert.equal(rows.length, 1);
|
||
assert.equal(rows[0].up, false);
|
||
});
|
||
|
||
test('buildLinkRows: adminUp:null but WITH LQM samples → up:true (data-flowing == up)', () => {
|
||
// Regression: on the live tenant, admin_up isn't exposed by the
|
||
// waninterface config API — every row starts with up=null. But if
|
||
// Prisma returns LQM samples for a path, that path is provably
|
||
// carrying traffic. Since traffic == up, we upgrade the row.
|
||
// Otherwise the WAN Link State check flags every path as unknown
|
||
// even when circuits are visibly healthy.
|
||
const rows = buildLinkRows({
|
||
wanInterfaces: [{ id: 'wi-x', name: 'Inet1', adminUp: null, usedFor: 'primary' }],
|
||
metricResponses: {
|
||
latency: lqmLiveResp({ pathId: 'wi-x', dataKey: 'rtt_latency', value: 22.3 }),
|
||
},
|
||
});
|
||
assert.equal(rows.length, 1);
|
||
assert.equal(rows[0].up, true, 'LQM samples prove the path is carrying traffic');
|
||
assert.equal(rows[0].latencyMs, 22.3);
|
||
});
|
||
|
||
test('buildLinkRows: adminUp:null WITHOUT LQM samples → up:null (still unknown)', () => {
|
||
// Complement of the previous test: if we have NEITHER admin_up
|
||
// NOR LQM samples, we genuinely don't know the state and must
|
||
// NOT lie about it.
|
||
const rows = buildLinkRows({
|
||
wanInterfaces: [{ id: 'wi-x', name: 'Inet1', adminUp: null, usedFor: 'primary' }],
|
||
metricResponses: {},
|
||
});
|
||
assert.equal(rows.length, 1);
|
||
assert.equal(rows[0].up, null, 'null (unknown) is meaningfully different from false (admin-down)');
|
||
});
|
||
|
||
test('buildLinkRows: adminUp:false → up:false even when LQM samples arrive (admin-disabled wins)', () => {
|
||
// Safety: an admin-disabled circuit is DOWN regardless of what
|
||
// Prisma monitoring says. Never upgrade explicit adminUp=false.
|
||
const rows = buildLinkRows({
|
||
wanInterfaces: [{ id: 'wi-lte', name: 'LTE', adminUp: false, usedFor: 'backup' }],
|
||
metricResponses: {
|
||
latency: lqmLiveResp({ pathId: 'wi-lte', dataKey: 'rtt_latency', value: 45 }),
|
||
},
|
||
});
|
||
assert.equal(rows.length, 1);
|
||
assert.equal(rows[0].up, false, 'admin-disabled must remain down even with monitor samples');
|
||
assert.equal(rows[0].latencyMs, 45, 'samples still recorded for diagnostics');
|
||
});
|
||
|
||
// ─── Unit tests: parseAlarms ────────────────────────────────────────
|
||
|
||
test('parseAlarms: nil → empty counts', () => {
|
||
const a = parseAlarms(null);
|
||
assert.deepEqual(a.last1h, { critical: 0, major: 0, minor: 0 });
|
||
assert.deepEqual(a.samples, []);
|
||
});
|
||
|
||
test('parseAlarms: counts by severity, keeps 5 most-recent samples', () => {
|
||
const resp = {
|
||
items: [
|
||
{ severity: 'critical', code: 'C1', info: 'boom', time: '2025-01-01T10:00:00Z' },
|
||
{ severity: 'major', code: 'M1', info: 'wobble', time: '2025-01-01T09:00:00Z' },
|
||
{ severity: 'major', code: 'M2', info: 'wobble2', time: '2025-01-01T08:00:00Z' },
|
||
{ severity: 'minor', code: 'm1', info: 'meh', time: '2025-01-01T07:00:00Z' },
|
||
{ severity: 'minor', code: 'm2', info: 'meh2', time: '2025-01-01T06:00:00Z' },
|
||
{ severity: 'minor', code: 'm3', info: 'meh3', time: '2025-01-01T05:00:00Z' },
|
||
],
|
||
};
|
||
const a = parseAlarms(resp);
|
||
assert.equal(a.last1h.critical, 1);
|
||
assert.equal(a.last1h.major, 2);
|
||
assert.equal(a.last1h.minor, 3);
|
||
assert.equal(a.samples.length, 5);
|
||
assert.equal(a.samples[0].code, 'C1', 'newest first');
|
||
});
|
||
|
||
test('parseAlarms: filters out cleared alarms (events/query returns both open + cleared)', () => {
|
||
const resp = {
|
||
items: [
|
||
{ severity: 'critical', code: 'STILL_OPEN', info: 'x', time: 't1' },
|
||
{ severity: 'critical', code: 'ALREADY_FIXED', info: 'y', time: 't2', cleared: true },
|
||
{ severity: 'major', code: 'ALSO_CLEARED', info: 'z', time: 't3', cleared: true },
|
||
],
|
||
};
|
||
const a = parseAlarms(resp);
|
||
assert.equal(a.last1h.critical, 1, 'only the still-open critical is counted');
|
||
assert.equal(a.last1h.major, 0);
|
||
assert.equal(a.samples.length, 1);
|
||
assert.equal(a.samples[0].code, 'STILL_OPEN');
|
||
});
|
||
|
||
test('parseAlarms: local site filter drops events whose site_id does not match', () => {
|
||
// Regression: Prisma's events/query sometimes silently ignores our
|
||
// `query.site` filter, in which case we get tenant-wide events. If
|
||
// we counted those as this-site alarms we'd report wildly inflated
|
||
// counts (e.g. 20 major for a small store). Local site match here
|
||
// is the safety net.
|
||
const resp = {
|
||
items: [
|
||
{ severity: 'critical', code: 'OUR_ALARM', site_id: 'target-site', time: 't1' },
|
||
{ severity: 'major', code: 'OTHER_STORE', site_id: 'other-site-1', time: 't2' },
|
||
{ severity: 'major', code: 'ALSO_OTHER', site_id: 'other-site-2', time: 't3' },
|
||
// No site_id — tenant-scoped event, kept
|
||
{ severity: 'minor', code: 'TENANT_WIDE', time: 't4' },
|
||
],
|
||
};
|
||
const a = parseAlarms(resp, 'target-site');
|
||
assert.equal(a.last1h.critical, 1);
|
||
assert.equal(a.last1h.major, 0, 'other-store alarms must NOT count toward this site');
|
||
assert.equal(a.last1h.minor, 1, 'events with no site_id are kept (tenant-scoped)');
|
||
assert.equal(a._diag.rawEventCount, 4);
|
||
assert.equal(a._diag.droppedForSiteMismatch, 2);
|
||
});
|
||
|
||
test('parseAlarms: no siteId argument → no local scoping (backwards-compat)', () => {
|
||
const resp = {
|
||
items: [
|
||
{ severity: 'critical', code: 'A', site_id: 'anything', time: 't1' },
|
||
{ severity: 'major', code: 'B', site_id: 'something-else', time: 't2' },
|
||
],
|
||
};
|
||
const a = parseAlarms(resp);
|
||
assert.equal(a.last1h.critical, 1);
|
||
assert.equal(a.last1h.major, 1, 'no siteId → do not drop by site');
|
||
assert.equal(a._diag.droppedForSiteMismatch, 0);
|
||
});
|
||
|
||
test('parseAlarms: nested info object → JSON-stringified (not "[object Object]")', () => {
|
||
// Live Prisma events regularly carry `info` as a nested object,
|
||
// e.g. { vpn_link_id: '...' }. If we let String() coerce this we
|
||
// dump [object Object] into chat. Flatten to JSON instead.
|
||
const resp = {
|
||
items: [
|
||
{ severity: 'major', code: 'ANYNET_DOWN', info: { vpn_link_id: 'link-1' }, time: 't' },
|
||
],
|
||
};
|
||
const a = parseAlarms(resp);
|
||
assert.equal(a.samples[0].message.startsWith('{'), true,
|
||
'nested object should be JSON-stringified for renderer safety');
|
||
assert.match(a.samples[0].message, /vpn_link_id/);
|
||
});
|
||
|
||
// ─── Integration: collectSdwanForStore end-to-end ───────────────────
|
||
|
||
async function makeFakePrisma(handlers) {
|
||
const server = http.createServer((req, res) => {
|
||
let body = '';
|
||
req.on('data', (c) => (body += c));
|
||
req.on('end', () => {
|
||
if (req.url === '/oauth2/access_token') {
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ access_token: 't', expires_in: 900 }));
|
||
return;
|
||
}
|
||
// Mandatory SASE unified SD-WAN session priming call.
|
||
if (req.url === '/sdwan/v2.1/api/profile' && req.method === 'GET') {
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ id: 'stub-profile' }));
|
||
return;
|
||
}
|
||
// Path-only fallback so tests don't have to encode ?limit=1000
|
||
// etc. in their handler keys.
|
||
const pathOnly = (req.url || '').split('?')[0];
|
||
const h = handlers[`${req.method} ${req.url}`] || handlers[`${req.method} ${pathOnly}`];
|
||
if (h) {
|
||
const parsed = body ? JSON.parse(body) : null;
|
||
const r = h({ req, body: parsed });
|
||
res.writeHead(r.status || 200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify(r.body || {}));
|
||
return;
|
||
}
|
||
res.writeHead(404);
|
||
res.end();
|
||
});
|
||
});
|
||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||
const { port } = server.address();
|
||
return { baseUrl: `http://127.0.0.1:${port}`, close: () => new Promise((r) => server.close(r)) };
|
||
}
|
||
|
||
function setupSaseEnv(baseUrl) {
|
||
process.env.PRISMA_AUTH_MODE = 'sase';
|
||
process.env.PRISMA_SASE_BASE_URL = baseUrl;
|
||
process.env.PRISMA_AUTH_URL = `${baseUrl}/oauth2/access_token`;
|
||
process.env.PRISMA_CLIENT_ID = 'id';
|
||
process.env.PRISMA_CLIENT_SECRET = 'secret';
|
||
process.env.PRISMA_TSG_ID = 'tsg';
|
||
}
|
||
function clearEnv() {
|
||
['PRISMA_AUTH_MODE','PRISMA_SASE_BASE_URL','PRISMA_AUTH_URL',
|
||
'PRISMA_CLIENT_ID','PRISMA_CLIENT_SECRET','PRISMA_TSG_ID',
|
||
'PRISMA_LEGACY_BASE_URL','PRISMA_EMAIL','PRISMA_PASSWORD']
|
||
.forEach((k) => delete process.env[k]);
|
||
}
|
||
|
||
test('collectSdwanForStore: no site → short-circuits with site:null', async () => {
|
||
_resetSitesCache(); _resetPrismaAuthCache();
|
||
const fake = await makeFakePrisma({
|
||
'GET /sdwan/v4.13/api/sites': () => ({
|
||
body: { items: [{ id: 's', name: 'CG99999' }] },
|
||
}),
|
||
});
|
||
setupSaseEnv(fake.baseUrl);
|
||
try {
|
||
const data = await collectSdwanForStore(782);
|
||
assert.equal(data.site, null);
|
||
assert.deepEqual(data.elements, []);
|
||
assert.deepEqual(data.links, []);
|
||
assert.equal(data.healthscore, null);
|
||
assert.equal(data.errors.length, 0);
|
||
assert.equal(data.storeNum, '782');
|
||
} finally {
|
||
await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
test('collectSdwanForStore: happy path composes site+elements+waninterfaces+LQM', async () => {
|
||
_resetSitesCache(); _resetPrismaAuthCache();
|
||
const fake = await makeFakePrisma({
|
||
'GET /sdwan/v4.13/api/sites': () => ({
|
||
body: { items: [{ id: 'site-A', name: 'CG00782', description: 'store 782' }] },
|
||
}),
|
||
'GET /sdwan/v3.1/api/elements': () => ({
|
||
body: { items: [{ id: 'el-1', name: 'ION-A', connected: true, site_id: 'site-A' }] },
|
||
}),
|
||
'GET /sdwan/v2.10/api/sites/site-A/waninterfaces': () => ({
|
||
body: { items: [
|
||
{ id: 'wi-mpls', name: 'MPLS Circuit', admin_up: true, used_for: 'primary' },
|
||
{ id: 'wi-bb', name: 'Broadband', admin_up: true, used_for: 'secondary' },
|
||
]},
|
||
}),
|
||
'POST /sdwan/monitor/v2.6/api/monitor/metrics': () => ({
|
||
// LIVE v2.6 healthscore response shape verified 2026-07-09.
|
||
// The response is already server-side scoped to filter.site,
|
||
// so no siteId filtering needed by parseHealthscore.
|
||
body: {
|
||
metrics: [{
|
||
series: [{
|
||
name: 'Healthscore',
|
||
unit: 'gauge',
|
||
interval: '5min',
|
||
view: 'summary',
|
||
data: [{
|
||
statistics: 'max',
|
||
datapoints: [
|
||
{ time: '2026-07-09T12:45:00Z', value: 88 },
|
||
{ time: '2026-07-09T13:00:00Z', value: 92 },
|
||
],
|
||
}],
|
||
}],
|
||
}],
|
||
},
|
||
}),
|
||
'POST /sdwan/monitor/v2.0/api/monitor/lqm_point_metrics': ({ body }) => {
|
||
const name = body?.metrics?.[0]?.name;
|
||
// Live-tenant data shapes per metric (verified 2026-07-09):
|
||
// latency/jitter/mos → single scalar under RTT/aggregate key
|
||
// loss → TWO keys, directional. buildLinkRows
|
||
// folds via max(downlink, uplink).
|
||
const dataFor = {
|
||
LqmLatencyPointMetric: { rtt_latency: 30 },
|
||
LqmJitterPointMetric: { rtt_jitter: 5 },
|
||
LqmPktLossPointMetric: { downlink_pkt_loss_avg: 0.05, uplink_pkt_loss_avg: 0.1 },
|
||
// MOS is directional × 3 stats. buildLinkRows takes
|
||
// min(downlink_mos_avg, uplink_mos_avg) — the worst average
|
||
// direction — so with downlink=4.35 + uplink=4.3 the row
|
||
// should end up at 4.3.
|
||
LqmMosPointMetric: {
|
||
downlink_mos_avg: 4.35,
|
||
downlink_mos_min: 4.2,
|
||
downlink_mos_max: 4.4,
|
||
uplink_mos_avg: 4.3,
|
||
uplink_mos_min: 4.1,
|
||
uplink_mos_max: 4.4,
|
||
},
|
||
};
|
||
return {
|
||
body: {
|
||
metrics: [{
|
||
name,
|
||
unit: body?.metrics?.[0]?.unit,
|
||
sites: [{
|
||
site_id: 'site-A',
|
||
paths: [{
|
||
path_id: 'wi-mpls',
|
||
remote_site_id: '0',
|
||
data: { sample_completeness: 100, ...(dataFor[name] || {}) },
|
||
}],
|
||
}],
|
||
}],
|
||
},
|
||
};
|
||
},
|
||
'POST /sdwan/v3.7/api/events/query': () => ({
|
||
body: { items: [] },
|
||
}),
|
||
});
|
||
setupSaseEnv(fake.baseUrl);
|
||
try {
|
||
const data = await collectSdwanForStore(782);
|
||
assert.equal(data.site?.name, 'CG00782');
|
||
assert.equal(data.elements.length, 1);
|
||
assert.equal(data.healthscore?.value, 92);
|
||
assert.equal(data.links.length, 2, 'one row per waninterface');
|
||
const mpls = data.links.find((l) => l.interfaceId === 'wi-mpls');
|
||
assert.equal(mpls.up, true);
|
||
assert.equal(mpls.latencyMs, 30);
|
||
assert.equal(mpls.jitterMs, 5);
|
||
assert.equal(mpls.lossPct, 0.1);
|
||
assert.equal(mpls.mos, 4.3);
|
||
assert.equal(mpls.transportType, 'primary');
|
||
const bb = data.links.find((l) => l.interfaceId === 'wi-bb');
|
||
assert.equal(bb.up, true);
|
||
assert.equal(bb.latencyMs, null, 'broadband has no LQM sample in the fixture');
|
||
assert.ok(Array.isArray(data.tunnels), 'tunnels array always present');
|
||
assert.equal(data.errors.length, 0);
|
||
} finally {
|
||
await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
test('collectSdwanForStore: reports the effective window on the returned payload', async () => {
|
||
_resetSitesCache(); _resetPrismaAuthCache();
|
||
const fake = await makeFakePrisma({
|
||
'GET /sdwan/v4.13/api/sites': () => ({
|
||
body: { items: [{ id: 's', name: 'CG00782' }] },
|
||
}),
|
||
'GET /sdwan/v3.1/api/elements': () => ({
|
||
body: { items: [] },
|
||
}),
|
||
'GET /sdwan/v2.10/api/sites/s/waninterfaces': () => ({
|
||
body: { items: [] },
|
||
}),
|
||
'POST /sdwan/monitor/v2.6/api/monitor/metrics': () => ({
|
||
body: { metrics: [] },
|
||
}),
|
||
'POST /sdwan/monitor/v2.0/api/monitor/lqm_point_metrics': () => ({
|
||
body: { metrics: [] },
|
||
}),
|
||
'POST /sdwan/v3.7/api/events/query': () => ({
|
||
body: { items: [] },
|
||
}),
|
||
});
|
||
setupSaseEnv(fake.baseUrl);
|
||
try {
|
||
// Default (no opts): 7 days (10080 min). Widened from 24h because
|
||
// per-app DPI metrics only get datapoints when calls actually
|
||
// happen — sporadic Webex Calling stores need a wider window for
|
||
// worst-window statistics to be meaningful. Override via
|
||
// WAN_STANDARD_WINDOW_MINUTES env or --window on /voicediag.
|
||
let data = await collectSdwanForStore(782);
|
||
assert.equal(data.window?.minutes, 10080);
|
||
assert.equal(data.window?.alarmMinutes, 10080,
|
||
'alarms match the requested window when it is >= 60m');
|
||
|
||
// Explicit 15m override → metric window narrows, alarms floor at 60m
|
||
data = await collectSdwanForStore(782, { windowMinutes: 15 });
|
||
assert.equal(data.window?.minutes, 15);
|
||
assert.equal(data.window?.alarmMinutes, 60,
|
||
'alarms floor at 60m even when the requested window is smaller');
|
||
|
||
// Explicit 24h override — the common "live triage" case
|
||
data = await collectSdwanForStore(782, { windowMinutes: 1440 });
|
||
assert.equal(data.window?.minutes, 1440,
|
||
'24h stays 24h — no forced upgrade to 7d');
|
||
assert.equal(data.window?.alarmMinutes, 1440);
|
||
|
||
// Out-of-range values are clamped to [1, 10080]
|
||
data = await collectSdwanForStore(782, { windowMinutes: 99999 });
|
||
assert.equal(data.window?.minutes, 10080,
|
||
'capped at 7d — Prisma downsamples per-app series past this to 1-day buckets');
|
||
|
||
data = await collectSdwanForStore(782, { windowMinutes: -5 });
|
||
assert.equal(data.window?.minutes, 10080,
|
||
'invalid → falls to env default (7d, was 24h before the 2026-07-09 widen)');
|
||
} finally {
|
||
await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
test('collectSdwanForStore: reports window even when site is unresolved', async () => {
|
||
_resetSitesCache(); _resetPrismaAuthCache();
|
||
const fake = await makeFakePrisma({
|
||
'GET /sdwan/v4.13/api/sites': () => ({
|
||
body: { items: [{ id: 's', name: 'CG99999' }] },
|
||
}),
|
||
});
|
||
setupSaseEnv(fake.baseUrl);
|
||
try {
|
||
const data = await collectSdwanForStore(782, { windowMinutes: 60 });
|
||
assert.equal(data.site, null);
|
||
assert.equal(data.window?.minutes, 60);
|
||
} finally {
|
||
await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
test('collectSdwanForStore: preserves per-metric failure in errors[]', async () => {
|
||
_resetSitesCache(); _resetPrismaAuthCache();
|
||
const fake = await makeFakePrisma({
|
||
'GET /sdwan/v4.13/api/sites': () => ({
|
||
body: { items: [{ id: 'site-B', name: 'CG00782' }] },
|
||
}),
|
||
'GET /sdwan/v3.1/api/elements': () => ({
|
||
body: { items: [{ id: 'el-1', name: 'ION', connected: true, site_id: 'site-B' }] },
|
||
}),
|
||
'GET /sdwan/v2.10/api/sites/site-B/waninterfaces': () => ({
|
||
body: { items: [] },
|
||
}),
|
||
'POST /sdwan/monitor/v2.6/api/monitor/metrics': () => ({
|
||
status: 500, body: { error: 'boom' },
|
||
}),
|
||
'POST /sdwan/monitor/v2.0/api/monitor/lqm_point_metrics': () => ({
|
||
body: { metrics: [] },
|
||
}),
|
||
'POST /sdwan/v3.7/api/events/query': () => ({
|
||
body: { items: [] },
|
||
}),
|
||
});
|
||
setupSaseEnv(fake.baseUrl);
|
||
try {
|
||
const data = await collectSdwanForStore(782);
|
||
// Site + elements arrived; healthscore null because upstream
|
||
// returned 500 (the metric wrapper catches + returns null with
|
||
// a log line — no errors[] entry, healthscore just stays null).
|
||
assert.equal(data.site?.name, 'CG00782');
|
||
assert.equal(data.healthscore, null);
|
||
assert.equal(data.links.length, 0, 'no waninterfaces → no rows');
|
||
} finally {
|
||
await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
// ─── Unit tests: summarizeAppSeries ─────────────────────────────────
|
||
//
|
||
// App metrics come back as a real time series (usually 288 pts over
|
||
// 24h at 5-min intervals). The summarizer must extract worst-window
|
||
// stats reliably regardless of null values, empty series, and the
|
||
// v2.6 response shape's quirks.
|
||
|
||
test('summarizeAppSeries: nil / malformed inputs → null', () => {
|
||
assert.equal(summarizeAppSeries(null, 'X'), null);
|
||
assert.equal(summarizeAppSeries({}, 'X'), null);
|
||
assert.equal(summarizeAppSeries({ metrics: [] }, 'X'), null);
|
||
assert.equal(summarizeAppSeries({ metrics: [{ series: [] }] }, 'X'), null);
|
||
});
|
||
|
||
test('summarizeAppSeries: HAR-shape MOS response → {avg,min,max,p95}', () => {
|
||
// Truncated version of the live rtp-base MOS response from
|
||
// rtp-base-metricCG00127.har (2026-07-09). The webex HAR
|
||
// (webex-base-metricCG00127.har) has an identical body shape —
|
||
// this single test covers both apps.
|
||
const resp = {
|
||
metrics: [{
|
||
series: [{
|
||
name: 'AppAudioMos',
|
||
unit: 'count',
|
||
interval: '5min',
|
||
data: [{
|
||
statistics: 'average',
|
||
datapoints: [
|
||
{ value: 3.5569644, time: 'T1' },
|
||
{ value: 3.55610356, time: 'T2' },
|
||
{ value: 3.92861172, time: 'T3' },
|
||
{ value: 1.8523214, time: 'T4' }, // worst window from the HAR
|
||
{ value: 4.4092858, time: 'T5' }, // best window
|
||
{ value: 4.0257409, time: 'T6' },
|
||
],
|
||
}],
|
||
}],
|
||
}],
|
||
};
|
||
const s = summarizeAppSeries(resp, 'AppAudioMos');
|
||
assert.ok(s, 'summary produced');
|
||
assert.equal(s.samples, 6);
|
||
assert.equal(s.validSamples, 6);
|
||
assert.equal(s.unit, 'count');
|
||
assert.equal(s.interval, '5min');
|
||
assert.equal(s.min, 1.85, 'worst-window MOS surfaced (the whole reason for the check)');
|
||
assert.equal(s.max, 4.41);
|
||
// Avg ≈ 3.5548 → 3.55 after round2
|
||
assert.equal(s.avg, 3.55);
|
||
assert.ok(Array.isArray(s.values), 'raw values retained for downstream stats');
|
||
assert.equal(s.values.length, 6);
|
||
});
|
||
|
||
test('summarizeAppSeries: all-null datapoints → validSamples=0, avg/min/max null', () => {
|
||
const resp = {
|
||
metrics: [{ series: [{
|
||
name: 'AppPerfUDPAudioJitter', unit: 'milliseconds', interval: '5min',
|
||
data: [{ datapoints: [{ value: null, time: 'T1' }, { time: 'T2' }] }],
|
||
}] }],
|
||
};
|
||
const s = summarizeAppSeries(resp, 'AppPerfUDPAudioJitter');
|
||
assert.equal(s.samples, 2);
|
||
assert.equal(s.validSamples, 0);
|
||
assert.equal(s.avg, null);
|
||
assert.equal(s.min, null);
|
||
assert.equal(s.max, null);
|
||
});
|
||
|
||
test('summarizeAppSeries: mixed null + numeric → nulls ignored in aggregates', () => {
|
||
const resp = {
|
||
metrics: [{ series: [{
|
||
name: 'AppPerfUDPAudioPacketLoss', unit: 'percentage', interval: '5min',
|
||
data: [{ datapoints: [
|
||
{ value: null, time: 'T1' },
|
||
{ value: 22, time: 'T2' },
|
||
{ value: 0, time: 'T3' },
|
||
{ value: null, time: 'T4' },
|
||
{ value: 5, time: 'T5' },
|
||
] }],
|
||
}] }],
|
||
};
|
||
const s = summarizeAppSeries(resp, 'AppPerfUDPAudioPacketLoss');
|
||
assert.equal(s.samples, 5);
|
||
assert.equal(s.validSamples, 3);
|
||
assert.equal(s.min, 0);
|
||
assert.equal(s.max, 22);
|
||
assert.equal(s.avg, 9, '(22 + 0 + 5) / 3 = 9');
|
||
});
|
||
|
||
// ─── resolveVoiceAppConfig ──────────────────────────────────────────
|
||
//
|
||
// Reads env vars at call time and returns {appId, appName} for the
|
||
// tenant-configured voice application. Two env vars are honored:
|
||
// - PRISMA_APP_ID_VOICE (canonical) + PRISMA_APP_NAME_VOICE (name)
|
||
// - PRISMA_APP_ID_RTP_BASE (legacy, backwards-compat only)
|
||
//
|
||
// Every test here restores the original env so parallel test files
|
||
// don't stomp each other, and clears the deprecation-log dedupe flag
|
||
// so the deprecation-warning test can observe fresh log behavior.
|
||
|
||
function withVoiceEnv(overrides, fn) {
|
||
const KEYS = ['PRISMA_APP_ID_VOICE', 'PRISMA_APP_ID_RTP_BASE', 'PRISMA_APP_NAME_VOICE'];
|
||
const saved = Object.fromEntries(KEYS.map((k) => [k, process.env[k]]));
|
||
try {
|
||
for (const k of KEYS) delete process.env[k];
|
||
for (const [k, v] of Object.entries(overrides)) {
|
||
if (v !== undefined) process.env[k] = v;
|
||
}
|
||
_resetVoiceAppDeprecationFlag();
|
||
return fn();
|
||
} finally {
|
||
for (const k of KEYS) {
|
||
if (saved[k] === undefined) delete process.env[k];
|
||
else process.env[k] = saved[k];
|
||
}
|
||
_resetVoiceAppDeprecationFlag();
|
||
}
|
||
}
|
||
|
||
test('resolveVoiceAppConfig: nothing set → {appId:null, appName:"voice"}', () => {
|
||
withVoiceEnv({}, () => {
|
||
const cfg = resolveVoiceAppConfig();
|
||
assert.equal(cfg.appId, null);
|
||
assert.equal(cfg.appName, 'voice');
|
||
});
|
||
});
|
||
|
||
test('resolveVoiceAppConfig: PRISMA_APP_ID_VOICE alone → id set, default name "voice"', () => {
|
||
withVoiceEnv({ PRISMA_APP_ID_VOICE: '1708539371717015196' }, () => {
|
||
const cfg = resolveVoiceAppConfig();
|
||
assert.equal(cfg.appId, '1708539371717015196');
|
||
assert.equal(cfg.appName, 'voice',
|
||
'unnamed config gets the generic "voice" label — tenants override with PRISMA_APP_NAME_VOICE');
|
||
});
|
||
});
|
||
|
||
test('resolveVoiceAppConfig: PRISMA_APP_NAME_VOICE overrides display label', () => {
|
||
// The recommended Webex Calling deployment.
|
||
withVoiceEnv({
|
||
PRISMA_APP_ID_VOICE: '1708539371717015196',
|
||
PRISMA_APP_NAME_VOICE: 'Webex_Calling_RTP',
|
||
}, () => {
|
||
const cfg = resolveVoiceAppConfig();
|
||
assert.equal(cfg.appId, '1708539371717015196');
|
||
assert.equal(cfg.appName, 'Webex_Calling_RTP');
|
||
});
|
||
});
|
||
|
||
test('resolveVoiceAppConfig: legacy PRISMA_APP_ID_RTP_BASE honored, appName defaults to "rtp-base"', () => {
|
||
// Backwards-compat path — existing deployments must keep working
|
||
// without any env changes. Display name defaults to "rtp-base" so
|
||
// dashboards/screenshots that reference the old label still match.
|
||
withVoiceEnv({ PRISMA_APP_ID_RTP_BASE: '15932000365560116' }, () => {
|
||
const cfg = resolveVoiceAppConfig();
|
||
assert.equal(cfg.appId, '15932000365560116');
|
||
assert.equal(cfg.appName, 'rtp-base');
|
||
});
|
||
});
|
||
|
||
test('resolveVoiceAppConfig: legacy id + explicit name override → uses both', () => {
|
||
withVoiceEnv({
|
||
PRISMA_APP_ID_RTP_BASE: '15932000365560116',
|
||
PRISMA_APP_NAME_VOICE: 'rtp',
|
||
}, () => {
|
||
const cfg = resolveVoiceAppConfig();
|
||
assert.equal(cfg.appId, '15932000365560116');
|
||
assert.equal(cfg.appName, 'rtp',
|
||
'explicit PRISMA_APP_NAME_VOICE wins over the legacy-default "rtp-base"');
|
||
});
|
||
});
|
||
|
||
test('resolveVoiceAppConfig: PRISMA_APP_ID_VOICE takes precedence over legacy PRISMA_APP_ID_RTP_BASE', () => {
|
||
// Migration scenario: operator set the new env alongside the old
|
||
// one before removing the old one. New value must win.
|
||
withVoiceEnv({
|
||
PRISMA_APP_ID_VOICE: '1708539371717015196',
|
||
PRISMA_APP_ID_RTP_BASE: '15932000365560116',
|
||
}, () => {
|
||
const cfg = resolveVoiceAppConfig();
|
||
assert.equal(cfg.appId, '1708539371717015196',
|
||
'canonical env var wins — legacy is only a fallback');
|
||
assert.equal(cfg.appName, 'voice',
|
||
'name default follows the new env, not the legacy one');
|
||
});
|
||
});
|
||
|
||
test('resolveVoiceAppConfig: whitespace-only values treated as unset', () => {
|
||
withVoiceEnv({
|
||
PRISMA_APP_ID_VOICE: ' ',
|
||
PRISMA_APP_ID_RTP_BASE: '\t\n',
|
||
PRISMA_APP_NAME_VOICE: ' ',
|
||
}, () => {
|
||
const cfg = resolveVoiceAppConfig();
|
||
assert.equal(cfg.appId, null);
|
||
assert.equal(cfg.appName, 'voice',
|
||
'whitespace-only PRISMA_APP_NAME_VOICE falls back to generic default');
|
||
});
|
||
});
|
||
|
||
test('resolveVoiceAppConfig: values are trimmed (env vars often carry accidental whitespace)', () => {
|
||
withVoiceEnv({
|
||
PRISMA_APP_ID_VOICE: ' 1708539371717015196 ',
|
||
PRISMA_APP_NAME_VOICE: ' Webex_Calling_RTP ',
|
||
}, () => {
|
||
const cfg = resolveVoiceAppConfig();
|
||
assert.equal(cfg.appId, '1708539371717015196', 'id trimmed');
|
||
assert.equal(cfg.appName, 'Webex_Calling_RTP', 'name trimmed');
|
||
});
|
||
});
|