"""Unit tests for cross-source matching service. Covers: - normalize_address(): abbreviation expansion, punctuation stripping, edge cases - address_fingerprint(): stability on case variation, sensitivity to coords, None handling - match_or_create_house() / match_or_create_listing(): mock-DB tier routing Reference: decisions/Cross_Source_Matching_Strategy.md """ from unittest.mock import MagicMock from app.services.matching.conflict_resolution import ( HOUSE_FIELD_PRIORITY, LISTING_FIELD_PRIORITY, update_canonical_fields, ) from app.services.matching.normalize import ( EKB_CITY_TOKEN, address_fingerprint, has_city_token, has_house_number, house_number_token, normalize_address, resolve_city_token, ) # --------------------------------------------------------------------------- # normalize_address # --------------------------------------------------------------------------- def test_normalize_strips_punctuation_and_collapses_whitespace(): assert normalize_address(" Test, Address! ") == "test address" def test_normalize_expands_ul_abbreviation(): result = normalize_address("ул Ленина, 5") assert "улица" in result assert "5" in result def test_normalize_expands_pr_abbreviation(): result = normalize_address("пр Мира 100") assert "проспект" in result assert "100" in result def test_normalize_expands_prkt_abbreviation(): result = normalize_address("пр-кт Ленина 10") assert "проспект" in result # 'кт' must NOT remain as a stray token after expansion assert "кт" not in result.split() def test_normalize_prkt_no_leftover_kt(): """пр-кт must expand to 'проспект' cleanly — no leftover 'кт' token.""" result = normalize_address("пр-кт Ленина 10") tokens = result.split() assert "проспект" in tokens assert "пр" not in tokens assert "кт" not in tokens def test_normalize_handles_none(): assert normalize_address(None) == "" def test_normalize_handles_empty_string(): assert normalize_address("") == "" def test_normalize_lowercases(): assert normalize_address("ТЕСТ") == "тест" def test_normalize_unicode_nfc(): # Composed vs decomposed е (U+0435 vs U+0435) s1 = normalize_address("улица") s2 = normalize_address("улица") assert s1 == s2 # --------------------------------------------------------------------------- # has_house_number / house_number_token (mis-bucketing hardening) # --------------------------------------------------------------------------- def test_has_house_number_true_when_trailing_number(): assert has_house_number("улица мамина сибиряка 126а") is True def test_has_house_number_false_for_street_name_digit_only(): # 'улица 8 марта' — the digit is part of the street NAME, not a house number assert has_house_number("улица 8 марта") is False def test_has_house_number_true_for_street_name_digit_plus_house(): # 'улица 8 марта 5' — '5' is the house number, '8' is in the name assert has_house_number("улица 8 марта 5") is True def test_has_house_number_false_for_bare_street(): assert has_house_number("улица мамина сибиряка") is False def test_has_house_number_false_for_none_and_empty(): assert has_house_number(None) is False assert has_house_number("") is False def test_house_number_token_extracts_trailing_token(): assert house_number_token("улица мамина сибиряка 126а") == "126а" def test_house_number_token_for_street_name_digit_plus_house(): assert house_number_token("улица 8 марта 5") == "5" def test_house_number_token_none_for_bare_street(): assert house_number_token("мамина сибиряка") is None def test_house_number_token_none_for_none(): assert house_number_token(None) is None # --------------------------------------------------------------------------- # address_fingerprint # --------------------------------------------------------------------------- def test_fingerprint_stable_on_case_variation(): f1 = address_fingerprint("Ленина 5", 56.8378, 60.5946) f2 = address_fingerprint("ЛЕНИНА 5", 56.8378, 60.5946) assert f1 == f2 def test_fingerprint_distinct_on_different_lat(): f1 = address_fingerprint("Same Address", 56.8, 60.5) f2 = address_fingerprint("Same Address", 56.9, 60.5) assert f1 != f2 def test_fingerprint_distinct_on_different_lon(): f1 = address_fingerprint("Same Address", 56.8, 60.5) f2 = address_fingerprint("Same Address", 56.8, 60.6) assert f1 != f2 def test_fingerprint_stable_within_11m(monkeypatch): # 4 decimal places → ~11 m resolution; coords differing in 5th decimal should match f1 = address_fingerprint("Addr", 56.83780, 60.59460) f2 = address_fingerprint("Addr", 56.83781, 60.59461) # 4dp rounding: 56.8378 == 56.8378, 60.5946 == 60.5946 → same fingerprint assert f1 == f2 def test_fingerprint_handles_none_coords(): fp = address_fingerprint("Some Address", None, None) assert len(fp) == 32 # truncated sha256 hex def test_fingerprint_handles_none_address(): fp = address_fingerprint(None, 56.8, 60.5) assert len(fp) == 32 def test_fingerprint_all_none(): fp = address_fingerprint(None, None, None) assert len(fp) == 32 # --------------------------------------------------------------------------- # has_city_token — обл.66 city-token detection (Tier-2b oblast guard) # --------------------------------------------------------------------------- def test_has_city_token_detects_ekb(): assert has_city_token("екатеринбург улица ленина 5") is True def test_has_city_token_detects_multiword_oblast_city(): assert has_city_token("нижний тагил улица ленина 100") is True assert has_city_token("каменск уральский улица мира 7") is True def test_has_city_token_bare_street_is_false(): # The collision-prone case: a bare street+number carries no city token. assert has_city_token("улица ленина 100") is False assert has_city_token("уральская улица 62к1") is False def test_has_city_token_no_false_positive_on_street_named_after_city(): # 'улица серова' must NOT match the city token 'серов' (word-bounded, not substring). assert has_city_token("улица серова 5") is False assert has_city_token("первоуральская улица 3") is False def test_has_city_token_handles_empty(): assert has_city_token("") is False assert has_city_token(None) is False # --------------------------------------------------------------------------- # resolve_city_token — which обл.66 city (or None); drives the Tier-2a oblast guard # --------------------------------------------------------------------------- def test_resolve_city_token_returns_ekb(): assert resolve_city_token("екатеринбург улица ленина 5") == EKB_CITY_TOKEN def test_resolve_city_token_returns_non_ekb_city(): assert resolve_city_token("нижний тагил улица ленина 100") == "нижний тагил" assert resolve_city_token("свердловская область каменск уральский улица мира 7") == ( "каменск уральский" ) def test_resolve_city_token_bare_street_is_none(): # The dominant Avito SERP format — no city token → None (guard stays dormant). assert resolve_city_token("улица ленина 100") is None def test_resolve_city_token_no_false_positive_on_street_named_after_city(): assert resolve_city_token("улица серова 5") is None assert resolve_city_token("первоуральская улица 3") is None def test_resolve_city_token_handles_empty(): assert resolve_city_token("") is None assert resolve_city_token(None) is None # --------------------------------------------------------------------------- # match_or_create_house — mock DB tier routing # --------------------------------------------------------------------------- def _make_db(rows: list[dict | None]) -> MagicMock: """Build a Session mock that returns rows sequentially per execute() call.""" db = MagicMock() side_effects = [] for row in rows: result = MagicMock() mappings = MagicMock() if row is None: mappings.first.return_value = None mappings.one.return_value = None else: mappings.first.return_value = row mappings.one.return_value = row result.mappings.return_value = mappings side_effects.append(result) db.execute.side_effect = side_effects return db def test_match_house_tier0_cadastr(): """Tier 0: cadastral_number match → returns cadastr_exact without geo query.""" from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock {"id": 42}, # cadastral match None, # _insert_alias (added by #1543: register fp/addr alias after cadastr_exact) None, # _upsert_house_source (INSERT ... ON CONFLICT) ] ) house_id, conf, method = match_or_create_house( db, "cian", "ext-001", address="ул Ленина 5", lat=56.8, lon=60.5, cadastral_number="66:41:0000000:999", ) assert house_id == 42 assert conf == 1.0 assert method == "cadastr_exact" def test_match_house_tier05_fias_exact(): """Tier 0.5: house_fias_id match → fias_exact (conf 0.95) before source/fp/geo. No cadastral_number supplied → Tier 0 skipped; the fias SELECT is the first lookup after the advisory lock. On hit, _upsert_house_source + _insert_alias fire (address carries a house number so the alias is registered). """ from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock {"id": 55}, # fias_exact hit (Tier 0.5) None, # _upsert_house_source None, # _insert_alias ] ) house_id, conf, method = match_or_create_house( db, "avito", "ext-fias-1", address="ул Ленина 5", lat=56.8, lon=60.5, house_fias_id="0a1b2c3d-0000-4000-8000-000000000001", ) assert house_id == 55 assert conf == 0.95 assert method == "fias_exact" def test_match_house_fias_skipped_when_absent(): """No house_fias_id → Tier 0.5 does not run; first lookup is house_sources.""" from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock {"house_id": 8}, # house_sources hit (fias tier skipped, no fias SELECT) ] ) house_id, _conf, method = match_or_create_house( db, "avito", "ext-nofias", address="пр Мира 10", lat=56.8, lon=60.5, ) assert house_id == 8 assert method == "source_exact" def test_match_house_tier1_source_exact(): """Tier 1: ext_source+ext_id already in house_sources. No cadastral_number supplied → Tier 0 skipped entirely. First db.execute call goes to house_sources query. source_exact returns immediately without calling _upsert_house_source. """ from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock {"house_id": 7}, # house_sources hit ] ) house_id, _conf, method = match_or_create_house( db, "avito", "ext-999", address="пр Мира 10", lat=56.8, lon=60.5, ) assert house_id == 7 assert method == "source_exact" def test_match_house_tier2_fingerprint(): """Tier 2a: fingerprint match in house_address_aliases (exact fp hit). After Tier 2a hit, _insert_alias is called to ensure fp is kept in sync (#849 Part 2 — alias sync). """ from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock None, # house_sources miss {"house_id": 15}, # fingerprint hit (Tier 2a) None, # _upsert_house_source None, # _insert_alias (sync fingerprint — Part 2) ] ) house_id, conf, method = match_or_create_house( db, "yandex", "ext-123", address="Тестовая 1", lat=56.84, lon=60.60, ) assert house_id == 15 assert conf == 0.9 assert method == "fingerprint" def test_match_house_tier3_geo(): """Tier 3: geo-proximity match. Tier 2b (normalized_address fallback) is tried after Tier 2a miss. """ from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock None, # house_sources miss None, # fingerprint miss (Tier 2a) None, # normalized_address miss (Tier 2b — Part 2) {"id": 22, "dist": 15.5}, # geo hit (Tier 3) None, # _upsert_house_source None, # _insert_alias ] ) house_id, conf, method = match_or_create_house( db, "n1", "ext-456", address="Новая 3", lat=56.83, lon=60.59, ) assert house_id == 22 assert conf == 0.7 assert method == "geo_proximity" def test_match_house_new(): """All tiers miss → new house created. Tier 2b (normalized_address fallback) is tried after Tier 2a miss. """ from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock None, # house_sources miss None, # fingerprint miss (Tier 2a) None, # normalized_address miss (Tier 2b — Part 2) None, # geo miss {"id": 99}, # INSERT RETURNING id None, # _upsert_house_source None, # _insert_alias ] ) house_id, conf, method = match_or_create_house( db, "avito", "new-ext", address="Новостройка 1", lat=56.85, lon=60.61, ) assert house_id == 99 assert conf == 1.0 assert method == "new" def test_match_house_advisory_lock_called_first(): """Regression: pg_advisory_xact_lock must be the first execute() call on match_or_create_house. Re-ordering would re-introduce the race (finding #1 from 2026-05-24 audit). PR #501.""" from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock {"house_id": 7}, # house_sources Tier 1 hit (short path) ] ) match_or_create_house( db, "avito", "regress-001", address="Тестовая 1", lat=56.8, lon=60.5, ) # First execute MUST be the lock acquisition, with bind {'fp': }. first_call = db.execute.call_args_list[0] sql_obj = first_call[0][0] # TextClause bind = first_call[0][1] # dict assert "pg_advisory_xact_lock" in str( sql_obj ), f"first execute must be advisory lock, got: {sql_obj}" assert "fp" in bind, f"lock bind must include fp, got: {bind}" assert ( isinstance(bind["fp"], str) and len(bind["fp"]) == 32 ), f"fp must be 32-char sha256 hex, got: {bind.get('fp')!r}" # --------------------------------------------------------------------------- # Mis-bucketing hardening — bare-street + geo address-consistency guards # --------------------------------------------------------------------------- def _executed_sqls(db: MagicMock) -> list[str]: """Collapsed SQL text of every db.execute() call (whitespace-normalized).""" out = [] for call in db.execute.call_args_list: if call.args: out.append(" ".join(str(call.args[0]).split())) return out def test_geo_match_does_not_register_alias(): """P3: a geo-proximity match must NOT INSERT a house_address_aliases row. A 0.7-confidence proximity hit is too weak to cement as a building key. Execute order: lock, source miss, fp miss, norm_addr miss, geo hit, upsert. """ from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock None, # house_sources miss None, # fingerprint miss (Tier 2a) None, # normalized_address miss (Tier 2b) {"id": 22, "dist": 12.0, "h_addr": "улица новая 3"}, # geo hit, same number None, # _upsert_house_source ] ) house_id, conf, method = match_or_create_house( db, "n1", "ext-geo", address="улица Новая 3", lat=56.83, lon=60.59 ) assert (house_id, conf, method) == (22, 0.7, "geo_proximity") assert not any( "INSERT INTO house_address_aliases" in s for s in _executed_sqls(db) ), "geo match must not write an alias (P3)" def test_geo_match_rejected_when_house_number_differs(): """P2: geo candidate with a DIFFERENT house number is rejected → New house. Listing 'Мамина Сибиряка 126' must not bucket into nearby house '... 52'. On rejection the flow falls through to the New-house INSERT. """ from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock None, # house_sources miss None, # fingerprint miss (Tier 2a) None, # normalized_address miss (Tier 2b) {"id": 99, "dist": 5.0, "h_addr": "улица мамина сибиряка 52"}, # geo hit, WRONG number {"id": 500}, # INSERT RETURNING id (New house) None, # _upsert_house_source None, # _insert_alias (address has number → executes) ] ) house_id, _conf, method = match_or_create_house( db, "yandex", "ext-rej", address="улица Мамина-Сибиряка 126", lat=56.84, lon=60.61 ) assert method == "new", "geo candidate with different house number must be rejected" assert house_id == 500 def test_bare_street_numberless_no_house_created(): """P1 (extended to INSERT-new): a numberless address with no cadastral → NO house. 'екатеринбург улица мамина сибиряка' has no house number and no cadastral number, so match_or_create_house refuses to MINT a canonical row (which becomes a mega-bucket that later numberless listings collapse into). Returns (None, 0.0, 'no_house_number') and writes nothing: no Tier 2b lookup, no INSERT INTO houses, no alias, no house_sources. Execute order: lock, source miss, fp miss — then the gate returns. """ from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock None, # house_sources miss None, # fingerprint miss (Tier 2a) ] ) house_id, conf, method = match_or_create_house( db, "yandex", "ext-bare", address="екатеринбург улица мамина сибиряка" ) assert (house_id, conf, method) == (None, 0.0, "no_house_number") sqls = _executed_sqls(db) assert not any( "normalized_address = :na" in s for s in sqls ), "bare-street address must not run Tier 2b normalized_address lookup (P1)" assert not any( "INSERT INTO houses" in s for s in sqls ), "numberless address must not create a house (P1 extended)" assert not any( "INSERT INTO house_address_aliases" in s for s in sqls ), "numberless address must not register an alias (P1)" assert not any( "INSERT INTO house_sources" in s for s in sqls ), "numberless refusal must not upsert house_sources" def test_numberless_none_address_with_coords_no_house_created(): """P1 edge: address=None but lat/lon present → still gated (no address-NULL house). Previously the New-house INSERT would mint a house with address NULL keyed only by coords — exactly the mis-bucket the gate refuses. has_num is False for an empty normalized address, so with no cadastral the flow returns 'no_house_number'. Execute order: lock, source miss, fp miss, geo miss — then the gate returns. """ from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock None, # house_sources miss None, # fingerprint miss (Tier 2a); Tier 2b skipped (empty norm_addr) None, # geo miss (Tier 3 — lat/lon present) ] ) house_id, conf, method = match_or_create_house( db, "yandex", "ext-none-addr", address=None, lat=56.83, lon=60.59 ) assert (house_id, conf, method) == (None, 0.0, "no_house_number") assert not any( "INSERT INTO houses" in s for s in _executed_sqls(db) ), "address-NULL coords-only listing must not create a house (P1 extended)" def test_numberless_address_with_cadastral_creates_house(): """P1 exemption: a numberless address WITH a cadastral number still creates a house. The cadastral number is a precise building identity, so the numberless gate does not apply. Tier 0 cadastr lookup misses (new cad), remaining tiers miss, and the New-house INSERT proceeds because `cad` is truthy. Execute order: lock, cadastr miss, source miss, fp miss, INSERT, upsert (_insert_alias is a no-op — bare-street normalized_address has no number). """ from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock None, # Tier 0 cadastr miss None, # house_sources miss None, # fingerprint miss (Tier 2a; no lat/lon → no Tier 2b/geo) {"id": 321}, # INSERT RETURNING id (New house — cad exempts the gate) None, # _upsert_house_source ] ) house_id, conf, method = match_or_create_house( db, "cian", "ext-cad", address="екатеринбург улица мамина сибиряка", cadastral_number="66:41:0000000:12345", ) assert (house_id, conf, method) == (321, 1.0, "new") assert any( "INSERT INTO houses" in s for s in _executed_sqls(db) ), "numberless + cadastral must still create a house (cadastral = identity)" def test_insert_alias_noop_for_bare_street(): """_insert_alias is a no-op when the normalized address has no house number.""" from app.services.matching.houses import _insert_alias db = MagicMock() _insert_alias( db, house_id=1, address="екатеринбург улица мамина сибиряка", fp="a" * 32, source="yandex", ) db.execute.assert_not_called() def test_insert_alias_executes_when_house_number_present(): """_insert_alias still writes when the normalized address carries a house number.""" from app.services.matching.houses import _insert_alias db = MagicMock() _insert_alias( db, house_id=1, address="улица Мамина-Сибиряка 126", fp="b" * 32, source="yandex", ) db.execute.assert_called_once() def test_insert_alias_conflict_only_rebinds_same_house(): """Oblast guard (#2): ON CONFLICT refreshes fingerprint/source ONLY for the same house. A different house colliding on a bare common-street normalized_address must not rewrite the owner's fingerprint (that would downgrade a coord-bearing alias to a coord-less one and let the next no-coord card mis-bucket via Tier 2a).""" from app.services.matching.houses import _insert_alias db = MagicMock() _insert_alias(db, house_id=1, address="улица Ленина 100", fp="c" * 32, source="avito") sql = " ".join(str(db.execute.call_args.args[0]).split()) assert "ON CONFLICT (normalized_address) DO UPDATE" in sql # fingerprint/source updated only when the existing owner == the incoming house. assert "house_address_aliases.house_id = EXCLUDED.house_id" in sql # --------------------------------------------------------------------------- # match_or_create_house — Tier-2b oblast geo/city guard (bug #2) # --------------------------------------------------------------------------- def test_tier2b_coord_present_uses_geo_guard_and_matches_within_radius(): """Coords present → Tier-2b runs the ST_DWithin JOIN guard; a house within the radius is accepted as a fingerprint match (0.9).""" from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock None, # house_sources miss (Tier 1) None, # fingerprint miss (Tier 2a) {"house_id": 77}, # Tier 2b JOIN+ST_DWithin hit (same place, within radius) None, # _upsert_house_source None, # _insert_alias ] ) house_id, conf, method = match_or_create_house( db, "avito", "ext-2b-geo", address="улица Ленина 100", lat=57.910, lon=59.980 ) assert (house_id, conf, method) == (77, 0.9, "fingerprint") # The Tier-2b lookup must be the geo-guarded JOIN, not a bare normalized_address SELECT. tier2b_sql = _executed_sqls(db)[3] assert "ST_DWithin" in tier2b_sql assert "JOIN houses" in tier2b_sql def test_tier2b_coord_present_cross_city_falls_through_to_new(): """A bare street+number oblast card WITH coords whose only same-street alias is a far-away (cross-city) house: the ST_DWithin guard filters it out (JOIN miss) → the card falls through to a New house instead of mis-bucketing into the ЕКБ house.""" from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock None, # house_sources miss None, # fingerprint miss (Tier 2a) None, # Tier 2b JOIN+ST_DWithin miss (candidate house is >3km away) None, # geo miss (Tier 3 — coords present) {"id": 909}, # INSERT RETURNING id (New house) None, # _upsert_house_source None, # _insert_alias ] ) house_id, conf, method = match_or_create_house( db, "avito", "ext-2b-xcity", address="улица Ленина 100", lat=57.910, lon=59.980 ) assert (house_id, conf, method) == (909, 1.0, "new") def test_tier2b_no_coords_no_city_token_skips_and_creates_new(): """No coords AND a bare common-street (no city token) → Tier-2b is SKIPPED entirely (no normalized_address lookup). The card falls through to a New house — conservative: do not mis-bucket a bare 'улица ленина 100' into a same-named ЕКБ alias.""" from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock None, # house_sources miss None, # fingerprint miss (Tier 2a); Tier 2b SKIPPED (no coords, no city token) {"id": 910}, # INSERT RETURNING id (New house) — no Tier 3 (no coords) None, # _upsert_house_source None, # _insert_alias ] ) house_id, conf, method = match_or_create_house( db, "avito", "ext-2b-bare", address="улица Ленина 100" ) assert (house_id, conf, method) == (910, 1.0, "new") assert not any( "normalized_address = :na" in s for s in _executed_sqls(db) ), "bare common-street with no coords/city must not run a Tier-2b lookup" def test_tier2b_no_coords_with_city_token_matches(): """No coords but the normalized_address carries a city token → self-disambiguating, so Tier-2b matches on normalized_address alone (0.9). ЕКБ cross-source dedup for city-qualified addresses is preserved.""" from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock None, # house_sources miss None, # fingerprint miss (Tier 2a) {"house_id": 33}, # Tier 2b normalized_address hit (city token present) None, # _upsert_house_source None, # _insert_alias ] ) house_id, conf, method = match_or_create_house( db, "avito", "ext-2b-city", address="Екатеринбург, улица Ленина, 100" ) assert (house_id, conf, method) == (33, 0.9, "fingerprint") tier2b_sql = _executed_sqls(db)[3] assert "normalized_address = :na" in tier2b_sql assert "ST_DWithin" not in tier2b_sql # --------------------------------------------------------------------------- # match_or_create_house — Tier-2a coord-less oblast city guard (#2500 follow-up) # --------------------------------------------------------------------------- def test_tier2a_coord_less_non_ekb_city_skips_both_lookups_and_creates_new(): """A coord-less card whose address resolves a NON-ЕКБ city (Н.Тагил) must NOT match a globally-unique alias by fingerprint OR normalized_address (either would corrupt ЕКБ house data). The guard skips both alias lookups → New house.""" from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock None, # house_sources miss (Tier 1) # Tier 2a (fingerprint) SKIPPED, Tier 2b (normalized_address) SKIPPED, # Tier 3 SKIPPED (no coords) — guard forces fall-through to New. {"id": 701}, # INSERT RETURNING id (New house) None, # _upsert_house_source None, # _insert_alias (address carries a house number → executes) ] ) house_id, conf, method = match_or_create_house( db, "avito", "ext-2a-nt", address="Нижний Тагил, ул. Ленина, 100" ) assert (house_id, conf, method) == (701, 1.0, "new") sqls = _executed_sqls(db) assert not any( "fingerprint = :fp" in s for s in sqls ), "coord-less non-ЕКБ card must NOT run the Tier-2a fingerprint lookup" assert not any( "normalized_address = :na" in s for s in sqls ), "coord-less non-ЕКБ card must NOT run the Tier-2b normalized_address lookup" def test_tier2a_coord_less_ekb_city_still_matches(): """EKB happy-path: a coord-less card that resolves ЕКБ runs Tier-2a EXACTLY as before — the guard does not fire (екатеринбург == EKB_CITY_TOKEN).""" from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock None, # house_sources miss {"house_id": 88}, # Tier 2a fingerprint HIT (guard did NOT skip it) None, # _upsert_house_source None, # _insert_alias ] ) house_id, conf, method = match_or_create_house( db, "avito", "ext-2a-ekb", address="Екатеринбург, ул. Ленина, 5" ) assert (house_id, conf, method) == (88, 0.9, "fingerprint") assert any( "fingerprint = :fp" in s for s in _executed_sqls(db) ), "ЕКБ coord-less card must still run the Tier-2a fingerprint lookup" def test_tier2a_coord_less_bare_street_still_runs_tier2a(): """EKB happy-path (dominant): a coord-less BARE card (no city token — today's Avito SERP format) resolves to None → guard dormant → Tier-2a fingerprint dedup UNCHANGED.""" from app.services.matching.houses import match_or_create_house db = _make_db( [ None, # pg_advisory_xact_lock None, # house_sources miss {"house_id": 99}, # Tier 2a fingerprint HIT (bare card → guard did not skip) None, # _upsert_house_source None, # _insert_alias ] ) house_id, conf, method = match_or_create_house( db, "avito", "ext-2a-bare", address="ул. Ленина, 5" ) assert (house_id, conf, method) == (99, 0.9, "fingerprint") assert any("fingerprint = :fp" in s for s in _executed_sqls(db)) # --------------------------------------------------------------------------- # match_or_create_listing — mock DB tier routing # --------------------------------------------------------------------------- def test_match_listing_tier0_cadastr(): from app.services.matching.listings import match_or_create_listing db = _make_db( [ {"id": 200}, # cadastral hit None, # _upsert_listing_source ] ) listing_id, _conf, method = match_or_create_listing( db, "cian", "cian-555", house_id=10, floor=5, rooms_count=2, area_m2=48.5, price_rub=5_000_000.0, cadastral_number="66:41:0204016:1234", ) assert listing_id == 200 assert method == "cadastr_exact" def test_match_listing_tier1_source_exact(): """Tier 1: no cadastral_number → Tier 0 skipped; first execute is listing_sources hit.""" from app.services.matching.listings import match_or_create_listing db = _make_db( [ {"listing_id": 101}, # listing_sources hit (first and only execute call) ] ) listing_id, _conf, method = match_or_create_listing( db, "avito", "avito-888", house_id=10, ) assert listing_id == 101 assert method == "source_exact" def test_match_listing_tier2_minhash(): from app.services.matching.listings import match_or_create_listing db = _make_db( [ None, # listing_sources miss {"id": 55}, # minhash hit None, # _upsert_listing_source ] ) listing_id, conf, method = match_or_create_listing( db, "cian", "cian-999", house_id=10, description_minhash="abc123deadbeef", ) assert listing_id == 55 assert conf == 0.85 assert method == "minhash" def test_match_listing_tier3_composite(): """Tier 3: no cadastral + no minhash provided → tiers 0/2 skipped. Execute order: listing_sources miss → composite hit → _upsert. """ from app.services.matching.listings import match_or_create_listing db = _make_db( [ None, # listing_sources miss (Tier 1) {"id": 77, "diff": 0.1}, # composite hit (Tier 3; Tier 2 skipped — no hash) None, # _upsert_listing_source ] ) listing_id, conf, method = match_or_create_listing( db, "cian", "cian-777", house_id=10, floor=3, rooms_count=1, area_m2=36.0, ) assert listing_id == 77 assert conf == 0.75 assert method == "composite" def test_match_listing_new(): from app.services.matching.listings import match_or_create_listing db = _make_db( [ None, # listing_sources miss None, # composite miss (floor/area/rooms all None → tier skipped) ] ) listing_id, _conf, method = match_or_create_listing( db, "avito", "avito-new", house_id=10, ) assert listing_id == 0 assert method == "new" # --------------------------------------------------------------------------- # conflict_resolution # --------------------------------------------------------------------------- def test_field_priority_dicts_not_empty(): assert HOUSE_FIELD_PRIORITY assert LISTING_FIELD_PRIORITY VALID_STRING_RULES = {"union", "cross_validate", "first_non_null"} def test_field_priority_sources_are_lists_or_known_string_rules(): """Each priority entry is either a source list or a known string rule. PR #471 (main) introduced 'union' / 'cross_validate' / 'first_non_null' string rules alongside list entries. Both are valid per LISTING_FIELD_PRIORITY type hint. """ for col, sources in HOUSE_FIELD_PRIORITY.items(): assert ( isinstance(sources, list) or sources in VALID_STRING_RULES ), f"HOUSE_FIELD_PRIORITY[{col!r}] must be list or known rule, got {sources!r}" for col, sources in LISTING_FIELD_PRIORITY.items(): assert ( isinstance(sources, list) or sources in VALID_STRING_RULES ), f"LISTING_FIELD_PRIORITY[{col!r}] must be list or known rule, got {sources!r}" def test_update_canonical_fields_is_callable(): """Stage 8 v1 — legacy stub (no-op pass). Full arbitration deferred to Stage 8.x. PR #471 (main) replaced NotImplementedError stub with silent pass to allow backward-compat imports. Test updated accordingly. """ db = MagicMock() # Should not raise — no-op stub returns None result = update_canonical_fields(db, listing_id=1, ext_source="cian", lot_data=object()) assert result is None