feat: add appointments, campaigns, admin, storage, tests and various updates

- Add new routers: admin, appointments, campaigns
- Add storage service and logging config
- Add migrations directory and test suite with pytest config
- Add supabase_migration_features.sql
- Update models, dependencies, config, and existing routers
- Remove whatsapp_service (deleted)
- Update pyproject.toml and uv.lock dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
belviskhoremk
2026-04-03 09:11:58 +00:00
parent 9dccc83293
commit 92d4c2fc5e
51 changed files with 7076 additions and 515 deletions

554
tests/test_appointments.py Normal file
View File

@@ -0,0 +1,554 @@
"""
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