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."