diff --git a/src/slotify/availability.py b/src/slotify/availability.py index a7c797f..2ff5a5c 100644 --- a/src/slotify/availability.py +++ b/src/slotify/availability.py @@ -666,6 +666,7 @@ def hold( if expires_at <= current: raise ValueError("expires_at must be in the future.") self.policy.validate(slot, now=current) + self._validate_limits(slot, now=current) if self.resource_pool: for resource_id in self._ordered_pool_resources(slot): candidate = self._candidate(slot, resource_id=resource_id) @@ -697,6 +698,8 @@ def confirm_hold( """Convert an active hold into a confirmed booking.""" current = self._current(now, self.generator) hold = self.hold_store.get(hold_id, now=current) + self.policy.validate(hold.slot, now=current) + self._validate_limits(hold.slot, now=current) booking = self._candidate( hold.slot, resource_id=hold.resource_id, @@ -725,9 +728,6 @@ def reserve( generator = self._generator_for(schedule_name) current = self._current(now, generator) - 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.") @@ -735,8 +735,15 @@ def reserve( if finder is not None: existing = finder(idempotency_key) if existing is not None: + if existing.slot != slot: + raise BookingConflictError( + "The idempotency key is already associated with a different slot." + ) return existing + self.policy.validate(slot, now=current) + self._validate_limits(slot, now=current) + if self.resource_pool: last_error: BookingConflictError | None = None for resource_id in self._ordered_pool_resources( @@ -775,14 +782,6 @@ 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, @@ -877,6 +876,11 @@ def reschedule( candidate = booking.reschedule(new_slot) + if self.hold_store.conflicts(candidate, now=current): + raise BookingConflictError( + "The requested interval is temporarily held." + ) + available = self.store.available_capacity( candidate, capacity=self.capacity, diff --git a/src/slotify/booking.py b/src/slotify/booking.py index 188a093..fad6bfd 100644 --- a/src/slotify/booking.py +++ b/src/slotify/booking.py @@ -174,6 +174,7 @@ def reschedule(self, new_slot: Slot) -> "Booking": resource_ids=self.resource_ids, series_id=self.series_id, rescheduled_count=self.rescheduled_count + 1, + idempotency_key=self.idempotency_key, ) def to_dict(self) -> dict[str, Any]: diff --git a/tests/test_v050_features.py b/tests/test_v050_features.py index f872416..0176869 100644 --- a/tests/test_v050_features.py +++ b/tests/test_v050_features.py @@ -8,6 +8,7 @@ BusyPeriod, BookingConflictError, SlotGenerator, + SlotUnavailableError, ) @@ -92,3 +93,66 @@ def test_different_idempotency_keys_still_conflict(): engine.reserve(slot, idempotency_key="one", now=now) with pytest.raises(BookingConflictError): engine.reserve(slot, idempotency_key="two", now=now) + + +def test_idempotency_key_cannot_be_reused_for_another_slot(): + engine = AvailabilityEngine(make_generator()) + slots = make_generator().generate_for_date("2026-10-05") + now = datetime.fromisoformat("2026-10-04T08:00:00+05:30") + engine.reserve(slots[0], idempotency_key="same-request", now=now) + with pytest.raises(BookingConflictError): + engine.reserve(slots[1], idempotency_key="same-request", now=now) + + +def test_reschedule_preserves_idempotency_key(): + engine = AvailabilityEngine(make_generator()) + slots = make_generator().generate_for_date("2026-10-05") + now = datetime.fromisoformat("2026-10-04T08:00:00+05:30") + booking = engine.reserve( + slots[0], idempotency_key="checkout-456", now=now + ) + moved = engine.reschedule(booking.booking_id, slots[1], now=now) + assert moved.idempotency_key == "checkout-456" + + +def test_hold_respects_booking_limits(): + policy = BookingPolicy(max_upcoming_bookings=1) + engine = AvailabilityEngine(make_generator(), policy=policy) + slots = make_generator().generate_for_date("2026-10-05") + now = datetime.fromisoformat("2026-10-04T08:00:00+05:30") + engine.reserve(slots[0], now=now) + with pytest.raises(SlotUnavailableError): + engine.hold( + slots[1], + expires_at=datetime.fromisoformat("2026-10-04T09:00:00+05:30"), + now=now, + ) + + +def test_confirm_hold_rechecks_booking_limits(): + policy = BookingPolicy(max_upcoming_bookings=1) + engine = AvailabilityEngine(make_generator(), policy=policy) + slots = make_generator().generate_for_date("2026-10-05") + now = datetime.fromisoformat("2026-10-04T08:00:00+05:30") + hold = engine.hold( + slots[0], + expires_at=datetime.fromisoformat("2026-10-04T09:00:00+05:30"), + now=now, + ) + engine.reserve(slots[1], now=now) + with pytest.raises(SlotUnavailableError): + engine.confirm_hold(hold.hold_id, now=now) + + +def test_reschedule_cannot_bypass_active_hold(): + engine = AvailabilityEngine(make_generator()) + slots = make_generator().generate_for_date("2026-10-05") + now = datetime.fromisoformat("2026-10-04T08:00:00+05:30") + booking = engine.reserve(slots[0], now=now) + engine.hold( + slots[1], + expires_at=datetime.fromisoformat("2026-10-04T09:00:00+05:30"), + now=now, + ) + with pytest.raises(BookingConflictError): + engine.reschedule(booking.booking_id, slots[1], now=now)