215 lines
8.0 KiB
Python
215 lines
8.0 KiB
Python
import asyncio
|
|
import io
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
|
|
def test_router_simple_create_contact_supports_extended_payload_and_company_links(monkeypatch):
|
|
from app.contacts.backend import router_simple
|
|
|
|
insert_calls = []
|
|
update_calls = []
|
|
|
|
def fake_execute_query(query, params=None):
|
|
if "UPDATE contact_companies" in query:
|
|
update_calls.append((query, params))
|
|
return []
|
|
|
|
def fake_execute_insert(query, params=None):
|
|
insert_calls.append((query, params))
|
|
if "INSERT INTO contacts" in query:
|
|
return 321
|
|
return 1
|
|
|
|
async def fake_get_contact(contact_id):
|
|
return {"id": contact_id, "first_name": "Ada"}
|
|
|
|
monkeypatch.setattr(router_simple, "execute_query", fake_execute_query)
|
|
monkeypatch.setattr(router_simple, "execute_insert", fake_execute_insert)
|
|
monkeypatch.setattr(router_simple, "get_contact", fake_get_contact)
|
|
|
|
payload = router_simple.ContactCreate(
|
|
first_name="Ada",
|
|
last_name="Lovelace",
|
|
email="ada@example.com",
|
|
phone="11111111",
|
|
mobile="22222222",
|
|
title="CTO",
|
|
department="IT",
|
|
company_ids=[10, 20],
|
|
is_primary=True,
|
|
role="Decision maker",
|
|
is_active=True,
|
|
)
|
|
|
|
created = asyncio.run(router_simple.create_contact(payload))
|
|
|
|
assert created["id"] == 321
|
|
assert any("INSERT INTO contacts" in query for query, _ in insert_calls)
|
|
company_link_params = [params for query, params in insert_calls if "INSERT INTO contact_companies" in query]
|
|
assert len(company_link_params) == 2
|
|
assert company_link_params[0][1] == 10
|
|
assert company_link_params[1][1] == 20
|
|
assert update_calls
|
|
|
|
|
|
def test_contact_email_regex_uses_exact_address_boundaries():
|
|
import re
|
|
from app.contacts.backend.router_simple import _exact_email_pattern
|
|
|
|
pattern = re.compile(_exact_email_pattern("ada@example.com"))
|
|
assert pattern.search("Ada <ada@example.com>, other@example.com")
|
|
assert not pattern.search("notada@example.com")
|
|
assert not pattern.search("ada@example.com.evil.test")
|
|
|
|
|
|
def test_contact_detail_has_cases_and_email_tabs():
|
|
template = Path("app/contacts/frontend/contact_detail.html").read_text(encoding="utf-8")
|
|
assert 'href="#cases"' in template
|
|
assert 'href="#emails"' in template
|
|
assert "/cases?limit=${contactRelatedPageSize}" in template
|
|
assert "/emails?limit=${contactRelatedPageSize}" in template
|
|
|
|
|
|
def test_contact_email_analysis_returns_review_only_changes():
|
|
from app.contacts.backend.router_simple import _contact_suggestions_from_email
|
|
|
|
contact = {
|
|
"first_name": "Ada",
|
|
"last_name": "Lovelace",
|
|
"email": "ada@old.example",
|
|
"phone": "11111111",
|
|
"mobile": None,
|
|
"title": "Udvikler",
|
|
"department": None,
|
|
}
|
|
parsed = {
|
|
"sender_name": "Ada Lovelace <ada@new.example>",
|
|
"sender_email": "ada@new.example",
|
|
"recipient_email": "support@bmc.example",
|
|
"body_text": "Hej\n\nMobil: +45 22 33 44 55\nTitel: CTO\nAfdeling: IT\n",
|
|
}
|
|
|
|
suggestions = _contact_suggestions_from_email(contact, parsed)
|
|
by_field = {item["field"]: item for item in suggestions}
|
|
|
|
assert by_field["email"]["suggested"] == "ada@new.example"
|
|
assert by_field["mobile"]["suggested"] == "+45 22 33 44 55"
|
|
assert by_field["title"]["suggested"] == "CTO"
|
|
assert by_field["department"]["suggested"] == "IT"
|
|
assert "first_name" not in by_field
|
|
assert "last_name" not in by_field
|
|
assert "phone" not in by_field
|
|
|
|
|
|
def test_contact_email_analysis_does_not_treat_our_sender_as_the_contact():
|
|
from app.contacts.backend.router_simple import _contact_suggestions_from_email
|
|
|
|
contact = {"email": "customer@example.com"}
|
|
parsed = {
|
|
"sender_name": "Support Agent",
|
|
"sender_email": "support@bmc.example",
|
|
"recipient_email": "Customer <customer@example.com>",
|
|
"body_text": "Venlig hilsen",
|
|
}
|
|
|
|
assert _contact_suggestions_from_email(contact, parsed) == []
|
|
|
|
|
|
def test_contact_email_analysis_handles_createx_outlook_signature():
|
|
from app.contacts.backend.router_simple import _contact_suggestions_from_email
|
|
|
|
contact = {
|
|
"first_name": "Ida", "last_name": "Gundersen",
|
|
"email": "ida@createx-onstage.com", "mobile": None, "title": None,
|
|
}
|
|
parsed = {
|
|
"sender_name": "Ida <ida@createx-onstage.com>",
|
|
"sender_email": "ida@createx-onstage.com",
|
|
"recipient_email": "support@example.com",
|
|
"body_text": """Ida
|
|
Kind regards
|
|
**Ida Gundersen**
|
|
*Technical Advisor & Co-owner*
|
|
|
|
**Mobile:** +45 42 25 59 08 **DK:&#xA0;**+45 55 86 05 00
|
|
**FI:** +358 40 550 5865 **NO:** +47 62 41 84 05
|
|
**Email:** ida\\@createx-onstage.com
|
|
""",
|
|
}
|
|
|
|
by_field = {
|
|
item["field"]: item
|
|
for item in _contact_suggestions_from_email(contact, parsed)
|
|
}
|
|
assert by_field["mobile"]["suggested"] == "+45 42 25 59 08"
|
|
assert by_field["title"]["suggested"] == "Technical Advisor & Co-owner"
|
|
|
|
|
|
def test_contact_detail_has_outlook_dropzone_and_review_modal():
|
|
template = Path("app/contacts/frontend/contact_detail.html").read_text(encoding="utf-8")
|
|
assert 'id="contactEmailDropzone"' in template
|
|
assert 'accept=".msg,.eml,message/rfc822,application/vnd.ms-outlook"' in template
|
|
assert "/analyze-email" in template
|
|
assert 'id="contactEmailSuggestionsModal"' in template
|
|
assert "applyContactEmailSuggestions()" in template
|
|
|
|
|
|
def test_new_contact_email_analysis_extracts_company_cvr_and_name():
|
|
from app.contacts.backend.router_simple import _company_from_email_body
|
|
|
|
company = _company_from_email_body(
|
|
"Ida Gundersen\nTechnical Advisor & Co-owner\nCreatex ApS\nStoregade 4C | 4780 Stege\nCVR: 12 34 56 78",
|
|
"Ida Gundersen",
|
|
)
|
|
assert company == {"name": "Createx ApS", "cvr_number": "12345678"}
|
|
|
|
|
|
def test_contacts_page_can_create_contact_from_email():
|
|
template = Path("app/contacts/frontend/contacts.html").read_text(encoding="utf-8")
|
|
assert "Træk Outlook-mail hertil" in template
|
|
assert 'id="createFromEmailInput"' in template
|
|
assert 'id="createFromEmailDropzone"' in template
|
|
assert "event.dataTransfer?.files?.[0]" in template
|
|
assert "initializeCreateFromEmailDropzone()" in template
|
|
assert "'/api/v1/contacts/analyze-email'" in template
|
|
assert "'/api/v1/contacts/resolve-email-company'" in template
|
|
assert 'id="createFromEmailModal"' in template
|
|
|
|
|
|
def test_new_contact_email_uses_existing_cvr_lookup(monkeypatch):
|
|
from starlette.datastructures import UploadFile
|
|
from app.contacts.backend import router_simple
|
|
from app.services.email_service import EmailService
|
|
|
|
parsed = {
|
|
"sender_name": "Ida Gundersen", "sender_email": "ida@example.com",
|
|
"recipient_email": "support@example.com", "subject": "Hej",
|
|
"body_text": "Ida Gundersen\nRådgiver\nForkert navn\nStoregade 4, 4780 Stege\nCVR: 12345678",
|
|
}
|
|
|
|
class FakeCvrService:
|
|
async def lookup_by_cvr(self, cvr):
|
|
assert cvr == "12345678"
|
|
return {"name": "Officielt Firma ApS", "address": "Torvet 1", "postal_code": "4780", "city": "Stege", "source": "firmaapi"}
|
|
|
|
monkeypatch.setattr(EmailService, "parse_eml_file", lambda self, content: parsed)
|
|
monkeypatch.setattr(router_simple, "get_cvr_service", lambda: FakeCvrService())
|
|
monkeypatch.setattr(router_simple, "execute_query_single", lambda *args, **kwargs: None)
|
|
|
|
result = asyncio.run(router_simple.analyze_email_for_new_contact(
|
|
UploadFile(filename="mail.eml", file=io.BytesIO(b"mail"))
|
|
))
|
|
assert result["company"]["lookup_found"] is True
|
|
assert result["company"]["name"] == "Officielt Firma ApS"
|
|
assert result["company"]["address"] == "Torvet 1"
|
|
|
|
|
|
def test_contacts_without_search_uses_valid_neutral_ordering():
|
|
source = Path("app/contacts/backend/router_simple.py").read_text(encoding="utf-8")
|
|
assert 'rank_order_sql = ""' in source
|
|
assert "ORDER BY {rank_order_sql} c.last_name" in source
|
|
assert 'rank_sql = "0"' not in source
|