"""Upgrade/migration robustness for settings.json: a user who upgrades from an older app version (legacy field names, removed fields, a field whose type drifted, a retired Literal value, or an outright corrupt file) must still boot. load_settings is called at startup, by GET /api/settings, and on every agent dispatch, so a raise here bricks the whole app. These tests pin both the migration mapping and the never-raise contract, and assert install-id / first_opened_at continuity.""" import json import os import pytest from backend.apps.settings import store from backend.apps.settings.models import AppSettings, DEFAULT_SYSTEM_PROMPT @pytest.fixture def settings_file(tmp_path, monkeypatch): """Point the store at an isolated settings.json under tmp_path.""" f = str(tmp_path / "settings.json") monkeypatch.setattr(store, "DATA_DIR", str(tmp_path)) monkeypatch.setattr(store, "SETTINGS_FILE", f) return f def p_write(path, obj): with open(path, "w", encoding="utf-8") as fh: json.dump(obj, fh) # ---------------- migrate_legacy_fields ---------------- def test_migrate_managed_to_openswarm_pro(): assert store.migrate_legacy_fields({"connection_mode": "managed"})["connection_mode"] == "openswarm-pro" def test_migrate_auth_token_renamed_and_popped(): out = store.migrate_legacy_fields({"openswarm_auth_token": "tok"}) assert out["openswarm_bearer_token"] == "tok" assert "openswarm_auth_token" not in out def test_migrate_does_not_clobber_existing_bearer(): out = store.migrate_legacy_fields({"openswarm_auth_token": "old", "openswarm_bearer_token": "new"}) assert out["openswarm_bearer_token"] == "new" def test_migrate_leaves_modern_values_untouched(): out = store.migrate_legacy_fields({"connection_mode": "own_key"}) assert out["connection_mode"] == "own_key" # ---------------- load_settings: happy paths ---------------- def test_no_file_returns_defaults(settings_file): s = store.load_settings() assert isinstance(s, AppSettings) assert s.default_system_prompt == DEFAULT_SYSTEM_PROMPT assert s.theme == "light" def test_minimal_old_file_fills_missing_with_defaults(settings_file): # An old build wrote only a couple of fields; everything else must default. p_write(settings_file, {"theme": "light"}) s = store.load_settings() assert s.theme == "light" assert s.default_model == "opus-5" # filled from default assert s.auto_reveal_sub_agents is True def test_legacy_fields_migrated_end_to_end(settings_file): p_write(settings_file, {"connection_mode": "managed", "openswarm_auth_token": "tok"}) s = store.load_settings() assert s.connection_mode == "openswarm-pro" assert s.openswarm_bearer_token == "tok" def test_install_id_and_first_opened_continuity(settings_file): # The identity carried across upgrades must survive a load untouched. p_write(settings_file, {"installation_id": "abc-123", "first_opened_at": "2025-01-01T00:00:00Z"}) s = store.load_settings() assert s.installation_id == "abc-123" assert s.first_opened_at == "2025-01-01T00:00:00Z" def test_null_system_prompt_backfilled(settings_file): p_write(settings_file, {"default_system_prompt": None}) assert store.load_settings().default_system_prompt == DEFAULT_SYSTEM_PROMPT # ---------------- load_settings: forward/backward-compat robustness ---------------- def test_unknown_removed_fields_are_ignored(settings_file): # A field that existed in a future/older schema but not this one must not crash. p_write(settings_file, {"theme": "light", "a_field_we_removed": 999, "another_ghost": {"x": 1}}) s = store.load_settings() assert s.theme == "light" def test_type_drifted_field_reverts_to_default_keeps_rest(settings_file): # dismissed_mcp_suggestions is dict[str,str] now; an old build stored a list. The bad field must revert to its default, every valid field must survive. p_write(settings_file, {"theme": "light", "dismissed_mcp_suggestions": ["legacy", "list"]}) s = store.load_settings() assert s.theme == "light" assert s.dismissed_mcp_suggestions == {} def test_retired_literal_value_reverts_to_default(settings_file): # default_thinking_level is a Literal; a retired value must not brick load. p_write(settings_file, {"theme": "light", "default_thinking_level": "ultra"}) s = store.load_settings() assert s.theme == "light" assert s.default_thinking_level == "auto" def test_multiple_bad_fields_all_revert_valid_survive(settings_file): p_write(settings_file, { "theme": "light", "default_thinking_level": "ultra", # retired literal "dismissed_mcp_suggestions": [1, 2, 3], # wrong type "zoom_sensitivity": "not-a-number", # wrong type }) s = store.load_settings() assert s.theme == "light" assert s.default_thinking_level == "auto" assert s.dismissed_mcp_suggestions == {} assert s.zoom_sensitivity == 50.0 def test_corrupt_json_returns_defaults_and_preserves_file(settings_file): with open(settings_file, "w", encoding="utf-8") as fh: fh.write("{ this is : not json ,,, ") s = store.load_settings() assert s.theme == "light" # defaults # Original is moved aside (recoverable), not silently destroyed. assert os.path.exists(settings_file + ".corrupt") assert not os.path.exists(settings_file) def test_non_dict_top_level_returns_defaults(settings_file): p_write(settings_file, ["not", "an", "object"]) s = store.load_settings() assert s.theme == "light" assert os.path.exists(settings_file + ".corrupt") # ---------------- round-trip ---------------- def test_save_then_load_roundtrip(settings_file): s = AppSettings(theme="light", default_model="opus", installation_id="keep-me") store.save_settings(s) loaded = store.load_settings() assert loaded.theme == "light" assert loaded.default_model == "opus" assert loaded.installation_id == "keep-me"