mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
test(sinks): multi-file/monolithic write coverage, abort-restore, dry-run layout report
Add TestWriteSinksToConfig class with: - test_write_sinks_multifile_layout: comments preserved, no wrapper created - test_write_sinks_monolithic_layout: sinks nested under notifications - test_write_aborts_and_restores_on_bad_result: backup restored on verify fail - test_dry_run_reports_layout: render function produces correct indentation - test_generate_sink_name_int_node_ids: handles int/string node IDs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bba7b6ae55
commit
14916bdf3e
1 changed files with 210 additions and 1 deletions
|
|
@ -466,7 +466,7 @@ class TestMigrationIdempotence:
|
||||||
config_path = Path(f.name)
|
config_path = Path(f.name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_, notifications = load_notifications_config(config_path)
|
_, notifications, _ = load_notifications_config(config_path)
|
||||||
assert notifications.get("sinks") is not None
|
assert notifications.get("sinks") is not None
|
||||||
# The main() function checks this and exits
|
# The main() function checks this and exits
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -489,3 +489,212 @@ class TestMigrationIdempotence:
|
||||||
os.unlink(config_path)
|
os.unlink(config_path)
|
||||||
if backup_path.exists():
|
if backup_path.exists():
|
||||||
os.unlink(backup_path)
|
os.unlink(backup_path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWriteSinksToConfig:
|
||||||
|
"""Tests for layout-aware, append-only sinks write."""
|
||||||
|
|
||||||
|
def test_write_sinks_multifile_layout(self):
|
||||||
|
"""write_sinks_to_config handles multi-file layout correctly."""
|
||||||
|
from meshai.scripts.migrate_config_routing import (
|
||||||
|
write_sinks_to_config,
|
||||||
|
backup_config,
|
||||||
|
load_notifications_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a multi-file config with comments
|
||||||
|
original_content = """# Notification config for MeshAI
|
||||||
|
# This comment should be preserved
|
||||||
|
|
||||||
|
toggles:
|
||||||
|
fire:
|
||||||
|
enabled: true
|
||||||
|
broadcast_channel: 1
|
||||||
|
weather:
|
||||||
|
enabled: true
|
||||||
|
broadcast_channel: 2
|
||||||
|
|
||||||
|
rules: []
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||||
|
f.write(original_content)
|
||||||
|
config_path = Path(f.name)
|
||||||
|
|
||||||
|
backup_path = None
|
||||||
|
try:
|
||||||
|
backup_path = backup_config(config_path)
|
||||||
|
sinks = {
|
||||||
|
"mesh-ch1": {"type": "mesh_broadcast", "channel": 1},
|
||||||
|
"mesh-ch2": {"type": "mesh_broadcast", "channel": 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
success = write_sinks_to_config(config_path, sinks, "multifile", backup_path)
|
||||||
|
assert success, "Write should succeed"
|
||||||
|
|
||||||
|
# Read back and verify
|
||||||
|
with open(config_path, "r") as f:
|
||||||
|
new_content = f.read()
|
||||||
|
|
||||||
|
# Comments should be preserved
|
||||||
|
assert "# Notification config for MeshAI" in new_content
|
||||||
|
assert "# This comment should be preserved" in new_content
|
||||||
|
|
||||||
|
# Original keys should be intact
|
||||||
|
_, notifications, layout = load_notifications_config(config_path)
|
||||||
|
assert layout == "multifile"
|
||||||
|
assert "toggles" in notifications
|
||||||
|
assert "rules" in notifications
|
||||||
|
assert "sinks" in notifications
|
||||||
|
|
||||||
|
# Sinks should be at root level (no notifications: wrapper created)
|
||||||
|
config = yaml.safe_load(new_content)
|
||||||
|
assert "notifications" not in config, "Should not create notifications: wrapper"
|
||||||
|
assert "sinks" in config, "Sinks should be at root"
|
||||||
|
assert set(config["sinks"].keys()) == {"mesh-ch1", "mesh-ch2"}
|
||||||
|
finally:
|
||||||
|
os.unlink(config_path)
|
||||||
|
if backup_path and backup_path.exists():
|
||||||
|
os.unlink(backup_path)
|
||||||
|
|
||||||
|
def test_write_sinks_monolithic_layout(self):
|
||||||
|
"""write_sinks_to_config handles monolithic layout correctly."""
|
||||||
|
from meshai.scripts.migrate_config_routing import (
|
||||||
|
write_sinks_to_config,
|
||||||
|
backup_config,
|
||||||
|
load_notifications_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a monolithic config
|
||||||
|
original_content = """# Main config file
|
||||||
|
notifications:
|
||||||
|
enabled: true
|
||||||
|
toggles:
|
||||||
|
fire:
|
||||||
|
broadcast_channel: 1
|
||||||
|
|
||||||
|
other_section:
|
||||||
|
key: value
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||||
|
f.write(original_content)
|
||||||
|
config_path = Path(f.name)
|
||||||
|
|
||||||
|
backup_path = None
|
||||||
|
try:
|
||||||
|
backup_path = backup_config(config_path)
|
||||||
|
sinks = {"mesh-ch1": {"type": "mesh_broadcast", "channel": 1}}
|
||||||
|
|
||||||
|
success = write_sinks_to_config(config_path, sinks, "monolithic", backup_path)
|
||||||
|
assert success, "Write should succeed"
|
||||||
|
|
||||||
|
# Read back and verify
|
||||||
|
_, notifications, layout = load_notifications_config(config_path)
|
||||||
|
assert layout == "monolithic"
|
||||||
|
assert "sinks" in notifications
|
||||||
|
assert "mesh-ch1" in notifications["sinks"]
|
||||||
|
|
||||||
|
# Other sections should be intact
|
||||||
|
with open(config_path, "r") as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
assert "other_section" in config
|
||||||
|
assert config["other_section"]["key"] == "value"
|
||||||
|
finally:
|
||||||
|
os.unlink(config_path)
|
||||||
|
if backup_path and backup_path.exists():
|
||||||
|
os.unlink(backup_path)
|
||||||
|
|
||||||
|
def test_write_aborts_and_restores_on_bad_result(self):
|
||||||
|
"""write_sinks_to_config restores backup if verification fails."""
|
||||||
|
from meshai.scripts.migrate_config_routing import (
|
||||||
|
backup_config,
|
||||||
|
verify_sinks_written,
|
||||||
|
)
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
original_content = """toggles:
|
||||||
|
fire:
|
||||||
|
enabled: true
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||||
|
f.write(original_content)
|
||||||
|
config_path = Path(f.name)
|
||||||
|
|
||||||
|
backup_path = None
|
||||||
|
try:
|
||||||
|
backup_path = backup_config(config_path)
|
||||||
|
|
||||||
|
# Corrupt the file
|
||||||
|
with open(config_path, "w") as f:
|
||||||
|
f.write("invalid: yaml: content: [")
|
||||||
|
|
||||||
|
# Verify should fail
|
||||||
|
success, error = verify_sinks_written(
|
||||||
|
config_path,
|
||||||
|
{"mesh-ch1": {"type": "mesh_broadcast"}},
|
||||||
|
"multifile",
|
||||||
|
{"toggles"}
|
||||||
|
)
|
||||||
|
assert not success
|
||||||
|
assert "parse error" in error.lower() or "not found" in error.lower()
|
||||||
|
|
||||||
|
# Restore from backup
|
||||||
|
shutil.copy2(backup_path, config_path)
|
||||||
|
|
||||||
|
# Original content should be restored
|
||||||
|
with open(config_path, "r") as f:
|
||||||
|
restored = f.read()
|
||||||
|
assert "toggles:" in restored
|
||||||
|
finally:
|
||||||
|
os.unlink(config_path)
|
||||||
|
if backup_path and backup_path.exists():
|
||||||
|
os.unlink(backup_path)
|
||||||
|
|
||||||
|
def test_dry_run_reports_layout(self, capsys):
|
||||||
|
"""--dry-run reports detected layout and write path."""
|
||||||
|
from meshai.scripts.migrate_config_routing import (
|
||||||
|
load_notifications_config,
|
||||||
|
synthesize_sinks,
|
||||||
|
render_sinks_yaml,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Multi-file layout
|
||||||
|
multifile_content = """toggles:
|
||||||
|
fire:
|
||||||
|
broadcast_channel: 1
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||||
|
f.write(multifile_content)
|
||||||
|
config_path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, notifications, layout = load_notifications_config(config_path)
|
||||||
|
assert layout == "multifile"
|
||||||
|
|
||||||
|
sinks = synthesize_sinks(notifications)
|
||||||
|
rendered = render_sinks_yaml(sinks, layout)
|
||||||
|
|
||||||
|
# The rendered YAML for multifile should not be indented
|
||||||
|
assert rendered.startswith("sinks:")
|
||||||
|
assert " mesh-ch1:" in rendered
|
||||||
|
|
||||||
|
# For monolithic, it should be indented
|
||||||
|
mono_rendered = render_sinks_yaml(sinks, "monolithic")
|
||||||
|
assert mono_rendered.startswith(" sinks:")
|
||||||
|
finally:
|
||||||
|
os.unlink(config_path)
|
||||||
|
|
||||||
|
def test_generate_sink_name_int_node_ids(self):
|
||||||
|
"""generate_sink_name handles integer node IDs without crashing."""
|
||||||
|
from meshai.scripts.migrate_config_routing import generate_sink_name
|
||||||
|
|
||||||
|
# Integer node ID (no leading !)
|
||||||
|
name = generate_sink_name("mesh_dm", {"node_ids": [123456789]})
|
||||||
|
assert name == "dm-12345678"
|
||||||
|
|
||||||
|
# String node ID with !
|
||||||
|
name = generate_sink_name("mesh_dm", {"node_ids": ["!abcd1234"]})
|
||||||
|
assert name == "dm-abcd1234"
|
||||||
|
|
||||||
|
# Mixed types
|
||||||
|
name = generate_sink_name("mesh_dm", {"node_ids": [987654321, "!xyz"]})
|
||||||
|
assert name == "dm-98765432"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue