3eb439b5ae
Reviewed-on: #114
259 lines
11 KiB
Python
259 lines
11 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 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 # 绑上了登录用户
|