diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1c85c2..d836b52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,7 @@ jobs: --health-retries 10 env: TIMEFLOW_DATABASE_URL: postgresql+psycopg://timeapp:timeapp@127.0.0.1:5432/timeapp + TIMEFLOW_TEST_DATABASE_URL: postgresql+psycopg://timeapp:timeapp@127.0.0.1:5432/timeapp steps: - uses: actions/checkout@v4 @@ -104,6 +105,13 @@ jobs: - name: Apply migrations (upgrade head) run: uv run alembic upgrade head + - name: PostgreSQL schema and repository integration tests + run: >- + uv run pytest + tests/test_postgres_schema.py + tests/test_postgres_schedule_repository.py + --no-cov + - name: Verify downgrade path (downgrade base) run: uv run alembic downgrade base diff --git a/backend/src/timeflow/data/repositories/__init__.py b/backend/src/timeflow/data/repositories/__init__.py new file mode 100644 index 0000000..5635556 --- /dev/null +++ b/backend/src/timeflow/data/repositories/__init__.py @@ -0,0 +1,8 @@ +"""Concrete database repositories.""" + +from timeflow.data.repositories.schedule import ( + ScheduleRepository, + ScheduleRevisionConflictError, +) + +__all__ = ["ScheduleRepository", "ScheduleRevisionConflictError"] diff --git a/backend/src/timeflow/data/repositories/schedule.py b/backend/src/timeflow/data/repositories/schedule.py new file mode 100644 index 0000000..76961a4 --- /dev/null +++ b/backend/src/timeflow/data/repositories/schedule.py @@ -0,0 +1,361 @@ +"""SQLAlchemy persistence adapter for schedules and occurrence overrides.""" + +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import select, update +from sqlalchemy.orm import Session + +from timeflow.business.calendar.contracts import ( + OccurrenceOverrideAction, + ReminderDispositionState, + ReminderStrength, + ReminderType, + ScheduleKind, + ScheduleOccurrenceOverrideSnapshot, + ScheduleSnapshot, + ScheduleStatus, + ScheduleType, +) +from timeflow.data.models import Schedule, ScheduleOccurrenceOverride + + +class ScheduleRevisionConflictError(RuntimeError): + """An account-owned schedule no longer has the expected revision.""" + + __slots__ = ("actual_revision", "expected_revision", "schedule_id") + + def __init__( + self, + *, + schedule_id: str, + expected_revision: int, + actual_revision: int, + ) -> None: + super().__init__( + f"Schedule {schedule_id!r} revision conflict: " + f"expected {expected_revision}, found {actual_revision}" + ) + self.schedule_id = schedule_id + self.expected_revision = expected_revision + self.actual_revision = actual_revision + + +class ScheduleRepository: + """Account-scoped persistence primitives used by the schedule service. + + The repository flushes changes but never commits or rolls back. Transaction + ownership stays with the later application service implementation. + """ + + def __init__(self, session: Session) -> None: + self._session = session + + def add_schedule(self, snapshot: ScheduleSnapshot) -> ScheduleSnapshot: + """Insert one cloud schedule without committing the surrounding transaction.""" + model = Schedule(**_schedule_values(snapshot)) + self._session.add(model) + self._session.flush() + return _to_schedule_snapshot(model) + + def get_schedule( + self, + *, + account_id: str, + schedule_id: str, + include_deleted: bool = False, + ) -> ScheduleSnapshot | None: + """Return one schedule only when it belongs to the requested account.""" + statement = select(Schedule).where( + Schedule.account_id == account_id, + Schedule.id == schedule_id, + ) + if not include_deleted: + statement = statement.where(Schedule.status == ScheduleStatus.ACTIVE.value) + + model = self._session.scalar(statement) + return None if model is None else _to_schedule_snapshot(model) + + def list_schedules( + self, + *, + account_id: str, + include_deleted: bool = False, + ) -> tuple[ScheduleSnapshot, ...]: + """List schedules for exactly one account in deterministic order.""" + statement = select(Schedule).where(Schedule.account_id == account_id) + if not include_deleted: + statement = statement.where(Schedule.status == ScheduleStatus.ACTIVE.value) + statement = statement.order_by(Schedule.start_time, Schedule.created_at, Schedule.id) + + return tuple(_to_schedule_snapshot(model) for model in self._session.scalars(statement)) + + def update_schedule( + self, + *, + snapshot: ScheduleSnapshot, + expected_revision: int, + ) -> ScheduleSnapshot | None: + """Atomically replace mutable fields and increment the persisted revision. + + Returns ``None`` when the schedule is absent from the account. Raises + ``ScheduleRevisionConflictError`` when the account owns the schedule but + its current revision differs from ``expected_revision``. + """ + statement = ( + update(Schedule) + .where( + Schedule.id == snapshot.id, + Schedule.account_id == snapshot.account_id, + Schedule.revision == expected_revision, + ) + .values( + **_schedule_update_values(snapshot), + revision=Schedule.revision + 1, + ) + .returning(Schedule) + ) + model = self._session.scalars(statement).one_or_none() + if model is not None: + return _to_schedule_snapshot(model) + + actual_revision = self._session.scalar( + select(Schedule.revision).where( + Schedule.id == snapshot.id, + Schedule.account_id == snapshot.account_id, + ) + ) + if actual_revision is not None: + raise ScheduleRevisionConflictError( + schedule_id=snapshot.id, + expected_revision=expected_revision, + actual_revision=actual_revision, + ) + return None + + def add_occurrence_override( + self, + *, + account_id: str, + snapshot: ScheduleOccurrenceOverrideSnapshot, + ) -> ScheduleOccurrenceOverrideSnapshot | None: + """Insert an override only for schedules owned by the requested account.""" + if not self._schedule_belongs_to_account(account_id, snapshot.schedule_id): + return None + if snapshot.replacement_schedule_id is not None and not self._schedule_belongs_to_account( + account_id, snapshot.replacement_schedule_id + ): + return None + + model = ScheduleOccurrenceOverride( + id=snapshot.id, + schedule_id=snapshot.schedule_id, + occurrence_start=snapshot.occurrence_start, + action=snapshot.action.value, + replacement_schedule_id=snapshot.replacement_schedule_id, + created_at=snapshot.created_at, + updated_at=snapshot.updated_at, + ) + self._session.add(model) + self._session.flush() + return _to_override_snapshot(model) + + def get_occurrence_override( + self, + *, + account_id: str, + override_id: str, + ) -> ScheduleOccurrenceOverrideSnapshot | None: + """Return one override through an account-scoped schedule join.""" + statement = ( + select(ScheduleOccurrenceOverride) + .join(Schedule, Schedule.id == ScheduleOccurrenceOverride.schedule_id) + .where( + Schedule.account_id == account_id, + ScheduleOccurrenceOverride.id == override_id, + ) + ) + model = self._session.scalar(statement) + return None if model is None else _to_override_snapshot(model) + + def update_occurrence_override( + self, + *, + account_id: str, + schedule_id: str, + occurrence_start: datetime, + action: OccurrenceOverrideAction, + replacement_schedule_id: str | None, + updated_at: datetime, + ) -> ScheduleOccurrenceOverrideSnapshot | None: + """Update the existing override identified by its schedule occurrence. + + This persistence operation deliberately does not increment the parent + schedule revision. The application service must call ``update_schedule`` + with the expected revision in the same Session transaction so a conflict + rolls back both aggregate changes together. + """ + if not self._schedule_belongs_to_account(account_id, schedule_id): + return None + if replacement_schedule_id is not None and not self._schedule_belongs_to_account( + account_id, replacement_schedule_id + ): + return None + + statement = ( + update(ScheduleOccurrenceOverride) + .where( + ScheduleOccurrenceOverride.schedule_id == schedule_id, + ScheduleOccurrenceOverride.occurrence_start == occurrence_start, + ) + .values( + action=action.value, + replacement_schedule_id=replacement_schedule_id, + updated_at=updated_at, + ) + .returning(ScheduleOccurrenceOverride) + ) + model = self._session.scalars(statement).one_or_none() + return None if model is None else _to_override_snapshot(model) + + def list_occurrence_overrides( + self, + *, + account_id: str, + schedule_id: str | None = None, + ) -> tuple[ScheduleOccurrenceOverrideSnapshot, ...]: + """List overrides whose recurring schedules belong to one account.""" + statement = ( + select(ScheduleOccurrenceOverride) + .join(Schedule, Schedule.id == ScheduleOccurrenceOverride.schedule_id) + .where(Schedule.account_id == account_id) + ) + if schedule_id is not None: + statement = statement.where(ScheduleOccurrenceOverride.schedule_id == schedule_id) + statement = statement.order_by( + ScheduleOccurrenceOverride.occurrence_start, + ScheduleOccurrenceOverride.id, + ) + return tuple(_to_override_snapshot(model) for model in self._session.scalars(statement)) + + def _schedule_belongs_to_account(self, account_id: str, schedule_id: str) -> bool: + statement = select(Schedule.id).where( + Schedule.account_id == account_id, + Schedule.id == schedule_id, + ) + return self._session.scalar(statement) is not None + + +def _schedule_values(snapshot: ScheduleSnapshot) -> dict[str, object]: + """Map the framework-independent snapshot to ORM column values.""" + return { + "id": snapshot.id, + "account_id": snapshot.account_id, + "schedule_type": snapshot.schedule_type.value, + "schedule_kind": snapshot.schedule_kind.value, + "title": snapshot.title, + "is_all_day": snapshot.is_all_day, + "start_time": snapshot.start_time, + "end_time": snapshot.end_time, + "timezone": snapshot.timezone, + "recurrence_rule": snapshot.recurrence_rule, + "location_name": snapshot.location_name, + "latitude": None if snapshot.latitude is None else Decimal(str(snapshot.latitude)), + "longitude": None if snapshot.longitude is None else Decimal(str(snapshot.longitude)), + "reminder_type": None if snapshot.reminder_type is None else snapshot.reminder_type.value, + "reminder_trigger_at": snapshot.reminder_trigger_at, + "reminder_offset_minutes": snapshot.reminder_offset_minutes, + "reminder_strength": ( + None if snapshot.reminder_strength is None else snapshot.reminder_strength.value + ), + "reminder_disposition_state": ( + None + if snapshot.reminder_disposition_state is None + else snapshot.reminder_disposition_state.value + ), + "status": snapshot.status.value, + "revision": snapshot.revision, + "created_at": snapshot.created_at, + "updated_at": snapshot.updated_at, + "deleted_at": snapshot.deleted_at, + } + + +def _schedule_update_values(snapshot: ScheduleSnapshot) -> dict[str, object]: + """Map only fields that the update contract permits callers to replace.""" + values = _schedule_values(snapshot) + mutable_fields = ( + "schedule_type", + "schedule_kind", + "title", + "is_all_day", + "start_time", + "end_time", + "timezone", + "recurrence_rule", + "location_name", + "latitude", + "longitude", + "reminder_type", + "reminder_trigger_at", + "reminder_offset_minutes", + "reminder_strength", + "reminder_disposition_state", + "status", + "updated_at", + "deleted_at", + ) + return {field: values[field] for field in mutable_fields} + + +def _to_schedule_snapshot(model: Schedule) -> ScheduleSnapshot: + """Map one ORM row to the shared final cloud snapshot contract.""" + return ScheduleSnapshot( + id=model.id, + account_id=model.account_id, + schedule_type=ScheduleType(model.schedule_type), + schedule_kind=ScheduleKind(model.schedule_kind), + title=model.title, + is_all_day=model.is_all_day, + timezone=model.timezone, + status=ScheduleStatus(model.status), + revision=model.revision, + created_at=model.created_at, + updated_at=model.updated_at, + start_time=model.start_time, + end_time=model.end_time, + recurrence_rule=model.recurrence_rule, + location_name=model.location_name, + latitude=None if model.latitude is None else float(model.latitude), + longitude=None if model.longitude is None else float(model.longitude), + reminder_type=None if model.reminder_type is None else ReminderType(model.reminder_type), + reminder_trigger_at=model.reminder_trigger_at, + reminder_offset_minutes=model.reminder_offset_minutes, + reminder_strength=( + None if model.reminder_strength is None else ReminderStrength(model.reminder_strength) + ), + reminder_disposition_state=( + None + if model.reminder_disposition_state is None + else ReminderDispositionState(model.reminder_disposition_state) + ), + deleted_at=model.deleted_at, + ) + + +def _to_override_snapshot( + model: ScheduleOccurrenceOverride, +) -> ScheduleOccurrenceOverrideSnapshot: + """Map one occurrence override row to the shared snapshot contract.""" + return ScheduleOccurrenceOverrideSnapshot( + id=model.id, + schedule_id=model.schedule_id, + occurrence_start=model.occurrence_start, + action=OccurrenceOverrideAction(model.action), + replacement_schedule_id=model.replacement_schedule_id, + created_at=model.created_at, + updated_at=model.updated_at, + ) + + +__all__ = ["ScheduleRepository", "ScheduleRevisionConflictError"] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..bb85ae9 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,33 @@ +"""Shared pytest fixtures for disposable PostgreSQL integration tests.""" + +import os +from collections.abc import Iterator + +import pytest +import sqlalchemy as sa +from sqlalchemy import Engine +from sqlalchemy.engine import Connection + + +@pytest.fixture(scope="session") +def postgres_engine() -> Iterator[Engine]: + """Connect only when an explicit disposable integration database is supplied.""" + database_url = os.getenv("TIMEFLOW_TEST_DATABASE_URL") + if database_url is None: + pytest.skip("TIMEFLOW_TEST_DATABASE_URL is not set") + engine = sa.create_engine(database_url) + try: + yield engine + finally: + engine.dispose() + + +@pytest.fixture +def postgres_connection(postgres_engine: Engine) -> Iterator[Connection]: + """Roll back integration-test data after each test.""" + with postgres_engine.connect() as connection: + transaction = connection.begin() + try: + yield connection + finally: + transaction.rollback() diff --git a/backend/tests/test_postgres_schedule_repository.py b/backend/tests/test_postgres_schedule_repository.py new file mode 100644 index 0000000..e609cee --- /dev/null +++ b/backend/tests/test_postgres_schedule_repository.py @@ -0,0 +1,215 @@ +"""PostgreSQL integration tests for the schedule persistence adapter.""" + +from collections.abc import Iterator +from dataclasses import replace +from datetime import UTC, datetime + +import pytest +import sqlalchemy as sa +from sqlalchemy import Engine +from sqlalchemy.engine import Connection +from sqlalchemy.orm import Session + +from timeflow.business.calendar import ( + OccurrenceOverrideAction, + ScheduleKind, + ScheduleOccurrenceOverrideSnapshot, + ScheduleSnapshot, + ScheduleStatus, + ScheduleType, +) +from timeflow.data.models import Account, ScheduleOccurrenceOverride +from timeflow.data.repositories import ScheduleRepository, ScheduleRevisionConflictError + + +@pytest.fixture +def postgres_session(postgres_connection: Connection) -> Iterator[Session]: + """Join the shared rollback transaction through a test-owned savepoint.""" + with Session( + bind=postgres_connection, + join_transaction_mode="create_savepoint", + ) as session: + yield session + + +def _seed_account(session: Session, account_id: str) -> None: + now = datetime.now(UTC) + session.add( + Account( + id=account_id, + username=f"{account_id}@example.com", + password_hash="test-password-hash", + created_at=now, + updated_at=now, + ) + ) + session.flush() + + +def _schedule( + schedule_id: str, + account_id: str, + *, + revision: int = 1, +) -> ScheduleSnapshot: + now = datetime.now(UTC) + return ScheduleSnapshot( + id=schedule_id, + account_id=account_id, + schedule_type=ScheduleType.TIME, + schedule_kind=ScheduleKind.ONCE, + title=f"Schedule {schedule_id}", + is_all_day=False, + timezone="Asia/Shanghai", + status=ScheduleStatus.ACTIVE, + revision=revision, + created_at=now, + updated_at=now, + start_time=now, + ) + + +def test_postgres_repository_insert_and_update_return_final_revision( + postgres_session: Session, +) -> None: + """PostgreSQL RETURNING exposes the row after its atomic revision increment.""" + _seed_account(postgres_session, "account-a") + repository = ScheduleRepository(postgres_session) + inserted = repository.add_schedule(_schedule("schedule-a", "account-a", revision=5)) + + assert inserted.revision == 5 + + caller_snapshot = replace( + inserted, + title="Updated", + revision=1, + updated_at=datetime.now(UTC), + ) + updated = repository.update_schedule(snapshot=caller_snapshot, expected_revision=5) + + assert updated is not None + assert updated.title == "Updated" + assert updated.revision == 6 + assert updated.created_at == inserted.created_at + + +def test_postgres_repository_reports_conflict_and_preserves_account_isolation( + postgres_session: Session, +) -> None: + """A stale owner gets a conflict while another account observes no target row.""" + _seed_account(postgres_session, "account-a") + _seed_account(postgres_session, "account-b") + repository = ScheduleRepository(postgres_session) + inserted = repository.add_schedule(_schedule("schedule-a", "account-a", revision=5)) + stale = replace(inserted, title="Stale", revision=100, updated_at=datetime.now(UTC)) + + with pytest.raises(ScheduleRevisionConflictError) as raised: + repository.update_schedule(snapshot=stale, expected_revision=4) + + assert raised.value.actual_revision == 5 + + wrong_owner = replace(stale, account_id="account-b") + assert repository.update_schedule(snapshot=wrong_owner, expected_revision=5) is None + persisted = repository.get_schedule(account_id="account-a", schedule_id=inserted.id) + assert persisted is not None + assert persisted.title == inserted.title + assert persisted.revision == 5 + + +def test_postgres_repository_updates_one_unique_occurrence_override( + postgres_session: Session, +) -> None: + """One occurrence can change action without duplicating its unique key.""" + _seed_account(postgres_session, "account-a") + repository = ScheduleRepository(postgres_session) + parent = repository.add_schedule(_schedule("series-a", "account-a", revision=5)) + replacement = repository.add_schedule(_schedule("replacement-a", "account-a")) + now = datetime.now(UTC) + original = ScheduleOccurrenceOverrideSnapshot( + id="override-a", + schedule_id=parent.id, + occurrence_start=now, + action=OccurrenceOverrideAction.CANCEL, + created_at=now, + updated_at=now, + ) + repository.add_occurrence_override(account_id="account-a", snapshot=original) + + updated = repository.update_occurrence_override( + account_id="account-a", + schedule_id=parent.id, + occurrence_start=now, + action=OccurrenceOverrideAction.REPLACE, + replacement_schedule_id=replacement.id, + updated_at=datetime.now(UTC), + ) + + assert updated is not None + assert updated.id == original.id + assert updated.action is OccurrenceOverrideAction.REPLACE + assert updated.replacement_schedule_id == replacement.id + persisted_parent = repository.get_schedule(account_id="account-a", schedule_id=parent.id) + assert persisted_parent is not None + assert persisted_parent.revision == 5 + + duplicate = replace(original, id="override-duplicate") + with pytest.raises(sa.exc.IntegrityError): + with postgres_session.begin_nested(): + repository.add_occurrence_override(account_id="account-a", snapshot=duplicate) + + +def test_postgres_repository_and_database_enforce_override_ownership_and_fk( + postgres_session: Session, +) -> None: + """Repository ownership checks complement the PostgreSQL foreign key.""" + _seed_account(postgres_session, "account-a") + _seed_account(postgres_session, "account-b") + repository = ScheduleRepository(postgres_session) + parent = repository.add_schedule(_schedule("series-a", "account-a")) + other_account_replacement = repository.add_schedule(_schedule("replacement-b", "account-b")) + now = datetime.now(UTC) + cross_account = ScheduleOccurrenceOverrideSnapshot( + id="override-cross-account", + schedule_id=parent.id, + occurrence_start=now, + action=OccurrenceOverrideAction.REPLACE, + replacement_schedule_id=other_account_replacement.id, + created_at=now, + updated_at=now, + ) + + assert ( + repository.add_occurrence_override(account_id="account-a", snapshot=cross_account) is None + ) + + with pytest.raises(sa.exc.IntegrityError): + with postgres_session.begin_nested(): + postgres_session.add( + ScheduleOccurrenceOverride( + id="override-missing-parent", + schedule_id="missing-series", + occurrence_start=now, + action=OccurrenceOverrideAction.CANCEL.value, + replacement_schedule_id=None, + created_at=now, + updated_at=now, + ) + ) + postgres_session.flush() + + +def test_postgres_repository_respects_caller_transaction_rollback( + postgres_engine: Engine, +) -> None: + """Repository flushes are discarded when the owning transaction rolls back.""" + account_id = "account-rollback" + schedule_id = "schedule-rollback" + with Session(postgres_engine) as session: + _seed_account(session, account_id) + repository = ScheduleRepository(session) + repository.add_schedule(_schedule(schedule_id, account_id)) + session.rollback() + + with Session(postgres_engine) as verification_session: + repository = ScheduleRepository(verification_session) + assert repository.get_schedule(account_id=account_id, schedule_id=schedule_id) is None diff --git a/backend/tests/test_postgres_schema.py b/backend/tests/test_postgres_schema.py index 90897cb..9182338 100644 --- a/backend/tests/test_postgres_schema.py +++ b/backend/tests/test_postgres_schema.py @@ -1,7 +1,5 @@ """PostgreSQL integration contract for the cloud schedule schema.""" -import os -from collections.abc import Iterator from datetime import UTC, datetime from typing import Any @@ -100,32 +98,6 @@ def _canonical_type(column_type: sa.types.TypeEngine[object]) -> str: raise AssertionError(f"Unexpected reflected type: {column_type!r}") -@pytest.fixture(scope="module") -def postgres_engine() -> Iterator[Engine]: - """Connect only when an explicit disposable integration database is supplied.""" - - database_url = os.getenv("TIMEFLOW_TEST_DATABASE_URL") - if database_url is None: - pytest.skip("TIMEFLOW_TEST_DATABASE_URL is not set") - engine = sa.create_engine(database_url) - try: - yield engine - finally: - engine.dispose() - - -@pytest.fixture -def postgres_connection(postgres_engine: Engine) -> Iterator[Connection]: - """Roll back behavior-test data after each test.""" - - with postgres_engine.connect() as connection: - transaction = connection.begin() - try: - yield connection - finally: - transaction.rollback() - - def _account_values(account_id: str = "acct-test") -> dict[str, Any]: now = datetime(2026, 8, 10, tzinfo=UTC) return { diff --git a/backend/tests/test_schedule_repository.py b/backend/tests/test_schedule_repository.py new file mode 100644 index 0000000..2c8cbff --- /dev/null +++ b/backend/tests/test_schedule_repository.py @@ -0,0 +1,283 @@ +"""Repository tests for account isolation and optimistic persistence primitives.""" + +from collections.abc import Generator +from dataclasses import replace +from datetime import UTC, datetime + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from timeflow.business.calendar import ( + OccurrenceOverrideAction, + ScheduleKind, + ScheduleOccurrenceOverrideSnapshot, + ScheduleSnapshot, + ScheduleStatus, + ScheduleType, +) +from timeflow.data.database import Base +from timeflow.data.models import Account +from timeflow.data.repositories import ScheduleRepository, ScheduleRevisionConflictError + + +@pytest.fixture +def session() -> Generator[Session, None, None]: + """Return an isolated SQLAlchemy session for repository behavior tests.""" + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(engine) + with Session(engine) as database_session: + now = datetime.now(UTC) + database_session.add_all( + [ + Account( + id=account_id, + username=f"{account_id}@example.com", + password_hash="test-password-hash", + created_at=now, + updated_at=now, + ) + for account_id in ("account-a", "account-b") + ] + ) + database_session.flush() + yield database_session + + +def _schedule( + schedule_id: str, + account_id: str, + *, + revision: int = 1, +) -> ScheduleSnapshot: + now = datetime.now(UTC) + return ScheduleSnapshot( + id=schedule_id, + account_id=account_id, + schedule_type=ScheduleType.TIME, + schedule_kind=ScheduleKind.ONCE, + title=f"Schedule {schedule_id}", + is_all_day=False, + timezone="Asia/Shanghai", + status=ScheduleStatus.ACTIVE, + revision=revision, + created_at=now, + updated_at=now, + start_time=now, + ) + + +def test_schedule_reads_are_account_scoped(session: Session) -> None: + """An account can never load rows owned by another account.""" + repository = ScheduleRepository(session) + repository.add_schedule(_schedule("schedule-a", "account-a")) + repository.add_schedule(_schedule("schedule-b", "account-b")) + + assert repository.get_schedule(account_id="account-a", schedule_id="schedule-b") is None + account_schedules = repository.list_schedules(account_id="account-a") + assert [snapshot.id for snapshot in account_schedules] == ["schedule-a"] + + +def test_schedule_update_atomically_increments_revision(session: Session) -> None: + """The database revision advances regardless of the caller snapshot value.""" + repository = ScheduleRepository(session) + original = repository.add_schedule(_schedule("schedule-a", "account-a", revision=5)) + unchanged_revision = replace( + original, + title="Updated once", + revision=5, + updated_at=datetime.now(UTC), + ) + + persisted = repository.update_schedule(snapshot=unchanged_revision, expected_revision=5) + + assert persisted is not None + assert persisted.title == "Updated once" + assert persisted.revision == 6 + + lower_revision = replace( + persisted, + title="Updated twice", + revision=1, + updated_at=datetime.now(UTC), + ) + persisted_again = repository.update_schedule(snapshot=lower_revision, expected_revision=6) + + assert persisted_again is not None + assert persisted_again.title == "Updated twice" + assert persisted_again.revision == 7 + + +def test_schedule_update_raises_explicit_revision_conflict(session: Session) -> None: + """A stale expected revision is distinguishable from a missing schedule.""" + repository = ScheduleRepository(session) + original = repository.add_schedule(_schedule("schedule-a", "account-a", revision=5)) + updated = replace(original, title="Stale update", revision=100, updated_at=datetime.now(UTC)) + + with pytest.raises(ScheduleRevisionConflictError) as raised: + repository.update_schedule(snapshot=updated, expected_revision=4) + + assert raised.value.schedule_id == original.id + assert raised.value.expected_revision == 4 + assert raised.value.actual_revision == 5 + persisted = repository.get_schedule(account_id="account-a", schedule_id=original.id) + assert persisted is not None + assert persisted.title == original.title + assert persisted.revision == 5 + + +def test_schedule_update_cannot_cross_account_boundary(session: Session) -> None: + """An otherwise valid revision cannot update another account's schedule.""" + repository = ScheduleRepository(session) + original = repository.add_schedule(_schedule("schedule-a", "account-a", revision=5)) + wrong_owner = replace( + original, + account_id="account-b", + title="Cross-account update", + revision=100, + updated_at=datetime.now(UTC), + ) + + assert repository.update_schedule(snapshot=wrong_owner, expected_revision=5) is None + persisted = repository.get_schedule(account_id="account-a", schedule_id=original.id) + assert persisted is not None + assert persisted.title == original.title + assert persisted.revision == 5 + + +def test_schedule_update_preserves_immutable_creation_time(session: Session) -> None: + """A replacement snapshot cannot rewrite the persisted creation timestamp.""" + repository = ScheduleRepository(session) + original = repository.add_schedule(_schedule("schedule-a", "account-a")) + caller_created_at = datetime(2020, 1, 1, tzinfo=UTC) + updated = replace( + original, + title="Updated", + revision=2, + created_at=caller_created_at, + updated_at=datetime.now(UTC), + ) + + persisted = repository.update_schedule(snapshot=updated, expected_revision=1) + + assert persisted is not None + assert persisted.created_at == original.created_at.replace(tzinfo=None) + assert persisted.created_at != caller_created_at.replace(tzinfo=None) + + +def test_deleted_schedules_are_hidden_by_default(session: Session) -> None: + """Soft-deleted rows remain available only to explicit snapshot queries.""" + repository = ScheduleRepository(session) + original = repository.add_schedule(_schedule("schedule-a", "account-a")) + deleted = replace( + original, + status=ScheduleStatus.DELETED, + revision=2, + updated_at=datetime.now(UTC), + deleted_at=datetime.now(UTC), + ) + assert repository.update_schedule(snapshot=deleted, expected_revision=1) is not None + + assert repository.get_schedule(account_id="account-a", schedule_id="schedule-a") is None + assert ( + repository.get_schedule( + account_id="account-a", + schedule_id="schedule-a", + include_deleted=True, + ) + is not None + ) + + +def test_occurrence_overrides_are_account_scoped(session: Session) -> None: + """Override writes and reads follow ownership through their parent schedule.""" + repository = ScheduleRepository(session) + parent = repository.add_schedule(_schedule("series-a", "account-a")) + repository.add_schedule(_schedule("series-b", "account-b")) + now = datetime.now(UTC) + override = ScheduleOccurrenceOverrideSnapshot( + id="override-a", + schedule_id=parent.id, + occurrence_start=now, + action=OccurrenceOverrideAction.CANCEL, + created_at=now, + updated_at=now, + ) + + assert repository.add_occurrence_override(account_id="account-b", snapshot=override) is None + assert repository.add_occurrence_override(account_id="account-a", snapshot=override) == override + assert repository.list_occurrence_overrides(account_id="account-b") == () + account_overrides = repository.list_occurrence_overrides(account_id="account-a") + assert [snapshot.id for snapshot in account_overrides] == ["override-a"] + assert account_overrides[0].action is OccurrenceOverrideAction.CANCEL + + +def test_occurrence_override_can_be_updated_without_hidden_revision_change( + session: Session, +) -> None: + """Override persistence changes one unique occurrence but not its parent aggregate.""" + repository = ScheduleRepository(session) + parent = repository.add_schedule(_schedule("series-a", "account-a", revision=5)) + replacement = repository.add_schedule(_schedule("replacement-a", "account-a")) + now = datetime.now(UTC) + original = ScheduleOccurrenceOverrideSnapshot( + id="override-a", + schedule_id=parent.id, + occurrence_start=now, + action=OccurrenceOverrideAction.CANCEL, + created_at=now, + updated_at=now, + ) + repository.add_occurrence_override(account_id="account-a", snapshot=original) + + updated = repository.update_occurrence_override( + account_id="account-a", + schedule_id=parent.id, + occurrence_start=now, + action=OccurrenceOverrideAction.REPLACE, + replacement_schedule_id=replacement.id, + updated_at=datetime.now(UTC), + ) + + assert updated is not None + assert updated.id == original.id + assert updated.action is OccurrenceOverrideAction.REPLACE + assert updated.replacement_schedule_id == replacement.id + assert updated.created_at.replace(tzinfo=UTC) == original.created_at + persisted_parent = repository.get_schedule(account_id="account-a", schedule_id=parent.id) + assert persisted_parent is not None + assert persisted_parent.revision == 5 + + +def test_occurrence_override_update_is_account_scoped(session: Session) -> None: + """An account cannot modify an override through another account's parent.""" + repository = ScheduleRepository(session) + parent = repository.add_schedule(_schedule("series-a", "account-a")) + now = datetime.now(UTC) + original = ScheduleOccurrenceOverrideSnapshot( + id="override-a", + schedule_id=parent.id, + occurrence_start=now, + action=OccurrenceOverrideAction.CANCEL, + created_at=now, + updated_at=now, + ) + repository.add_occurrence_override(account_id="account-a", snapshot=original) + + assert ( + repository.update_occurrence_override( + account_id="account-b", + schedule_id=parent.id, + occurrence_start=now, + action=OccurrenceOverrideAction.REPLACE, + replacement_schedule_id=None, + updated_at=datetime.now(UTC), + ) + is None + ) + persisted = repository.get_occurrence_override( + account_id="account-a", + override_id=original.id, + ) + assert persisted is not None + assert persisted.action is OccurrenceOverrideAction.CANCEL