// 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.). * 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.', () => { 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'); }); });