|
| 1 | +"""Creating a company lead must never silently replace its existing contact.""" |
| 2 | + |
| 3 | +from concurrent.futures import ThreadPoolExecutor |
| 4 | +import threading |
| 5 | +from types import SimpleNamespace |
| 6 | + |
| 7 | +import pytest |
| 8 | +from fastapi import FastAPI |
| 9 | +from fastapi.testclient import TestClient |
| 10 | +from sqlalchemy import create_engine |
| 11 | +from sqlalchemy.orm import sessionmaker |
| 12 | + |
| 13 | +from apps.api.routers import leads |
| 14 | +from apps.api.services.leadgen.db import LeadDB |
| 15 | +from apps.api.services.leadgen.orm_models import LeadRow |
| 16 | +from apps.api.services.leadgen import store |
| 17 | + |
| 18 | + |
| 19 | +@pytest.fixture(params=["sqlite", "orm"]) |
| 20 | +def client(request, tmp_path, monkeypatch): |
| 21 | + if request.param == "sqlite": |
| 22 | + path = tmp_path / "leads.db" |
| 23 | + factory = lambda: LeadDB(str(path)) |
| 24 | + else: |
| 25 | + engine = create_engine(f"sqlite:///{tmp_path / 'orm.db'}", connect_args={"check_same_thread": False, "timeout": 30}) |
| 26 | + LeadRow.__table__.create(engine) |
| 27 | + monkeypatch.setattr(store, "SessionLocal", sessionmaker(bind=engine)) |
| 28 | + factory = lambda: store.PgLeadStore("ws-fixture") |
| 29 | + ctx = SimpleNamespace(workspace_id="ws-fixture", lead_db=factory) |
| 30 | + app = FastAPI() |
| 31 | + app.include_router(leads.router) |
| 32 | + app.dependency_overrides[leads.require_editor] = lambda: ctx |
| 33 | + app.dependency_overrides[leads.current_workspace] = lambda: ctx |
| 34 | + with TestClient(app) as http: |
| 35 | + yield http |
| 36 | + if request.param == "orm": |
| 37 | + engine.dispose() |
| 38 | + |
| 39 | + |
| 40 | +def test_create_collision_preserves_contact_and_omitted_enrichment(client): |
| 41 | + first = client.post("/api/lead", json={ |
| 42 | + "company": "Fixture", "city": "Repro City", "contact_person": "Person One", |
| 43 | + "email": "one@example.invalid", "website": "https://example.invalid", |
| 44 | + }) |
| 45 | + assert first.status_code == 200, first.text |
| 46 | + lead_id = first.json()["id"] |
| 47 | + assert client.put(f"/api/lead/{lead_id}", json={"phone": "+15555550100", "specialization": "B2B SaaS"}).status_code == 200 |
| 48 | + before = client.get(f"/api/lead/{lead_id}").json() |
| 49 | + collision = client.post("/api/lead", json={ |
| 50 | + "company": "Fixture", "city": "Repro City", "contact_person": "Person Two", |
| 51 | + "email": "two@example.invalid", |
| 52 | + }) |
| 53 | + assert collision.status_code == 409, collision.text |
| 54 | + assert collision.json()["detail"]["lead_id"] == lead_id |
| 55 | + assert client.get(f"/api/lead/{lead_id}").json() == before |
| 56 | + # An explicit edit still supports replacement and deliberate clearing. |
| 57 | + changed = client.put(f"/api/lead/{lead_id}", json={"contact_person": "Person Two", "email": "two@example.invalid", "phone": ""}) |
| 58 | + assert changed.status_code == 200, changed.text |
| 59 | + updated = client.get(f"/api/lead/{lead_id}").json() |
| 60 | + assert updated["contact_person"] == "Person Two" |
| 61 | + assert updated["email"] == "two@example.invalid" and updated["phone"] == "" |
| 62 | + assert updated["website"] == "https://example.invalid" |
| 63 | + assert updated["specialization"] == "B2B SaaS" |
| 64 | + |
| 65 | + |
| 66 | +def test_concurrent_create_has_one_winner_without_contact_replacement(client): |
| 67 | + # Initialize the schema before the simultaneous HTTP requests. |
| 68 | + assert client.post("/api/lead", json={"company": "Other Fixture"}).status_code == 200 |
| 69 | + barrier = threading.Barrier(6) |
| 70 | + |
| 71 | + def create(index): |
| 72 | + barrier.wait(timeout=10) |
| 73 | + response = client.post("/api/lead", json={ |
| 74 | + "company": "Concurrent Fixture", "city": "Repro City", |
| 75 | + "contact_person": f"Person {index}", "email": f"person{index}@example.invalid", |
| 76 | + }) |
| 77 | + return index, response |
| 78 | + |
| 79 | + with ThreadPoolExecutor(max_workers=6) as pool: |
| 80 | + responses = list(pool.map(create, range(6))) |
| 81 | + winners = [(index, response) for index, response in responses if response.status_code == 200] |
| 82 | + assert len(winners) == 1, [(index, response.status_code, response.text) for index, response in responses] |
| 83 | + winner, response = winners[0] |
| 84 | + lead_id = response.json()["id"] |
| 85 | + for index, result in responses: |
| 86 | + if index != winner: |
| 87 | + assert result.status_code == 409, result.text |
| 88 | + assert result.json()["detail"]["lead_id"] == lead_id |
| 89 | + saved = client.get(f"/api/lead/{lead_id}").json() |
| 90 | + assert saved["contact_person"] == f"Person {winner}" |
| 91 | + assert saved["email"] == f"person{winner}@example.invalid" |
0 commit comments