Files
gh-christianlouis-inboxconv…/backend/tests/unit/test_config_service.py
T
copilot-swe-agent[bot] 702650376e Add database-backed configuration (AppSetting model, ConfigService, settings API)
- Add AppSetting model for key-value settings storage in PostgreSQL
- Create ConfigService with DB-first, env-var-fallback resolution
- Add admin-only /api/v1/settings CRUD endpoints
- Update tasks.py to use ConfigService for SMTP config
- Seed default settings on application startup
- Add 24 unit tests for ConfigService (all passing)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/ce88d4a8-d8c2-49b3-95a1-30592105a769
2026-03-23 13:35:52 +00:00

300 lines
10 KiB
Python

"""
Unit tests for the database-backed configuration service.
"""
import os
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from app.services.config_service import (
ConfigService,
BOOTSTRAP_KEYS,
DEFAULT_SETTINGS,
_cast_value,
)
# ── _cast_value tests ─────────────────────────────────────────────
class TestCastValue:
"""Test low-level value casting."""
def test_cast_string(self):
assert _cast_value("hello", "string") == "hello"
def test_cast_int(self):
assert _cast_value("42", "int") == 42
def test_cast_float(self):
assert _cast_value("3.14", "float") == 3.14
def test_cast_bool_true(self):
for val in ("true", "True", "1", "yes"):
assert _cast_value(val, "bool") is True
def test_cast_bool_false(self):
for val in ("false", "False", "0", "no"):
assert _cast_value(val, "bool") is False
def test_cast_json(self):
assert _cast_value('["INBOX"]', "json") == ["INBOX"]
assert _cast_value('{"a": 1}', "json") == {"a": 1}
def test_cast_none(self):
assert _cast_value(None, "string") is None
assert _cast_value(None, "int") is None
# ── ConfigService.get tests ────────────────────────────────────────
class TestConfigServiceGet:
"""Test ConfigService.get() resolution order."""
@pytest.mark.asyncio
async def test_returns_default_when_no_source(self):
"""When neither DB nor env has the key, return the default."""
with patch.dict(os.environ, {}, clear=False):
# Make sure the key is not in env
os.environ.pop("MY_TEST_KEY_XYZ", None)
result = await ConfigService.get("MY_TEST_KEY_XYZ", default="fallback")
assert result == "fallback"
@pytest.mark.asyncio
async def test_env_overrides_default(self):
"""Env var should override the default."""
with patch.dict(os.environ, {"MY_TEST_KEY_XYZ": "from_env"}):
result = await ConfigService.get("MY_TEST_KEY_XYZ", default="fallback")
assert result == "from_env"
@pytest.mark.asyncio
async def test_db_overrides_env(self):
"""Database value should take precedence over env var."""
mock_setting = MagicMock()
mock_setting.value = "from_db"
mock_setting.value_type = "string"
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_setting
mock_db = AsyncMock()
mock_db.execute.return_value = mock_result
with patch.dict(os.environ, {"SMTP_HOST": "from_env"}):
result = await ConfigService.get("SMTP_HOST", db=mock_db, default="default")
assert result == "from_db"
@pytest.mark.asyncio
async def test_bootstrap_key_skips_db(self):
"""Bootstrap keys must never read from the database."""
mock_db = AsyncMock()
with patch.dict(os.environ, {"SECRET_KEY": "env_secret_value"}):
result = await ConfigService.get("SECRET_KEY", db=mock_db)
# DB should not have been called
mock_db.execute.assert_not_called()
assert result == "env_secret_value"
@pytest.mark.asyncio
async def test_db_exception_falls_back_to_env(self):
"""If DB lookup fails, fall back to env var gracefully."""
mock_db = AsyncMock()
mock_db.execute.side_effect = Exception("DB down")
with patch.dict(os.environ, {"SMTP_HOST": "env_value"}):
result = await ConfigService.get("SMTP_HOST", db=mock_db)
assert result == "env_value"
# ── ConfigService.get_many tests ───────────────────────────────────
class TestConfigServiceGetMany:
"""Test ConfigService.get_many() bulk retrieval."""
@pytest.mark.asyncio
async def test_get_many_from_env(self):
"""When no DB, all values come from env."""
with patch.dict(
os.environ, {"SMTP_HOST": "host", "SMTP_PORT": "587"}, clear=False
):
result = await ConfigService.get_many(["SMTP_HOST", "SMTP_PORT"])
assert result["SMTP_HOST"] == "host"
assert result["SMTP_PORT"] == "587"
@pytest.mark.asyncio
async def test_get_many_with_db(self):
"""DB values should be included in results."""
mock_setting = MagicMock()
mock_setting.key = "SMTP_HOST"
mock_setting.value = "db_host"
mock_setting.value_type = "string"
mock_scalars = MagicMock()
mock_scalars.all.return_value = [mock_setting]
mock_result = MagicMock()
mock_result.scalars.return_value = mock_scalars
mock_db = AsyncMock()
mock_db.execute.return_value = mock_result
with patch.dict(os.environ, {"SMTP_PORT": "465"}, clear=False):
os.environ.pop("SMTP_HOST", None)
result = await ConfigService.get_many(
["SMTP_HOST", "SMTP_PORT"], db=mock_db
)
assert result["SMTP_HOST"] == "db_host"
assert result["SMTP_PORT"] == "465"
# ── ConfigService.set tests ────────────────────────────────────────
class TestConfigServiceSet:
"""Test ConfigService.set() create and update."""
@pytest.mark.asyncio
async def test_set_rejects_bootstrap_key(self):
"""Setting a bootstrap key must raise ValueError."""
mock_db = AsyncMock()
with pytest.raises(ValueError, match="bootstrap setting"):
await ConfigService.set("SECRET_KEY", "value", db=mock_db)
@pytest.mark.asyncio
async def test_set_creates_new_setting(self):
"""set() should create a new record when key does not exist."""
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
mock_db = AsyncMock()
mock_db.execute.return_value = mock_result
# The method will call db.add() and db.commit()
await ConfigService.set(
"SMTP_HOST", "new.host.com", db=mock_db, category="smtp"
)
mock_db.add.assert_called_once()
mock_db.commit.assert_called()
@pytest.mark.asyncio
async def test_set_updates_existing_setting(self):
"""set() should update an existing record."""
existing = MagicMock()
existing.value = "old_value"
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = existing
mock_db = AsyncMock()
mock_db.execute.return_value = mock_result
await ConfigService.set("SMTP_HOST", "updated.host.com", db=mock_db)
assert existing.value == "updated.host.com"
mock_db.commit.assert_called()
# ── ConfigService.delete tests ─────────────────────────────────────
class TestConfigServiceDelete:
"""Test ConfigService.delete()."""
@pytest.mark.asyncio
async def test_delete_rejects_bootstrap_key(self):
mock_db = AsyncMock()
with pytest.raises(ValueError, match="bootstrap setting"):
await ConfigService.delete("DATABASE_URL", db=mock_db)
@pytest.mark.asyncio
async def test_delete_returns_false_when_missing(self):
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
mock_db = AsyncMock()
mock_db.execute.return_value = mock_result
assert await ConfigService.delete("NONEXISTENT", db=mock_db) is False
@pytest.mark.asyncio
async def test_delete_removes_existing(self):
existing = MagicMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = existing
mock_db = AsyncMock()
mock_db.execute.return_value = mock_result
assert await ConfigService.delete("SMTP_HOST", db=mock_db) is True
mock_db.delete.assert_called_once_with(existing)
# ── ConfigService.get_smtp_config tests ────────────────────────────
class TestConfigServiceSmtpConfig:
"""Test the SMTP convenience helper."""
@pytest.mark.asyncio
async def test_smtp_config_defaults(self):
"""When nothing is configured, sensible defaults are returned."""
with patch.dict(os.environ, {}, clear=False):
for key in (
"SMTP_HOST",
"SMTP_PORT",
"SMTP_USER",
"SMTP_PASSWORD",
"SMTP_USE_TLS",
):
os.environ.pop(key, None)
config = await ConfigService.get_smtp_config()
assert config["host"] == "smtp.gmail.com"
assert config["port"] == 587
assert config["username"] == ""
assert config["password"] == ""
assert config["use_tls"] is True
@pytest.mark.asyncio
async def test_smtp_config_from_env(self):
"""Env vars should populate the SMTP config."""
env = {
"SMTP_HOST": "mail.example.com",
"SMTP_PORT": "465",
"SMTP_USER": "user",
"SMTP_PASSWORD": "pass",
"SMTP_USE_TLS": "false",
}
with patch.dict(os.environ, env, clear=False):
config = await ConfigService.get_smtp_config()
assert config["host"] == "mail.example.com"
assert config["port"] == 465
assert config["username"] == "user"
assert config["password"] == "pass"
assert config["use_tls"] is False
# ── DEFAULT_SETTINGS sanity check ──────────────────────────────────
class TestDefaultSettings:
"""Verify the built-in defaults are well-formed."""
def test_all_defaults_have_required_fields(self):
for item in DEFAULT_SETTINGS:
assert "key" in item
assert "value" in item
assert "value_type" in item
def test_no_default_is_a_bootstrap_key(self):
for item in DEFAULT_SETTINGS:
assert item["key"] not in BOOTSTRAP_KEYS