Task 5.23: Deduplication / Merge — ContactMergeHistory model, dedup service, API routes (duplicates/merge/merge-history), DedupDialog frontend, i18n, 5 tests
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user