Expand test coverage: ~188 new tests across 6 critical files
- Fix failing test_authenticated_request_succeeds (dependency override) - Expand test_anime_service.py (+35 tests: status events, DB, broadcasts) - Create test_queue_repository.py (27 tests: CRUD, model conversion) - Expand test_enhanced_provider.py (+24 tests: fetch, download, redirect) - Expand test_serie_scanner.py (+25 tests: events, year extract, mp4 scan) - Create test_database_connection.py (38 tests: sessions, transactions) - Expand test_anime_endpoints.py (+39 tests: status, search, loading) - Clean up docs/instructions.md TODO list
This commit is contained in:
475
tests/unit/test_database_connection.py
Normal file
475
tests/unit/test_database_connection.py
Normal file
@@ -0,0 +1,475 @@
|
||||
"""Unit tests for database connection module.
|
||||
|
||||
Tests cover engine/session lifecycle, utility functions,
|
||||
TransactionManager, SavepointHandle, and various error paths.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import src.server.database.connection as conn_mod
|
||||
from src.server.database.connection import (
|
||||
SavepointHandle,
|
||||
TransactionManager,
|
||||
_get_database_url,
|
||||
get_session_transaction_depth,
|
||||
is_session_in_transaction,
|
||||
)
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_globals():
|
||||
"""Reset the module-level globals before/after every test."""
|
||||
old_engine = conn_mod._engine
|
||||
old_sync = conn_mod._sync_engine
|
||||
old_sf = conn_mod._session_factory
|
||||
old_ssf = conn_mod._sync_session_factory
|
||||
|
||||
conn_mod._engine = None
|
||||
conn_mod._sync_engine = None
|
||||
conn_mod._session_factory = None
|
||||
conn_mod._sync_session_factory = None
|
||||
|
||||
yield
|
||||
|
||||
conn_mod._engine = old_engine
|
||||
conn_mod._sync_engine = old_sync
|
||||
conn_mod._session_factory = old_sf
|
||||
conn_mod._sync_session_factory = old_ssf
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# _get_database_url
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGetDatabaseURL:
|
||||
"""Test _get_database_url helper."""
|
||||
|
||||
def test_sqlite_url_converted(self):
|
||||
"""sqlite:/// should be converted to sqlite+aiosqlite:///."""
|
||||
with patch.object(
|
||||
conn_mod.settings, "database_url",
|
||||
"sqlite:///./data/anime.db",
|
||||
):
|
||||
result = _get_database_url()
|
||||
assert "aiosqlite" in result
|
||||
|
||||
def test_non_sqlite_url_unchanged(self):
|
||||
"""Non-SQLite URL should remain unchanged."""
|
||||
with patch.object(
|
||||
conn_mod.settings, "database_url",
|
||||
"postgresql://user:pass@localhost/db",
|
||||
):
|
||||
result = _get_database_url()
|
||||
assert result == "postgresql://user:pass@localhost/db"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# get_engine / get_sync_engine
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGetEngine:
|
||||
"""Test get_engine and get_sync_engine."""
|
||||
|
||||
def test_raises_when_not_initialized(self):
|
||||
"""get_engine should raise RuntimeError before init_db."""
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
conn_mod.get_engine()
|
||||
|
||||
def test_returns_engine_when_set(self):
|
||||
"""Should return the engine when initialised."""
|
||||
fake_engine = MagicMock()
|
||||
conn_mod._engine = fake_engine
|
||||
assert conn_mod.get_engine() is fake_engine
|
||||
|
||||
def test_get_sync_engine_raises(self):
|
||||
"""get_sync_engine should raise RuntimeError before init_db."""
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
conn_mod.get_sync_engine()
|
||||
|
||||
def test_get_sync_engine_returns(self):
|
||||
"""Should return sync engine when set."""
|
||||
fake = MagicMock()
|
||||
conn_mod._sync_engine = fake
|
||||
assert conn_mod.get_sync_engine() is fake
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# get_db_session
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGetDBSession:
|
||||
"""Test get_db_session async context manager."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_when_not_initialized(self):
|
||||
"""Should raise RuntimeError if session factory is None."""
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
async with conn_mod.get_db_session():
|
||||
pass
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commits_on_success(self):
|
||||
"""Session should be committed on normal exit."""
|
||||
mock_session = AsyncMock()
|
||||
factory = MagicMock(return_value=mock_session)
|
||||
conn_mod._session_factory = factory
|
||||
|
||||
async with conn_mod.get_db_session() as session:
|
||||
assert session is mock_session
|
||||
|
||||
mock_session.commit.assert_called_once()
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rollback_on_exception(self):
|
||||
"""Session should be rolled back on exception."""
|
||||
mock_session = AsyncMock()
|
||||
factory = MagicMock(return_value=mock_session)
|
||||
conn_mod._session_factory = factory
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
async with conn_mod.get_db_session():
|
||||
raise ValueError("boom")
|
||||
|
||||
mock_session.rollback.assert_called_once()
|
||||
mock_session.commit.assert_not_called()
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# get_sync_session / get_async_session_factory
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGetSyncSession:
|
||||
"""Test get_sync_session."""
|
||||
|
||||
def test_raises_when_not_initialized(self):
|
||||
"""Should raise RuntimeError."""
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
conn_mod.get_sync_session()
|
||||
|
||||
def test_returns_session(self):
|
||||
"""Should return a session from the factory."""
|
||||
mock_session = MagicMock()
|
||||
conn_mod._sync_session_factory = MagicMock(return_value=mock_session)
|
||||
assert conn_mod.get_sync_session() is mock_session
|
||||
|
||||
|
||||
class TestGetAsyncSessionFactory:
|
||||
"""Test get_async_session_factory."""
|
||||
|
||||
def test_raises_when_not_initialized(self):
|
||||
"""Should raise RuntimeError."""
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
conn_mod.get_async_session_factory()
|
||||
|
||||
def test_returns_session(self):
|
||||
"""Should return a new async session."""
|
||||
mock_session = AsyncMock()
|
||||
conn_mod._session_factory = MagicMock(return_value=mock_session)
|
||||
assert conn_mod.get_async_session_factory() is mock_session
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# get_transactional_session
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGetTransactionalSession:
|
||||
"""Test get_transactional_session."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_when_not_initialized(self):
|
||||
"""Should raise RuntimeError."""
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
async with conn_mod.get_transactional_session():
|
||||
pass
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_auto_commit(self):
|
||||
"""Session should NOT be committed on normal exit."""
|
||||
mock_session = AsyncMock()
|
||||
conn_mod._session_factory = MagicMock(return_value=mock_session)
|
||||
|
||||
async with conn_mod.get_transactional_session() as session:
|
||||
pass
|
||||
|
||||
mock_session.commit.assert_not_called()
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rollback_on_exception(self):
|
||||
"""Should rollback on exception."""
|
||||
mock_session = AsyncMock()
|
||||
conn_mod._session_factory = MagicMock(return_value=mock_session)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
async with conn_mod.get_transactional_session():
|
||||
raise ValueError("boom")
|
||||
|
||||
mock_session.rollback.assert_called_once()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# close_db
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestCloseDB:
|
||||
"""Test close_db function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disposes_engines(self):
|
||||
"""Should dispose both engines."""
|
||||
mock_engine = AsyncMock()
|
||||
mock_sync = MagicMock()
|
||||
mock_sync.url = "sqlite:///test.db"
|
||||
mock_sync.connect.return_value.__enter__ = MagicMock()
|
||||
mock_sync.connect.return_value.__exit__ = MagicMock()
|
||||
|
||||
conn_ctx = MagicMock()
|
||||
conn_ctx.__enter__ = MagicMock(return_value=MagicMock())
|
||||
conn_ctx.__exit__ = MagicMock(return_value=False)
|
||||
mock_sync.connect.return_value = conn_ctx
|
||||
|
||||
conn_mod._engine = mock_engine
|
||||
conn_mod._sync_engine = mock_sync
|
||||
conn_mod._session_factory = MagicMock()
|
||||
conn_mod._sync_session_factory = MagicMock()
|
||||
|
||||
await conn_mod.close_db()
|
||||
|
||||
mock_engine.dispose.assert_called_once()
|
||||
mock_sync.dispose.assert_called_once()
|
||||
assert conn_mod._engine is None
|
||||
assert conn_mod._sync_engine is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_noop_when_not_initialized(self):
|
||||
"""Should not raise if engines are None."""
|
||||
await conn_mod.close_db() # should not raise
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# TransactionManager
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestTransactionManager:
|
||||
"""Test TransactionManager class."""
|
||||
|
||||
def test_init_raises_without_factory(self):
|
||||
"""Should raise RuntimeError when no session factory."""
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
TransactionManager()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_manager_creates_and_closes_session(self):
|
||||
"""Should create session on enter and close on exit."""
|
||||
mock_session = AsyncMock()
|
||||
factory = MagicMock(return_value=mock_session)
|
||||
|
||||
async with TransactionManager(session_factory=factory) as tm:
|
||||
session = await tm.get_session()
|
||||
assert session is mock_session
|
||||
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_begin_commit(self):
|
||||
"""begin then commit should work."""
|
||||
mock_session = AsyncMock()
|
||||
factory = MagicMock(return_value=mock_session)
|
||||
|
||||
async with TransactionManager(session_factory=factory) as tm:
|
||||
await tm.begin()
|
||||
assert tm.is_in_transaction() is True
|
||||
await tm.commit()
|
||||
assert tm.is_in_transaction() is False
|
||||
|
||||
mock_session.begin.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_begin_rollback(self):
|
||||
"""begin then rollback should work."""
|
||||
mock_session = AsyncMock()
|
||||
factory = MagicMock(return_value=mock_session)
|
||||
|
||||
async with TransactionManager(session_factory=factory) as tm:
|
||||
await tm.begin()
|
||||
await tm.rollback()
|
||||
assert tm.is_in_transaction() is False
|
||||
|
||||
mock_session.rollback.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_auto_rollback(self):
|
||||
"""Exception inside context manager should auto rollback."""
|
||||
mock_session = AsyncMock()
|
||||
factory = MagicMock(return_value=mock_session)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
async with TransactionManager(session_factory=factory) as tm:
|
||||
await tm.begin()
|
||||
raise ValueError("boom")
|
||||
|
||||
mock_session.rollback.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_double_begin_raises(self):
|
||||
"""begin called twice should raise."""
|
||||
mock_session = AsyncMock()
|
||||
factory = MagicMock(return_value=mock_session)
|
||||
|
||||
async with TransactionManager(session_factory=factory) as tm:
|
||||
await tm.begin()
|
||||
with pytest.raises(RuntimeError, match="Already in"):
|
||||
await tm.begin()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_without_begin_raises(self):
|
||||
"""commit without begin should raise."""
|
||||
mock_session = AsyncMock()
|
||||
factory = MagicMock(return_value=mock_session)
|
||||
|
||||
async with TransactionManager(session_factory=factory) as tm:
|
||||
with pytest.raises(RuntimeError, match="Not in"):
|
||||
await tm.commit()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_session_outside_context_raises(self):
|
||||
"""get_session outside context manager should raise."""
|
||||
factory = MagicMock()
|
||||
tm = TransactionManager(session_factory=factory)
|
||||
with pytest.raises(RuntimeError, match="context manager"):
|
||||
await tm.get_session()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transaction_depth(self):
|
||||
"""get_transaction_depth should reflect state."""
|
||||
mock_session = AsyncMock()
|
||||
factory = MagicMock(return_value=mock_session)
|
||||
|
||||
async with TransactionManager(session_factory=factory) as tm:
|
||||
assert tm.get_transaction_depth() == 0
|
||||
await tm.begin()
|
||||
assert tm.get_transaction_depth() == 1
|
||||
await tm.commit()
|
||||
assert tm.get_transaction_depth() == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_savepoint_creation(self):
|
||||
"""savepoint should return SavepointHandle."""
|
||||
mock_session = AsyncMock()
|
||||
mock_nested = AsyncMock()
|
||||
mock_session.begin_nested = AsyncMock(return_value=mock_nested)
|
||||
factory = MagicMock(return_value=mock_session)
|
||||
|
||||
async with TransactionManager(session_factory=factory) as tm:
|
||||
await tm.begin()
|
||||
sp = await tm.savepoint("sp1")
|
||||
assert isinstance(sp, SavepointHandle)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_savepoint_without_transaction_raises(self):
|
||||
"""savepoint outside transaction should raise."""
|
||||
mock_session = AsyncMock()
|
||||
factory = MagicMock(return_value=mock_session)
|
||||
|
||||
async with TransactionManager(session_factory=factory) as tm:
|
||||
with pytest.raises(RuntimeError, match="Must be in"):
|
||||
await tm.savepoint()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rollback_without_session_raises(self):
|
||||
"""rollback without active session should raise."""
|
||||
factory = MagicMock()
|
||||
tm = TransactionManager(session_factory=factory)
|
||||
with pytest.raises(RuntimeError, match="No active session"):
|
||||
await tm.rollback()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# SavepointHandle
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestSavepointHandle:
|
||||
"""Test SavepointHandle class."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rollback(self):
|
||||
"""Should call nested.rollback()."""
|
||||
mock_nested = AsyncMock()
|
||||
sp = SavepointHandle(mock_nested, "sp1")
|
||||
await sp.rollback()
|
||||
mock_nested.rollback.assert_called_once()
|
||||
assert sp._released is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rollback_idempotent(self):
|
||||
"""Second rollback should be a noop."""
|
||||
mock_nested = AsyncMock()
|
||||
sp = SavepointHandle(mock_nested, "sp1")
|
||||
await sp.rollback()
|
||||
await sp.rollback()
|
||||
mock_nested.rollback.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release(self):
|
||||
"""Should mark as released."""
|
||||
mock_nested = AsyncMock()
|
||||
sp = SavepointHandle(mock_nested, "sp1")
|
||||
await sp.release()
|
||||
assert sp._released is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_idempotent(self):
|
||||
"""Second release should be a noop."""
|
||||
mock_nested = AsyncMock()
|
||||
sp = SavepointHandle(mock_nested, "sp1")
|
||||
await sp.release()
|
||||
await sp.release()
|
||||
assert sp._released is True
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# Utility Functions
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestUtilityFunctions:
|
||||
"""Test is_session_in_transaction and get_session_transaction_depth."""
|
||||
|
||||
def test_in_transaction_true(self):
|
||||
"""Should return True when session is in transaction."""
|
||||
session = MagicMock()
|
||||
session.in_transaction.return_value = True
|
||||
assert is_session_in_transaction(session) is True
|
||||
|
||||
def test_in_transaction_false(self):
|
||||
"""Should return False when session is not in transaction."""
|
||||
session = MagicMock()
|
||||
session.in_transaction.return_value = False
|
||||
assert is_session_in_transaction(session) is False
|
||||
|
||||
def test_transaction_depth_zero(self):
|
||||
"""Should return 0 when not in transaction."""
|
||||
session = MagicMock()
|
||||
session.in_transaction.return_value = False
|
||||
assert get_session_transaction_depth(session) == 0
|
||||
|
||||
def test_transaction_depth_one(self):
|
||||
"""Should return 1 when in transaction."""
|
||||
session = MagicMock()
|
||||
session.in_transaction.return_value = True
|
||||
assert get_session_transaction_depth(session) == 1
|
||||
Reference in New Issue
Block a user