Organized

This commit is contained in:
Jason Thistlethwaite
2026-10-17 20:50:35 -04:00
parent bd26c8894f
commit 9ee9eb3ec6
130 changed files with 24172 additions and 140 deletions
+272
View File
@@ -0,0 +1,272 @@
"""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"])