- Created .dockerignore to exclude unnecessary files from Docker builds. - Added .repomixignore for managing ignored patterns in Repomix. - Introduced Dockerfile.dev for development environment setup with Python 3.12. - Configured docker-compose.yaml to define services, including a PostgreSQL database. - Established a devcontainer.json for Visual Studio Code integration. - Implemented postCreate.sh for automatic dependency installation in the dev container. - Added constants.py to centralize configuration constants for the project. - Updated pyproject.toml to include new development dependencies. - Created initial documentation files for project overview and style conventions. - Added tests for new functionalities to ensure reliability and correctness.
377 lines
14 KiB
Python
377 lines
14 KiB
Python
"""Tests for SqlAlchemyUnitOfWork transaction boundaries and rollback behavior.
|
|
|
|
Verifies rollback works correctly when operations fail mid-transaction.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
from noteflow.domain.entities.meeting import Meeting
|
|
from noteflow.domain.entities.segment import Segment
|
|
from noteflow.infrastructure.persistence.unit_of_work import SqlAlchemyUnitOfWork
|
|
|
|
if TYPE_CHECKING:
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
pytestmark = [pytest.mark.integration, pytest.mark.stress]
|
|
|
|
|
|
class TestExceptionRollback:
|
|
"""Test automatic rollback on exception."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exception_during_context_rolls_back(
|
|
self, postgres_session_factory: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""Exception in context manager triggers automatic rollback."""
|
|
meeting = Meeting.create(title="Rollback Test")
|
|
|
|
with pytest.raises(RuntimeError):
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
await uow.meetings.create(meeting)
|
|
raise RuntimeError("Simulated failure")
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
result = await uow.meetings.get(meeting.id)
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rollback_after_multiple_operations(
|
|
self, postgres_session_factory: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""Rollback after multiple operations reverts all changes."""
|
|
meeting = Meeting.create(title="Multi-op Rollback")
|
|
|
|
with pytest.raises(ValueError):
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
await uow.meetings.create(meeting)
|
|
|
|
segment = Segment(
|
|
segment_id=0,
|
|
text="Test segment",
|
|
start_time=0.0,
|
|
end_time=1.0,
|
|
meeting_id=meeting.id,
|
|
)
|
|
await uow.segments.add(meeting.id, segment)
|
|
|
|
raise ValueError("Simulated batch failure")
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
result = await uow.meetings.get(meeting.id)
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exception_type_does_not_matter(
|
|
self, postgres_session_factory: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""Any exception type triggers rollback."""
|
|
meeting = Meeting.create(title="Exception Type Test")
|
|
|
|
class CustomError(Exception):
|
|
pass
|
|
|
|
with pytest.raises(CustomError):
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
await uow.meetings.create(meeting)
|
|
raise CustomError("Custom error")
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
result = await uow.meetings.get(meeting.id)
|
|
assert result is None
|
|
|
|
|
|
class TestExplicitRollback:
|
|
"""Test explicit rollback behavior."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_rollback_reverts_changes(
|
|
self, postgres_session_factory: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""Explicit rollback() call reverts uncommitted changes."""
|
|
meeting = Meeting.create(title="Explicit Rollback")
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
await uow.meetings.create(meeting)
|
|
await uow.rollback()
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
result = await uow.meetings.get(meeting.id)
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commit_after_rollback_is_no_op(
|
|
self, postgres_session_factory: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""Commit after rollback doesn't resurrect rolled-back data."""
|
|
meeting = Meeting.create(title="Commit After Rollback")
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
await uow.meetings.create(meeting)
|
|
await uow.rollback()
|
|
await uow.commit()
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
result = await uow.meetings.get(meeting.id)
|
|
assert result is None
|
|
|
|
|
|
class TestCommitPersistence:
|
|
"""Test that committed data persists."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_committed_data_visible_in_new_uow(
|
|
self, postgres_session_factory: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""Data committed in one UoW is visible in subsequent UoW."""
|
|
meeting = Meeting.create(title="Visibility Test")
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
await uow.meetings.create(meeting)
|
|
await uow.commit()
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
result = await uow.meetings.get(meeting.id)
|
|
assert result is not None
|
|
assert result.title == "Visibility Test"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_committed_meeting_and_segment(
|
|
self, postgres_session_factory: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""Committed meeting and segment both persist."""
|
|
meeting = Meeting.create(title="Meeting With Segment")
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
await uow.meetings.create(meeting)
|
|
await uow.commit()
|
|
|
|
segment = Segment(
|
|
segment_id=0,
|
|
text="Test segment text",
|
|
start_time=0.0,
|
|
end_time=1.5,
|
|
meeting_id=meeting.id,
|
|
)
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
await uow.segments.add(meeting.id, segment)
|
|
await uow.commit()
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
segments = await uow.segments.get_by_meeting(meeting.id)
|
|
assert len(segments) == 1
|
|
assert segments[0].text == "Test segment text"
|
|
|
|
|
|
class TestBatchOperationRollback:
|
|
"""Test rollback behavior with batch operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_segment_add_rollback(
|
|
self, postgres_session_factory: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""Batch segment operations are fully rolled back on failure."""
|
|
meeting = Meeting.create(title="Batch Rollback Test")
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
await uow.meetings.create(meeting)
|
|
await uow.commit()
|
|
|
|
segments = [
|
|
Segment(
|
|
segment_id=i,
|
|
text=f"Segment {i}",
|
|
start_time=float(i),
|
|
end_time=float(i + 1),
|
|
meeting_id=meeting.id,
|
|
)
|
|
for i in range(10)
|
|
]
|
|
|
|
with pytest.raises(RuntimeError):
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
await uow.segments.add_batch(meeting.id, segments)
|
|
raise RuntimeError("Batch failure")
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
result = await uow.segments.get_by_meeting(meeting.id)
|
|
assert len(result) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_batch_no_partial_persist(
|
|
self, postgres_session_factory: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""Failure mid-batch doesn't leave partial data."""
|
|
meeting = Meeting.create(title="Partial Batch Test")
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
await uow.meetings.create(meeting)
|
|
await uow.commit()
|
|
|
|
with pytest.raises(ValueError):
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
for i in range(5):
|
|
segment = Segment(
|
|
segment_id=i,
|
|
text=f"Segment {i}",
|
|
start_time=float(i),
|
|
end_time=float(i + 1),
|
|
meeting_id=meeting.id,
|
|
)
|
|
await uow.segments.add(meeting.id, segment)
|
|
|
|
raise ValueError("Mid-batch failure")
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
result = await uow.segments.get_by_meeting(meeting.id)
|
|
assert len(result) == 0
|
|
|
|
|
|
class TestIsolation:
|
|
"""Test transaction isolation between UoW instances."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_uncommitted_data_not_visible_externally(
|
|
self, postgres_session_factory: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""Uncommitted data in one UoW not visible in another."""
|
|
meeting = Meeting.create(title="Isolation Test")
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow1:
|
|
await uow1.meetings.create(meeting)
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow2:
|
|
result = await uow2.meetings.get(meeting.id)
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_independent_uow_transactions(
|
|
self, postgres_session_factory: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""Two UoW instances have independent transactions."""
|
|
meeting1 = Meeting.create(title="Meeting 1")
|
|
meeting2 = Meeting.create(title="Meeting 2")
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow1:
|
|
await uow1.meetings.create(meeting1)
|
|
await uow1.commit()
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow2:
|
|
await uow2.meetings.create(meeting2)
|
|
await uow2.rollback()
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
result1 = await uow.meetings.get(meeting1.id)
|
|
result2 = await uow.meetings.get(meeting2.id)
|
|
|
|
assert result1 is not None
|
|
assert result2 is None
|
|
|
|
|
|
class TestMeetingStateRollback:
|
|
"""Test rollback on meeting state changes."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_meeting_state_change_rollback(
|
|
self, postgres_session_factory: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""Meeting state changes are rolled back on failure."""
|
|
meeting = Meeting.create(title="State Rollback")
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
await uow.meetings.create(meeting)
|
|
await uow.commit()
|
|
|
|
original_state = meeting.state
|
|
|
|
with pytest.raises(ValueError):
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
m = await uow.meetings.get(meeting.id)
|
|
assert m is not None
|
|
m.start_recording()
|
|
await uow.meetings.update(m)
|
|
raise ValueError("Business logic failure")
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
result = await uow.meetings.get(meeting.id)
|
|
assert result is not None
|
|
assert result.state == original_state
|
|
|
|
|
|
class TestRepositoryContextRequirement:
|
|
"""Test that repositories require UoW context."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_repo_access_outside_context_raises(
|
|
self, postgres_session_factory: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""Accessing repository outside context raises RuntimeError."""
|
|
uow = SqlAlchemyUnitOfWork(postgres_session_factory)
|
|
|
|
with pytest.raises(RuntimeError, match="UnitOfWork not in context"):
|
|
_ = uow.meetings
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commit_outside_context_raises(
|
|
self, postgres_session_factory: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""Calling commit outside context raises RuntimeError."""
|
|
uow = SqlAlchemyUnitOfWork(postgres_session_factory)
|
|
|
|
with pytest.raises(RuntimeError, match="UnitOfWork not in context"):
|
|
await uow.commit()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rollback_outside_context_raises(
|
|
self, postgres_session_factory: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""Calling rollback outside context raises RuntimeError."""
|
|
uow = SqlAlchemyUnitOfWork(postgres_session_factory)
|
|
|
|
with pytest.raises(RuntimeError, match="UnitOfWork not in context"):
|
|
await uow.rollback()
|
|
|
|
|
|
class TestMultipleMeetingOperations:
|
|
"""Test transactions spanning multiple meetings."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_meetings_atomic(
|
|
self, postgres_session_factory: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""Multiple meeting creates are atomic."""
|
|
meetings = [Meeting.create(title=f"Meeting {i}") for i in range(5)]
|
|
|
|
with pytest.raises(RuntimeError):
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
for meeting in meetings:
|
|
await uow.meetings.create(meeting)
|
|
raise RuntimeError("All or nothing")
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
for meeting in meetings:
|
|
result = await uow.meetings.get(meeting.id)
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_meetings_commit_all(
|
|
self, postgres_session_factory: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""Multiple meetings commit together."""
|
|
meetings = [Meeting.create(title=f"Meeting {i}") for i in range(5)]
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
for meeting in meetings:
|
|
await uow.meetings.create(meeting)
|
|
await uow.commit()
|
|
|
|
async with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
|
|
for meeting in meetings:
|
|
result = await uow.meetings.get(meeting.id)
|
|
assert result is not None
|
|
assert meeting.title in result.title
|