"""P0 订单读取适配层回归。 重点守住三件事: 1. **route 按订单号维度聚合**:同一个订单号在 order_site 里有 541 组重复 (发货方内部行 + 真实店铺行,见规划 §6.5.1)。任一行已发货就要判 ``auto_fulfilled`` —— 否则这 540 个已发货订单会被放行重复履约。这条是 P0 最容易写错、后果最重的地方,用直接构造两行的用例钉死。 2. **回显取真实店铺行**:内部行(``shop_id=91kaquan``)的 sku_code 是 ``suit_sand_dancer`` 这类内部编码,不能回显给买家。 3. **可降级**:未启用 / 未配置 / 库不可用时抛 ``OrderLookupUnavailable``, 主流程退化成人工填写,而不是把异常抛给用户。 真实只读库的连通性验证不进自动回归(需生产网络),见 ``tests/test_external_order_source_live.py``(手工跑)。 """ from __future__ import annotations import os from datetime import UTC, datetime, timedelta import pytest import web.backend.services.external_order_source as src from tests.support.db import get_test_engine, make_test_session from web.backend.models import ExternalOrderCache @pytest.fixture(autouse=True) def _clean_env(monkeypatch): """每个用例从干净的外部配置起步,避免相互污染。""" src.reset_engine() monkeypatch.delenv("ORDER_SITE_DATABASE_URL", raising=False) monkeypatch.delenv("ORDER_SITE_LOOKUP_ENABLED", raising=False) yield src.reset_engine() @pytest.fixture def session(): engine = get_test_engine() s = make_test_session(engine) s.query(ExternalOrderCache).delete() s.commit() yield s s.rollback() s.close() def _row( source_id=100, shop_id="4661340439", sku_name="兔兔白日梦", sku_code="187749890445642", has_ft=False, has_wo=False, spec=None, order_status="paid", ): return { "source_order_id": source_id, "platform": "kuaishou", "provider": "91kaquan", "shop_id": shop_id, "order_status": order_status, "sku_code": sku_code, "sku_name": sku_name, "quantity": 1, "spec_json": spec if spec is not None else {}, "has_fulfillment": has_ft, "has_work_order": has_wo, } class TestRouteAggregation: """route 判定(纯函数,不需要数据库)。""" def test_single_unhandled_row_is_candidate(self): assert src._route_of([_row()]) == src.ROUTE_UNHANDLED def test_fulfillment_row_wins_over_unhandled(self): """同一个订单号:内部行已发货 + 店铺行无人管 → 已处理。 这是 §6.5.1 那 540 组的核心判定,反过来写就会重复履约。 """ rows = [ _row(source_id=145013, shop_id=src.INTERNAL_SHOP_ID, has_ft=True), _row(source_id=145012, shop_id="4661340439"), ] assert src._route_of(rows) == src.ROUTE_AUTO_FULFILLED def test_work_order_row_wins_over_unhandled(self): rows = [_row(source_id=1, has_wo=True), _row(source_id=2)] assert src._route_of(rows) == src.ROUTE_WORKER_PLATFORM def test_fulfillment_wins_over_work_order(self): """两桶理论上互斥,真撞上时按「已发货」优先。""" rows = [_row(has_ft=True), _row(has_wo=True)] assert src._route_of(rows) == src.ROUTE_AUTO_FULFILLED class TestDisplayRow: def test_prefers_real_shop_row_over_internal(self): rows = [ _row(source_id=145013, shop_id=src.INTERNAL_SHOP_ID, sku_code="suit_x"), _row(source_id=145012, shop_id="4661340439", sku_name="狂沙舞者"), ] result = src._build_result("2624100167260659", rows) assert result["sku_name"] == "狂沙舞者" assert result["source_order_id"] == "145012" assert result["sku_code"] != "suit_x" def test_falls_back_to_internal_row_when_only_one(self): rows = [_row(shop_id=src.INTERNAL_SHOP_ID, sku_code="suit_x")] assert src._build_result("x", rows)["sku_code"] == "suit_x" class TestSpecExtraction: def test_sku_keys_read_from_spec_json(self): spec = { "skuId": "192758405127439", "skuNick": "(3选1)甜心娇蕊", "sellerId": "4661340439", "relItemId": "25228845231439", "relSkuId": "", "itemTitle": "和平精英皮肤小店入口...", } result = src._build_result("2627501925533255", [_row(spec=spec, sku_name="")]) assert result["seller_id"] == "4661340439" assert result["rel_item_id"] == "25228845231439" assert result["sku_nick"] == "(3选1)甜心娇蕊" # relSkuId 为空时回退到 skuId(规划 §3.6:skuId 才是快手 SKU ID) assert result["rel_sku_id"] == "192758405127439" assert result["item_title"] == "和平精英皮肤小店入口..." def test_spec_json_as_string_is_parsed(self): result = src._build_result("x", [_row(spec='{"sellerId": "3676797936"}')]) assert result["seller_id"] == "3676797936" def test_malformed_spec_json_is_ignored(self): result = src._build_result("x", [_row(spec="{not json")]) assert result["seller_id"] == "" class TestUrlNormalization: def test_bare_postgresql_url_gets_driver(self): url = "postgresql://u:p@h:5432/db" assert src._normalize_url(url) == "postgresql+psycopg://u:p@h:5432/db" def test_url_with_driver_is_untouched(self): url = "postgresql+psycopg://u:p@h:5432/db" assert src._normalize_url(url) == url class TestDegradation: def test_missing_url_disables_lookup(self, monkeypatch): monkeypatch.setenv("ORDER_SITE_LOOKUP_ENABLED", "true") with pytest.raises(src.OrderLookupUnavailable): src.lookup_order("2627501925533255") def test_disabled_flag_disables_lookup(self, monkeypatch): monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db") monkeypatch.setenv("ORDER_SITE_LOOKUP_ENABLED", "false") with pytest.raises(src.OrderLookupUnavailable): src.lookup_order("2627501925533255") def test_empty_order_no_raises(self, monkeypatch): monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db") monkeypatch.setenv("ORDER_SITE_LOOKUP_ENABLED", "true") with pytest.raises(src.OrderLookupUnavailable): src.lookup_order(" ") class TestCacheRoundTrip: """缓存读写:命中未过期快照就不查外部。""" def _seed(self, session, order_no, *, route=src.ROUTE_UNHANDLED, age_hours=0): session.add( ExternalOrderCache( platform_order_id=order_no, source_order_id="145012", platform="kuaishou", provider="91kaquan", shop_id="4661340439", order_status="paid", sku_code="187749890445642", sku_name="兔兔白日梦", quantity=1, route=route, seller_id="4661340439", rel_item_id="25228845231439", rel_sku_id="187749890445642", sku_nick="兔兔白日梦", item_title="和平精英小店", fetched_at=datetime.now(UTC) - timedelta(hours=age_hours), ) ) session.commit() def test_fresh_cache_is_returned_without_external_query(self, session, monkeypatch): monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db") monkeypatch.setenv("ORDER_SITE_LOOKUP_ENABLED", "true") self._seed(session, "2627501925533255") def _boom(_order_no): raise AssertionError("命中缓存时不应查询外部库") monkeypatch.setattr(src, "_fetch_rows", _boom) result = src.lookup_order("2627501925533255") assert result["cached"] is True assert result["sku_name"] == "兔兔白日梦" assert result["route"] == src.ROUTE_UNHANDLED def test_expired_cache_is_ignored(self, session, monkeypatch): monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db") monkeypatch.setenv("ORDER_SITE_LOOKUP_ENABLED", "true") self._seed(session, "2627501925533255", age_hours=25) monkeypatch.setattr(src, "_fetch_rows", lambda _o: [_row()]) result = src.lookup_order("2627501925533255") assert result["cached"] is False def test_lookup_writes_cache(self, session, monkeypatch): monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db") monkeypatch.setenv("ORDER_SITE_LOOKUP_ENABLED", "true") monkeypatch.setattr(src, "_fetch_rows", lambda _o: [_row()]) src.lookup_order("2627501925533255") row = ( session.query(ExternalOrderCache) .filter_by(platform_order_id="2627501925533255") .first() ) assert row is not None assert row.route == src.ROUTE_UNHANDLED assert row.sku_name == "兔兔白日梦" def test_duplicate_writes_do_not_collide(self, session, monkeypatch): """同一订单号两行都要能存下 —— 单列主键会把它们互相覆盖。""" monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db") monkeypatch.setenv("ORDER_SITE_LOOKUP_ENABLED", "true") rows = [ _row(source_id=145013, shop_id=src.INTERNAL_SHOP_ID, has_ft=True), _row(source_id=145012, shop_id="4661340439", sku_name="狂沙舞者"), ] monkeypatch.setattr(src, "_fetch_rows", lambda _o: rows) result = src.lookup_order("2624100167260659") assert result["route"] == src.ROUTE_AUTO_FULFILLED assert result["sku_name"] == "狂沙舞者" stored = ( session.query(ExternalOrderCache) .filter_by(platform_order_id="2624100167260659") .all() ) # 适配层只落「回显用的那一行」,但主键带 source_order_id, # 换一行回显时不会覆盖前一条。 assert len(stored) == 1 assert stored[0].source_order_id == "145012" class TestExternalFailure: def test_external_error_becomes_unavailable(self, session, monkeypatch): monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db") monkeypatch.setenv("ORDER_SITE_LOOKUP_ENABLED", "true") def _raise(_order_no): raise RuntimeError("connection refused") monkeypatch.setattr(src, "_fetch_rows", _raise) with pytest.raises(src.OrderLookupUnavailable): src.lookup_order("2627501925533255") def test_no_rows_means_order_not_found(self, session, monkeypatch): monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db") monkeypatch.setenv("ORDER_SITE_LOOKUP_ENABLED", "true") monkeypatch.setattr(src, "_fetch_rows", lambda _o: []) with pytest.raises(src.OrderLookupUnavailable): src.lookup_order("0000000000000000") class TestEngineConfig: def test_engine_uses_tiny_pool_and_statement_timeout(self, monkeypatch): """外部库的连接必须又小又带超时,别拖累对方生产库。""" monkeypatch.setenv( "ORDER_SITE_DATABASE_URL", "postgresql://livehub_ro:p@h:5432/order_site" ) monkeypatch.setenv("ORDER_SITE_LOOKUP_TIMEOUT_SECONDS", "3") created: dict[str, object] = {} real_create_engine = src.create_engine def _spy(url, **kwargs): created.update(kwargs) return real_create_engine(url, **kwargs) monkeypatch.setattr(src, "create_engine", _spy) engine = src._get_engine() try: assert engine.pool.size() == 2 assert engine.pool._max_overflow == 0 # statement_timeout 下发给连接,不是应用侧 kill(账号级已有 3s 兜底) assert created["connect_args"]["options"] == "-c statement_timeout=3000" finally: engine.dispose() def test_engine_is_created_once(self, monkeypatch): monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db") a = src._get_engine() b = src._get_engine() assert a is b a.dispose() def test_missing_url_does_not_poison_later_calls(self, monkeypatch): """未配置时抛错,但补上连接串后必须能恢复。 曾经的写法是先置 ``_INITIALIZED = True`` 再读 URL,一次落空就永久 毒化成不可用 —— 真机上表现为「明明配了 URL 却一直报未配置」。 """ with pytest.raises(src.OrderLookupUnavailable): src._get_engine() monkeypatch.setenv("ORDER_SITE_DATABASE_URL", "postgresql://u:p@h:5432/db") engine = src._get_engine() assert engine is not None engine.dispose() def test_os_environ_not_leaked_between_tests(): """保险:用例没把 ORDER_SITE_* 留在环境里。""" assert "ORDER_SITE_DATABASE_URL" not in os.environ