feat(onboarding): 新增重置新手引导端点 /api/v1/user/onboarding/reset (#114)
Reviewed-on: #114
This commit was merged in pull request #114.
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
"""后端 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 # 绑上了登录用户
|
||||
@@ -20,8 +20,10 @@ def _auth(token: str) -> dict[str, str]:
|
||||
|
||||
def _report_success(client, token: str, trace_id: str) -> None:
|
||||
"""上报一条成功比价(有非源有效价 → status 派生 success)。"""
|
||||
# trace_id 现全局唯一(prod 由 app-server 签发 UUID);测试共用 session DB,故按 token
|
||||
# (每用户不同)加前缀防跨用户/跨文件复用 "s-1" 撞车(见 comparison_record_trace_unique 迁移)。
|
||||
payload = {
|
||||
"trace_id": trace_id,
|
||||
"trace_id": f"{token[-16:]}:{trace_id}",
|
||||
"business_type": "food",
|
||||
"store_name": "测试店",
|
||||
"source_platform_id": "taobao_flash",
|
||||
@@ -40,7 +42,7 @@ def _report_success(client, token: str, trace_id: str) -> None:
|
||||
def _report_failed(client, token: str, trace_id: str) -> None:
|
||||
"""上报一条失败比价(只有源 → status 派生 failed,不计入解锁)。"""
|
||||
payload = {
|
||||
"trace_id": trace_id,
|
||||
"trace_id": f"{token[-16:]}:{trace_id}",
|
||||
"business_type": "food",
|
||||
"comparison_results": [
|
||||
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "price": 30.0,
|
||||
|
||||
@@ -9,6 +9,7 @@ mock 掉对 pricebot 的 httpx 调用,验证(两个端点参数化同跑):
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
@@ -41,7 +42,8 @@ def _stub_body() -> dict:
|
||||
|
||||
@pytest.mark.parametrize("path,upstream", ENDPOINTS)
|
||||
def test_passes_body_through(client, path, upstream) -> None:
|
||||
"""无 token + pricebot 200 → 响应原样透传,body 原样转发,URL 去掉 /v1。"""
|
||||
"""无 token(软鉴权放行)+ pricebot 200 → 响应透传(+ app-server 注入 trace_id),
|
||||
body 原样转发(自带 trace_id 时不重签),URL 去掉 /v1。"""
|
||||
fake_resp = {
|
||||
"success": True,
|
||||
"action": {"command": "wait", "params": {"duration_ms": 500}},
|
||||
@@ -49,20 +51,25 @@ def test_passes_body_through(client, path, upstream) -> None:
|
||||
}
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_post(self, url, json=None, **kw):
|
||||
async def fake_post(self, url, content=None, **kw):
|
||||
# 透传壳用 content=raw(bytes)转发,不是 json=;这里捕获 content 解回来核对
|
||||
captured["url"] = url
|
||||
captured["json"] = json
|
||||
captured["content"] = content
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json = lambda: fake_resp
|
||||
mock_resp.json = lambda: dict(fake_resp) # 每次新副本(端点会 setdefault trace_id 进去)
|
||||
return mock_resp
|
||||
|
||||
with patch.object(httpx.AsyncClient, "post", fake_post):
|
||||
r = client.post(path, json=_stub_body())
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == fake_resp
|
||||
assert captured["json"] == _stub_body() # body 原样转发(不鉴权,无需 token)
|
||||
body = r.json()
|
||||
assert body["success"] is True
|
||||
assert body["action"] == fake_resp["action"]
|
||||
assert body["trace_id"] == "test-trace-1" # 自带 trace_id → 原样回传, 不 mint
|
||||
# body 原样转发(content=raw bytes;自带 trace_id 时不重新序列化)
|
||||
assert json.loads(captured["content"]) == _stub_body()
|
||||
assert captured["url"].endswith(upstream) # /api/v1/xxx → /api/xxx
|
||||
|
||||
|
||||
|
||||
@@ -65,3 +65,28 @@ def test_onboarding_missing_device_id_treated_as_incomplete(client) -> None:
|
||||
phone = "13800138003"
|
||||
data = _login(client, phone, device_id=None)
|
||||
assert data["onboarding_completed"] is False
|
||||
|
||||
|
||||
def _reset(client, access: str, device_id: str):
|
||||
return client.post(
|
||||
"/api/v1/user/onboarding/reset",
|
||||
json={"device_id": device_id},
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
)
|
||||
|
||||
|
||||
def test_onboarding_reset_makes_relogin_reonboard(client) -> None:
|
||||
"""重置(删完成标记)后,同 (账号, 设备) 再登录 → onboarding_completed=False,重走引导。
|
||||
对应客户端「开发设置 → 重置新手引导」:先删后端行,再退登录重开。"""
|
||||
phone = "13800138004"
|
||||
dev = "android-id-reset"
|
||||
|
||||
# ① 走完引导 → 标记完成 → 再登录已完成
|
||||
access = _login(client, phone, dev)["access_token"]
|
||||
assert _mark_complete(client, access, dev).status_code == 200
|
||||
assert _login(client, phone, dev)["onboarding_completed"] is True
|
||||
|
||||
# ② 重置(幂等:重复重置也 200)→ 再登录变未完成 → 重走引导
|
||||
assert _reset(client, access, dev).status_code == 200
|
||||
assert _reset(client, access, dev).json()["ok"] is True
|
||||
assert _login(client, phone, dev)["onboarding_completed"] is False
|
||||
|
||||
Reference in New Issue
Block a user