Files
0pt/services/api/tests/test_resolve_determinism.py
admin 6befaa7be7
CI (SIAX Cloud) / security (pull_request) Successful in 14s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (push) Successful in 17s
CI (SIAX Cloud) / security (push) Successful in 29s
CI (SIAX Cloud) / integration (pull_request) Failing after 31s
CI (SIAX Cloud) / integration (push) Failing after 32s
CI (SIAX Cloud) / quality (pull_request) Successful in 36s
CI (SIAX Cloud) / quality (push) Successful in 38s
Python Engine CI (SIAX Cloud) / python-engine (push) Successful in 9m31s
Python Engine CI (SIAX Cloud) / python-engine (pull_request) Successful in 9m32s
test(er): resolv-determinism — match-pairs behöver hard-rule decided_by
_apply_matches ignorerar medvetet decided_by=score (defence in depth:
score/fuzzy får aldrig skapa canonical identity-länkar) — testernas
stub-bridge returnerade score-pairs och förväntade ändå merge → CI-fail var
implementationen som hade rätt. Pairs är nu decided_by=stable-identifiers.v3.

Verifierat: pytest services/api/tests 6+14 pass, full unit 945/945,
typecheck EXIT 0, real-Postgres 5/5.
2026-09-07 19:50:02 +02:00

167 lines
6.6 KiB
Python

