fix(tests): backend test suite - app version, DB roles, admin RBAC, companies route, field names, DeletionLog, ABAC, imports
This commit is contained in:
@@ -75,6 +75,25 @@ class CreateContactCommand(BaseCommand):
|
|||||||
self._created_contact_id = uuid.UUID(serialized["id"])
|
self._created_contact_id = uuid.UUID(serialized["id"])
|
||||||
self._serialized = serialized
|
self._serialized = serialized
|
||||||
|
|
||||||
|
# Handle company_ids — create ContactPerson links
|
||||||
|
company_ids = self.data.get("company_ids")
|
||||||
|
if company_ids:
|
||||||
|
from app.models.contact import ContactPerson
|
||||||
|
for cid in company_ids:
|
||||||
|
cp = ContactPerson(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
contact_id=uuid.UUID(cid),
|
||||||
|
displayname=serialized.get("displayname", ""),
|
||||||
|
firstname=self.data.get("firstname"),
|
||||||
|
lastname=self.data.get("surname"),
|
||||||
|
email=self.data.get("email_1"),
|
||||||
|
phone=self.data.get("phone_1"),
|
||||||
|
created_by=user_id,
|
||||||
|
updated_by=user_id,
|
||||||
|
)
|
||||||
|
db.add(cp)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
# Enqueue outbox events
|
# Enqueue outbox events
|
||||||
events: list[dict] = []
|
events: list[dict] = []
|
||||||
await enqueue_outbox_event(db, tenant_id, "contact.created", {
|
await enqueue_outbox_event(db, tenant_id, "contact.created", {
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ class Settings(BaseSettings):
|
|||||||
extra="ignore",
|
extra="ignore",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# App version (used for plugin compatibility checks)
|
||||||
|
app_version: str = "1.0.0"
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
environment: Literal["development", "production", "testing"] = "development"
|
environment: Literal["development", "production", "testing"] = "development"
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
|
|||||||
@@ -299,3 +299,4 @@ async def update_session_tenant(
|
|||||||
|
|
||||||
# ⚠️ Legacy check_permission and filter_fields_by_permission removed from auth.py.
|
# ⚠️ Legacy check_permission and filter_fields_by_permission removed from auth.py.
|
||||||
# Use app.core.permissions.check_permission and app.core.permissions.filter_fields_by_permission instead.
|
# Use app.core.permissions.check_permission and app.core.permissions.filter_fields_by_permission instead.
|
||||||
|
# Tests should import directly from app.core.permissions.
|
||||||
|
|||||||
@@ -417,6 +417,8 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(groups.router)
|
app.include_router(groups.router)
|
||||||
app.include_router(tenants.router)
|
app.include_router(tenants.router)
|
||||||
app.include_router(notifications.router)
|
app.include_router(notifications.router)
|
||||||
|
from app.routes.companies import router as companies_router
|
||||||
|
app.include_router(companies_router)
|
||||||
app.include_router(contacts.router)
|
app.include_router(contacts.router)
|
||||||
app.include_router(contact_folders.router)
|
app.include_router(contact_folders.router)
|
||||||
app.include_router(contact_folder_permissions.router)
|
app.include_router(contact_folder_permissions.router)
|
||||||
|
|||||||
+5
-1
@@ -1,6 +1,7 @@
|
|||||||
"""AuditLog model — audit trail for all create/update/delete/login actions.
|
"""AuditLog model — audit trail for all create/update/delete/login actions.
|
||||||
|
|
||||||
Note: DeletionLog has been merged into EntityHistory (action='delete').
|
Note: DeletionLog has been merged into EntityHistory (action='delete').
|
||||||
|
DeletionLog is re-exported here as an alias for backward compatibility.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -16,6 +17,10 @@ from sqlalchemy.orm import Mapped, mapped_column
|
|||||||
|
|
||||||
from app.core.db import Base, TenantMixin
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
# Re-export EntityHistory as DeletionLog for backward compatibility.
|
||||||
|
# Tests import DeletionLog from app.models.audit and use entity_snapshot attribute.
|
||||||
|
from app.models.entity_history import EntityHistory as DeletionLog
|
||||||
|
|
||||||
|
|
||||||
class AuditLog(Base, TenantMixin):
|
class AuditLog(Base, TenantMixin):
|
||||||
"""Audit trail for all create/update/delete/login actions."""
|
"""Audit trail for all create/update/delete/login actions."""
|
||||||
@@ -35,4 +40,3 @@ class AuditLog(Base, TenantMixin):
|
|||||||
timestamp: Mapped[datetime] = mapped_column(
|
timestamp: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
|
DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -39,3 +39,8 @@ class EntityHistory(Base, TenantMixin, OwnedMixin):
|
|||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
|
DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def entity_snapshot(self) -> dict[str, Any] | None:
|
||||||
|
"""Compatibility alias for snapshot_before (used by DeletionLog tests)."""
|
||||||
|
return self.snapshot_before
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"""Companies routes — CRUD, search, filter, export, contact links.
|
||||||
|
|
||||||
|
Companies are Contact entities with type='company'.
|
||||||
|
Industry and description are stored in the custom JSONB field.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.deps import get_current_user, get_redis_dep, require_permission
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.contact import Contact
|
||||||
|
from app.services.entity_history_service import record_history
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/companies", tags=["companies"])
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_company(c: Contact) -> dict[str, Any]:
|
||||||
|
custom = c.custom or {}
|
||||||
|
return {
|
||||||
|
"id": str(c.id),
|
||||||
|
"name": c.name,
|
||||||
|
"displayname": c.displayname,
|
||||||
|
"status": c.status,
|
||||||
|
"industry": custom.get("industry"),
|
||||||
|
"description": custom.get("description"),
|
||||||
|
"email_1": c.email_1,
|
||||||
|
"email_2": c.email_2,
|
||||||
|
"phone_1": c.phone_1,
|
||||||
|
"phone_2": c.phone_2,
|
||||||
|
"website": c.website,
|
||||||
|
"mailing_city": c.mailing_city,
|
||||||
|
"mailing_postalcode": c.mailing_postalcode,
|
||||||
|
"mailing_country": c.mailing_country,
|
||||||
|
"tags": c.tags,
|
||||||
|
"custom": c.custom,
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_companies(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
search: str | None = Query(None),
|
||||||
|
industry: str | None = Query(None),
|
||||||
|
sort_by: str = Query("name"),
|
||||||
|
sort_order: str = Query("asc", pattern="^(asc|desc)$"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("contacts:read")),
|
||||||
|
):
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
q = select(Contact).where(
|
||||||
|
Contact.tenant_id == tenant_id,
|
||||||
|
Contact.type == "company",
|
||||||
|
Contact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
if search:
|
||||||
|
q = q.where(Contact.search_tsv.match(search) | Contact.name.ilike(f"%{search}%"))
|
||||||
|
if industry:
|
||||||
|
q = q.where(Contact.custom["industry"].astext == industry)
|
||||||
|
count_q = select(func.count()).select_from(q.subquery())
|
||||||
|
total = (await db.execute(count_q)).scalar() or 0
|
||||||
|
sort_col = getattr(Contact, sort_by, Contact.name)
|
||||||
|
if sort_order == "desc":
|
||||||
|
sort_col = sort_col.desc()
|
||||||
|
q = q.order_by(sort_col).offset((page - 1) * page_size).limit(page_size)
|
||||||
|
result = await db.execute(q)
|
||||||
|
companies = result.scalars().all()
|
||||||
|
return {"items": [_serialize_company(c) for c in companies], "total": total, "page": page, "page_size": page_size}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_company(
|
||||||
|
body: dict[str, Any],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis=Depends(get_redis_dep),
|
||||||
|
current_user: dict = Depends(require_permission("contacts:write")),
|
||||||
|
):
|
||||||
|
name = body.get("name")
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=422, detail="name is required")
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
custom = {}
|
||||||
|
if body.get("industry"):
|
||||||
|
custom["industry"] = body["industry"]
|
||||||
|
if body.get("description"):
|
||||||
|
custom["description"] = body["description"]
|
||||||
|
for k, v in body.items():
|
||||||
|
if k not in ("name", "industry", "description"):
|
||||||
|
custom[k] = v
|
||||||
|
company = Contact(
|
||||||
|
tenant_id=tenant_id, type="company", name=name, displayname=name,
|
||||||
|
status=body.get("status", "lead"), custom=custom,
|
||||||
|
created_by=user_id, updated_by=user_id,
|
||||||
|
)
|
||||||
|
db.add(company)
|
||||||
|
await db.flush()
|
||||||
|
audit_entry = AuditLog(
|
||||||
|
tenant_id=tenant_id, user_id=user_id, action="create",
|
||||||
|
entity_type="contact", entity_id=company.id,
|
||||||
|
changes={"name": name, "type": "company", **custom},
|
||||||
|
)
|
||||||
|
db.add(audit_entry)
|
||||||
|
await db.flush()
|
||||||
|
return _serialize_company(company)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export")
|
||||||
|
async def export_companies(
|
||||||
|
format: str = Query("csv", pattern="^(csv|xlsx)$"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("contacts:read")),
|
||||||
|
):
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
q = select(Contact).where(
|
||||||
|
Contact.tenant_id == tenant_id, Contact.type == "company",
|
||||||
|
Contact.deleted_at.is_(None),
|
||||||
|
).order_by(Contact.name)
|
||||||
|
result = await db.execute(q)
|
||||||
|
companies = result.scalars().all()
|
||||||
|
rows = [_serialize_company(c) for c in companies]
|
||||||
|
if format == "csv":
|
||||||
|
import csv
|
||||||
|
output = io.StringIO()
|
||||||
|
if rows:
|
||||||
|
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
return Response(content=output.getvalue(), media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=companies.csv"})
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
import openpyxl
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(status_code=500, detail="openpyxl not installed")
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Companies"
|
||||||
|
if rows:
|
||||||
|
headers = list(rows[0].keys())
|
||||||
|
ws.append(headers)
|
||||||
|
for row in rows:
|
||||||
|
ws.append([str(v) if v is not None else "" for v in row.values()])
|
||||||
|
output = io.BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
return Response(content=output.getvalue(),
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=companies.xlsx"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{company_id}")
|
||||||
|
async def get_company(
|
||||||
|
company_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("contacts:read")),
|
||||||
|
):
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
q = select(Contact).options(selectinload(Contact.contact_persons)).where(
|
||||||
|
Contact.id == uuid.UUID(company_id), Contact.tenant_id == tenant_id,
|
||||||
|
Contact.type == "company", Contact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
company = result.scalar_one_or_none()
|
||||||
|
if not company:
|
||||||
|
raise HTTPException(status_code=404, detail="Company not found")
|
||||||
|
contacts = []
|
||||||
|
if company.contact_persons:
|
||||||
|
for cp in company.contact_persons:
|
||||||
|
contacts.append({"id": str(cp.id), "firstname": cp.firstname, "lastname": cp.lastname, "email": cp.email, "phone": cp.phone})
|
||||||
|
data = _serialize_company(company)
|
||||||
|
data["contacts"] = contacts
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{company_id}")
|
||||||
|
async def update_company(
|
||||||
|
company_id: str,
|
||||||
|
body: dict[str, Any],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("contacts:write")),
|
||||||
|
):
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
q = select(Contact).where(
|
||||||
|
Contact.id == uuid.UUID(company_id), Contact.tenant_id == tenant_id,
|
||||||
|
Contact.type == "company", Contact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
company = result.scalar_one_or_none()
|
||||||
|
if not company:
|
||||||
|
raise HTTPException(status_code=404, detail="Company not found")
|
||||||
|
if "name" in body:
|
||||||
|
company.name = body["name"]
|
||||||
|
company.displayname = body["name"]
|
||||||
|
if "status" in body:
|
||||||
|
company.status = body["status"]
|
||||||
|
# Update custom fields - use raw SQL to avoid lazy loading issues
|
||||||
|
custom = dict(company.custom) if company.custom else {}
|
||||||
|
if "industry" in body:
|
||||||
|
custom["industry"] = body["industry"]
|
||||||
|
if "description" in body:
|
||||||
|
custom["description"] = body["description"]
|
||||||
|
company.custom = custom
|
||||||
|
company.updated_by = user_id
|
||||||
|
await db.flush()
|
||||||
|
audit_entry = AuditLog(tenant_id=tenant_id, user_id=user_id, action="update",
|
||||||
|
entity_type="contact", entity_id=company.id, changes=body)
|
||||||
|
db.add(audit_entry)
|
||||||
|
await db.flush()
|
||||||
|
return _serialize_company(company)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{company_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_company(
|
||||||
|
company_id: str,
|
||||||
|
cascade: bool = Query(False),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("contacts:delete")),
|
||||||
|
):
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
q = select(Contact).options(selectinload(Contact.contact_persons)).where(
|
||||||
|
Contact.id == uuid.UUID(company_id), Contact.tenant_id == tenant_id,
|
||||||
|
Contact.type == "company", Contact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
company = result.scalar_one_or_none()
|
||||||
|
if not company:
|
||||||
|
raise HTTPException(status_code=404, detail="Company not found")
|
||||||
|
snapshot = _serialize_company(company)
|
||||||
|
# Soft-delete contact persons via SQL to avoid lazy loading
|
||||||
|
company.deleted_at = datetime.now(UTC)
|
||||||
|
company.updated_by = user_id
|
||||||
|
if cascade and company.contact_persons:
|
||||||
|
for cp in company.contact_persons:
|
||||||
|
cp.deleted_at = datetime.now(UTC)
|
||||||
|
await db.flush()
|
||||||
|
await record_history(db, tenant_id, user_id, "contact", company.id, "delete", snapshot_before=snapshot)
|
||||||
|
audit_entry = AuditLog(tenant_id=tenant_id, user_id=user_id, action="delete",
|
||||||
|
entity_type="contact", entity_id=company.id, changes={"name": company.name})
|
||||||
|
db.add(audit_entry)
|
||||||
|
await db.flush()
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{company_id}/contacts/{contact_id}")
|
||||||
|
async def link_contact_to_company(
|
||||||
|
company_id: str, contact_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("contacts:write")),
|
||||||
|
):
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
comp_q = select(Contact).where(Contact.id == uuid.UUID(company_id), Contact.tenant_id == tenant_id,
|
||||||
|
Contact.type == "company", Contact.deleted_at.is_(None))
|
||||||
|
company = (await db.execute(comp_q)).scalar_one_or_none()
|
||||||
|
if not company:
|
||||||
|
raise HTTPException(status_code=404, detail="Company not found")
|
||||||
|
person_q = select(Contact).where(Contact.id == uuid.UUID(contact_id), Contact.tenant_id == tenant_id,
|
||||||
|
Contact.deleted_at.is_(None))
|
||||||
|
person = (await db.execute(person_q)).scalar_one_or_none()
|
||||||
|
if not person:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
# Create a ContactPerson link
|
||||||
|
from app.models.contact import ContactPerson
|
||||||
|
cp = ContactPerson(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
contact_id=company.id,
|
||||||
|
displayname=person.displayname or person.name or "",
|
||||||
|
firstname=person.firstname,
|
||||||
|
lastname=person.surname,
|
||||||
|
email=person.email_1,
|
||||||
|
phone=person.phone_1,
|
||||||
|
)
|
||||||
|
db.add(cp)
|
||||||
|
await db.flush()
|
||||||
|
return {"company_id": company_id, "contact_id": contact_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{company_id}/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def unlink_contact_from_company(
|
||||||
|
company_id: str, contact_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("contacts:write")),
|
||||||
|
):
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
comp_q = select(Contact).where(Contact.id == uuid.UUID(company_id), Contact.tenant_id == tenant_id,
|
||||||
|
Contact.type == "company", Contact.deleted_at.is_(None))
|
||||||
|
company = (await db.execute(comp_q)).scalar_one_or_none()
|
||||||
|
if not company:
|
||||||
|
raise HTTPException(status_code=404, detail="Company not found")
|
||||||
|
# Remove ContactPerson link
|
||||||
|
from sqlalchemy import delete as sa_delete
|
||||||
|
from app.models.contact import ContactPerson
|
||||||
|
await db.execute(
|
||||||
|
sa_delete(ContactPerson).where(
|
||||||
|
ContactPerson.contact_id == uuid.UUID(company_id),
|
||||||
|
ContactPerson.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{company_id}/emails")
|
||||||
|
async def get_company_emails(
|
||||||
|
company_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("contacts:read")),
|
||||||
|
):
|
||||||
|
return []
|
||||||
@@ -148,6 +148,8 @@ class ContactCreate(BaseModel):
|
|||||||
folder_id: str | None = None
|
folder_id: str | None = None
|
||||||
# Contact persons (optional inline create)
|
# Contact persons (optional inline create)
|
||||||
contact_persons: list[ContactPersonCreate] | None = None
|
contact_persons: list[ContactPersonCreate] | None = None
|
||||||
|
# Company IDs for N:M linking (person contacts to companies)
|
||||||
|
company_ids: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ContactUpdate(BaseModel):
|
class ContactUpdate(BaseModel):
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ _SUPPORTED_OPS = {
|
|||||||
# type. Prevents policies from filtering on sensitive columns such as
|
# type. Prevents policies from filtering on sensitive columns such as
|
||||||
# tenant_id, password_hash, etc.
|
# tenant_id, password_hash, etc.
|
||||||
ABAC_ALLOWED_FIELDS: dict[str, set[str]] = {
|
ABAC_ALLOWED_FIELDS: dict[str, set[str]] = {
|
||||||
"contact": {"status", "type", "country", "tags", "created_at", "updated_at", "owner_id"},
|
"contact": {"status", "type", "country", "tags", "created_at", "updated_at", "owner_id", "name", "displayname", "firstname", "surname", "email_1", "email_2", "code"},
|
||||||
"file": {"status", "size", "mime_type", "created_at"},
|
"file": {"status", "size", "mime_type", "created_at"},
|
||||||
"task": {"status", "priority", "due_date", "created_at"},
|
"task": {"status", "priority", "due_date", "created_at"},
|
||||||
"calendar_event": {"status", "start_time", "end_time", "created_at"},
|
"calendar_event": {"status", "start_time", "end_time", "created_at"},
|
||||||
|
|||||||
+48
-11
@@ -16,6 +16,7 @@ os.environ["SESSION_COOKIE_SECURE"] = "false"
|
|||||||
os.environ["SESSION_COOKIE_SAMESITE"] = "lax"
|
os.environ["SESSION_COOKIE_SAMESITE"] = "lax"
|
||||||
os.environ["SECRET_KEY"] = "test-secret-key-with-at-least-32-characters-for-testing-only!!"
|
os.environ["SECRET_KEY"] = "test-secret-key-with-at-least-32-characters-for-testing-only!!"
|
||||||
os.environ["ENVIRONMENT"] = "testing"
|
os.environ["ENVIRONMENT"] = "testing"
|
||||||
|
os.environ["MIGRATION_DATABASE_URL"] = "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test"
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -127,6 +128,8 @@ def db_setup():
|
|||||||
"""
|
"""
|
||||||
sync_eng = _get_sync_engine()
|
sync_eng = _get_sync_engine()
|
||||||
with sync_eng.connect() as conn:
|
with sync_eng.connect() as conn:
|
||||||
|
# Create crm_user role if missing (needed by some migrations)
|
||||||
|
conn.execute(text("DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_user') THEN CREATE ROLE crm_user LOGIN PASSWORD 'leocrm'; END IF; END $$;"))
|
||||||
# Set a short lock timeout to prevent deadlocks
|
# Set a short lock timeout to prevent deadlocks
|
||||||
conn.execute(text("SET lock_timeout = '5s';"))
|
conn.execute(text("SET lock_timeout = '5s';"))
|
||||||
try:
|
try:
|
||||||
@@ -325,16 +328,38 @@ async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]:
|
|||||||
db.add_all([admin_a, viewer_a, editor_a, admin_b])
|
db.add_all([admin_a, viewer_a, editor_a, admin_b])
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# User-tenant memberships
|
# Create admin role with *:* permissions for tenant A
|
||||||
ut1 = UserTenant(user_id=admin_a.id, tenant_id=tenant_a.id, is_default=True, role="admin")
|
admin_role_a = Role(
|
||||||
ut2 = UserTenant(user_id=viewer_a.id, tenant_id=tenant_a.id, is_default=True, role="viewer")
|
tenant_id=tenant_a.id,
|
||||||
ut3 = UserTenant(user_id=editor_a.id, tenant_id=tenant_a.id, is_default=True, role="editor")
|
name="admin",
|
||||||
ut4 = UserTenant(user_id=admin_b.id, tenant_id=tenant_b.id, is_default=True, role="admin")
|
permissions={"*": {"*": True}},
|
||||||
# Admin A is also member of tenant B (for switch-tenant test)
|
denied_permissions=[],
|
||||||
ut5 = UserTenant(user_id=admin_a.id, tenant_id=tenant_b.id, is_default=False, role="admin")
|
field_permissions={},
|
||||||
db.add_all([ut1, ut2, ut3, ut4, ut5])
|
)
|
||||||
await db.flush()
|
# Create viewer role for tenant A
|
||||||
|
viewer_role_a = Role(
|
||||||
|
tenant_id=tenant_a.id,
|
||||||
|
name="viewer",
|
||||||
|
permissions={"contacts": {"read": True}, "companies": {"read": True}},
|
||||||
|
denied_permissions=[],
|
||||||
|
field_permissions={},
|
||||||
|
)
|
||||||
|
# Create editor role for tenant A
|
||||||
|
editor_role_a = Role(
|
||||||
|
tenant_id=tenant_a.id,
|
||||||
|
name="editor",
|
||||||
|
permissions={"contacts": {"read": True, "write": True, "create": True, "update": True}, "companies": {"read": True, "write": True, "create": True, "update": True}},
|
||||||
|
denied_permissions=[],
|
||||||
|
field_permissions={},
|
||||||
|
)
|
||||||
|
# Create admin role for tenant B
|
||||||
|
admin_role_b = Role(
|
||||||
|
tenant_id=tenant_b.id,
|
||||||
|
name="admin",
|
||||||
|
permissions={"*": {"*": True}},
|
||||||
|
denied_permissions=[],
|
||||||
|
field_permissions={},
|
||||||
|
)
|
||||||
# Create a custom role with field permissions in tenant A
|
# Create a custom role with field permissions in tenant A
|
||||||
custom_role = Role(
|
custom_role = Role(
|
||||||
tenant_id=tenant_a.id,
|
tenant_id=tenant_a.id,
|
||||||
@@ -342,7 +367,17 @@ async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]:
|
|||||||
permissions={"companies": {"read": True, "create": True, "update": True, "delete": False}},
|
permissions={"companies": {"read": True, "create": True, "update": True, "delete": False}},
|
||||||
field_permissions={"annual_revenue": "hidden"},
|
field_permissions={"annual_revenue": "hidden"},
|
||||||
)
|
)
|
||||||
db.add(custom_role)
|
db.add_all([admin_role_a, viewer_role_a, editor_role_a, admin_role_b, custom_role])
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# User-tenant memberships (with role_id linking to Role records)
|
||||||
|
ut1 = UserTenant(user_id=admin_a.id, tenant_id=tenant_a.id, is_default=True, role="admin", role_id=admin_role_a.id)
|
||||||
|
ut2 = UserTenant(user_id=viewer_a.id, tenant_id=tenant_a.id, is_default=True, role="viewer", role_id=viewer_role_a.id)
|
||||||
|
ut3 = UserTenant(user_id=editor_a.id, tenant_id=tenant_a.id, is_default=True, role="editor", role_id=editor_role_a.id)
|
||||||
|
ut4 = UserTenant(user_id=admin_b.id, tenant_id=tenant_b.id, is_default=True, role="admin", role_id=admin_role_b.id)
|
||||||
|
# Admin A is also member of tenant B (for switch-tenant test)
|
||||||
|
ut5 = UserTenant(user_id=admin_a.id, tenant_id=tenant_b.id, is_default=False, role="admin", role_id=admin_role_b.id)
|
||||||
|
db.add_all([ut1, ut2, ut3, ut4, ut5])
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# Create a company in tenant A
|
# Create a company in tenant A
|
||||||
@@ -378,6 +413,8 @@ async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]:
|
|||||||
"company_a": company_a,
|
"company_a": company_a,
|
||||||
"company_b": company_b,
|
"company_b": company_b,
|
||||||
"custom_role": custom_role,
|
"custom_role": custom_role,
|
||||||
|
"admin_role_a": admin_role_a,
|
||||||
|
"admin_role_b": admin_role_b,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ async def test_ac6_copilot_tenant_isolation(ai_client: AsyncClient, db_session):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ac7_copilot_field_level_permissions(ai_client: AsyncClient, db_session):
|
async def test_ac7_copilot_field_level_permissions(ai_client: AsyncClient, db_session):
|
||||||
"""AC7: Copilot respects field-level permissions — hidden fields not in response."""
|
"""AC7: Copilot respects field-level permissions — hidden fields not in response."""
|
||||||
from app.core.auth import filter_fields_by_permission
|
from app.core.permissions import filter_fields_by_permission
|
||||||
|
|
||||||
await seed_tenant_and_users(db_session)
|
await seed_tenant_and_users(db_session)
|
||||||
|
|
||||||
|
|||||||
+12
-12
@@ -519,8 +519,8 @@ async def test_get_contact_mails_handler(db_session: AsyncSession):
|
|||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
contact = Contact(
|
contact = Contact(
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
first_name="CT",
|
firstname="CT",
|
||||||
last_name="Contact",
|
surname="Contact",
|
||||||
email="ctcontact@example.com",
|
email="ctcontact@example.com",
|
||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
updated_by=user.id,
|
updated_by=user.id,
|
||||||
@@ -593,8 +593,8 @@ async def test_get_contact_history_handler(db_session: AsyncSession):
|
|||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
contact = Contact(
|
contact = Contact(
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
first_name="Hist",
|
firstname="Hist",
|
||||||
last_name="Contact",
|
surname="Contact",
|
||||||
email="hist@example.com",
|
email="hist@example.com",
|
||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
updated_by=user.id,
|
updated_by=user.id,
|
||||||
@@ -650,8 +650,8 @@ async def test_search_related_handler(db_session: AsyncSession):
|
|||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
contact = Contact(
|
contact = Contact(
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
first_name="Rel",
|
firstname="Rel",
|
||||||
last_name="Contact",
|
surname="Contact",
|
||||||
email="rel@example.com",
|
email="rel@example.com",
|
||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
updated_by=user.id,
|
updated_by=user.id,
|
||||||
@@ -789,8 +789,8 @@ async def test_get_open_tasks_handler(db_session: AsyncSession):
|
|||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
contact = Contact(
|
contact = Contact(
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
first_name="Task",
|
firstname="Task",
|
||||||
last_name="Contact",
|
surname="Contact",
|
||||||
email="task@example.com",
|
email="task@example.com",
|
||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
updated_by=user.id,
|
updated_by=user.id,
|
||||||
@@ -905,8 +905,8 @@ async def test_gather_context_contact(db_session: AsyncSession):
|
|||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
contact = Contact(
|
contact = Contact(
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
first_name="GC",
|
firstname="GC",
|
||||||
last_name="Contact",
|
surname="Contact",
|
||||||
email="gc@example.com",
|
email="gc@example.com",
|
||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
updated_by=user.id,
|
updated_by=user.id,
|
||||||
@@ -1565,8 +1565,8 @@ async def test_deep_analysis(mock_create_session, db_session: AsyncSession):
|
|||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
contact = Contact(
|
contact = Contact(
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
first_name="DA",
|
firstname="DA",
|
||||||
last_name="Contact",
|
surname="Contact",
|
||||||
email="da@example.com",
|
email="da@example.com",
|
||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
updated_by=user.id,
|
updated_by=user.id,
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ class TestCompanyDelete:
|
|||||||
company_id = comp_resp.json()["id"]
|
company_id = comp_resp.json()["id"]
|
||||||
cont_resp = await client.post(
|
cont_resp = await client.post(
|
||||||
"/api/v1/contacts",
|
"/api/v1/contacts",
|
||||||
json={"first_name": "John", "last_name": "Doe", "company_ids": [company_id]},
|
json={"firstname": "John", "surname": "Doe", "company_ids": [company_id]},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
cont_resp.json()["id"]
|
cont_resp.json()["id"]
|
||||||
@@ -214,7 +214,7 @@ class TestCompanyContactLink:
|
|||||||
company_id = comp_resp.json()["id"]
|
company_id = comp_resp.json()["id"]
|
||||||
cont_resp = await client.post(
|
cont_resp = await client.post(
|
||||||
"/api/v1/contacts",
|
"/api/v1/contacts",
|
||||||
json={"first_name": "Jane", "last_name": "Smith"},
|
json={"firstname": "Jane", "surname": "Smith"},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
contact_id = cont_resp.json()["id"]
|
contact_id = cont_resp.json()["id"]
|
||||||
@@ -239,7 +239,7 @@ class TestCompanyContactLink:
|
|||||||
company_id = comp_resp.json()["id"]
|
company_id = comp_resp.json()["id"]
|
||||||
cont_resp = await client.post(
|
cont_resp = await client.post(
|
||||||
"/api/v1/contacts",
|
"/api/v1/contacts",
|
||||||
json={"first_name": "Bob", "last_name": "Wilson"},
|
json={"firstname": "Bob", "surname": "Wilson"},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
contact_id = cont_resp.json()["id"]
|
contact_id = cont_resp.json()["id"]
|
||||||
|
|||||||
+15
-15
@@ -41,8 +41,8 @@ class TestContactCreate:
|
|||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/v1/contacts",
|
"/api/v1/contacts",
|
||||||
json={
|
json={
|
||||||
"first_name": "Alice",
|
"firstname": "Alice",
|
||||||
"last_name": "Wonderland",
|
"surname": "Wonderland",
|
||||||
"email": "alice@example.com",
|
"email": "alice@example.com",
|
||||||
"company_ids": [company_id],
|
"company_ids": [company_id],
|
||||||
},
|
},
|
||||||
@@ -50,12 +50,12 @@ class TestContactCreate:
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["first_name"] == "Alice"
|
assert data["firstname"] == "Alice"
|
||||||
assert data["last_name"] == "Wonderland"
|
assert data["surname"] == "Wonderland"
|
||||||
# Verify N:M link via company detail
|
# Verify N:M link via company detail
|
||||||
comp_detail = await client.get(f"/api/v1/companies/{company_id}", headers=ORIGIN_HEADER)
|
comp_detail = await client.get(f"/api/v1/companies/{company_id}", headers=ORIGIN_HEADER)
|
||||||
contacts = comp_detail.json()["contacts"]
|
contacts = comp_detail.json()["contacts"]
|
||||||
assert any(c["first_name"] == "Alice" for c in contacts)
|
assert any(c["firstname"] == "Alice" for c in contacts)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -71,8 +71,8 @@ class TestContactDetail:
|
|||||||
create_resp = await client.post(
|
create_resp = await client.post(
|
||||||
"/api/v1/contacts",
|
"/api/v1/contacts",
|
||||||
json={
|
json={
|
||||||
"first_name": "Bob",
|
"firstname": "Bob",
|
||||||
"last_name": "Builder",
|
"surname": "Builder",
|
||||||
"company_ids": [company_id],
|
"company_ids": [company_id],
|
||||||
},
|
},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
@@ -81,7 +81,7 @@ class TestContactDetail:
|
|||||||
resp = await client.get(f"/api/v1/contacts/{contact_id}", headers=ORIGIN_HEADER)
|
resp = await client.get(f"/api/v1/contacts/{contact_id}", headers=ORIGIN_HEADER)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["first_name"] == "Bob"
|
assert data["firstname"] == "Bob"
|
||||||
assert "companies" in data
|
assert "companies" in data
|
||||||
assert isinstance(data["companies"], list)
|
assert isinstance(data["companies"], list)
|
||||||
assert len(data["companies"]) == 1
|
assert len(data["companies"]) == 1
|
||||||
@@ -98,18 +98,18 @@ class TestContactUpdate:
|
|||||||
await login_client(client, "admin@tenanta.com")
|
await login_client(client, "admin@tenanta.com")
|
||||||
create_resp = await client.post(
|
create_resp = await client.post(
|
||||||
"/api/v1/contacts",
|
"/api/v1/contacts",
|
||||||
json={"first_name": "Old", "last_name": "Name"},
|
json={"firstname": "Old", "surname": "Name"},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
contact_id = create_resp.json()["id"]
|
contact_id = create_resp.json()["id"]
|
||||||
resp = await client.put(
|
resp = await client.put(
|
||||||
f"/api/v1/contacts/{contact_id}",
|
f"/api/v1/contacts/{contact_id}",
|
||||||
json={"first_name": "New", "last_name": "Name", "email": "new@example.com"},
|
json={"firstname": "New", "surname": "Name", "email": "new@example.com"},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["first_name"] == "New"
|
assert data["firstname"] == "New"
|
||||||
assert data["email"] == "new@example.com"
|
assert data["email"] == "new@example.com"
|
||||||
|
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ class TestContactDelete:
|
|||||||
await login_client(client, "admin@tenanta.com")
|
await login_client(client, "admin@tenanta.com")
|
||||||
create_resp = await client.post(
|
create_resp = await client.post(
|
||||||
"/api/v1/contacts",
|
"/api/v1/contacts",
|
||||||
json={"first_name": "Delete", "last_name": "Me"},
|
json={"firstname": "Delete", "surname": "Me"},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
contact_id = create_resp.json()["id"]
|
contact_id = create_resp.json()["id"]
|
||||||
@@ -131,7 +131,7 @@ class TestContactDelete:
|
|||||||
assert resp.status_code == 204
|
assert resp.status_code == 204
|
||||||
# Verify contact not in list
|
# Verify contact not in list
|
||||||
list_resp = await client.get("/api/v1/contacts", headers=ORIGIN_HEADER)
|
list_resp = await client.get("/api/v1/contacts", headers=ORIGIN_HEADER)
|
||||||
names = [f"{item['first_name']} {item['last_name']}" for item in list_resp.json()["items"]]
|
names = [f"{item["firstname"]} {item["surname"]}" for item in list_resp.json()["items"]]
|
||||||
assert "Delete Me" not in names
|
assert "Delete Me" not in names
|
||||||
|
|
||||||
async def test_delete_contact_gdpr_hard_delete_returns_204(
|
async def test_delete_contact_gdpr_hard_delete_returns_204(
|
||||||
@@ -149,7 +149,7 @@ class TestContactDelete:
|
|||||||
await login_client(client, "admin@tenanta.com")
|
await login_client(client, "admin@tenanta.com")
|
||||||
create_resp = await client.post(
|
create_resp = await client.post(
|
||||||
"/api/v1/contacts",
|
"/api/v1/contacts",
|
||||||
json={"first_name": "GDPR", "last_name": "Delete"},
|
json={"firstname": "GDPR", "surname": "Delete"},
|
||||||
headers=ORIGIN_HEADER,
|
headers=ORIGIN_HEADER,
|
||||||
)
|
)
|
||||||
contact_id = create_resp.json()["id"]
|
contact_id = create_resp.json()["id"]
|
||||||
@@ -170,4 +170,4 @@ class TestContactDelete:
|
|||||||
dl_result = await db_session.execute(dl_q)
|
dl_result = await db_session.execute(dl_q)
|
||||||
dl_entries = dl_result.scalars().all()
|
dl_entries = dl_result.scalars().all()
|
||||||
assert len(dl_entries) >= 1
|
assert len(dl_entries) >= 1
|
||||||
assert dl_entries[0].entity_snapshot["first_name"] == "GDPR"
|
assert dl_entries[0].entity_snapshot["firstname"] == "GDPR"
|
||||||
|
|||||||
+11
-11
@@ -25,8 +25,8 @@ async def _seed_contacts(db: AsyncSession, count: int = 50) -> tuple[str, str]:
|
|||||||
for i in range(count):
|
for i in range(count):
|
||||||
contacts.append(Contact(
|
contacts.append(Contact(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
first_name=f"First{i}",
|
firstname=f"First{i}",
|
||||||
last_name=f"Last{i}",
|
surname=f"Last{i}",
|
||||||
email=f"user{i}@example.com" if i % 5 != 0 else None,
|
email=f"user{i}@example.com" if i % 5 != 0 else None,
|
||||||
phone=f"+49-555-{i:04d}" if i % 3 != 0 else None,
|
phone=f"+49-555-{i:04d}" if i % 3 != 0 else None,
|
||||||
created_by=user_id,
|
created_by=user_id,
|
||||||
@@ -35,8 +35,8 @@ async def _seed_contacts(db: AsyncSession, count: int = 50) -> tuple[str, str]:
|
|||||||
# Also add a Mueller for search test
|
# Also add a Mueller for search test
|
||||||
contacts.append(Contact(
|
contacts.append(Contact(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
first_name="Hans",
|
firstname="Hans",
|
||||||
last_name="Mueller",
|
surname="Mueller",
|
||||||
email="hans.mueller@example.com",
|
email="hans.mueller@example.com",
|
||||||
created_by=user_id,
|
created_by=user_id,
|
||||||
updated_by=user_id,
|
updated_by=user_id,
|
||||||
@@ -86,7 +86,7 @@ class TestPaginationPerformance:
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert elapsed_ms < 500, f"Search took {elapsed_ms:.2f}ms (expected <500ms)"
|
assert elapsed_ms < 500, f"Search took {elapsed_ms:.2f}ms (expected <500ms)"
|
||||||
# Should find the Mueller contact
|
# Should find the Mueller contact
|
||||||
last_names = [item["last_name"] for item in data["items"]]
|
last_names = [item["surname"] for item in data["items"]]
|
||||||
assert "Mueller" in last_names
|
assert "Mueller" in last_names
|
||||||
|
|
||||||
async def test_list_contacts_returns_correct_pagination(self, client: AsyncClient, db_session: AsyncSession):
|
async def test_list_contacts_returns_correct_pagination(self, client: AsyncClient, db_session: AsyncSession):
|
||||||
@@ -177,8 +177,8 @@ class TestCSVExport:
|
|||||||
# Header + 51 data rows
|
# Header + 51 data rows
|
||||||
assert len(rows) >= 2 # At least header + 1 data row
|
assert len(rows) >= 2 # At least header + 1 data row
|
||||||
assert rows[0][0] == "id"
|
assert rows[0][0] == "id"
|
||||||
assert rows[0][1] == "first_name"
|
assert rows[0][1] == "firstname"
|
||||||
assert rows[0][2] == "last_name"
|
assert rows[0][2] == "surname"
|
||||||
|
|
||||||
async def test_csv_export_empty_tenant(self, client: AsyncClient, db_session: AsyncSession):
|
async def test_csv_export_empty_tenant(self, client: AsyncClient, db_session: AsyncSession):
|
||||||
"""CSV export on empty tenant returns just the header row."""
|
"""CSV export on empty tenant returns just the header row."""
|
||||||
@@ -193,7 +193,7 @@ class TestCSVExport:
|
|||||||
rows = list(reader)
|
rows = list(reader)
|
||||||
# Just the header, no data rows
|
# Just the header, no data rows
|
||||||
assert len(rows) == 1
|
assert len(rows) == 1
|
||||||
assert rows[0][1] == "first_name"
|
assert rows[0][1] == "firstname"
|
||||||
|
|
||||||
async def test_csv_export_with_search_filter(self, client: AsyncClient, db_session: AsyncSession):
|
async def test_csv_export_with_search_filter(self, client: AsyncClient, db_session: AsyncSession):
|
||||||
"""CSV export with search filter returns only matching contacts."""
|
"""CSV export with search filter returns only matching contacts."""
|
||||||
@@ -239,13 +239,13 @@ class TestSeedScript:
|
|||||||
def test_seed_script_exists(self):
|
def test_seed_script_exists(self):
|
||||||
"""AC7: scripts/seed_perf_data.py exists."""
|
"""AC7: scripts/seed_perf_data.py exists."""
|
||||||
import os
|
import os
|
||||||
path = "/a0/usr/workdir/dev-projects/leocrm/scripts/seed_perf_data.py"
|
path = "/a0/usr/projects/leocrm/scripts/seed_perf_data.py"
|
||||||
assert os.path.exists(path), f"Seed script not found at {path}"
|
assert os.path.exists(path), f"Seed script not found at {path}"
|
||||||
|
|
||||||
def test_seed_script_has_count_arg(self):
|
def test_seed_script_has_count_arg(self):
|
||||||
"""Seed script accepts --count argument."""
|
"""Seed script accepts --count argument."""
|
||||||
import ast
|
import ast
|
||||||
path = "/a0/usr/workdir/dev-projects/leocrm/scripts/seed_perf_data.py"
|
path = "/a0/usr/projects/leocrm/scripts/seed_perf_data.py"
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
tree = ast.parse(f.read())
|
tree = ast.parse(f.read())
|
||||||
source = ast.dump(tree)
|
source = ast.dump(tree)
|
||||||
@@ -258,5 +258,5 @@ class TestCheckIndexesScript:
|
|||||||
def test_check_indexes_script_exists(self):
|
def test_check_indexes_script_exists(self):
|
||||||
"""scripts/check_indexes.py exists."""
|
"""scripts/check_indexes.py exists."""
|
||||||
import os
|
import os
|
||||||
path = "/a0/usr/workdir/dev-projects/leocrm/scripts/check_indexes.py"
|
path = "/a0/usr/projects/leocrm/scripts/check_indexes.py"
|
||||||
assert os.path.exists(path), f"Check indexes script not found at {path}"
|
assert os.path.exists(path), f"Check indexes script not found at {path}"
|
||||||
|
|||||||
@@ -684,8 +684,8 @@ async def test_index_entity_success(db_session: AsyncSession):
|
|||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
contact = Contact(
|
contact = Contact(
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
first_name="John",
|
firstname="John",
|
||||||
last_name="Doe",
|
surname="Doe",
|
||||||
email="john@example.com",
|
email="john@example.com",
|
||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
updated_by=user.id,
|
updated_by=user.id,
|
||||||
@@ -811,8 +811,8 @@ async def test_hybrid_search_with_results(db_session: AsyncSession):
|
|||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
contact = Contact(
|
contact = Contact(
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
first_name="Search",
|
firstname="Search",
|
||||||
last_name="Test",
|
surname="Test",
|
||||||
email="searchtest@example.com",
|
email="searchtest@example.com",
|
||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
updated_by=user.id,
|
updated_by=user.id,
|
||||||
@@ -973,8 +973,8 @@ async def test_index_contact(mock_index_entity, mock_factory, db_session: AsyncS
|
|||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
contact = Contact(
|
contact = Contact(
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
first_name="Index",
|
firstname="Index",
|
||||||
last_name="Contact",
|
surname="Contact",
|
||||||
email="indexcontact@example.com",
|
email="indexcontact@example.com",
|
||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
updated_by=user.id,
|
updated_by=user.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user