# SPDX-License-Identifier: MIT """Pin the on-disk config schema_version marker + the migration hook. 0.6.0 introduces the marker without changing the TOML layout, so the hook is a no-op today. These tests lock the contract so a 0.7.0+ migration lands with the seam intact: * Config.load() reads ``schema_version`` (missing -> 0 = pre-0.6.0). * When the read value differs from SCHEMA_VERSION, _migrate_config() runs and cfg.schema_version is bumped to current. * save() writes ``schema_version`` at the top of the TOML. * A pre-0.6.0 file (no marker) round-trips: load it, save it, the file now has schema_version = SCHEMA_VERSION and no user settings dropped. """ from __future__ import annotations from pathlib import Path from unittest.mock import patch import pytest from winpodx.core.config import SCHEMA_VERSION, Config, _migrate_config def test_schema_version_constant_is_positive() -> None: # Sanity: 0 is reserved for "pre-marker file", so the live constant # must be >= 1. assert isinstance(SCHEMA_VERSION, int) assert SCHEMA_VERSION >= 1 def test_config_default_carries_current_schema_version() -> None: cfg = Config() assert cfg.schema_version == SCHEMA_VERSION def test_migrate_hook_is_noop_at_current_version() -> None: # 0.6.0 introduces the marker without restructuring the file, so the # hook returns the data unchanged. The contract is *the dict is # returned*, not "the same identity" -- future migrations may copy. data = {"rdp": {"user": "alice"}, "schema_version": SCHEMA_VERSION} out = _migrate_config(dict(data), SCHEMA_VERSION) assert out == data def test_migrate_hook_from_pre_marker_preserves_settings() -> None: # A pre-0.6.0 file has no schema_version key. The hook receives # from_version=0 and must not drop any user-authored settings. data = { "rdp": {"user": "alice", "port": 3389}, "pod": {"backend": "podman"}, "ui": {"language": "ko"}, } out = _migrate_config(dict(data), 0) # Today's hook is a no-op, so all keys round-trip. assert out["rdp"] == data["rdp"] assert out["pod"] == data["pod"] assert out["ui"] == data["ui"] def test_load_unmarked_file_bumps_schema_version(tmp_path: Path) -> None: # Write a pre-0.6.0 file (no schema_version) and confirm load() sets # cfg.schema_version = SCHEMA_VERSION so the next save converges. cfg_path = tmp_path / "winpodx.toml" cfg_path.write_text( "[rdp]\nuser = 'alice'\nport = 3389\n[pod]\nbackend = 'podman'\n", encoding="utf-8", ) with patch.object(Config, "path", classmethod(lambda cls: cfg_path)): cfg = Config.load() assert cfg.schema_version == SCHEMA_VERSION assert cfg.rdp.user == "alice" assert cfg.pod.backend == "podman" def test_load_corrupt_schema_version_falls_back_to_zero(tmp_path: Path) -> None: # A hand-edit could write a non-int value; load must not crash, it # treats the file as pre-marker and runs the migration hook. cfg_path = tmp_path / "winpodx.toml" cfg_path.write_text( "schema_version = 'banana'\n[rdp]\nuser = 'bob'\n", encoding="utf-8", ) with patch.object(Config, "path", classmethod(lambda cls: cfg_path)): cfg = Config.load() assert cfg.schema_version == SCHEMA_VERSION assert cfg.rdp.user == "bob" def test_save_emits_schema_version_first(tmp_path: Path) -> None: # Marker MUST land in the saved file (so a future load sees it) and # MUST be near the top so a hand-edit can flag the layout version # without scrolling. cfg_path = tmp_path / "winpodx.toml" with patch.object(Config, "path", classmethod(lambda cls: cfg_path)): cfg = Config() cfg.save() text = cfg_path.read_text(encoding="utf-8") assert f"schema_version = {SCHEMA_VERSION}" in text # Must appear before any section header. first_section_idx = text.find("[") schema_idx = text.find("schema_version") assert 0 <= schema_idx < first_section_idx def test_legacy_file_round_trip_through_save_keeps_settings(tmp_path: Path) -> None: # The full upgrade story: load a 0.5.x file -> save -> reload. Every # user setting survives, and the file gains the marker. cfg_path = tmp_path / "winpodx.toml" cfg_path.write_text( "[rdp]\nuser = 'carol'\nport = 4001\n[ui]\nlanguage = 'ko'\n", encoding="utf-8", ) with patch.object(Config, "path", classmethod(lambda cls: cfg_path)): cfg = Config.load() cfg.save() reloaded = Config.load() assert reloaded.rdp.user == "carol" assert reloaded.rdp.port == 4001 assert reloaded.ui.language == "ko" assert reloaded.schema_version == SCHEMA_VERSION def test_schema_version_not_dropped_by_apply(tmp_path: Path) -> None: # _apply mustn't trip over a top-level non-section key. A future # accident (typo'd field name, stray hand-edit) shouldn't break load. cfg_path = tmp_path / "winpodx.toml" cfg_path.write_text( f"schema_version = {SCHEMA_VERSION}\nstray_top_level_key = 42\n[rdp]\nuser = 'dave'\n", encoding="utf-8", ) with patch.object(Config, "path", classmethod(lambda cls: cfg_path)): cfg = Config.load() assert cfg.schema_version == SCHEMA_VERSION assert cfg.rdp.user == "dave" @pytest.mark.parametrize("from_v", [0, 1]) def test_migrate_hook_called_with_correct_from_version(from_v: int) -> None: # When _migrate_config grows real logic, callers must pass the # original file version. Locking the call shape now keeps a future # 0.7.0 migration from being silently bypassed. calls: list[tuple[int, dict]] = [] def spy(data: dict, from_version: int) -> dict: calls.append((from_version, dict(data))) return data if from_v == SCHEMA_VERSION: # Same version: hook is intentionally skipped (no migration needed). return with patch("winpodx.core.config._migrate_config", side_effect=spy): # Exercise via load() against an in-memory dict. from winpodx.core import config as cfg_mod Config.load.__func__ # noqa: B018 — sanity-check method exists # Direct call path: simulate what load does. data = {"schema_version": from_v} if from_v else {} from_version = int(data.get("schema_version", 0)) if from_version != cfg_mod.SCHEMA_VERSION: cfg_mod._migrate_config(data, from_version) assert calls and calls[0][0] == from_v