Files
AstrBot/tests/unit/test_db_init.py
LIghtJUNction 169a856a3a test: mass-coverage push — ~1000+ new unit tests
Coverage improvements:
- agent/tool/core: 150+ tests (FunctionTool, ToolSet, ToolSchema, run_context, messages, registry)
- provider: 152 tests (entities, Provider ABC, register, manager)
- star module: 58 tests (Context, PluginManager, StarHandler)
- platform: 71 tests (Platform ABC, manager, MessageSession)
- pipeline: 130 tests (process/respond stages, scheduler, bootstrap, conversation_mgr)
- kb+skills: 96 tests (mgr, helpers, chunkers, skill_manager)
- config+builtins: 92 tests (astrbot_config, config_mgr, default, LTM, star main)
- cron+db+event_bus: 89 tests (cron events/manager, PO models, BaseDatabase, EventBus)
- dashboard routes: expanded smoke tests for all route modules
2026-04-29 08:40:26 +08:00

255 lines
10 KiB
Python

"""Tests for BaseDatabase abstract interface and initialization.
Verifies the abstract method contract, engine creation, session factory
setup, and the ``get_db`` async context manager.
"""
import inspect
from abc import ABC
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from astrbot.core.db import BaseDatabase
class TestBaseDatabaseAbstract:
"""Tests for BaseDatabase's abstract nature and method count."""
def test_is_abstract_class(self):
"""BaseDatabase uses ABCMeta and cannot be directly instantiated."""
assert isinstance(BaseDatabase, ABC)
with pytest.raises(TypeError, match="abstract"):
BaseDatabase()
def test_abstract_method_count(self):
"""BaseDatabase defines a known number of abstract methods."""
abs_methods = BaseDatabase.__abstractmethods__
# The interface is large -- expect >= 90 abstract methods
assert len(abs_methods) >= 90
# Key abstract methods should be present
assert "create_cron_job" in abs_methods
assert "update_cron_job" in abs_methods
assert "delete_cron_job" in abs_methods
assert "get_cron_job" in abs_methods
assert "list_cron_jobs" in abs_methods
assert "get_conversations" in abs_methods
assert "create_conversation" in abs_methods
assert "insert_platform_stats" in abs_methods
assert "insert_persona" in abs_methods
assert "insert_api_key" in abs_methods # kept as create_api_key is the actual name
# Deprecated methods are still abstract
assert "get_base_stats" in abs_methods
assert "get_total_message_count" in abs_methods
def test_abstract_methods_have_docstrings_or_impl(self):
"""All abstract methods should have ... or docstrings (no syntax errors at module level)."""
abs_methods = BaseDatabase.__abstractmethods__
for name in abs_methods:
method = getattr(BaseDatabase, name)
# Should not raise AttributeError or similar
assert callable(method)
def test_initialize_is_not_abstract(self):
"""initialize() has a concrete default implementation."""
assert "initialize" not in BaseDatabase.__abstractmethods__
def test_get_db_is_not_abstract(self):
"""get_db() has a concrete implementation (async context manager)."""
assert "get_db" not in BaseDatabase.__abstractmethods__
class TestBaseDatabaseInit:
"""Tests for BaseDatabase.__init__ and engine setup."""
def test_init_sets_inited_false(self):
"""inited starts as False."""
with (
patch.object(BaseDatabase, "__abstractmethods__", frozenset()),
patch("astrbot.core.db.create_async_engine") as mock_engine,
patch("astrbot.core.db.async_sessionmaker") as mock_smaker,
):
mock_engine.return_value = MagicMock()
mock_smaker.return_value = MagicMock()
BaseDatabase.DATABASE_URL = "sqlite+aiosqlite:///test.db"
db = BaseDatabase()
assert db.inited is False
def test_init_sqlite_adds_connect_args_timeout(self):
"""SQLite URL adds timeout=30 to connect_args."""
with (
patch.object(BaseDatabase, "__abstractmethods__", frozenset()),
patch("astrbot.core.db.create_async_engine") as mock_engine,
patch("astrbot.core.db.async_sessionmaker") as mock_smaker,
):
mock_engine.return_value = MagicMock()
mock_smaker.return_value = MagicMock()
BaseDatabase.DATABASE_URL = "sqlite+aiosqlite:///test.db"
db = BaseDatabase() # noqa: F841
mock_engine.assert_called_once()
call_kwargs = mock_engine.call_args.kwargs
assert call_kwargs["connect_args"] == {"timeout": 30}
def test_init_non_sqlite_does_not_add_connect_args(self):
"""Non-SQLite URL omits connect_args (no timeout)."""
with (
patch.object(BaseDatabase, "__abstractmethods__", frozenset()),
patch("astrbot.core.db.create_async_engine") as mock_engine,
patch("astrbot.core.db.async_sessionmaker") as mock_smaker,
):
mock_engine.return_value = MagicMock()
mock_smaker.return_value = MagicMock()
BaseDatabase.DATABASE_URL = "postgresql+asyncpg://localhost/db"
db = BaseDatabase() # noqa: F841
call_kwargs = mock_engine.call_args.kwargs
assert "connect_args" not in call_kwargs or call_kwargs["connect_args"] == {}
def test_init_creates_async_session_local(self):
"""A session factory is created from the engine."""
with (
patch.object(BaseDatabase, "__abstractmethods__", frozenset()),
patch("astrbot.core.db.create_async_engine") as mock_engine,
patch("astrbot.core.db.async_sessionmaker") as mock_smaker,
):
mock_engine.return_value = MagicMock()
mock_smaker.return_value = MagicMock()
BaseDatabase.DATABASE_URL = "sqlite+aiosqlite:///test.db"
db = BaseDatabase() # noqa: F841
mock_smaker.assert_called_once()
# The session maker is bound to the engine with expire_on_commit=False
assert mock_smaker.call_args.kwargs.get("expire_on_commit") is False
def test_init_engine_echo_false_future_true(self):
"""Engine is created with echo=False and future=True."""
with (
patch.object(BaseDatabase, "__abstractmethods__", frozenset()),
patch("astrbot.core.db.create_async_engine") as mock_engine,
patch("astrbot.core.db.async_sessionmaker") as mock_smaker,
):
mock_engine.return_value = MagicMock()
mock_smaker.return_value = MagicMock()
BaseDatabase.DATABASE_URL = "sqlite+aiosqlite:///test.db"
db = BaseDatabase() # noqa: F841
call_kwargs = mock_engine.call_args.kwargs
assert call_kwargs["echo"] is False
assert call_kwargs["future"] is True
def test_same_url_passed_to_engine(self):
"""DATABASE_URL is passed to create_async_engine."""
with (
patch.object(BaseDatabase, "__abstractmethods__", frozenset()),
patch("astrbot.core.db.create_async_engine") as mock_engine,
patch("astrbot.core.db.async_sessionmaker") as mock_smaker,
):
mock_engine.return_value = MagicMock()
mock_smaker.return_value = MagicMock()
BaseDatabase.DATABASE_URL = "sqlite+aiosqlite:///custom.db"
db = BaseDatabase() # noqa: F841
assert mock_engine.call_args[0][0] == "sqlite+aiosqlite:///custom.db"
class TestBaseDatabaseInitialize:
"""Tests for the concrete initialize method."""
@pytest.mark.asyncio
async def test_initialize_does_not_raise(self):
"""initialize() is concrete and can be called without error."""
with (
patch.object(BaseDatabase, "__abstractmethods__", frozenset()),
patch("astrbot.core.db.create_async_engine"),
patch("astrbot.core.db.async_sessionmaker"),
):
BaseDatabase.DATABASE_URL = "sqlite+aiosqlite:///test.db"
db = BaseDatabase()
await db.initialize() # should not raise
class TestBaseDatabaseGetDb:
"""Tests for the get_db async context manager."""
@pytest.mark.asyncio
async def test_get_db_calls_initialize_when_not_inited(self):
"""get_db calls initialize() if inited is False."""
from sqlalchemy.ext.asyncio import AsyncSession
with (
patch.object(BaseDatabase, "__abstractmethods__", frozenset()),
patch("astrbot.core.db.create_async_engine"),
patch("astrbot.core.db.async_sessionmaker"),
):
BaseDatabase.DATABASE_URL = "sqlite+aiosqlite:///test.db"
db = BaseDatabase()
db.inited = False
# Mock the session factory
mock_session = MagicMock(spec=AsyncSession)
mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_session)
mock_cm.__aexit__ = AsyncMock(return_value=None)
db.AsyncSessionLocal = MagicMock(return_value=mock_cm)
with patch.object(db, "initialize", new_callable=AsyncMock) as mock_init:
async with db.get_db() as session:
assert session == mock_session
mock_init.assert_awaited_once()
@pytest.mark.asyncio
async def test_get_db_does_not_call_initialize_when_inited(self):
"""get_db skips initialize() if inited is already True."""
from sqlalchemy.ext.asyncio import AsyncSession
with (
patch.object(BaseDatabase, "__abstractmethods__", frozenset()),
patch("astrbot.core.db.create_async_engine"),
patch("astrbot.core.db.async_sessionmaker"),
):
BaseDatabase.DATABASE_URL = "sqlite+aiosqlite:///test.db"
db = BaseDatabase()
db.inited = True
mock_session = MagicMock(spec=AsyncSession)
mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_session)
mock_cm.__aexit__ = AsyncMock(return_value=None)
db.AsyncSessionLocal = MagicMock(return_value=mock_cm)
with patch.object(db, "initialize", new_callable=AsyncMock) as mock_init:
async with db.get_db() as session:
assert session == mock_session
mock_init.assert_not_called()
@pytest.mark.asyncio
async def test_get_db_sets_inited_after_initialize(self):
"""inited is set to True after initialize completes."""
with (
patch.object(BaseDatabase, "__abstractmethods__", frozenset()),
patch("astrbot.core.db.create_async_engine"),
patch("astrbot.core.db.async_sessionmaker"),
):
BaseDatabase.DATABASE_URL = "sqlite+aiosqlite:///test.db"
db = BaseDatabase()
db.inited = False
mock_session = MagicMock()
mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_session)
mock_cm.__aexit__ = AsyncMock(return_value=None)
db.AsyncSessionLocal = MagicMock(return_value=mock_cm)
async with db.get_db():
pass
assert db.inited is True