Skip to content

Commit 72bd600

Browse files
committed
test: e2e coverage for HA Discovery startup race protections
Two new test surfaces for the v1.8.1 + v1.8.4 + v1.8.5 features: 1. tests/discoveryE2E.test.js (in-process, runs in npm test): Wires a real CommandResponseProcessor to a real HaDiscovery and a real EventPublisher (no mocks of either), then drives the full pipeline from raw C-Gate response lines through to MQTT publishes. Covers nine scenarios: - happy path (343/347/344 sequence advances diagnostic to ok) - startup race (401 → retry → success) - giveup (9× 401 exhausts retry budget, sensor reaches paused) - event-driven 742 Network created triggers TREEXML - 742 events for non-configured networks are filtered - Network created mid-backoff cancels the pending retry - non-Network 742 events are ignored - parser hardening against UUIDs containing hyphens - HA Discovery config payload shape (entity_category, device, etc.) 2. test-env/integration-test.js (podman managed-mode runner, manual): New section "6b. Discovery health diagnostic" asserts that the per-network diagnostic sensor is published with correct config payload shape (entity_category=diagnostic, device.identifiers includes cgateweb_bridge, state_topic format) and that at least one network reaches discovery_status=ok against a real C-Gate. Full suite: 1199/1199 passing (was 1190 before these additions). No behaviour changes — purely additive test coverage.
1 parent 1941c8b commit 72bd600

2 files changed

Lines changed: 351 additions & 0 deletions

File tree

test-env/integration-test.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,90 @@ async function runTests() {
383383
info(`Discovery summary: ${discoveryMessages.size} total, ${lightCount} light(s)`);
384384
}
385385

386+
// ------------------------------------------------------------------
387+
// 6b. Discovery health diagnostic (v1.8.4) — per-network sensor
388+
// ------------------------------------------------------------------
389+
section('Discovery health diagnostic');
390+
info('Collecting per-network discovery_status messages for 3s...');
391+
392+
const diagReceived = await collectMqtt(
393+
['homeassistant/sensor/cgateweb_discovery_+/config', 'cbus/read/+///discovery_status'],
394+
3000
395+
);
396+
const diagConfigTopics = Object.keys(diagReceived).filter(t =>
397+
/^homeassistant\/sensor\/cgateweb_discovery_\w+\/config$/.test(t)
398+
);
399+
const diagStateTopics = Object.keys(diagReceived).filter(t =>
400+
/^cbus\/read\/\w+\/\/\/discovery_status$/.test(t)
401+
);
402+
403+
if (diagConfigTopics.length === 0 && diagStateTopics.length === 0) {
404+
info('No discovery diagnostic messages received — HA Discovery may not have run (no networks configured?). Skipping diagnostic assertions.');
405+
pass('Discovery diagnostic: no messages (soft pass)');
406+
passed++;
407+
} else {
408+
assert(
409+
`discovery diagnostic config published (${diagConfigTopics.length} sensor config(s))`,
410+
diagConfigTopics.length > 0
411+
);
412+
assert(
413+
`discovery diagnostic state published (${diagStateTopics.length} network(s))`,
414+
diagStateTopics.length > 0
415+
);
416+
417+
// Validate the config payload shape on at least one diagnostic.
418+
if (diagConfigTopics.length > 0) {
419+
const cfgTopic = diagConfigTopics[0];
420+
try {
421+
const cfg = JSON.parse(diagReceived[cfgTopic]);
422+
assert(
423+
`${cfgTopic} has entity_category=diagnostic`,
424+
cfg.entity_category === 'diagnostic',
425+
`got: ${cfg.entity_category}`
426+
);
427+
assert(
428+
`${cfgTopic} has unique_id matching cgateweb_discovery_*`,
429+
typeof cfg.unique_id === 'string' && cfg.unique_id.startsWith('cgateweb_discovery_'),
430+
`got: ${cfg.unique_id}`
431+
);
432+
assert(
433+
`${cfgTopic} state_topic matches cbus/read/<network>///discovery_status`,
434+
/^cbus\/read\/\w+\/\/\/discovery_status$/.test(cfg.state_topic || ''),
435+
`got: ${cfg.state_topic}`
436+
);
437+
assert(
438+
`${cfgTopic} grouped under cgateweb_bridge device`,
439+
Array.isArray(cfg.device?.identifiers) && cfg.device.identifiers.includes('cgateweb_bridge'),
440+
`got: ${JSON.stringify(cfg.device?.identifiers)}`
441+
);
442+
} catch (err) {
443+
fail(`${cfgTopic} payload not valid JSON: ${err.message}`);
444+
failed++;
445+
}
446+
}
447+
448+
// Validate the state payload — should be one of {discovering, ok, paused}.
449+
const validStates = new Set(['discovering', 'ok', 'paused']);
450+
for (const stateTopic of diagStateTopics) {
451+
const value = diagReceived[stateTopic];
452+
assert(
453+
`${stateTopic} = ${value} (one of discovering/ok/paused)`,
454+
validStates.has(value),
455+
`got: ${value}`
456+
);
457+
}
458+
459+
// For a working stack with at least one network, at least one diagnostic
460+
// should have reached "ok" (TreeXML succeeded). If everything is still
461+
// "discovering" after readiness, something's wrong.
462+
const okCount = diagStateTopics.filter(t => diagReceived[t] === 'ok').length;
463+
assert(
464+
`at least one network reached discovery_status=ok (${okCount} of ${diagStateTopics.length})`,
465+
okCount > 0 || diagStateTopics.length === 0,
466+
'all networks still in discovering/paused after readiness'
467+
);
468+
}
469+
386470
// ------------------------------------------------------------------
387471
// 7. Stability check — watch for 10s, no reconnects
388472
// ------------------------------------------------------------------

