Files
noteflow/tests/stress/test_transaction_boundaries.py
Travis Vasceannie b333ea5b23 Add initial Docker and development environment setup
- 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.
2025-12-19 05:02:16 +00:00

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