Files
shaguabijia-app-server/tests/test_compare_harvest.py
T
linkeyu ab2de6ec79 修复:统一比价记录状态枚举口径 (#209)
## 变更说明

- below_minimum 归入成功,保留原始业务结局
- store_closed/store_not_found/items_not_found/no_delivery/unsupported 归入失败
- running 保持进行中生命周期状态
- 历史细分状态迁移为三态终态,迁移可逆
- 后台成功/失败筛选及汇总兼容迁移前历史值

## 验证

- 相关回归:48 passed
- Ruff:通过
- Alembic:单一 head

---------

Co-authored-by: guke <guke@wonderable.ai>
Co-authored-by: linkeyu <798648091@qq.com>
Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #209
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-08-01 23:09:21 +08:00

494 lines
22 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.
"""后端 harvest 落库测试(比价记录改后端 harvest 后新增)。
覆盖:
repo 层:harvest_running(建行/幂等回填)、harvest_done(派生+newly_success 幂等)、
harvest_abort(夭折 / **不降级 success**)、upsert_record 不降级。
端点层:price/step 首帧 mint trace_id + 回传 + 建 running 行;done 帧落 success;
trace/finalize 落 cancelled;软鉴权(无 token 也放行、user_id 暂空)。
pricebot 用 httpx mock,不真连(同 test_compare_proxy)。
"""
from __future__ import annotations
import uuid
from unittest.mock import MagicMock, patch
import httpx
import pytest
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.repositories import comparison as crud
from app.schemas.compare_record import ComparisonRecordIn
def _tid() -> str:
return uuid.uuid4().hex
@pytest.mark.parametrize(
("raw_status", "record_status"),
[
("success", "success"),
("below_minimum", "success"),
("failed", "failed"),
("store_closed", "failed"),
("store_not_found", "failed"),
("items_not_found", "failed"),
("no_delivery", "failed"),
("unsupported", "failed"),
("cancelled", "cancelled"),
("running", "running"),
(None, None),
],
)
def test_record_status_normalization(raw_status, record_status) -> None:
assert crud._normalize_record_status(raw_status) == record_status
def _done_params() -> dict:
"""一份典型 done 帧 params:美团 25 元 vs 源淘宝闪购 30 元 → 省 5 元、success。"""
return {
"comparison_results": [
{"platform_id": "meituan", "platform_name": "美团", "package": "com.sankuai.meituan",
"price": 25.0, "is_source": False, "rank": 1, "items": []},
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购",
"package": "com.taobao.taobao", "price": 30.0, "is_source": True, "rank": 2,
"store_name": "测试店", "items": [{"name": "肥牛饭", "qty": 1}]},
],
"information": "美团更便宜",
"trace_url": "https://price.shaguabijia.com/traces/done/",
}
def _get(db, trace_id: str) -> ComparisonRecord | None:
return db.execute(
select(ComparisonRecord).where(ComparisonRecord.trace_id == trace_id)
).scalar_one_or_none()
# ============================================================
# repo 层
# ============================================================
def test_harvest_running_creates_row(client) -> None:
tid = _tid()
with SessionLocal() as db:
crud.harvest_running(
db, trace_id=tid, user_id=None, device_id="dev-1",
device_info={"brand": "vivo", "model": "V2309A", "android_version": "14",
"rom_version": "14"},
trace_url="https://price.shaguabijia.com/traces/run/",
)
rec = _get(db, tid)
assert rec is not None
assert rec.status == "running"
assert rec.user_id is None
assert rec.device_id == "dev-1"
assert rec.device_model == "V2309A"
assert rec.device_manufacturer == "vivo"
assert rec.rom_version == 14
assert rec.trace_url.endswith("/run/")
def test_harvest_running_idempotent_backfills(client) -> None:
tid = _tid()
with SessionLocal() as db:
crud.harvest_running(db, trace_id=tid, user_id=None, trace_url=None)
# 第二帧带上 trace_url + user_id → 回填空缺,不新建、不改 status
crud.harvest_running(db, trace_id=tid, user_id=None,
trace_url="https://price.shaguabijia.com/traces/late/")
rows = db.execute(
select(ComparisonRecord).where(ComparisonRecord.trace_id == tid)
).scalars().all()
assert len(rows) == 1 # 幂等:仍一行
assert rows[0].status == "running"
assert rows[0].trace_url.endswith("/late/") # 空缺被回填
def test_harvest_done_derives_and_newly_success_once(client) -> None:
tid = _tid()
with SessionLocal() as db:
crud.harvest_running(db, trace_id=tid, user_id=None)
rec, newly = crud.harvest_done(db, trace_id=tid, user_id=None,
done_params=_done_params())
assert newly is True # running → success 是"新落成"
assert rec.status == "success"
assert rec.source_platform_id == "taobao_flash"
assert rec.source_price_cents == 3000
assert rec.best_platform_id == "meituan"
assert rec.best_price_cents == 2500
assert rec.saved_amount_cents == 500 # 30 - 25
assert rec.is_source_best is False
assert rec.store_name == "测试店"
assert rec.information == "美团更便宜"
assert rec.fail_reason is None # 成功记录不派生失败原因
assert rec.items == [{"name": "肥牛饭", "qty": 1}]
assert rec.trace_url.endswith("/done/")
# 再来一次(重试 done)→ 已 success,newly_success=False(发奖不重复触发)
_rec2, newly2 = crud.harvest_done(db, trace_id=tid, user_id=None,
done_params=_done_params())
assert newly2 is False
def test_harvest_done_below_minimum_counts_as_completed_success(client) -> None:
"""未达起送是可信业务结论:主状态/完成奖励归 success,细分结局仍留在 raw_payload。"""
tid = _tid()
done_below_minimum = {
"record_status": "below_minimum",
"comparison_results": [
{
"platform_id": "meituan",
"platform_name": "美团",
"package": "com.sankuai.meituan",
"price": 59.0,
"is_source": True,
"rank": 1,
"store_name": "测试店",
"items": [{"name": "红乌苏", "qty": 1}],
},
],
"platform_results": {
"meituan": {"is_source": True, "status": "source", "price": 59.0},
"taobao_flash": {
"is_source": False,
"status": "below_minimum",
"reason": "购物车未达起送门槛(差 ¥25.2",
},
},
"information": "淘宝未达起送门槛,可加菜凑单后下单",
}
with SessionLocal() as db:
crud.harvest_running(db, trace_id=tid, user_id=None)
rec, newly = crud.harvest_done(
db, trace_id=tid, user_id=None, done_params=done_below_minimum
)
assert rec.status == "success"
assert newly is True
assert rec.fail_reason is None
assert rec.raw_payload["record_status"] == "below_minimum"
assert (
rec.raw_payload["platform_results"]["taobao_flash"]["status"]
== "below_minimum"
)
def test_harvest_done_platforms_below_minimum_counts_as_success(client) -> None:
"""新 platforms 单源派生路径也必须执行同一 below_minimum → success 归一化。"""
tid = _tid()
with SessionLocal() as db:
rec, newly = crud.harvest_done(
db,
trace_id=tid,
user_id=None,
done_params={
"record_status": "below_minimum",
"platforms": [
{
"role": "source",
"platform_id": "meituan",
"platform_name": "美团",
"price": 59.0,
"store_name": "测试店",
"items": [{"name": "红乌苏", "qty": 1}],
},
{
"role": "target",
"platform_id": "taobao_flash",
"platform_name": "淘宝",
"status": "below_minimum",
"price": None,
},
],
"platform_results": {
"taobao_flash": {
"is_source": False,
"status": "below_minimum",
"reason": "购物车未达起送门槛",
}
},
},
)
assert rec.status == "success"
assert newly is True
assert rec.raw_payload["record_status"] == "below_minimum"
def test_legacy_upsert_below_minimum_counts_as_success(client) -> None:
"""灰度期客户端直报路径无论走 status 还是 record_status 都不能落第四种主状态。"""
tid = _tid()
payload = ComparisonRecordIn(
trace_id=tid,
business_type="food",
status="below_minimum",
comparison_results=[],
platform_results={
"taobao_flash": {
"is_source": False,
"status": "below_minimum",
"reason": "购物车未达起送门槛",
}
},
information="淘宝未达起送门槛,可加菜凑单后下单",
)
with SessionLocal() as db:
rec = crud.upsert_record(db, user_id=987654, payload=payload)
assert rec.status == "success"
assert rec.fail_reason is None
assert rec.raw_payload["status"] == "below_minimum"
def test_harvest_done_failed_derives_fail_reason(client) -> None:
"""failed 记录:记录级 information 笼统,但 fail_reason 从 platform_results 救出具体原因
(id 3030 型:美团系统失败 + 京东 items_not_found → 展示京东那条)。"""
tid = _tid()
done_failed = {
"comparison_results": [
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购",
"package": "com.taobao.taobao", "price": 23.04, "is_source": True, "rank": 1,
"items": [{"name": "肥牛饭", "qty": 1}]},
],
"platform_results": {
"taobao_flash": {"is_source": True, "status": "source", "price": 23.04},
"meituan_waimai": {"is_source": False, "status": "failed",
"reason": "搜索店铺失败, 无法跳转到搜索页"},
"jd_waimai_standalone": {"is_source": False, "status": "items_not_found",
"reason": "京东外卖此店内未找到这些菜品"},
},
"information": "比价过程出错,请稍后重试",
}
with SessionLocal() as db:
crud.harvest_running(db, trace_id=tid, user_id=None)
rec, newly = crud.harvest_done(db, trace_id=tid, user_id=None,
done_params=done_failed)
assert newly is False # 没落成 success
assert rec.status == "failed"
assert rec.fail_reason == "京东外卖此店内未找到这些菜品"
assert rec.information == "比价过程出错,请稍后重试" # 原文案仍留存
def _done_params_platforms_no_isbest() -> dict:
"""id 3304 型:done 帧 platforms 全平台「相似替换/仅供参考」(has_dish_diff),pricebot
一个 is_best 都没标,但有有价目标(美团 57.8 < 源淘宝闪购 60.8)。"""
return {
"platforms": [
{"role": "source", "platform_id": "eleme", "platform_name": "淘宝闪购",
"package": "me.ele", "price": 60.8, "is_best": False, "has_dish_diff": False,
"store_name": "窑鸡王", "items": [{"name": "招牌窑鸡 整只-香辣", "qty": 1}]},
{"role": "target", "platform_id": "meituan_waimai", "platform_name": "美团外卖",
"package": "com.sankuai.meituan.takeoutnew", "price": 57.8, "is_best": False,
"has_dish_diff": True},
{"role": "target", "platform_id": "jd_waimai_standalone", "platform_name": "京东外卖",
"package": "com.jd.waimai", "price": 74.9, "is_best": False, "has_dish_diff": True},
],
"information": "美团更便宜(含相似商品替换)",
}
def test_harvest_done_platforms_no_isbest_falls_back_to_cheapest_target(client) -> None:
"""回归(id 3304):platforms 全无 is_best(全平台相似替换)但有有价目标 → best 应兜底取
有价目标里最低那家,不能让 best_*/saved 整条落 NULL(否则首页价 0.00 / 记录页无最低红框 /
省额丢失)。"""
tid = _tid()
with SessionLocal() as db:
crud.harvest_running(db, trace_id=tid, user_id=None)
rec, _ = crud.harvest_done(db, trace_id=tid, user_id=None,
done_params=_done_params_platforms_no_isbest())
assert rec.status == "success"
assert rec.best_platform_id == "meituan_waimai" # 有价目标里最低
assert rec.best_price_cents == 5780 # 57.8 元
assert rec.source_price_cents == 6080 # 源淘宝闪购 60.8
assert rec.saved_amount_cents == 300 # 60.8 - 57.8
assert rec.is_source_best is False # 兜底选的是目标,非源
def _done_params_no_isbest_source_cheapest() -> dict:
"""无 is_best 且源本身最便宜:源淘宝闪购 50.0 < 全部 dish-diff 目标(美团 57.8 / 京东 74.9)。
此时不能强选更贵的目标当 best(否则 saved 变负、污染「累计发现可省」),应回落源。"""
return {
"platforms": [
{"role": "source", "platform_id": "eleme", "platform_name": "淘宝闪购",
"package": "me.ele", "price": 50.0, "is_best": False, "has_dish_diff": False,
"store_name": "窑鸡王", "items": [{"name": "招牌窑鸡 整只-香辣", "qty": 1}]},
{"role": "target", "platform_id": "meituan_waimai", "platform_name": "美团外卖",
"package": "com.sankuai.meituan.takeoutnew", "price": 57.8, "is_best": False,
"has_dish_diff": True},
{"role": "target", "platform_id": "jd_waimai_standalone", "platform_name": "京东外卖",
"package": "com.jd.waimai", "price": 74.9, "is_best": False, "has_dish_diff": True},
],
"information": "源平台已是最低(其余为相似替换)",
}
def test_harvest_done_no_isbest_source_cheapest_falls_back_to_source(client) -> None:
"""回归:无 is_best 且源最便宜 → best 回落源(与 _derive「全目标缺菜回落源、不虚报省」同语义),
saved=0、is_source_best=True,绝不因强选更贵目标而让 saved 变负、倒扣「累计发现可省」。"""
tid = _tid()
with SessionLocal() as db:
crud.harvest_running(db, trace_id=tid, user_id=None)
rec, _ = crud.harvest_done(db, trace_id=tid, user_id=None,
done_params=_done_params_no_isbest_source_cheapest())
assert rec.status == "success"
assert rec.best_platform_id == "eleme" # 回落到源(源最便宜)
assert rec.best_price_cents == 5000 # 源 50.0
assert rec.source_price_cents == 5000
assert rec.saved_amount_cents == 0 # 没省到,绝不为负
assert rec.is_source_best is True # 源就是最便宜
def test_harvest_abort_cancels_running(client) -> None:
tid = _tid()
with SessionLocal() as db:
crud.harvest_running(db, trace_id=tid, user_id=None)
rec = crud.harvest_abort(db, trace_id=tid, status="cancelled",
reason="用户终止比价",
trace_url="https://price.shaguabijia.com/traces/ab/")
assert rec is not None
assert rec.status == "cancelled"
assert rec.information == "用户终止比价"
assert rec.trace_url.endswith("/ab/")
def test_harvest_abort_no_downgrade_success(client) -> None:
"""已 success 的行,finalize 后到(收尾取消)→ 只 refresh trace_url,status 不降级。"""
tid = _tid()
with SessionLocal() as db:
crud.harvest_running(db, trace_id=tid, user_id=None)
crud.harvest_done(db, trace_id=tid, user_id=None, done_params=_done_params())
rec = crud.harvest_abort(db, trace_id=tid, status="cancelled", reason="收尾误触")
assert rec.status == "success" # 不降级
assert rec.information == "美团更便宜" # 不被 abort 的 reason 覆盖
def test_harvest_abort_missing_row_returns_none(client) -> None:
with SessionLocal() as db:
assert crud.harvest_abort(db, trace_id=_tid(), status="cancelled",
reason=None) is None
def test_upsert_record_no_downgrade_after_harvest_success(client) -> None:
"""harvest 落 success 后,老客户端 fromFailure 的 cancelled 上报不许把它盖回去。"""
tid = _tid()
with SessionLocal() as db:
crud.harvest_done(db, trace_id=tid, user_id=None, done_params=_done_params())
payload = ComparisonRecordIn(
trace_id=tid, business_type="food", status="cancelled",
information="用户终止", comparison_results=[],
)
# 用一个不会与顺序自增用户撞的合成 id(SQLite 测试库 FK 不强制;别用小整数,
# 否则会撞上别的测试 login 出来的真实 user_id → 记录混进那个用户的列表)。
rec = crud.upsert_record(db, user_id=987654, payload=payload)
assert rec.status == "success" # 不降级
assert rec.user_id == 987654 # 但补上了 user_id(原为 None)
# ============================================================
# 端点层(mock pricebot)
# ============================================================
def _mock_pricebot(resp_json: dict):
"""patch httpx.AsyncClient.post 返回给定响应;捕获转发的 content。"""
captured: dict = {}
async def fake_post(self, url, content=None, **kw):
captured["url"] = url
captured["content"] = content
m = MagicMock()
m.status_code = 200
m.json = lambda: dict(resp_json)
return m
return patch.object(httpx.AsyncClient, "post", fake_post), captured
def _stub_body(**over) -> dict:
body = {
"device_id": "dev-x",
"step": 0,
"query": "海底捞",
"device_info": {"brand": "OPPO", "model": "PEXM00", "android_version": "13",
"rom_version": "13"},
"screen_state": {"screen": {"width": 1080, "height": 2340, "density": 3.0},
"foreground": {"package": "com.taobao.taobao", "activity": ""},
"windows": []},
}
body.update(over)
return body
def test_price_step_mints_trace_id_and_creates_running(client) -> None:
"""首帧不带 trace_id → app-server 签发 + 回传;并建 running 行(带机型/设备)。"""
wait_frame = {"success": True, "action": {"command": "wait", "params": {"duration_ms": 500}},
"continue": True, "trace_url": "https://price.shaguabijia.com/traces/mint/"}
p, _cap = _mock_pricebot(wait_frame)
with p:
r = client.post("/api/v1/price/step", json=_stub_body()) # 无 trace_id、无 token
assert r.status_code == 200, r.text
tid = r.json().get("trace_id")
assert tid and len(tid) >= 16 # 回传了签发的 trace_id
with SessionLocal() as db:
rec = _get(db, tid)
assert rec is not None and rec.status == "running"
assert rec.user_id is None # 无 token → 软鉴权放行、user_id 暂空
assert rec.device_model == "PEXM00"
assert rec.trace_url.endswith("/mint/")
def test_price_step_done_harvests_success(client) -> None:
tid = _tid()
done_frame = {"success": True, "continue": False,
"action": {"command": "done", "params": _done_params()},
"trace_url": "https://price.shaguabijia.com/traces/done2/"}
p, _cap = _mock_pricebot(done_frame)
with p, patch(
"app.api.v1.compare.backfill_comparison_llm_cost"
) as backfill:
r = client.post("/api/v1/price/step", json=_stub_body(trace_id=tid, step=8))
assert r.status_code == 200
with SessionLocal() as db:
rec = _get(db, tid)
assert rec is not None and rec.status == "success"
assert rec.best_platform_id == "meituan"
assert rec.saved_amount_cents == 500
backfill.assert_called_once_with(rec.id, tid)
def test_trace_finalize_harvests_abort(client) -> None:
tid = _tid()
with SessionLocal() as db: # 先有 running 行(帧0建的)
crud.harvest_running(db, trace_id=tid, user_id=None)
p, _cap = _mock_pricebot({"trace_url": "https://price.shaguabijia.com/traces/fin/"})
with p, patch(
"app.api.v1.compare.backfill_comparison_llm_cost"
) as backfill:
r = client.post("/api/v1/trace/finalize",
json={"trace_id": tid, "status": "cancelled", "reason": "用户终止"})
assert r.status_code == 200
with SessionLocal() as db:
rec = _get(db, tid)
assert rec is not None and rec.status == "cancelled"
assert rec.trace_url.endswith("/fin/")
backfill.assert_called_once_with(rec.id, tid)
def test_price_step_binds_user_when_authed(client) -> None:
"""带 JWT 的首帧 → running 行绑上 user_id。"""
client.post("/api/v1/auth/sms/send", json={"phone": "13800009001"})
token = client.post("/api/v1/auth/sms/login",
json={"phone": "13800009001", "code": "123456"}).json()["access_token"]
wait_frame = {"success": True, "action": {"command": "wait", "params": {}},
"continue": True, "trace_url": "https://price.shaguabijia.com/traces/auth/"}
p, _cap = _mock_pricebot(wait_frame)
with p:
r = client.post("/api/v1/price/step", json=_stub_body(),
headers={"Authorization": f"Bearer {token}"})
tid = r.json()["trace_id"]
with SessionLocal() as db:
rec = _get(db, tid)
assert rec is not None and rec.user_id is not None # 绑上了登录用户