collabSupport/tests/statusXml.test.js
jmcqueen 2b8c4e06aa Improve /dectstatus with handset RF context and cleaner base cards.
Surface handset registrations and RSSI, tighten reboot health to 7 days,
and consolidate Base / Handsets & RF / Network & RTP sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 09:02:55 -04:00

355 lines
15 KiB
JavaScript

// Unit tests for integrations/cisco-dect/statusXml.js.
//
// Pure — no network, no fs. The fixture below is a REDACTED copy of a
// real /admin/status.xml pulled from a lab DBS-210 during the DECT
// spike. MAC, IP, RFPI, and firmware server URL are all changed to
// obviously-fake values so this file is safe to commit and safe to
// leave in CI logs. The XML SHAPE (tag nesting, whitespace, encoding
// oddities like `text/text` mimetype on the wire) is preserved
// verbatim — that's exactly what the parser has to be robust against.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
xmlToObject,
parseStatusXml,
parseRebootLine,
summarizeBaseHealth,
} from '../integrations/cisco-dect/statusXml.js';
const FIXTURE = `<?xml version="1.0" encoding="UTF-8"?>
<Status>
<System_Information>
<Released_Build>Yes</Released_Build>
<Multi_Cell>Unchained(TXT_STATE_UNCHAINED) Allowed to Join as Secondary</Multi_Cell>
<Phone_Type>IPDECT-V2 (DBS-210-3PC)</Phone_Type>
<System_Type>Generic SIP (RFC 3261)</System_Type>
<Unit_Name>SME VoIP</Unit_Name>
<Unit_Index>Base Idx:0</Unit_Index>
<RF_Band>US</RF_Band>
<Conflict_Info>No Conflict</Conflict_Info>
<Current_Local_Time>02-Jul-2026 13:22:48</Current_Local_Time>
<Operating_Time>00:10:20 (H:M:S)</Operating_Time>
<RFPI_Address>ABCDEF12; RPN:00</RFPI_Address>
<MAC_Address>001122334455</MAC_Address>
<IP_Address>10.0.0.100</IP_Address>
<Product_Configuration>0000</Product_Configuration>
<Firmware_Version>IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29</Firmware_Version>
<Firmware_URL>
<Update_Server_Address>https://example.invalid</Update_Server_Address>
<Path>dms/dbS210</Path>
</Firmware_URL>
<Reboot_Log>
<Reboot_Line_1>2026-07-02 13:11:46 (164) Normal Reboot (21) Firmware Version 05-01-03-0101-09</Reboot_Line_1>
<Reboot_Line_2>2026-07-02 13:09:50 (163) Normal Reboot (21) Firmware Version 05-01-03-0101-09</Reboot_Line_2>
<Reboot_Line_3>2026-07-02 13:06:28 (162) Normal Reboot (21) Firmware Version 05-01-03-0101-09</Reboot_Line_3>
<Reboot_Line_4>2026-07-02 12:54:12 (161) Power Loss (80) Firmware Version 05-01-03-0101-09</Reboot_Line_4>
<Reboot_Line_5>2026-07-02 12:49:50 (160) Normal Reboot (21) Firmware Version 05-01-03-0101-09</Reboot_Line_5>
<Reboot_Line_6>2026-07-02 12:48:40 (159) Normal Reboot (21) Firmware Version 05-01-03-0101-09</Reboot_Line_6>
</Reboot_Log>
<Base_Station_Status>Idle</Base_Station_Status>
<Custom_CA_Status>
<Custom_CA_Provisioning_Status>N/A</Custom_CA_Provisioning_Status>
<Custom_CA_Info>Not Installed</Custom_CA_Info>
</Custom_CA_Status>
<Dot1x_Authentication>
<Transaction_status>Unavailable</Transaction_status>
<Protocol>N/A</Protocol>
</Dot1x_Authentication>
<RSSI_List>
</RSSI_List>
<RTP_Usage>
<Total_RTP>2</Total_RTP>
<Max_RTP>-1</Max_RTP>
<Time_In_Max_RTP>49710 days 06:28:15 [H:M:S]</Time_In_Max_RTP>
<Current_RTP>0</Current_RTP>
<Current_Local_RTP>0</Current_Local_RTP>
<Current_Relay_RTP>0</Current_Relay_RTP>
<Remote_Relay_RTP>0</Remote_Relay_RTP>
<Current_Recording>0</Current_Recording>
</RTP_Usage>
<Device_Presence>
</Device_Presence>
<Device_FWU_Info>
<Device_Base_Station>Base type:DBS-210-3PC - Required Version:501 Required Branch:309</Device_Base_Station>
<Device_Line_0>Device type:6825 - Required Version:501 Required Branch:308 Language Pack:6825_default</Device_Line_0>
<Device_Line_1>Device type:6825-RGD - Required Version:501 Required Branch:308 Language Pack:6825-RGD_default</Device_Line_1>
<Device_Line_2>Device type:6823 - Required Version:501 Required Branch:308 Language Pack:6823_default</Device_Line_2>
<Device_Line_3>Device type:RPT-110-3PC - Required Version:501 Required Branch:303</Device_Line_3>
</Device_FWU_Info>
<Push_To_Talk>Off</Push_To_Talk>
<Emergency_Calls>
<Emergency_Number_1>911</Emergency_Number_1>
<Emergency_Number_2>1911</Emergency_Number_2>
<Emergency_Number_3>933</Emergency_Number_3>
<Emergency_Number_4>No Number set!</Emergency_Number_4>
<Emergency_Number_5>No Number set!</Emergency_Number_5>
</Emergency_Calls>
<SIP_Identity_Status>
</SIP_Identity_Status>
</System_Information>
<Device_Information>
</Device_Information>
<Statistics>
<Network_Statistics>
<Tx_Packets>1959</Tx_Packets>
<Tx_Blocked>0</Tx_Blocked>
<Tx_Dropped>0</Tx_Dropped>
<Tx_Errors>0</Tx_Errors>
<Tx_Broadcasts>0</Tx_Broadcasts>
<Rx_Packets>49318</Rx_Packets>
<Rx_Blocked>0</Rx_Blocked>
<Rx_Dropped>9</Rx_Dropped>
<Rx_Errors>0</Rx_Errors>
<Rx_Broadcasts>5834</Rx_Broadcasts>
</Network_Statistics>
<Header_Line_Idx>RPN, MAC-Addr, OP[s], DT[s]</Header_Line_Idx>
</Statistics>
</Status>`;
// ─── Low-level parser ───────────────────────────────────────────────
test('xmlToObject: parses the DBS-210 status.xml shape into a nested object', () => {
const tree = xmlToObject(FIXTURE);
assert.ok(tree.Status, 'root element is Status');
assert.equal(tree.Status.System_Information.Phone_Type, 'IPDECT-V2 (DBS-210-3PC)');
assert.equal(tree.Status.System_Information.MAC_Address, '001122334455');
// Nested Firmware_URL is a child object, not a string.
assert.equal(typeof tree.Status.System_Information.Firmware_URL, 'object');
assert.equal(tree.Status.System_Information.Firmware_URL.Path, 'dms/dbS210');
// Empty <RSSI_List>\n</RSSI_List> becomes an empty leaf string —
// XML alone can't distinguish empty-container from empty-leaf, so
// the parser stays neutral. parseStatusXml() then normalizes both
// shapes into `[]` for callers that expect a container.
assert.equal(tree.Status.System_Information.RSSI_List, '');
// Statistics has the massive CSV header preserved as a single string.
assert.match(tree.Status.Statistics.Header_Line_Idx, /RPN, MAC-Addr/);
});
test('xmlToObject: preserves whitespace inside leaf text values', () => {
const tree = xmlToObject(FIXTURE);
// RFPI has a `; ` separator that must survive intact.
assert.equal(tree.Status.System_Information.RFPI_Address, 'ABCDEF12; RPN:00');
});
test('xmlToObject: throws on empty input', () => {
assert.throws(() => xmlToObject(''), /empty input/);
assert.throws(() => xmlToObject('<?xml version="1.0"?>'), /empty input/);
});
test('xmlToObject: throws on non-string input', () => {
assert.throws(() => xmlToObject(null), /expected a string/);
assert.throws(() => xmlToObject(123), /expected a string/);
});
// ─── Reboot line parser ─────────────────────────────────────────────
test('parseRebootLine: normal reboot line', () => {
const p = parseRebootLine('2026-07-02 13:11:46 (164) Normal Reboot (21) Firmware Version 05-01-03-0101-09');
assert.equal(p.at, '2026-07-02T13:11:46');
assert.equal(p.sequence, 164);
assert.equal(p.reasonName, 'Normal Reboot');
assert.equal(p.reasonCode, 21);
assert.equal(p.firmwareAtBoot, '05-01-03-0101-09');
});
test('parseRebootLine: power loss line', () => {
const p = parseRebootLine('2026-07-02 12:54:12 (161) Power Loss (80) Firmware Version 05-01-03-0101-09');
assert.equal(p.reasonName, 'Power Loss');
assert.equal(p.reasonCode, 80);
});
test('parseRebootLine: marks unrecognized shapes without dropping them', () => {
const p = parseRebootLine('Something totally different');
assert.equal(p.unrecognized, true);
assert.equal(p.raw, 'Something totally different');
});
test('parseRebootLine: empty/null input', () => {
assert.equal(parseRebootLine(''), null);
assert.equal(parseRebootLine(' '), null);
assert.equal(parseRebootLine(null), null);
assert.equal(parseRebootLine(undefined), null);
});
// ─── High-level parseStatusXml ──────────────────────────────────────
test('parseStatusXml: extracts core device identity', () => {
const s = parseStatusXml(FIXTURE);
assert.equal(s.device.model, 'IPDECT-V2 (DBS-210-3PC)');
assert.equal(s.device.macAddress, '00:11:22:33:44:55'); // normalized colon-form
assert.equal(s.device.ipAddress, '10.0.0.100');
assert.equal(s.device.rfBand, 'US');
assert.equal(s.device.rfpiAddress, 'ABCDEF12; RPN:00');
assert.equal(s.device.releasedBuild, true);
});
test('parseStatusXml: firmware version and required-per-device map', () => {
const s = parseStatusXml(FIXTURE);
assert.equal(s.firmware.version, 'IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29');
assert.equal(s.firmware.updateServer, 'https://example.invalid');
assert.equal(s.firmware.updatePath, 'dms/dbS210');
// Device_Base_Station → keyed by model
assert.deepEqual(s.firmware.requiredFor['DBS-210-3PC'], {
requiredVersion: '501',
requiredBranch: '309',
languagePack: null,
_sourceKey: 'Device_Base_Station',
});
// Handset lines carry language pack too
assert.equal(s.firmware.requiredFor['6825'].languagePack, '6825_default');
});
test('parseStatusXml: operating time gets converted to seconds', () => {
const s = parseStatusXml(FIXTURE);
assert.equal(s.time.operatingTime, '00:10:20 (H:M:S)');
assert.equal(s.time.operatingTimeSeconds, 620); // 10m 20s
});
test('parseStatusXml: multi-cell role parsed to a normalized token', () => {
const s = parseStatusXml(FIXTURE);
assert.equal(s.multiCell.role, 'unchained');
assert.match(s.multiCell.raw, /TXT_STATE_UNCHAINED/);
});
test('parseStatusXml: ready+primary multi-cell keeps primary as role', () => {
const xml = FIXTURE.replace(
/<Multi_Cell>[^<]+<\/Multi_Cell>/,
'<Multi_Cell>Ready(TXT_STATE_KEEP_ALIVE) Primary</Multi_Cell>',
);
const s = parseStatusXml(xml);
assert.equal(s.multiCell.role, 'primary');
assert.equal(s.multiCell.state, 'ready');
});
test('parseStatusXml: reboot log is 6 sorted entries with structured fields', () => {
const s = parseStatusXml(FIXTURE);
assert.equal(s.rebootLog.length, 6);
// First entry = the newest by sequence#, matching physical order in the XML.
assert.equal(s.rebootLog[0].sequence, 164);
assert.equal(s.rebootLog[0].reasonName, 'Normal Reboot');
// The Power Loss event is entry #4 (sequence 161).
const powerLoss = s.rebootLog.find((r) => r.reasonCode === 80);
assert.ok(powerLoss);
assert.equal(powerLoss.reasonName, 'Power Loss');
assert.equal(powerLoss.sequence, 161);
});
test('parseStatusXml: network stats decoded as numbers', () => {
const s = parseStatusXml(FIXTURE);
assert.equal(s.network.txPackets, 1959);
assert.equal(s.network.rxPackets, 49318);
assert.equal(s.network.rxDropped, 9);
assert.equal(s.network.rxErrors, 0);
});
test('parseStatusXml: emergency numbers filter out "No Number set" placeholders', () => {
const s = parseStatusXml(FIXTURE);
assert.deepEqual(s.emergencyNumbers, ['911', '1911', '933']);
});
test('parseStatusXml: RTP usage counters', () => {
const s = parseStatusXml(FIXTURE);
assert.equal(s.rtp.total, 2);
assert.equal(s.rtp.current, 0);
assert.equal(s.rtp.max, -1);
});
test('parseStatusXml: security / dot1x / customCA', () => {
const s = parseStatusXml(FIXTURE);
assert.equal(s.security.customCa.installed, false);
assert.equal(s.security.customCa.info, 'Not Installed');
assert.equal(s.security.dot1x.transactionStatus, 'Unavailable');
});
test('parseStatusXml: pushToTalk feature flag', () => {
const s = parseStatusXml(FIXTURE);
assert.equal(s.features.pushToTalk, false);
});
// ─── Health verdict ─────────────────────────────────────────────────
test('summarizeBaseHealth: fixture rx-dropped surfaces as info (old reboots ignored)', () => {
const s = parseStatusXml(FIXTURE);
const verdict = summarizeBaseHealth(s);
// Fixture power-loss reboot is from 2026-07-02 — outside the 7-day window.
assert.ok(
!verdict.warnings.some((w) => /concerning reboot/i.test(w)),
`old reboot should not warn; got: ${JSON.stringify(verdict.warnings)}`,
);
assert.ok(
verdict.info.some((i) => /rx dropped/i.test(i)),
`expected rx-dropped info; got: ${JSON.stringify(verdict.info)}`,
);
});
test('summarizeBaseHealth: power-loss within 7 days is flagged', () => {
const recent = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 19);
const verdict = summarizeBaseHealth({
conflictInfo: 'No Conflict',
network: { rxDropped: 0 },
rebootLog: [
{ at: recent, sequence: 1, reasonName: 'Power Loss', reasonCode: 80 },
],
});
assert.equal(verdict.healthy, false);
assert.ok(
verdict.warnings.some((w) => /concerning reboot/i.test(w)),
`expected concerning reboot warning; got: ${JSON.stringify(verdict.warnings)}`,
);
});
test('summarizeBaseHealth: unexpected reboot within 7 days is flagged', () => {
const recent = new Date(Date.now() - 1 * 86400000).toISOString().slice(0, 19);
const verdict = summarizeBaseHealth({
conflictInfo: 'No Conflict',
network: { rxDropped: 0 },
rebootLog: [
{ at: recent, sequence: 1, reasonName: 'Unexpected Reboot', reasonCode: 43 },
],
});
assert.ok(verdict.warnings.some((w) => /Unexpected reboot/i.test(w)));
});
test('summarizeBaseHealth: RF conflict is flagged as a warning', () => {
const s = parseStatusXml(FIXTURE.replace(
'<Conflict_Info>No Conflict</Conflict_Info>',
'<Conflict_Info>Conflict Detected on RPN 00</Conflict_Info>',
));
const verdict = summarizeBaseHealth(s);
assert.ok(verdict.warnings.some((w) => /RF conflict/i.test(w)));
});
test('summarizeBaseHealth: very short uptime is flagged as recent reboot', () => {
const s = parseStatusXml(FIXTURE.replace(
'<Operating_Time>00:10:20 (H:M:S)</Operating_Time>',
'<Operating_Time>00:02:15 (H:M:S)</Operating_Time>',
));
const verdict = summarizeBaseHealth(s);
assert.ok(verdict.warnings.some((w) => /rebooted very recently/i.test(w)));
});
test('summarizeBaseHealth: healthy base with clean stats returns healthy:true', () => {
// Wipe rx_dropped and the power-loss reboot line so nothing warns.
const cleaned = FIXTURE
.replace(/<Rx_Dropped>\d+<\/Rx_Dropped>/, '<Rx_Dropped>0</Rx_Dropped>')
.replace(
/<Reboot_Line_4>[^<]+<\/Reboot_Line_4>/,
'<Reboot_Line_4>2026-07-02 12:54:12 (161) Normal Reboot (21) Firmware Version 05-01-03-0101-09</Reboot_Line_4>',
)
.replace(
'<Operating_Time>00:10:20 (H:M:S)</Operating_Time>',
'<Operating_Time>24:15:00 (H:M:S)</Operating_Time>',
);
const s = parseStatusXml(cleaned);
const verdict = summarizeBaseHealth(s);
assert.equal(verdict.healthy, true, `not healthy: ${JSON.stringify(verdict.warnings)}`);
assert.equal(verdict.warnings.length, 0);
});
test('summarizeBaseHealth: bad input degrades gracefully', () => {
assert.equal(summarizeBaseHealth(null).healthy, false);
assert.equal(summarizeBaseHealth(undefined).healthy, false);
assert.equal(summarizeBaseHealth('nope').healthy, false);
});