collabSupport/tests/paloalto.metrics.test.js
jmcqueen 9d0dbb071e Add per-app DPI voice-quality checks + widen WAN window to 7d
Adds three new SD-WAN checks (wanAppRtpMos/Loss/Jitter) that measure
REAL voice-traffic quality on actual RTP frames via Prisma DPI, not
synthetic link probes. Graded against the WORST 5-minute window so
transient degradation the 24h link-probe averages smooth away
actually surfaces.

Voice-app selection is tenant-configurable via PRISMA_APP_ID_VOICE +
PRISMA_APP_NAME_VOICE (Webex_Calling_RTP recommended for Webex
Calling shops — the Webex-specific DPI signature excludes non-Webex
UDP noise). Legacy PRISMA_APP_ID_RTP_BASE still honored with a
one-time deprecation warning.

Widens the default WAN look-back from 24h to 7 days: per-app metrics
only get datapoints when calls actually happen, so sporadic Webex
Calling stores (3-4 calls/day) need a wider window for worst-window
statistics to be meaningful. Interval picker snaps 7d to 1hour
buckets (168 pts) to keep payloads bounded while preserving
worst-hour granularity. Hard-capped at 7d — beyond that Prisma
downsamples to 1-day buckets and the signal collapses.

Also:
- Client-side concurrency limiter (PRISMA_MAX_INFLIGHT, default 3)
  to prevent 429 cascades when /voicediag fans out 10+ parallel
  metric fetches
- "View in Prisma UI" deep links in both /phonestatus WAN follow-up
  and /voicediag details, threading through a new
  integrations/paloalto/urls.js builder
- humanizeMetricUnit maps raw API unit strings ("percentage",
  "milliseconds") to display symbols ("%", "ms") to fix
  "11.83percentage" leaking to the UI
- getAppAudio envelope distinguishes not-configured / fetch-failed /
  no-traffic states so misleading "set env var" messages don't fire
  when the real problem is a 429

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 13:54:15 -04:00

