273 lines
7.8 KiB
Python
273 lines
7.8 KiB
Python
"""Unit tests for Redmine client, mapper, and CRM upsert."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from http import HTTPStatus
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
import responses
|
|
|
|
from redmine_crm.crm_client import CrmClient, CrmError
|
|
from redmine_crm.mapper import enrich, map_contact
|
|
from redmine_crm.redmine_client import (
|
|
RedmineAuthError,
|
|
RedmineClient,
|
|
RedmineNotFoundError,
|
|
RedmineTransientError,
|
|
RetryConfig,
|
|
)
|
|
from redmine_crm.sync import SyncState, run_sync
|
|
|
|
|
|
BASE_URL = "https://redmine.example.com"
|
|
|
|
|
|
@responses.activate
|
|
def test_fetch_contacts_success():
|
|
contacts = [{"id": 1, "first_name": "Ada", "last_name": "Lovelace"}]
|
|
responses.add(
|
|
responses.GET,
|
|
f"{BASE_URL}/contacts.json",
|
|
json={"contacts": contacts},
|
|
status=200,
|
|
)
|
|
|
|
client = RedmineClient(BASE_URL, "secret")
|
|
result = client.fetch_contacts()
|
|
|
|
assert result == contacts
|
|
request = responses.calls[0].request
|
|
assert request.headers["X-Redmine-API-Key"] == "secret"
|
|
|
|
|
|
@responses.activate
|
|
def test_fetch_contacts_transient_retry_then_success():
|
|
responses.add(responses.GET, f"{BASE_URL}/contacts.json", status=503)
|
|
responses.add(
|
|
responses.GET,
|
|
f"{BASE_URL}/contacts.json",
|
|
json={"contacts": [{"id": 1, "first_name": "Grace", "last_name": "Hopper"}]},
|
|
status=200,
|
|
)
|
|
|
|
client = RedmineClient(
|
|
BASE_URL,
|
|
"secret",
|
|
retry=RetryConfig(max_attempts=2, base_delay_seconds=0.001),
|
|
)
|
|
result = client.fetch_contacts()
|
|
|
|
assert len(result) == 1
|
|
assert len(responses.calls) == 2
|
|
|
|
|
|
@responses.activate
|
|
def test_fetch_contacts_permanent_failure_after_retries():
|
|
responses.add(responses.GET, f"{BASE_URL}/contacts.json", status=503)
|
|
|
|
client = RedmineClient(
|
|
BASE_URL,
|
|
"secret",
|
|
retry=RetryConfig(max_attempts=1, base_delay_seconds=0.001),
|
|
)
|
|
with pytest.raises(RedmineTransientError):
|
|
client.fetch_contacts()
|
|
|
|
|
|
@responses.activate
|
|
def test_fetch_contact_not_found_raises():
|
|
responses.add(responses.GET, f"{BASE_URL}/contacts/99.json", status=404)
|
|
|
|
client = RedmineClient(BASE_URL, "secret")
|
|
with pytest.raises(RedmineNotFoundError):
|
|
client.fetch_contact(99)
|
|
|
|
|
|
@responses.activate
|
|
def test_auth_error_is_permanent():
|
|
responses.add(responses.GET, f"{BASE_URL}/contacts.json", status=401)
|
|
|
|
client = RedmineClient(BASE_URL, "secret")
|
|
with pytest.raises(RedmineAuthError):
|
|
client.fetch_contacts()
|
|
|
|
|
|
def test_map_contact_basic():
|
|
raw = {
|
|
"id": 7,
|
|
"first_name": "Alan",
|
|
"last_name": "Turing",
|
|
"company": "Bletchley Park",
|
|
"job_title": "Mathematician",
|
|
"email": "alan@example.com",
|
|
"phone": "+44 345 678",
|
|
"website": "https://turing.example.com",
|
|
"birthday": "1912-06-23",
|
|
"background": "Father of computer science.",
|
|
"tag_list": ["Hero", "pioneer"],
|
|
"updated_at": "2026-07-01T12:00:00Z",
|
|
"address_attributes": {
|
|
"street": "1 Enigma Way",
|
|
"city": "Bletchley",
|
|
"state": "England",
|
|
"country": "UK",
|
|
"zip": "MK3 9HB",
|
|
},
|
|
"custom_fields": [
|
|
{"name": "LinkedIn", "value": "https://linkedin.com/in/alan"},
|
|
{"name": "Source", "value": "conference"},
|
|
],
|
|
}
|
|
|
|
mapped = map_contact(raw, base_url=BASE_URL)
|
|
|
|
assert mapped["redmine_contact_id"] == 7
|
|
assert mapped["full_name"] == "Alan Turing"
|
|
assert mapped["company"] == "Bletchley Park"
|
|
assert mapped["title"] == "Mathematician"
|
|
assert mapped["emails"] == [{"value": "alan@example.com", "type": "work"}]
|
|
assert mapped["urls"] == [
|
|
{"value": "https://turing.example.com", "type": "work"},
|
|
{"value": "https://linkedin.com/in/alan", "type": "linkedin"},
|
|
]
|
|
assert mapped["address"]["city"] == "Bletchley"
|
|
assert mapped["address"]["postal_code"] == "MK3 9HB"
|
|
assert "Father of computer science" in mapped["notes"]
|
|
assert "LinkedIn" not in mapped["notes"] # promoted to URL
|
|
assert "Source" in mapped["notes"]
|
|
assert "from-redmine" in mapped["tags"]
|
|
assert mapped["tags"] == sorted({"hero", "pioneer", "from-redmine"})
|
|
assert mapped["last_redmine_update"] is not None
|
|
|
|
|
|
def test_map_company_record():
|
|
raw = {
|
|
"id": 8,
|
|
"is_company": True,
|
|
"company": "Acme Corp",
|
|
"email": "info@acme.example.com",
|
|
"updated_at": "2026-07-02T10:00:00Z",
|
|
}
|
|
mapped = map_contact(raw, base_url=BASE_URL)
|
|
assert mapped["full_name"] == "Acme Corp"
|
|
assert mapped["is_company"] is True
|
|
|
|
|
|
def test_enrich_fills_email_from_background():
|
|
raw = {
|
|
"id": 9,
|
|
"first_name": "Linus",
|
|
"last_name": "Torvalds",
|
|
"background": "Reach me at linus@linux.org for kernel stuff.",
|
|
"updated_at": "2026-07-03T10:00:00Z",
|
|
}
|
|
mapped = map_contact(raw, base_url=BASE_URL)
|
|
assert mapped["emails"] == []
|
|
enriched, meta = enrich(mapped, raw)
|
|
assert enriched["emails"] == [{"value": "linus@linux.org", "type": "work"}]
|
|
assert meta.email_source == "background"
|
|
assert enriched["enriched_at"] is not None
|
|
|
|
|
|
def test_enrich_derives_company_from_domain():
|
|
raw = {
|
|
"id": 10,
|
|
"first_name": "Tim",
|
|
"last_name": "Berners-Lee",
|
|
"email": "tim@w3.org",
|
|
"updated_at": "2026-07-03T10:00:00Z",
|
|
}
|
|
mapped = map_contact(raw, base_url=BASE_URL)
|
|
mapped.pop("company", None)
|
|
enriched, meta = enrich(mapped, raw)
|
|
assert enriched["company"] == "W3 Inc"
|
|
assert meta.company_source == "domain_heuristic"
|
|
|
|
|
|
def test_crm_create_and_update_by_redmine_id():
|
|
crm = CrmClient()
|
|
record = {
|
|
"redmine_contact_id": 1,
|
|
"full_name": "Ada Lovelace",
|
|
"emails": [{"value": "ada@example.com", "type": "work"}],
|
|
"last_redmine_update": "2026-07-01T00:00:00Z",
|
|
}
|
|
|
|
result1 = crm.upsert(record)
|
|
assert result1.created is True
|
|
|
|
result2 = crm.upsert(record)
|
|
assert result2.skipped_unchanged is True
|
|
|
|
updated = dict(record)
|
|
updated["last_redmine_update"] = "2026-07-02T00:00:00Z"
|
|
result3 = crm.upsert(updated)
|
|
assert result3.updated is True
|
|
|
|
|
|
def test_crm_email_fallback_links_record():
|
|
crm = CrmClient()
|
|
crm.upsert({
|
|
"redmine_contact_id": 2,
|
|
"full_name": "Old Name",
|
|
"emails": [{"value": "match@example.com", "type": "work"}],
|
|
"last_redmine_update": "2026-07-01T00:00:00Z",
|
|
})
|
|
|
|
result = crm.upsert({
|
|
"redmine_contact_id": 3,
|
|
"full_name": "New Name",
|
|
"emails": [{"value": "match@example.com", "type": "work"}],
|
|
"last_redmine_update": "2026-07-02T00:00:00Z",
|
|
})
|
|
assert result.updated is True
|
|
|
|
|
|
@responses.activate
|
|
def test_run_sync_end_to_end():
|
|
contacts = [
|
|
{
|
|
"id": 1,
|
|
"first_name": "Grace",
|
|
"last_name": "Hopper",
|
|
"email": "grace@example.com",
|
|
"updated_at": "2026-07-04T12:00:00Z",
|
|
}
|
|
]
|
|
responses.add(
|
|
responses.GET,
|
|
f"{BASE_URL}/contacts.json",
|
|
json={"contacts": contacts},
|
|
status=200,
|
|
)
|
|
|
|
redmine = RedmineClient(BASE_URL, "secret")
|
|
crm = CrmClient()
|
|
crm._store.clear()
|
|
state = SyncState()
|
|
|
|
stats = run_sync(redmine, crm, state, base_url=BASE_URL, batch_size=100)
|
|
|
|
assert stats["processed"] == 1
|
|
assert stats["created"] == 1
|
|
assert stats["errors"] == 0
|
|
|
|
|
|
def test_upsert_requires_redmine_id():
|
|
crm = CrmClient()
|
|
with pytest.raises(CrmError):
|
|
crm.upsert({"full_name": "No ID"})
|
|
|
|
|
|
def test_map_contact_notes_include_redmine_source_url():
|
|
raw = {
|
|
"id": 42,
|
|
"first_name": "Marie",
|
|
"last_name": "Curie",
|
|
"updated_at": "2026-07-01T00:00:00Z",
|
|
}
|
|
mapped = map_contact(raw, base_url=BASE_URL)
|
|
assert re.search(r"Redmine source: https://redmine\.example\.com/contacts/42", mapped["notes"])
|