"""Company tests — ACs 1-13, 22-24: CRUD, search, filter, export, N:M, emails, audit, soft-delete.""" from __future__ import annotations import pytest from httpx import AsyncClient from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client @pytest.mark.asyncio class TestCompanyList: """ACs 1-3: List, search, filter+sort.""" async def test_list_companies_returns_200_paginated(self, client: AsyncClient, db_session): """AC 1: GET /api/v1/companies -> 200 + paginated (total/page/page_size).""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get("/api/v1/companies", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert "items" in data assert "total" in data assert "page" in data assert "page_size" in data assert data["total"] == 1 # seed creates one company in tenant A async def test_list_companies_search_fts(self, client: AsyncClient, db_session): """AC 2: GET /api/v1/companies?search=Tech -> 200 + FTS results.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") # Create a company with 'Tech' in name/description resp = await client.post( "/api/v1/companies", json={"name": "TechCorp", "industry": "IT", "description": "Technology solutions"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 # Search for 'Tech' resp = await client.get("/api/v1/companies?search=Tech", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert data["total"] >= 1 names = [item["name"] for item in data["items"]] assert "TechCorp" in names async def test_list_companies_filter_and_sort(self, client: AsyncClient, db_session): """AC 3: GET /api/v1/companies?industry=IT&sort_by=name&sort_order=asc -> 200 + filtered+sorted.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") # Create additional companies await client.post( "/api/v1/companies", json={"name": "ZetaTech", "industry": "IT"}, headers=ORIGIN_HEADER, ) await client.post( "/api/v1/companies", json={"name": "AlphaTech", "industry": "IT"}, headers=ORIGIN_HEADER, ) resp = await client.get( "/api/v1/companies?industry=IT&sort_by=name&sort_order=asc", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() names = [item["name"] for item in data["items"]] assert names == sorted(names) assert all(item["industry"] == "IT" for item in data["items"]) @pytest.mark.asyncio class TestCompanyCreate: """ACs 4-5: Create valid, missing name.""" async def test_create_company_valid_returns_201(self, client: AsyncClient, db_session): """AC 4: POST /api/v1/companies valid -> 201 + company object.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.post( "/api/v1/companies", json={"name": "New Corp", "industry": "Finance"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 data = resp.json() assert data["name"] == "New Corp" assert data["industry"] == "Finance" assert "id" in data async def test_create_company_missing_name_returns_422(self, client: AsyncClient, db_session): """AC 5: POST /api/v1/companies missing name -> 422.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.post( "/api/v1/companies", json={"industry": "Finance"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 422 @pytest.mark.asyncio class TestCompanyDetail: """AC 6: Get single company with contacts array.""" async def test_get_company_returns_200_with_contacts(self, client: AsyncClient, db_session): """AC 6: GET /api/v1/companies/{id} -> 200 + detail inkl. contacts array.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") # Create a company create_resp = await client.post( "/api/v1/companies", json={"name": "Detail Corp"}, headers=ORIGIN_HEADER, ) company_id = create_resp.json()["id"] resp = await client.get(f"/api/v1/companies/{company_id}", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert data["name"] == "Detail Corp" assert "contacts" in data assert isinstance(data["contacts"], list) @pytest.mark.asyncio class TestCompanyUpdate: """AC 7: Update company.""" async def test_update_company_returns_200(self, client: AsyncClient, db_session): """AC 7: PUT /api/v1/companies/{id} -> 200 + updated.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/companies", json={"name": "Old Name"}, headers=ORIGIN_HEADER, ) company_id = create_resp.json()["id"] resp = await client.put( f"/api/v1/companies/{company_id}", json={"name": "New Name", "industry": "Healthcare"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert data["name"] == "New Name" assert data["industry"] == "Healthcare" @pytest.mark.asyncio class TestCompanyDelete: """ACs 8-9: Soft-delete, cascade delete.""" async def test_delete_company_soft_delete_returns_204(self, client: AsyncClient, db_session): """AC 8: DELETE /api/v1/companies/{id} -> 204, deleted_at gesetzt (soft-delete).""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/companies", json={"name": "Delete Me"}, headers=ORIGIN_HEADER, ) company_id = create_resp.json()["id"] resp = await client.delete(f"/api/v1/companies/{company_id}", headers=ORIGIN_HEADER) assert resp.status_code == 204 # Verify company is not in list (soft-deleted) list_resp = await client.get("/api/v1/companies", headers=ORIGIN_HEADER) names = [item["name"] for item in list_resp.json()["items"]] assert "Delete Me" not in names async def test_delete_company_cascade_returns_204(self, client: AsyncClient, db_session): """AC 9: DELETE /api/v1/companies/{id}?cascade=true -> 204, company + links geloescht.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") # Create company and contact comp_resp = await client.post( "/api/v1/companies", json={"name": "Cascade Corp"}, headers=ORIGIN_HEADER, ) 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]}, headers=ORIGIN_HEADER, ) contact_id = cont_resp.json()["id"] # Verify link exists detail_resp = await client.get(f"/api/v1/companies/{company_id}", headers=ORIGIN_HEADER) assert len(detail_resp.json()["contacts"]) == 1 # Cascade delete resp = await client.delete( f"/api/v1/companies/{company_id}?cascade=true", headers=ORIGIN_HEADER, ) assert resp.status_code == 204 @pytest.mark.asyncio class TestCompanyContactLink: """ACs 10-11: N:M link, unlink.""" async def test_link_contact_to_company_returns_200(self, client: AsyncClient, db_session): """AC 10: POST /api/v1/companies/{id}/contacts/{cid} -> 200, N:M link.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") comp_resp = await client.post( "/api/v1/companies", json={"name": "Link Corp"}, headers=ORIGIN_HEADER, ) company_id = comp_resp.json()["id"] cont_resp = await client.post( "/api/v1/contacts", json={"first_name": "Jane", "last_name": "Smith"}, headers=ORIGIN_HEADER, ) contact_id = cont_resp.json()["id"] resp = await client.post( f"/api/v1/companies/{company_id}/contacts/{contact_id}", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert data["company_id"] == company_id assert data["contact_id"] == contact_id async def test_unlink_contact_from_company_returns_204(self, client: AsyncClient, db_session): """AC 11: DELETE /api/v1/companies/{id}/contacts/{cid} -> 204, N:M unlink.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") comp_resp = await client.post( "/api/v1/companies", json={"name": "Unlink Corp"}, headers=ORIGIN_HEADER, ) company_id = comp_resp.json()["id"] cont_resp = await client.post( "/api/v1/contacts", json={"first_name": "Bob", "last_name": "Wilson"}, headers=ORIGIN_HEADER, ) contact_id = cont_resp.json()["id"] # Link first await client.post( f"/api/v1/companies/{company_id}/contacts/{contact_id}", headers=ORIGIN_HEADER, ) # Unlink resp = await client.delete( f"/api/v1/companies/{company_id}/contacts/{contact_id}", headers=ORIGIN_HEADER, ) assert resp.status_code == 204 # Verify contact is no longer linked detail = await client.get(f"/api/v1/companies/{company_id}", headers=ORIGIN_HEADER) assert len(detail.json()["contacts"]) == 0 @pytest.mark.asyncio class TestCompanyExport: """ACs 12-13: CSV export, XLSX export.""" async def test_export_csv_returns_200_text_csv(self, client: AsyncClient, db_session): """AC 12: GET /api/v1/companies/export?format=csv -> 200 + text/csv.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get( "/api/v1/companies/export?format=csv", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert "text/csv" in resp.headers.get("content-type", "") assert "name" in resp.text # CSV header async def test_export_xlsx_returns_200_openxml(self, client: AsyncClient, db_session): """AC 13: GET /api/v1/companies/export?format=xlsx -> 200 + openxmlformats.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get( "/api/v1/companies/export?format=xlsx", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 ct = resp.headers.get("content-type", "") assert "spreadsheet" in ct or "openxml" in ct assert len(resp.content) > 0 @pytest.mark.asyncio class TestCompanyEmails: """AC 22: Emails endpoint (mail plugin inactive).""" async def test_get_company_emails_returns_200_empty(self, client: AsyncClient, db_session): """AC 22: GET /api/v1/companies/{id}/emails -> 200 (empty array, mail plugin inactive).""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") comp_resp = await client.post( "/api/v1/companies", json={"name": "Email Corp"}, headers=ORIGIN_HEADER, ) company_id = comp_resp.json()["id"] resp = await client.get( f"/api/v1/companies/{company_id}/emails", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json() == [] @pytest.mark.asyncio class TestCompanyAuditAndSoftDelete: """ACs 23-24: Audit log, soft-delete filter.""" async def test_audit_log_on_company_create(self, client: AsyncClient, db_session): """AC 23: Audit log entry on every company/contact mutation.""" from sqlalchemy import select from app.models.audit import AuditLog await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") await client.post( "/api/v1/companies", json={"name": "Audited Corp"}, headers=ORIGIN_HEADER, ) q = select(AuditLog).where(AuditLog.entity_type == "company", AuditLog.action == "create") result = await db_session.execute(q) entries = result.scalars().all() assert len(entries) >= 1 assert any(e.changes and e.changes.get("name") == "Audited Corp" for e in entries) async def test_soft_deleted_company_not_in_list(self, client: AsyncClient, db_session): """AC 24: Soft-deleted company not in GET list (deleted_at IS NULL filter).""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") # Create and then delete a company create_resp = await client.post( "/api/v1/companies", json={"name": "SoftDelete Corp"}, headers=ORIGIN_HEADER, ) company_id = create_resp.json()["id"] await client.delete(f"/api/v1/companies/{company_id}", headers=ORIGIN_HEADER) # List should not contain deleted company list_resp = await client.get("/api/v1/companies", headers=ORIGIN_HEADER) names = [item["name"] for item in list_resp.json()["items"]] assert "SoftDelete Corp" not in names assert "Company Alpha" in names # Seed company still present