# #region TestStorageConfigMigration [C:3] [TYPE Module] [SEMANTICS test,storage,config,git] # @BRIEF Verify StorageConfig root_path change from "backups" to "/app/storage", # ConfigManager fallback without config.json, validate_path behaviour, and # GitPlugin shared-config-manager integration. # @RELATION BINDS_TO -> [StorageModels] # @RELATION BINDS_TO -> [ConfigManager] # @RELATION BINDS_TO -> [GitPluginModule] # @TEST_EDGE: missing_config_json -> ConfigManager falls back to _default_config when config.json absent # @TEST_EDGE: default_root_path -> StorageConfig().root_path == "/app/storage" (was "backups") # @TEST_EDGE: invalid_path -> validate_path returns False for /nonexistent/path # @TEST_EDGE: git_plugin_uses_shared_config -> GitPlugin.__init__ uses shared config_manager from src.dependencies # @TEST_INVARIANT: INV_7 (<400 lines) -> VERIFIED_BY: module_length_within_limits # @RATIONALE Orthogonal coverage across 4 projections: # P1 (model contract), P4 (traceability from @POST), # P2 (decision-memory — GitPlugin no longer reads config.json fallback), # P5 (architecture realism — tests match real runtime paths). import os from pathlib import Path import sys from unittest.mock import MagicMock, patch sys.path.insert(0, str(Path(__file__).parent.parent / "src")) import pytest # #region TestStorageConfigDefaults [C:2] [TYPE Class] # @BRIEF Verify StorageConfig default field values after the root_path change. class TestStorageConfigDefaults: """Verify StorageConfig default field values.""" # #region test_root_path_default [C:2] [TYPE Function] # @BRIEF StorageConfig().root_path must equal "/app/storage" (was "backups"). def test_root_path_default(self): from src.models.storage import StorageConfig config = StorageConfig() assert config.root_path == "/app/storage" # #endregion test_root_path_default # #region test_root_path_is_string [C:2] [TYPE Function] # @BRIEF root_path is always a str (type safety). def test_root_path_is_string(self): from src.models.storage import StorageConfig config = StorageConfig() assert isinstance(config.root_path, str) # #endregion test_root_path_is_string # #region test_other_defaults_unchanged [C:2] [TYPE Function] # @BRIEF Other StorageConfig fields were not affected by the root_path change. def test_other_defaults_unchanged(self): from src.models.storage import StorageConfig config = StorageConfig() assert config.backup_path == "backups" assert config.repo_path == "repositories" assert config.filename_pattern == "{name}_{timestamp}" assert config.backup_structure_pattern == "{category}/" # #endregion test_other_defaults_unchanged # #region test_global_settings_propagates_storage [C:2] [TYPE Function] # @BRIEF GlobalSettings().storage.root_path inherits the StorageConfig default. def test_global_settings_propagates_storage(self): from src.core.config_models import GlobalSettings from src.models.storage import StorageConfig settings = GlobalSettings() assert settings.storage.root_path == "/app/storage" assert isinstance(settings.storage, StorageConfig) # #endregion test_global_settings_propagates_storage # #region test_override_root_path [C:2] [TYPE Function] # @BRIEF Explicit root_path overrides the default. def test_override_root_path(self): from src.models.storage import StorageConfig config = StorageConfig(root_path="/custom/path") assert config.root_path == "/custom/path" # Other fields should remain at their defaults assert config.backup_path == "backups" # #endregion test_override_root_path # #endregion TestStorageConfigDefaults # #region TestConfigManagerDefaults [C:2] [TYPE Class] # @BRIEF Verify ConfigManager produces correct defaults when no config.json exists. class TestConfigManagerDefaults: """Verify ConfigManager initialisation without config.json (DB + file fallback).""" # #region test_init_falls_to_defaults [C:2] [TYPE Function] # @BRIEF ConfigManager(…nonexistent…) loads defaults when both DB and config.json are absent. # @TEST_FIXTURE: tmp_path + mocked SessionLocal returning no record. # @RATIONALE Full path: _load_config → no DB record → _load_from_legacy_file (absent) # → _default_config(). Ensures the entire chain works without config.json. def test_init_falls_to_defaults(self, tmp_path: Path): nonexistent = tmp_path / "no_config.json" with patch("src.core.config_manager.SessionLocal") as mock_sl: mock_session = MagicMock() mock_sl.return_value = mock_session mock_session.query.return_value.filter.return_value.first.return_value = None from src.core.config_manager import ConfigManager cm = ConfigManager(config_path=str(nonexistent)) config = cm.get_config() # Core assertions assert config.settings.storage.root_path == "/app/storage" assert config.settings.logging.level == "INFO" assert config.environments == [] # #endregion test_init_falls_to_defaults # #region test_default_config_root_path [C:2] [TYPE Function] # @BRIEF _default_config() hardcodes root_path == "/app/storage" via StorageConfig default. def test_default_config_root_path(self): from src.core.config_manager import ConfigManager with patch("src.core.config_manager.SessionLocal") as mock_sl: mock_session = MagicMock() mock_sl.return_value = mock_session mock_session.query.return_value.filter.return_value.first.return_value = None cm = ConfigManager(config_path="/nonexistent-test-defaults.json") config = cm.get_config() assert config.settings.storage.root_path == "/app/storage" # #endregion test_default_config_root_path # #region test_features_from_env_still_applied [C:2] [TYPE Function] # @BRIEF Even without config.json, _apply_features_from_env runs during default init. def test_features_from_env_still_applied(self, tmp_path: Path): nonexistent = tmp_path / "no_config.json" with patch("src.core.config_manager.SessionLocal") as mock_sl: mock_session = MagicMock() mock_sl.return_value = mock_session mock_session.query.return_value.filter.return_value.first.return_value = None with patch.dict(os.environ, {"FEATURES__DATASET_REVIEW": "false"}, clear=False): from src.core.config_manager import ConfigManager cm = ConfigManager(config_path=str(nonexistent)) config = cm.get_config() assert config.settings.features.dataset_review is False assert config.settings.storage.root_path == "/app/storage" # #endregion test_features_from_env_still_applied # #endregion TestConfigManagerDefaults # #region TestValidatePath [C:2] [TYPE Class] # @BRIEF Verify ConfigManager.validate_path contract. class TestValidatePath: """Verify validate_path creates writable directories and rejects invalid paths.""" # #region test_validate_path_creates_directory [C:2] [TYPE Function] # @BRIEF validate_path creates the target directory and returns (True, "OK"). # @TEST_FIXTURE: tmp_path ensures no side effects on real storage. def test_validate_path_creates_directory(self, tmp_path: Path): target = tmp_path / "app" / "storage" assert not target.exists() with patch("src.core.config_manager.SessionLocal") as mock_sl: mock_session = MagicMock() mock_sl.return_value = mock_session mock_session.query.return_value.filter.return_value.first.return_value = None from src.core.config_manager import ConfigManager cm = ConfigManager(config_path=str(tmp_path / "cfg.json")) is_valid, message = cm.validate_path(str(target)) assert is_valid is True assert message == "OK" assert target.exists() assert target.is_dir() # #endregion test_validate_path_creates_directory # #region test_validate_path_already_exists [C:2] [TYPE Function] # @BRIEF validate_path succeeds when the directory already exists. def test_validate_path_already_exists(self, tmp_path: Path): existing = tmp_path / "existing" / "storage" existing.mkdir(parents=True) with patch("src.core.config_manager.SessionLocal") as mock_sl: mock_session = MagicMock() mock_sl.return_value = mock_session mock_session.query.return_value.filter.return_value.first.return_value = None from src.core.config_manager import ConfigManager cm = ConfigManager(config_path=str(tmp_path / "cfg.json")) is_valid, message = cm.validate_path(str(existing)) assert is_valid is True assert message == "OK" # #endregion test_validate_path_already_exists # #region test_validate_path_nonexistent_root [C:2] [TYPE Function] # @BRIEF validate_path returns False for a path under / (non-writable by non-root). # @RATIONALE /nonexistent/path cannot be created by a non-root user. # Skip when running as root because root can create directories anywhere. @pytest.mark.skipif( os.geteuid() == 0, reason="Cannot test permission-denied path as root", ) def test_validate_path_nonexistent_root(self): with patch("src.core.config_manager.SessionLocal") as mock_sl: mock_session = MagicMock() mock_sl.return_value = mock_session mock_session.query.return_value.filter.return_value.first.return_value = None from src.core.config_manager import ConfigManager cm = ConfigManager(config_path="/tmp/__nonexistent_test_cfg.json") is_valid, message = cm.validate_path("/nonexistent/path") assert is_valid is False assert isinstance(message, str) assert len(message) > 0 # #endregion test_validate_path_nonexistent_root # #region test_validate_path_read_only_parent [C:2] [TYPE Function] # @BRIEF validate_path returns False when the parent directory is not writable. def test_validate_path_read_only_parent(self, tmp_path: Path): import stat read_only_parent = tmp_path / "readonly_parent" read_only_parent.mkdir() # Remove write permission from parent read_only_parent.chmod(stat.S_IRUSR | stat.S_IXUSR) child = read_only_parent / "subdir" with patch("src.core.config_manager.SessionLocal") as mock_sl: mock_session = MagicMock() mock_sl.return_value = mock_session mock_session.query.return_value.filter.return_value.first.return_value = None from src.core.config_manager import ConfigManager cm = ConfigManager(config_path=str(tmp_path / "cfg.json")) is_valid, message = cm.validate_path(str(child)) assert is_valid is False assert isinstance(message, str) # #endregion test_validate_path_read_only_parent # #endregion TestValidatePath # #region TestGitPluginConfigIntegration [C:2] [TYPE Class] # @BRIEF Verify GitPlugin uses shared config_manager from src.dependencies (no config.json fallback). class TestGitPluginConfigIntegration: """Verify GitPlugin shares config_manager without re-reading config.json.""" # #region test_git_plugin_uses_shared_config_manager [C:2] [TYPE Function] # @BRIEF GitPlugin.__init__ picks up the shared config_manager from src.dependencies. # @TEST_FIXTURE: mock GitService to avoid real git initialisation. def test_git_plugin_uses_shared_config_manager(self): mock_config_manager = MagicMock() mock_config_manager.get_environments.return_value = [] with ( patch("src.plugins.git_plugin.GitService") as mock_git_svc, patch("src.dependencies.config_manager", mock_config_manager), ): from src.plugins.git_plugin import GitPlugin plugin = GitPlugin() # Prove the shared instance is used, NOT a freshly-created ConfigManager assert plugin.config_manager is mock_config_manager assert plugin.config_manager.get_environments() == [] mock_git_svc.assert_called_once() # #endregion test_git_plugin_uses_shared_config_manager # #region test_git_plugin_fallback_creates_new [C:2] [TYPE Function] # @BRIEF When from src.dependencies import config_manager fails, # GitPlugin falls back to ConfigManager(). # @RATIONALE The try/except in GitPlugin.__init__ guards against import # failure. This test forces the fallback by removing config_manager # from dependencies module dict before the import executes. def test_git_plugin_fallback_creates_new(self): import src.dependencies # Remove config_manager so 'from src.dependencies import config_manager' raises ImportError saved = getattr(src.dependencies, "config_manager", None) if "config_manager" in src.dependencies.__dict__: del src.dependencies.__dict__["config_manager"] with ( patch("src.plugins.git_plugin.GitService") as mock_git_svc, patch("src.plugins.git_plugin.ConfigManager") as mock_cm_cls, ): from src.plugins.git_plugin import GitPlugin plugin = GitPlugin() mock_cm_cls.assert_called_once() assert plugin.config_manager is mock_cm_cls.return_value mock_git_svc.assert_called_once() # Restore to avoid side effects in other tests src.dependencies.config_manager = saved # #endregion test_git_plugin_fallback_creates_new # #endregion TestGitPluginConfigIntegration # #region TestRegressionGuard [C:2] [TYPE Class] # @BRIEF Guard against regressions in existing test contracts and stale assertions. class TestRegressionGuard: """Detect stale assertions in sibling test files.""" # #region test_existing_test_would_fail [C:2] [TYPE Function] # @BRIEF Existing test_storage_audit_fixes.py still asserts root_path=="backups" (WRONG). # @RATIONALE The sibling test at test_storage_audit_fixes.py:66 contains # `assert config.root_path == "backups"` which is now a stale assertion. # This test documents the intentional break and serves as a canary # until that file is updated. def test_existing_test_would_fail(self): from src.models.storage import StorageConfig config = StorageConfig() # The OLD default was "backups"; the NEW default is "/app/storage". # This assertion does NOT match the old test! assert config.root_path == "/app/storage" assert config.root_path != "backups", ( "Stale assertion in test_storage_audit_fixes.py:66 expects 'backups'. " "Update that file after confirming this change is intentional." ) # #endregion test_existing_test_would_fail # #region test_storage_settings_endpoint_shape [C:2] [TYPE Function] # @BRIEF The API response shape for storage settings is still StorageConfig. def test_storage_settings_endpoint_shape(self): from src.models.storage import StorageConfig schema = StorageConfig.model_json_schema() props = schema.get("properties", {}) assert "root_path" in props assert props["root_path"]["default"] == "/app/storage" assert props["backup_path"]["default"] == "backups" # #endregion test_storage_settings_endpoint_shape # #endregion TestRegressionGuard # #endregion TestStorageConfigMigration