"""Regression tests for the Python adapter to canonical TypeScript ER.
The removed Python scorer is deliberately not tested because it no longer
exists. These tests pin the load-bearing adapter semantics instead:
canonical match edges merge; review/non-match edges do not; hard negative
identity evidence constrains the whole cluster; bridge failure leaves records
unmerged.
"""
from __future__ import annotations
import copy
import httpx
import pytest
import respx
from workers.resolve import _apply_matches, resolve
def _prov(field_name: str, source_id: str, confidence: float) -> dict:
return {
"field_name": field_name,
"source_id": source_id,
"confidence": confidence,
"entity_resolution": None,
}
def _rec(name: str, source: str, confidence: float = 0.9) -> dict:
return {
"market": "SE",
"name": name,
"status": "active",
"lei": None,
"org_nr": None,
"source": source,
"confidence": confidence,
"provenance": [_prov("name", source, confidence)],
}
def _pair(a: str, b: str, status: str = "match", decided_by: str = "score", score: float = 0.97) -> dict:
return {
"candidate_id": f"c_{a}_{b}",
"record_a": a,
"record_b": b,
"score": score,
"status": status,
"decided_by": decided_by,
}
def test_apply_matches_is_deterministic_and_uses_canonical_edges_only():
records = [_rec("Acme AB", "source-a", 0.91), _rec("ACME Aktiebolag", "source-b", 0.96)]
assert len(_apply_matches(copy.deepcopy(records), [])) == 2
@pytest.mark.asyncio
@respx.mock
async def test_canonical_match_merges_and_stamps_provenance(monkeypatch):
monkeypatch.setenv("OPT_ER_BRIDGE_URL", "https://edge.example.test")
monkeypatch.setenv("INTERNAL_TOKEN", "test-internal-token")
records = [_rec("Acme AB", "source-a", 0.91), _rec("Acme AB", "source-b", 0.96)]
route = respx.post("https://edge.example.test/api/0pt/internal/er/candidates")
def responder(request: httpx.Request) -> httpx.Response:
body = __import__("json").loads(request.content)
assert request.headers["X-Internal-Token"] == "test-internal-token"
assert body["tenant_id"] == "tenant-a"
assert body["blocking_fields"] == ["market", "block_initial"]
a, b = body["records"][0]["record_id"], body["records"][1]["record_id"]
# Endast hard-rule-beslut (decided_by != score/None) far skapa en
# canonical identity-lank - consumenten ignorerar medvetet score/"match".
return httpx.Response(
200,
json={"pairs": [_pair(a, b, "match", "0pt.company.stable-identifiers.v3", 0.99)], "review_queue": []},
)
route.side_effect = responder
result = await resolve(records, tenant_id="tenant-a", project_id="project-a")
assert len(result) == 1
assert result[0]["agreed_sources"] == 2
assert result[0]["confidence"] == pytest.approx(0.99)
assert set(result[0]["merged_from"]) <= {"source-a", "source-b"}
for provenance in result[0]["provenance"]:
er = provenance["entity_resolution"]
assert er["method"] == "canonical_ts_entity_resolution"
assert set(er["merged_sources"]) == {"source-a", "source-b"}
@pytest.mark.asyncio
@respx.mock
async def test_pending_review_never_auto_merges(monkeypatch):
monkeypatch.setenv("OPT_ER_BRIDGE_URL", "https://edge.example.test")
monkeypatch.setenv("INTERNAL_TOKEN", "test-internal-token")
records = [_rec("Acme AB", "source-a"), _rec("Acme Group AB", "source-b")]
def responder(request: httpx.Request) -> httpx.Response:
body = __import__("json").loads(request.content)
a, b = body["records"][0]["record_id"], body["records"][1]["record_id"]
pair = _pair(a, b, "pending_review")
return httpx.Response(200, json={"pairs": [pair], "review_queue": [pair]})
respx.post("https://edge.example.test/api/0pt/internal/er/candidates").side_effect = responder
result = await resolve(records, tenant_id="tenant-a", project_id="project-a")
assert len(result) == 2
assert all(not r.get("merged_from") for r in result)
@pytest.mark.asyncio
@respx.mock
async def test_hard_non_match_blocks_transitive_cluster_merge(monkeypatch):
"""A-B and B-C matches may never imply A=C when canonical hard evidence says A!=C."""
monkeypatch.setenv("OPT_ER_BRIDGE_URL", "https://edge.example.test")
monkeypatch.setenv("INTERNAL_TOKEN", "test-internal-token")
records = [
_rec("Acme AB", "source-a", 0.95),
_rec("Acme AB", "source-b", 0.94),
_rec("Acme AB", "source-c", 0.93),
]
def responder(request: httpx.Request) -> httpx.Response:
body = __import__("json").loads(request.content)
a, b, c = [item["record_id"] for item in body["records"]]
return httpx.Response(
200,
json={
"pairs": [
_pair(a, b, "match", "0pt.company.stable-identifiers.v3", 0.98),
_pair(b, c, "match", "0pt.company.stable-identifiers.v3", 0.97),
_pair(a, c, "non_match", "0pt.company.stable-identifiers.v2", 0.0),
],
"review_queue": [],
},
)
respx.post("https://edge.example.test/api/0pt/internal/er/candidates").side_effect = responder
result = await resolve(records, tenant_id="tenant-a", project_id="project-a")
assert len(result) == 2
assert max(r.get("agreed_sources", 1) for r in result) == 2
assert not any(set(r.get("merged_from", [])) >= {"source-b", "source-c"} for r in result)
@pytest.mark.asyncio
@respx.mock
async def test_bridge_outage_fails_closed_to_unmerged_records(monkeypatch):
monkeypatch.setenv("OPT_ER_BRIDGE_URL", "https://edge.example.test")
monkeypatch.setenv("INTERNAL_TOKEN", "test-internal-token")
records = [_rec("Acme AB", "source-a"), _rec("Acme AB", "source-b")]
respx.post("https://edge.example.test/api/0pt/internal/er/candidates").mock(side_effect=httpx.ConnectError("offline"))
result = await resolve(records, tenant_id="tenant-a", project_id="project-a")
assert len(result) == 2
assert all(not r.get("merged_from") for r in result)
@pytest.mark.asyncio
async def test_missing_internal_configuration_makes_zero_network_calls(monkeypatch):
monkeypatch.delenv("OPT_ER_BRIDGE_URL", raising=False)
monkeypatch.delenv("EDGE_URL", raising=False)
monkeypatch.delenv("INTERNAL_TOKEN", raising=False)
records = [_rec("Acme AB", "source-a"), _rec("Acme AB", "source-b")]
result = await resolve(records, tenant_id="tenant-a", project_id="project-a")
assert len(result) == 2