diff --git a/services/api/tests/test_entity_resolution.py b/services/api/tests/test_entity_resolution.py new file mode 100644 index 0000000..ecc937b --- /dev/null +++ b/services/api/tests/test_entity_resolution.py @@ -0,0 +1,93 @@ +from services.api.workers.entity_resolution import ( + EntityCandidate, + ResolutionDecision, + rank_resolution_candidates, + resolve_entity_pair, +) + + +def organization(entity_id: str, **overrides): + values = { + "entity_id": entity_id, + "entity_type": "organization", + "name": "Example Technology AB", + "source_ref": f"source:{entity_id}", + "registration_country": "SE", + "registration_number": None, + "website": None, + "city": "Stockholm", + } + values.update(overrides) + return EntityCandidate(**values) + + +def person(entity_id: str, **overrides): + values = { + "entity_id": entity_id, + "entity_type": "person", + "name": "Anna Andersson", + "source_ref": f"source:{entity_id}", + "email": None, + "organization_id": None, + } + values.update(overrides) + return EntityCandidate(**values) + + +def test_registration_number_is_a_strong_organization_identifier(): + result = resolve_entity_pair( + organization("left", registration_number="556123-4567"), + organization("right", registration_number="5561234567"), + ) + assert result.decision is ResolutionDecision.EXACT_LINK + assert result.score == 1.0 + assert result.requires_human_review is False + + +def test_conflicting_registration_numbers_fail_distinct_even_when_names_match(): + result = resolve_entity_pair( + organization("left", registration_number="5561234567"), + organization("right", registration_number="5569999999"), + ) + assert result.decision is ResolutionDecision.DISTINCT + assert result.score == 0.0 + + +def test_same_person_email_is_exact_link(): + result = resolve_entity_pair( + person("left", email="Anna@example.com"), + person("right", email=" anna@EXAMPLE.com "), + ) + assert result.decision is ResolutionDecision.EXACT_LINK + assert result.requires_human_review is False + + +def test_fuzzy_organization_similarity_never_auto_merges(): + result = resolve_entity_pair( + organization("left", name="Example Technology AB", website="example.se"), + organization("right", name="Example Technologies AB", website="https://www.example.se"), + ) + assert result.score > 0.72 + assert result.decision is ResolutionDecision.REVIEW + assert result.requires_human_review is True + + +def test_different_entity_types_are_distinct(): + result = resolve_entity_pair( + organization("org"), + person("person"), + ) + assert result.decision is ResolutionDecision.DISTINCT + + +def test_candidate_ranking_prefers_exact_then_review_and_is_stable(): + query = organization("query", registration_number="5561234567", website="example.se") + candidates = [ + organization("weak", name="Completely Different Company", registration_country="NO", city="Oslo"), + organization("review", name="Example Technologies AB", website="example.se"), + organization("exact", registration_number="556123-4567"), + ] + ranked = rank_resolution_candidates(query, candidates) + assert [result.right_id for result in ranked] == ["exact", "review", "weak"] + assert ranked[0].decision is ResolutionDecision.EXACT_LINK + assert ranked[1].decision is ResolutionDecision.REVIEW