3e779be6f3
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
CI / test (3.14, ubuntu-24.04-arm) (push) Has been cancelled
165 lines
6.4 KiB
Python
165 lines
6.4 KiB
Python
# 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
|