Files
live-hub-py/tests/test_external_order_source.py
T
yml2213 afa5c815f6 feat(orders): P0 订单读取适配层——连 order_site 只读库查订单(默认关闭)
让运营能按订单号查到订单,作为后续资料收集(P2)与皮肤准入(P3/P4)的输入。
order_site 零改动:只读直连、独立极小连接池、可一键降级。

上机核实推翻了规划两处假设(详见 §6.5.1):
- orders 表没有 order_no 列,只有 id 与 platform_order_id → 查询入口用后者;
- platform_order_id 非唯一:541 组重复,形态固定为「91kaquan 内部行(已发货)
  + 真实店铺行(无人管)」。按规划原文的单行判据,这 540 个已发货订单会被误判
  成「没人管」而放行重复履约。改为**订单号维度聚合**:任一行已发货即 auto_fulfilled。

真机验证(只读账号 livehub_ro):三桶判定全对,重复组正确拦下并回显真实
店铺行的中文名(狂沙舞者,而非内部码 suit_sand_dancer),单次查询 3.3ms。

顺带修一个只在真机才暴露的 bug:_get_engine 先置 _INITIALIZED 再读 URL,
一次落空就把模块永久毒化成不可用,之后补上连接串也救不回来。
2026-10-02 22:50:20 +08:00

333 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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