544 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// tests/paloalto.metrics.test.js
//
// Regression guards for the Prisma SD-WAN metric-body shapes. Every
// bug in this file was found the hard way — live tenant returned
// SCHEMA_CHECK_FAIL 400s that took a round trip to pin down. Keep
// these tests thin, focused, and worded so a future refactor can
// see exactly WHICH schema constraint the assertion is protecting.
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import {
getHealthscore,
getLqmMetric,
getAppMetric,
getAlarms,
APP_METRIC_NAMES,
} from '../integrations/paloalto/metrics.js';
import { _resetPrismaAuthCache } from '../integrations/paloalto/client.js';
async function makeFakePrisma(routes) {
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;
}
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;
}
const h = routes[`${req.method} ${req.url}`];
if (h) {
const parsed = body ? JSON.parse(body) : null;
const r = h({ req, body: parsed });
res.writeHead(r.status || 200, { 'Content-Type': 'application/json', ...(r.headers || {}) });
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 setSaseEnv(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]);
}
// ─── Body-shape regressions ─────────────────────────────────────────
test('getHealthscore: uses v2.6 unified metrics endpoint (v2.0 aiops/health is a dead end on this tenant)', async () => {
_resetPrismaAuthCache();
const validIntervals = new Set(['10sec', '1min', '5min', '1hour', '1day']);
let seenBody = null;
let seenUrl = null;
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ req, body }) => {
seenBody = body;
seenUrl = req.url;
return { body: { metrics: [] } };
},
// Trip-wire on the old v2.0 aiops/health endpoint. Fails loudly
// if a future refactor accidentally regresses to it.
'POST /sdwan/monitor/v2.0/api/monitor/aiops/health': () => {
throw new Error('REGRESSION: healthscore should hit v2.6 /monitor/metrics, not v2.0 /aiops/health');
},
});
setSaseEnv(fake.baseUrl);
try {
await getHealthscore('site-A');
// v2.6 endpoint is the ONLY healthscore endpoint that returns 200
// on the observed tenant (verified 2026-07-09 via prismaProbe
// try-shapes; both v2.0 aiops/health and v2.1 aggregates dead-end
// on tenant-enforced schema errors).
assert.equal(seenUrl, '/sdwan/monitor/v2.6/api/monitor/metrics');
assert.deepEqual(seenBody.filter, { site: ['site-A'] },
'v2.6 accepts filter.site as an ARRAY (unlike v2.0)');
assert.deepEqual(seenBody.view, {},
'v2.6 accepts view as an empty object (unlike v2.0 aiops/health which wants a string enum)');
assert.equal(seenBody.metrics[0].name, 'Healthscore');
assert.deepEqual(seenBody.metrics[0].statistics, ['max']);
assert.equal(seenBody.metrics[0].unit, 'gauge');
assert.equal(
Object.prototype.hasOwnProperty.call(seenBody, 'end_time'), false,
'no end_time — endpoint interprets window as [start_time, now)',
);
assert.ok(validIntervals.has(seenBody.interval),
`interval must be one of ${[...validIntervals].join(', ')} — got "${seenBody.interval}"`);
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
test('getLqmMetric: uses dedicated lqm_point_metrics endpoint (not sys_point_metrics)', async () => {
_resetPrismaAuthCache();
let seenBody = null;
let seenUrl = null;
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.0/api/monitor/lqm_point_metrics': ({ req, body }) => {
seenBody = body;
seenUrl = req.url;
return { body: { metrics: [] } };
},
// Regression trip-wire: sys_point_metrics is the CPU/Memory/Disk
// endpoint and rejects filter.wan_interfaces. If we regress to it,
// fail the test with a specific message instead of a generic 404.
'POST /sdwan/monitor/v2.0/api/monitor/sys_point_metrics': () => {
throw new Error('REGRESSION: getLqmMetric should use lqm_point_metrics, not sys_point_metrics');
},
});
setSaseEnv(fake.baseUrl);
try {
await getLqmMetric('site-A', ['wi-1', 'wi-2'], 'latency');
assert.equal(seenUrl, '/sdwan/monitor/v2.0/api/monitor/lqm_point_metrics');
// Live 400 regressions + LIVEcommunity #1235108 working example:
// - `end_time` is REJECTED ("not defined in the schema"). The
// endpoint interprets the window as [start_time, now).
// - filter.site must be an ARRAY on lqm_point_metrics — 400 was
// "$.filter.site: string found, array expected". OPPOSITE of
// sys_point_metrics, which wants a string.
// - filter.path is the KEY THAT WORKS. Both `wan_interfaces`
// and `waninterface` were rejected as "not defined in the
// schema". The LIVEcommunity example for LqmLatency uses:
// filter: { site: [id], path: [wi_id] }
assert.equal(
Object.prototype.hasOwnProperty.call(seenBody, 'end_time'), false,
'lqm_point_metrics rejects `end_time` — do not send',
);
assert.ok(Array.isArray(seenBody.filter.site),
'lqm_point_metrics requires filter.site as an ARRAY (opposite of sys_point_metrics)');
assert.deepEqual(seenBody.filter.site, ['site-A']);
assert.deepEqual(seenBody.filter.path, ['wi-1', 'wi-2'],
'filter key is `path` — NOT wan_interfaces (rejected), NOT waninterface (sys_point_metrics variant)');
assert.equal(
Object.prototype.hasOwnProperty.call(seenBody.filter, 'wan_interfaces'),
false,
'do NOT send `wan_interfaces` — rejected as "not defined in the schema"',
);
assert.equal(
Object.prototype.hasOwnProperty.call(seenBody.filter, 'waninterface'),
false,
'do NOT send `waninterface` — that key belongs to sys_point_metrics',
);
assert.equal(
Object.prototype.hasOwnProperty.call(seenBody.filter, 'elements'),
false,
'do NOT send `elements` — that was a sys_point_metrics quirk',
);
assert.equal(seenBody.metrics[0].name, 'LqmLatencyPointMetric');
assert.equal(seenBody.metrics[0].unit, 'milliseconds');
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
test('getLqmMetric: correct PointMetric name + unit per key (jitter/loss/mos)', async () => {
_resetPrismaAuthCache();
const seen = [];
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.0/api/monitor/lqm_point_metrics': ({ body }) => {
seen.push(body.metrics[0]);
return { body: { metrics: [] } };
},
});
setSaseEnv(fake.baseUrl);
try {
await getLqmMetric('site-A', ['wi-1'], 'jitter');
await getLqmMetric('site-A', ['wi-1'], 'loss');
await getLqmMetric('site-A', ['wi-1'], 'mos');
assert.deepEqual(seen.map((m) => [m.name, m.unit]), [
// Regression: units are CASE-SENSITIVE per Prisma's schema
// validator. `Percentage` (capital P) was rejected on this
// tenant with 400 METRIC_UNIT_NOT_SUPPORTED — the correct
// spelling is lowercase `percentage`. Verified 2026-07-09 via
// `try-shapes lqm-loss`.
['LqmJitterPointMetric', 'milliseconds'],
['LqmPktLossPointMetric', 'percentage'],
['LqmMosPointMetric', 'count'],
]);
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
test('getLqmMetric: empty waninterfaceIds → skip (no HTTP call)', async () => {
_resetPrismaAuthCache();
let called = false;
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.0/api/monitor/lqm_point_metrics': () => {
called = true;
return { body: { metrics: [] } };
},
});
setSaseEnv(fake.baseUrl);
try {
assert.equal(await getLqmMetric('site-A', [], 'latency'), null);
assert.equal(called, false);
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
// ─── 429 retry ──────────────────────────────────────────────────────
test('client: 429 → retry with exponential backoff, then succeed on second attempt', async () => {
_resetPrismaAuthCache();
let attempt = 0;
const startTs = Date.now();
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.6/api/monitor/metrics': () => {
attempt += 1;
if (attempt === 1) {
return { status: 429, body: { error: 'too many' } };
}
return { body: { metrics: [{ name: 'Healthscore', series: [{ data: [{ value: 95 }] }] }] } };
},
});
setSaseEnv(fake.baseUrl);
try {
const resp = await getHealthscore('site-A');
const elapsed = Date.now() - startTs;
assert.equal(attempt, 2, 'should have retried exactly once');
assert.ok(resp, 'second attempt should return the payload');
assert.equal(resp.metrics[0].name, 'Healthscore');
assert.ok(elapsed >= 1500, `should have waited ~1.5s (base backoff) — waited ${elapsed}ms`);
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
test('client: 429 → honors Retry-After header when present', async () => {
_resetPrismaAuthCache();
let attempt = 0;
const startTs = Date.now();
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.6/api/monitor/metrics': () => {
attempt += 1;
if (attempt === 1) {
// Retry-After of 2 seconds — should be honored over the
// client's default 1.5s exponential backoff.
return { status: 429, headers: { 'Retry-After': '2' }, body: {} };
}
return { body: { metrics: [] } };
},
});
setSaseEnv(fake.baseUrl);
try {
await getHealthscore('site-A');
const elapsed = Date.now() - startTs;
assert.ok(elapsed >= 2000, `should have waited ~2s per Retry-After — waited ${elapsed}ms`);
assert.equal(attempt, 2);
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
test('client: 429 → gives up after MAX_429_RETRIES and returns null', async () => {
_resetPrismaAuthCache();
let attempt = 0;
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.6/api/monitor/metrics': () => {
attempt += 1;
return { status: 429, body: {} };
},
});
setSaseEnv(fake.baseUrl);
try {
const resp = await getHealthscore('site-A');
assert.equal(resp, null, 'metric wrapper absorbs the exhausted-retry error');
// 1 original + 2 retries = 3 total attempts.
assert.equal(attempt, 3, 'should attempt exactly MAX_429_RETRIES + 1 times');
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
// ─── Alarms body shape (events/query endpoint) ──────────────────────
test('getAlarms: uses events/query endpoint (not /monitor/alarms which 404s)', async () => {
_resetPrismaAuthCache();
let seenBody = null;
let seenUrl = null;
const fake = await makeFakePrisma({
'POST /sdwan/v3.7/api/events/query': ({ req, body }) => {
seenBody = body;
seenUrl = req.url;
return { body: { items: [] } };
},
// Regression trip-wire: if we accidentally regress to the old
// /monitor/alarms path, this handler catches it and fails the
// test with a specific message instead of a generic 404.
'POST /sdwan/monitor/v2.0/api/monitor/alarms': () => {
throw new Error('REGRESSION: getAlarms should use /events/query, not /monitor/alarms');
},
});
setSaseEnv(fake.baseUrl);
try {
const resp = await getAlarms('site-A');
assert.ok(resp, 'events/query response returned');
assert.equal(seenUrl, '/sdwan/v3.7/api/events/query');
assert.deepEqual(seenBody.query.site, ['site-A']);
assert.deepEqual(seenBody.query.type, ['alarm'],
'must filter for type=alarm — informational events would inflate the count');
assert.deepEqual(seenBody.severity, ['critical', 'major', 'minor']);
assert.ok(seenBody.limit && typeof seenBody.limit === 'object',
'limit is an OBJECT on events/query (count + sort_on + sort_order), not an int');
assert.equal(seenBody.limit.sort_on, 'time');
assert.equal(seenBody.limit.sort_order, 'descending');
assert.ok(seenBody.start_time, 'start_time populated');
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
// ─── Per-application metrics — body-shape regressions ──────────────
//
// Ground truth for these assertions comes from two HAR captures
// against a live tenant (site CG00127), both taken 2026-07-09:
// - /Users/McqueenJ/Downloads/rtp-base-metricCG00127.har
// (app id 15932000365560116)
// - /Users/McqueenJ/Downloads/webex-base-metricCG00127.har
// (app id 1708539371717015196 → Webex_Calling_RTP)
//
// The metric names, units, and filter shapes are IDENTICAL across
// voice apps — only filter.app differs. That's why the check code is
// app-agnostic and these tests use `<APP_ID>` as a filler rather than
// pinning one app.
//
// Every non-obvious body key here is one Prisma silently rejects if
// wrong. Do NOT relax these without a fresh HAR — see
// scripts/prismaProbe.js `try-shapes app-audio-*` to re-discover.
test('getAppMetric(mos): AppAudioMos requires direction="Ingress" and OMITS filter.path_type', async () => {
_resetPrismaAuthCache();
let seenBody = null;
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => {
seenBody = body;
return { body: { metrics: [{ series: [{ name: 'AppAudioMos', unit: 'count', data: [{ datapoints: [] }] }] }] } };
},
});
setSaseEnv(fake.baseUrl);
try {
await getAppMetric('site-CG127', '15932000365560116', 'mos', 60);
assert.ok(seenBody, 'request reached fake server');
// The winning metric name from the HAR — anything else 400s
// with METRIC_NOT_SUPPORTED, so pin it.
assert.deepEqual(seenBody.metrics, [{
name: 'AppAudioMos', statistics: ['average'], unit: 'count',
}]);
// AppAudioMos is the ONE audio metric that does NOT take
// filter.path_type. If we add it, the schema check flips from
// "no matching metric" to "unsupported filter" — different bug
// symptom, but same net "returns nothing" outcome.
assert.ok(!('path_type' in seenBody.filter),
'AppAudioMos must NOT include filter.path_type (unlike AppPerfUDP*)');
// Direction is REQUIRED. Without it Prisma returns 400.
assert.equal(seenBody.filter.direction, 'Ingress',
'AppAudioMos requires filter.direction="Ingress" (audio quality is what you RECEIVE)');
assert.deepEqual(seenBody.filter.site, ['site-CG127']);
assert.deepEqual(seenBody.filter.app, ['15932000365560116'],
'app id must be sent as a STRING inside an ARRAY, not a bare int or a bare string');
// Empty view {} — the single-metric per-app queries use this
// rather than {summary:true} or {individual:'app'}.
assert.deepEqual(seenBody.view, {});
assert.ok(seenBody.start_time && seenBody.end_time,
'start_time + end_time both required — v2.6 monitor/metrics rejects without them');
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
test('getAppMetric(loss): AppPerfUDPAudioPacketLoss includes ALL 5 path_types', async () => {
_resetPrismaAuthCache();
let seenBody = null;
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => {
seenBody = body;
return { body: { metrics: [{ series: [{ name: 'AppPerfUDPAudioPacketLoss', unit: 'percentage', data: [{ datapoints: [] }] }] }] } };
},
});
setSaseEnv(fake.baseUrl);
try {
await getAppMetric('site-CG127', '15932000365560116', 'loss', 60);
assert.equal(seenBody.metrics[0].name, 'AppPerfUDPAudioPacketLoss');
// Case-sensitive — 'Percentage' returned METRIC_UNIT_NOT_SUPPORTED
// in earlier probe runs. Lowercase-p is a live constraint.
assert.equal(seenBody.metrics[0].unit, 'percentage');
assert.equal(seenBody.filter.direction, 'Ingress');
const paths = seenBody.filter.path_type;
assert.ok(Array.isArray(paths));
for (const t of ['DirectInternet', 'VPN', 'PrivateWAN', 'PrivateVPN', 'ServiceLink']) {
assert.ok(paths.includes(t), `path_type must include "${t}" (Prisma UI passes all 5)`);
}
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
test('getAppMetric(jitter): AppPerfUDPAudioJitter uses milliseconds unit', async () => {
_resetPrismaAuthCache();
let seenBody = null;
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => {
seenBody = body;
return { body: {} };
},
});
setSaseEnv(fake.baseUrl);
try {
await getAppMetric('site-CG127', '15932000365560116', 'jitter', 60);
assert.equal(seenBody.metrics[0].name, 'AppPerfUDPAudioJitter');
assert.equal(seenBody.metrics[0].unit, 'milliseconds');
assert.equal(seenBody.filter.direction, 'Ingress');
assert.ok(Array.isArray(seenBody.filter.path_type));
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
test('getAppMetric(bandwidth): AppPerfUDPAudioBandwidth has NO direction (bidirectional aggregate)', async () => {
_resetPrismaAuthCache();
let seenBody = null;
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => {
seenBody = body;
return { body: {} };
},
});
setSaseEnv(fake.baseUrl);
try {
await getAppMetric('site-CG127', '15932000365560116', 'bandwidth', 60);
assert.equal(seenBody.metrics[0].name, 'AppPerfUDPAudioBandwidth');
assert.equal(seenBody.metrics[0].unit, 'Mbps');
assert.ok(!('direction' in seenBody.filter),
'bandwidth is a bidirectional aggregate — Prisma rejects filter.direction on this metric');
assert.ok(Array.isArray(seenBody.filter.path_type));
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
test('getAppMetric: no-op with clean null return when siteId or appId missing', async () => {
// No network setup — if the wrapper tried to make a call, the
// client would fail to resolve the SASE URL and throw. A silent
// null keeps the enrichment pipeline resilient when a caller
// forgets to configure PRISMA_APP_ID_VOICE (or the legacy
// PRISMA_APP_ID_RTP_BASE).
const r1 = await getAppMetric(null, '1708539371717015196', 'mos');
const r2 = await getAppMetric('site-X', null, 'mos');
assert.equal(r1, null);
assert.equal(r2, null);
});
test('getAppMetric: uses 5min interval for 24h window (NOT 1day — preserves 288-point time series)', async () => {
// The whole point of client-side aggregation is that we can
// extract WORST-window numbers from the series. If we ask for
// interval=1day on a 1440min window, Prisma returns 1-2
// aggregated datapoints and the min/max collapse to the same
// value as the avg — completely defeating the purpose. HAR
// confirms the Prisma UI uses 5min for the 24h view.
_resetPrismaAuthCache();
let seenBody = null;
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => {
seenBody = body;
return { body: {} };
},
});
setSaseEnv(fake.baseUrl);
try {
await getAppMetric('site-CG127', '15932000365560116', 'mos', 1440);
assert.equal(seenBody.interval, '5min',
'1440-min window MUST use 5min interval (yields ~288 pts, matches HAR). ' +
'Falling back to 1day would collapse the series to 1-2 aggregated pts.');
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
test('getAppMetric: uses 1hour interval for 7d window (168 points — the new default)', async () => {
_resetPrismaAuthCache();
let seenBody = null;
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => {
seenBody = body;
return { body: {} };
},
});
setSaseEnv(fake.baseUrl);
try {
// 7 days = 10080 min. This is now the default window (widened
// from 24h so sporadic Webex Calling stores get enough per-app
// samples). 5min buckets would be 2016 points per metric × 4
// metrics = 8064 numbers per site request. Snap to 1hour (168
// pts) to keep payloads reasonable while still catching
// worst-hour degradation.
await getAppMetric('site-CG127', '1708539371717015196', 'mos', 10080);
assert.equal(seenBody.interval, '1hour');
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
test('APP_METRIC_NAMES: registry exposes the HAR-confirmed winners', () => {
// Cheap "someone renamed the constant" check. The four keys are
// the API surface that sdwanEnrichment.js iterates over — a rename
// would silently drop that metric from the enrichment payload
// (parseAppSeries would return null and the check would go to
// skipped rather than error).
assert.equal(APP_METRIC_NAMES.mos.name, 'AppAudioMos');
assert.equal(APP_METRIC_NAMES.loss.name, 'AppPerfUDPAudioPacketLoss');
assert.equal(APP_METRIC_NAMES.jitter.name, 'AppPerfUDPAudioJitter');
assert.equal(APP_METRIC_NAMES.bandwidth.name, 'AppPerfUDPAudioBandwidth');
assert.equal(APP_METRIC_NAMES.mos.lowIsBad, true,
'MOS grades against worst-window minimum — lowIsBad must be true');
});