feat: Ipad app features and Dream Weaver for Velocity WebOS
Some checks failed
Production Readiness / backend-contracts (pull_request) Has been cancelled
Production Readiness / webos-typecheck (pull_request) Has been cancelled
Production Readiness / ipad-parse (pull_request) Has been cancelled

This commit is contained in:
Sayan Datta
2026-04-28 10:59:07 +05:30
parent 184bfa77f8
commit fefe8373ec
117 changed files with 19510 additions and 6383 deletions

View File

@@ -0,0 +1,212 @@
from __future__ import annotations
import asyncio
import os
from contextlib import asynccontextmanager
from typing import Any
from jose import jwt
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
from backend.auth.dependencies import UserPrincipal
from backend.auth import service as auth_service
auth_service.verify_password = lambda plain, hashed: plain == hashed
class AppState:
def __init__(self, pool: Any) -> None:
self.db_pool = pool
self._auth_user_directory_schema_ready = False
class AppStub:
def __init__(self, pool: Any) -> None:
self.state = AppState(pool)
class RequestStub:
def __init__(self, pool: Any) -> None:
self.app = AppStub(pool)
class FakeConn:
def __init__(self) -> None:
password_hash = "velocity-demo-password"
self.users: dict[str, dict[str, Any]] = {
"user-alpha": {
"id": "00000000-0000-0000-0000-000000000001",
"email": "alpha@example.com",
"password_hash": password_hash,
"role": "ADMIN",
"tenant_id": "tenant_alpha",
"full_name": "Alpha Operator",
"avatar_url": "/assets/profile_avatars/alpha.png",
"is_active": True,
},
"user-beta": {
"id": "00000000-0000-0000-0000-000000000002",
"email": "beta@example.com",
"password_hash": password_hash,
"role": "ADMIN",
"tenant_id": "tenant_beta",
"full_name": "Beta Operator",
"avatar_url": "/assets/profile_avatars/beta.png",
"is_active": True,
},
"user-legacy": {
"id": "00000000-0000-0000-0000-000000000003",
"email": "legacy@example.com",
"password_hash": password_hash,
"role": "SENIOR_BROKER",
"tenant_id": "",
"full_name": "Legacy Tenant User",
"avatar_url": None,
"is_active": True,
},
}
self.schema_ready = False
async def execute(self, query: str, *args):
normalized = " ".join(query.strip().split())
if normalized.startswith("ALTER TABLE users_and_roles ADD COLUMN IF NOT EXISTS tenant_id TEXT"):
for user in self.users.values():
user.setdefault("tenant_id", "")
return "ALTER TABLE"
if normalized.startswith("UPDATE users_and_roles SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
for user in self.users.values():
if not user.get("tenant_id"):
user["tenant_id"] = args[0]
return "UPDATE"
if normalized.startswith("ALTER TABLE users_and_roles ALTER COLUMN tenant_id SET DEFAULT"):
return "ALTER TABLE"
if normalized.startswith("ALTER TABLE users_and_roles ALTER COLUMN tenant_id SET NOT NULL"):
self.schema_ready = True
return "ALTER TABLE"
if normalized.startswith("CREATE INDEX IF NOT EXISTS idx_users_tenant_active ON users_and_roles (tenant_id, is_active)"):
return "CREATE INDEX"
if normalized.startswith("UPDATE users_and_roles SET avatar_url = $2 WHERE id = $1::uuid AND tenant_id = $3"):
for user in self.users.values():
if user["id"] == args[0] and user["tenant_id"] == args[2]:
user["avatar_url"] = args[1]
return "UPDATE 1"
return "UPDATE 0"
raise AssertionError(f"Unexpected execute query: {query}")
async def fetchrow(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "FROM users_and_roles" not in normalized:
raise AssertionError(f"Unexpected fetchrow query: {query}")
if "password_hash" in normalized:
email, tenant_fallback = args
for user in self.users.values():
if user["email"] == email and user["is_active"]:
return {
"id": user["id"],
"role": user["role"],
"password_hash": user["password_hash"],
"tenant_id": user["tenant_id"] or tenant_fallback,
}
return None
if "WHERE id = $1::uuid AND COALESCE(NULLIF(tenant_id, ''), $2) = $2" in normalized:
user_id, tenant_id = args
for user in self.users.values():
resolved_tenant = user["tenant_id"] or tenant_id
if user["id"] == user_id and resolved_tenant == tenant_id:
return {
"full_name": user["full_name"],
"email": user["email"],
"avatar_url": user["avatar_url"],
"tenant_id": resolved_tenant,
}
return None
raise AssertionError(f"Unexpected fetchrow query: {query}")
async def fetch(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "FROM users_and_roles" not in normalized:
raise AssertionError(f"Unexpected fetch query: {query}")
tenant_fallback, tenant_id = args
rows = []
for user in self.users.values():
resolved_tenant = user["tenant_id"] or tenant_fallback
if user["is_active"] and resolved_tenant == tenant_id:
rows.append(
{
"user_id": user["id"],
"role": user["role"],
"tenant_id": resolved_tenant,
"full_name": user["full_name"],
"email": user["email"],
"avatar_url": user["avatar_url"],
}
)
rows.sort(key=lambda row: (row["full_name"] or row["email"] or row["user_id"]))
return rows
class FakePool:
def __init__(self) -> None:
self.conn = FakeConn()
@asynccontextmanager
async def acquire(self):
yield self.conn
def _build_request() -> tuple[RequestStub, FakePool]:
pool = FakePool()
return RequestStub(pool), pool
def test_login_mints_token_with_user_tenant_id() -> None:
request, pool = _build_request()
response = asyncio.run(
auth_service.login_with_directory(
app=request.app,
email="alpha@example.com",
password="velocity-demo-password",
)
)
payload = jwt.get_unverified_claims(response["access_token"])
assert payload["tenant_id"] == "tenant_alpha"
assert pool.conn.schema_ready is True
def test_login_backfills_legacy_users_to_default_tenant_before_minting() -> None:
request, pool = _build_request()
response = asyncio.run(
auth_service.login_with_directory(
app=request.app,
email="legacy@example.com",
password="velocity-demo-password",
)
)
payload = jwt.get_unverified_claims(response["access_token"])
assert payload["tenant_id"] == "tenant_velocity"
assert pool.conn.users["user-legacy"]["tenant_id"] == "tenant_velocity"
def test_auth_me_returns_profile_for_authenticated_tenant() -> None:
request, _pool = _build_request()
user = UserPrincipal("00000000-0000-0000-0000-000000000001", "ADMIN", "tenant_alpha")
response = asyncio.run(auth_service.read_authenticated_user_profile(app=request.app, user=user))
assert response["tenant_id"] == "tenant_alpha"
assert response["email"] == "alpha@example.com"
def test_auth_users_are_scoped_to_authenticated_tenant() -> None:
request, _pool = _build_request()
user = UserPrincipal("00000000-0000-0000-0000-000000000001", "ADMIN", "tenant_alpha")
users = asyncio.run(auth_service.list_tenant_users(app=request.app, user=user))
assert [user["email"] for user in users] == ["alpha@example.com"]
assert all(user["tenant_id"] == "tenant_alpha" for user in users)

View File

@@ -0,0 +1,162 @@
from __future__ import annotations
import io
import os
from contextlib import asynccontextmanager
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
class FakeConn:
async def execute(self, query: str, *args):
return "OK"
class FakePool:
def __init__(self) -> None:
self.conn = FakeConn()
@asynccontextmanager
async def acquire(self):
yield self.conn
def _build_app(*, authenticated: bool) -> TestClient:
app = FastAPI()
app.state.db_pool = FakePool()
app.include_router(routes_crm_imports.router, prefix="/api")
if authenticated:
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
"00000000-0000-0000-0000-000000000001",
"ADMIN",
"tenant_alpha",
)
return TestClient(app)
def test_canonical_crm_routes_require_authentication() -> None:
client = _build_app(authenticated=False)
response = client.get("/api/crm/contacts")
assert response.status_code == 401
assert response.json()["detail"] == "Missing or malformed Authorization header."
def test_canonical_crm_task_routes_require_authentication() -> None:
client = _build_app(authenticated=False)
response = client.get("/api/crm/tasks")
assert response.status_code == 401
assert response.json()["detail"] == "Missing or malformed Authorization header."
def test_canonical_crm_task_write_routes_require_authentication() -> None:
client = _build_app(authenticated=False)
response = client.patch(
"/api/crm/tasks/33333333-3333-3333-3333-333333333333",
json={"status": "done"},
)
assert response.status_code == 401
assert response.json()["detail"] == "Missing or malformed Authorization header."
def test_canonical_crm_lead_stage_write_routes_require_authentication() -> None:
client = _build_app(authenticated=False)
response = client.patch(
"/api/crm/leads/22222222-2222-2222-2222-222222222222/stage",
json={"status": "qualified"},
)
assert response.status_code == 401
assert response.json()["detail"] == "Missing or malformed Authorization header."
def test_canonical_crm_opportunity_write_routes_require_authentication() -> None:
client = _build_app(authenticated=False)
response = client.patch(
"/api/crm/opportunities/55555555-5555-5555-5555-555555555555",
json={"stage": "negotiation"},
)
assert response.status_code == 401
assert response.json()["detail"] == "Missing or malformed Authorization header."
def test_canonical_crm_import_upload_requires_authentication() -> None:
client = _build_app(authenticated=False)
response = client.post(
"/api/crm/imports",
params={"source_system": "csv_upload"},
files={"file": ("contacts.csv", io.BytesIO(b"name,phone\nAmina,+9715000\n"), "text/csv")},
)
assert response.status_code == 401
assert response.json()["detail"] == "Missing or malformed Authorization header."
def test_canonical_crm_contacts_can_be_read_when_authenticated(monkeypatch) -> None:
client = _build_app(authenticated=True)
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"
assert search is None
assert buyer_type is None
assert status is None
return {
"contacts": [
{
"person_id": "11111111-1111-1111-1111-111111111111",
"full_name": "Amina Rahman",
"primary_email": "amina@example.com",
"primary_phone": "+971500000001",
"buyer_type": "high_intent",
"lead_id": "22222222-2222-2222-2222-222222222222",
"legacy_li_id": None,
"lead_status": "qualified",
"budget_band": "AED 12M",
"urgency": "high",
"primary_interest": "Marina Penthouse",
"intent_score": 0.94,
"engagement_score": 0.91,
"urgency_score": 0.88,
"interaction_count": 6,
"last_interaction_at": "2026-04-22T10:00:00+00:00",
"pending_tasks": 1,
"created_at": "2026-04-21T10:00:00+00:00",
}
],
"total": 1,
"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
payload = response.json()["data"]
assert payload["total"] == 1
assert payload["contacts"][0]["full_name"] == "Amina Rahman"

View File

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

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any
@@ -7,7 +8,10 @@ from typing import Any
from fastapi import FastAPI
from fastapi.testclient import TestClient
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
from backend.api.routes_crm import analytics_router, crm_router
from backend.auth.dependencies import UserPrincipal, get_current_user
def _now() -> datetime:
@@ -23,8 +27,36 @@ class FakeConn:
normalized = query.strip()
if "CREATE TABLE IF NOT EXISTS leads" in normalized or "CREATE TABLE IF NOT EXISTS chat_logs" in normalized:
return "CREATE"
if normalized.startswith("ALTER TABLE leads ADD COLUMN IF NOT EXISTS tenant_id"):
for lead in self.leads.values():
lead.setdefault("tenant_id", "tenant_velocity")
return "ALTER TABLE"
if normalized.startswith("ALTER TABLE chat_logs ADD COLUMN IF NOT EXISTS tenant_id"):
for log in self.chat_logs.values():
log.setdefault("tenant_id", "tenant_velocity")
return "ALTER TABLE"
if normalized.startswith("UPDATE leads") and "SET tenant_id = $1" in normalized:
for lead in self.leads.values():
if not lead.get("tenant_id"):
lead["tenant_id"] = args[0]
return "UPDATE"
if normalized.startswith("UPDATE chat_logs") and "SET tenant_id = $1" in normalized:
for log in self.chat_logs.values():
if not log.get("tenant_id"):
log["tenant_id"] = args[0]
return "UPDATE"
if normalized.startswith("ALTER TABLE leads ALTER COLUMN tenant_id SET DEFAULT"):
return "ALTER TABLE"
if normalized.startswith("ALTER TABLE chat_logs ALTER COLUMN tenant_id SET DEFAULT"):
return "ALTER TABLE"
if "CREATE INDEX IF NOT EXISTS" in normalized:
return "CREATE INDEX"
if normalized.startswith("DELETE FROM leads WHERE id = $1 AND tenant_id = $2"):
existed = self.leads.get(args[0])
if existed and existed["tenant_id"] == args[1]:
self.leads.pop(args[0], None)
return "DELETE 1"
return "DELETE 0"
if normalized.startswith("DELETE FROM leads WHERE id = $1"):
existed = self.leads.pop(args[0], None)
return "DELETE 1" if existed else "DELETE 0"
@@ -33,18 +65,22 @@ class FakeConn:
async def fetchrow(self, query: str, *args):
normalized = query.strip()
if "INSERT INTO leads" in normalized:
has_tenant = "tenant_id" in normalized.split("(", 1)[1].split(")", 1)[0]
tenant_id = args[1] if has_tenant else "tenant_velocity"
base = 2 if has_tenant else 1
row = {
"id": args[0],
"name": args[1],
"email": args[2],
"phone": args[3],
"source": args[4],
"notes": args[5],
"qualification": args[6],
"score": args[7],
"kanban_status": args[8],
"budget": args[9],
"unit_interest": args[10],
"tenant_id": tenant_id,
"name": args[base],
"email": args[base + 1],
"phone": args[base + 2],
"source": args[base + 3],
"notes": args[base + 4],
"qualification": args[base + 5],
"score": args[base + 6],
"kanban_status": args[base + 7],
"budget": args[base + 8],
"unit_interest": args[base + 9],
"metadata": {},
"created_at": _now(),
"updated_at": _now(),
@@ -53,7 +89,7 @@ class FakeConn:
return row
if normalized.startswith("UPDATE leads") and "SET kanban_status" in normalized:
lead = self.leads.get(args[0])
if not lead:
if not lead or lead["tenant_id"] != args[2]:
return None
lead["kanban_status"] = args[1]
lead["updated_at"] = _now()
@@ -66,7 +102,7 @@ class FakeConn:
return lead
if normalized.startswith("UPDATE leads") and "RETURNING" in normalized:
lead = self.leads.get(args[0])
if not lead:
if not lead or lead["tenant_id"] != args[12]:
return None
lead.update(
{
@@ -84,38 +120,47 @@ class FakeConn:
}
)
return lead
if normalized.startswith("SELECT id FROM leads WHERE id = $1"):
if normalized.startswith("SELECT id FROM leads WHERE id = $1 AND tenant_id = $2"):
lead = self.leads.get(args[0])
return {"id": lead["id"]} if lead else None
return {"id": lead["id"]} if lead and lead["tenant_id"] == args[1] else None
if "INSERT INTO chat_logs" in normalized:
has_tenant = "tenant_id" in normalized.split("(", 1)[1].split(")", 1)[0]
tenant_id = args[1] if has_tenant else "tenant_velocity"
base = 2 if has_tenant else 1
row = {
"id": args[0],
"lead_id": args[1],
"sender": args[2],
"channel": args[3],
"content": args[4],
"tenant_id": tenant_id,
"lead_id": args[base],
"sender": args[base + 1],
"channel": args[base + 2],
"content": args[base + 3],
"metadata": {},
"created_at": _now(),
}
self.chat_logs[row["id"]] = row
return row
if "FROM leads" in normalized and "WHERE id = $1 AND tenant_id = $2" in normalized:
lead = self.leads.get(args[0])
return lead if lead and lead["tenant_id"] == args[1] else None
raise AssertionError(f"Unexpected fetchrow query: {query}")
async def fetch(self, query: str, *args):
normalized = query.strip()
if "FROM leads" in normalized and "GROUP BY source" not in normalized and "GROUP BY qualification" not in normalized:
rows = list(self.leads.values())
if "WHERE kanban_status = $1" in normalized:
rows = [row for row in rows if row["kanban_status"] == args[0]]
rows = [row for row in self.leads.values() if row["tenant_id"] == args[0]]
if "WHERE tenant_id = $1 AND kanban_status = $2" in normalized:
rows = [row for row in rows if row["kanban_status"] == args[1]]
return rows
if "FROM chat_logs" in normalized:
rows = list(self.chat_logs.values())
if "WHERE lead_id = $1" in normalized:
rows = [row for row in rows if row["lead_id"] == args[0]]
rows = [row for row in self.chat_logs.values() if row["tenant_id"] == args[0]]
if "WHERE tenant_id = $1 AND lead_id = $2" in normalized:
rows = [row for row in rows if row["lead_id"] == args[1]]
return rows
if "GROUP BY source" in normalized:
grouped: dict[str, dict[str, Any]] = {}
for lead in self.leads.values():
if lead["tenant_id"] != args[0]:
continue
slot = grouped.setdefault(lead["source"], {"source": lead["source"], "lead_count": 0, "avg_score": 0.0})
slot["lead_count"] += 1
slot["avg_score"] += float(lead["score"])
@@ -125,6 +170,8 @@ class FakeConn:
if "GROUP BY qualification" in normalized:
grouped: dict[str, dict[str, Any]] = {}
for lead in self.leads.values():
if lead["tenant_id"] != args[0]:
continue
slot = grouped.setdefault(lead["qualification"], {"qualification": lead["qualification"], "lead_count": 0})
slot["lead_count"] += 1
return list(grouped.values())
@@ -140,15 +187,34 @@ class FakePool:
yield self.conn
def _build_client() -> tuple[TestClient, FakePool]:
def _build_client(
*,
authenticated: bool = True,
tenant_id: str = "tenant_velocity",
) -> tuple[TestClient, FakePool]:
app = FastAPI()
pool = FakePool()
app.state.db_pool = pool
app.include_router(crm_router, prefix="/api")
app.include_router(analytics_router, prefix="/api/analytics")
if authenticated:
app.dependency_overrides[get_current_user] = lambda: UserPrincipal("user-1", "ADMIN", tenant_id)
return TestClient(app), pool
def _build_shared_app(pool: FakePool, current_user: dict[str, str]) -> TestClient:
app = FastAPI()
app.state.db_pool = pool
app.include_router(crm_router, prefix="/api")
app.include_router(analytics_router, prefix="/api/analytics")
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
"user-1",
current_user["role"],
current_user["tenant_id"],
)
return TestClient(app)
def test_crm_crud_and_analytics_flow() -> None:
client, _pool = _build_client()
@@ -213,3 +279,42 @@ def test_lead_demographics_groups_by_source_and_qualification() -> None:
payload = response.json()["data"]
assert len(payload["by_source"]) == 2
assert any(row["qualification"] == "POTENTIAL" for row in payload["by_qualification"])
def test_crm_routes_require_authentication() -> None:
client, _pool = _build_client(authenticated=False)
response = client.get("/api/leads")
assert response.status_code == 401
assert response.json()["detail"] == "Missing or malformed Authorization header."
def test_crm_routes_are_scoped_to_authenticated_tenant() -> None:
pool = FakePool()
tenant_a = {"role": "ADMIN", "tenant_id": "tenant_alpha"}
client_a = _build_shared_app(pool, tenant_a)
create_response = client_a.post(
"/api/leads",
json={"name": "Tenant Alpha Lead", "source": "website", "score": 88},
)
assert create_response.status_code == 201
lead_id = create_response.json()["data"]["id"]
tenant_b = {"role": "ADMIN", "tenant_id": "tenant_beta"}
client_b = _build_shared_app(pool, tenant_b)
list_response = client_b.get("/api/leads")
assert list_response.status_code == 200
assert list_response.json()["meta"]["count"] == 0
get_response = client_b.get(f"/api/leads/{lead_id}")
assert get_response.status_code == 404
delete_response = client_b.delete(f"/api/leads/{lead_id}")
assert delete_response.status_code == 404
own_list_response = client_a.get("/api/leads")
assert own_list_response.status_code == 200
assert own_list_response.json()["meta"]["count"] == 1

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from comfy_engine.scripts.gateway_auth import (
extract_gateway_api_key,
is_gateway_request_authorized,
load_gateway_api_key,
)
def test_load_gateway_api_key_prefers_explicit_gateway_env() -> None:
env = {
"DREAM_WEAVER_API_KEY": "fallback-key",
"DREAM_WEAVER_GATEWAY_API_KEY": "primary-key",
}
assert load_gateway_api_key(env) == "primary-key"
def test_extract_gateway_api_key_supports_dedicated_headers() -> None:
assert extract_gateway_api_key({"x-dream-weaver-api-key": "dw-key"}) == "dw-key"
assert extract_gateway_api_key({"x-api-key": "legacy-key"}) == "legacy-key"
def test_extract_gateway_api_key_supports_bearer_authorization() -> None:
assert extract_gateway_api_key({"authorization": "Bearer shared-key"}) == "shared-key"
def test_gateway_auth_allows_open_gateways_and_blocks_wrong_keys() -> None:
assert is_gateway_request_authorized({}, None) is True
assert is_gateway_request_authorized({"x-dream-weaver-api-key": "correct"}, "correct") is True
assert is_gateway_request_authorized({"authorization": "Bearer wrong"}, "correct") is False

View File

@@ -0,0 +1,238 @@
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
from backend.auth.dependencies import UserPrincipal, get_current_user
def _now() -> datetime:
return datetime.now(timezone.utc)
class FakeConn:
def __init__(self) -> None:
self.leads: dict[str, dict[str, Any]] = {
"legacy-1": {
"id": "legacy-1",
"tenant_id": "tenant_alpha",
"name": "Legacy Duplicate",
"email": "legacy.duplicate@example.com",
"phone": "+971500000001",
"source": "website",
"notes": "Old legacy note",
"qualification": "HOT",
"score": 82,
"kanban_status": "Qualifying",
"budget": "AED 5M",
"unit_interest": "Legacy Tower",
"metadata": {},
"created_at": _now(),
"updated_at": _now(),
},
"legacy-2": {
"id": "legacy-2",
"tenant_id": "tenant_alpha",
"name": "Legacy Only",
"email": "legacy.only@example.com",
"phone": "+971500000002",
"source": "walkin",
"notes": "Pure legacy lead",
"qualification": "POTENTIAL",
"score": 74,
"kanban_status": "Negotiation",
"budget": "AED 7M",
"unit_interest": "Legacy Residence",
"metadata": {},
"created_at": _now(),
"updated_at": _now(),
},
}
self.chat_logs: dict[str, dict[str, Any]] = {}
async def execute(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "CREATE TABLE IF NOT EXISTS leads" in normalized or "CREATE TABLE IF NOT EXISTS chat_logs" in normalized:
return "CREATE"
if normalized.startswith("ALTER TABLE leads ADD COLUMN IF NOT EXISTS tenant_id"):
return "ALTER TABLE"
if normalized.startswith("ALTER TABLE chat_logs ADD COLUMN IF NOT EXISTS tenant_id"):
return "ALTER TABLE"
if normalized.startswith("UPDATE leads SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
return "UPDATE"
if normalized.startswith("UPDATE chat_logs SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
return "UPDATE"
if normalized.startswith("ALTER TABLE leads ALTER COLUMN tenant_id SET DEFAULT"):
return "ALTER TABLE"
if normalized.startswith("ALTER TABLE chat_logs ALTER COLUMN tenant_id SET DEFAULT"):
return "ALTER TABLE"
if "CREATE INDEX IF NOT EXISTS" in normalized:
return "CREATE INDEX"
raise AssertionError(f"Unexpected execute query: {query}")
async def fetch(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "FROM leads" in normalized:
rows = [row for row in self.leads.values() if row["tenant_id"] == args[0]]
return rows
if "FROM chat_logs" in normalized:
rows = [row for row in self.chat_logs.values() if row["tenant_id"] == args[0]]
if len(args) >= 2:
rows = [row for row in rows if row["lead_id"] == args[1]]
return rows
raise AssertionError(f"Unexpected fetch query: {query}")
async def fetchrow(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "FROM leads" in normalized and "WHERE id = $1 AND tenant_id = $2" in normalized:
row = self.leads.get(args[0])
return row if row and row["tenant_id"] == args[1] else None
raise AssertionError(f"Unexpected fetchrow query: {query}")
class FakePool:
def __init__(self) -> None:
self.conn = FakeConn()
@asynccontextmanager
async def acquire(self):
yield self.conn
def _build_client() -> TestClient:
app = FastAPI()
app.state.db_pool = FakePool()
app.include_router(routes_crm.crm_router, prefix="/api")
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
"user-1",
"ADMIN",
"tenant_alpha",
)
return TestClient(app)
def test_list_leads_merges_canonical_and_legacy_without_duplicate_shadow(monkeypatch) -> None:
client = _build_client()
async def fake_fetch_canonical_leads(conn: Any, tenant_id: str, search: str | None = None) -> list[dict[str, Any]]:
assert tenant_id == "tenant_alpha"
return [
{
"id": "legacy-1",
"name": "Canonical Preferred",
"email": "canonical@example.com",
"phone": "+971500009999",
"source": "website",
"notes": "Canonical note",
"qualification": "WHALE",
"score": 96,
"kanban_status": "Negotiation",
"stage": "negotiation",
"budget": "AED 18M",
"unit_interest": "Sky Deck Penthouse",
"metadata": {
"legacy_lead_id": "legacy-1",
"canonical_lead_id": "canon-1",
"canonical_person_id": "person-1",
},
"created_at": "2026-04-22T10:00:00+00:00",
"updated_at": "2026-04-22T11:00:00+00:00",
}
]
monkeypatch.setattr(routes_crm, "_fetch_canonical_leads", fake_fetch_canonical_leads)
response = client.get("/api/leads")
assert response.status_code == 200
payload = response.json()["data"]
assert len(payload) == 2
assert payload[0]["name"] == "Canonical Preferred"
assert payload[1]["id"] == "legacy-2"
def test_get_lead_resolves_canonical_record_by_canonical_id(monkeypatch) -> None:
client = _build_client()
async def fake_fetch_canonical_leads(conn: Any, tenant_id: str, search: str | None = None) -> list[dict[str, Any]]:
return [
{
"id": "legacy-1",
"name": "Canonical Preferred",
"email": "canonical@example.com",
"phone": "+971500009999",
"source": "website",
"notes": "Canonical note",
"qualification": "WHALE",
"score": 96,
"kanban_status": "Negotiation",
"stage": "negotiation",
"budget": "AED 18M",
"unit_interest": "Sky Deck Penthouse",
"metadata": {
"legacy_lead_id": "legacy-1",
"canonical_lead_id": "canon-1",
"canonical_person_id": "person-1",
},
"created_at": "2026-04-22T10:00:00+00:00",
"updated_at": "2026-04-22T11:00:00+00:00",
}
]
monkeypatch.setattr(routes_crm, "_fetch_canonical_leads", fake_fetch_canonical_leads)
response = client.get("/api/leads/canon-1")
assert response.status_code == 200
assert response.json()["data"]["name"] == "Canonical Preferred"
def test_chat_logs_fall_back_to_canonical_interactions(monkeypatch) -> None:
client = _build_client()
async def fake_fetch_canonical_chat_logs(conn: Any, tenant_id: str, lead_id: str, channel: str | None = None) -> list[dict[str, Any]]:
assert tenant_id == "tenant_alpha"
assert lead_id == "canon-1"
return [
{
"id": "interaction-1",
"lead_id": "canon-1",
"sender": "oracle",
"channel": "whatsapp",
"content": "Canonical interaction summary",
"metadata": {"source_of_truth": "canonical_crm"},
"created_at": "2026-04-22T12:00:00+00:00",
}
]
monkeypatch.setattr(routes_crm, "_fetch_canonical_chat_logs", fake_fetch_canonical_chat_logs)
response = client.get("/api/chat-logs", params={"lead_id": "canon-1"})
assert response.status_code == 200
payload = response.json()["data"]
assert len(payload) == 1
assert payload[0]["content"] == "Canonical interaction summary"
def test_list_leads_falls_back_to_legacy_when_canonical_bridge_unavailable(monkeypatch) -> None:
client = _build_client()
async def failing_fetch_canonical_leads(conn: Any, tenant_id: str, search: str | None = None) -> list[dict[str, Any]]:
raise RuntimeError("canonical tables unavailable")
monkeypatch.setattr(routes_crm, "_fetch_canonical_leads", failing_fetch_canonical_leads)
response = client.get("/api/leads")
assert response.status_code == 200
payload = response.json()["data"]
assert [lead["id"] for lead in payload] == ["legacy-1", "legacy-2"]

View File

@@ -0,0 +1,243 @@
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.routes_crm import crm_router
from backend.auth.dependencies import UserPrincipal, get_current_user
def _now() -> datetime:
return datetime.now(timezone.utc)
class FakeConn:
def __init__(self) -> None:
self.leads: dict[str, dict[str, Any]] = {}
self.chat_logs: dict[str, dict[str, Any]] = {}
async def execute(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "CREATE TABLE IF NOT EXISTS leads" in normalized or "CREATE TABLE IF NOT EXISTS chat_logs" in normalized:
return "CREATE"
if normalized.startswith("ALTER TABLE leads ADD COLUMN IF NOT EXISTS tenant_id"):
return "ALTER TABLE"
if normalized.startswith("ALTER TABLE chat_logs ADD COLUMN IF NOT EXISTS tenant_id"):
return "ALTER TABLE"
if normalized.startswith("UPDATE leads SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
return "UPDATE"
if normalized.startswith("UPDATE chat_logs SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
return "UPDATE"
if normalized.startswith("ALTER TABLE leads ALTER COLUMN tenant_id SET DEFAULT"):
return "ALTER TABLE"
if normalized.startswith("ALTER TABLE chat_logs ALTER COLUMN tenant_id SET DEFAULT"):
return "ALTER TABLE"
if "CREATE INDEX IF NOT EXISTS" in normalized:
return "CREATE INDEX"
if normalized.startswith("DELETE FROM leads WHERE id = $1 AND tenant_id = $2"):
existed = self.leads.get(args[0])
if existed and existed["tenant_id"] == args[1]:
self.leads.pop(args[0], None)
return "DELETE 1"
return "DELETE 0"
raise AssertionError(f"Unexpected execute query: {query}")
async def fetchrow(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "INSERT INTO leads" in normalized:
row = {
"id": args[0],
"tenant_id": args[1],
"name": args[2],
"email": args[3],
"phone": args[4],
"source": args[5],
"notes": args[6],
"qualification": args[7],
"score": args[8],
"kanban_status": args[9],
"budget": args[10],
"unit_interest": args[11],
"metadata": {},
"created_at": _now(),
"updated_at": _now(),
}
self.leads[row["id"]] = row
return row
if normalized.startswith("UPDATE leads") and "RETURNING" in normalized:
row = self.leads.get(args[0])
tenant_arg = args[-1]
if not row or row["tenant_id"] != tenant_arg:
return None
if len(args) == 13:
row.update(
{
"name": args[1],
"email": args[2],
"phone": args[3],
"source": args[4],
"notes": args[5],
"qualification": args[6],
"score": args[7],
"kanban_status": args[8],
"budget": args[9],
"unit_interest": args[10],
"updated_at": _now(),
}
)
else:
row.update(
{
"kanban_status": args[1],
"qualification": "HOT" if row["score"] >= 45 else row["qualification"],
"updated_at": _now(),
}
)
return row
if normalized.startswith("SELECT id, name, email, phone, source, notes, qualification, score, kanban_status, budget, unit_interest, metadata, created_at, updated_at FROM leads WHERE id = $1 AND tenant_id = $2"):
row = self.leads.get(args[0])
return row if row and row["tenant_id"] == args[1] else None
if normalized.startswith("INSERT INTO chat_logs"):
row = {
"id": args[0],
"tenant_id": args[1],
"lead_id": args[2],
"sender": args[3],
"channel": args[4],
"content": args[5],
"metadata": {},
"created_at": _now(),
}
self.chat_logs[row["id"]] = row
return row
raise AssertionError(f"Unexpected fetchrow query: {query}")
class FakePool:
def __init__(self) -> None:
self.conn = FakeConn()
@asynccontextmanager
async def acquire(self):
yield self.conn
def _build_client() -> tuple[TestClient, FakePool]:
app = FastAPI()
pool = FakePool()
app.state.db_pool = pool
app.include_router(crm_router, prefix="/api")
app.dependency_overrides[get_current_user] = lambda: UserPrincipal("user-1", "ADMIN", "tenant_alpha")
return TestClient(app), pool
def test_create_lead_triggers_canonical_write_bridge(monkeypatch) -> None:
client, _pool = _build_client()
calls: list[dict[str, Any]] = []
async def fake_sync(request, conn, user, legacy_lead):
calls.append({"lead_id": legacy_lead["id"], "name": legacy_lead["name"], "tenant_id": user.tenant_id})
return {"person_id": "person-1", "lead_id": "canon-1"}
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_lead_bridge", fake_sync)
response = client.post("/api/leads", json={"name": "Amina Rahman", "source": "website", "score": 88})
assert response.status_code == 201
assert len(calls) == 1
assert calls[0]["name"] == "Amina Rahman"
assert calls[0]["tenant_id"] == "tenant_alpha"
def test_update_lead_triggers_canonical_write_bridge(monkeypatch) -> None:
client, _pool = _build_client()
calls: list[dict[str, Any]] = []
async def fake_sync(request, conn, user, legacy_lead):
calls.append({"lead_id": legacy_lead["id"], "status": legacy_lead["kanban_status"]})
return {"person_id": "person-1", "lead_id": "canon-1"}
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_lead_bridge", fake_sync)
create_response = client.post("/api/leads", json={"name": "Lead One", "source": "website", "score": 60})
lead_id = create_response.json()["data"]["id"]
calls.clear()
update_response = client.put(
f"/api/leads/{lead_id}",
json={"name": "Lead One Updated", "source": "website", "score": 75, "kanban_status": "negotiation"},
)
assert update_response.status_code == 200
assert len(calls) == 1
assert calls[0]["lead_id"] == lead_id
assert calls[0]["status"] == "Negotiation"
def test_create_chat_log_triggers_canonical_chat_bridge(monkeypatch) -> None:
client, _pool = _build_client()
calls: list[dict[str, Any]] = []
async def fake_lead_sync(request, conn, user, legacy_lead):
return {"person_id": "person-1", "lead_id": "canon-1"}
async def fake_chat_sync(request, conn, user, legacy_chat_log, legacy_lead):
calls.append(
{
"chat_log_id": legacy_chat_log["id"],
"lead_id": legacy_chat_log["lead_id"],
"content": legacy_chat_log["content"],
"lead_name": legacy_lead["name"],
}
)
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_lead_bridge", fake_lead_sync)
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_chat_log_bridge", fake_chat_sync)
create_response = client.post("/api/leads", json={"name": "Lead One", "source": "website", "score": 60})
lead_id = create_response.json()["data"]["id"]
response = client.post(
"/api/chat-logs",
json={"lead_id": lead_id, "sender": "oracle", "channel": "whatsapp", "content": "Follow up tonight"},
)
assert response.status_code == 201
assert len(calls) == 1
assert calls[0]["lead_id"] == lead_id
assert calls[0]["content"] == "Follow up tonight"
def test_move_and_delete_trigger_canonical_write_bridges(monkeypatch) -> None:
client, _pool = _build_client()
move_calls: list[dict[str, Any]] = []
delete_calls: list[str] = []
async def fake_sync(request, conn, user, legacy_lead):
move_calls.append({"lead_id": legacy_lead["id"], "status": legacy_lead["kanban_status"]})
return {"person_id": "person-1", "lead_id": "canon-1"}
async def fake_delete(request, conn, user, legacy_lead_id):
delete_calls.append(legacy_lead_id)
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_lead_bridge", fake_sync)
monkeypatch.setattr("backend.api.routes_crm._delete_canonical_lead_bridge", fake_delete)
create_response = client.post("/api/leads", json={"name": "Lead One", "source": "website", "score": 60})
lead_id = create_response.json()["data"]["id"]
move_calls.clear()
move_response = client.put("/api/kanban/move", json={"lead_id": lead_id, "target_status": "site_visit"})
delete_response = client.delete(f"/api/leads/{lead_id}")
assert move_response.status_code == 200
assert delete_response.status_code == 200
assert move_calls == [{"lead_id": lead_id, "status": "Site Visit"}]
assert delete_calls == [lead_id]

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
import os
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
from backend.migrations.runner import discover_migrations
from backend.observability import RequestObservabilityMiddleware
def test_migration_discovery_is_ordered_and_checksummed() -> None:
migrations = discover_migrations(Path("backend/migrations/versions"))
assert migrations
assert migrations == sorted(migrations, key=lambda migration: migration.version)
assert all(len(migration.checksum) == 64 for migration in migrations)
assert len({migration.version for migration in migrations}) == len(migrations)
def test_observability_middleware_adds_request_headers_and_snapshot() -> None:
app = FastAPI()
app.add_middleware(RequestObservabilityMiddleware)
@app.get("/ping")
async def ping() -> dict[str, str]:
return {"status": "ok"}
client = TestClient(app)
response = client.get("/ping", headers={"X-Request-ID": "req-test"})
assert response.status_code == 200
assert response.headers["X-Request-ID"] == "req-test"
assert "X-Response-Time-Ms" in response.headers
assert app.state.request_metrics[-1].request_id == "req-test"
assert app.state.request_metrics[-1].path == "/ping"

View File

@@ -0,0 +1,470 @@
from __future__ import annotations
import os
import uuid
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.routes_inventory import router as inventory_router
from backend.api.routes_mobile_edge import router as mobile_edge_router
from backend.auth.dependencies import UserPrincipal, get_current_user
def _now() -> datetime:
return datetime.now(timezone.utc)
class FakeSurfaceConn:
def __init__(self) -> None:
self.events: dict[str, dict[str, Any]] = {}
self.calendar_events: dict[str, dict[str, Any]] = {}
self.properties: dict[str, dict[str, Any]] = {}
self.import_batches: dict[str, dict[str, Any]] = {}
async def fetchrow(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "INSERT INTO edge_communication_events" in normalized:
event_id = str(uuid.uuid4())
row = {
"event_id": event_id,
"tenant_id": args[0],
"lead_id": args[1],
"channel": args[2],
"direction": args[3],
"provider": args[4],
"capture_mode": args[5],
"consent_state": args[6],
"duration_seconds": args[7],
"summary": args[8],
"raw_reference": args[9],
"recording_ref": args[10],
"provider_metadata": args[11],
"timestamp": _now(),
"created_at": _now(),
}
self.events[event_id] = row
return {"event_id": event_id, "created_at": row["created_at"]}
if "INSERT INTO user_calendar_events" in normalized:
calendar_event_id = str(uuid.uuid4())
row = {
"calendar_event_id": calendar_event_id,
"tenant_id": args[0],
"owner_user_id": args[1],
"lead_id": args[2],
"source_event_id": args[3],
"title": args[4],
"description": args[5],
"start_at": args[6],
"end_at": args[7],
"all_day": args[8],
"status": args[9],
"reminder_minutes": args[10],
"created_by": args[11],
"location": args[12],
"metadata": args[13],
"created_at": _now(),
}
self.calendar_events[calendar_event_id] = row
return row
if "INSERT INTO inventory_import_batches" in normalized:
batch_id = str(uuid.uuid4())
row = {
"batch_id": batch_id,
"tenant_id": args[0],
"source_type": args[1],
"submitted_by": args[2],
"total_rows": args[3],
"source_file_ref": args[4],
"accepted_rows": 0,
"rejected_rows": 0,
"status": "pending",
"created_at": _now(),
"completed_at": None,
}
self.import_batches[batch_id] = row
return {
"batch_id": batch_id,
"status": row["status"],
"created_at": row["created_at"],
}
if "INSERT INTO inventory_properties" in normalized:
property_id = str(uuid.uuid4())
row = {
"property_id": property_id,
"tenant_id": args[0],
"batch_id": args[1],
"source_id": args[2],
"project_name": args[3],
"developer_name": args[4],
"location": args[5],
"property_type": args[6],
"price_bands": args[7],
"unit_mix": args[8],
"amenities": args[9],
"status": args[10],
"validation_state": args[11],
"ingested_at": None,
"created_at": _now(),
"updated_at": _now(),
}
self.properties[property_id] = row
return {"property_id": property_id, "created_at": row["created_at"]}
if normalized.startswith("SELECT * FROM inventory_import_batches WHERE batch_id=$1 AND tenant_id=$2"):
row = self.import_batches.get(args[0])
return row if row and row["tenant_id"] == args[1] else None
if normalized.startswith("SELECT * FROM inventory_properties WHERE property_id=$1 AND tenant_id=$2"):
row = self.properties.get(args[0])
return row if row and row["tenant_id"] == args[1] else None
raise AssertionError(f"Unexpected fetchrow query: {query}")
async def fetch(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "FROM edge_communication_events" in normalized:
tenant_id, lead_id, limit, offset = args
rows = [
{
"event_id": row["event_id"],
"lead_id": row["lead_id"],
"channel": row["channel"],
"direction": row["direction"],
"provider": row["provider"],
"capture_mode": row["capture_mode"],
"consent_state": row["consent_state"],
"timestamp": row["timestamp"].isoformat(),
"duration_seconds": row["duration_seconds"],
"summary": row["summary"],
"raw_reference": row["raw_reference"],
"recording_ref": row["recording_ref"],
"provider_metadata": row["provider_metadata"],
"created_at": row["created_at"].isoformat(),
}
for row in self.events.values()
if row["tenant_id"] == tenant_id and row["lead_id"] == lead_id
]
rows.sort(key=lambda item: item["timestamp"], reverse=True)
return rows[offset : offset + limit]
if "FROM user_calendar_events" in normalized:
tenant_id = args[0]
owner_user_id = args[1]
limit = args[-1]
rows = [
{
"calendar_event_id": row["calendar_event_id"],
"lead_id": row["lead_id"],
"title": row["title"],
"description": row["description"],
"start_at": row["start_at"],
"end_at": row["end_at"],
"all_day": row["all_day"],
"status": row["status"],
"reminder_minutes": row["reminder_minutes"],
"created_by": row["created_by"],
"location": row["location"],
"metadata": row["metadata"],
"created_at": row["created_at"].isoformat(),
}
for row in self.calendar_events.values()
if row["tenant_id"] == tenant_id and row["owner_user_id"] == owner_user_id
and row["status"] != "cancelled"
]
rows.sort(key=lambda item: item["start_at"])
return rows[:limit]
if "FROM inventory_import_batches" in normalized:
tenant_id, limit, offset = args
rows = [
{
"batch_id": row["batch_id"],
"source_type": row["source_type"],
"submitted_by": row["submitted_by"],
"status": row["status"],
"total_rows": row["total_rows"],
"accepted_rows": row["accepted_rows"],
"rejected_rows": row["rejected_rows"],
"created_at": row["created_at"].isoformat(),
"completed_at": row["completed_at"],
}
for row in self.import_batches.values()
if row["tenant_id"] == tenant_id
]
rows.sort(key=lambda item: item["created_at"], reverse=True)
return rows[offset : offset + limit]
if "FROM inventory_properties" in normalized:
params = list(args)
tenant_id = params[0]
limit = params[-2]
offset = params[-1]
status_filter = None
property_type = None
if len(params) == 4:
status_filter = params[1]
if len(params) == 5:
status_filter = params[1]
property_type = params[2]
rows = [
{
"property_id": row["property_id"],
"project_name": row["project_name"],
"developer_name": row["developer_name"],
"property_type": row["property_type"],
"location": row["location"],
"price_bands": row["price_bands"],
"unit_mix": row["unit_mix"],
"status": row["status"],
"ingested_at": row["ingested_at"],
"created_at": row["created_at"].isoformat(),
}
for row in self.properties.values()
if row["tenant_id"] == tenant_id
and (status_filter is None or row["status"] == status_filter)
and (property_type is None or row["property_type"] == property_type)
]
rows.sort(key=lambda item: item["created_at"], reverse=True)
return rows[offset : offset + limit]
raise AssertionError(f"Unexpected fetch query: {query}")
async def fetchval(self, query: str, *args):
normalized = " ".join(query.strip().split())
if normalized.startswith("SELECT COUNT(*) FROM edge_communication_events WHERE tenant_id = $1 AND lead_id = $2"):
tenant_id, lead_id = args
return sum(
1
for row in self.events.values()
if row["tenant_id"] == tenant_id and row["lead_id"] == lead_id
)
if normalized.startswith("SELECT COUNT(*) FROM inventory_import_batches WHERE tenant_id=$1"):
tenant_id = args[0]
return sum(1 for row in self.import_batches.values() if row["tenant_id"] == tenant_id)
if normalized.startswith("SELECT COUNT(*) FROM inventory_properties WHERE tenant_id = $1"):
tenant_id = args[0]
return sum(1 for row in self.properties.values() if row["tenant_id"] == tenant_id)
raise AssertionError(f"Unexpected fetchval query: {query}")
async def execute(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "UPDATE user_calendar_events SET status='cancelled'" in normalized:
tenant_id, owner_user_id, calendar_event_id = args
row = self.calendar_events.get(calendar_event_id)
if not row or row["tenant_id"] != tenant_id or row["owner_user_id"] != owner_user_id:
return "UPDATE 0"
row["status"] = "cancelled"
return "UPDATE 1"
if normalized.startswith("UPDATE user_calendar_events SET"):
tenant_id = args[-3]
owner_user_id = args[-2]
calendar_event_id = args[-1]
row = self.calendar_events.get(calendar_event_id)
if not row or row["tenant_id"] != tenant_id or row["owner_user_id"] != owner_user_id:
return "UPDATE 0"
assignments = normalized.split(" SET ", 1)[1].split(" WHERE ", 1)[0].split(", ")
for assignment, value in zip(assignments, args):
column = assignment.split(" = ", 1)[0]
if column not in {"tenant_id", "owner_user_id"}:
row[column] = value
return "UPDATE 1"
raise AssertionError(f"Unexpected execute query: {query}")
class FakeSurfacePool:
def __init__(self) -> None:
self.conn = FakeSurfaceConn()
@asynccontextmanager
async def acquire(self):
yield self.conn
def _build_shared_app(pool: FakeSurfacePool, current_user: dict[str, str]) -> TestClient:
app = FastAPI()
app.state.db_pool = pool
app.include_router(mobile_edge_router, prefix="/api/mobile-edge")
app.include_router(inventory_router, prefix="/api/inventory")
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
current_user["user_id"],
current_user["role"],
current_user["tenant_id"],
)
return TestClient(app)
def test_mobile_edge_event_routes_scope_by_tenant_id_instead_of_role() -> None:
pool = FakeSurfacePool()
tenant_a = _build_shared_app(
pool,
{"user_id": "user-a", "role": "ADMIN", "tenant_id": "tenant_alpha"},
)
tenant_b = _build_shared_app(
pool,
{"user_id": "user-b", "role": "ADMIN", "tenant_id": "tenant_beta"},
)
create_response = tenant_a.post(
"/api/mobile-edge/events",
json={
"lead_id": "lead-123",
"channel": "whatsapp_message",
"direction": "inbound",
"capture_mode": "operator_note",
"consent_state": "granted",
"summary": "Client asked for a marina brochure.",
},
)
assert create_response.status_code == 201
tenant_a_events = tenant_a.get("/api/mobile-edge/events", params={"lead_id": "lead-123", "limit": 10})
assert tenant_a_events.status_code == 200
assert tenant_a_events.json()["total"] == 1
tenant_b_events = tenant_b.get("/api/mobile-edge/events", params={"lead_id": "lead-123", "limit": 10})
assert tenant_b_events.status_code == 200
assert tenant_b_events.json()["total"] == 0
assert tenant_b_events.json()["events"] == []
def test_mobile_edge_calendar_routes_scope_by_tenant_id_instead_of_role() -> None:
pool = FakeSurfacePool()
tenant_a = _build_shared_app(
pool,
{"user_id": "user-a", "role": "ADMIN", "tenant_id": "tenant_alpha"},
)
tenant_b = _build_shared_app(
pool,
{"user_id": "user-b", "role": "ADMIN", "tenant_id": "tenant_beta"},
)
create_response = tenant_a.post(
"/api/mobile-edge/calendar",
json={
"lead_id": "lead-123",
"title": "Private site visit",
"description": "Walkthrough with the lead",
"start_at": "2026-04-23T10:00:00Z",
"end_at": "2026-04-23T11:00:00Z",
"all_day": False,
"status": "tentative",
"reminder_minutes": [15],
"location": "Dubai Marina",
"metadata": {"source": "ipad"},
},
)
assert create_response.status_code == 201
created_payload = create_response.json()
assert created_payload["status"] == "ok"
assert created_payload["event"]["title"] == "Private site visit"
assert created_payload["event"]["location"] == "Dubai Marina"
assert created_payload["event"]["status"] == "tentative"
assert created_payload["event"]["reminder_minutes"] == [15]
tenant_a_calendar = tenant_a.get("/api/mobile-edge/calendar")
assert tenant_a_calendar.status_code == 200
assert len(tenant_a_calendar.json()["events"]) == 1
event_id = created_payload["event"]["calendar_event_id"]
update_response = tenant_a.patch(
f"/api/mobile-edge/calendar/{event_id}",
json={"status": "confirmed"},
)
assert update_response.status_code == 200
tenant_a_calendar = tenant_a.get("/api/mobile-edge/calendar")
assert tenant_a_calendar.json()["events"][0]["status"] == "confirmed"
cancel_response = tenant_a.delete(f"/api/mobile-edge/calendar/{event_id}")
assert cancel_response.status_code == 200
tenant_a_calendar = tenant_a.get("/api/mobile-edge/calendar")
assert tenant_a_calendar.json()["events"] == []
tenant_b_calendar = tenant_b.get("/api/mobile-edge/calendar")
assert tenant_b_calendar.status_code == 200
assert tenant_b_calendar.json()["events"] == []
def test_inventory_property_routes_scope_by_tenant_id_instead_of_role() -> None:
pool = FakeSurfacePool()
tenant_a = _build_shared_app(
pool,
{"user_id": "user-a", "role": "ADMIN", "tenant_id": "tenant_alpha"},
)
tenant_b = _build_shared_app(
pool,
{"user_id": "user-b", "role": "ADMIN", "tenant_id": "tenant_beta"},
)
create_response = tenant_a.post(
"/api/inventory/properties",
json={
"project_name": "Marina One",
"developer_name": "Desi Neuron Estates",
"location": {"city": "Dubai", "district": "Marina"},
"property_type": "apartment",
"price_bands": [{"label": "from", "amount": 2400000}],
"unit_mix": [{"type": "2BR", "count": 18}],
"amenities": ["pool", "gym"],
"status": "active",
"validation_state": {"validated": True},
},
)
assert create_response.status_code == 201
tenant_a_properties = tenant_a.get("/api/inventory/properties", params={"limit": 20})
assert tenant_a_properties.status_code == 200
assert tenant_a_properties.json()["total"] == 1
tenant_b_properties = tenant_b.get("/api/inventory/properties", params={"limit": 20})
assert tenant_b_properties.status_code == 200
assert tenant_b_properties.json()["total"] == 0
assert tenant_b_properties.json()["properties"] == []
def test_inventory_import_batch_routes_scope_by_tenant_id_instead_of_role() -> None:
pool = FakeSurfacePool()
tenant_a = _build_shared_app(
pool,
{"user_id": "user-a", "role": "ADMIN", "tenant_id": "tenant_alpha"},
)
tenant_b = _build_shared_app(
pool,
{"user_id": "user-b", "role": "ADMIN", "tenant_id": "tenant_beta"},
)
create_response = tenant_a.post(
"/api/inventory/import-batches",
json={
"source_type": "csv",
"source_file_ref": "s3://velocity/imports/marina.csv",
"total_rows": 24,
},
)
assert create_response.status_code == 201
tenant_a_batches = tenant_a.get("/api/inventory/import-batches", params={"limit": 20})
assert tenant_a_batches.status_code == 200
assert tenant_a_batches.json()["total"] == 1
tenant_b_batches = tenant_b.get("/api/inventory/import-batches", params={"limit": 20})
assert tenant_b_batches.status_code == 200
assert tenant_b_batches.json()["total"] == 0
assert tenant_b_batches.json()["batches"] == []