Skip to content

Commit 53ec392

Browse files
committed
fix(test): use wait_for_tool_result for backup-list assertions
The new full-loop tests (helper, label, category, zone, area, group, entity, dashboard_resource, schedule) asserted ``len(mine) >= 1`` on a synchronous list call directly after the triggering edit. That exposed a HA storage propagation race: between create and edit, the decorator's pre-edit fetch may run before HA's storage layer has indexed the freshly-created entity. The original ``input_boolean`` test only checked ``listing.get("success") is True`` so the race was silently masked. Add ``_wait_for_backup`` helper that polls the backup list via ``wait_for_tool_result`` until the expected snapshot appears (15s default timeout). Replace every per-test synchronous list+assert block with a single ``_wait_for_backup`` call. Schedule edit also needed ``name`` in the payload — HA's schedule WS update schema rejects ``required key not provided @ data['name']`` without it. The input_boolean edit gets the same field defensively. Entity test wraps the poll in ``try/TimeoutError → pytest.skip`` so HA versions where ``ha_set_entity`` takes the entity-registry path (not POST ``/api/states``) skip instead of failing — the decorator doesn't fire for that path, and surfacing a clear skip beats an opaque assert. Group test was also keying on ``group.<object_id>`` for the lookup, but the decorator uses ``id_param="object_id"`` so the snapshot is keyed on the bare object_id. Fixed.
1 parent ff33217 commit 53ec392

1 file changed

Lines changed: 74 additions & 170 deletions

File tree

tests/src/e2e/workflows/auto_backup/test_capture_and_restore.py

Lines changed: 74 additions & 170 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import pytest
2121

2222
from ...utilities.assertions import safe_call_tool
23+
from ...utilities.wait_helpers import wait_for_tool_result
2324

2425
logger = logging.getLogger(__name__)
2526

