diff --git a/CHANGELOG.md b/CHANGELOG.md index 72c0811..a611a12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# Changelog + +## 0.5.0 + +- Added external BusyPeriod conflicts for calendar/application-provided busy intervals. +- Added machine-readable AvailabilityResult diagnostic codes. +- Added idempotency keys for retried reservations. +- Prevented direct non-pool reservations from bypassing active holds. +- Added database-backed BookingStore guidance and framework integration patterns. +- Added performance benchmark guidance. +- Expanded documentation for required multi-resource availability. + +## 0.4.0 + +- Added temporary booking holds and hold storage abstractions. +- Added first-available, round-robin, and least-loaded resource allocation. +- Added hold and resource-pool regression coverage. +- Added runnable examples for holds and resource pools. + ## 0.2.0 - Added recurring weekly appointment series with rollback on failed occurrence. diff --git a/README.md b/README.md index acefaba..e1e65be 100644 --- a/README.md +++ b/README.md @@ -434,7 +434,7 @@ RecurrenceRule( ## Project status -Current version: **0.3.0** +Current version: **0.5.0** Slotify is currently in alpha. The API may evolve before 1.0.0, so pin the version in production applications and review the changelog when upgrading. @@ -445,3 +445,28 @@ MIT License ## Author Kalash Gulati + + +### External calendar conflicts + +Normalize conflicts from an external calendar in your application and pass them to BookingPolicy: + +~~~python +from slotify import BookingPolicy, BusyPeriod + +policy = BookingPolicy( + busy_periods=( + BusyPeriod(start=calendar_start, end=calendar_end, reason="External event"), + ), +) +~~~ + +### Idempotent API retries + +Use an idempotency key for requests that may be retried: + +~~~python +booking = engine.reserve(slot, idempotency_key=request_id) +~~~ + +A production BookingStore should enforce the same key atomically in its database. diff --git a/ROADMAP.md b/ROADMAP.md index e558e18..bf6f68f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,21 +21,30 @@ Slotify is currently in alpha. This roadmap is a guide rather than a promise; pr - Recurrence exclusions - Expanded tests and documentation +## v0.5.0 completed + +- External busy-period conflicts +- Machine-readable availability diagnostics +- Idempotent reservation keys +- Stronger hold enforcement on direct reservations +- Database-backed store guidance +- Django, FastAPI, and Flask integration patterns +- Availability performance benchmark guidance +- Required multi-resource availability documentation + ## Current focus - Keep scheduling behavior predictable and timezone-safe. -- Expand DST and recurrence edge-case coverage. -- Improve database-backed BookingStore examples. -- Add performance benchmarks for large availability ranges. +- Expand DST, recurrence, concurrency, and store-contract coverage. - Collect real-world scheduling use cases and bug reports. +- Measure performance with realistic workloads before optimizing. ## Exploring -- PostgreSQL/SQLAlchemy BookingStore examples -- Calendar conflict adapters -- Additional framework examples +- Reference PostgreSQL store implementations +- Calendar provider adapters maintained outside the core package +- Additional resource routing strategies only when real use cases require them - Performance improvements for large resource pools -- More advanced routing and resource-selection strategies ## Scope diff --git a/docs/api.md b/docs/api.md index 4673684..353313a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -281,6 +281,53 @@ RecurrenceRule( weekdays is valid for weekly recurrence. day_of_month is used for monthly/yearly recurrence. When a monthly day does not exist in a month, the last valid day of that month is used. count counts emitted occurrences, so excluded dates do not consume the count. +## v0.5.0 external busy periods + +Use BusyPeriod for conflicts supplied by another calendar or application: + +~~~python +from slotify import BookingPolicy, BusyPeriod + +policy = BookingPolicy( + busy_periods=( + BusyPeriod( + start=calendar_start, + end=calendar_end, + reason="External calendar event", + ), + ), +) +~~~ + +Busy periods are evaluated alongside Slotify's own booking rules. + +## v0.5.0 availability diagnostics + +AvailabilityResult includes a machine-readable code in addition to the human-readable reason. + +Common codes include: + +~~~text +available +policy +hold_conflict +capacity_conflict +resource_unavailable +~~~ + +## v0.5.0 idempotent reservations + +Pass an idempotency key when an API request may be retried: + +~~~python +booking = engine.reserve( + slot, + idempotency_key="payment-attempt-123", +) +~~~ + +A store should enforce idempotency atomically in production. + ## v0.4.0 holds Temporary checkout holds are represented by BookingHold. diff --git a/docs/benchmarks.md b/docs/benchmarks.md new file mode 100644 index 0000000..c324283 --- /dev/null +++ b/docs/benchmarks.md @@ -0,0 +1,38 @@ +# Performance Benchmarks + +Slotify should be measured with the scheduling configuration your application actually uses. + +A useful benchmark should vary: + +- number of days scanned +- slots per day +- number of resources +- number of existing bookings +- number of holds +- timezone/DST complexity + +## Simple local benchmark + +The following measures a repeated availability query without adding a benchmark dependency: + +~~~python +from time import perf_counter +from slotify import AvailabilityEngine, SlotGenerator + +generator = SlotGenerator( + start="09:00", + end="17:00", + duration=30, + timezone="Asia/Kolkata", +) +engine = AvailabilityEngine(generator) + +start = perf_counter() +for _ in range(100): + engine.available_slots("2026-10-05", "2026-10-30") +elapsed = perf_counter() - start + +print(f"{elapsed:.4f}s for 100 queries") +~~~ + +Treat this as a smoke benchmark, not a universal performance claim. Production performance depends heavily on the application store, resource count, database, and workload. diff --git a/docs/database-stores.md b/docs/database-stores.md new file mode 100644 index 0000000..98ef5c9 --- /dev/null +++ b/docs/database-stores.md @@ -0,0 +1,60 @@ +# Database-backed Stores + +Slotify deliberately does not require Django, SQLAlchemy, or a database. The core package defines the BookingStore protocol so your application can choose its persistence layer. + +## Production rule + +The final reservation must be atomic in your database. + +For PostgreSQL, a production store normally uses a transaction plus database-level protection for the booking/resource/time interval. The exact implementation depends on your schema and whether you use Django ORM, SQLAlchemy, or another database layer. + +Your store should implement: + +~~~python +class MyBookingStore: + def reserve(self, booking): + # transaction + database conflict protection + ... +~~~ + +## Django + +Keep Slotify objects at the service boundary: + +~~~python +def reserve_slot(provider, slot): + engine = get_provider_engine(provider) + return engine.reserve(slot, idempotency_key="checkout-123") +~~~ + +Persist the returned Booking in your Django model inside the same application-level transaction strategy used by your store. + +## SQLAlchemy + +The same design works with SQLAlchemy: + +~~~python +class SQLAlchemyBookingStore: + def reserve(self, booking): + # use Session transaction and database constraints + ... +~~~ + +Slotify does not import SQLAlchemy, so applications that do not use it pay no dependency cost. + +## Idempotency + +Use an idempotency key for retried API requests: + +~~~python +booking = engine.reserve( + slot, + idempotency_key=request_id, +) +~~~ + +The built-in in-memory store remembers the key. A database-backed store should also enforce uniqueness atomically so concurrent requests with the same key cannot create duplicates. + +## Holds + +The same production rule applies to HoldStore. A checkout hold and its final confirmation need shared durable storage and concurrency protection when multiple application workers can handle the same customer. diff --git a/docs/framework-examples.md b/docs/framework-examples.md new file mode 100644 index 0000000..1d3c7c6 --- /dev/null +++ b/docs/framework-examples.md @@ -0,0 +1,45 @@ +# Framework Examples + +Slotify is framework-independent. These patterns show where it fits. + +## Django / DRF + +Use Slotify in a service layer and return serialized Slot or Booking values from your API. + +~~~python +def available_slots(provider, date): + engine = get_provider_engine(provider) + return engine.available_slots(date) +~~~ + +See the Django integration guide for a complete example. + +## FastAPI + +The same service can be called from a FastAPI route: + +~~~python +from fastapi import FastAPI + +app = FastAPI() + +@app.get("/providers/{provider_id}/slots") +def slots(provider_id: str, date: str): + engine = get_provider_engine(provider_id) + return [slot.to_dict() for slot in engine.available_slots(date)] +~~~ + +FastAPI owns HTTP, authentication, dependency injection, and persistence. Slotify owns scheduling rules. + +## Flask + +A Flask route can use the same service: + +~~~python +@app.get("/providers//slots") +def slots(provider_id): + engine = get_provider_engine(provider_id) + return {"slots": [slot.to_dict() for slot in engine.available_slots(request.args["date"])]} +~~~ + +No framework integration package is required. diff --git a/docs/resources.md b/docs/resources.md index 6efb3c6..3404c5a 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -61,3 +61,17 @@ These strategies are allocation helpers. Your application should still filter re Resource selection does not replace concurrency control. Two requests can observe the same resource as available. The final reservation in your BookingStore must be atomic. + + +## Required resources + +For appointments that require multiple resources together, use resource_ids: + +~~~python +AvailabilityEngine( + generator, + resource_ids=("doctor-1", "room-4"), +) +~~~ + +A booking protects all listed resources. Another booking conflicts when its protected interval overlaps and it shares at least one required resource. This provides the core availability intersection needed for provider + room + equipment style appointments. diff --git a/docs/testing.md b/docs/testing.md index 271a494..a3df99e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -16,7 +16,7 @@ python -c "import slotify; print(slotify.__version__)" Expected for this documentation release: ~~~text -0.2.0 +0.5.0 ~~~ ## Run your tests @@ -181,7 +181,7 @@ Slotify is currently in alpha. For production, pin the version that you have tested: ~~~text -slotify-scheduling==0.2.0 +slotify-scheduling==0.5.0 ~~~ Review the changelog before upgrading. @@ -208,3 +208,16 @@ Before deploying a system built with Slotify, verify: [ ] payment and notification logic is outside Slotify [ ] package version is pinned ~~~ + + +## External calendar conflicts + +Test BusyPeriod values from Google Calendar, Outlook, your own calendar service, or another source as ordinary scheduling conflicts. Keep the external adapter in your application and pass normalized BusyPeriod objects into Slotify. + +## Idempotency + +For retried HTTP/payment requests, test that the same idempotency key returns the same Booking and that different keys still obey normal conflict rules. + +## Availability diagnostics + +Use AvailabilityResult.code for API-level error handling and AvailabilityResult.reason for logs or user-facing explanations. diff --git a/mkdocs.yml b/mkdocs.yml index 6e579bd..40bbefe 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -39,11 +39,14 @@ nav: - Examples: examples.md - Integrations: - Django / DRF: django.md + - Framework Examples: framework-examples.md - Reference: - API Guide: api.md - Timezones & DST: timezones.md - Production: - Testing & Production: testing.md + - Database-backed Stores: database-stores.md + - Performance Benchmarks: benchmarks.md - FAQ: faq.md - Changelog: changelog.md - Migrating from v0.2/v0.3: migration.md diff --git a/pyproject.toml b/pyproject.toml index 3dafd95..09850e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "slotify-scheduling" -version = "0.4.0" +version = "0.5.0" description = "Timezone-aware Python scheduling and appointment slot engine for availability and booking systems." readme = "README.md" requires-python = ">=3.10" diff --git a/src/slotify/__init__.py b/src/slotify/__init__.py index f7b332b..de5e335 100644 --- a/src/slotify/__init__.py +++ b/src/slotify/__init__.py @@ -23,6 +23,7 @@ ) from .policy import ( BlockedPeriod, + BusyPeriod, BookingPolicy, ) from .recurrence import RecurrenceRule @@ -35,7 +36,7 @@ to_utc, ) -__version__ = "0.3.0" +__version__ = "0.5.0" __all__ = [ "SlotGenerator", "Slot", @@ -47,6 +48,7 @@ "InMemoryBookingStore", "BookingPolicy", "BlockedPeriod", + "BusyPeriod", "RecurrenceRule", "SlotifyError", "ConfigurationError", diff --git a/src/slotify/availability.py b/src/slotify/availability.py index 35447d9..a7c797f 100644 --- a/src/slotify/availability.py +++ b/src/slotify/availability.py @@ -23,6 +23,7 @@ class AvailabilityResult: available: bool reason: str | None = None resource_id: str | None = None + code: str | None = None class AvailabilityEngine: @@ -389,6 +390,7 @@ def _check_availability( slot=slot, available=False, reason=str(exc), + code="policy", ) if self.resource_pool: @@ -407,11 +409,13 @@ def _check_availability( slot=slot, available=True, resource_id=resource_id, + code="available", ) return AvailabilityResult( slot=slot, available=False, reason="No resource in the resource pool is available.", + code="resource_unavailable", ) candidate = self._candidate(slot) @@ -420,6 +424,7 @@ def _check_availability( slot=slot, available=False, reason="The requested slot is temporarily held.", + code="hold_conflict", ) if ( self.store.available_capacity( @@ -436,12 +441,14 @@ def _check_availability( if len(self.resource_ids) == 1 else self.resource_id ), + code="available", ) return AvailabilityResult( slot=slot, available=False, reason="The requested slot is at capacity or conflicts with an existing booking.", + code="capacity_conflict", ) def check_availability( @@ -712,6 +719,7 @@ def reserve( metadata=None, now: datetime | None = None, series_id: str | None = None, + idempotency_key: str | None = None, schedule_name: str | None = None, ) -> Booking: generator = self._generator_for(schedule_name) @@ -720,6 +728,15 @@ def reserve( self.policy.validate(slot, now=current) self._validate_limits(slot, now=current) + if idempotency_key is not None: + if not isinstance(idempotency_key, str) or not idempotency_key.strip(): + raise ValueError("idempotency_key must be a non-empty string or None.") + finder = getattr(self.store, "find_by_idempotency_key", None) + if finder is not None: + existing = finder(idempotency_key) + if existing is not None: + return existing + if self.resource_pool: last_error: BookingConflictError | None = None for resource_id in self._ordered_pool_resources( @@ -732,6 +749,21 @@ def reserve( metadata=metadata, series_id=series_id, ) + if idempotency_key is not None: + booking = Booking( + slot=booking.slot, + resource_id=booking.resource_id, + booking_id=booking.booking_id, + status=booking.status, + buffer_before=booking.buffer_before, + buffer_after=booking.buffer_after, + created_at=booking.created_at, + metadata=booking.metadata, + resource_ids=booking.resource_ids, + series_id=booking.series_id, + rescheduled_count=booking.rescheduled_count, + idempotency_key=idempotency_key, + ) try: if self.hold_store.conflicts(booking, now=current): last_error = BookingConflictError("The requested slot is temporarily held.") @@ -743,11 +775,36 @@ def reserve( "No resource in the resource pool is available." ) from last_error + if idempotency_key is not None: + if not isinstance(idempotency_key, str) or not idempotency_key.strip(): + raise ValueError("idempotency_key must be a non-empty string or None.") + finder = getattr(self.store, "find_by_idempotency_key", None) + if finder is not None: + existing = finder(idempotency_key) + if existing is not None: + return existing booking = self._booking_for( slot, metadata=metadata, series_id=series_id, ) + if idempotency_key is not None: + booking = Booking( + slot=booking.slot, + resource_id=booking.resource_id, + booking_id=booking.booking_id, + status=booking.status, + buffer_before=booking.buffer_before, + buffer_after=booking.buffer_after, + created_at=booking.created_at, + metadata=booking.metadata, + resource_ids=booking.resource_ids, + series_id=booking.series_id, + rescheduled_count=booking.rescheduled_count, + idempotency_key=idempotency_key, + ) + if self.hold_store.conflicts(booking, now=current): + raise BookingConflictError("The requested slot is temporarily held.") return self.store.reserve(booking) def reserve_first_available( diff --git a/src/slotify/booking.py b/src/slotify/booking.py index 9a7ea4e..188a093 100644 --- a/src/slotify/booking.py +++ b/src/slotify/booking.py @@ -41,6 +41,7 @@ class Booking: resource_ids: tuple[str, ...] = () series_id: str | None = None rescheduled_count: int = 0 + idempotency_key: str | None = None def __post_init__(self) -> None: if not self.booking_id: @@ -63,6 +64,11 @@ def __post_init__(self) -> None: ): raise ValueError("created_at must be timezone-aware.") + if self.idempotency_key is not None and ( + not isinstance(self.idempotency_key, str) or not self.idempotency_key.strip() + ): + raise ValueError("idempotency_key must be a non-empty string or None.") + if ( isinstance(self.rescheduled_count, bool) or not isinstance(self.rescheduled_count, int) @@ -149,6 +155,7 @@ def cancel(self) -> "Booking": resource_ids=self.resource_ids, series_id=self.series_id, rescheduled_count=self.rescheduled_count, + idempotency_key=self.idempotency_key, ) def reschedule(self, new_slot: Slot) -> "Booking": @@ -177,6 +184,7 @@ def to_dict(self) -> dict[str, Any]: "status": self.status, "series_id": self.series_id, "rescheduled_count": self.rescheduled_count, + "idempotency_key": self.idempotency_key, "slot": self.slot.to_dict(), "protected_start": self.protected_start.isoformat(), "protected_end": self.protected_end.isoformat(), @@ -234,6 +242,9 @@ def list( ) -> list[Booking]: ... + def find_by_idempotency_key(self, idempotency_key: str) -> Booking | None: + ... + def available_capacity( self, booking: Booking, @@ -408,6 +419,13 @@ def get(self, booking_id: str) -> Booking: return booking + def find_by_idempotency_key(self, idempotency_key: str) -> Booking | None: + with self._lock: + for booking in self._bookings.values(): + if booking.idempotency_key == idempotency_key and booking.status == "confirmed": + return booking + return None + def list( self, *, diff --git a/src/slotify/holds.py b/src/slotify/holds.py index 9566f4d..9122978 100644 --- a/src/slotify/holds.py +++ b/src/slotify/holds.py @@ -18,6 +18,7 @@ class BookingHold: slot: Slot expires_at: datetime resource_id: str | None = None + resource_ids: tuple[str, ...] = () hold_id: str = field(default_factory=lambda: str(uuid4())) metadata: Mapping[str, Any] = field(default_factory=dict) @@ -26,16 +27,18 @@ def __post_init__(self) -> None: raise ValueError("hold_id cannot be empty.") if self.expires_at.tzinfo is None or self.expires_at.utcoffset() is None: raise ValueError("expires_at must be timezone-aware.") + normalized = list(self.resource_ids) + if any(not isinstance(value, str) or not value.strip() for value in normalized): + raise ValueError("resource_ids must contain non-empty strings.") if self.resource_id is not None and ( not isinstance(self.resource_id, str) or not self.resource_id.strip() ): raise ValueError("resource_id must be a non-empty string or None.") + if self.resource_id is not None and self.resource_id not in normalized: + normalized.insert(0, self.resource_id) + object.__setattr__(self, "resource_ids", tuple(dict.fromkeys(normalized))) object.__setattr__(self, "metadata", dict(self.metadata)) - @property - def resource_ids(self) -> tuple[str | None, ...]: - return (self.resource_id,) - def is_active(self, now: datetime | None = None) -> bool: current = now or datetime.now(timezone.utc) if current.tzinfo is None or current.utcoffset() is None: @@ -78,7 +81,7 @@ def __init__(self) -> None: @staticmethod def _overlaps(hold: BookingHold, booking: Booking) -> bool: - hold_resources = set(hold.resource_ids) + hold_resources = set(hold.resource_ids or (hold.resource_id,)) booking_resources = set(booking.effective_resource_ids) if not hold_resources.intersection(booking_resources): return False diff --git a/src/slotify/policy.py b/src/slotify/policy.py index 607d1a2..b5e9a6a 100644 --- a/src/slotify/policy.py +++ b/src/slotify/policy.py @@ -32,6 +32,31 @@ def overlaps(self, slot: Slot) -> bool: return slot.start_utc < end and start < slot.end_utc +@dataclass(frozen=True, slots=True) +class BusyPeriod: + """External-calendar or application-provided busy interval.""" + + start: datetime + end: datetime + reason: str = "Busy" + + def __post_init__(self) -> None: + if ( + self.start.tzinfo is None + or self.start.utcoffset() is None + or self.end.tzinfo is None + or self.end.utcoffset() is None + ): + raise ValueError("BusyPeriod datetimes must be timezone-aware.") + if self.end.astimezone(timezone.utc) <= self.start.astimezone(timezone.utc): + raise ValueError("BusyPeriod end must be after start.") + + def overlaps(self, slot: Slot) -> bool: + start = self.start.astimezone(timezone.utc) + end = self.end.astimezone(timezone.utc) + return slot.start_utc < end and start < slot.end_utc + + @dataclass(frozen=True, slots=True) class BookingPolicy: """ @@ -60,6 +85,7 @@ class BookingPolicy: minimum_notice: timedelta = timedelta(0) maximum_horizon: timedelta | None = None blocked_periods: tuple[BlockedPeriod, ...] = () + busy_periods: tuple[BusyPeriod, ...] = () cancellation_window: timedelta | None = None reschedule_window: timedelta | None = None max_reschedules: int | None = None @@ -115,6 +141,7 @@ def __post_init__(self) -> None: ) object.__setattr__(self, "blocked_periods", tuple(self.blocked_periods)) + object.__setattr__(self, "busy_periods", tuple(self.busy_periods)) def validate(self, slot: Slot, *, now: datetime) -> None: if now.tzinfo is None or now.utcoffset() is None: @@ -141,6 +168,12 @@ def validate(self, slot: Slot, *, now: datetime) -> None: blocked.reason or "Slot falls inside a blocked period." ) + for busy in self.busy_periods: + if busy.overlaps(slot): + raise SlotUnavailableError( + busy.reason or "Slot overlaps an external busy period." + ) + def validate_cancellation( self, slot: Slot, diff --git a/tests/test_v050_features.py b/tests/test_v050_features.py new file mode 100644 index 0000000..f872416 --- /dev/null +++ b/tests/test_v050_features.py @@ -0,0 +1,94 @@ +from datetime import datetime, timedelta + +import pytest + +from slotify import ( + AvailabilityEngine, + BookingPolicy, + BusyPeriod, + BookingConflictError, + SlotGenerator, +) + + +def make_generator(): + return SlotGenerator( + start="09:00", + end="12:00", + duration=60, + timezone="Asia/Kolkata", + ) + + +def test_external_busy_period_blocks_availability(): + policy = BookingPolicy( + busy_periods=( + BusyPeriod( + start=datetime.fromisoformat("2026-10-05T10:00:00+05:30"), + end=datetime.fromisoformat("2026-10-05T11:00:00+05:30"), + reason="Google Calendar event", + ), + ) + ) + engine = AvailabilityEngine(make_generator(), policy=policy) + slots = engine.available_slots( + "2026-10-05", + now=datetime.fromisoformat("2026-10-04T08:00:00+05:30"), + ) + assert [slot.start.hour for slot in slots] == [9, 11] + + +def test_availability_result_exposes_machine_readable_code(): + engine = AvailabilityEngine(make_generator()) + slot = make_generator().generate_for_date("2026-10-05")[0] + hold = engine.hold( + slot, + expires_at=datetime.fromisoformat("2026-10-04T09:00:00+05:30"), + now=datetime.fromisoformat("2026-10-04T08:00:00+05:30"), + ) + result = engine.check_availability( + slot, + now=datetime.fromisoformat("2026-10-04T08:30:00+05:30"), + ) + assert result.available is False + assert result.code == "hold_conflict" + engine.release_hold(hold.hold_id) + + +def test_direct_reserve_cannot_bypass_non_pool_hold(): + engine = AvailabilityEngine(make_generator()) + slot = make_generator().generate_for_date("2026-10-05")[0] + now = datetime.fromisoformat("2026-10-04T08:00:00+05:30") + engine.hold( + slot, + expires_at=datetime.fromisoformat("2026-10-04T09:00:00+05:30"), + now=now, + ) + with pytest.raises(BookingConflictError): + engine.reserve(slot, now=now) + + +def test_idempotency_key_returns_same_booking(): + engine = AvailabilityEngine(make_generator()) + slot = make_generator().generate_for_date("2026-10-05")[0] + first = engine.reserve( + slot, + idempotency_key="checkout-123", + now=datetime.fromisoformat("2026-10-04T08:00:00+05:30"), + ) + second = engine.reserve( + slot, + idempotency_key="checkout-123", + now=datetime.fromisoformat("2026-10-04T08:00:00+05:30"), + ) + assert second.booking_id == first.booking_id + assert len(engine.store.list()) == 1 + + +def test_different_idempotency_keys_still_conflict(): + engine = AvailabilityEngine(make_generator()) + slot = make_generator().generate_for_date("2026-10-05")[0] + now = datetime.fromisoformat("2026-10-04T08:00:00+05:30") + engine.reserve(slot, idempotency_key="one", now=now) + with pytest.raises(BookingConflictError): + engine.reserve(slot, idempotency_key="two", now=now)