feat: Implement LLM provider deletion and refactor ConfigManager to preserve unknown payload sections.
This commit is contained in:
53
backend/src/core/__tests__/test_config_manager_compat.py
Normal file
53
backend/src/core/__tests__/test_config_manager_compat.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# [DEF:backend.src.core.__tests__.test_config_manager_compat:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: config-manager, compatibility, payload, tests
|
||||
# @PURPOSE: Verifies ConfigManager compatibility wrappers preserve legacy payload sections.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: VERIFIES -> ConfigManager
|
||||
|
||||
from src.core.config_manager import ConfigManager
|
||||
from src.core.config_models import AppConfig, GlobalSettings
|
||||
|
||||
|
||||
# [DEF:test_get_payload_preserves_legacy_sections:Function]
|
||||
# @PURPOSE: Ensure get_payload merges typed config into raw payload without dropping legacy sections.
|
||||
def test_get_payload_preserves_legacy_sections():
|
||||
manager = ConfigManager.__new__(ConfigManager)
|
||||
manager.raw_payload = {"notifications": {"smtp": {"host": "mail.local"}}}
|
||||
manager.config = AppConfig(environments=[], settings=GlobalSettings())
|
||||
|
||||
payload = manager.get_payload()
|
||||
|
||||
assert payload["settings"]["migration_sync_cron"] == "0 2 * * *"
|
||||
assert payload["notifications"]["smtp"]["host"] == "mail.local"
|
||||
# [/DEF:test_get_payload_preserves_legacy_sections:Function]
|
||||
|
||||
|
||||
# [DEF:test_save_config_accepts_raw_payload_and_keeps_extras:Function]
|
||||
# @PURPOSE: Ensure save_config accepts raw dict payload, refreshes typed config, and preserves extra sections.
|
||||
def test_save_config_accepts_raw_payload_and_keeps_extras(monkeypatch):
|
||||
manager = ConfigManager.__new__(ConfigManager)
|
||||
manager.raw_payload = {}
|
||||
manager.config = AppConfig(environments=[], settings=GlobalSettings())
|
||||
|
||||
persisted = {}
|
||||
|
||||
def _capture_save(config, session=None):
|
||||
persisted["payload"] = manager.get_payload()
|
||||
|
||||
monkeypatch.setattr(manager, "_save_config_to_db", _capture_save)
|
||||
|
||||
manager.save_config(
|
||||
{
|
||||
"environments": [],
|
||||
"settings": GlobalSettings().model_dump(),
|
||||
"notifications": {"telegram": {"bot_token": "secret"}},
|
||||
}
|
||||
)
|
||||
|
||||
assert manager.raw_payload["notifications"]["telegram"]["bot_token"] == "secret"
|
||||
assert manager.config.settings.migration_sync_cron == "0 2 * * *"
|
||||
assert persisted["payload"]["notifications"]["telegram"]["bot_token"] == "secret"
|
||||
# [/DEF:test_save_config_accepts_raw_payload_and_keeps_extras:Function]
|
||||
|
||||
# [/DEF:backend.src.core.__tests__.test_config_manager_compat:Module]
|
||||
@@ -15,7 +15,7 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from typing import Any, Optional, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -44,6 +44,7 @@ class ConfigManager:
|
||||
logger.reason(f"Initializing ConfigManager with legacy path: {config_path}")
|
||||
|
||||
self.config_path = Path(config_path)
|
||||
self.raw_payload: dict[str, Any] = {}
|
||||
self.config: AppConfig = self._load_config()
|
||||
|
||||
configure_logger(self.config.settings.logging)
|
||||
@@ -69,6 +70,20 @@ class ConfigManager:
|
||||
)
|
||||
# [/DEF:_default_config:Function]
|
||||
|
||||
# [DEF:_sync_raw_payload_from_config:Function]
|
||||
# @PURPOSE: Merge typed AppConfig state into raw payload while preserving unsupported legacy sections.
|
||||
# @PRE: self.config is initialized as AppConfig.
|
||||
# @POST: self.raw_payload contains AppConfig fields refreshed from self.config.
|
||||
# @SIDE_EFFECT: Mutates self.raw_payload in memory.
|
||||
# @DATA_CONTRACT: Input(None) -> Output(dict[str, Any])
|
||||
def _sync_raw_payload_from_config(self) -> dict[str, Any]:
|
||||
with belief_scope("ConfigManager._sync_raw_payload_from_config"):
|
||||
payload = dict(self.raw_payload) if isinstance(self.raw_payload, dict) else {}
|
||||
payload.update(self.config.model_dump())
|
||||
self.raw_payload = payload
|
||||
return payload
|
||||
# [/DEF:_sync_raw_payload_from_config:Function]
|
||||
|
||||
# [DEF:_load_from_legacy_file:Function]
|
||||
# @PURPOSE: Load legacy JSON configuration for migration fallback path.
|
||||
# @PRE: self.config_path is initialized.
|
||||
@@ -79,15 +94,18 @@ class ConfigManager:
|
||||
with belief_scope("_load_from_legacy_file"):
|
||||
if not self.config_path.exists():
|
||||
logger.info("[_load_from_legacy_file][Action] Legacy config file not found, using defaults")
|
||||
self.raw_payload = self._default_config().model_dump()
|
||||
return self._default_config()
|
||||
|
||||
try:
|
||||
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
self.raw_payload = dict(data) if isinstance(data, dict) else {}
|
||||
logger.info("[_load_from_legacy_file][Coherence:OK] Legacy configuration loaded")
|
||||
return AppConfig(**data)
|
||||
except Exception as e:
|
||||
logger.error(f"[_load_from_legacy_file][Coherence:Failed] Error loading legacy config: {e}")
|
||||
self.raw_payload = self._default_config().model_dump()
|
||||
return self._default_config()
|
||||
# [/DEF:_load_from_legacy_file:Function]
|
||||
|
||||
@@ -115,6 +133,7 @@ class ConfigManager:
|
||||
record = self._get_record(session)
|
||||
if record and record.payload:
|
||||
logger.reason("Configuration found in database")
|
||||
self.raw_payload = dict(record.payload)
|
||||
config = AppConfig(**record.payload)
|
||||
logger.reflect("Database configuration validated")
|
||||
return config
|
||||
@@ -126,6 +145,7 @@ class ConfigManager:
|
||||
return config
|
||||
except Exception as e:
|
||||
logger.explore(f"Error loading config from DB: {e}")
|
||||
self.raw_payload = self._default_config().model_dump()
|
||||
return self._default_config()
|
||||
finally:
|
||||
session.close()
|
||||
@@ -146,7 +166,7 @@ class ConfigManager:
|
||||
db = session or SessionLocal()
|
||||
try:
|
||||
record = self._get_record(db)
|
||||
payload = config.model_dump()
|
||||
payload = self._sync_raw_payload_from_config()
|
||||
if record is None:
|
||||
logger.reason("Creating new global configuration record")
|
||||
record = AppConfigRecord(id="global", payload=payload)
|
||||
@@ -187,6 +207,38 @@ class ConfigManager:
|
||||
return self.config
|
||||
# [/DEF:get_config:Function]
|
||||
|
||||
# [DEF:get_payload:Function]
|
||||
# @PURPOSE: Return full persisted payload including sections outside typed AppConfig schema.
|
||||
# @PRE: Manager state is initialized.
|
||||
# @POST: Returns dict payload with current AppConfig fields synchronized.
|
||||
# @SIDE_EFFECT: Refreshes self.raw_payload from self.config before returning.
|
||||
# @DATA_CONTRACT: Input(None) -> Output(dict[str, Any])
|
||||
def get_payload(self) -> dict[str, Any]:
|
||||
with belief_scope("ConfigManager.get_payload"):
|
||||
return dict(self._sync_raw_payload_from_config())
|
||||
# [/DEF:get_payload:Function]
|
||||
|
||||
# [DEF:save_config:Function]
|
||||
# @PURPOSE: Persist configuration provided either as typed AppConfig or raw payload dict.
|
||||
# @PRE: config is AppConfig or dict compatible with AppConfig core schema.
|
||||
# @POST: self.config and self.raw_payload are synchronized and persisted to DB.
|
||||
# @SIDE_EFFECT: Mutates in-memory config state, writes database record, may reconfigure logger.
|
||||
# @DATA_CONTRACT: Input(AppConfig|dict[str, Any]) -> Output(None)
|
||||
def save_config(self, config: AppConfig | dict[str, Any]):
|
||||
with belief_scope("ConfigManager.save_config"):
|
||||
if isinstance(config, AppConfig):
|
||||
self.config = config
|
||||
self.raw_payload = dict(config.model_dump())
|
||||
elif isinstance(config, dict):
|
||||
self.raw_payload = dict(config)
|
||||
self.config = AppConfig(**config)
|
||||
else:
|
||||
raise TypeError("config must be an AppConfig instance or dict payload")
|
||||
|
||||
self._save_config_to_db(self.config)
|
||||
configure_logger(self.config.settings.logging)
|
||||
# [/DEF:save_config:Function]
|
||||
|
||||
# [DEF:update_global_settings:Function]
|
||||
# @PURPOSE: Replace global settings and persist the resulting configuration.
|
||||
# @PRE: settings is GlobalSettings.
|
||||
|
||||
Reference in New Issue
Block a user