diff --git a/app/commands/contact_commands.py b/app/commands/contact_commands.py index 35373da..1a71a67 100644 --- a/app/commands/contact_commands.py +++ b/app/commands/contact_commands.py @@ -75,6 +75,25 @@ class CreateContactCommand(BaseCommand): self._created_contact_id = uuid.UUID(serialized["id"]) 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 events: list[dict] = [] await enqueue_outbox_event(db, tenant_id, "contact.created", { diff --git a/app/config.py b/app/config.py index f78d3b2..9ba6ef3 100644 --- a/app/config.py +++ b/app/config.py @@ -18,6 +18,9 @@ class Settings(BaseSettings): extra="ignore", ) + # App version (used for plugin compatibility checks) + app_version: str = "1.0.0" + # Environment environment: Literal["development", "production", "testing"] = "development" log_level: str = "INFO" diff --git a/app/core/auth.py b/app/core/auth.py index 49275a7..9773edc 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -299,3 +299,4 @@ async def update_session_tenant( # ⚠️ 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. +# Tests should import directly from app.core.permissions. diff --git a/app/main.py b/app/main.py index 87ff9b0..358f278 100644 --- a/app/main.py +++ b/app/main.py @@ -417,6 +417,8 @@ def create_app() -> FastAPI: app.include_router(groups.router) app.include_router(tenants.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(contact_folders.router) app.include_router(contact_folder_permissions.router) diff --git a/app/models/audit.py b/app/models/audit.py index 1a5648d..02055cc 100644 --- a/app/models/audit.py +++ b/app/models/audit.py @@ -1,6 +1,7 @@ """AuditLog model — audit trail for all create/update/delete/login actions. Note: DeletionLog has been merged into EntityHistory (action='delete'). +DeletionLog is re-exported here as an alias for backward compatibility. """ from __future__ import annotations @@ -16,6 +17,10 @@ from sqlalchemy.orm import Mapped, mapped_column 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): """Audit trail for all create/update/delete/login actions.""" @@ -35,4 +40,3 @@ class AuditLog(Base, TenantMixin): timestamp: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), index=True ) - diff --git a/app/models/entity_history.py b/app/models/entity_history.py index 853b5af..68b18fe 100644 --- a/app/models/entity_history.py +++ b/app/models/entity_history.py @@ -39,3 +39,8 @@ class EntityHistory(Base, TenantMixin, OwnedMixin): created_at: Mapped[datetime] = mapped_column( 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 diff --git a/app/routes/companies.py b/app/routes/companies.py new file mode 100644 index 0000000..a0a7791 --- /dev/null +++ b/app/routes/companies.py @@ -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 [] diff --git a/app/schemas/contact.py b/app/schemas/contact.py index 3d0d3e5..3ed5bd4 100644 --- a/app/schemas/contact.py +++ b/app/schemas/contact.py @@ -148,6 +148,8 @@ class ContactCreate(BaseModel): folder_id: str | None = None # Contact persons (optional inline create) 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): diff --git a/app/services/policy_service.py b/app/services/policy_service.py index 08bcb41..57472f2 100644 --- a/app/services/policy_service.py +++ b/app/services/policy_service.py @@ -35,7 +35,7 @@ _SUPPORTED_OPS = { # type. Prevents policies from filtering on sensitive columns such as # tenant_id, password_hash, etc. 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"}, "task": {"status", "priority", "due_date", "created_at"}, "calendar_event": {"status", "start_time", "end_time", "created_at"}, diff --git a/tests/conftest.py b/tests/conftest.py index 8a5c0ba..9691807 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,7 @@ os.environ["SESSION_COOKIE_SECURE"] = "false" os.environ["SESSION_COOKIE_SAMESITE"] = "lax" os.environ["SECRET_KEY"] = "test-secret-key-with-at-least-32-characters-for-testing-only!!" os.environ["ENVIRONMENT"] = "testing" +os.environ["MIGRATION_DATABASE_URL"] = "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test" from collections.abc import AsyncGenerator from typing import Any @@ -127,6 +128,8 @@ def db_setup(): """ sync_eng = _get_sync_engine() 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 conn.execute(text("SET lock_timeout = '5s';")) 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]) await db.flush() - # User-tenant memberships - ut1 = UserTenant(user_id=admin_a.id, tenant_id=tenant_a.id, is_default=True, role="admin") - ut2 = UserTenant(user_id=viewer_a.id, tenant_id=tenant_a.id, is_default=True, role="viewer") - ut3 = UserTenant(user_id=editor_a.id, tenant_id=tenant_a.id, is_default=True, role="editor") - ut4 = UserTenant(user_id=admin_b.id, tenant_id=tenant_b.id, is_default=True, role="admin") - # 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") - db.add_all([ut1, ut2, ut3, ut4, ut5]) - await db.flush() - + # Create admin role with *:* permissions for tenant A + admin_role_a = Role( + tenant_id=tenant_a.id, + name="admin", + permissions={"*": {"*": True}}, + denied_permissions=[], + field_permissions={}, + ) + # 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 custom_role = Role( 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}}, 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() # 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_b": company_b, "custom_role": custom_role, + "admin_role_a": admin_role_a, + "admin_role_b": admin_role_b, } diff --git a/tests/test_ai_copilot.py b/tests/test_ai_copilot.py index 3f7b4bd..fa2cdf9 100644 --- a/tests/test_ai_copilot.py +++ b/tests/test_ai_copilot.py @@ -186,7 +186,7 @@ async def test_ac6_copilot_tenant_isolation(ai_client: AsyncClient, db_session): @pytest.mark.asyncio async def test_ac7_copilot_field_level_permissions(ai_client: AsyncClient, db_session): """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) diff --git a/tests/test_ai_proactive.py b/tests/test_ai_proactive.py index f005a62..0b61922 100644 --- a/tests/test_ai_proactive.py +++ b/tests/test_ai_proactive.py @@ -519,8 +519,8 @@ async def test_get_contact_mails_handler(db_session: AsyncSession): await db_session.flush() contact = Contact( tenant_id=tenant.id, - first_name="CT", - last_name="Contact", + firstname="CT", + surname="Contact", email="ctcontact@example.com", created_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() contact = Contact( tenant_id=tenant.id, - first_name="Hist", - last_name="Contact", + firstname="Hist", + surname="Contact", email="hist@example.com", created_by=user.id, updated_by=user.id, @@ -650,8 +650,8 @@ async def test_search_related_handler(db_session: AsyncSession): await db_session.flush() contact = Contact( tenant_id=tenant.id, - first_name="Rel", - last_name="Contact", + firstname="Rel", + surname="Contact", email="rel@example.com", created_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() contact = Contact( tenant_id=tenant.id, - first_name="Task", - last_name="Contact", + firstname="Task", + surname="Contact", email="task@example.com", created_by=user.id, updated_by=user.id, @@ -905,8 +905,8 @@ async def test_gather_context_contact(db_session: AsyncSession): await db_session.flush() contact = Contact( tenant_id=tenant.id, - first_name="GC", - last_name="Contact", + firstname="GC", + surname="Contact", email="gc@example.com", created_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() contact = Contact( tenant_id=tenant.id, - first_name="DA", - last_name="Contact", + firstname="DA", + surname="Contact", email="da@example.com", created_by=user.id, updated_by=user.id, diff --git a/tests/test_companies.py b/tests/test_companies.py index 665ce60..53ba68a 100644 --- a/tests/test_companies.py +++ b/tests/test_companies.py @@ -183,7 +183,7 @@ class TestCompanyDelete: company_id = comp_resp.json()["id"] cont_resp = await client.post( "/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, ) cont_resp.json()["id"] @@ -214,7 +214,7 @@ class TestCompanyContactLink: company_id = comp_resp.json()["id"] cont_resp = await client.post( "/api/v1/contacts", - json={"first_name": "Jane", "last_name": "Smith"}, + json={"firstname": "Jane", "surname": "Smith"}, headers=ORIGIN_HEADER, ) contact_id = cont_resp.json()["id"] @@ -239,7 +239,7 @@ class TestCompanyContactLink: company_id = comp_resp.json()["id"] cont_resp = await client.post( "/api/v1/contacts", - json={"first_name": "Bob", "last_name": "Wilson"}, + json={"firstname": "Bob", "surname": "Wilson"}, headers=ORIGIN_HEADER, ) contact_id = cont_resp.json()["id"] diff --git a/tests/test_contacts.py b/tests/test_contacts.py index 02e26dc..7764523 100644 --- a/tests/test_contacts.py +++ b/tests/test_contacts.py @@ -41,8 +41,8 @@ class TestContactCreate: resp = await client.post( "/api/v1/contacts", json={ - "first_name": "Alice", - "last_name": "Wonderland", + "firstname": "Alice", + "surname": "Wonderland", "email": "alice@example.com", "company_ids": [company_id], }, @@ -50,12 +50,12 @@ class TestContactCreate: ) assert resp.status_code == 201 data = resp.json() - assert data["first_name"] == "Alice" - assert data["last_name"] == "Wonderland" + assert data["firstname"] == "Alice" + assert data["surname"] == "Wonderland" # Verify N:M link via company detail comp_detail = await client.get(f"/api/v1/companies/{company_id}", headers=ORIGIN_HEADER) 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 @@ -71,8 +71,8 @@ class TestContactDetail: create_resp = await client.post( "/api/v1/contacts", json={ - "first_name": "Bob", - "last_name": "Builder", + "firstname": "Bob", + "surname": "Builder", "company_ids": [company_id], }, headers=ORIGIN_HEADER, @@ -81,7 +81,7 @@ class TestContactDetail: resp = await client.get(f"/api/v1/contacts/{contact_id}", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() - assert data["first_name"] == "Bob" + assert data["firstname"] == "Bob" assert "companies" in data assert isinstance(data["companies"], list) assert len(data["companies"]) == 1 @@ -98,18 +98,18 @@ class TestContactUpdate: await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/contacts", - json={"first_name": "Old", "last_name": "Name"}, + json={"firstname": "Old", "surname": "Name"}, headers=ORIGIN_HEADER, ) contact_id = create_resp.json()["id"] resp = await client.put( 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, ) assert resp.status_code == 200 data = resp.json() - assert data["first_name"] == "New" + assert data["firstname"] == "New" assert data["email"] == "new@example.com" @@ -123,7 +123,7 @@ class TestContactDelete: await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/contacts", - json={"first_name": "Delete", "last_name": "Me"}, + json={"firstname": "Delete", "surname": "Me"}, headers=ORIGIN_HEADER, ) contact_id = create_resp.json()["id"] @@ -131,7 +131,7 @@ class TestContactDelete: assert resp.status_code == 204 # Verify contact not in list 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 async def test_delete_contact_gdpr_hard_delete_returns_204( @@ -149,7 +149,7 @@ class TestContactDelete: await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/contacts", - json={"first_name": "GDPR", "last_name": "Delete"}, + json={"firstname": "GDPR", "surname": "Delete"}, headers=ORIGIN_HEADER, ) contact_id = create_resp.json()["id"] @@ -170,4 +170,4 @@ class TestContactDelete: dl_result = await db_session.execute(dl_q) dl_entries = dl_result.scalars().all() assert len(dl_entries) >= 1 - assert dl_entries[0].entity_snapshot["first_name"] == "GDPR" + assert dl_entries[0].entity_snapshot["firstname"] == "GDPR" diff --git a/tests/test_performance.py b/tests/test_performance.py index 404723a..d31392c 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -25,8 +25,8 @@ async def _seed_contacts(db: AsyncSession, count: int = 50) -> tuple[str, str]: for i in range(count): contacts.append(Contact( tenant_id=tenant_id, - first_name=f"First{i}", - last_name=f"Last{i}", + firstname=f"First{i}", + surname=f"Last{i}", email=f"user{i}@example.com" if i % 5 != 0 else None, phone=f"+49-555-{i:04d}" if i % 3 != 0 else None, 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 contacts.append(Contact( tenant_id=tenant_id, - first_name="Hans", - last_name="Mueller", + firstname="Hans", + surname="Mueller", email="hans.mueller@example.com", created_by=user_id, updated_by=user_id, @@ -86,7 +86,7 @@ class TestPaginationPerformance: data = resp.json() assert elapsed_ms < 500, f"Search took {elapsed_ms:.2f}ms (expected <500ms)" # 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 async def test_list_contacts_returns_correct_pagination(self, client: AsyncClient, db_session: AsyncSession): @@ -177,8 +177,8 @@ class TestCSVExport: # Header + 51 data rows assert len(rows) >= 2 # At least header + 1 data row assert rows[0][0] == "id" - assert rows[0][1] == "first_name" - assert rows[0][2] == "last_name" + assert rows[0][1] == "firstname" + assert rows[0][2] == "surname" async def test_csv_export_empty_tenant(self, client: AsyncClient, db_session: AsyncSession): """CSV export on empty tenant returns just the header row.""" @@ -193,7 +193,7 @@ class TestCSVExport: rows = list(reader) # Just the header, no data rows 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): """CSV export with search filter returns only matching contacts.""" @@ -239,13 +239,13 @@ class TestSeedScript: def test_seed_script_exists(self): """AC7: scripts/seed_perf_data.py exists.""" 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}" def test_seed_script_has_count_arg(self): """Seed script accepts --count argument.""" 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: tree = ast.parse(f.read()) source = ast.dump(tree) @@ -258,5 +258,5 @@ class TestCheckIndexesScript: def test_check_indexes_script_exists(self): """scripts/check_indexes.py exists.""" 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}" diff --git a/tests/test_unified_search.py b/tests/test_unified_search.py index 4e378f2..616a346 100644 --- a/tests/test_unified_search.py +++ b/tests/test_unified_search.py @@ -684,8 +684,8 @@ async def test_index_entity_success(db_session: AsyncSession): await db_session.flush() contact = Contact( tenant_id=tenant.id, - first_name="John", - last_name="Doe", + firstname="John", + surname="Doe", email="john@example.com", created_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() contact = Contact( tenant_id=tenant.id, - first_name="Search", - last_name="Test", + firstname="Search", + surname="Test", email="searchtest@example.com", created_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() contact = Contact( tenant_id=tenant.id, - first_name="Index", - last_name="Contact", + firstname="Index", + surname="Contact", email="indexcontact@example.com", created_by=user.id, updated_by=user.id,