Spaces:
Sleeping
Sleeping
| """Appointment Agent — happy path tests. OWNER: Person B.""" | |
| from __future__ import annotations | |
| from datetime import datetime, timedelta, timezone | |
| from unittest.mock import MagicMock, patch | |
| import pytest | |
| from app.contracts import BookingStatus, Channel | |
| # --------------------------------------------------------------------------- | |
| # Mocks | |
| # --------------------------------------------------------------------------- | |
| def mock_calendar(): | |
| """Patch calendar_mcp so tests don't need a real Google Calendar.""" | |
| with patch("app.agents.appointment.api.calendar_mcp") as m: | |
| m.list_events.return_value = [] # empty calendar = all slots free | |
| m.create_event.return_value = {"id": "gcal-test-001"} | |
| yield m | |
| def mock_celery(): | |
| """Patch Celery send_reminder so tests don't need a running broker.""" | |
| with patch("app.agents.appointment.api.send_reminder") as m: | |
| m.apply_async.return_value = MagicMock() | |
| yield m | |
| def now_window(): | |
| now = datetime.now(timezone.utc) | |
| return now + timedelta(days=1, hours=9), now + timedelta(days=1, hours=17) | |
| # --------------------------------------------------------------------------- | |
| # book() | |
| # --------------------------------------------------------------------------- | |
| def test_book_returns_booking_result(mock_calendar, mock_celery, now_window): | |
| from app.agents.appointment.api import book | |
| result = book(patient_id="p-001", requested_window=now_window) | |
| assert result.patient_id == "p-001" | |
| assert result.status == BookingStatus.CONFIRMED | |
| assert result.appointment_id.startswith("appt-") | |
| assert result.calendar_event_id == "gcal-test-001" | |
| mock_calendar.create_event.assert_called_once() | |
| def test_book_enqueues_reminders(mock_calendar, mock_celery, now_window): | |
| from app.agents.appointment.api import book | |
| result = book(patient_id="p-001", requested_window=now_window) | |
| # Should have 2 reminder jobs: T-24h email and T-2h WhatsApp | |
| assert len(result.reminder_jobs) == 2 | |
| channels = {j.channel for j in result.reminder_jobs} | |
| assert Channel.EMAIL in channels | |
| assert Channel.WHATSAPP in channels | |
| for job in result.reminder_jobs: | |
| assert job.enqueued is True | |
| def test_book_no_slot_raises(mock_calendar, mock_celery): | |
| """When the calendar is fully booked, book() should raise ValueError.""" | |
| from app.agents.appointment.api import book, _SLOT_DURATION | |
| now = datetime.now(timezone.utc) | |
| w_start = now + timedelta(hours=1) | |
| w_end = w_start + timedelta(minutes=_SLOT_DURATION - 1) # window too narrow | |
| # Make every possible slot busy | |
| mock_calendar.list_events.return_value = [ | |
| { | |
| "start_iso": w_start.isoformat(), | |
| "end_iso": (w_start + timedelta(hours=8)).isoformat(), | |
| } | |
| ] | |
| with pytest.raises(ValueError, match="No available slot"): | |
| book(patient_id="p-001", requested_window=(w_start, w_end)) | |
| def test_book_skips_busy_slots(mock_calendar, mock_celery): | |
| """book() should skip over occupied slots and find the first free one.""" | |
| from app.agents.appointment.api import book, _SLOT_DURATION | |
| now = datetime.now(timezone.utc) | |
| w_start = now + timedelta(days=1, hours=9) | |
| w_end = now + timedelta(days=1, hours=11) | |
| # Block the first slot | |
| mock_calendar.list_events.return_value = [ | |
| { | |
| "start_iso": w_start.isoformat(), | |
| "end_iso": (w_start + timedelta(minutes=_SLOT_DURATION)).isoformat(), | |
| } | |
| ] | |
| result = book(patient_id="p-002", requested_window=(w_start, w_end)) | |
| assert result.slot_iso >= w_start + timedelta(minutes=_SLOT_DURATION) | |
| # --------------------------------------------------------------------------- | |
| # handle_inbound_message() | |
| # --------------------------------------------------------------------------- | |
| def test_handle_inbound_book_intent(mock_calendar, mock_celery): | |
| from app.agents.appointment.api import handle_inbound_message | |
| with patch("app.agents.appointment.api._parse_intent") as mock_parse: | |
| mock_parse.return_value = {"intent": "book", "preferred_date": None} | |
| draft = handle_inbound_message( | |
| channel="whatsapp", | |
| payload={"from": "+919876543210", "body": "book appointment"}, | |
| ) | |
| assert draft.channel == Channel.WHATSAPP | |
| assert draft.to == "+919876543210" | |
| assert "confirmed" in draft.body.lower() or "appt-" in draft.body | |
| def test_handle_inbound_other_intent(): | |
| from app.agents.appointment.api import handle_inbound_message | |
| with patch("app.agents.appointment.api._parse_intent") as mock_parse: | |
| mock_parse.return_value = {"intent": "other"} | |
| draft = handle_inbound_message( | |
| channel="web", | |
| payload={"from": "demo", "body": "hello"}, | |
| ) | |
| assert "book" in draft.body.lower() or "appointment" in draft.body.lower() | |
| # --------------------------------------------------------------------------- | |
| # cancel() | |
| # --------------------------------------------------------------------------- | |
| def test_cancel_returns_cancelled_result(): | |
| from app.agents.appointment.api import cancel | |
| result = cancel("appt-abc123", reason="patient no longer needed") | |
| assert result.status == BookingStatus.CANCELLED | |
| assert result.appointment_id == "appt-abc123" | |