From 60fdb41228afdf1ffe2c7dda87e433201fba8974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=83=A7=E6=B4=97=E5=8F=91=E7=94=A8=E9=A3=98?= =?UTF-8?q?=E6=9F=94?= <2906266135@qq.com> Date: Tue, 11 Aug 2026 17:08:34 +0800 Subject: [PATCH 1/5] feat(schedule): implement agent application service --- backend/pyproject.toml | 2 + .../timeflow/business/calendar/__init__.py | 3 +- .../src/timeflow/business/calendar/ports.py | 101 +++ .../timeflow/business/calendar/recurrence.py | 83 +++ .../src/timeflow/business/calendar/service.py | 636 +++++++++++++++++- .../timeflow/data/repositories/schedule.py | 22 +- .../timeflow/data/schedule_unit_of_work.py | 45 ++ .../test_postgres_schedule_repository.py | 121 +++- .../test_schedule_application_service.py | 595 ++++++++++++++++ .../tests/test_schedule_service_skeleton.py | 39 +- backend/tests/test_schedule_unit_of_work.py | 110 +++ backend/uv.lock | 34 + 12 files changed, 1757 insertions(+), 34 deletions(-) create mode 100644 backend/src/timeflow/business/calendar/ports.py create mode 100644 backend/src/timeflow/business/calendar/recurrence.py create mode 100644 backend/src/timeflow/data/schedule_unit_of_work.py create mode 100644 backend/tests/test_schedule_application_service.py create mode 100644 backend/tests/test_schedule_unit_of_work.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5172114..9b9f99b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "psycopg[binary]>=3.2,<4", "pydantic>=2.13,<3", "python-dotenv>=1.1,<2", + "python-dateutil>=2.9,<3", "sqlalchemy>=2.0,<3", "uvicorn>=0.51,<1", "websockets>=15,<16", @@ -28,6 +29,7 @@ dev = [ "pytest-asyncio>=1,<2", "pytest-cov>=7,<8", "ruff>=0.15,<1", + "types-python-dateutil>=2.9,<3", ] [tool.hatch.build.targets.wheel] diff --git a/backend/src/timeflow/business/calendar/__init__.py b/backend/src/timeflow/business/calendar/__init__.py index 6112fa4..7bd7798 100644 --- a/backend/src/timeflow/business/calendar/__init__.py +++ b/backend/src/timeflow/business/calendar/__init__.py @@ -22,7 +22,7 @@ ScheduleUpdatePatch, UpdateScheduleCommand, ) -from timeflow.business.calendar.service import ScheduleAgentService +from timeflow.business.calendar.service import ScheduleAgentService, ScheduleApplicationService __all__ = [ "CreateScheduleCommand", @@ -35,6 +35,7 @@ "ReminderStrength", "ReminderType", "ScheduleAgentService", + "ScheduleApplicationService", "ScheduleBusinessError", "ScheduleErrorCode", "ScheduleKind", diff --git a/backend/src/timeflow/business/calendar/ports.py b/backend/src/timeflow/business/calendar/ports.py new file mode 100644 index 0000000..5814452 --- /dev/null +++ b/backend/src/timeflow/business/calendar/ports.py @@ -0,0 +1,101 @@ +"""Persistence abstractions owned by the schedule business layer.""" + +from collections.abc import Callable +from types import TracebackType +from typing import Protocol, Self + +from timeflow.business.calendar.contracts import ( + ScheduleOccurrenceOverrideSnapshot, + ScheduleSnapshot, +) + + +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 ScheduleRepositoryPort(Protocol): + """Account-scoped persistence operations required by the application service.""" + + def add_schedule(self, snapshot: ScheduleSnapshot) -> ScheduleSnapshot: ... + + def get_schedule( + self, + *, + account_id: str, + schedule_id: str, + include_deleted: bool = False, + ) -> ScheduleSnapshot | None: ... + + def list_schedules( + self, + *, + account_id: str, + include_deleted: bool = False, + ) -> tuple[ScheduleSnapshot, ...]: ... + + def update_schedule( + self, + *, + snapshot: ScheduleSnapshot, + expected_revision: int, + ) -> ScheduleSnapshot | None: ... + + def add_occurrence_override( + self, + *, + account_id: str, + snapshot: ScheduleOccurrenceOverrideSnapshot, + ) -> ScheduleOccurrenceOverrideSnapshot | None: ... + + def list_occurrence_overrides( + self, + *, + account_id: str, + schedule_id: str | None = None, + ) -> tuple[ScheduleOccurrenceOverrideSnapshot, ...]: ... + + +class ScheduleUnitOfWork(Protocol): + """One atomic schedule use-case transaction.""" + + schedules: ScheduleRepositoryPort + + def __enter__(self) -> Self: ... + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: ... + + def commit(self) -> None: ... + + +ScheduleUnitOfWorkFactory = Callable[[], ScheduleUnitOfWork] + + +__all__ = [ + "ScheduleRepositoryPort", + "ScheduleRevisionConflictError", + "ScheduleUnitOfWork", + "ScheduleUnitOfWorkFactory", +] diff --git a/backend/src/timeflow/business/calendar/recurrence.py b/backend/src/timeflow/business/calendar/recurrence.py new file mode 100644 index 0000000..78f5989 --- /dev/null +++ b/backend/src/timeflow/business/calendar/recurrence.py @@ -0,0 +1,83 @@ +"""RRULE validation and occurrence selection for schedule use cases.""" + +from datetime import UTC, datetime, time +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from dateutil.rrule import rrulebase, rrulestr + +from timeflow.business.calendar.contracts import ( + ScheduleOccurrenceOverrideSnapshot, + ScheduleSnapshot, +) + + +class InvalidRecurrenceRuleError(ValueError): + """A recurrence rule cannot be expanded from its schedule start.""" + + +def parse_recurrence_rule(rule: str, *, start_time: datetime) -> rrulebase: + """Parse one RFC 5545 RRULE using the schedule start as DTSTART.""" + if not rule.strip() or "\n" in rule or "\r" in rule: + raise InvalidRecurrenceRuleError("recurrence_rule must contain one RRULE") + try: + parsed = rrulestr(rule, dtstart=start_time) + first = parsed.after(start_time, inc=True) + except (TypeError, ValueError, OverflowError) as exc: + raise InvalidRecurrenceRuleError("recurrence_rule is not a valid RFC 5545 RRULE") from exc + if first is None: + raise InvalidRecurrenceRuleError("recurrence_rule has no occurrence at or after start_time") + return parsed + + +def first_active_occurrence_on_or_after_local_date( + schedule: ScheduleSnapshot, + *, + now: datetime, + overrides: tuple[ScheduleOccurrenceOverrideSnapshot, ...], +) -> datetime | None: + """Return the first non-overridden occurrence on/after today's local date.""" + if schedule.start_time is None or schedule.recurrence_rule is None: + return None + try: + timezone = ZoneInfo(schedule.timezone) + except ZoneInfoNotFoundError: + return None + local_date = now.astimezone(timezone).date() + boundary = datetime.combine(local_date, time.min, tzinfo=timezone) + rule = parse_recurrence_rule(schedule.recurrence_rule, start_time=schedule.start_time) + overridden = {override.occurrence_start for override in overrides} + occurrence = rule.after(boundary, inc=True) + while occurrence is not None and occurrence in overridden: + occurrence = rule.after(occurrence, inc=False) + return occurrence + + +def truncate_rule_before_occurrence( + schedule: ScheduleSnapshot, + occurrence: datetime, +) -> str | None: + """Return an RRULE ending at the prior occurrence, or None for the first one.""" + if schedule.start_time is None or schedule.recurrence_rule is None: + return None + rule = parse_recurrence_rule(schedule.recurrence_rule, start_time=schedule.start_time) + previous = rule.before(occurrence, inc=False) + if previous is None: + return None + + components = [ + component + for component in schedule.recurrence_rule.split(";") + if not component.upper().startswith(("UNTIL=", "COUNT=")) + ] + until = previous.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ") + truncated = ";".join((*components, f"UNTIL={until}")) + parse_recurrence_rule(truncated, start_time=schedule.start_time) + return truncated + + +__all__ = [ + "InvalidRecurrenceRuleError", + "first_active_occurrence_on_or_after_local_date", + "parse_recurrence_rule", + "truncate_rule_before_occurrence", +] diff --git a/backend/src/timeflow/business/calendar/service.py b/backend/src/timeflow/business/calendar/service.py index e111ca1..4338da5 100644 --- a/backend/src/timeflow/business/calendar/service.py +++ b/backend/src/timeflow/business/calendar/service.py @@ -1,23 +1,51 @@ -"""Agent-facing schedule application service skeleton.""" +"""Agent-facing schedule service boundary and application implementation.""" from abc import ABC, abstractmethod +from collections.abc import Callable +from dataclasses import replace +from datetime import UTC, datetime +from typing import NoReturn +from uuid import uuid4 +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from timeflow.business.calendar.contracts import ( CreateScheduleCommand, DeleteOnceScheduleCommand, DeleteRecurringScheduleCommand, FindSchedulesQuery, + OccurrenceOverrideAction, + RecurringDeleteScope, + ReminderType, + ScheduleBusinessError, + ScheduleErrorCode, + ScheduleKind, ScheduleMutationResult, + ScheduleOccurrenceOverrideSnapshot, ScheduleSearchResult, + ScheduleSnapshot, + ScheduleStatus, + ScheduleType, + ScheduleUpdatePatch, UpdateScheduleCommand, ) +from timeflow.business.calendar.ports import ( + ScheduleRepositoryPort, + ScheduleRevisionConflictError, + ScheduleUnitOfWorkFactory, +) +from timeflow.business.calendar.recurrence import ( + InvalidRecurrenceRuleError, + first_active_occurrence_on_or_after_local_date, + parse_recurrence_rule, + truncate_rule_before_occurrence, +) class ScheduleAgentService(ABC): """Five stable schedule operations exposed to the Agent. - This abstract class intentionally contains no persistence, validation, - recurrence expansion, or mutation logic yet. + Implementations own persistence, validation, recurrence expansion, and + mutation logic while callers depend only on this stable boundary. """ @abstractmethod @@ -32,7 +60,6 @@ def create_schedule( Raises: ScheduleBusinessError: If the confirmed command is invalid. """ - # TODO(person-2): validate the aggregate and persist it transactionally. raise NotImplementedError @abstractmethod @@ -47,7 +74,6 @@ def find_schedules( Raises: ScheduleBusinessError: If the query contains invalid criteria. """ - # TODO(person-2): implement account-scoped schedule matching. raise NotImplementedError @abstractmethod @@ -62,7 +88,6 @@ def update_schedule( Raises: ScheduleBusinessError: If the target, revision, or patch is invalid. """ - # TODO(person-2): validate the patch, revision, and final aggregate. raise NotImplementedError @abstractmethod @@ -77,7 +102,6 @@ def delete_once_schedule( Raises: ScheduleBusinessError: If the target or revision is invalid. """ - # TODO(person-2): validate the target and create a deleted cloud snapshot. raise NotImplementedError @abstractmethod @@ -92,9 +116,601 @@ def delete_recurring_schedule( Raises: ScheduleBusinessError: If the target, revision, or occurrence is invalid. """ - # TODO(person-2): resolve the current occurrence when the confirmed - # scope requires one, then apply command.scope transactionally. raise NotImplementedError -__all__ = ["ScheduleAgentService"] +class ScheduleApplicationService(ScheduleAgentService): + """Transactional implementation of the five stable Agent operations.""" + + def __init__( + self, + unit_of_work_factory: ScheduleUnitOfWorkFactory, + *, + clock: Callable[[], datetime] | None = None, + id_factory: Callable[[], str] | None = None, + ) -> None: + self._unit_of_work_factory = unit_of_work_factory + self._clock = clock or (lambda: datetime.now(UTC)) + self._id_factory = id_factory or (lambda: uuid4().hex) + + def create_schedule( + self, + *, + account_id: str, + command: CreateScheduleCommand, + ) -> ScheduleMutationResult: + """Validate and persist a new account-owned schedule.""" + _validate_account_id(account_id) + now = _aware_now(self._clock) + snapshot = ScheduleSnapshot( + id=_new_id(self._id_factory, field="id"), + account_id=account_id, + schedule_type=command.schedule_type, + schedule_kind=command.schedule_kind, + title=command.title, + is_all_day=command.is_all_day, + timezone=command.timezone, + status=ScheduleStatus.ACTIVE, + revision=1, + created_at=now, + updated_at=now, + start_time=command.start_time, + end_time=command.end_time, + recurrence_rule=command.recurrence_rule, + location_name=command.location_name, + latitude=command.latitude, + longitude=command.longitude, + reminder_type=command.reminder_type, + reminder_trigger_at=command.reminder_trigger_at, + reminder_offset_minutes=command.reminder_offset_minutes, + reminder_strength=command.reminder_strength, + ) + _validate_snapshot(snapshot) + + with self._unit_of_work_factory() as unit_of_work: + persisted = unit_of_work.schedules.add_schedule(snapshot) + unit_of_work.commit() + return ScheduleMutationResult(schedules=(persisted,)) + + def find_schedules( + self, + *, + account_id: str, + query: FindSchedulesQuery, + ) -> ScheduleSearchResult: + """Return account-scoped schedules matching every supplied criterion.""" + _validate_account_id(account_id) + _validate_query(query) + + with self._unit_of_work_factory() as unit_of_work: + if query.schedule_id is None: + candidates = unit_of_work.schedules.list_schedules( + account_id=account_id, + include_deleted=query.include_deleted, + ) + else: + match = unit_of_work.schedules.get_schedule( + account_id=account_id, + schedule_id=query.schedule_id, + include_deleted=query.include_deleted, + ) + candidates = () if match is None else (match,) + + title = None if query.title is None else query.title.casefold() + location = None if query.location_name is None else query.location_name.casefold() + matches = tuple( + schedule + for schedule in candidates + if (title is None or title in schedule.title.casefold()) + and ( + location is None + or ( + schedule.location_name is not None + and location in schedule.location_name.casefold() + ) + ) + and ( + query.starts_at_or_after is None + or ( + schedule.start_time is not None + and schedule.start_time >= query.starts_at_or_after + ) + ) + and ( + query.starts_before is None + or (schedule.start_time is not None and schedule.start_time < query.starts_before) + ) + ) + return ScheduleSearchResult(schedules=matches) + + def update_schedule( + self, + *, + account_id: str, + command: UpdateScheduleCommand, + ) -> ScheduleMutationResult: + """Apply only explicitly supplied mutable fields and increment revision.""" + _validate_account_id(account_id) + _validate_update_patch(command) + now = _aware_now(self._clock) + + with self._unit_of_work_factory() as unit_of_work: + current = _require_active_schedule( + unit_of_work.schedules, + account_id=account_id, + schedule_id=command.schedule_id, + ) + candidate = replace(current, **command.changes, updated_at=now) + _validate_snapshot(candidate) + persisted = _persist_update( + unit_of_work.schedules, + candidate, + expected_revision=command.expected_revision, + ) + unit_of_work.commit() + return ScheduleMutationResult(schedules=(persisted,)) + + def delete_once_schedule( + self, + *, + account_id: str, + command: DeleteOnceScheduleCommand, + ) -> ScheduleMutationResult: + """Soft-delete one ordinary schedule and return its final snapshot.""" + _validate_account_id(account_id) + now = _aware_now(self._clock) + with self._unit_of_work_factory() as unit_of_work: + current = _require_active_schedule( + unit_of_work.schedules, + account_id=account_id, + schedule_id=command.schedule_id, + ) + if current.schedule_kind is not ScheduleKind.ONCE: + _raise_business_error( + ScheduleErrorCode.INVALID_SCHEDULE_KIND, + "A recurring schedule must use delete_recurring_schedule.", + schedule_id=current.id, + field="schedule_id", + ) + persisted = _soft_delete( + unit_of_work.schedules, + current, + expected_revision=command.expected_revision, + now=now, + ) + unit_of_work.commit() + return ScheduleMutationResult(schedules=(persisted,)) + + def delete_recurring_schedule( + self, + *, + account_id: str, + command: DeleteRecurringScheduleCommand, + ) -> ScheduleMutationResult: + """Delete a recurring series according to the Wiki-defined scope.""" + _validate_account_id(account_id) + now = _aware_now(self._clock) + + with self._unit_of_work_factory() as unit_of_work: + current = _require_active_schedule( + unit_of_work.schedules, + account_id=account_id, + schedule_id=command.schedule_id, + ) + if current.schedule_kind is not ScheduleKind.RECURRING: + _raise_business_error( + ScheduleErrorCode.INVALID_SCHEDULE_KIND, + "A non-recurring schedule must use delete_once_schedule.", + schedule_id=current.id, + field="schedule_id", + ) + + if command.scope is RecurringDeleteScope.ENTIRE_SERIES: + persisted = _soft_delete( + unit_of_work.schedules, + current, + expected_revision=command.expected_revision, + now=now, + ) + result = ScheduleMutationResult(schedules=(persisted,)) + else: + result = self._delete_recurring_range( + unit_of_work.schedules, + account_id=account_id, + current=current, + command=command, + now=now, + ) + unit_of_work.commit() + return result + + def _delete_recurring_range( + self, + repository: ScheduleRepositoryPort, + *, + account_id: str, + current: ScheduleSnapshot, + command: DeleteRecurringScheduleCommand, + now: datetime, + ) -> ScheduleMutationResult: + overrides = repository.list_occurrence_overrides( + account_id=account_id, + schedule_id=current.id, + ) + try: + occurrence = first_active_occurrence_on_or_after_local_date( + current, + now=now, + overrides=overrides, + ) + except InvalidRecurrenceRuleError: + _raise_business_error( + ScheduleErrorCode.VALIDATION_FAILED, + "The persisted recurrence_rule cannot be expanded.", + schedule_id=current.id, + field="recurrence_rule", + ) + if occurrence is None: + _raise_business_error( + ScheduleErrorCode.OCCURRENCE_NOT_FOUND, + "No recurring occurrence exists on or after the current local date.", + schedule_id=current.id, + field="scope", + ) + + if command.scope is RecurringDeleteScope.THIS_OCCURRENCE: + updated_schedule = _persist_update( + repository, + replace(current, updated_at=now), + expected_revision=command.expected_revision, + ) + override = ScheduleOccurrenceOverrideSnapshot( + id=_new_id(self._id_factory, field="occurrence_override_id"), + schedule_id=current.id, + occurrence_start=occurrence, + action=OccurrenceOverrideAction.CANCEL, + created_at=now, + updated_at=now, + ) + persisted_override = repository.add_occurrence_override( + account_id=account_id, + snapshot=override, + ) + if persisted_override is None: + _raise_not_found(current.id) + return ScheduleMutationResult( + schedules=(updated_schedule,), + occurrence_overrides=(persisted_override,), + ) + + try: + truncated_rule = truncate_rule_before_occurrence(current, occurrence) + except InvalidRecurrenceRuleError: + _raise_business_error( + ScheduleErrorCode.VALIDATION_FAILED, + "The persisted recurrence_rule cannot be truncated.", + schedule_id=current.id, + field="recurrence_rule", + ) + if truncated_rule is None: + persisted = _soft_delete( + repository, + current, + expected_revision=command.expected_revision, + now=now, + ) + else: + candidate = replace(current, recurrence_rule=truncated_rule, updated_at=now) + _validate_snapshot(candidate) + persisted = _persist_update( + repository, + candidate, + expected_revision=command.expected_revision, + ) + return ScheduleMutationResult(schedules=(persisted,)) + + +def _persist_update( + repository: ScheduleRepositoryPort, + snapshot: ScheduleSnapshot, + *, + expected_revision: int, +) -> ScheduleSnapshot: + try: + persisted = repository.update_schedule( + snapshot=snapshot, + expected_revision=expected_revision, + ) + except ScheduleRevisionConflictError as exc: + _raise_business_error( + ScheduleErrorCode.REVISION_CONFLICT, + "The schedule changed after it was read; query it again before retrying.", + schedule_id=exc.schedule_id, + field="expected_revision", + ) + if persisted is None: + _raise_not_found(snapshot.id) + return persisted + + +def _soft_delete( + repository: ScheduleRepositoryPort, + current: ScheduleSnapshot, + *, + expected_revision: int, + now: datetime, +) -> ScheduleSnapshot: + candidate = replace( + current, + status=ScheduleStatus.DELETED, + updated_at=now, + deleted_at=now, + ) + return _persist_update(repository, candidate, expected_revision=expected_revision) + + +def _require_active_schedule( + repository: ScheduleRepositoryPort, + *, + account_id: str, + schedule_id: str, +) -> ScheduleSnapshot: + if not schedule_id.strip(): + _raise_business_error( + ScheduleErrorCode.VALIDATION_FAILED, + "schedule_id must be non-empty.", + field="schedule_id", + ) + schedule = repository.get_schedule(account_id=account_id, schedule_id=schedule_id) + if schedule is None: + _raise_not_found(schedule_id) + return schedule + + +def _validate_account_id(account_id: str) -> None: + if not account_id.strip(): + _raise_business_error( + ScheduleErrorCode.VALIDATION_FAILED, + "account_id must be non-empty.", + field="account_id", + ) + + +def _validate_query(query: FindSchedulesQuery) -> None: + for field, value in (("schedule_id", query.schedule_id), ("title", query.title)): + if value is not None and not value.strip(): + _raise_business_error( + ScheduleErrorCode.VALIDATION_FAILED, + f"{field} must be non-empty when supplied.", + field=field, + ) + if query.location_name is not None and not query.location_name.strip(): + _raise_business_error( + ScheduleErrorCode.VALIDATION_FAILED, + "location_name must be non-empty when supplied.", + field="location_name", + ) + _validate_optional_datetime(query.starts_at_or_after, "starts_at_or_after") + _validate_optional_datetime(query.starts_before, "starts_before") + if ( + query.starts_at_or_after is not None + and query.starts_before is not None + and query.starts_at_or_after >= query.starts_before + ): + _raise_business_error( + ScheduleErrorCode.VALIDATION_FAILED, + "starts_at_or_after must be earlier than starts_before.", + field="starts_before", + ) + + +def _validate_update_patch(command: UpdateScheduleCommand) -> None: + if not command.changes: + _raise_business_error( + ScheduleErrorCode.INVALID_UPDATE_PATCH, + "changes must contain at least one editable field.", + schedule_id=command.schedule_id, + field="changes", + ) + allowed = ScheduleUpdatePatch.__optional_keys__ + unknown = set(command.changes).difference(allowed) + if unknown: + _raise_business_error( + ScheduleErrorCode.INVALID_UPDATE_PATCH, + f"changes contains protected or unknown fields: {', '.join(sorted(unknown))}.", + schedule_id=command.schedule_id, + field="changes", + ) + + +def _validate_snapshot(snapshot: ScheduleSnapshot) -> None: + if not snapshot.title.strip() or len(snapshot.title) > 255: + _validation_error("title must contain 1 to 255 characters", field="title") + if not snapshot.timezone.strip() or len(snapshot.timezone) > 64: + _invalid_timezone(snapshot.timezone) + try: + ZoneInfo(snapshot.timezone) + except ZoneInfoNotFoundError: + _invalid_timezone(snapshot.timezone) + + for field, value in ( + ("created_at", snapshot.created_at), + ("updated_at", snapshot.updated_at), + ("start_time", snapshot.start_time), + ("end_time", snapshot.end_time), + ("reminder_trigger_at", snapshot.reminder_trigger_at), + ("deleted_at", snapshot.deleted_at), + ): + _validate_optional_datetime(value, field) + + if snapshot.revision < 1: + _validation_error("revision must be positive", field="revision") + if snapshot.location_name is not None and len(snapshot.location_name) > 255: + _validation_error("location_name must not exceed 255 characters", field="location_name") + if (snapshot.latitude is None) != (snapshot.longitude is None): + _validation_error("latitude and longitude must be supplied together", field="latitude") + if snapshot.latitude is not None and not -90 <= snapshot.latitude <= 90: + _validation_error("latitude must be between -90 and 90", field="latitude") + if snapshot.longitude is not None and not -180 <= snapshot.longitude <= 180: + _validation_error("longitude must be between -180 and 180", field="longitude") + + if snapshot.schedule_type is ScheduleType.TIME: + if snapshot.start_time is None: + _validation_error("time schedules require start_time", field="start_time") + elif snapshot.schedule_type is ScheduleType.LOCATION: + if snapshot.is_all_day: + _validation_error("location schedules cannot be all-day", field="is_all_day") + if snapshot.latitude is None: + _validation_error("location schedules require coordinates", field="latitude") + + if snapshot.end_time is not None: + if snapshot.start_time is None or snapshot.end_time <= snapshot.start_time: + _validation_error("end_time must be later than start_time", field="end_time") + if snapshot.is_all_day and ( + snapshot.schedule_type is not ScheduleType.TIME or snapshot.end_time is None + ): + _validation_error("all-day schedules require an exclusive end_time", field="end_time") + + if snapshot.schedule_kind is ScheduleKind.ONCE: + if snapshot.recurrence_rule is not None: + _validation_error( + "one-time schedules cannot have recurrence_rule", field="recurrence_rule" + ) + else: + if snapshot.schedule_type is not ScheduleType.TIME: + _validation_error("recurring schedules must be time schedules", field="schedule_type") + if snapshot.recurrence_rule is None or snapshot.start_time is None: + _validation_error( + "recurring schedules require recurrence_rule", field="recurrence_rule" + ) + if len(snapshot.recurrence_rule) > 512: + _validation_error( + "recurrence_rule must not exceed 512 characters", field="recurrence_rule" + ) + try: + parse_recurrence_rule(snapshot.recurrence_rule, start_time=snapshot.start_time) + except InvalidRecurrenceRuleError: + _validation_error( + "recurrence_rule is not a valid RFC 5545 RRULE", + field="recurrence_rule", + ) + + _validate_reminder(snapshot) + if snapshot.status is ScheduleStatus.ACTIVE and snapshot.deleted_at is not None: + _validation_error("active schedules cannot have deleted_at", field="deleted_at") + if snapshot.status is ScheduleStatus.DELETED and snapshot.deleted_at is None: + _validation_error("deleted schedules require deleted_at", field="deleted_at") + + +def _validate_reminder(snapshot: ScheduleSnapshot) -> None: + if snapshot.reminder_type is None: + if any( + value is not None + for value in ( + snapshot.reminder_trigger_at, + snapshot.reminder_offset_minutes, + snapshot.reminder_strength, + snapshot.reminder_disposition_state, + ) + ): + _validation_error( + "reminder fields must be empty when reminder_type is empty", + field="reminder_type", + ) + return + if snapshot.reminder_strength is None: + _validation_error("reminder_strength is required", field="reminder_strength") + if snapshot.reminder_offset_minutes is not None and snapshot.reminder_offset_minutes < 0: + _validation_error( + "reminder_offset_minutes cannot be negative", + field="reminder_offset_minutes", + ) + if snapshot.reminder_type is ReminderType.AT_TIME: + if ( + snapshot.schedule_type is not ScheduleType.TIME + or snapshot.start_time is None + or snapshot.reminder_trigger_at is None + or snapshot.reminder_offset_minutes is not None + ): + _validation_error( + "at_time requires a time schedule and reminder_trigger_at only", + field="reminder_type", + ) + elif snapshot.reminder_type is ReminderType.BEFORE_START: + if ( + snapshot.schedule_type is not ScheduleType.TIME + or snapshot.start_time is None + or snapshot.reminder_trigger_at is not None + or snapshot.reminder_offset_minutes is None + ): + _validation_error( + "before_start requires reminder_offset_minutes on a time schedule", + field="reminder_type", + ) + elif ( + snapshot.latitude is None + or snapshot.reminder_trigger_at is not None + or snapshot.reminder_offset_minutes is not None + ): + _validation_error( + "location reminders require coordinates and no time trigger fields", + field="reminder_type", + ) + + +def _validate_optional_datetime(value: datetime | None, field: str) -> None: + if value is not None and (value.tzinfo is None or value.utcoffset() is None): + _validation_error(f"{field} must include a UTC offset", field=field) + + +def _aware_now(clock: Callable[[], datetime]) -> datetime: + now = clock() + if now.tzinfo is None or now.utcoffset() is None: + _validation_error("the application clock must return an aware datetime", field="clock") + return now + + +def _new_id(id_factory: Callable[[], str], *, field: str) -> str: + value = id_factory() + if not value or len(value) > 64: + _validation_error(f"{field} must contain 1 to 64 characters", field=field) + return value + + +def _invalid_timezone(timezone: str) -> NoReturn: + _raise_business_error( + ScheduleErrorCode.INVALID_TIMEZONE, + f"{timezone!r} is not a valid IANA timezone.", + field="timezone", + ) + + +def _validation_error(message: str, *, field: str) -> NoReturn: + _raise_business_error(ScheduleErrorCode.VALIDATION_FAILED, message, field=field) + + +def _raise_not_found(schedule_id: str) -> NoReturn: + _raise_business_error( + ScheduleErrorCode.SCHEDULE_NOT_FOUND, + "The schedule does not exist for this account.", + schedule_id=schedule_id, + field="schedule_id", + ) + + +def _raise_business_error( + code: ScheduleErrorCode, + message: str, + *, + schedule_id: str | None = None, + field: str | None = None, +) -> NoReturn: + raise ScheduleBusinessError( + code=code, + message=message, + schedule_id=schedule_id, + field=field, + ) + + +__all__ = ["ScheduleAgentService", "ScheduleApplicationService"] diff --git a/backend/src/timeflow/data/repositories/schedule.py b/backend/src/timeflow/data/repositories/schedule.py index 76961a4..fdd047c 100644 --- a/backend/src/timeflow/data/repositories/schedule.py +++ b/backend/src/timeflow/data/repositories/schedule.py @@ -17,30 +17,10 @@ ScheduleStatus, ScheduleType, ) +from timeflow.business.calendar.ports import ScheduleRevisionConflictError 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. diff --git a/backend/src/timeflow/data/schedule_unit_of_work.py b/backend/src/timeflow/data/schedule_unit_of_work.py new file mode 100644 index 0000000..90e0cad --- /dev/null +++ b/backend/src/timeflow/data/schedule_unit_of_work.py @@ -0,0 +1,45 @@ +"""SQLAlchemy transaction adapter for schedule application use cases.""" + +from types import TracebackType + +from sqlalchemy.orm import Session, sessionmaker + +from timeflow.data.repositories.schedule import ScheduleRepository + + +class SqlAlchemyScheduleUnitOfWork: + """Own one SQLAlchemy Session and expose its account-scoped repository.""" + + def __init__(self, session_factory: sessionmaker[Session]) -> None: + self._session_factory = session_factory + self._session: Session | None = None + self.schedules: ScheduleRepository + + def __enter__(self) -> "SqlAlchemyScheduleUnitOfWork": + self._session = self._session_factory() + self.schedules = ScheduleRepository(self._session) + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + session = self._require_session() + if exc_type is not None: + session.rollback() + session.close() + self._session = None + + def commit(self) -> None: + """Commit all repository writes performed by the current use case.""" + self._require_session().commit() + + def _require_session(self) -> Session: + if self._session is None: + raise RuntimeError("The schedule unit of work is not active") + return self._session + + +__all__ = ["SqlAlchemyScheduleUnitOfWork"] diff --git a/backend/tests/test_postgres_schedule_repository.py b/backend/tests/test_postgres_schedule_repository.py index e609cee..7cc1edb 100644 --- a/backend/tests/test_postgres_schedule_repository.py +++ b/backend/tests/test_postgres_schedule_repository.py @@ -8,18 +8,28 @@ import sqlalchemy as sa from sqlalchemy import Engine from sqlalchemy.engine import Connection -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from timeflow.business.calendar import ( + CreateScheduleCommand, + DeleteOnceScheduleCommand, + DeleteRecurringScheduleCommand, + FindSchedulesQuery, OccurrenceOverrideAction, + RecurringDeleteScope, + ScheduleApplicationService, + ScheduleBusinessError, + ScheduleErrorCode, ScheduleKind, ScheduleOccurrenceOverrideSnapshot, ScheduleSnapshot, ScheduleStatus, ScheduleType, + UpdateScheduleCommand, ) from timeflow.data.models import Account, ScheduleOccurrenceOverride from timeflow.data.repositories import ScheduleRepository, ScheduleRevisionConflictError +from timeflow.data.schedule_unit_of_work import SqlAlchemyScheduleUnitOfWork @pytest.fixture @@ -213,3 +223,112 @@ def test_postgres_repository_respects_caller_transaction_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 + + +def _application_service( + postgres_connection: Connection, + *, + account_id: str, + ids: Iterator[str], + now: datetime, +) -> ScheduleApplicationService: + factory = sessionmaker( + bind=postgres_connection, + join_transaction_mode="create_savepoint", + expire_on_commit=False, + ) + with factory() as session: + _seed_account(session, account_id) + session.commit() + return ScheduleApplicationService( + lambda: SqlAlchemyScheduleUnitOfWork(factory), + clock=lambda: now, + id_factory=lambda: next(ids), + ) + + +def test_postgres_application_service_commits_create_update_and_soft_delete( + postgres_connection: Connection, +) -> None: + """The application service returns each PostgreSQL-committed final snapshot.""" + account_id = "account-service-crud" + now = datetime(2026, 8, 11, 1, tzinfo=UTC) + service = _application_service( + postgres_connection, + account_id=account_id, + ids=iter(("schedule-service-crud",)), + now=now, + ) + command = CreateScheduleCommand( + schedule_type=ScheduleType.TIME, + schedule_kind=ScheduleKind.ONCE, + title="Project sync", + timezone="Asia/Shanghai", + start_time=datetime(2026, 8, 12, 7, tzinfo=UTC), + ) + + created = service.create_schedule(account_id=account_id, command=command).schedules[0] + updated = service.update_schedule( + account_id=account_id, + command=UpdateScheduleCommand(created.id, 1, {"title": "Updated sync"}), + ).schedules[0] + deleted = service.delete_once_schedule( + account_id=account_id, + command=DeleteOnceScheduleCommand(updated.id, 2), + ).schedules[0] + + assert created.revision == 1 + assert updated.revision == 2 + assert updated.title == "Updated sync" + assert deleted.revision == 3 + assert deleted.status is ScheduleStatus.DELETED + found = service.find_schedules( + account_id=account_id, + query=FindSchedulesQuery(schedule_id=deleted.id, include_deleted=True), + ) + assert found.schedules == (deleted,) + + with pytest.raises(ScheduleBusinessError) as raised: + service.update_schedule( + account_id=account_id, + command=UpdateScheduleCommand(deleted.id, 2, {"title": "Stale"}), + ) + assert raised.value.code is ScheduleErrorCode.SCHEDULE_NOT_FOUND + + +def test_postgres_application_service_atomically_cancels_current_occurrence( + postgres_connection: Connection, +) -> None: + """Occurrence cancellation and parent revision commit in one PostgreSQL transaction.""" + account_id = "account-service-recurring" + now = datetime(2026, 8, 11, 1, tzinfo=UTC) + service = _application_service( + postgres_connection, + account_id=account_id, + ids=iter(("schedule-service-recurring", "override-service-recurring")), + now=now, + ) + created = service.create_schedule( + account_id=account_id, + command=CreateScheduleCommand( + schedule_type=ScheduleType.TIME, + schedule_kind=ScheduleKind.RECURRING, + title="Weekly sync", + timezone="Asia/Shanghai", + start_time=datetime(2026, 8, 3, 2, tzinfo=UTC), + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ), + ).schedules[0] + + result = service.delete_recurring_schedule( + account_id=account_id, + command=DeleteRecurringScheduleCommand( + created.id, + 1, + RecurringDeleteScope.THIS_OCCURRENCE, + ), + ) + + assert result.schedules[0].revision == 2 + assert result.occurrence_overrides[0].action is OccurrenceOverrideAction.CANCEL + assert result.occurrence_overrides[0].occurrence_start == datetime(2026, 8, 17, 2, tzinfo=UTC) diff --git a/backend/tests/test_schedule_application_service.py b/backend/tests/test_schedule_application_service.py new file mode 100644 index 0000000..da86fbf --- /dev/null +++ b/backend/tests/test_schedule_application_service.py @@ -0,0 +1,595 @@ +"""Business tests for the five stable Agent schedule operations.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, replace +from datetime import UTC, datetime +from itertools import count +from types import TracebackType + +import pytest + +from timeflow.business.calendar import ( + CreateScheduleCommand, + DeleteOnceScheduleCommand, + DeleteRecurringScheduleCommand, + FindSchedulesQuery, + OccurrenceOverrideAction, + RecurringDeleteScope, + ReminderStrength, + ReminderType, + ScheduleApplicationService, + ScheduleBusinessError, + ScheduleErrorCode, + ScheduleKind, + ScheduleOccurrenceOverrideSnapshot, + ScheduleSnapshot, + ScheduleStatus, + ScheduleType, + UpdateScheduleCommand, +) +from timeflow.business.calendar.ports import ScheduleRevisionConflictError + +NOW = datetime(2026, 8, 11, 1, tzinfo=UTC) + + +@dataclass +class _Store: + schedules: dict[str, ScheduleSnapshot] + overrides: dict[str, ScheduleOccurrenceOverrideSnapshot] + + +class _Repository: + def __init__(self, store: _Store) -> None: + self._store = store + + def add_schedule(self, snapshot: ScheduleSnapshot) -> ScheduleSnapshot: + if snapshot.id in self._store.schedules: + raise RuntimeError("duplicate test id") + self._store.schedules[snapshot.id] = snapshot + return snapshot + + def get_schedule( + self, + *, + account_id: str, + schedule_id: str, + include_deleted: bool = False, + ) -> ScheduleSnapshot | None: + snapshot = self._store.schedules.get(schedule_id) + if snapshot is None or snapshot.account_id != account_id: + return None + if not include_deleted and snapshot.status is ScheduleStatus.DELETED: + return None + return snapshot + + def list_schedules( + self, + *, + account_id: str, + include_deleted: bool = False, + ) -> tuple[ScheduleSnapshot, ...]: + return tuple( + snapshot + for snapshot in sorted( + self._store.schedules.values(), + key=lambda item: (item.start_time or item.created_at, item.id), + ) + if snapshot.account_id == account_id + and (include_deleted or snapshot.status is ScheduleStatus.ACTIVE) + ) + + def update_schedule( + self, + *, + snapshot: ScheduleSnapshot, + expected_revision: int, + ) -> ScheduleSnapshot | None: + current = self._store.schedules.get(snapshot.id) + if current is None or current.account_id != snapshot.account_id: + return None + if current.revision != expected_revision: + raise ScheduleRevisionConflictError( + schedule_id=current.id, + expected_revision=expected_revision, + actual_revision=current.revision, + ) + persisted = replace( + snapshot, + revision=current.revision + 1, + created_at=current.created_at, + ) + self._store.schedules[persisted.id] = persisted + return persisted + + def add_occurrence_override( + self, + *, + account_id: str, + snapshot: ScheduleOccurrenceOverrideSnapshot, + ) -> ScheduleOccurrenceOverrideSnapshot | None: + parent = self._store.schedules.get(snapshot.schedule_id) + if parent is None or parent.account_id != account_id: + return None + duplicate = any( + item.schedule_id == snapshot.schedule_id + and item.occurrence_start == snapshot.occurrence_start + for item in self._store.overrides.values() + ) + if duplicate: + raise RuntimeError("duplicate test occurrence") + self._store.overrides[snapshot.id] = snapshot + return snapshot + + 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: + for override_id, current in self._store.overrides.items(): + parent = self._store.schedules.get(current.schedule_id) + if ( + current.schedule_id == schedule_id + and current.occurrence_start == occurrence_start + and parent is not None + and parent.account_id == account_id + ): + updated = replace( + current, + action=action, + replacement_schedule_id=replacement_schedule_id, + updated_at=updated_at, + ) + self._store.overrides[override_id] = updated + return updated + return None + + def list_occurrence_overrides( + self, + *, + account_id: str, + schedule_id: str | None = None, + ) -> tuple[ScheduleOccurrenceOverrideSnapshot, ...]: + return tuple( + override + for override in sorted( + self._store.overrides.values(), + key=lambda item: (item.occurrence_start, item.id), + ) + if (schedule_id is None or override.schedule_id == schedule_id) + and self._store.schedules[override.schedule_id].account_id == account_id + ) + + +class _UnitOfWork: + def __init__(self, committed: _Store) -> None: + self._committed = committed + self._working = _Store( + schedules=dict(committed.schedules), + overrides=dict(committed.overrides), + ) + self.schedules = _Repository(self._working) + + def __enter__(self) -> _UnitOfWork: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + return None + + def commit(self) -> None: + self._committed.schedules = dict(self._working.schedules) + self._committed.overrides = dict(self._working.overrides) + + +def _service( + *, + now: datetime = NOW, +) -> tuple[ScheduleApplicationService, _Store]: + store = _Store({}, {}) + sequence = count(1) + service = ScheduleApplicationService( + lambda: _UnitOfWork(store), + clock=lambda: now, + id_factory=lambda: f"generated-{next(sequence)}", + ) + return service, store + + +def _time_command( + *, + title: str = "项目同步", + start_time: datetime = datetime(2026, 8, 12, 7, tzinfo=UTC), + schedule_kind: ScheduleKind = ScheduleKind.ONCE, + recurrence_rule: str | None = None, +) -> CreateScheduleCommand: + return CreateScheduleCommand( + schedule_type=ScheduleType.TIME, + schedule_kind=schedule_kind, + title=title, + timezone="Asia/Shanghai", + start_time=start_time, + recurrence_rule=recurrence_rule, + ) + + +def _assert_error( + expected: ScheduleErrorCode, + operation: Callable[[], object], +) -> ScheduleBusinessError: + with pytest.raises(ScheduleBusinessError) as raised: + operation() + assert raised.value.code is expected + return raised.value + + +def test_create_schedule_returns_the_committed_cloud_snapshot() -> None: + service, store = _service() + + result = service.create_schedule(account_id="account-a", command=_time_command()) + + snapshot = result.schedules[0] + assert snapshot.id == "generated-1" + assert snapshot.account_id == "account-a" + assert snapshot.status is ScheduleStatus.ACTIVE + assert snapshot.revision == 1 + assert snapshot.created_at == NOW + assert snapshot.updated_at == NOW + assert store.schedules[snapshot.id] == snapshot + + +@pytest.mark.parametrize( + ("command", "code", "field"), + [ + ( + replace(_time_command(), timezone="Not/A-Timezone"), + ScheduleErrorCode.INVALID_TIMEZONE, + "timezone", + ), + ( + replace(_time_command(), start_time=None), + ScheduleErrorCode.VALIDATION_FAILED, + "start_time", + ), + ( + CreateScheduleCommand( + schedule_type=ScheduleType.LOCATION, + schedule_kind=ScheduleKind.ONCE, + title="回到停车位置", + timezone="Asia/Shanghai", + ), + ScheduleErrorCode.VALIDATION_FAILED, + "latitude", + ), + ( + _time_command( + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="not-an-rrule", + ), + ScheduleErrorCode.VALIDATION_FAILED, + "recurrence_rule", + ), + ( + replace( + _time_command(), + reminder_type=ReminderType.BEFORE_START, + reminder_offset_minutes=15, + ), + ScheduleErrorCode.VALIDATION_FAILED, + "reminder_strength", + ), + ], +) +def test_create_schedule_rejects_invalid_aggregates( + command: CreateScheduleCommand, + code: ScheduleErrorCode, + field: str, +) -> None: + service, store = _service() + + error = _assert_error( + code, + lambda: service.create_schedule(account_id="account-a", command=command), + ) + + assert error.field == field + assert store.schedules == {} + + +def test_create_schedule_accepts_location_recurring_and_reminder_shapes() -> None: + service, _ = _service() + location = CreateScheduleCommand( + schedule_type=ScheduleType.LOCATION, + schedule_kind=ScheduleKind.ONCE, + title="到公司", + timezone="Asia/Shanghai", + location_name="办公室", + latitude=31.2304, + longitude=121.4737, + reminder_type=ReminderType.ARRIVE_LOCATION, + reminder_strength=ReminderStrength.MEDIUM, + ) + recurring = replace( + _time_command(), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=WE", + reminder_type=ReminderType.BEFORE_START, + reminder_offset_minutes=15, + reminder_strength=ReminderStrength.HIGH, + ) + + location_result = service.create_schedule(account_id="account-a", command=location) + recurring_result = service.create_schedule(account_id="account-a", command=recurring) + + assert location_result.schedules[0].schedule_type is ScheduleType.LOCATION + assert recurring_result.schedules[0].schedule_kind is ScheduleKind.RECURRING + + +def test_find_schedules_filters_without_leaking_other_accounts_or_deleted_rows() -> None: + service, _ = _service() + first = service.create_schedule( + account_id="account-a", + command=_time_command(title="项目同步", start_time=datetime(2026, 8, 12, 7, tzinfo=UTC)), + ).schedules[0] + second = service.create_schedule( + account_id="account-a", + command=replace( + _time_command( + title="项目复盘", + start_time=datetime(2026, 8, 14, 7, tzinfo=UTC), + ), + location_name="203 会议室", + ), + ).schedules[0] + service.create_schedule(account_id="account-b", command=_time_command(title="项目秘密")) + service.delete_once_schedule( + account_id="account-a", + command=DeleteOnceScheduleCommand(first.id, first.revision), + ) + + matches = service.find_schedules( + account_id="account-a", + query=FindSchedulesQuery( + title="项目", + location_name="203", + starts_at_or_after=datetime(2026, 8, 13, tzinfo=UTC), + starts_before=datetime(2026, 8, 15, tzinfo=UTC), + ), + ) + with_deleted = service.find_schedules( + account_id="account-a", + query=FindSchedulesQuery(schedule_id=first.id, include_deleted=True), + ) + + assert matches.schedules == (second,) + assert with_deleted.schedules[0].status is ScheduleStatus.DELETED + + +def test_update_schedule_applies_patch_and_translates_revision_conflict() -> None: + service, store = _service(now=NOW) + created = service.create_schedule(account_id="account-a", command=_time_command()).schedules[0] + later = datetime(2026, 8, 11, 2, tzinfo=UTC) + service._clock = lambda: later + + updated = service.update_schedule( + account_id="account-a", + command=UpdateScheduleCommand(created.id, 1, {"title": "新标题"}), + ).schedules[0] + conflict = _assert_error( + ScheduleErrorCode.REVISION_CONFLICT, + lambda: service.update_schedule( + account_id="account-a", + command=UpdateScheduleCommand(created.id, 1, {"title": "过期写入"}), + ), + ) + + assert updated.title == "新标题" + assert updated.start_time == created.start_time + assert updated.revision == 2 + assert updated.updated_at == later + assert conflict.field == "expected_revision" + assert store.schedules[created.id] == updated + + +def test_update_rejects_empty_or_protected_patch_without_writing() -> None: + service, store = _service() + created = service.create_schedule(account_id="account-a", command=_time_command()).schedules[0] + + _assert_error( + ScheduleErrorCode.INVALID_UPDATE_PATCH, + lambda: service.update_schedule( + account_id="account-a", + command=UpdateScheduleCommand(created.id, 1, {}), + ), + ) + _assert_error( + ScheduleErrorCode.INVALID_UPDATE_PATCH, + lambda: service.update_schedule( + account_id="account-a", + command=UpdateScheduleCommand( + created.id, + 1, + {"revision": 99}, # type: ignore[typeddict-unknown-key] + ), + ), + ) + + assert store.schedules[created.id] == created + + +def test_delete_once_is_soft_account_scoped_and_kind_checked() -> None: + service, store = _service() + ordinary = service.create_schedule(account_id="account-a", command=_time_command()).schedules[0] + recurring = service.create_schedule( + account_id="account-a", + command=_time_command( + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=DAILY", + ), + ).schedules[0] + + deleted = service.delete_once_schedule( + account_id="account-a", + command=DeleteOnceScheduleCommand(ordinary.id, ordinary.revision), + ).schedules[0] + _assert_error( + ScheduleErrorCode.SCHEDULE_NOT_FOUND, + lambda: service.delete_once_schedule( + account_id="account-b", + command=DeleteOnceScheduleCommand(recurring.id, recurring.revision), + ), + ) + _assert_error( + ScheduleErrorCode.INVALID_SCHEDULE_KIND, + lambda: service.delete_once_schedule( + account_id="account-a", + command=DeleteOnceScheduleCommand(recurring.id, recurring.revision), + ), + ) + + assert deleted.status is ScheduleStatus.DELETED + assert deleted.deleted_at == NOW + assert deleted.revision == 2 + assert store.schedules[ordinary.id] == deleted + + +def test_delete_this_occurrence_uses_current_schedule_timezone_and_skips_overrides() -> None: + # It is still August 10 in UTC, but already August 11 in Asia/Shanghai. + # The August 10 occurrence must therefore be treated as yesterday. + service, store = _service(now=datetime(2026, 8, 10, 17, tzinfo=UTC)) + recurring = service.create_schedule( + account_id="account-a", + command=_time_command( + start_time=datetime(2026, 8, 3, 2, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ), + ).schedules[0] + + first = service.delete_recurring_schedule( + account_id="account-a", + command=DeleteRecurringScheduleCommand( + recurring.id, + 1, + RecurringDeleteScope.THIS_OCCURRENCE, + ), + ) + second = service.delete_recurring_schedule( + account_id="account-a", + command=DeleteRecurringScheduleCommand( + recurring.id, + 2, + RecurringDeleteScope.THIS_OCCURRENCE, + ), + ) + + assert first.schedules[0].revision == 2 + assert first.occurrence_overrides[0].action is OccurrenceOverrideAction.CANCEL + assert first.occurrence_overrides[0].occurrence_start == datetime(2026, 8, 17, 2, tzinfo=UTC) + assert second.schedules[0].revision == 3 + assert second.occurrence_overrides[0].occurrence_start == datetime(2026, 8, 24, 2, tzinfo=UTC) + assert len(store.overrides) == 2 + + +def test_delete_this_and_future_truncates_after_last_retained_occurrence() -> None: + service, _ = _service(now=NOW) + recurring = service.create_schedule( + account_id="account-a", + command=_time_command( + start_time=datetime(2026, 8, 3, 2, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO;COUNT=20", + ), + ).schedules[0] + + result = service.delete_recurring_schedule( + account_id="account-a", + command=DeleteRecurringScheduleCommand( + recurring.id, + 1, + RecurringDeleteScope.THIS_AND_FUTURE, + ), + ) + + updated = result.schedules[0] + assert updated.status is ScheduleStatus.ACTIVE + assert updated.revision == 2 + assert updated.recurrence_rule == "FREQ=WEEKLY;BYDAY=MO;UNTIL=20260810T020000Z" + + +def test_delete_this_and_future_on_first_occurrence_deletes_entire_series() -> None: + service, _ = _service(now=NOW) + recurring = service.create_schedule( + account_id="account-a", + command=_time_command( + start_time=datetime(2026, 8, 17, 2, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ), + ).schedules[0] + + result = service.delete_recurring_schedule( + account_id="account-a", + command=DeleteRecurringScheduleCommand( + recurring.id, + 1, + RecurringDeleteScope.THIS_AND_FUTURE, + ), + ) + + assert result.schedules[0].status is ScheduleStatus.DELETED + assert result.schedules[0].deleted_at == NOW + + +def test_delete_recurring_entire_series_and_missing_future_occurrence() -> None: + service, store = _service(now=NOW) + past = service.create_schedule( + account_id="account-a", + command=_time_command( + start_time=datetime(2026, 8, 3, 2, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=DAILY;COUNT=1", + ), + ).schedules[0] + future = service.create_schedule( + account_id="account-a", + command=_time_command( + start_time=datetime(2026, 8, 12, 2, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=DAILY", + ), + ).schedules[0] + + _assert_error( + ScheduleErrorCode.OCCURRENCE_NOT_FOUND, + lambda: service.delete_recurring_schedule( + account_id="account-a", + command=DeleteRecurringScheduleCommand( + past.id, + 1, + RecurringDeleteScope.THIS_OCCURRENCE, + ), + ), + ) + deleted = service.delete_recurring_schedule( + account_id="account-a", + command=DeleteRecurringScheduleCommand( + future.id, + 1, + RecurringDeleteScope.ENTIRE_SERIES, + ), + ).schedules[0] + + assert store.schedules[past.id].revision == 1 + assert deleted.status is ScheduleStatus.DELETED + assert deleted.revision == 2 diff --git a/backend/tests/test_schedule_service_skeleton.py b/backend/tests/test_schedule_service_skeleton.py index 8ed67f4..d0fc19e 100644 --- a/backend/tests/test_schedule_service_skeleton.py +++ b/backend/tests/test_schedule_service_skeleton.py @@ -2,16 +2,23 @@ import json from datetime import UTC, datetime +from inspect import Parameter, signature import pytest from timeflow.business.calendar import ( + CreateScheduleCommand, + DeleteOnceScheduleCommand, + DeleteRecurringScheduleCommand, + FindSchedulesQuery, RecurringDeleteScope, ReminderDispositionState, ScheduleAgentService, ScheduleBusinessError, ScheduleErrorCode, ScheduleKind, + ScheduleMutationResult, + ScheduleSearchResult, ScheduleSnapshot, ScheduleStatus, ScheduleType, @@ -28,7 +35,6 @@ def test_agent_schedule_service_exposes_exactly_five_business_operations() -> No for name, value in ScheduleAgentService.__dict__.items() if callable(value) and getattr(value, "__isabstractmethod__", False) } - assert operations == { "create_schedule", "find_schedules", @@ -38,6 +44,37 @@ def test_agent_schedule_service_exposes_exactly_five_business_operations() -> No } +def test_agent_schedule_service_keeps_all_five_public_signatures_stable() -> None: + """Agent integration code can keep calling the already-merged contract unchanged.""" + + expected = { + "create_schedule": ("command", CreateScheduleCommand, ScheduleMutationResult), + "find_schedules": ("query", FindSchedulesQuery, ScheduleSearchResult), + "update_schedule": ("command", UpdateScheduleCommand, ScheduleMutationResult), + "delete_once_schedule": ( + "command", + DeleteOnceScheduleCommand, + ScheduleMutationResult, + ), + "delete_recurring_schedule": ( + "command", + DeleteRecurringScheduleCommand, + ScheduleMutationResult, + ), + } + + for operation, (input_name, input_type, return_type) in expected.items(): + operation_signature = signature(getattr(ScheduleAgentService, operation)) + assert list(operation_signature.parameters) == ["self", "account_id", input_name] + account = operation_signature.parameters["account_id"] + command = operation_signature.parameters[input_name] + assert account.kind is Parameter.KEYWORD_ONLY + assert account.annotation is str + assert command.kind is Parameter.KEYWORD_ONLY + assert command.annotation is input_type + assert operation_signature.return_annotation is return_type + + def test_recurring_delete_scope_matches_the_three_wiki_wire_values() -> None: """Recurring deletion scopes serialize exactly as the v3.10 Wiki defines.""" diff --git a/backend/tests/test_schedule_unit_of_work.py b/backend/tests/test_schedule_unit_of_work.py new file mode 100644 index 0000000..5ca0e39 --- /dev/null +++ b/backend/tests/test_schedule_unit_of_work.py @@ -0,0 +1,110 @@ +"""Transaction-boundary tests for the SQLAlchemy schedule adapter.""" + +from datetime import UTC, datetime + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from timeflow.business.calendar import ( + ScheduleKind, + ScheduleSnapshot, + ScheduleStatus, + ScheduleType, +) +from timeflow.data.database import Base +from timeflow.data.models import Account +from timeflow.data.repositories import ScheduleRepository +from timeflow.data.schedule_unit_of_work import SqlAlchemyScheduleUnitOfWork + + +def _factory() -> sessionmaker[Session]: + engine = create_engine( + "sqlite+pysqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine, expire_on_commit=False) + now = datetime.now(UTC) + with factory() as session: + session.add( + Account( + id="account-a", + username="account-a@example.com", + password_hash="test-password-hash", + created_at=now, + updated_at=now, + ) + ) + session.commit() + return factory + + +def _schedule(schedule_id: str) -> ScheduleSnapshot: + now = datetime.now(UTC) + return ScheduleSnapshot( + id=schedule_id, + account_id="account-a", + schedule_type=ScheduleType.TIME, + schedule_kind=ScheduleKind.ONCE, + title="Schedule", + is_all_day=False, + timezone="Asia/Shanghai", + status=ScheduleStatus.ACTIVE, + revision=1, + created_at=now, + updated_at=now, + start_time=now, + ) + + +def test_unit_of_work_commits_repository_flushes() -> None: + factory = _factory() + + with SqlAlchemyScheduleUnitOfWork(factory) as unit_of_work: + unit_of_work.schedules.add_schedule(_schedule("committed")) + unit_of_work.commit() + + with factory() as session: + persisted = ScheduleRepository(session).get_schedule( + account_id="account-a", + schedule_id="committed", + ) + assert persisted is not None + + +def test_unit_of_work_rolls_back_on_error_or_missing_commit() -> None: + factory = _factory() + + with pytest.raises(RuntimeError, match="stop transaction"): + with SqlAlchemyScheduleUnitOfWork(factory) as unit_of_work: + unit_of_work.schedules.add_schedule(_schedule("rolled-back-error")) + raise RuntimeError("stop transaction") + with SqlAlchemyScheduleUnitOfWork(factory) as unit_of_work: + unit_of_work.schedules.add_schedule(_schedule("rolled-back-close")) + + with factory() as session: + repository = ScheduleRepository(session) + assert ( + repository.get_schedule( + account_id="account-a", + schedule_id="rolled-back-error", + ) + is None + ) + assert ( + repository.get_schedule( + account_id="account-a", + schedule_id="rolled-back-close", + ) + is None + ) + + +def test_unit_of_work_rejects_commit_outside_context() -> None: + unit_of_work = SqlAlchemyScheduleUnitOfWork(_factory()) + + with pytest.raises(RuntimeError, match="not active"): + unit_of_work.commit() diff --git a/backend/uv.lock b/backend/uv.lock index 8927353..4ffd705 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -527,6 +527,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -561,6 +573,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -613,6 +634,7 @@ dependencies = [ { name = "openai" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, + { name = "python-dateutil" }, { name = "python-dotenv" }, { name = "sqlalchemy" }, { name = "uvicorn" }, @@ -627,6 +649,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "types-python-dateutil" }, ] [package.metadata] @@ -636,6 +659,7 @@ requires-dist = [ { name = "openai", specifier = ">=2,<3" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, { name = "pydantic", specifier = ">=2.13,<3" }, + { name = "python-dateutil", specifier = ">=2.9,<3" }, { name = "python-dotenv", specifier = ">=1.1,<2" }, { name = "sqlalchemy", specifier = ">=2.0,<3" }, { name = "uvicorn", specifier = ">=0.51,<1" }, @@ -650,6 +674,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=1,<2" }, { name = "pytest-cov", specifier = ">=7,<8" }, { name = "ruff", specifier = ">=0.15,<1" }, + { name = "types-python-dateutil", specifier = ">=2.9,<3" }, ] [[package]] @@ -691,6 +716,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, ] +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20260807" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/4e/b3fa538f9cb38dfece0d6ccf6d3d0d925bdedb144fb9c8129dfc007cd003/types_python_dateutil-2.9.0.20260807.tar.gz", hash = "sha256:e0b8a90d464c8684c66b7b8e4556d9074afdddcc56ca45323f0987134f9e7034", size = 17618, upload-time = "2026-08-07T04:17:13.491Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/5e/3715867caea2f4cea56ccb04c851cde23ed063449c3b004c7a047f20dd48/types_python_dateutil-2.9.0.20260807-py3-none-any.whl", hash = "sha256:54aa3707350ed7a9cc0776fd2f6739679d6967d11b40150985e81edcb86df4db", size = 18486, upload-time = "2026-08-07T04:17:12.504Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" From edbae26617c3a90ec7a8a8aaa15890ff4ea1c551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=83=A7=E6=B4=97=E5=8F=91=E7=94=A8=E9=A3=98?= =?UTF-8?q?=E6=9F=94?= <2906266135@qq.com> Date: Tue, 11 Aug 2026 17:33:49 +0800 Subject: [PATCH 2/5] fix(schedule): harden recurrence timezone handling --- .../timeflow/business/calendar/recurrence.py | 63 ++++++-- .../src/timeflow/business/calendar/service.py | 41 ++++- .../test_schedule_application_service.py | 130 ++++++++++++++++ backend/tests/test_schedule_recurrence.py | 145 ++++++++++++++++++ 4 files changed, 358 insertions(+), 21 deletions(-) create mode 100644 backend/tests/test_schedule_recurrence.py diff --git a/backend/src/timeflow/business/calendar/recurrence.py b/backend/src/timeflow/business/calendar/recurrence.py index 78f5989..5a687d5 100644 --- a/backend/src/timeflow/business/calendar/recurrence.py +++ b/backend/src/timeflow/business/calendar/recurrence.py @@ -3,7 +3,7 @@ from datetime import UTC, datetime, time from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -from dateutil.rrule import rrulebase, rrulestr +from dateutil.rrule import rrule, rrulebase, rrulestr from timeflow.business.calendar.contracts import ( ScheduleOccurrenceOverrideSnapshot, @@ -15,12 +15,43 @@ class InvalidRecurrenceRuleError(ValueError): """A recurrence rule cannot be expanded from its schedule start.""" +class InvalidTimezoneKeyError(ValueError): + """A schedule timezone is not a valid IANA key.""" + + +def get_schedule_timezone(key: str) -> ZoneInfo: + """Return an IANA timezone while normalizing known invalid-key failures.""" + try: + return ZoneInfo(key) + except (ZoneInfoNotFoundError, ValueError) as exc: + raise InvalidTimezoneKeyError(key) from exc + + +def normalize_recurrence_rule(rule: str) -> str: + """Return the canonical body of exactly one RRULE. + + The shared contract stores an RRULE body such as ``FREQ=WEEKLY;BYDAY=MO``. + A single legacy-style ``RRULE:`` prefix is accepted and removed, while + recurrence sets, content lines, and embedded DTSTART/RDATE/EXDATE data are + rejected before dateutil parses the rule body. + """ + normalized = rule.strip() + if not normalized or "\n" in normalized or "\r" in normalized: + raise InvalidRecurrenceRuleError("recurrence_rule must contain one RRULE") + if normalized[:6].upper() == "RRULE:": + normalized = normalized[6:] + if not normalized or ":" in normalized: + raise InvalidRecurrenceRuleError("recurrence_rule must contain only one RRULE body") + return normalized + + def parse_recurrence_rule(rule: str, *, start_time: datetime) -> rrulebase: """Parse one RFC 5545 RRULE using the schedule start as DTSTART.""" - if not rule.strip() or "\n" in rule or "\r" in rule: - raise InvalidRecurrenceRuleError("recurrence_rule must contain one RRULE") + normalized = normalize_recurrence_rule(rule) try: - parsed = rrulestr(rule, dtstart=start_time) + parsed = rrulestr(normalized, dtstart=start_time) + if not isinstance(parsed, rrule): + raise InvalidRecurrenceRuleError("recurrence_rule must contain exactly one RRULE") first = parsed.after(start_time, inc=True) except (TypeError, ValueError, OverflowError) as exc: raise InvalidRecurrenceRuleError("recurrence_rule is not a valid RFC 5545 RRULE") from exc @@ -38,18 +69,16 @@ def first_active_occurrence_on_or_after_local_date( """Return the first non-overridden occurrence on/after today's local date.""" if schedule.start_time is None or schedule.recurrence_rule is None: return None - try: - timezone = ZoneInfo(schedule.timezone) - except ZoneInfoNotFoundError: - return None + timezone = get_schedule_timezone(schedule.timezone) + local_start = schedule.start_time.astimezone(timezone) local_date = now.astimezone(timezone).date() boundary = datetime.combine(local_date, time.min, tzinfo=timezone) - rule = parse_recurrence_rule(schedule.recurrence_rule, start_time=schedule.start_time) + rule = parse_recurrence_rule(schedule.recurrence_rule, start_time=local_start) overridden = {override.occurrence_start for override in overrides} occurrence = rule.after(boundary, inc=True) while occurrence is not None and occurrence in overridden: occurrence = rule.after(occurrence, inc=False) - return occurrence + return None if occurrence is None else occurrence.astimezone(UTC) def truncate_rule_before_occurrence( @@ -59,25 +88,31 @@ def truncate_rule_before_occurrence( """Return an RRULE ending at the prior occurrence, or None for the first one.""" if schedule.start_time is None or schedule.recurrence_rule is None: return None - rule = parse_recurrence_rule(schedule.recurrence_rule, start_time=schedule.start_time) - previous = rule.before(occurrence, inc=False) + timezone = get_schedule_timezone(schedule.timezone) + local_start = schedule.start_time.astimezone(timezone) + local_occurrence = occurrence.astimezone(timezone) + rule = parse_recurrence_rule(schedule.recurrence_rule, start_time=local_start) + previous = rule.before(local_occurrence, inc=False) if previous is None: return None components = [ component - for component in schedule.recurrence_rule.split(";") + for component in normalize_recurrence_rule(schedule.recurrence_rule).split(";") if not component.upper().startswith(("UNTIL=", "COUNT=")) ] until = previous.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ") truncated = ";".join((*components, f"UNTIL={until}")) - parse_recurrence_rule(truncated, start_time=schedule.start_time) + parse_recurrence_rule(truncated, start_time=local_start) return truncated __all__ = [ "InvalidRecurrenceRuleError", + "InvalidTimezoneKeyError", "first_active_occurrence_on_or_after_local_date", + "get_schedule_timezone", + "normalize_recurrence_rule", "parse_recurrence_rule", "truncate_rule_before_occurrence", ] diff --git a/backend/src/timeflow/business/calendar/service.py b/backend/src/timeflow/business/calendar/service.py index 4338da5..195e1cb 100644 --- a/backend/src/timeflow/business/calendar/service.py +++ b/backend/src/timeflow/business/calendar/service.py @@ -6,7 +6,7 @@ from datetime import UTC, datetime from typing import NoReturn from uuid import uuid4 -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from zoneinfo import ZoneInfo from timeflow.business.calendar.contracts import ( CreateScheduleCommand, @@ -35,7 +35,10 @@ ) from timeflow.business.calendar.recurrence import ( InvalidRecurrenceRuleError, + InvalidTimezoneKeyError, first_active_occurrence_on_or_after_local_date, + get_schedule_timezone, + normalize_recurrence_rule, parse_recurrence_rule, truncate_rule_before_occurrence, ) @@ -156,7 +159,7 @@ def create_schedule( updated_at=now, start_time=command.start_time, end_time=command.end_time, - recurrence_rule=command.recurrence_rule, + recurrence_rule=_normalize_optional_recurrence_rule(command.recurrence_rule), location_name=command.location_name, latitude=command.latitude, longitude=command.longitude, @@ -241,6 +244,10 @@ def update_schedule( schedule_id=command.schedule_id, ) candidate = replace(current, **command.changes, updated_at=now) + candidate = replace( + candidate, + recurrence_rule=_normalize_optional_recurrence_rule(candidate.recurrence_rule), + ) _validate_snapshot(candidate) persisted = _persist_update( unit_of_work.schedules, @@ -333,6 +340,7 @@ def _delete_recurring_range( command: DeleteRecurringScheduleCommand, now: datetime, ) -> ScheduleMutationResult: + _validated_timezone(current.timezone) overrides = repository.list_occurrence_overrides( account_id=account_id, schedule_id=current.id, @@ -528,10 +536,7 @@ def _validate_snapshot(snapshot: ScheduleSnapshot) -> None: _validation_error("title must contain 1 to 255 characters", field="title") if not snapshot.timezone.strip() or len(snapshot.timezone) > 64: _invalid_timezone(snapshot.timezone) - try: - ZoneInfo(snapshot.timezone) - except ZoneInfoNotFoundError: - _invalid_timezone(snapshot.timezone) + timezone = _validated_timezone(snapshot.timezone) for field, value in ( ("created_at", snapshot.created_at), @@ -588,7 +593,10 @@ def _validate_snapshot(snapshot: ScheduleSnapshot) -> None: "recurrence_rule must not exceed 512 characters", field="recurrence_rule" ) try: - parse_recurrence_rule(snapshot.recurrence_rule, start_time=snapshot.start_time) + parse_recurrence_rule( + snapshot.recurrence_rule, + start_time=snapshot.start_time.astimezone(timezone), + ) except InvalidRecurrenceRuleError: _validation_error( "recurrence_rule is not a valid RFC 5545 RRULE", @@ -677,6 +685,25 @@ def _new_id(id_factory: Callable[[], str], *, field: str) -> str: return value +def _normalize_optional_recurrence_rule(rule: str | None) -> str | None: + if rule is None: + return None + try: + return normalize_recurrence_rule(rule) + except InvalidRecurrenceRuleError: + _validation_error( + "recurrence_rule must contain exactly one RFC 5545 RRULE", + field="recurrence_rule", + ) + + +def _validated_timezone(timezone: str) -> ZoneInfo: + try: + return get_schedule_timezone(timezone) + except InvalidTimezoneKeyError: + _invalid_timezone(timezone) + + def _invalid_timezone(timezone: str) -> NoReturn: _raise_business_error( ScheduleErrorCode.INVALID_TIMEZONE, diff --git a/backend/tests/test_schedule_application_service.py b/backend/tests/test_schedule_application_service.py index da86fbf..58ae1bb 100644 --- a/backend/tests/test_schedule_application_service.py +++ b/backend/tests/test_schedule_application_service.py @@ -256,6 +256,11 @@ def test_create_schedule_returns_the_committed_cloud_snapshot() -> None: ScheduleErrorCode.INVALID_TIMEZONE, "timezone", ), + ( + replace(_time_command(), timezone="../America/New_York"), + ScheduleErrorCode.INVALID_TIMEZONE, + "timezone", + ), ( replace(_time_command(), start_time=None), ScheduleErrorCode.VALIDATION_FAILED, @@ -335,6 +340,59 @@ def test_create_schedule_accepts_location_recurring_and_reminder_shapes() -> Non assert recurring_result.schedules[0].schedule_kind is ScheduleKind.RECURRING +@pytest.mark.parametrize("timezone", ["UTC", "Asia/Shanghai", "America/New_York"]) +def test_create_schedule_accepts_valid_iana_timezones(timezone: str) -> None: + service, _ = _service() + + result = service.create_schedule( + account_id="account-a", + command=replace(_time_command(), timezone=timezone), + ) + + assert result.schedules[0].timezone == timezone + + +@pytest.mark.parametrize( + "rule", + [ + "RDATE:20260810T090000Z", + "EXRULE:FREQ=WEEKLY", + "DTSTART:20260810T090000Z", + "RRULE:FREQ=DAILY\nRDATE:20260810T090000Z", + "", + "FREQ=NOT_A_FREQUENCY", + ], +) +def test_create_schedule_translates_non_single_rrules_to_business_errors(rule: str) -> None: + service, store = _service() + command = _time_command( + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule=rule, + ) + + error = _assert_error( + ScheduleErrorCode.VALIDATION_FAILED, + lambda: service.create_schedule(account_id="account-a", command=command), + ) + + assert error.field == "recurrence_rule" + assert store.schedules == {} + + +def test_create_schedule_normalizes_one_rrule_prefix_before_persistence() -> None: + service, _ = _service() + + result = service.create_schedule( + account_id="account-a", + command=_time_command( + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="RRULE:FREQ=WEEKLY;BYDAY=WE", + ), + ) + + assert result.schedules[0].recurrence_rule == "FREQ=WEEKLY;BYDAY=WE" + + def test_find_schedules_filters_without_leaking_other_accounts_or_deleted_rows() -> None: service, _ = _service() first = service.create_schedule( @@ -427,6 +485,26 @@ def test_update_rejects_empty_or_protected_patch_without_writing() -> None: assert store.schedules[created.id] == created +def test_update_translates_invalid_timezone_value_error_without_writing() -> None: + service, store = _service() + created = service.create_schedule(account_id="account-a", command=_time_command()).schedules[0] + + error = _assert_error( + ScheduleErrorCode.INVALID_TIMEZONE, + lambda: service.update_schedule( + account_id="account-a", + command=UpdateScheduleCommand( + created.id, + 1, + {"timezone": "../America/New_York"}, + ), + ), + ) + + assert error.field == "timezone" + assert store.schedules[created.id] == created + + def test_delete_once_is_soft_account_scoped_and_kind_checked() -> None: service, store = _service() ordinary = service.create_schedule(account_id="account-a", command=_time_command()).schedules[0] @@ -501,6 +579,58 @@ def test_delete_this_occurrence_uses_current_schedule_timezone_and_skips_overrid assert len(store.overrides) == 2 +def test_delete_this_occurrence_keeps_new_york_wall_time_after_dst() -> None: + service, _ = _service(now=datetime(2026, 3, 8, 16, tzinfo=UTC)) + recurring = service.create_schedule( + account_id="account-a", + command=replace( + _time_command( + start_time=datetime(2026, 1, 5, 14, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ), + timezone="America/New_York", + ), + ).schedules[0] + + result = service.delete_recurring_schedule( + account_id="account-a", + command=DeleteRecurringScheduleCommand( + recurring.id, + 1, + RecurringDeleteScope.THIS_OCCURRENCE, + ), + ) + + assert result.occurrence_overrides[0].occurrence_start == datetime(2026, 3, 9, 13, tzinfo=UTC) + + +def test_delete_this_and_future_uses_dst_correct_prior_occurrence() -> None: + service, _ = _service(now=datetime(2026, 3, 8, 16, tzinfo=UTC)) + recurring = service.create_schedule( + account_id="account-a", + command=replace( + _time_command( + start_time=datetime(2026, 1, 5, 14, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO;COUNT=20", + ), + timezone="America/New_York", + ), + ).schedules[0] + + result = service.delete_recurring_schedule( + account_id="account-a", + command=DeleteRecurringScheduleCommand( + recurring.id, + 1, + RecurringDeleteScope.THIS_AND_FUTURE, + ), + ) + + assert result.schedules[0].recurrence_rule == ("FREQ=WEEKLY;BYDAY=MO;UNTIL=20260302T140000Z") + + def test_delete_this_and_future_truncates_after_last_retained_occurrence() -> None: service, _ = _service(now=NOW) recurring = service.create_schedule( diff --git a/backend/tests/test_schedule_recurrence.py b/backend/tests/test_schedule_recurrence.py new file mode 100644 index 0000000..83d9311 --- /dev/null +++ b/backend/tests/test_schedule_recurrence.py @@ -0,0 +1,145 @@ +"""Strict RRULE and timezone-aware recurrence behavior tests.""" + +from datetime import UTC, datetime +from zoneinfo import ZoneInfo + +import pytest + +from timeflow.business.calendar import ( + ScheduleKind, + ScheduleSnapshot, + ScheduleStatus, + ScheduleType, +) +from timeflow.business.calendar.recurrence import ( + InvalidRecurrenceRuleError, + first_active_occurrence_on_or_after_local_date, + normalize_recurrence_rule, + parse_recurrence_rule, + truncate_rule_before_occurrence, +) + + +def _recurring_schedule( + *, + timezone: str, + start_time: datetime, + recurrence_rule: str = "FREQ=WEEKLY;BYDAY=MO", +) -> ScheduleSnapshot: + now = datetime(2026, 1, 1, tzinfo=UTC) + return ScheduleSnapshot( + id="recurring-schedule", + account_id="account-a", + schedule_type=ScheduleType.TIME, + schedule_kind=ScheduleKind.RECURRING, + title="Weekly sync", + is_all_day=False, + timezone=timezone, + status=ScheduleStatus.ACTIVE, + revision=1, + created_at=now, + updated_at=now, + start_time=start_time, + recurrence_rule=recurrence_rule, + ) + + +@pytest.mark.parametrize( + "rule", + [ + "FREQ=DAILY", + "FREQ=WEEKLY;BYDAY=MO,WE", + "FREQ=MONTHLY;INTERVAL=2", + ], +) +def test_single_rrule_bodies_are_accepted(rule: str) -> None: + local_start = datetime(2026, 1, 5, 9, tzinfo=ZoneInfo("America/New_York")) + + parsed = parse_recurrence_rule(rule, start_time=local_start) + + assert parsed.after(local_start, inc=True) is not None + + +def test_one_rrule_prefix_is_normalized_to_the_contract_body() -> None: + assert normalize_recurrence_rule("RRULE:FREQ=WEEKLY;BYDAY=MO") == ("FREQ=WEEKLY;BYDAY=MO") + + +@pytest.mark.parametrize( + "rule", + [ + "RDATE:20260810T090000Z", + "EXDATE:20260810T090000Z", + "EXRULE:FREQ=WEEKLY", + "DTSTART:20260810T090000Z", + "RRULE:FREQ=DAILY\nRRULE:FREQ=WEEKLY", + "RRULE:FREQ=DAILY\nRDATE:20260810T090000Z", + "", + "FREQ=NOT_A_FREQUENCY", + ], +) +def test_recurrence_sets_and_non_rrule_content_are_rejected(rule: str) -> None: + local_start = datetime(2026, 1, 5, 9, tzinfo=ZoneInfo("America/New_York")) + + with pytest.raises(InvalidRecurrenceRuleError): + parse_recurrence_rule(rule, start_time=local_start) + + +def test_new_york_weekly_occurrences_keep_nine_am_across_dst() -> None: + timezone = ZoneInfo("America/New_York") + schedule = _recurring_schedule( + timezone=timezone.key, + start_time=datetime(2026, 1, 5, 14, tzinfo=UTC), + ) + + before_dst = first_active_occurrence_on_or_after_local_date( + schedule, + now=datetime(2026, 3, 1, 12, tzinfo=UTC), + overrides=(), + ) + after_dst = first_active_occurrence_on_or_after_local_date( + schedule, + now=datetime(2026, 3, 8, 16, tzinfo=UTC), + overrides=(), + ) + + assert before_dst == datetime(2026, 3, 2, 14, tzinfo=UTC) + assert after_dst == datetime(2026, 3, 9, 13, tzinfo=UTC) + assert before_dst.astimezone(timezone).hour == 9 + assert after_dst.astimezone(timezone).hour == 9 + assert before_dst.astimezone(timezone).utcoffset().total_seconds() == -5 * 3600 + assert after_dst.astimezone(timezone).utcoffset().total_seconds() == -4 * 3600 + + +def test_this_and_future_cutoff_uses_the_dst_correct_prior_occurrence() -> None: + schedule = _recurring_schedule( + timezone="America/New_York", + start_time=datetime(2026, 1, 5, 14, tzinfo=UTC), + recurrence_rule="FREQ=WEEKLY;BYDAY=MO;COUNT=20", + ) + occurrence = first_active_occurrence_on_or_after_local_date( + schedule, + now=datetime(2026, 3, 8, 16, tzinfo=UTC), + overrides=(), + ) + + assert occurrence == datetime(2026, 3, 9, 13, tzinfo=UTC) + assert truncate_rule_before_occurrence(schedule, occurrence) == ( + "FREQ=WEEKLY;BYDAY=MO;UNTIL=20260302T140000Z" + ) + + +def test_shanghai_occurrence_keeps_its_non_dst_wall_time() -> None: + timezone = ZoneInfo("Asia/Shanghai") + schedule = _recurring_schedule( + timezone=timezone.key, + start_time=datetime(2026, 8, 3, 2, tzinfo=UTC), + ) + + occurrence = first_active_occurrence_on_or_after_local_date( + schedule, + now=datetime(2026, 8, 10, 17, tzinfo=UTC), + overrides=(), + ) + + assert occurrence == datetime(2026, 8, 17, 2, tzinfo=UTC) + assert occurrence.astimezone(timezone).hour == 10 From 3316c7291b4e738732885b43c3c2a2299e9e57e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=83=A7=E6=B4=97=E5=8F=91=E7=94=A8=E9=A3=98?= =?UTF-8?q?=E6=9F=94?= <2906266135@qq.com> Date: Tue, 11 Aug 2026 18:20:42 +0800 Subject: [PATCH 3/5] fix(schedule): resolve effective occurrence queries --- .../src/timeflow/business/calendar/ports.py | 10 + .../timeflow/business/calendar/recurrence.py | 42 ++ .../src/timeflow/business/calendar/service.py | 175 ++++++-- .../timeflow/data/repositories/schedule.py | 34 +- .../test_postgres_schedule_repository.py | 39 ++ .../test_schedule_application_service.py | 414 ++++++++++++++++++ backend/tests/test_schedule_recurrence.py | 41 ++ backend/tests/test_schedule_repository.py | 43 ++ 8 files changed, 762 insertions(+), 36 deletions(-) diff --git a/backend/src/timeflow/business/calendar/ports.py b/backend/src/timeflow/business/calendar/ports.py index 5814452..62bec1d 100644 --- a/backend/src/timeflow/business/calendar/ports.py +++ b/backend/src/timeflow/business/calendar/ports.py @@ -1,6 +1,7 @@ """Persistence abstractions owned by the schedule business layer.""" from collections.abc import Callable +from datetime import datetime from types import TracebackType from typing import Protocol, Self @@ -51,6 +52,15 @@ def list_schedules( include_deleted: bool = False, ) -> tuple[ScheduleSnapshot, ...]: ... + def list_schedule_candidates( + self, + *, + account_id: str, + starts_at_or_after: datetime | None, + starts_before: datetime | None, + include_deleted: bool = False, + ) -> tuple[ScheduleSnapshot, ...]: ... + def update_schedule( self, *, diff --git a/backend/src/timeflow/business/calendar/recurrence.py b/backend/src/timeflow/business/calendar/recurrence.py index 5a687d5..9618cd5 100644 --- a/backend/src/timeflow/business/calendar/recurrence.py +++ b/backend/src/timeflow/business/calendar/recurrence.py @@ -81,6 +81,47 @@ def first_active_occurrence_on_or_after_local_date( return None if occurrence is None else occurrence.astimezone(UTC) +def first_occurrence_in_window( + schedule: ScheduleSnapshot, + *, + starts_at_or_after: datetime | None, + starts_before: datetime | None, + excluded_occurrence_starts: frozenset[datetime], +) -> datetime | None: + """Find one non-overridden occurrence in a half-open query window. + + ``after``/``before`` jump directly to the window edge. Iteration advances + only when that occurrence has an override, rather than replaying history + from the schedule start. + """ + if schedule.start_time is None or schedule.recurrence_rule is None: + return None + timezone = get_schedule_timezone(schedule.timezone) + local_start = schedule.start_time.astimezone(timezone) + rule = parse_recurrence_rule(schedule.recurrence_rule, start_time=local_start) + local_lower = None if starts_at_or_after is None else starts_at_or_after.astimezone(timezone) + local_upper = None if starts_before is None else starts_before.astimezone(timezone) + + if local_lower is not None: + occurrence = rule.after(local_lower, inc=True) + while occurrence is not None and (local_upper is None or occurrence < local_upper): + utc_occurrence = occurrence.astimezone(UTC) + if utc_occurrence not in excluded_occurrence_starts: + return utc_occurrence + occurrence = rule.after(occurrence, inc=False) + return None + + if local_upper is None: + return None + occurrence = rule.before(local_upper, inc=False) + while occurrence is not None and occurrence >= local_start: + utc_occurrence = occurrence.astimezone(UTC) + if utc_occurrence not in excluded_occurrence_starts: + return utc_occurrence + occurrence = rule.before(occurrence, inc=False) + return None + + def truncate_rule_before_occurrence( schedule: ScheduleSnapshot, occurrence: datetime, @@ -111,6 +152,7 @@ def truncate_rule_before_occurrence( "InvalidRecurrenceRuleError", "InvalidTimezoneKeyError", "first_active_occurrence_on_or_after_local_date", + "first_occurrence_in_window", "get_schedule_timezone", "normalize_recurrence_rule", "parse_recurrence_rule", diff --git a/backend/src/timeflow/business/calendar/service.py b/backend/src/timeflow/business/calendar/service.py index 195e1cb..29e6b0c 100644 --- a/backend/src/timeflow/business/calendar/service.py +++ b/backend/src/timeflow/business/calendar/service.py @@ -37,6 +37,7 @@ InvalidRecurrenceRuleError, InvalidTimezoneKeyError, first_active_occurrence_on_or_after_local_date, + first_occurrence_in_window, get_schedule_timezone, normalize_recurrence_rule, parse_recurrence_rule, @@ -184,13 +185,22 @@ def find_schedules( """Return account-scoped schedules matching every supplied criterion.""" _validate_account_id(account_id) _validate_query(query) + has_time_window = query.starts_at_or_after is not None or query.starts_before is not None with self._unit_of_work_factory() as unit_of_work: if query.schedule_id is None: - candidates = unit_of_work.schedules.list_schedules( - account_id=account_id, - include_deleted=query.include_deleted, - ) + if has_time_window: + candidates = unit_of_work.schedules.list_schedule_candidates( + account_id=account_id, + starts_at_or_after=query.starts_at_or_after, + starts_before=query.starts_before, + include_deleted=query.include_deleted, + ) + else: + candidates = unit_of_work.schedules.list_schedules( + account_id=account_id, + include_deleted=query.include_deleted, + ) else: match = unit_of_work.schedules.get_schedule( account_id=account_id, @@ -198,32 +208,38 @@ def find_schedules( include_deleted=query.include_deleted, ) candidates = () if match is None else (match,) - - title = None if query.title is None else query.title.casefold() - location = None if query.location_name is None else query.location_name.casefold() - matches = tuple( - schedule - for schedule in candidates - if (title is None or title in schedule.title.casefold()) - and ( - location is None - or ( - schedule.location_name is not None - and location in schedule.location_name.casefold() - ) + candidates = tuple( + schedule for schedule in candidates if _matches_static_query(schedule, query) ) - and ( - query.starts_at_or_after is None - or ( - schedule.start_time is not None - and schedule.start_time >= query.starts_at_or_after + if not has_time_window: + return ScheduleSearchResult(schedules=candidates) + + recurring_ids = { + schedule.id + for schedule in candidates + if schedule.schedule_kind is ScheduleKind.RECURRING + } + overrides_by_schedule: dict[str, list[ScheduleOccurrenceOverrideSnapshot]] = { + schedule_id: [] for schedule_id in recurring_ids + } + if recurring_ids: + for override in unit_of_work.schedules.list_occurrence_overrides( + account_id=account_id + ): + if override.schedule_id in overrides_by_schedule: + overrides_by_schedule[override.schedule_id].append(override) + + matches = tuple( + schedule + for schedule in candidates + if _has_effective_occurrence_in_window( + unit_of_work.schedules, + account_id=account_id, + schedule=schedule, + query=query, + overrides=tuple(overrides_by_schedule.get(schedule.id, ())), ) ) - and ( - query.starts_before is None - or (schedule.start_time is not None and schedule.start_time < query.starts_before) - ) - ) return ScheduleSearchResult(schedules=matches) def update_schedule( @@ -244,10 +260,11 @@ def update_schedule( schedule_id=command.schedule_id, ) candidate = replace(current, **command.changes, updated_at=now) - candidate = replace( - candidate, - recurrence_rule=_normalize_optional_recurrence_rule(candidate.recurrence_rule), - ) + if "recurrence_rule" in command.changes: + candidate = replace( + candidate, + recurrence_rule=_normalize_optional_recurrence_rule(candidate.recurrence_rule), + ) _validate_snapshot(candidate) persisted = _persist_update( unit_of_work.schedules, @@ -418,6 +435,67 @@ def _delete_recurring_range( return ScheduleMutationResult(schedules=(persisted,)) +def _matches_static_query(schedule: ScheduleSnapshot, query: FindSchedulesQuery) -> bool: + title = None if query.title is None else query.title.casefold() + location = None if query.location_name is None else query.location_name.casefold() + return (title is None or title in schedule.title.casefold()) and ( + location is None + or (schedule.location_name is not None and location in schedule.location_name.casefold()) + ) + + +def _has_effective_occurrence_in_window( + repository: ScheduleRepositoryPort, + *, + account_id: str, + schedule: ScheduleSnapshot, + query: FindSchedulesQuery, + overrides: tuple[ScheduleOccurrenceOverrideSnapshot, ...], +) -> bool: + if schedule.schedule_kind is ScheduleKind.ONCE: + return _start_is_in_window(schedule.start_time, query) + + excluded_starts = frozenset(override.occurrence_start for override in overrides) + for override in overrides: + if ( + override.action is not OccurrenceOverrideAction.REPLACE + or override.replacement_schedule_id is None + ): + continue + replacement = repository.get_schedule( + account_id=account_id, + schedule_id=override.replacement_schedule_id, + ) + if replacement is not None and _start_is_in_window(replacement.start_time, query): + return True + + try: + occurrence = first_occurrence_in_window( + schedule, + starts_at_or_after=query.starts_at_or_after, + starts_before=query.starts_before, + excluded_occurrence_starts=excluded_starts, + ) + except InvalidTimezoneKeyError: + _invalid_timezone(schedule.timezone) + except InvalidRecurrenceRuleError: + _raise_business_error( + ScheduleErrorCode.VALIDATION_FAILED, + "The persisted recurrence_rule cannot be expanded.", + schedule_id=schedule.id, + field="recurrence_rule", + ) + return occurrence is not None + + +def _start_is_in_window(start_time: datetime | None, query: FindSchedulesQuery) -> bool: + if start_time is None: + return False + return (query.starts_at_or_after is None or start_time >= query.starts_at_or_after) and ( + query.starts_before is None or start_time < query.starts_before + ) + + def _persist_update( repository: ScheduleRepositoryPort, snapshot: ScheduleSnapshot, @@ -571,10 +649,33 @@ def _validate_snapshot(snapshot: ScheduleSnapshot) -> None: if snapshot.end_time is not None: if snapshot.start_time is None or snapshot.end_time <= snapshot.start_time: _validation_error("end_time must be later than start_time", field="end_time") - if snapshot.is_all_day and ( - snapshot.schedule_type is not ScheduleType.TIME or snapshot.end_time is None - ): - _validation_error("all-day schedules require an exclusive end_time", field="end_time") + if snapshot.is_all_day: + if ( + snapshot.schedule_type is not ScheduleType.TIME + or snapshot.start_time is None + or snapshot.end_time is None + ): + _validation_error( + "all-day schedules require time-schedule date boundaries", + field="end_time", + ) + local_start = snapshot.start_time.astimezone(timezone) + local_end = snapshot.end_time.astimezone(timezone) + if not _is_local_midnight(local_start): + _validation_error( + "all-day start_time must be local midnight", + field="start_time", + ) + if not _is_local_midnight(local_end): + _validation_error( + "all-day end_time must be local midnight", + field="end_time", + ) + if local_end.date() <= local_start.date(): + _validation_error( + "all-day end date must be later than its start date", + field="end_time", + ) if snapshot.schedule_kind is ScheduleKind.ONCE: if snapshot.recurrence_rule is not None: @@ -671,6 +772,10 @@ def _validate_optional_datetime(value: datetime | None, field: str) -> None: _validation_error(f"{field} must include a UTC offset", field=field) +def _is_local_midnight(value: datetime) -> bool: + return (value.hour, value.minute, value.second, value.microsecond) == (0, 0, 0, 0) + + def _aware_now(clock: Callable[[], datetime]) -> datetime: now = clock() if now.tzinfo is None or now.utcoffset() is None: diff --git a/backend/src/timeflow/data/repositories/schedule.py b/backend/src/timeflow/data/repositories/schedule.py index fdd047c..56b97a3 100644 --- a/backend/src/timeflow/data/repositories/schedule.py +++ b/backend/src/timeflow/data/repositories/schedule.py @@ -3,7 +3,7 @@ from datetime import datetime from decimal import Decimal -from sqlalchemy import select, update +from sqlalchemy import and_, or_, select, update from sqlalchemy.orm import Session from timeflow.business.calendar.contracts import ( @@ -70,6 +70,38 @@ def list_schedules( return tuple(_to_schedule_snapshot(model) for model in self._session.scalars(statement)) + def list_schedule_candidates( + self, + *, + account_id: str, + starts_at_or_after: datetime | None, + starts_before: datetime | None, + include_deleted: bool = False, + ) -> tuple[ScheduleSnapshot, ...]: + """Coarsely filter one-time rows and possible recurring matches. + + PostgreSQL never parses RRULE. One-time rows are bounded directly by + their start, while recurring rows remain candidates when their series + started before the exclusive query end. The application service makes + the final occurrence and override decision. + """ + once_filters = [Schedule.schedule_kind == ScheduleKind.ONCE.value] + recurring_filters = [Schedule.schedule_kind == ScheduleKind.RECURRING.value] + if starts_at_or_after is not None: + once_filters.append(Schedule.start_time >= starts_at_or_after) + if starts_before is not None: + once_filters.append(Schedule.start_time < starts_before) + recurring_filters.append(Schedule.start_time < starts_before) + + statement = select(Schedule).where( + Schedule.account_id == account_id, + or_(and_(*once_filters), and_(*recurring_filters)), + ) + 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, *, diff --git a/backend/tests/test_postgres_schedule_repository.py b/backend/tests/test_postgres_schedule_repository.py index 7cc1edb..1d23eda 100644 --- a/backend/tests/test_postgres_schedule_repository.py +++ b/backend/tests/test_postgres_schedule_repository.py @@ -126,6 +126,45 @@ def test_postgres_repository_reports_conflict_and_preserves_account_isolation( assert persisted.revision == 5 +def test_postgres_schedule_candidates_keep_old_recurring_series_and_bound_once_rows( + postgres_session: Session, +) -> None: + """PostgreSQL performs only the safe coarse filter needed before RRULE expansion.""" + _seed_account(postgres_session, "account-a") + _seed_account(postgres_session, "account-b") + repository = ScheduleRepository(postgres_session) + lower = datetime(2026, 8, 17, tzinfo=UTC) + upper = datetime(2026, 8, 18, tzinfo=UTC) + recurring = replace( + _schedule("recurring-old", "account-a"), + schedule_kind=ScheduleKind.RECURRING, + start_time=datetime(2026, 8, 3, 2, tzinfo=UTC), + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ) + rows = ( + recurring, + replace(_schedule("once-inside", "account-a"), start_time=lower), + replace(_schedule("once-at-end", "account-a"), start_time=upper), + replace( + _schedule("deleted-inside", "account-a"), + start_time=lower, + status=ScheduleStatus.DELETED, + deleted_at=lower, + ), + replace(_schedule("other-account", "account-b"), start_time=lower), + ) + for row in rows: + repository.add_schedule(row) + + candidates = repository.list_schedule_candidates( + account_id="account-a", + starts_at_or_after=lower, + starts_before=upper, + ) + + assert [schedule.id for schedule in candidates] == ["recurring-old", "once-inside"] + + def test_postgres_repository_updates_one_unique_occurrence_override( postgres_session: Session, ) -> None: diff --git a/backend/tests/test_schedule_application_service.py b/backend/tests/test_schedule_application_service.py index 58ae1bb..9748d69 100644 --- a/backend/tests/test_schedule_application_service.py +++ b/backend/tests/test_schedule_application_service.py @@ -80,6 +80,34 @@ def list_schedules( and (include_deleted or snapshot.status is ScheduleStatus.ACTIVE) ) + def list_schedule_candidates( + self, + *, + account_id: str, + starts_at_or_after: datetime | None, + starts_before: datetime | None, + include_deleted: bool = False, + ) -> tuple[ScheduleSnapshot, ...]: + schedules = self.list_schedules( + account_id=account_id, + include_deleted=include_deleted, + ) + return tuple( + schedule + for schedule in schedules + if ( + schedule.schedule_kind is ScheduleKind.RECURRING + and schedule.start_time is not None + and (starts_before is None or schedule.start_time < starts_before) + ) + or ( + schedule.schedule_kind is ScheduleKind.ONCE + and schedule.start_time is not None + and (starts_at_or_after is None or schedule.start_time >= starts_at_or_after) + and (starts_before is None or schedule.start_time < starts_before) + ) + ) + def update_schedule( self, *, @@ -223,6 +251,23 @@ def _time_command( ) +def _all_day_command( + *, + timezone: str, + start_time: datetime, + end_time: datetime, +) -> CreateScheduleCommand: + return CreateScheduleCommand( + schedule_type=ScheduleType.TIME, + schedule_kind=ScheduleKind.ONCE, + title="All-day event", + timezone=timezone, + is_all_day=True, + start_time=start_time, + end_time=end_time, + ) + + def _assert_error( expected: ScheduleErrorCode, operation: Callable[[], object], @@ -233,6 +278,26 @@ def _assert_error( return raised.value +def _add_override( + store: _Store, + *, + override_id: str, + schedule_id: str, + occurrence_start: datetime, + action: OccurrenceOverrideAction, + replacement_schedule_id: str | None = None, +) -> None: + store.overrides[override_id] = ScheduleOccurrenceOverrideSnapshot( + id=override_id, + schedule_id=schedule_id, + occurrence_start=occurrence_start, + action=action, + replacement_schedule_id=replacement_schedule_id, + created_at=NOW, + updated_at=NOW, + ) + + def test_create_schedule_returns_the_committed_cloud_snapshot() -> None: service, store = _service() @@ -340,6 +405,80 @@ def test_create_schedule_accepts_location_recurring_and_reminder_shapes() -> Non assert recurring_result.schedules[0].schedule_kind is ScheduleKind.RECURRING +@pytest.mark.parametrize( + "command", + [ + _all_day_command( + timezone="Asia/Shanghai", + start_time=datetime(2026, 8, 16, 16, tzinfo=UTC), + end_time=datetime(2026, 8, 17, 16, tzinfo=UTC), + ), + _all_day_command( + timezone="Asia/Shanghai", + start_time=datetime(2026, 8, 16, 16, tzinfo=UTC), + end_time=datetime(2026, 8, 20, 16, tzinfo=UTC), + ), + _all_day_command( + timezone="America/New_York", + start_time=datetime(2026, 3, 8, 5, tzinfo=UTC), + end_time=datetime(2026, 3, 9, 4, tzinfo=UTC), + ), + ], +) +def test_create_schedule_accepts_local_all_day_boundaries( + command: CreateScheduleCommand, +) -> None: + service, _ = _service() + + result = service.create_schedule(account_id="account-a", command=command) + + assert result.schedules[0].is_all_day is True + + +@pytest.mark.parametrize( + "command", + [ + _all_day_command( + timezone="Asia/Shanghai", + start_time=datetime(2026, 8, 17, 2, tzinfo=UTC), + end_time=datetime(2026, 8, 17, 16, tzinfo=UTC), + ), + _all_day_command( + timezone="Asia/Shanghai", + start_time=datetime(2026, 8, 16, 16, tzinfo=UTC), + end_time=datetime(2026, 8, 17, 3, tzinfo=UTC), + ), + _all_day_command( + timezone="Asia/Shanghai", + start_time=datetime(2026, 8, 16, 16, tzinfo=UTC), + end_time=datetime(2026, 8, 16, 16, tzinfo=UTC), + ), + CreateScheduleCommand( + schedule_type=ScheduleType.LOCATION, + schedule_kind=ScheduleKind.ONCE, + title="Invalid all-day location", + timezone="Asia/Shanghai", + is_all_day=True, + start_time=datetime(2026, 8, 16, 16, tzinfo=UTC), + end_time=datetime(2026, 8, 17, 16, tzinfo=UTC), + latitude=31.2304, + longitude=121.4737, + ), + ], +) +def test_create_schedule_rejects_non_boundary_all_day_values( + command: CreateScheduleCommand, +) -> None: + service, store = _service() + + _assert_error( + ScheduleErrorCode.VALIDATION_FAILED, + lambda: service.create_schedule(account_id="account-a", command=command), + ) + + assert store.schedules == {} + + @pytest.mark.parametrize("timezone", ["UTC", "Asia/Shanghai", "America/New_York"]) def test_create_schedule_accepts_valid_iana_timezones(timezone: str) -> None: service, _ = _service() @@ -433,6 +572,238 @@ def test_find_schedules_filters_without_leaking_other_accounts_or_deleted_rows() assert with_deleted.schedules[0].status is ScheduleStatus.DELETED +def test_find_schedules_filters_one_time_occurrences_by_half_open_window() -> None: + service, _ = _service() + inside = service.create_schedule( + account_id="account-a", + command=_time_command(start_time=datetime(2026, 8, 17, 1, tzinfo=UTC)), + ).schedules[0] + service.create_schedule( + account_id="account-a", + command=_time_command(start_time=datetime(2026, 8, 18, 1, tzinfo=UTC)), + ) + query = FindSchedulesQuery( + starts_at_or_after=datetime(2026, 8, 16, 16, tzinfo=UTC), + starts_before=datetime(2026, 8, 17, 16, tzinfo=UTC), + ) + + result = service.find_schedules(account_id="account-a", query=query) + + assert result.schedules == (inside,) + + +def test_find_schedules_expands_recurring_occurrences_only_in_query_window() -> None: + service, _ = _service() + recurring = service.create_schedule( + account_id="account-a", + command=_time_command( + start_time=datetime(2026, 8, 3, 1, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ), + ).schedules[0] + + august_17 = service.find_schedules( + account_id="account-a", + query=FindSchedulesQuery( + starts_at_or_after=datetime(2026, 8, 16, 16, tzinfo=UTC), + starts_before=datetime(2026, 8, 17, 16, tzinfo=UTC), + ), + ) + august_18 = service.find_schedules( + account_id="account-a", + query=FindSchedulesQuery( + starts_at_or_after=datetime(2026, 8, 17, 16, tzinfo=UTC), + starts_before=datetime(2026, 8, 18, 16, tzinfo=UTC), + ), + ) + + assert august_17.schedules == (recurring,) + assert august_18.schedules == () + + +def test_find_schedules_excludes_a_cancelled_recurring_occurrence() -> None: + service, store = _service() + recurring = service.create_schedule( + account_id="account-a", + command=_time_command( + start_time=datetime(2026, 8, 3, 1, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ), + ).schedules[0] + _add_override( + store, + override_id="cancel-august-17", + schedule_id=recurring.id, + occurrence_start=datetime(2026, 8, 17, 1, tzinfo=UTC), + action=OccurrenceOverrideAction.CANCEL, + ) + + result = service.find_schedules( + account_id="account-a", + query=FindSchedulesQuery( + starts_at_or_after=datetime(2026, 8, 16, 16, tzinfo=UTC), + starts_before=datetime(2026, 8, 17, 16, tzinfo=UTC), + ), + ) + + assert result.schedules == () + + +def test_find_schedules_uses_same_day_replacement_effective_time() -> None: + service, store = _service() + recurring = service.create_schedule( + account_id="account-a", + command=_time_command( + start_time=datetime(2026, 8, 3, 1, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ), + ).schedules[0] + replacement = service.create_schedule( + account_id="account-a", + command=_time_command(start_time=datetime(2026, 8, 17, 6, tzinfo=UTC)), + ).schedules[0] + _add_override( + store, + override_id="replace-august-17", + schedule_id=recurring.id, + occurrence_start=datetime(2026, 8, 17, 1, tzinfo=UTC), + action=OccurrenceOverrideAction.REPLACE, + replacement_schedule_id=replacement.id, + ) + + effective_window = service.find_schedules( + account_id="account-a", + query=FindSchedulesQuery( + starts_at_or_after=datetime(2026, 8, 17, 5, tzinfo=UTC), + starts_before=datetime(2026, 8, 17, 7, tzinfo=UTC), + ), + ) + original_window = service.find_schedules( + account_id="account-a", + query=FindSchedulesQuery( + starts_at_or_after=datetime(2026, 8, 17, 0, tzinfo=UTC), + starts_before=datetime(2026, 8, 17, 2, tzinfo=UTC), + ), + ) + + assert recurring in effective_window.schedules + assert recurring not in original_window.schedules + + +def test_find_schedules_includes_replacement_that_crosses_into_window() -> None: + service, store = _service() + recurring = service.create_schedule( + account_id="account-a", + command=_time_command( + start_time=datetime(2026, 8, 2, 1, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=SU", + ), + ).schedules[0] + replacement = service.create_schedule( + account_id="account-a", + command=_time_command(start_time=datetime(2026, 8, 17, 6, tzinfo=UTC)), + ).schedules[0] + _add_override( + store, + override_id="replace-cross-in", + schedule_id=recurring.id, + occurrence_start=datetime(2026, 8, 16, 1, tzinfo=UTC), + action=OccurrenceOverrideAction.REPLACE, + replacement_schedule_id=replacement.id, + ) + + result = service.find_schedules( + account_id="account-a", + query=FindSchedulesQuery( + starts_at_or_after=datetime(2026, 8, 16, 16, tzinfo=UTC), + starts_before=datetime(2026, 8, 17, 16, tzinfo=UTC), + ), + ) + + assert recurring in result.schedules + + +def test_find_schedules_excludes_replacement_that_crosses_out_of_window() -> None: + service, store = _service() + recurring = service.create_schedule( + account_id="account-a", + command=_time_command( + start_time=datetime(2026, 8, 3, 1, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ), + ).schedules[0] + replacement = service.create_schedule( + account_id="account-a", + command=_time_command(start_time=datetime(2026, 8, 18, 6, tzinfo=UTC)), + ).schedules[0] + _add_override( + store, + override_id="replace-cross-out", + schedule_id=recurring.id, + occurrence_start=datetime(2026, 8, 17, 1, tzinfo=UTC), + action=OccurrenceOverrideAction.REPLACE, + replacement_schedule_id=replacement.id, + ) + + result = service.find_schedules( + account_id="account-a", + query=FindSchedulesQuery( + starts_at_or_after=datetime(2026, 8, 16, 16, tzinfo=UTC), + starts_before=datetime(2026, 8, 17, 16, tzinfo=UTC), + ), + ) + + assert recurring not in result.schedules + + +def test_find_schedules_preserves_dst_and_non_dst_wall_times() -> None: + service, _ = _service() + new_york = service.create_schedule( + account_id="account-a", + command=replace( + _time_command( + title="New York weekly", + start_time=datetime(2026, 1, 5, 14, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ), + timezone="America/New_York", + ), + ).schedules[0] + shanghai = service.create_schedule( + account_id="account-a", + command=_time_command( + title="Shanghai weekly", + start_time=datetime(2026, 8, 3, 1, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ), + ).schedules[0] + + new_york_result = service.find_schedules( + account_id="account-a", + query=FindSchedulesQuery( + starts_at_or_after=datetime(2026, 3, 9, 12, tzinfo=UTC), + starts_before=datetime(2026, 3, 9, 14, tzinfo=UTC), + ), + ) + shanghai_result = service.find_schedules( + account_id="account-a", + query=FindSchedulesQuery( + starts_at_or_after=datetime(2026, 8, 17, 0, tzinfo=UTC), + starts_before=datetime(2026, 8, 17, 2, tzinfo=UTC), + ), + ) + + assert new_york in new_york_result.schedules + assert shanghai in shanghai_result.schedules + + def test_update_schedule_applies_patch_and_translates_revision_conflict() -> None: service, store = _service(now=NOW) created = service.create_schedule(account_id="account-a", command=_time_command()).schedules[0] @@ -459,6 +830,49 @@ def test_update_schedule_applies_patch_and_translates_revision_conflict() -> Non assert store.schedules[created.id] == updated +def test_update_preserves_unmentioned_recurrence_rule_byte_for_byte() -> None: + service, store = _service() + created = service.create_schedule( + account_id="account-a", + command=_time_command( + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ), + ).schedules[0] + legacy_rule = "RRULE:FREQ=WEEKLY;BYDAY=MO" + store.schedules[created.id] = replace(created, recurrence_rule=legacy_rule) + + updated = service.update_schedule( + account_id="account-a", + command=UpdateScheduleCommand(created.id, 1, {"title": "项目周会"}), + ).schedules[0] + + assert updated.title == "项目周会" + assert updated.recurrence_rule == legacy_rule + + +def test_update_normalizes_explicit_recurrence_rule_change() -> None: + service, _ = _service() + created = service.create_schedule( + account_id="account-a", + command=_time_command( + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ), + ).schedules[0] + + updated = service.update_schedule( + account_id="account-a", + command=UpdateScheduleCommand( + created.id, + 1, + {"recurrence_rule": "RRULE:FREQ=DAILY"}, + ), + ).schedules[0] + + assert updated.recurrence_rule == "FREQ=DAILY" + + def test_update_rejects_empty_or_protected_patch_without_writing() -> None: service, store = _service() created = service.create_schedule(account_id="account-a", command=_time_command()).schedules[0] diff --git a/backend/tests/test_schedule_recurrence.py b/backend/tests/test_schedule_recurrence.py index 83d9311..aec71aa 100644 --- a/backend/tests/test_schedule_recurrence.py +++ b/backend/tests/test_schedule_recurrence.py @@ -14,6 +14,7 @@ from timeflow.business.calendar.recurrence import ( InvalidRecurrenceRuleError, first_active_occurrence_on_or_after_local_date, + first_occurrence_in_window, normalize_recurrence_rule, parse_recurrence_rule, truncate_rule_before_occurrence, @@ -143,3 +144,43 @@ def test_shanghai_occurrence_keeps_its_non_dst_wall_time() -> None: assert occurrence == datetime(2026, 8, 17, 2, tzinfo=UTC) assert occurrence.astimezone(timezone).hour == 10 + + +def test_occurrence_window_is_lower_inclusive_and_upper_exclusive() -> None: + schedule = _recurring_schedule( + timezone="Asia/Shanghai", + start_time=datetime(2026, 8, 3, 2, tzinfo=UTC), + ) + + at_lower = first_occurrence_in_window( + schedule, + starts_at_or_after=datetime(2026, 8, 17, 2, tzinfo=UTC), + starts_before=datetime(2026, 8, 24, 2, tzinfo=UTC), + excluded_occurrence_starts=frozenset(), + ) + before_upper = first_occurrence_in_window( + schedule, + starts_at_or_after=None, + starts_before=datetime(2026, 8, 17, 2, tzinfo=UTC), + excluded_occurrence_starts=frozenset(), + ) + + assert at_lower == datetime(2026, 8, 17, 2, tzinfo=UTC) + assert before_upper == datetime(2026, 8, 10, 2, tzinfo=UTC) + + +def test_occurrence_window_skips_overridden_starts_without_replaying_history() -> None: + schedule = _recurring_schedule( + timezone="Asia/Shanghai", + start_time=datetime(2026, 8, 3, 2, tzinfo=UTC), + ) + overridden = datetime(2026, 8, 17, 2, tzinfo=UTC) + + occurrence = first_occurrence_in_window( + schedule, + starts_at_or_after=overridden, + starts_before=datetime(2026, 8, 31, 2, tzinfo=UTC), + excluded_occurrence_starts=frozenset({overridden}), + ) + + assert occurrence == datetime(2026, 8, 24, 2, tzinfo=UTC) diff --git a/backend/tests/test_schedule_repository.py b/backend/tests/test_schedule_repository.py index 2c8cbff..c7abf41 100644 --- a/backend/tests/test_schedule_repository.py +++ b/backend/tests/test_schedule_repository.py @@ -78,6 +78,49 @@ def test_schedule_reads_are_account_scoped(session: Session) -> None: assert [snapshot.id for snapshot in account_schedules] == ["schedule-a"] +def test_schedule_candidates_coarsely_filter_once_rows_and_keep_recurring_series( + session: Session, +) -> None: + """Candidate SQL bounds one-time starts without dropping older recurring series.""" + repository = ScheduleRepository(session) + lower = datetime(2026, 8, 17, tzinfo=UTC) + upper = datetime(2026, 8, 18, tzinfo=UTC) + recurring = replace( + _schedule("recurring-old", "account-a"), + schedule_kind=ScheduleKind.RECURRING, + start_time=datetime(2026, 8, 3, 2, tzinfo=UTC), + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ) + once_inside = replace(_schedule("once-inside", "account-a"), start_time=lower) + once_at_exclusive_end = replace( + _schedule("once-at-end", "account-a"), + start_time=upper, + ) + deleted_inside = replace( + _schedule("deleted-inside", "account-a"), + start_time=lower, + status=ScheduleStatus.DELETED, + deleted_at=lower, + ) + other_account = replace(_schedule("other-account", "account-b"), start_time=lower) + for schedule in ( + recurring, + once_inside, + once_at_exclusive_end, + deleted_inside, + other_account, + ): + repository.add_schedule(schedule) + + candidates = repository.list_schedule_candidates( + account_id="account-a", + starts_at_or_after=lower, + starts_before=upper, + ) + + assert [schedule.id for schedule in candidates] == ["recurring-old", "once-inside"] + + def test_schedule_update_atomically_increments_revision(session: Session) -> None: """The database revision advances regardless of the caller snapshot value.""" repository = ScheduleRepository(session) From b1c11945277b52fedb144bc0af22de1e71fc529a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=83=A7=E6=B4=97=E5=8F=91=E7=94=A8=E9=A3=98?= =?UTF-8?q?=E6=9F=94?= <2906266135@qq.com> Date: Tue, 11 Aug 2026 18:27:01 +0800 Subject: [PATCH 4/5] fix(schedule): avoid duplicate replacement matches --- .../src/timeflow/business/calendar/service.py | 17 ----------------- .../tests/test_schedule_application_service.py | 8 ++++---- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/backend/src/timeflow/business/calendar/service.py b/backend/src/timeflow/business/calendar/service.py index 29e6b0c..4b3ed78 100644 --- a/backend/src/timeflow/business/calendar/service.py +++ b/backend/src/timeflow/business/calendar/service.py @@ -233,8 +233,6 @@ def find_schedules( schedule for schedule in candidates if _has_effective_occurrence_in_window( - unit_of_work.schedules, - account_id=account_id, schedule=schedule, query=query, overrides=tuple(overrides_by_schedule.get(schedule.id, ())), @@ -445,9 +443,7 @@ def _matches_static_query(schedule: ScheduleSnapshot, query: FindSchedulesQuery) def _has_effective_occurrence_in_window( - repository: ScheduleRepositoryPort, *, - account_id: str, schedule: ScheduleSnapshot, query: FindSchedulesQuery, overrides: tuple[ScheduleOccurrenceOverrideSnapshot, ...], @@ -456,19 +452,6 @@ def _has_effective_occurrence_in_window( return _start_is_in_window(schedule.start_time, query) excluded_starts = frozenset(override.occurrence_start for override in overrides) - for override in overrides: - if ( - override.action is not OccurrenceOverrideAction.REPLACE - or override.replacement_schedule_id is None - ): - continue - replacement = repository.get_schedule( - account_id=account_id, - schedule_id=override.replacement_schedule_id, - ) - if replacement is not None and _start_is_in_window(replacement.start_time, query): - return True - try: occurrence = first_occurrence_in_window( schedule, diff --git a/backend/tests/test_schedule_application_service.py b/backend/tests/test_schedule_application_service.py index 9748d69..2c2c789 100644 --- a/backend/tests/test_schedule_application_service.py +++ b/backend/tests/test_schedule_application_service.py @@ -689,8 +689,8 @@ def test_find_schedules_uses_same_day_replacement_effective_time() -> None: ), ) - assert recurring in effective_window.schedules - assert recurring not in original_window.schedules + assert effective_window.schedules == (replacement,) + assert original_window.schedules == () def test_find_schedules_includes_replacement_that_crosses_into_window() -> None: @@ -724,7 +724,7 @@ def test_find_schedules_includes_replacement_that_crosses_into_window() -> None: ), ) - assert recurring in result.schedules + assert result.schedules == (replacement,) def test_find_schedules_excludes_replacement_that_crosses_out_of_window() -> None: @@ -758,7 +758,7 @@ def test_find_schedules_excludes_replacement_that_crosses_out_of_window() -> Non ), ) - assert recurring not in result.schedules + assert result.schedules == () def test_find_schedules_preserves_dst_and_non_dst_wall_times() -> None: From 124841640d149a3226b30664f1aeaad589caa7ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=83=A7=E6=B4=97=E5=8F=91=E7=94=A8=E9=A3=98?= =?UTF-8?q?=E6=9F=94?= <2906266135@qq.com> Date: Wed, 12 Aug 2026 09:46:31 +0800 Subject: [PATCH 5/5] fix(schedule): retire replacements with recurring deletions --- .../timeflow/business/calendar/recurrence.py | 16 +- .../src/timeflow/business/calendar/service.py | 83 ++++- .../test_postgres_schedule_repository.py | 79 +++++ .../test_schedule_application_service.py | 313 +++++++++++++++++- backend/tests/test_schedule_recurrence.py | 41 +++ 5 files changed, 525 insertions(+), 7 deletions(-) diff --git a/backend/src/timeflow/business/calendar/recurrence.py b/backend/src/timeflow/business/calendar/recurrence.py index 9618cd5..cc74bf7 100644 --- a/backend/src/timeflow/business/calendar/recurrence.py +++ b/backend/src/timeflow/business/calendar/recurrence.py @@ -6,6 +6,7 @@ from dateutil.rrule import rrule, rrulebase, rrulestr from timeflow.business.calendar.contracts import ( + OccurrenceOverrideAction, ScheduleOccurrenceOverrideSnapshot, ScheduleSnapshot, ) @@ -66,7 +67,12 @@ def first_active_occurrence_on_or_after_local_date( now: datetime, overrides: tuple[ScheduleOccurrenceOverrideSnapshot, ...], ) -> datetime | None: - """Return the first non-overridden occurrence on/after today's local date.""" + """Return the first non-cancelled original occurrence from today's local date. + + A replaced occurrence still belongs to the recurring series at its original + start. Deletion scope must therefore be able to select it even though normal + calendar expansion displays its replacement schedule instead. + """ if schedule.start_time is None or schedule.recurrence_rule is None: return None timezone = get_schedule_timezone(schedule.timezone) @@ -74,9 +80,13 @@ def first_active_occurrence_on_or_after_local_date( local_date = now.astimezone(timezone).date() boundary = datetime.combine(local_date, time.min, tzinfo=timezone) rule = parse_recurrence_rule(schedule.recurrence_rule, start_time=local_start) - overridden = {override.occurrence_start for override in overrides} + cancelled = { + override.occurrence_start + for override in overrides + if override.action is OccurrenceOverrideAction.CANCEL + } occurrence = rule.after(boundary, inc=True) - while occurrence is not None and occurrence in overridden: + while occurrence is not None and occurrence in cancelled: occurrence = rule.after(occurrence, inc=False) return None if occurrence is None else occurrence.astimezone(UTC) diff --git a/backend/src/timeflow/business/calendar/service.py b/backend/src/timeflow/business/calendar/service.py index 4b3ed78..3910b9a 100644 --- a/backend/src/timeflow/business/calendar/service.py +++ b/backend/src/timeflow/business/calendar/service.py @@ -328,13 +328,23 @@ def delete_recurring_schedule( ) if command.scope is RecurringDeleteScope.ENTIRE_SERIES: - persisted = _soft_delete( + overrides = unit_of_work.schedules.list_occurrence_overrides( + account_id=account_id, + schedule_id=current.id, + ) + persisted_parent = _soft_delete( unit_of_work.schedules, current, expected_revision=command.expected_revision, now=now, ) - result = ScheduleMutationResult(schedules=(persisted,)) + deleted_replacements = _soft_delete_replacements( + unit_of_work.schedules, + account_id=account_id, + overrides=overrides, + now=now, + ) + result = ScheduleMutationResult(schedules=(persisted_parent, *deleted_replacements)) else: result = self._delete_recurring_range( unit_of_work.schedules, @@ -387,6 +397,20 @@ def _delete_recurring_range( replace(current, updated_at=now), expected_revision=command.expected_revision, ) + matching_replacements = tuple( + override + for override in overrides + if override.occurrence_start == occurrence + and override.action is OccurrenceOverrideAction.REPLACE + ) + if matching_replacements: + deleted_replacements = _soft_delete_replacements( + repository, + account_id=account_id, + overrides=matching_replacements, + now=now, + ) + return ScheduleMutationResult(schedules=(updated_schedule, *deleted_replacements)) override = ScheduleOccurrenceOverrideSnapshot( id=_new_id(self._id_factory, field="occurrence_override_id"), schedule_id=current.id, @@ -430,7 +454,14 @@ def _delete_recurring_range( candidate, expected_revision=command.expected_revision, ) - return ScheduleMutationResult(schedules=(persisted,)) + deleted_replacements = _soft_delete_replacements( + repository, + account_id=account_id, + overrides=overrides, + now=now, + occurrence_start_at_or_after=occurrence, + ) + return ScheduleMutationResult(schedules=(persisted, *deleted_replacements)) def _matches_static_query(schedule: ScheduleSnapshot, query: FindSchedulesQuery) -> bool: @@ -518,6 +549,52 @@ def _soft_delete( return _persist_update(repository, candidate, expected_revision=expected_revision) +def _soft_delete_replacements( + repository: ScheduleRepositoryPort, + *, + account_id: str, + overrides: tuple[ScheduleOccurrenceOverrideSnapshot, ...], + now: datetime, + occurrence_start_at_or_after: datetime | None = None, +) -> tuple[ScheduleSnapshot, ...]: + """Soft-delete active replacement schedules selected by original occurrence. + + Each replacement uses its own current revision through the ordinary + Repository update path. The caller owns the surrounding unit of work, so a + conflict on any replacement rolls back the parent and every earlier write. + """ + deleted: list[ScheduleSnapshot] = [] + handled_schedule_ids: set[str] = set() + for override in overrides: + replacement_id = override.replacement_schedule_id + if ( + override.action is not OccurrenceOverrideAction.REPLACE + or replacement_id is None + or replacement_id in handled_schedule_ids + or ( + occurrence_start_at_or_after is not None + and override.occurrence_start < occurrence_start_at_or_after + ) + ): + continue + handled_schedule_ids.add(replacement_id) + replacement = repository.get_schedule( + account_id=account_id, + schedule_id=replacement_id, + ) + if replacement is None: + continue + deleted.append( + _soft_delete( + repository, + replacement, + expected_revision=replacement.revision, + now=now, + ) + ) + return tuple(deleted) + + def _require_active_schedule( repository: ScheduleRepositoryPort, *, diff --git a/backend/tests/test_postgres_schedule_repository.py b/backend/tests/test_postgres_schedule_repository.py index 1d23eda..89ee475 100644 --- a/backend/tests/test_postgres_schedule_repository.py +++ b/backend/tests/test_postgres_schedule_repository.py @@ -371,3 +371,82 @@ def test_postgres_application_service_atomically_cancels_current_occurrence( assert result.schedules[0].revision == 2 assert result.occurrence_overrides[0].action is OccurrenceOverrideAction.CANCEL assert result.occurrence_overrides[0].occurrence_start == datetime(2026, 8, 17, 2, tzinfo=UTC) + + +def test_postgres_entire_series_atomically_soft_deletes_replacement( + postgres_connection: Connection, +) -> None: + """The parent and its replacement commit as one PostgreSQL mutation.""" + account_id = "account-service-replacement-delete" + now = datetime(2026, 8, 11, 1, tzinfo=UTC) + service = _application_service( + postgres_connection, + account_id=account_id, + ids=iter(("recurring-with-replacement", "replacement-once")), + now=now, + ) + recurring = service.create_schedule( + account_id=account_id, + command=CreateScheduleCommand( + schedule_type=ScheduleType.TIME, + schedule_kind=ScheduleKind.RECURRING, + title="Weekly sync", + timezone="Asia/Shanghai", + start_time=datetime(2026, 8, 3, 2, tzinfo=UTC), + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ), + ).schedules[0] + replacement = service.create_schedule( + account_id=account_id, + command=CreateScheduleCommand( + schedule_type=ScheduleType.TIME, + schedule_kind=ScheduleKind.ONCE, + title="Moved sync", + timezone="Asia/Shanghai", + start_time=datetime(2026, 8, 17, 6, tzinfo=UTC), + ), + ).schedules[0] + with Session( + bind=postgres_connection, + join_transaction_mode="create_savepoint", + ) as session: + repository = ScheduleRepository(session) + persisted_override = repository.add_occurrence_override( + account_id=account_id, + snapshot=ScheduleOccurrenceOverrideSnapshot( + id="replace-override", + schedule_id=recurring.id, + occurrence_start=datetime(2026, 8, 17, 2, tzinfo=UTC), + action=OccurrenceOverrideAction.REPLACE, + replacement_schedule_id=replacement.id, + created_at=now, + updated_at=now, + ), + ) + assert persisted_override is not None + session.commit() + + result = service.delete_recurring_schedule( + account_id=account_id, + command=DeleteRecurringScheduleCommand( + recurring.id, + recurring.revision, + RecurringDeleteScope.ENTIRE_SERIES, + ), + ) + + assert [schedule.id for schedule in result.schedules] == [ + recurring.id, + replacement.id, + ] + assert all(schedule.status is ScheduleStatus.DELETED for schedule in result.schedules) + assert all(schedule.revision == 2 for schedule in result.schedules) + for schedule in result.schedules: + found = service.find_schedules( + account_id=account_id, + query=FindSchedulesQuery( + schedule_id=schedule.id, + include_deleted=True, + ), + ) + assert found.schedules == (schedule,) diff --git a/backend/tests/test_schedule_application_service.py b/backend/tests/test_schedule_application_service.py index 2c2c789..a6a7f75 100644 --- a/backend/tests/test_schedule_application_service.py +++ b/backend/tests/test_schedule_application_service.py @@ -298,6 +298,34 @@ def _add_override( ) +def _add_replacement( + service: ScheduleApplicationService, + store: _Store, + *, + parent_id: str, + override_id: str, + occurrence_start: datetime, + replacement_start: datetime, + account_id: str = "account-a", +) -> ScheduleSnapshot: + replacement = service.create_schedule( + account_id=account_id, + command=_time_command( + title=f"Replacement {override_id}", + start_time=replacement_start, + ), + ).schedules[0] + _add_override( + store, + override_id=override_id, + schedule_id=parent_id, + occurrence_start=occurrence_start, + action=OccurrenceOverrideAction.REPLACE, + replacement_schedule_id=replacement.id, + ) + return replacement + + def test_create_schedule_returns_the_committed_cloud_snapshot() -> None: service, store = _service() @@ -955,7 +983,7 @@ def test_delete_once_is_soft_account_scoped_and_kind_checked() -> None: assert store.schedules[ordinary.id] == deleted -def test_delete_this_occurrence_uses_current_schedule_timezone_and_skips_overrides() -> None: +def test_delete_this_occurrence_uses_current_schedule_timezone_and_skips_cancellations() -> None: # It is still August 10 in UTC, but already August 11 in Asia/Shanghai. # The August 10 occurrence must therefore be treated as yesterday. service, store = _service(now=datetime(2026, 8, 10, 17, tzinfo=UTC)) @@ -993,6 +1021,53 @@ def test_delete_this_occurrence_uses_current_schedule_timezone_and_skips_overrid assert len(store.overrides) == 2 +def test_delete_this_occurrence_soft_deletes_its_existing_replacement() -> None: + service, store = _service(now=datetime(2026, 8, 23, 16, tzinfo=UTC)) + recurring = service.create_schedule( + account_id="account-a", + command=_time_command( + start_time=datetime(2026, 8, 3, 2, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ), + ).schedules[0] + occurrence = datetime(2026, 8, 24, 2, tzinfo=UTC) + replacement = _add_replacement( + service, + store, + parent_id=recurring.id, + override_id="replace-august-24", + occurrence_start=occurrence, + replacement_start=datetime(2026, 8, 24, 6, tzinfo=UTC), + ) + + result = service.delete_recurring_schedule( + account_id="account-a", + command=DeleteRecurringScheduleCommand( + recurring.id, + recurring.revision, + RecurringDeleteScope.THIS_OCCURRENCE, + ), + ) + + persisted_parent, persisted_replacement = result.schedules + assert persisted_parent.revision == 2 + assert persisted_parent.status is ScheduleStatus.ACTIVE + assert persisted_replacement.id == replacement.id + assert persisted_replacement.status is ScheduleStatus.DELETED + assert persisted_replacement.revision == 2 + assert result.occurrence_overrides == () + assert store.overrides["replace-august-24"].action is OccurrenceOverrideAction.REPLACE + found = service.find_schedules( + account_id="account-a", + query=FindSchedulesQuery( + starts_at_or_after=datetime(2026, 8, 24, tzinfo=UTC), + starts_before=datetime(2026, 8, 25, tzinfo=UTC), + ), + ) + assert found.schedules == () + + def test_delete_this_occurrence_keeps_new_york_wall_time_after_dst() -> None: service, _ = _service(now=datetime(2026, 3, 8, 16, tzinfo=UTC)) recurring = service.create_schedule( @@ -1071,6 +1146,72 @@ def test_delete_this_and_future_truncates_after_last_retained_occurrence() -> No assert updated.recurrence_rule == "FREQ=WEEKLY;BYDAY=MO;UNTIL=20260810T020000Z" +def test_delete_this_and_future_uses_original_occurrence_for_replacement_ownership() -> None: + service, store = _service(now=NOW) + recurring = service.create_schedule( + account_id="account-a", + command=_time_command( + start_time=datetime(2026, 8, 3, 2, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO;COUNT=20", + ), + ).schedules[0] + past_moved_future = _add_replacement( + service, + store, + parent_id=recurring.id, + override_id="replace-august-10", + occurrence_start=datetime(2026, 8, 10, 2, tzinfo=UTC), + replacement_start=datetime(2026, 8, 24, 6, tzinfo=UTC), + ) + future_moved_past = _add_replacement( + service, + store, + parent_id=recurring.id, + override_id="replace-august-24", + occurrence_start=datetime(2026, 8, 24, 2, tzinfo=UTC), + replacement_start=datetime(2026, 8, 15, 6, tzinfo=UTC), + ) + future_replacement = _add_replacement( + service, + store, + parent_id=recurring.id, + override_id="replace-august-31", + occurrence_start=datetime(2026, 8, 31, 2, tzinfo=UTC), + replacement_start=datetime(2026, 8, 31, 6, tzinfo=UTC), + ) + + result = service.delete_recurring_schedule( + account_id="account-a", + command=DeleteRecurringScheduleCommand( + recurring.id, + recurring.revision, + RecurringDeleteScope.THIS_AND_FUTURE, + ), + ) + + assert result.schedules[0].recurrence_rule == ("FREQ=WEEKLY;BYDAY=MO;UNTIL=20260810T020000Z") + assert {schedule.id for schedule in result.schedules[1:]} == { + future_moved_past.id, + future_replacement.id, + } + assert store.schedules[past_moved_future.id].status is ScheduleStatus.ACTIVE + assert store.schedules[past_moved_future.id].revision == 1 + assert store.schedules[future_moved_past.id].status is ScheduleStatus.DELETED + assert store.schedules[future_moved_past.id].revision == 2 + assert store.schedules[future_replacement.id].status is ScheduleStatus.DELETED + assert store.schedules[future_replacement.id].revision == 2 + + found = service.find_schedules( + account_id="account-a", + query=FindSchedulesQuery( + starts_at_or_after=datetime(2026, 8, 24, tzinfo=UTC), + starts_before=datetime(2026, 8, 25, tzinfo=UTC), + ), + ) + assert found.schedules == (store.schedules[past_moved_future.id],) + + def test_delete_this_and_future_on_first_occurrence_deletes_entire_series() -> None: service, _ = _service(now=NOW) recurring = service.create_schedule( @@ -1137,3 +1278,173 @@ def test_delete_recurring_entire_series_and_missing_future_occurrence() -> None: assert store.schedules[past.id].revision == 1 assert deleted.status is ScheduleStatus.DELETED assert deleted.revision == 2 + + +def test_delete_entire_series_soft_deletes_all_replacements_but_keeps_overrides() -> None: + service, store = _service(now=NOW) + recurring = service.create_schedule( + account_id="account-a", + command=_time_command( + start_time=datetime(2026, 8, 3, 2, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ), + ).schedules[0] + replacements = ( + _add_replacement( + service, + store, + parent_id=recurring.id, + override_id="replace-august-10", + occurrence_start=datetime(2026, 8, 10, 2, tzinfo=UTC), + replacement_start=datetime(2026, 8, 10, 6, tzinfo=UTC), + ), + _add_replacement( + service, + store, + parent_id=recurring.id, + override_id="replace-august-24", + occurrence_start=datetime(2026, 8, 24, 2, tzinfo=UTC), + replacement_start=datetime(2026, 8, 24, 6, tzinfo=UTC), + ), + ) + _add_override( + store, + override_id="cancel-august-17", + schedule_id=recurring.id, + occurrence_start=datetime(2026, 8, 17, 2, tzinfo=UTC), + action=OccurrenceOverrideAction.CANCEL, + ) + + result = service.delete_recurring_schedule( + account_id="account-a", + command=DeleteRecurringScheduleCommand( + recurring.id, + recurring.revision, + RecurringDeleteScope.ENTIRE_SERIES, + ), + ) + + assert result.schedules[0].id == recurring.id + assert result.schedules[0].status is ScheduleStatus.DELETED + assert {schedule.id for schedule in result.schedules[1:]} == { + replacement.id for replacement in replacements + } + assert all( + store.schedules[replacement.id].status is ScheduleStatus.DELETED + for replacement in replacements + ) + assert all(store.schedules[replacement.id].revision == 2 for replacement in replacements) + assert set(store.overrides) == { + "replace-august-10", + "cancel-august-17", + "replace-august-24", + } + assert result.occurrence_overrides == () + + +def test_delete_entire_series_does_not_cross_account_for_replacement() -> None: + service, store = _service(now=NOW) + recurring = service.create_schedule( + account_id="account-a", + command=_time_command( + start_time=datetime(2026, 8, 3, 2, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ), + ).schedules[0] + other_account_replacement = _add_replacement( + service, + store, + parent_id=recurring.id, + override_id="inconsistent-cross-account-replacement", + occurrence_start=datetime(2026, 8, 17, 2, tzinfo=UTC), + replacement_start=datetime(2026, 8, 17, 6, tzinfo=UTC), + account_id="account-b", + ) + + result = service.delete_recurring_schedule( + account_id="account-a", + command=DeleteRecurringScheduleCommand( + recurring.id, + recurring.revision, + RecurringDeleteScope.ENTIRE_SERIES, + ), + ) + + assert result.schedules == (store.schedules[recurring.id],) + assert store.schedules[other_account_replacement.id].status is ScheduleStatus.ACTIVE + assert store.schedules[other_account_replacement.id].revision == 1 + + +def test_replacement_revision_conflict_rolls_back_parent_and_other_replacements() -> None: + service, store = _service(now=NOW) + recurring = service.create_schedule( + account_id="account-a", + command=_time_command( + start_time=datetime(2026, 8, 3, 2, tzinfo=UTC), + schedule_kind=ScheduleKind.RECURRING, + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + ), + ).schedules[0] + first_replacement = _add_replacement( + service, + store, + parent_id=recurring.id, + override_id="replace-august-17", + occurrence_start=datetime(2026, 8, 17, 2, tzinfo=UTC), + replacement_start=datetime(2026, 8, 17, 6, tzinfo=UTC), + ) + conflicting_replacement = _add_replacement( + service, + store, + parent_id=recurring.id, + override_id="replace-august-24", + occurrence_start=datetime(2026, 8, 24, 2, tzinfo=UTC), + replacement_start=datetime(2026, 8, 24, 6, tzinfo=UTC), + ) + + class _ConflictRepository(_Repository): + def update_schedule( + self, + *, + snapshot: ScheduleSnapshot, + expected_revision: int, + ) -> ScheduleSnapshot | None: + if snapshot.id == conflicting_replacement.id: + raise ScheduleRevisionConflictError( + schedule_id=snapshot.id, + expected_revision=expected_revision, + actual_revision=expected_revision + 1, + ) + return super().update_schedule( + snapshot=snapshot, + expected_revision=expected_revision, + ) + + class _ConflictUnitOfWork(_UnitOfWork): + def __init__(self, committed: _Store) -> None: + super().__init__(committed) + self.schedules = _ConflictRepository(self._working) + + deleting_service = ScheduleApplicationService( + lambda: _ConflictUnitOfWork(store), + clock=lambda: NOW, + id_factory=lambda: "unused-id", + ) + + _assert_error( + ScheduleErrorCode.REVISION_CONFLICT, + lambda: deleting_service.delete_recurring_schedule( + account_id="account-a", + command=DeleteRecurringScheduleCommand( + recurring.id, + recurring.revision, + RecurringDeleteScope.ENTIRE_SERIES, + ), + ), + ) + + assert store.schedules[recurring.id] == recurring + assert store.schedules[first_replacement.id] == first_replacement + assert store.schedules[conflicting_replacement.id] == conflicting_replacement diff --git a/backend/tests/test_schedule_recurrence.py b/backend/tests/test_schedule_recurrence.py index aec71aa..c096ddd 100644 --- a/backend/tests/test_schedule_recurrence.py +++ b/backend/tests/test_schedule_recurrence.py @@ -6,7 +6,9 @@ import pytest from timeflow.business.calendar import ( + OccurrenceOverrideAction, ScheduleKind, + ScheduleOccurrenceOverrideSnapshot, ScheduleSnapshot, ScheduleStatus, ScheduleType, @@ -146,6 +148,45 @@ def test_shanghai_occurrence_keeps_its_non_dst_wall_time() -> None: assert occurrence.astimezone(timezone).hour == 10 +def test_delete_selection_keeps_replaced_original_but_skips_cancelled_occurrence() -> None: + schedule = _recurring_schedule( + timezone="Asia/Shanghai", + start_time=datetime(2026, 8, 3, 2, tzinfo=UTC), + ) + now = datetime(2026, 8, 11, 1, tzinfo=UTC) + replaced = ScheduleOccurrenceOverrideSnapshot( + id="replace-august-17", + schedule_id=schedule.id, + occurrence_start=datetime(2026, 8, 17, 2, tzinfo=UTC), + action=OccurrenceOverrideAction.REPLACE, + replacement_schedule_id="replacement-august-17", + created_at=now, + updated_at=now, + ) + cancelled = ScheduleOccurrenceOverrideSnapshot( + id="cancel-august-17", + schedule_id=schedule.id, + occurrence_start=datetime(2026, 8, 17, 2, tzinfo=UTC), + action=OccurrenceOverrideAction.CANCEL, + created_at=now, + updated_at=now, + ) + + replaced_occurrence = first_active_occurrence_on_or_after_local_date( + schedule, + now=now, + overrides=(replaced,), + ) + after_cancel = first_active_occurrence_on_or_after_local_date( + schedule, + now=now, + overrides=(cancelled,), + ) + + assert replaced_occurrence == datetime(2026, 8, 17, 2, tzinfo=UTC) + assert after_cancel == datetime(2026, 8, 24, 2, tzinfo=UTC) + + def test_occurrence_window_is_lower_inclusive_and_upper_exclusive() -> None: schedule = _recurring_schedule( timezone="Asia/Shanghai",