mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
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
255 lines
10 KiB
Python
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
|