@@ -48,6 +49,42 @@ def _backups_for(
4849
return [e for e in entries if e["domain"] == domain and e["entity_id"] == entity_id]
4950

5051

52+
async def _wait_for_backup(
53+
mcp_client, *, domain: str, entity_id: str, timeout: int = 15
54+
) -> str:
55+
"""Poll the backup list until at least one snapshot for ``domain:entity_id``
56+
appears, returning the first backup name.
57+
58+
Captures are written synchronously by the decorator before the wrapped
59+
write returns, but HA storage propagation between create and edit can
60+
delay when the decorator's pre-edit fetch sees the freshly-created
61+
entity. The poll absorbs that delay rather than asserting on the first
62+
list call (which would fail with ``assert 0 >= 1`` for slow rigs).
63+
"""
64+
data = await wait_for_tool_result(
65+
mcp_client,
66+
tool_name="ha_manage_backup",
67+
arguments={
68+
"scope": "edits",
69+
"action": "list",
70+
"domain": domain,
71+
"entity_id": entity_id,
72+
},
73+
predicate=lambda d: bool(
74+
_backups_for(
75+
d.get("backups", []) or d.get("data", {}).get("backups", []),
76+
domain=domain,
77+
entity_id=entity_id,
78+
)
79+
),
80+
description=f"auto-backup snapshot for {domain}:{entity_id}",
81+
timeout=timeout,
82+
)
83+
entries = data.get("backups", []) or data.get("data", {}).get("backups", [])
84+
mine = _backups_for(entries, domain=domain, entity_id=entity_id)
85+
return mine[0]["name"]
86+
87+
5188
# ---------------------------------------------------------------- gating
5289

5390

@@ -238,23 +275,17 @@ async def test_input_boolean_full_loop(
238275
{
239276
"helper_type": "input_boolean",
240277
"helper_id": helper_id,
278+
"name": helper_id,
241279
"icon": "mdi:test-tube-empty",
242280
},
243281
)
244282
assert edit.get("success") is not False
245283

246-
# List backups in the helper_input_boolean domain.
247-
listing = await safe_call_tool(
248-
mcp_client,
249-
"ha_manage_backup",
250-
{"scope": "edits", "action": "list", "domain": "helper_input_boolean"},
284+
# Poll until the snapshot file appears — absorbs HA storage
285+
# propagation delay between create and edit on slower rigs.
286+
backup_name = await _wait_for_backup(
287+
mcp_client, domain="helper_input_boolean", entity_id=helper_id
251288
)
252-
assert listing.get("success") is True
253-
entries = listing.get("data", {}).get("backups", [])
254-
mine = _backups_for(entries, domain="helper_input_boolean", entity_id=helper_id)
255-
assert len(mine) >= 1
256-
257-
backup_name = mine[0]["name"]
258289

259290
# Restore — exercises ``input_boolean/update`` WS command.
260291
restore = await safe_call_tool(
@@ -325,9 +356,12 @@ async def test_schedule_full_loop(
325356
assert create.get("success") is not False
326357

327358
# Edit — shrink Monday, split Tuesday, drop Wednesday entirely.
359+
# ``name`` is required by HA's schedule schema on every update
360+
# (validator rejects the WS payload otherwise).
328361
edited = {
329362
"helper_type": "schedule",
330363
"helper_id": helper_id,
364+
"name": helper_id,
331365
"icon": "mdi:calendar-remove",
332366
"monday": [{"from": "10:00:00", "to": "12:00:00"}],
333367
"tuesday": [
@@ -340,22 +374,11 @@ async def test_schedule_full_loop(
340374
edit = await safe_call_tool(mcp_client, "ha_config_set_helper", edited)
341375
assert edit.get("success") is not False
342376

343-
# List + verify the snapshot captured the pre-edit nested structure.
344-
listing = await safe_call_tool(
345-
mcp_client,
346-
"ha_manage_backup",
347-
{
348-
"scope": "edits",
349-
"action": "list",
350-
"domain": "helper_schedule",
351-
"entity_id": helper_id,
352-
},
377+
# Poll until the schedule snapshot appears so an HA storage
378+
# propagation race doesn't masquerade as a real assert.
379+
backup_name = await _wait_for_backup(
380+
mcp_client, domain="helper_schedule", entity_id=helper_id
353381
)
354-
assert listing.get("success") is True
355-
entries = listing.get("data", {}).get("backups", [])
356-
mine = _backups_for(entries, domain="helper_schedule", entity_id=helper_id)
357-
assert len(mine) >= 1, "snapshot not captured for schedule edit"
358-
backup_name = mine[0]["name"]
359382

360383
# View — assert the nested arrays survived YAML round-trip into
361384
# the snapshot file. Schedule shape:
@@ -437,21 +460,9 @@ async def test_dashboard_full_loop(
437460
"config": {"views": [{"title": "Updated"}]},
438461
},
439462
)
440-
listing = await safe_call_tool(
441-
mcp_client,
442-
"ha_manage_backup",
443-
{
444-
"scope": "edits",
445-
"action": "list",
446-
"domain": "dashboard",
447-
"entity_id": url_path,
448-
},
463+
backup_name = await _wait_for_backup(
464+
mcp_client, domain="dashboard", entity_id=url_path
449465
)
450-
assert listing.get("success") is True
451-
entries = listing.get("data", {}).get("backups", [])
452-
mine = _backups_for(entries, domain="dashboard", entity_id=url_path)
453-
assert len(mine) >= 1
454-
backup_name = mine[0]["name"]
455466

456467
# Restore — fires ``lovelace/config/save``.
457468
restore = await safe_call_tool(
@@ -518,21 +529,9 @@ async def test_script_full_loop(
518529
},
519530
},
520531
)
521-
listing = await safe_call_tool(
522-
mcp_client,
523-
"ha_manage_backup",
524-
{
525-
"scope": "edits",
526-
"action": "list",
527-
"domain": "script",
528-
"entity_id": script_id,
529-
},
532+
backup_name = await _wait_for_backup(
533+
mcp_client, domain="script", entity_id=script_id
530534
)
531-
assert listing.get("success") is True
532-
entries = listing.get("data", {}).get("backups", [])
533-
mine = _backups_for(entries, domain="script", entity_id=script_id)
534-
assert len(mine) >= 1
535-
backup_name = mine[0]["name"]
536535

537536
restore = await safe_call_tool(
538537
mcp_client,
@@ -595,21 +594,9 @@ async def test_scene_full_loop(
595594
},
596595
},
597596
)
598-
listing = await safe_call_tool(
599-
mcp_client,
600-
"ha_manage_backup",
601-
{
602-
"scope": "edits",
603-
"action": "list",
604-
"domain": "scene",
605-
"entity_id": scene_id,
606-
},
597+
backup_name = await _wait_for_backup(
598+
mcp_client, domain="scene", entity_id=scene_id
607599
)
608-
assert listing.get("success") is True
609-
entries = listing.get("data", {}).get("backups", [])
610-
mine = _backups_for(entries, domain="scene", entity_id=scene_id)
611-
assert len(mine) >= 1
612-
backup_name = mine[0]["name"]
613600

614601
restore = await safe_call_tool(
615602
mcp_client,
@@ -742,21 +729,9 @@ async def test_label_full_loop(
742729
)
743730
assert edit.get("success") is not False
744731

745-
listing = await safe_call_tool(
746-
mcp_client,
747-
"ha_manage_backup",
748-
{
749-
"scope": "edits",
750-
"action": "list",
751-
"domain": "label",
752-
"entity_id": label_id,
753-
},
732+
backup_name = await _wait_for_backup(
733+
mcp_client, domain="label", entity_id=label_id
754734
)
755-
assert listing.get("success") is True
756-
entries = listing.get("data", {}).get("backups", [])
757-
mine = _backups_for(entries, domain="label", entity_id=label_id)
758-
assert len(mine) >= 1
759-
backup_name = mine[0]["name"]
760735

761736
restore = await safe_call_tool(
762737
mcp_client,
@@ -825,21 +800,9 @@ async def test_category_full_loop(
825800
)
826801
assert edit.get("success") is not False
827802

828-
listing = await safe_call_tool(
829-
mcp_client,
830-
"ha_manage_backup",
831-
{
832-
"scope": "edits",
833-
"action": "list",
834-
"domain": "category",
835-
"entity_id": composite,
836-
},
803+
backup_name = await _wait_for_backup(
804+
mcp_client, domain="category", entity_id=composite
837805
)
838-
assert listing.get("success") is True
839-
entries = listing.get("data", {}).get("backups", [])
840-
mine = _backups_for(entries, domain="category", entity_id=composite)
841-
assert len(mine) >= 1
842-
backup_name = mine[0]["name"]
843806

844807
restore = await safe_call_tool(
845808
mcp_client,
@@ -908,21 +871,9 @@ async def test_zone_full_loop(
908871
)
909872
assert edit.get("success") is not False
910873

911-
listing = await safe_call_tool(
912-
mcp_client,
913-
"ha_manage_backup",
914-
{
915-
"scope": "edits",
916-
"action": "list",
917-
"domain": "zone",
918-
"entity_id": zone_id,
919-
},
874+
backup_name = await _wait_for_backup(
875+
mcp_client, domain="zone", entity_id=zone_id
920876
)
921-
assert listing.get("success") is True
922-
entries = listing.get("data", {}).get("backups", [])
923-
mine = _backups_for(entries, domain="zone", entity_id=zone_id)
924-
assert len(mine) >= 1
925-
backup_name = mine[0]["name"]
926877

927878
restore = await safe_call_tool(
928879
mcp_client,
@@ -988,21 +939,9 @@ async def test_area_full_loop(
988939
)
989940
assert edit.get("success") is not False
990941

991-
listing = await safe_call_tool(
992-
mcp_client,
993-
"ha_manage_backup",
994-
{
995-
"scope": "edits",
996-
"action": "list",
997-
"domain": "area_or_floor",
998-
"entity_id": composite,
999-
},
942+
backup_name = await _wait_for_backup(
943+
mcp_client, domain="area_or_floor", entity_id=composite
1000944
)
1001-
assert listing.get("success") is True
1002-
entries = listing.get("data", {}).get("backups", [])
1003-
mine = _backups_for(entries, domain="area_or_floor", entity_id=composite)
1004-
assert len(mine) >= 1
1005-
backup_name = mine[0]["name"]
1006945

1007946
restore = await safe_call_tool(
1008947
mcp_client,
@@ -1068,22 +1007,11 @@ async def test_group_full_loop(
10681007
)
10691008
assert edit.get("success") is not False
10701009

1071-
entity_id = f"group.{object_id}"
1072-
listing = await safe_call_tool(
1073-
mcp_client,
1074-
"ha_manage_backup",
1075-
{
1076-
"scope": "edits",
1077-
"action": "list",
1078-
"domain": "group",
1079-
"entity_id": entity_id,
1080-
},
1010+
# Decorator uses ``id_param="object_id"`` so the snapshot keys on
1011+
# the object_id, not the full ``group.<id>`` entity_id form.
1012+
backup_name = await _wait_for_backup(
1013+
mcp_client, domain="group", entity_id=object_id
10811014
)
1082-
assert listing.get("success") is True
1083-
entries = listing.get("data", {}).get("backups", [])
1084-
mine = _backups_for(entries, domain="group", entity_id=entity_id)
1085-
assert len(mine) >= 1
1086-
backup_name = mine[0]["name"]
10871015

10881016
restore = await safe_call_tool(
10891017
mcp_client,
@@ -1141,25 +1069,15 @@ async def test_entity_full_loop(
11411069
if edit.get("success") is False:
11421070
pytest.skip(f"ha_set_entity unsupported on this HA: {edit}")
11431071

1144-
listing = await safe_call_tool(
1145-
mcp_client,
1146-
"ha_manage_backup",
1147-
{
1148-
"scope": "edits",
1149-
"action": "list",
1150-
"domain": "entity",
1151-
"entity_id": entity_id,
1152-
},
1153-
)
1154-
assert listing.get("success") is True
1155-
entries = listing.get("data", {}).get("backups", [])
1156-
mine = _backups_for(entries, domain="entity", entity_id=entity_id)
1157-
if not mine:
1072+
try:
1073+
backup_name = await _wait_for_backup(
1074+
mcp_client, domain="entity", entity_id=entity_id, timeout=10
1075+
)
1076+
except TimeoutError:
11581077
# ha_set_entity may take the entity-registry path (no /api/states
11591078
# POST) on some HA versions; if so the entity-domain handler
11601079
# won't fire. Surface clearly rather than asserting wrong.
11611080
pytest.skip("entity-domain handler did not capture for this edit")
1162-
backup_name = mine[0]["name"]
11631081

11641082
restore = await safe_call_tool(
11651083
mcp_client,
@@ -1226,23 +1144,9 @@ async def test_dashboard_resource_full_loop(
12261144
)
12271145
assert edit.get("success") is not False
12281146

1229-
listing = await safe_call_tool(
1230-
mcp_client,
1231-
"ha_manage_backup",
1232-
{
1233-
"scope": "edits",
1234-
"action": "list",
1235-
"domain": "dashboard_resource",
1236-
"entity_id": str(resource_id),
1237-
},
1238-
)
1239-
assert listing.get("success") is True
1240-
entries = listing.get("data", {}).get("backups", [])
1241-
mine = _backups_for(
1242-
entries, domain="dashboard_resource", entity_id=str(resource_id)
1147+
backup_name = await _wait_for_backup(
1148+
mcp_client, domain="dashboard_resource", entity_id=str(resource_id)
12431149
)
1244-
assert len(mine) >= 1
1245-
backup_name = mine[0]["name"]
12461150

12471151
restore = await safe_call_tool(
12481152
mcp_client,

0 commit comments

Comments
 (0)