Second half of the DECT spike: the read-side "collector" that turns
a raw /admin/status.xml body into a normalized JS object plus a
pure health verdict. This is what will feed the /phonestatus base-
station diagnostics section once we wire it in.
- integrations/cisco-dect/statusXml.js:
- xmlToObject(): 60-line hand-rolled parser targeted at the
DBS-210's flat XML shape. No attributes, no CDATA, no comments
— so we avoid pulling in a generic XML lib. Throws loudly on
malformed input.
- parseRebootLine(): decodes the reboot-log entries the device
keeps in Reboot_Line_1..6, extracting timestamp + sequence #
+ reason name/code + firmware version. Unrecognized shapes come
back marked `unrecognized:true` instead of being dropped.
- parseStatusXml(): grouped, camelCased view of the device state
(device / firmware / time / multiCell / rebootLog / rtp /
network / security / emergencyNumbers / features). Every field
is null-safe.
- summarizeBaseHealth(): pure-function verdict. Flags recent
reboots (uptime < 10 min), power-loss events in the log,
DECT RF conflicts, non-zero rx/tx errors. Splits into
warnings vs info so consumers can render at the right severity.
- tests/statusXml.test.js: 23 tests covering the parser, the
reboot-line decoder, the higher-level normalizer, and the health
verdict — using a REDACTED inline copy of a real status.xml
captured from a lab base. MAC/IP/RFPI/firmware-server URL are
all obviously-fake so the fixture is safe to commit.
320 lines
13 KiB
JavaScript
320 lines
13 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: 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 flags power-loss reboot and short uptime', () => {
|
|
const s = parseStatusXml(FIXTURE);
|
|
const verdict = summarizeBaseHealth(s);
|
|
assert.equal(verdict.healthy, false);
|
|
// Uptime 620s (~10 min) is < 600 → boundary, so no uptime warning.
|
|
// But we DO have a power loss in the log, so healthy=false via that.
|
|
assert.ok(
|
|
verdict.warnings.some((w) => /power-loss/i.test(w)),
|
|
`expected power-loss warning; got: ${JSON.stringify(verdict.warnings)}`,
|
|
);
|
|
// rxDropped=9 → info, not warning
|
|
assert.ok(
|
|
verdict.info.some((i) => /rx dropped/i.test(i)),
|
|
`expected rx-dropped info; got: ${JSON.stringify(verdict.info)}`,
|
|
);
|
|
});
|
|
|
|
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);
|
|
});
|