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..62bec1d --- /dev/null +++ b/backend/src/timeflow/business/calendar/ports.py @@ -0,0 +1,111 @@ +"""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 + +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 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, + *, + 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..9618cd5 --- /dev/null +++ b/backend/src/timeflow/business/calendar/recurrence.py @@ -0,0 +1,160 @@ +"""RRULE validation and occurrence selection for schedule use cases.""" + +from datetime import UTC, datetime, time +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from dateutil.rrule import rrule, rrulebase, rrulestr + +from timeflow.business.calendar.contracts import ( + ScheduleOccurrenceOverrideSnapshot, + ScheduleSnapshot, +) + + +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.""" + normalized = normalize_recurrence_rule(rule) + try: + 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 + 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 + 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=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 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, +) -> 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 + 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 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=local_start) + return truncated + + +__all__ = [ + "InvalidRecurrenceRuleError", + "InvalidTimezoneKeyError", + "first_active_occurrence_on_or_after_local_date", + "first_occurrence_in_window", + "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 e111ca1..4b3ed78 100644 --- a/backend/src/timeflow/business/calendar/service.py +++ b/backend/src/timeflow/business/calendar/service.py @@ -1,23 +1,55 @@ -"""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 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, + InvalidTimezoneKeyError, + first_active_occurrence_on_or_after_local_date, + first_occurrence_in_window, + get_schedule_timezone, + normalize_recurrence_rule, + 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 +64,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 +78,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 +92,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 +106,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 +120,712 @@ 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=_normalize_optional_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) + 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: + 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, + schedule_id=query.schedule_id, + include_deleted=query.include_deleted, + ) + candidates = () if match is None else (match,) + candidates = tuple( + schedule for schedule in candidates if _matches_static_query(schedule, query) + ) + 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( + schedule=schedule, + query=query, + overrides=tuple(overrides_by_schedule.get(schedule.id, ())), + ) + ) + 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) + 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, + 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: + _validated_timezone(current.timezone) + 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 _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( + *, + 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) + 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, + *, + 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) + timezone = _validated_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: + 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: + _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.astimezone(timezone), + ) + 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 _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: + _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 _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, + 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..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 ( @@ -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. @@ -90,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/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..1d23eda 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 @@ -116,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: @@ -213,3 +262,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..2c2c789 --- /dev/null +++ b/backend/tests/test_schedule_application_service.py @@ -0,0 +1,1139 @@ +"""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 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, + *, + 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 _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], +) -> ScheduleBusinessError: + with pytest.raises(ScheduleBusinessError) as raised: + operation() + assert raised.value.code is expected + 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() + + 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(), timezone="../America/New_York"), + 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 + + +@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() + + 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( + 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_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 effective_window.schedules == (replacement,) + assert 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 result.schedules == (replacement,) + + +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 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] + 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_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] + + _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_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] + 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_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( + 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_recurrence.py b/backend/tests/test_schedule_recurrence.py new file mode 100644 index 0000000..aec71aa --- /dev/null +++ b/backend/tests/test_schedule_recurrence.py @@ -0,0 +1,186 @@ +"""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, + first_occurrence_in_window, + 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 + + +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) 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"