mirror of
http://88.130.71.182:3000/BlitTech/contexta_be.git
synced 2026-06-12 23:23:21 +00:00
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:
428
tests/test_inbox.py
Normal file
428
tests/test_inbox.py
Normal file
@@ -0,0 +1,428 @@
|
||||
"""
|
||||
Tests for inbox endpoints:
|
||||
GET /api/v1/inbox/conversations
|
||||
GET /api/v1/inbox/conversations/{id}
|
||||
PATCH /api/v1/inbox/conversations/{id}/status
|
||||
POST /api/v1/inbox/conversations/{id}/reply
|
||||
DELETE /api/v1/inbox/conversations/{id}
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def make_user(uid="user-1"):
|
||||
u = MagicMock()
|
||||
u.id = uid
|
||||
u.email = "user@example.com"
|
||||
return u
|
||||
|
||||
|
||||
def make_supabase(plan="starter", company_id="company-1", conversations=None,
|
||||
messages=None, conversation=None):
|
||||
"""
|
||||
Build a Supabase mock wired for inbox tests.
|
||||
|
||||
table() calls are routed by table name; every chain returns self so
|
||||
.select().eq()…execute() works.
|
||||
"""
|
||||
sb = MagicMock()
|
||||
|
||||
def table_side(name):
|
||||
t = MagicMock()
|
||||
t.select = MagicMock(return_value=t)
|
||||
t.insert = MagicMock(return_value=t)
|
||||
t.update = MagicMock(return_value=t)
|
||||
t.delete = MagicMock(return_value=t)
|
||||
t.eq = MagicMock(return_value=t)
|
||||
t.neq = MagicMock(return_value=t)
|
||||
t.in_ = MagicMock(return_value=t)
|
||||
t.order = MagicMock(return_value=t)
|
||||
t.range = MagicMock(return_value=t)
|
||||
t.limit = MagicMock(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", "name": "My Bot", "company_id": company_id}
|
||||
]))
|
||||
elif name == "conversations":
|
||||
if conversation is not None:
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[conversation]))
|
||||
else:
|
||||
rows = conversations or [
|
||||
{"id": "conv-1", "chatbot_id": "chatbot-1", "session_id": "s1",
|
||||
"language": "en", "message_count": 3, "status": "open",
|
||||
"last_agent_reply_at": None, "created_at": "2024-01-01T00:00:00"},
|
||||
]
|
||||
t.execute = MagicMock(return_value=MagicMock(data=rows))
|
||||
elif name == "messages":
|
||||
rows = messages or [
|
||||
{"id": "msg-1", "role": "user", "content": "Hello",
|
||||
"sources": None, "confidence_score": None,
|
||||
"is_handoff": False, "created_at": "2024-01-01T00:00:00"},
|
||||
]
|
||||
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 conversations ──────────────────────────────────────────────────
|
||||
|
||||
class TestListConversations:
|
||||
def test_requires_auth(self, client):
|
||||
resp = client.get("/api/v1/inbox/conversations")
|
||||
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.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.get("/api/v1/inbox/conversations",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 402
|
||||
|
||||
def test_returns_conversation_list(self, client):
|
||||
user = make_user()
|
||||
sb = make_supabase(plan="starter")
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.get("/api/v1/inbox/conversations",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 1
|
||||
assert data[0]["id"] == "conv-1"
|
||||
assert data[0]["status"] == "open"
|
||||
assert data[0]["chatbot_name"] == "My Bot"
|
||||
|
||||
def test_empty_when_no_chatbots(self, client):
|
||||
user = make_user()
|
||||
sb = make_supabase(plan="starter")
|
||||
# Override chatbots table to return nothing
|
||||
original_side = sb.table.side_effect
|
||||
def patched(name):
|
||||
t = original_side(name)
|
||||
if name == "chatbots":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[]))
|
||||
return t
|
||||
sb.table.side_effect = patched
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.get("/api/v1/inbox/conversations",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
def test_pagination_params_accepted(self, client):
|
||||
user = make_user()
|
||||
sb = make_supabase(plan="starter")
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.get("/api/v1/inbox/conversations?page=2&limit=5",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── Tests: get single conversation ────────────────────────────────────────────
|
||||
|
||||
class TestGetConversation:
|
||||
def _owned_conv(self, company_id="company-1"):
|
||||
return {
|
||||
"id": "conv-1", "chatbot_id": "chatbot-1",
|
||||
"session_id": "s1", "language": "en",
|
||||
"status": "open", "last_agent_reply_at": None,
|
||||
"created_at": "2024-01-01T00:00:00",
|
||||
"chatbots": {"company_id": company_id, "name": "My Bot"},
|
||||
}
|
||||
|
||||
def test_requires_auth(self, client):
|
||||
resp = client.get("/api/v1/inbox/conversations/conv-1")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_returns_messages(self, client):
|
||||
user = make_user()
|
||||
conv = self._owned_conv()
|
||||
sb = make_supabase(plan="starter", conversation=conv)
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.get("/api/v1/inbox/conversations/conv-1",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["conversation_id"] == "conv-1"
|
||||
assert len(body["messages"]) == 1
|
||||
assert body["messages"][0]["role"] == "user"
|
||||
|
||||
def test_returns_404_when_not_found(self, client):
|
||||
user = make_user()
|
||||
sb = make_supabase(plan="starter", conversation=None, conversations=[])
|
||||
# conversations table returns empty
|
||||
original = sb.table.side_effect
|
||||
def patched(name):
|
||||
t = original(name)
|
||||
if name == "conversations":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[]))
|
||||
return t
|
||||
sb.table.side_effect = patched
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.get("/api/v1/inbox/conversations/nonexistent",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_returns_403_for_other_companys_conversation(self, client):
|
||||
user = make_user()
|
||||
# Conversation belongs to company-OTHER, user belongs to company-1
|
||||
conv = self._owned_conv(company_id="company-OTHER")
|
||||
sb = make_supabase(plan="starter", company_id="company-1", conversation=conv)
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.get("/api/v1/inbox/conversations/conv-1",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ── Tests: update conversation status ─────────────────────────────────────────
|
||||
|
||||
class TestUpdateConversationStatus:
|
||||
def _owned_conv(self, company_id="company-1", status="open"):
|
||||
return {
|
||||
"id": "conv-1", "status": status,
|
||||
"chatbots": {"company_id": company_id},
|
||||
}
|
||||
|
||||
def test_requires_auth(self, client):
|
||||
resp = client.patch("/api/v1/inbox/conversations/conv-1/status",
|
||||
json={"status": "resolved"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_valid_statuses_accepted(self, client):
|
||||
for s in ("open", "agent_handling", "resolved"):
|
||||
user = make_user()
|
||||
conv = self._owned_conv(status="open")
|
||||
sb = make_supabase(plan="starter", conversation=conv)
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.patch("/api/v1/inbox/conversations/conv-1/status",
|
||||
json={"status": s},
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == s
|
||||
|
||||
def test_invalid_status_returns_400(self, client):
|
||||
user = make_user()
|
||||
conv = self._owned_conv()
|
||||
sb = make_supabase(plan="starter", conversation=conv)
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.patch("/api/v1/inbox/conversations/conv-1/status",
|
||||
json={"status": "invalid_status"},
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_403_for_other_companys_conversation(self, client):
|
||||
user = make_user()
|
||||
conv = self._owned_conv(company_id="company-OTHER")
|
||||
sb = make_supabase(plan="starter", company_id="company-1", conversation=conv)
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.patch("/api/v1/inbox/conversations/conv-1/status",
|
||||
json={"status": "resolved"},
|
||||
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 == "conversations":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[]))
|
||||
return t
|
||||
sb.table.side_effect = patched
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.patch("/api/v1/inbox/conversations/nonexistent/status",
|
||||
json={"status": "resolved"},
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── Tests: agent reply ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestAgentReply:
|
||||
def _owned_conv(self, company_id="company-1", status="open"):
|
||||
return {
|
||||
"id": "conv-1", "status": status,
|
||||
"chatbots": {"company_id": company_id},
|
||||
}
|
||||
|
||||
def test_requires_auth(self, client):
|
||||
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
|
||||
json={"message": "Hello!"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_sends_reply_and_returns_message_id(self, client):
|
||||
user = make_user()
|
||||
conv = self._owned_conv(status="open")
|
||||
sb = make_supabase(plan="starter", conversation=conv)
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
|
||||
json={"message": "Thanks for contacting us!"},
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert "message_id" in body
|
||||
|
||||
def test_empty_message_returns_422(self, client):
|
||||
user = make_user()
|
||||
conv = self._owned_conv()
|
||||
sb = make_supabase(plan="starter", conversation=conv)
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
|
||||
json={"message": ""},
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_sets_status_to_agent_handling_when_open(self, client):
|
||||
user = make_user()
|
||||
conv = self._owned_conv(status="open")
|
||||
sb = make_supabase(plan="starter", conversation=conv)
|
||||
|
||||
update_calls = []
|
||||
original = sb.table.side_effect
|
||||
def patched(name):
|
||||
t = original(name)
|
||||
if name == "conversations":
|
||||
orig_update = t.update
|
||||
def track_update(data):
|
||||
update_calls.append(data)
|
||||
return t
|
||||
t.update = track_update
|
||||
return t
|
||||
sb.table.side_effect = patched
|
||||
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
|
||||
json={"message": "Hello"},
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
# Should have updated status to agent_handling
|
||||
statuses = [c.get("status") for c in update_calls if "status" in c]
|
||||
assert "agent_handling" in statuses
|
||||
|
||||
def test_does_not_change_status_when_already_agent_handling(self, client):
|
||||
user = make_user()
|
||||
conv = self._owned_conv(status="agent_handling")
|
||||
sb = make_supabase(plan="starter", conversation=conv)
|
||||
|
||||
update_calls = []
|
||||
original = sb.table.side_effect
|
||||
def patched(name):
|
||||
t = original(name)
|
||||
if name == "conversations":
|
||||
orig_update = t.update
|
||||
def track_update(data):
|
||||
update_calls.append(data)
|
||||
return t
|
||||
t.update = track_update
|
||||
return t
|
||||
sb.table.side_effect = patched
|
||||
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
|
||||
json={"message": "Follow-up"},
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
# status should NOT appear in any update when already agent_handling
|
||||
new_statuses = [c.get("status") for c in update_calls if "status" in c]
|
||||
assert "agent_handling" not in new_statuses
|
||||
|
||||
def test_403_for_other_companys_conversation(self, client):
|
||||
user = make_user()
|
||||
conv = self._owned_conv(company_id="company-OTHER")
|
||||
sb = make_supabase(plan="starter", company_id="company-1", conversation=conv)
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
|
||||
json={"message": "Hi"},
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_free_plan_returns_402(self, client):
|
||||
user = make_user()
|
||||
sb = make_supabase(plan="free")
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
|
||||
json={"message": "Hi"},
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 402
|
||||
|
||||
|
||||
# ── Tests: delete conversation ─────────────────────────────────────────────────
|
||||
|
||||
class TestDeleteConversation:
|
||||
def _owned_conv(self, company_id="company-1"):
|
||||
return {
|
||||
"id": "conv-1",
|
||||
"chatbots": {"company_id": company_id},
|
||||
}
|
||||
|
||||
def test_requires_auth(self, client):
|
||||
resp = client.delete("/api/v1/inbox/conversations/conv-1")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_deletes_successfully(self, client):
|
||||
user = make_user()
|
||||
conv = self._owned_conv()
|
||||
sb = make_supabase(plan="starter", conversation=conv)
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.delete("/api/v1/inbox/conversations/conv-1",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is True
|
||||
|
||||
def test_returns_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 == "conversations":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[]))
|
||||
return t
|
||||
sb.table.side_effect = patched
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.delete("/api/v1/inbox/conversations/nonexistent",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_returns_403_for_other_companys_conversation(self, client):
|
||||
user = make_user()
|
||||
conv = self._owned_conv(company_id="company-OTHER")
|
||||
sb = make_supabase(plan="starter", company_id="company-1", conversation=conv)
|
||||
with patch("app.routers.inbox.get_current_user", return_value=user), \
|
||||
patch("app.routers.inbox.get_supabase", return_value=sb):
|
||||
resp = client.delete("/api/v1/inbox/conversations/conv-1",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 403
|
||||
Reference in New Issue
Block a user