"""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"])