T02: companies + contacts + import/export + N:M + soft-delete + GDPR + FTS
- Company CRUD with soft-delete, FTS search (tsvector + GIN), filter, pagination - Contact CRUD with N:M company linking via company_contacts - CSV/XLSX export, CSV import with dry-run preview - GDPR hard-delete with deletion_log - Audit log on all mutations - 27 new tests (24 ACs), 56 total tests pass - Migration 0002: contacts, company_contacts, FTS search_tsv - Fixed T01 tests: POST→201, PATCH→PUT compatibility
This commit is contained in:
+2
-1
@@ -31,6 +31,7 @@ from app.models.tenant import Tenant
|
||||
from app.models.user import User, UserTenant
|
||||
from app.models.role import Role
|
||||
from app.models.company import Company
|
||||
from app.models.contact import Contact, CompanyContact
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
@@ -86,7 +87,7 @@ def clean_tables(db_setup):
|
||||
sync_eng = _get_sync_engine()
|
||||
with sync_eng.connect() as conn:
|
||||
# TRUNCATE all tables with CASCADE — fast and reliable isolation
|
||||
conn.execute(text("TRUNCATE TABLE api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"))
|
||||
conn.execute(text("TRUNCATE TABLE company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"))
|
||||
conn.commit()
|
||||
sync_eng.dispose()
|
||||
yield
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
"""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
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Contact tests — ACs 14-19: CRUD, N:M, soft-delete, GDPR hard-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 TestContactList:
|
||||
"""AC 14: List contacts paginated."""
|
||||
|
||||
async def test_list_contacts_returns_200_paginated(self, client: AsyncClient, db_session):
|
||||
"""AC 14: GET /api/v1/contacts -> 200 + paginated."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.get("/api/v1/contacts", 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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestContactCreate:
|
||||
"""AC 15: Create contact with company_ids array -> N:M links."""
|
||||
|
||||
async def test_create_contact_with_company_ids_returns_201(self, client: AsyncClient, db_session):
|
||||
"""AC 15: POST /api/v1/contacts mit company_ids array -> 201 + N:M links."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
# Get seeded company ID
|
||||
list_resp = await client.get("/api/v1/companies", headers=ORIGIN_HEADER)
|
||||
company_id = list_resp.json()["items"][0]["id"]
|
||||
resp = await client.post(
|
||||
"/api/v1/contacts",
|
||||
json={
|
||||
"first_name": "Alice",
|
||||
"last_name": "Wonderland",
|
||||
"email": "alice@example.com",
|
||||
"company_ids": [company_id],
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["first_name"] == "Alice"
|
||||
assert data["last_name"] == "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)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestContactDetail:
|
||||
"""AC 16: Get contact detail with companies array."""
|
||||
|
||||
async def test_get_contact_returns_200_with_companies(self, client: AsyncClient, db_session):
|
||||
"""AC 16: GET /api/v1/contacts/{id} -> 200 + detail inkl. companies array."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
list_resp = await client.get("/api/v1/companies", headers=ORIGIN_HEADER)
|
||||
company_id = list_resp.json()["items"][0]["id"]
|
||||
create_resp = await client.post(
|
||||
"/api/v1/contacts",
|
||||
json={
|
||||
"first_name": "Bob",
|
||||
"last_name": "Builder",
|
||||
"company_ids": [company_id],
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
contact_id = create_resp.json()["id"]
|
||||
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 "companies" in data
|
||||
assert isinstance(data["companies"], list)
|
||||
assert len(data["companies"]) == 1
|
||||
assert data["companies"][0]["name"] == "Company Alpha"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestContactUpdate:
|
||||
"""AC 17: Update contact."""
|
||||
|
||||
async def test_update_contact_returns_200(self, client: AsyncClient, db_session):
|
||||
"""AC 17: PUT /api/v1/contacts/{id} -> 200."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
create_resp = await client.post(
|
||||
"/api/v1/contacts",
|
||||
json={"first_name": "Old", "last_name": "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"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["first_name"] == "New"
|
||||
assert data["email"] == "new@example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestContactDelete:
|
||||
"""ACs 18-19: Soft-delete, GDPR hard-delete."""
|
||||
|
||||
async def test_delete_contact_soft_delete_returns_204(self, client: AsyncClient, db_session):
|
||||
"""AC 18: DELETE /api/v1/contacts/{id} -> 204, soft-delete."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
create_resp = await client.post(
|
||||
"/api/v1/contacts",
|
||||
json={"first_name": "Delete", "last_name": "Me"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
contact_id = create_resp.json()["id"]
|
||||
resp = await client.delete(f"/api/v1/contacts/{contact_id}", headers=ORIGIN_HEADER)
|
||||
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"]]
|
||||
assert "Delete Me" not in names
|
||||
|
||||
async def test_delete_contact_gdpr_hard_delete_returns_204(self, client: AsyncClient, db_session):
|
||||
"""AC 19: DELETE /api/v1/contacts/{id}?gdpr=true -> 204, hard-delete + deletion_log."""
|
||||
from sqlalchemy import select
|
||||
from app.models.audit import DeletionLog
|
||||
from app.models.contact import Contact
|
||||
import uuid as uuid_mod
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
create_resp = await client.post(
|
||||
"/api/v1/contacts",
|
||||
json={"first_name": "GDPR", "last_name": "Delete"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
contact_id = create_resp.json()["id"]
|
||||
resp = await client.delete(
|
||||
f"/api/v1/contacts/{contact_id}?gdpr=true",
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
# Verify physical delete — contact should not exist in DB
|
||||
q = select(Contact).where(Contact.id == uuid_mod.UUID(contact_id))
|
||||
result = await db_session.execute(q)
|
||||
assert result.scalar_one_or_none() is None
|
||||
# Verify deletion_log entry exists
|
||||
dl_q = select(DeletionLog).where(
|
||||
DeletionLog.entity_type == "contact",
|
||||
DeletionLog.entity_id == uuid_mod.UUID(contact_id),
|
||||
)
|
||||
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"
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Import/export tests — ACs 20-21: CSV import, dry-run preview."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client
|
||||
|
||||
|
||||
CSV_COMPANIES = """name,industry,phone,email,website,description
|
||||
ImportCorp,IT,123456,import@example.com,https://import.example,Imported company
|
||||
TechImport,Finance,654321,tech@example.com,https://tech.example,Tech company
|
||||
"""
|
||||
|
||||
CSV_COMPANIES_INVALID = """name,industry
|
||||
,IT
|
||||
ValidCorp,Finance
|
||||
"""
|
||||
|
||||
CSV_CONTACTS = """first_name,last_name,email,phone,mobile,position,department
|
||||
Alice,Wonderland,alice@example.com,123,456,Manager,Sales
|
||||
Bob,Builder,bob@example.com,789,012,Developer,Tech
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestImportCompanies:
|
||||
"""AC 20: CSV import for companies."""
|
||||
|
||||
async def test_import_companies_csv_returns_200(self, client: AsyncClient, db_session):
|
||||
"""AC 20: POST /api/v1/import CSV + entity_type=companies -> 200 + result."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
files = {"file": ("companies.csv", CSV_COMPANIES.encode(), "text/csv")}
|
||||
data = {"entity_type": "companies"}
|
||||
resp = await client.post(
|
||||
"/api/v1/import",
|
||||
files=files,
|
||||
data=data,
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
result = resp.json()
|
||||
assert result["total"] == 2
|
||||
assert result["valid"] == 2
|
||||
assert result["invalid"] == 0
|
||||
assert len(result["created"]) == 2
|
||||
assert result["dry_run"] is False
|
||||
|
||||
async def test_import_companies_with_invalid_rows(self, client: AsyncClient, db_session):
|
||||
"""Import CSV with some invalid rows — should report errors but import valid ones."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
files = {"file": ("companies.csv", CSV_COMPANIES_INVALID.encode(), "text/csv")}
|
||||
data = {"entity_type": "companies"}
|
||||
resp = await client.post(
|
||||
"/api/v1/import",
|
||||
files=files,
|
||||
data=data,
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
result = resp.json()
|
||||
assert result["total"] == 2
|
||||
assert result["valid"] == 1
|
||||
assert result["invalid"] == 1
|
||||
assert len(result["errors"]) == 1
|
||||
assert len(result["created"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestImportPreview:
|
||||
"""AC 21: Dry-run preview (no DB changes)."""
|
||||
|
||||
async def test_import_preview_no_db_changes(self, client: AsyncClient, db_session):
|
||||
"""AC 21: POST /api/v1/import/preview CSV -> 200 + dry-run (no DB changes)."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
files = {"file": ("companies.csv", CSV_COMPANIES.encode(), "text/csv")}
|
||||
data = {"entity_type": "companies"}
|
||||
resp = await client.post(
|
||||
"/api/v1/import/preview",
|
||||
files=files,
|
||||
data=data,
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
result = resp.json()
|
||||
assert result["total"] == 2
|
||||
assert result["valid"] == 2
|
||||
assert result["dry_run"] is True
|
||||
assert len(result["created"]) == 0 # No actual creations
|
||||
|
||||
# Verify no companies were actually created
|
||||
list_resp = await client.get("/api/v1/companies", headers=ORIGIN_HEADER)
|
||||
names = [item["name"] for item in list_resp.json()["items"]]
|
||||
assert "ImportCorp" not in names
|
||||
assert "TechImport" not in names
|
||||
|
||||
async def test_import_preview_contacts_no_db_changes(self, client: AsyncClient, db_session):
|
||||
"""Preview import for contacts — dry-run."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
files = {"file": ("contacts.csv", CSV_CONTACTS.encode(), "text/csv")}
|
||||
data = {"entity_type": "contacts"}
|
||||
resp = await client.post(
|
||||
"/api/v1/import/preview",
|
||||
files=files,
|
||||
data=data,
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
result = resp.json()
|
||||
assert result["total"] == 2
|
||||
assert result["valid"] == 2
|
||||
assert result["dry_run"] is True
|
||||
assert len(result["created"]) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestImportContacts:
|
||||
"""Import contacts via CSV."""
|
||||
|
||||
async def test_import_contacts_csv_returns_200(self, client: AsyncClient, db_session):
|
||||
"""Import contacts via CSV."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
files = {"file": ("contacts.csv", CSV_CONTACTS.encode(), "text/csv")}
|
||||
data = {"entity_type": "contacts"}
|
||||
resp = await client.post(
|
||||
"/api/v1/import",
|
||||
files=files,
|
||||
data=data,
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
result = resp.json()
|
||||
assert result["total"] == 2
|
||||
assert result["valid"] == 2
|
||||
assert result["invalid"] == 0
|
||||
assert len(result["created"]) == 2
|
||||
# Verify contacts appear in list
|
||||
list_resp = await client.get("/api/v1/contacts", headers=ORIGIN_HEADER)
|
||||
assert list_resp.json()["total"] >= 2
|
||||
@@ -209,7 +209,7 @@ class TestAuditLog:
|
||||
json={"name": "Audit Test Co"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.status_code == 201
|
||||
|
||||
# Check audit_log table
|
||||
from sqlalchemy import select as sel
|
||||
@@ -222,7 +222,7 @@ class TestAuditLog:
|
||||
"""AC 19: Audit log entry created on company.update."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.patch(
|
||||
resp = await client.put(
|
||||
f"/api/v1/companies/{seed['company_a'].id}",
|
||||
json={"name": "Updated Company Alpha"},
|
||||
headers=ORIGIN_HEADER,
|
||||
|
||||
Reference in New Issue
Block a user