e6dcad7c7d
无 is_best 兜底原本排除源、强选最低目标;当源本身最便宜时(全平台 dish-diff 相似替换),会误选更贵目标 → saved 变负,倒扣 get_stats 的 「累计发现可省」(该聚合按 status=success 求和、不带 >0 过滤)。 改为在含源的有价行里取最低价,与老派生函数 _derive「全目标缺菜回落源、 不虚报省」同一语义:源最便宜 → best=源、saved=0、is_source_best=True。 补回归测试:源最便宜场景(旧逻辑 best 误选更贵目标、saved 为负)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
363 lines
17 KiB
Python
363 lines
17 KiB
Python
"""后端 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
|
|
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
|
|
|
|
|
|
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_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 # 绑上了登录用户
|