feat: Ipad app features and Dream Weaver for Velocity WebOS
This commit is contained in:
212
backend/tests/test_auth_tenant_contract.py
Normal file
212
backend/tests/test_auth_tenant_contract.py
Normal 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)
|
||||
162
backend/tests/test_canonical_crm_auth.py
Normal file
162
backend/tests/test_canonical_crm_auth.py
Normal 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"
|
||||
517
backend/tests/test_canonical_crm_tenant_scoping.py
Normal file
517
backend/tests/test_canonical_crm_tenant_scoping.py
Normal 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."
|
||||
@@ -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
|
||||
|
||||
30
backend/tests/test_dream_weaver_gateway_auth.py
Normal file
30
backend/tests/test_dream_weaver_gateway_auth.py
Normal 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
|
||||
238
backend/tests/test_legacy_crm_canonical_bridge.py
Normal file
238
backend/tests/test_legacy_crm_canonical_bridge.py
Normal 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"]
|
||||
243
backend/tests/test_legacy_crm_write_bridge.py
Normal file
243
backend/tests/test_legacy_crm_write_bridge.py
Normal 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]
|
||||
40
backend/tests/test_migrations_and_observability.py
Normal file
40
backend/tests/test_migrations_and_observability.py
Normal 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"
|
||||
|
||||
470
backend/tests/test_surface_route_tenant_scoping.py
Normal file
470
backend/tests/test_surface_route_tenant_scoping.py
Normal 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"] == []
|
||||
Reference in New Issue
Block a user