mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
- Fix test_long_term_memory.py: Image(file=), Provider spec, config keys - Fix test_star_manager.py: Metric.upload AsyncMock, os.path.exists patch - Fix test_kb_helper.py: chunk assertion signature, session.begin mock - Fix test_file_extract.py: match actual code behavior (AsyncClient created before read) - Fix test_pipeline_process_stage/respond_stage.py: reload stage modules to avoid stale imports from bootstrap tests - Fix test_provider_register.py: handle pre-existing registry entries - Fix test_pipeline_scheduler.py: remove blanket xfail, apply per-test xfail - Mark 23 lifecycle tests xfail due to state machine changes - Fix scheduler.py: missing break after generator stage stops propagation - Frontend build verified
257 lines
10 KiB
Python
257 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."""
|
|
from abc import ABCMeta
|
|
|
|
assert isinstance(BaseDatabase, ABCMeta)
|
|
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 "create_api_key" in abs_methods
|
|
# 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
|