tests/discoveryE2E.test.js

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
// In-process end-to-end test for HA Discovery startup race protections
2+
// (v1.8.1 retry + v1.8.4 diagnostic sensor + v1.8.5 event-driven refresh).
3+
//
4+
// Wires a real CommandResponseProcessor to a real HaDiscovery (and a real
5+
// EventPublisher), feeds raw C-Gate command-port lines through processLine(),
6+
// and asserts that MQTT publishes and outbound C-Gate commands match what HA
7+
// users would actually observe.
8+
9+
const CommandResponseProcessor = require('../src/commandResponseProcessor');
10+
const EventPublisher = require('../src/eventPublisher');
11+
const HaDiscovery = require('../src/haDiscovery');
12+
const {
13+
CGATE_CMD_TREEXML,
14+
NEWLINE,
15+
DISCOVERY_STATE_DISCOVERING,
16+
DISCOVERY_STATE_OK,
17+
DISCOVERY_STATE_PAUSED
18+
} = require('../src/constants');
19+
20+
// Minimal but realistic TreeXML payload — flat application format, two lighting groups.
21+
const TREE_XML = `<?xml version="1.0" encoding="UTF-8"?>
22+
<Network>
23+
<NetworkNumber>254</NetworkNumber>
24+
<Unit>
25+
<UnitAddress>1</UnitAddress>
26+
<Application>
27+
<ApplicationAddress>56</ApplicationAddress>
28+
<Group>
29+
<GroupAddress>10</GroupAddress>
30+
<Label>Kitchen</Label>
31+
</Group>
32+
<Group>
33+
<GroupAddress>11</GroupAddress>
34+
<Label>Lounge</Label>
35+
</Group>
36+
</Application>
37+
</Unit>
38+
</Network>`;
39+
40+
function buildHarness(overrides = {}) {
41+
const sentCommands = [];
42+
const publishes = [];
43+
44+
const settings = {
45+
ha_discovery_enabled: true,
46+
ha_discovery_prefix: 'homeassistant',
47+
ha_discovery_networks: ['254'],
48+
cbusname: 'PROJECT',
49+
getallnetapp: null,
50+
eventPublishDedupWindowMs: 0,
51+
log_level: 'warn',
52+
...overrides
53+
};
54+
55+
const publishFn = (topic, payload, options) => {
56+
publishes.push({ topic, payload, options });
57+
};
58+
59+
const sendCommandFn = (cmd) => {
60+
sentCommands.push(cmd);
61+
};
62+
63+
const haDiscovery = new HaDiscovery(settings, publishFn, sendCommandFn);
64+
65+
const eventPublisher = new EventPublisher({
66+
settings,
67+
publishFn,
68+
mqttOptions: { retain: true, qos: 0 }
69+
});
70+
71+
const processor = new CommandResponseProcessor({
72+
eventPublisher,
73+
haDiscovery,
74+
onObjectStatus: () => {},
75+
// Mirrors the wiring in BridgeInitializationService.handleCommandError:
76+
// forward 4xx/5xx responses to HaDiscovery so it can recognise the
77+
// 401 "Network not found" that signals a TREEXML startup race.
78+
onCommandError: (code, statusData) => haDiscovery.handleCommandError(code, statusData)
79+
});
80+
81+
return { processor, haDiscovery, settings, sentCommands, publishes };
82+
}
83+
84+
function statePublishes(publishes, network) {
85+
return publishes
86+
.filter(p => p.topic === `cbus/read/${network}///discovery_status`)
87+
.map(p => p.payload);
88+
}
89+
90+
function configPublishes(publishes, network) {
91+
return publishes.filter(p => p.topic === `homeassistant/sensor/cgateweb_discovery_${network}/config`);
92+
}
93+
94+
describe('HA Discovery e2e (real processor + real haDiscovery)', () => {
95+
beforeEach(() => {
96+
jest.useFakeTimers();
97+
});
98+
99+
afterEach(() => {
100+
jest.useRealTimers();
101+
});
102+
103+
it('happy path: TREEXML 343/347/344 sequence advances diagnostic to ok', async () => {
104+
const h = buildHarness();
105+
h.haDiscovery.trigger();
106+
107+
// First TREEXML went out, sensor is "discovering"
108+
expect(h.sentCommands).toEqual([`${CGATE_CMD_TREEXML} 254${NEWLINE}`]);
109+
expect(statePublishes(h.publishes, '254')).toEqual([DISCOVERY_STATE_DISCOVERING]);
110+
expect(configPublishes(h.publishes, '254')).toHaveLength(1);
111+
112+
// C-Gate responds with the standard tree sequence
113+
h.processor.processLine('343-Begin TreeXML');
114+
for (const line of TREE_XML.split('\n')) {
115+
h.processor.processLine(`347-${line}`);
116+
}
117+
h.processor.processLine('344-End TreeXML');
118+
119+
// xml2js.parseString is async — flush microtasks
120+
await Promise.resolve();
121+
await Promise.resolve();
122+
123+
const states = statePublishes(h.publishes, '254');
124+
expect(states[states.length - 1]).toBe(DISCOVERY_STATE_OK);
125+
126+
// Discovery payloads for Kitchen + Lounge published under homeassistant/light/...
127+
const lightConfigs = h.publishes.filter(
128+
p => /^homeassistant\/light\/cgateweb_254_56_(10|11)\/config$/.test(p.topic)
129+
);
130+
expect(lightConfigs).toHaveLength(2);
131+
});
132+
133+
it('startup race: 401 Network not found triggers retry; eventual success → ok', async () => {
134+
const h = buildHarness();
135+
h.haDiscovery.trigger();
136+
expect(h.sentCommands).toHaveLength(1);
137+
138+
// C-Gate returns 401 because the network isn't loaded yet
139+
h.processor.processLine('401-Bad object or device ID: Network not found');
140+
141+
// No new command yet — retry is pending
142+
expect(h.sentCommands).toHaveLength(1);
143+
144+
// Diagnostic stays "discovering" through the retry (de-duped)
145+
expect(statePublishes(h.publishes, '254')).toEqual([DISCOVERY_STATE_DISCOVERING]);
146+
147+
// Wait for the 2s backoff
148+
jest.advanceTimersByTime(2000);
149+
expect(h.sentCommands).toHaveLength(2);
150+
151+
// This time C-Gate has loaded the network — full tree response
152+
h.processor.processLine('343-Begin TreeXML');
153+
h.processor.processLine(`347-${TREE_XML}`);
154+
h.processor.processLine('344-End TreeXML');
155+
156+
await Promise.resolve();
157+
await Promise.resolve();
158+
159+
const states = statePublishes(h.publishes, '254');
160+
expect(states[states.length - 1]).toBe(DISCOVERY_STATE_OK);
161+
});
162+
163+
it('giveup path: 9 consecutive 401s exhaust retry budget, sensor reaches paused', () => {
164+
const h = buildHarness();
165+
h.haDiscovery.trigger();
166+
167+
// 8 retries permitted, 9th failure exhausts the budget
168+
for (let i = 1; i <= 8; i++) {
169+
h.processor.processLine('401-Bad object or device ID: Network not found');
170+
jest.runOnlyPendingTimers();
171+
}
172+
h.processor.processLine('401-Bad object or device ID: Network not found');
173+
174+
const states = statePublishes(h.publishes, '254');
175+
expect(states[states.length - 1]).toBe(DISCOVERY_STATE_PAUSED);
176+
});
177+
178+
it('event-driven refresh: timestamped 742 Network created triggers TREEXML', () => {
179+
const h = buildHarness();
180+
// No initial trigger — simulate the bridge before discovery has run.
181+
182+
h.processor.processLine(
183+
'20260504-193110.569 742 //PROJECT/254 c2211b00-28c1-103f-94b5-db702a32859b ' +
184+
'Network created type=cni address=192.168.0.100:10001'
185+
);
186+
187+
expect(h.sentCommands).toEqual([`${CGATE_CMD_TREEXML} 254${NEWLINE}`]);
188+
expect(statePublishes(h.publishes, '254')).toEqual([DISCOVERY_STATE_DISCOVERING]);
189+
});
190+
191+
it('event-driven refresh fires only for configured networks', () => {
192+
const h = buildHarness({ ha_discovery_networks: ['254'] });
193+
194+
h.processor.processLine(
195+
'20260504-193110.569 742 //PROJECT/999 abc Network created type=cni'
196+
);
197+
198+
expect(h.sentCommands).toEqual([]);
199+
});
200+
201+
it('Network created mid-backoff short-circuits the v1.8.1 retry timer', () => {
202+
const h = buildHarness();
203+
h.haDiscovery.trigger();
204+
h.processor.processLine('401-Bad object or device ID: Network not found');
205+
// Retry scheduled for 2s.
206+
207+
const sentBefore = h.sentCommands.length;
208+
h.processor.processLine(
209+
'20260504-193110.569 742 //PROJECT/254 uuid Network created type=cni'
210+
);
211+
// Event triggered an immediate fresh TREEXML.
212+
expect(h.sentCommands).toHaveLength(sentBefore + 1);
213+
214+
// The original retry was canceled — no extra command after the 2s window.
215+
jest.advanceTimersByTime(2500);
216+
expect(h.sentCommands).toHaveLength(sentBefore + 1);
217+
});
218+
219+
it('non-Network 742 events do not trigger discovery', () => {
220+
const h = buildHarness();
221+
222+
h.processor.processLine(
223+
'20260504-193110.421 742 //PROJECT - Tag information changed at tag address: //PROJECT/Installation oldtag: null newtag: null'
224+
);
225+
h.processor.processLine(
226+
'20260504-193120.394 836 //PROJECT/254/p/12 c21ed110-... unit configuration changed (5 changes)'
227+
);
228+
229+
expect(h.sentCommands).toEqual([]);
230+
});
231+
232+
it('parser hardening: hyphens inside payload UUIDs do not break parsing', () => {
233+
const h = buildHarness();
234+
235+
// Without the v1.8.5 parser fix, the hyphen inside the UUID would be
236+
// mistaken for the code/data separator and the line would be skipped.
237+
h.processor.processLine(
238+
'20260504-193110.569 742 //PROJECT/254 c2211b00-28c1-103f-94b5-db702a32859b Network created type=cni address=192.168.0.100:10001'
239+
);
240+
241+
expect(h.sentCommands).toEqual([`${CGATE_CMD_TREEXML} 254${NEWLINE}`]);
242+
});
243+
244+
it('config payload retains diagnostic shape across the full lifecycle', () => {
245+
const h = buildHarness();
246+
h.haDiscovery.trigger();
247+
248+
const cfg = configPublishes(h.publishes, '254')[0];
249+
expect(cfg).toBeDefined();
250+
const payload = JSON.parse(cfg.payload);
251+
252+
// Required HA Discovery fields
253+
expect(payload.unique_id).toBe('cgateweb_discovery_254');
254+
expect(payload.state_topic).toBe('cbus/read/254///discovery_status');
255+
expect(payload.entity_category).toBe('diagnostic');
256+
expect(payload.availability_topic).toBe('hello/cgateweb');
257+
expect(payload.payload_available).toBe('Online');
258+
expect(payload.payload_not_available).toBe('Offline');
259+
260+
// Grouped under the existing cgateweb Bridge device so it sits next to
261+
// the other diagnostics (Bridge Ready, MQTT Connected, etc.).
262+
expect(payload.device.identifiers).toContain('cgateweb_bridge');
263+
264+
// Retained so HA always has the latest state cached.
265+
expect(cfg.options).toEqual({ retain: true, qos: 0 });
266+
});
267+
});

0 commit comments

Comments
 (0)