518 lines
20 KiB
Python
518 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
|
|
|
from backend.api import routes_crm_imports
|
|
from backend.auth.dependencies import UserPrincipal, get_current_user
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
class FakeConn:
|
|
def __init__(self) -> None:
|
|
self.people: dict[str, dict[str, Any]] = {
|
|
"11111111-1111-1111-1111-111111111111": {
|
|
"person_id": "11111111-1111-1111-1111-111111111111",
|
|
"tenant_id": "tenant_alpha",
|
|
"full_name": "Amina Rahman",
|
|
"primary_email": "amina@example.com",
|
|
"primary_phone": "+971500000001",
|
|
"secondary_phone": None,
|
|
"buyer_type": "high_intent",
|
|
"persona_labels": [],
|
|
"source_confidence": 1.0,
|
|
"created_at": _now(),
|
|
"updated_at": _now(),
|
|
}
|
|
}
|
|
self.leads: dict[str, dict[str, Any]] = {
|
|
"22222222-2222-2222-2222-222222222222": {
|
|
"lead_id": "22222222-2222-2222-2222-222222222222",
|
|
"tenant_id": "tenant_alpha",
|
|
"person_id": "11111111-1111-1111-1111-111111111111",
|
|
"status": "new",
|
|
"budget_band": "AED 12M",
|
|
"urgency": "high",
|
|
}
|
|
}
|
|
self.reminders: dict[str, dict[str, Any]] = {
|
|
"33333333-3333-3333-3333-333333333333": {
|
|
"reminder_id": "33333333-3333-3333-3333-333333333333",
|
|
"tenant_id": "tenant_alpha",
|
|
"person_id": "11111111-1111-1111-1111-111111111111",
|
|
"lead_id": "22222222-2222-2222-2222-222222222222",
|
|
"reminder_type": "follow_up",
|
|
"title": "Call marina lead",
|
|
"notes": "Confirm visit time",
|
|
"status": "pending",
|
|
"priority": "high",
|
|
"due_at": _now(),
|
|
},
|
|
"44444444-4444-4444-4444-444444444444": {
|
|
"reminder_id": "44444444-4444-4444-4444-444444444444",
|
|
"tenant_id": "tenant_beta",
|
|
"person_id": "99999999-9999-9999-9999-999999999999",
|
|
"lead_id": None,
|
|
"reminder_type": "follow_up",
|
|
"title": "Cross-tenant task",
|
|
"notes": "Should not leak",
|
|
"status": "pending",
|
|
"priority": "normal",
|
|
"due_at": _now(),
|
|
},
|
|
}
|
|
self.opportunities: dict[str, dict[str, Any]] = {
|
|
"55555555-5555-5555-5555-555555555555": {
|
|
"opportunity_id": "55555555-5555-5555-5555-555555555555",
|
|
"tenant_id": "tenant_alpha",
|
|
"lead_id": "22222222-2222-2222-2222-222222222222",
|
|
"stage": "proposal",
|
|
"value": 12000000.0,
|
|
"probability": 60,
|
|
"expected_close_date": None,
|
|
"next_action": "Share proposal",
|
|
"notes": "Initial terms shared",
|
|
"project_name": "Marina Residences",
|
|
},
|
|
"66666666-6666-6666-6666-666666666666": {
|
|
"opportunity_id": "66666666-6666-6666-6666-666666666666",
|
|
"tenant_id": "tenant_beta",
|
|
"lead_id": "99999999-9999-9999-9999-999999999999",
|
|
"stage": "proposal",
|
|
"value": 7000000.0,
|
|
"probability": 50,
|
|
"expected_close_date": None,
|
|
"next_action": "Cross tenant",
|
|
"notes": None,
|
|
"project_name": None,
|
|
},
|
|
}
|
|
self.stage_history: list[dict[str, Any]] = []
|
|
|
|
async def execute(self, query: str, *args):
|
|
normalized = " ".join(query.strip().split())
|
|
if normalized.startswith("ALTER TABLE ") or normalized.startswith("CREATE INDEX IF NOT EXISTS "):
|
|
return "OK"
|
|
if normalized.startswith("UPDATE ") and " SET tenant_id = $1 " in f" {normalized} ":
|
|
return "UPDATE"
|
|
if normalized.startswith("INSERT INTO crm_people"):
|
|
self.people[args[0]] = {
|
|
"person_id": args[0],
|
|
"tenant_id": args[1],
|
|
"full_name": args[2],
|
|
"primary_email": args[3],
|
|
"primary_phone": args[4],
|
|
"secondary_phone": None,
|
|
"buyer_type": args[5],
|
|
"persona_labels": [],
|
|
"source_confidence": 1.0,
|
|
"created_at": _now(),
|
|
"updated_at": _now(),
|
|
}
|
|
return "INSERT 1"
|
|
if normalized.startswith("INSERT INTO crm_leads"):
|
|
self.leads[args[0]] = {
|
|
"lead_id": args[0],
|
|
"tenant_id": args[1],
|
|
"person_id": args[2],
|
|
}
|
|
return "INSERT 1"
|
|
if normalized.startswith("INSERT INTO crm_property_interests"):
|
|
return "INSERT 1"
|
|
if normalized.startswith("INSERT INTO intel_reminders"):
|
|
self.reminders[args[0]] = {
|
|
"reminder_id": args[0],
|
|
"tenant_id": args[1],
|
|
"person_id": args[2],
|
|
"lead_id": args[3],
|
|
"reminder_type": args[4],
|
|
"title": args[5],
|
|
"notes": args[6],
|
|
"due_at": args[7],
|
|
"status": "pending",
|
|
"priority": args[8],
|
|
}
|
|
return "INSERT 1"
|
|
if normalized.startswith("INSERT INTO crm_stage_history"):
|
|
self.stage_history.append(
|
|
{
|
|
"history_id": args[0],
|
|
"lead_id": args[1],
|
|
"from_status": args[2],
|
|
"to_status": args[3],
|
|
"changed_by": args[4],
|
|
"notes": args[5],
|
|
}
|
|
)
|
|
return "INSERT 1"
|
|
raise AssertionError(f"Unexpected execute query: {query}")
|
|
|
|
async def fetchrow(self, query: str, *args):
|
|
normalized = " ".join(query.strip().split())
|
|
if "FROM crm_people" in normalized and "WHERE person_id = $1::uuid AND tenant_id = $2" in normalized:
|
|
row = self.people.get(args[0])
|
|
return dict(row) if row and row["tenant_id"] == args[1] else None
|
|
if "FROM crm_leads" in normalized and "WHERE lead_id = $1::uuid AND person_id = $2::uuid AND tenant_id = $3" in normalized:
|
|
row = self.leads.get(args[0])
|
|
return dict(row) if row and row["person_id"] == args[1] and row["tenant_id"] == args[2] else None
|
|
if "FROM intel_reminders" in normalized and "WHERE reminder_id = $1::uuid AND tenant_id = $2" in normalized:
|
|
row = self.reminders.get(args[0])
|
|
return dict(row) if row and row["tenant_id"] == args[1] else None
|
|
if normalized.startswith("UPDATE intel_reminders ir SET status ="):
|
|
row = self.reminders.get(args[0])
|
|
if not row or row["tenant_id"] != args[1]:
|
|
return None
|
|
row["status"] = args[2]
|
|
if args[3] is not None:
|
|
row["due_at"] = args[3]
|
|
if args[4] is not None:
|
|
row["notes"] = args[4]
|
|
person = self.people[row["person_id"]]
|
|
return {
|
|
"reminder_id": row["reminder_id"],
|
|
"reminder_type": row["reminder_type"],
|
|
"title": row["title"],
|
|
"notes": row["notes"],
|
|
"due_at": row["due_at"],
|
|
"status": row["status"],
|
|
"priority": row["priority"],
|
|
"person_id": person["person_id"],
|
|
"full_name": person["full_name"],
|
|
"primary_phone": person["primary_phone"],
|
|
}
|
|
if "FROM crm_leads cl" in normalized and "WHERE cl.lead_id = $1::uuid AND cl.tenant_id = $2" in normalized:
|
|
row = self.leads.get(args[0])
|
|
if not row or row["tenant_id"] != args[1]:
|
|
return None
|
|
person = self.people[row["person_id"]]
|
|
return {
|
|
"lead_id": row["lead_id"],
|
|
"person_id": row["person_id"],
|
|
"status": row["status"],
|
|
"budget_band": row["budget_band"],
|
|
"urgency": row["urgency"],
|
|
"full_name": person["full_name"],
|
|
"primary_phone": person["primary_phone"],
|
|
}
|
|
if normalized.startswith("UPDATE crm_leads SET status = $3::crm_lead_status, updated_at = NOW()"):
|
|
row = self.leads.get(args[0])
|
|
if not row or row["tenant_id"] != args[1]:
|
|
return None
|
|
row["status"] = args[2]
|
|
return {
|
|
"lead_id": row["lead_id"],
|
|
"person_id": row["person_id"],
|
|
"status": row["status"],
|
|
"budget_band": row["budget_band"],
|
|
"urgency": row["urgency"],
|
|
}
|
|
if "FROM crm_opportunities co" in normalized and "WHERE co.opportunity_id = $1::uuid AND co.tenant_id = $2" in normalized:
|
|
row = self.opportunities.get(args[0])
|
|
if not row or row["tenant_id"] != args[1]:
|
|
return None
|
|
lead = self.leads[row["lead_id"]]
|
|
person = self.people[lead["person_id"]]
|
|
return {
|
|
"opportunity_id": row["opportunity_id"],
|
|
"stage": row["stage"],
|
|
"value": row["value"],
|
|
"probability": row["probability"],
|
|
"expected_close_date": row["expected_close_date"],
|
|
"next_action": row["next_action"],
|
|
"notes": row["notes"],
|
|
"person_id": person["person_id"],
|
|
"full_name": person["full_name"],
|
|
"primary_phone": person["primary_phone"],
|
|
"project_name": row["project_name"],
|
|
}
|
|
if normalized.startswith("WITH updated AS ( UPDATE crm_opportunities co SET stage ="):
|
|
row = self.opportunities.get(args[0])
|
|
if not row or row["tenant_id"] != args[1]:
|
|
return None
|
|
if args[2] is not None:
|
|
row["stage"] = args[2]
|
|
if args[3]:
|
|
row["value"] = args[4]
|
|
if args[5]:
|
|
row["probability"] = args[6]
|
|
if args[7]:
|
|
row["expected_close_date"] = args[8]
|
|
if args[9]:
|
|
row["next_action"] = args[10]
|
|
if args[11]:
|
|
row["notes"] = args[12]
|
|
lead = self.leads[row["lead_id"]]
|
|
person = self.people[lead["person_id"]]
|
|
return {
|
|
"opportunity_id": row["opportunity_id"],
|
|
"stage": row["stage"],
|
|
"value": row["value"],
|
|
"probability": row["probability"],
|
|
"expected_close_date": row["expected_close_date"],
|
|
"next_action": row["next_action"],
|
|
"notes": row["notes"],
|
|
"person_id": person["person_id"],
|
|
"full_name": person["full_name"],
|
|
"primary_phone": person["primary_phone"],
|
|
"project_name": row["project_name"],
|
|
}
|
|
raise AssertionError(f"Unexpected fetchrow query: {query}")
|
|
|
|
async def fetch(self, query: str, *args):
|
|
normalized = " ".join(query.strip().split())
|
|
if "FROM intel_reminders ir" in normalized:
|
|
tenant_id = args[0]
|
|
status_filter = args[1] if len(args) >= 3 else None
|
|
rows = []
|
|
for reminder in self.reminders.values():
|
|
if reminder["tenant_id"] != tenant_id:
|
|
continue
|
|
if status_filter and reminder["status"] != status_filter:
|
|
continue
|
|
person = self.people.get(reminder["person_id"])
|
|
if not person or person["tenant_id"] != tenant_id:
|
|
continue
|
|
rows.append(
|
|
{
|
|
"reminder_id": reminder["reminder_id"],
|
|
"reminder_type": reminder["reminder_type"],
|
|
"title": reminder["title"],
|
|
"notes": reminder["notes"],
|
|
"due_at": reminder["due_at"],
|
|
"status": reminder["status"],
|
|
"priority": reminder["priority"],
|
|
"person_id": person["person_id"],
|
|
"full_name": person["full_name"],
|
|
"primary_phone": person["primary_phone"],
|
|
}
|
|
)
|
|
return rows
|
|
raise AssertionError(f"Unexpected fetch query: {query}")
|
|
|
|
|
|
class FakePool:
|
|
def __init__(self) -> None:
|
|
self.conn = FakeConn()
|
|
|
|
@asynccontextmanager
|
|
async def acquire(self):
|
|
yield self.conn
|
|
|
|
|
|
def _build_shared_app(pool: FakePool, tenant_id: str) -> TestClient:
|
|
app = FastAPI()
|
|
app.state.db_pool = pool
|
|
app.include_router(routes_crm_imports.router, prefix="/api")
|
|
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
|
|
"00000000-0000-0000-0000-000000000001",
|
|
"ADMIN",
|
|
tenant_id,
|
|
)
|
|
return TestClient(app)
|
|
|
|
|
|
def test_canonical_contact_list_receives_authenticated_tenant(monkeypatch) -> None:
|
|
pool = FakePool()
|
|
client = _build_shared_app(pool, "tenant_alpha")
|
|
|
|
async def fake_get_contact_list(
|
|
conn: Any,
|
|
tenant_id: str,
|
|
search: str | None = None,
|
|
buyer_type: str | None = None,
|
|
status: str | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> dict[str, Any]:
|
|
assert tenant_id == "tenant_alpha"
|
|
return {"contacts": [], "total": 0, "limit": limit, "offset": offset}
|
|
|
|
monkeypatch.setattr(routes_crm_imports, "get_contact_list", fake_get_contact_list)
|
|
|
|
response = client.get("/api/crm/contacts")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["data"]["total"] == 0
|
|
|
|
|
|
def test_canonical_task_routes_are_scoped_to_authenticated_tenant() -> None:
|
|
pool = FakePool()
|
|
tenant_alpha = _build_shared_app(pool, "tenant_alpha")
|
|
tenant_beta = _build_shared_app(pool, "tenant_beta")
|
|
|
|
alpha_response = tenant_alpha.get("/api/crm/tasks")
|
|
beta_response = tenant_beta.get("/api/crm/tasks")
|
|
|
|
assert alpha_response.status_code == 200
|
|
assert len(alpha_response.json()["data"]) == 1
|
|
assert alpha_response.json()["data"][0]["title"] == "Call marina lead"
|
|
|
|
assert beta_response.status_code == 200
|
|
assert beta_response.json()["data"] == []
|
|
|
|
|
|
def test_create_contact_persists_authenticated_tenant_on_canonical_records() -> None:
|
|
pool = FakePool()
|
|
client = _build_shared_app(pool, "tenant_alpha")
|
|
|
|
response = client.post(
|
|
"/api/crm/contacts",
|
|
json={
|
|
"full_name": "New Canonical Contact",
|
|
"primary_phone": "+971500000010",
|
|
"budget_band": "AED 8M",
|
|
"project_name": "Skyline",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
person_id = response.json()["data"]["person_id"]
|
|
created_person = pool.conn.people[person_id]
|
|
assert created_person["tenant_id"] == "tenant_alpha"
|
|
|
|
assert any(lead["tenant_id"] == "tenant_alpha" and lead["person_id"] == person_id for lead in pool.conn.leads.values())
|
|
|
|
|
|
def test_create_task_rejects_cross_tenant_person_reference() -> None:
|
|
pool = FakePool()
|
|
client = _build_shared_app(pool, "tenant_beta")
|
|
|
|
response = client.post(
|
|
"/api/crm/tasks",
|
|
json={
|
|
"person_id": "11111111-1111-1111-1111-111111111111",
|
|
"lead_id": "22222222-2222-2222-2222-222222222222",
|
|
"title": "Cross tenant task",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "Contact '11111111-1111-1111-1111-111111111111' not found."
|
|
|
|
|
|
def test_update_task_marks_done_for_authenticated_tenant() -> None:
|
|
pool = FakePool()
|
|
client = _build_shared_app(pool, "tenant_alpha")
|
|
|
|
response = client.patch(
|
|
"/api/crm/tasks/33333333-3333-3333-3333-333333333333",
|
|
json={"status": "done"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["data"]["status"] == "done"
|
|
assert payload["meta"]["previous_status"] == "pending"
|
|
assert payload["meta"]["changed"] is True
|
|
assert pool.conn.reminders["33333333-3333-3333-3333-333333333333"]["status"] == "done"
|
|
|
|
|
|
def test_update_task_marks_confirmed_for_authenticated_tenant() -> None:
|
|
pool = FakePool()
|
|
client = _build_shared_app(pool, "tenant_alpha")
|
|
|
|
response = client.patch(
|
|
"/api/crm/tasks/33333333-3333-3333-3333-333333333333",
|
|
json={"status": "confirmed"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["data"]["status"] == "confirmed"
|
|
assert payload["meta"]["previous_status"] == "pending"
|
|
assert payload["meta"]["changed"] is True
|
|
assert pool.conn.reminders["33333333-3333-3333-3333-333333333333"]["status"] == "confirmed"
|
|
|
|
|
|
def test_update_task_rejects_cross_tenant_task_reference() -> None:
|
|
pool = FakePool()
|
|
client = _build_shared_app(pool, "tenant_beta")
|
|
|
|
response = client.patch(
|
|
"/api/crm/tasks/33333333-3333-3333-3333-333333333333",
|
|
json={"status": "done"},
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "Task '33333333-3333-3333-3333-333333333333' not found."
|
|
|
|
|
|
def test_update_lead_stage_records_canonical_stage_history() -> None:
|
|
pool = FakePool()
|
|
client = _build_shared_app(pool, "tenant_alpha")
|
|
|
|
response = client.patch(
|
|
"/api/crm/leads/22222222-2222-2222-2222-222222222222/stage",
|
|
json={"status": "qualified", "notes": "Advanced from iPad Oracle pipeline."},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["data"]["status"] == "qualified"
|
|
assert payload["meta"]["previous_status"] == "new"
|
|
assert payload["meta"]["changed"] is True
|
|
assert pool.conn.leads["22222222-2222-2222-2222-222222222222"]["status"] == "qualified"
|
|
assert len(pool.conn.stage_history) == 1
|
|
assert pool.conn.stage_history[0]["from_status"] == "new"
|
|
assert pool.conn.stage_history[0]["to_status"] == "qualified"
|
|
|
|
|
|
def test_update_lead_stage_rejects_cross_tenant_reference() -> None:
|
|
pool = FakePool()
|
|
client = _build_shared_app(pool, "tenant_beta")
|
|
|
|
response = client.patch(
|
|
"/api/crm/leads/22222222-2222-2222-2222-222222222222/stage",
|
|
json={"status": "qualified"},
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "Lead '22222222-2222-2222-2222-222222222222' not found."
|
|
|
|
|
|
def test_update_opportunity_mutates_canonical_deal_for_authenticated_tenant() -> None:
|
|
pool = FakePool()
|
|
client = _build_shared_app(pool, "tenant_alpha")
|
|
|
|
response = client.patch(
|
|
"/api/crm/opportunities/55555555-5555-5555-5555-555555555555",
|
|
json={
|
|
"stage": "negotiation",
|
|
"probability": 75,
|
|
"next_action": "Schedule commercial review",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["data"]["stage"] == "negotiation"
|
|
assert payload["data"]["probability"] == 75
|
|
assert payload["data"]["next_action"] == "Schedule commercial review"
|
|
assert payload["data"]["client_name"] == "Amina Rahman"
|
|
assert payload["meta"]["previous_stage"] == "proposal"
|
|
assert payload["meta"]["changed"] is True
|
|
assert pool.conn.opportunities["55555555-5555-5555-5555-555555555555"]["stage"] == "negotiation"
|
|
|
|
|
|
def test_update_opportunity_rejects_cross_tenant_reference() -> None:
|
|
pool = FakePool()
|
|
client = _build_shared_app(pool, "tenant_beta")
|
|
|
|
response = client.patch(
|
|
"/api/crm/opportunities/55555555-5555-5555-5555-555555555555",
|
|
json={"stage": "negotiation"},
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "Opportunity '55555555-5555-5555-5555-555555555555' not found."
|