"""Deduplication / merge tests — Task 5.23.""" from __future__ import annotations import pytest from httpx import AsyncClient from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users async def _get_csrf_token(client: AsyncClient) -> str: """Extract CSRF token from login response.""" # The login already happened, but we need the token from the response. # Re-login to get a fresh token (or extract from the last response). resp = await client.post( "/api/v1/auth/login", json={"email": "admin@tenanta.com", "password": "TestPass123!"}, headers=ORIGIN_HEADER, ) return resp.json().get("csrf_token", "") def _csrf_headers(token: str) -> dict: """Return headers with CSRF token.""" headers = dict(ORIGIN_HEADER) headers["X-CSRF-Token"] = token return headers @pytest.mark.asyncio class TestFindDuplicates: """POST /api/v1/contacts/duplicates — find duplicate contacts.""" async def test_find_duplicates_by_email(self, client: AsyncClient, db_session): """Two contacts with same email should be detected as duplicates.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") csrf = await _get_csrf_token(client) hdrs = _csrf_headers(csrf) # Create two contacts with same email resp1 = await client.post( "/api/v1/contacts", json={"type": "person", "firstname": "John", "surname": "Doe", "email_1": "john@example.com"}, headers=hdrs, ) assert resp1.status_code == 201, f"Create 1 failed: {resp1.text}" resp2 = await client.post( "/api/v1/contacts", json={"type": "person", "firstname": "Johnny", "surname": "Doe", "email_1": "john@example.com"}, headers=hdrs, ) assert resp2.status_code == 201, f"Create 2 failed: {resp2.text}" # Find duplicates resp = await client.post( "/api/v1/contacts/duplicates", json={"threshold": 0.5, "limit": 50}, headers=hdrs, ) assert resp.status_code == 200 data = resp.json() assert isinstance(data, list) assert len(data) >= 1 pair = data[0] assert "source_contact" in pair assert "target_contact" in pair assert "similarity_score" in pair assert "match_reasons" in pair assert "email_match" in pair["match_reasons"] async def test_find_duplicates_empty(self, client: AsyncClient, db_session): """No duplicates when contacts are unique.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") csrf = await _get_csrf_token(client) hdrs = _csrf_headers(csrf) resp = await client.post( "/api/v1/contacts", json={"type": "person", "firstname": "Unique", "surname": "Person", "email_1": "unique@example.com"}, headers=hdrs, ) assert resp.status_code == 201, f"Create failed: {resp.text}" resp = await client.post( "/api/v1/contacts/duplicates", json={"threshold": 0.7, "limit": 50}, headers=hdrs, ) assert resp.status_code == 200 data = resp.json() assert isinstance(data, list) assert len(data) == 0 @pytest.mark.asyncio class TestMergeContacts: """POST /api/v1/contacts/merge — merge two contacts.""" async def test_merge_contacts_success(self, client: AsyncClient, db_session): """Merge source into target, source should be soft-deleted.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") csrf = await _get_csrf_token(client) hdrs = _csrf_headers(csrf) # Create source contact with phone resp1 = await client.post( "/api/v1/contacts", json={"type": "person", "firstname": "Source", "surname": "Test", "email_1": "source@example.com", "phone_1": "+49123456789"}, headers=hdrs, ) assert resp1.status_code == 201, f"Create source failed: {resp1.text}" source_id = resp1.json()["id"] # Create target contact without phone resp2 = await client.post( "/api/v1/contacts", json={"type": "person", "firstname": "Target", "surname": "Test", "email_1": "target@example.com"}, headers=hdrs, ) assert resp2.status_code == 201, f"Create target failed: {resp2.text}" target_id = resp2.json()["id"] # Merge resp = await client.post( "/api/v1/contacts/merge", json={"source_contact_id": source_id, "target_contact_id": target_id}, headers=hdrs, ) assert resp.status_code == 200, f"Merge failed: {resp.text}" data = resp.json() assert data["source_contact_id"] == source_id assert data["target_contact_id"] == target_id assert "merge_id" in data assert "merged_fields" in data # Phone should have been auto-merged assert "phone_1" in data["merged_fields"] # Source should be soft-deleted (not in list) list_resp = await client.get("/api/v1/contacts", headers=ORIGIN_HEADER) contact_ids = [c["id"] for c in list_resp.json()["items"]] assert source_id not in contact_ids assert target_id in contact_ids async def test_merge_same_contact_fails(self, client: AsyncClient, db_session): """Merging a contact with itself should fail.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") csrf = await _get_csrf_token(client) hdrs = _csrf_headers(csrf) resp = await client.post( "/api/v1/contacts", json={"type": "person", "firstname": "Same", "surname": "Contact", "email_1": "same@example.com"}, headers=hdrs, ) assert resp.status_code == 201, f"Create failed: {resp.text}" contact_id = resp.json()["id"] resp = await client.post( "/api/v1/contacts/merge", json={"source_contact_id": contact_id, "target_contact_id": contact_id}, headers=hdrs, ) assert resp.status_code == 400 @pytest.mark.asyncio class TestMergeHistory: """GET /api/v1/contacts/merge-history — merge history.""" async def test_merge_history_returns_records(self, client: AsyncClient, db_session): """After a merge, history should contain the record.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") csrf = await _get_csrf_token(client) hdrs = _csrf_headers(csrf) # Create and merge two contacts resp1 = await client.post( "/api/v1/contacts", json={"type": "person", "firstname": "Hist", "surname": "Source", "email_1": "hist-source@example.com"}, headers=hdrs, ) assert resp1.status_code == 201 source_id = resp1.json()["id"] resp2 = await client.post( "/api/v1/contacts", json={"type": "person", "firstname": "Hist", "surname": "Target", "email_1": "hist-target@example.com"}, headers=hdrs, ) assert resp2.status_code == 201 target_id = resp2.json()["id"] merge_resp = await client.post( "/api/v1/contacts/merge", json={"source_contact_id": source_id, "target_contact_id": target_id, "note": "Test merge"}, headers=hdrs, ) assert merge_resp.status_code == 200 # Get history (GET doesn't need CSRF) resp = await client.get("/api/v1/contacts/merge-history", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert "items" in data assert "total" in data assert data["total"] >= 1 item = data["items"][0] assert item["source_contact_id"] == source_id assert item["target_contact_id"] == target_id assert item["note"] == "Test merge"