""" Tests for appointment endpoints: GET /api/v1/appointments PATCH /api/v1/appointments/{id} GET /api/v1/appointments/chatbot/{chatbot_id}/hours PUT /api/v1/appointments/chatbot/{chatbot_id}/hours GET /api/v1/chatbots/{chatbot_id}/booking-info (public) GET /api/v1/chatbots/{chatbot_id}/available-slots (public) POST /api/v1/chatbots/{chatbot_id}/appointments (public) """ import pytest from unittest.mock import MagicMock, patch from datetime import datetime, timedelta, date # ── Helpers ──────────────────────────────────────────────────────────────────── def make_user(uid="user-1"): u = MagicMock() u.id = uid u.email = "user@example.com" return u SAMPLE_APPT = { "id": "appt-1", "chatbot_id": "chatbot-1", "conversation_id": None, "customer_name": "Alice", "customer_contact": "alice@example.com", "service": "Consultation", "slot_start": "2099-06-10T09:00:00", "slot_end": "2099-06-10T10:00:00", "status": "pending", "notes": None, "created_at": "2024-01-01T00:00:00", } SAMPLE_HOURS = { "id": "bh-1", "chatbot_id": "chatbot-1", "day_of_week": 0, # Monday "is_open": True, "open_time": "09:00", "close_time": "17:00", "slot_duration_minutes": 60, } def make_supabase(plan="starter", company_id="company-1", appointments=None, appointment=None, hours=None, chatbot_booking=True): sb = MagicMock() def table_side(name): t = MagicMock() for m in ("select", "insert", "update", "delete", "eq", "neq", "in_", "order", "range", "limit", "gte", "lt"): getattr(t, m).return_value = t if name == "subscriptions": t.execute = MagicMock(return_value=MagicMock(data=[{"plan": plan}])) elif name == "companies": t.execute = MagicMock(return_value=MagicMock(data=[{"id": company_id}])) elif name == "chatbots": t.execute = MagicMock(return_value=MagicMock(data=[{ "id": "chatbot-1", "company_id": company_id, "booking_enabled": chatbot_booking, "is_published": True, }])) elif name == "appointments": if appointment is not None: t.execute = MagicMock(return_value=MagicMock(data=[appointment])) else: t.execute = MagicMock(return_value=MagicMock( data=appointments if appointments is not None else [SAMPLE_APPT] )) elif name == "business_hours": rows = hours if hours is not None else [SAMPLE_HOURS] t.execute = MagicMock(return_value=MagicMock(data=rows)) else: t.execute = MagicMock(return_value=MagicMock(data=[])) return t sb.table.side_effect = table_side return sb # ── Tests: list appointments ─────────────────────────────────────────────────── class TestListAppointments: def test_requires_auth(self, client): resp = client.get("/api/v1/appointments") assert resp.status_code == 401 def test_free_plan_returns_402(self, client): user = make_user() sb = make_supabase(plan="free") with patch("app.routers.appointments.get_current_user", return_value=user), \ patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.get("/api/v1/appointments", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 402 def test_returns_appointment_list(self, client): user = make_user() sb = make_supabase(plan="starter") with patch("app.routers.appointments.get_current_user", return_value=user), \ patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.get("/api/v1/appointments", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 data = resp.json() assert len(data) == 1 assert data[0]["customer_name"] == "Alice" assert data[0]["status"] == "pending" def test_returns_empty_when_no_chatbots(self, client): user = make_user() sb = make_supabase(plan="starter") original = sb.table.side_effect def patched(name): t = original(name) if name == "chatbots": t.execute = MagicMock(return_value=MagicMock(data=[])) return t sb.table.side_effect = patched with patch("app.routers.appointments.get_current_user", return_value=user), \ patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.get("/api/v1/appointments", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 assert resp.json() == [] # ── Tests: update appointment status ────────────────────────────────────────── class TestUpdateAppointmentStatus: def _owned_appt(self, company_id="company-1", status="pending"): return {**SAMPLE_APPT, "status": status, "chatbots": {"company_id": company_id}} def test_requires_auth(self, client): resp = client.patch("/api/v1/appointments/appt-1", json={"status": "confirmed"}) assert resp.status_code == 401 def test_valid_status_transitions(self, client): for new_status in ("pending", "confirmed", "cancelled", "completed"): user = make_user() appt = self._owned_appt() updated = {**SAMPLE_APPT, "status": new_status} call_count = {"n": 0} def table_side(name, _ns=new_status): t = MagicMock() for m in ("select", "insert", "update", "delete", "eq", "neq", "in_", "order", "range", "limit"): getattr(t, m).return_value = t if name == "subscriptions": t.execute = MagicMock(return_value=MagicMock(data=[{"plan": "starter"}])) elif name == "companies": t.execute = MagicMock(return_value=MagicMock(data=[{"id": "company-1"}])) elif name == "appointments": call_count["n"] += 1 if call_count["n"] == 1: t.execute = MagicMock(return_value=MagicMock(data=[appt])) else: t.execute = MagicMock(return_value=MagicMock( data=[{**SAMPLE_APPT, "status": _ns}])) elif name == "chatbots": t.execute = MagicMock(return_value=MagicMock(data=[ {"id": "chatbot-1", "company_id": "company-1"} ])) else: t.execute = MagicMock(return_value=MagicMock(data=[])) return t sb = MagicMock() sb.table.side_effect = table_side with patch("app.routers.appointments.get_current_user", return_value=user), \ patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.patch("/api/v1/appointments/appt-1", json={"status": new_status}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200, f"Failed for status={new_status}" def test_invalid_status_returns_400(self, client): user = make_user() appt = self._owned_appt() sb = make_supabase(plan="starter", appointment=appt) with patch("app.routers.appointments.get_current_user", return_value=user), \ patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.patch("/api/v1/appointments/appt-1", json={"status": "flying"}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 400 def test_403_for_other_companys_appointment(self, client): user = make_user() appt = self._owned_appt(company_id="company-OTHER") sb = make_supabase(plan="starter", company_id="company-1", appointment=appt) with patch("app.routers.appointments.get_current_user", return_value=user), \ patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.patch("/api/v1/appointments/appt-1", json={"status": "confirmed"}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 403 def test_404_when_not_found(self, client): user = make_user() sb = make_supabase(plan="starter") original = sb.table.side_effect def patched(name): t = original(name) if name == "appointments": t.execute = MagicMock(return_value=MagicMock(data=[])) return t sb.table.side_effect = patched with patch("app.routers.appointments.get_current_user", return_value=user), \ patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.patch("/api/v1/appointments/nonexistent", json={"status": "confirmed"}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 404 # ── Tests: business hours ────────────────────────────────────────────────────── class TestBusinessHours: def test_get_hours_requires_auth(self, client): resp = client.get("/api/v1/appointments/chatbot/chatbot-1/hours") assert resp.status_code == 401 def test_get_hours_returns_list(self, client): user = make_user() sb = make_supabase(plan="starter") with patch("app.routers.appointments.get_current_user", return_value=user), \ patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.get("/api/v1/appointments/chatbot/chatbot-1/hours", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 data = resp.json() assert isinstance(data, list) assert data[0]["day_of_week"] == 0 assert data[0]["open_time"] == "09:00" def test_save_hours_requires_auth(self, client): resp = client.put("/api/v1/appointments/chatbot/chatbot-1/hours", json={"hours": []}) assert resp.status_code == 401 def test_save_hours_success(self, client): user = make_user() sb = make_supabase(plan="starter") hours_payload = [ {"day_of_week": 0, "is_open": True, "open_time": "09:00", "close_time": "17:00", "slot_duration_minutes": 60}, {"day_of_week": 6, "is_open": False, "open_time": "09:00", "close_time": "17:00", "slot_duration_minutes": 60}, ] with patch("app.routers.appointments.get_current_user", return_value=user), \ patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.put("/api/v1/appointments/chatbot/chatbot-1/hours", json={"hours": hours_payload}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 assert resp.json()["success"] is True def test_save_hours_free_plan_returns_402(self, client): user = make_user() sb = make_supabase(plan="free") with patch("app.routers.appointments.get_current_user", return_value=user), \ patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.put("/api/v1/appointments/chatbot/chatbot-1/hours", json={"hours": []}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 402 def test_save_hours_inserts_when_not_exists(self, client): """When no existing row, should INSERT.""" user = make_user() sb = make_supabase(plan="starter", hours=[]) # no existing hours insert_calls = [] original = sb.table.side_effect def patched(name): t = original(name) if name == "business_hours": orig_insert = t.insert def track_insert(data): insert_calls.append(data) return t t.insert = track_insert return t sb.table.side_effect = patched with patch("app.routers.appointments.get_current_user", return_value=user), \ patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.put("/api/v1/appointments/chatbot/chatbot-1/hours", json={"hours": [ {"day_of_week": 1, "is_open": True, "open_time": "08:00", "close_time": "16:00", "slot_duration_minutes": 30} ]}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 assert len(insert_calls) == 1 assert insert_calls[0]["day_of_week"] == 1 assert "id" in insert_calls[0] # ── Tests: public booking-info ───────────────────────────────────────────────── class TestPublicBookingInfo: def test_returns_booking_info(self, client): sb = make_supabase(chatbot_booking=True) original = sb.table.side_effect def patched(name): t = original(name) if name == "chatbots": t.execute = MagicMock(return_value=MagicMock(data=[{ "id": "chatbot-1", "name": "Support Bot", "booking_enabled": True, "companies": {"name": "ACME Corp"}, }])) return t sb.table.side_effect = patched with patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.get("/api/v1/chatbots/chatbot-1/booking-info") assert resp.status_code == 200 body = resp.json() assert body["chatbot_name"] == "Support Bot" assert body["company_name"] == "ACME Corp" assert body["chatbot_id"] == "chatbot-1" def test_404_when_chatbot_not_found(self, client): sb = make_supabase() original = sb.table.side_effect def patched(name): t = original(name) if name == "chatbots": t.execute = MagicMock(return_value=MagicMock(data=[])) return t sb.table.side_effect = patched with patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.get("/api/v1/chatbots/nonexistent/booking-info") assert resp.status_code == 404 def test_400_when_booking_disabled(self, client): sb = make_supabase(chatbot_booking=False) original = sb.table.side_effect def patched(name): t = original(name) if name == "chatbots": t.execute = MagicMock(return_value=MagicMock(data=[{ "id": "chatbot-1", "name": "Bot", "booking_enabled": False, "companies": {"name": "ACME"}, }])) return t sb.table.side_effect = patched with patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.get("/api/v1/chatbots/chatbot-1/booking-info") assert resp.status_code == 400 # ── Tests: public available-slots ───────────────────────────────────────────── class TestAvailableSlots: def test_returns_slots_for_open_day(self, client): sb = make_supabase(chatbot_booking=True, hours=[SAMPLE_HOURS]) # No booked appointments original = sb.table.side_effect def patched(name): t = original(name) if name == "appointments": t.execute = MagicMock(return_value=MagicMock(data=[])) return t sb.table.side_effect = patched # Use a far-future Monday so none are "past" future_monday = "2099-06-09" # a Monday with patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.get(f"/api/v1/chatbots/chatbot-1/available-slots?date={future_monday}") assert resp.status_code == 200 body = resp.json() assert body["date"] == future_monday # 09:00-17:00 with 60-min slots = 8 slots assert len(body["slots"]) == 8 def test_returns_empty_for_closed_day(self, client): closed_hours = {**SAMPLE_HOURS, "is_open": False} sb = make_supabase(chatbot_booking=True, hours=[closed_hours]) with patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.get("/api/v1/chatbots/chatbot-1/available-slots?date=2099-06-09") assert resp.status_code == 200 assert resp.json()["slots"] == [] def test_returns_empty_when_no_hours_configured(self, client): sb = make_supabase(chatbot_booking=True, hours=[]) with patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.get("/api/v1/chatbots/chatbot-1/available-slots?date=2099-06-09") assert resp.status_code == 200 assert resp.json()["slots"] == [] def test_booked_slots_excluded(self, client): """A slot that is already booked should not appear.""" sb = make_supabase(chatbot_booking=True, hours=[SAMPLE_HOURS]) original = sb.table.side_effect def patched(name): t = original(name) if name == "appointments": # 09:00 is already booked t.execute = MagicMock(return_value=MagicMock(data=[ {"slot_start": "2099-06-09T09:00:00", "slot_end": "2099-06-09T10:00:00"} ])) return t sb.table.side_effect = patched with patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.get("/api/v1/chatbots/chatbot-1/available-slots?date=2099-06-09") assert resp.status_code == 200 slots = resp.json()["slots"] starts = [s["slot_start"] for s in slots] assert not any("T09:00:00" in s for s in starts) def test_invalid_date_format_returns_400(self, client): sb = make_supabase(chatbot_booking=True) with patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.get("/api/v1/chatbots/chatbot-1/available-slots?date=not-a-date") assert resp.status_code == 400 def test_booking_disabled_returns_400(self, client): sb = make_supabase(chatbot_booking=False) original = sb.table.side_effect def patched(name): t = original(name) if name == "chatbots": t.execute = MagicMock(return_value=MagicMock(data=[ {"id": "chatbot-1", "booking_enabled": False, "is_published": True} ])) return t sb.table.side_effect = patched with patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.get("/api/v1/chatbots/chatbot-1/available-slots?date=2099-06-09") assert resp.status_code == 400 def test_30_min_slots_yield_correct_count(self, client): """09:00-11:00 with 30-min slots should yield 4 slots.""" short_hours = {**SAMPLE_HOURS, "close_time": "11:00", "slot_duration_minutes": 30} sb = make_supabase(chatbot_booking=True, hours=[short_hours]) original = sb.table.side_effect def patched(name): t = original(name) if name == "appointments": t.execute = MagicMock(return_value=MagicMock(data=[])) return t sb.table.side_effect = patched with patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.get("/api/v1/chatbots/chatbot-1/available-slots?date=2099-06-09") assert len(resp.json()["slots"]) == 4 # ── Tests: public create appointment ────────────────────────────────────────── class TestCreateAppointment: def _sb_with_open_slot(self, slot_start="2099-06-09T09:00:00"): """Supabase mock that returns one available slot matching slot_start.""" insert_result = {**SAMPLE_APPT, "slot_start": slot_start} call_count = {"n": 0} sb = MagicMock() def table_side(name): t = MagicMock() for m in ("select", "insert", "update", "delete", "eq", "neq", "in_", "order", "range", "limit", "gte", "lt"): getattr(t, m).return_value = t if name == "chatbots": t.execute = MagicMock(return_value=MagicMock(data=[{ "id": "chatbot-1", "booking_enabled": True, "is_published": True, }])) elif name == "business_hours": t.execute = MagicMock(return_value=MagicMock(data=[SAMPLE_HOURS])) elif name == "appointments": call_count["n"] += 1 if call_count["n"] == 1: # _get_available_slots: booked slots check — nothing booked t.execute = MagicMock(return_value=MagicMock(data=[])) else: # The actual INSERT t.execute = MagicMock(return_value=MagicMock(data=[insert_result])) else: t.execute = MagicMock(return_value=MagicMock(data=[])) return t sb.table.side_effect = table_side return sb def test_creates_appointment_successfully(self, client): sb = self._sb_with_open_slot() with patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.post("/api/v1/chatbots/chatbot-1/appointments", json={ "customer_name": "Alice", "customer_contact": "alice@example.com", "slot_start": "2099-06-09T09:00:00", "service": "Consultation", }) assert resp.status_code == 201 assert resp.json()["customer_name"] == "Alice" assert resp.json()["status"] == "pending" def test_missing_required_fields_returns_422(self, client): sb = make_supabase(chatbot_booking=True) with patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.post("/api/v1/chatbots/chatbot-1/appointments", json={"customer_name": "Alice"}) # missing contact + slot assert resp.status_code == 422 def test_404_when_chatbot_not_found(self, client): sb = make_supabase() original = sb.table.side_effect def patched(name): t = original(name) if name == "chatbots": t.execute = MagicMock(return_value=MagicMock(data=[])) return t sb.table.side_effect = patched with patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.post("/api/v1/chatbots/nonexistent/appointments", json={ "customer_name": "Alice", "customer_contact": "alice@example.com", "slot_start": "2099-06-09T09:00:00", }) assert resp.status_code == 404 def test_409_when_slot_already_taken(self, client): """Slot is marked as booked, so should return 409 Conflict.""" sb = make_supabase(chatbot_booking=True, hours=[SAMPLE_HOURS]) original = sb.table.side_effect def patched(name): t = original(name) if name == "appointments": # 09:00 already booked t.execute = MagicMock(return_value=MagicMock(data=[ {"slot_start": "2099-06-09T09:00:00", "slot_end": "2099-06-09T10:00:00"} ])) return t sb.table.side_effect = patched with patch("app.routers.appointments.get_supabase", return_value=sb): resp = client.post("/api/v1/chatbots/chatbot-1/appointments", json={ "customer_name": "Alice", "customer_contact": "alice@example.com", "slot_start": "2099-06-09T09:00:00", }) assert resp.status_code == 409