"""后端 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 json import uuid from unittest.mock import MagicMock, patch import httpx 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 from sqlalchemy import select 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.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_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: 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 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: 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/") 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 # 绑上了登录用户