702650376e
- 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
300 lines
10 KiB
Python
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
|