3663b5b11c
背景 / 问题 比价记录页「已下单」tab 混进大量本不该出现的记录:同一家店比价多次、只下过一单,却把这家店的每一条比价(含失败灰卡、没真正下单的成功比价)都塞进「已下单」。 根因 「已下单」原先是店级判定——下单上报(POST /order/report)不带 trace_id、savings_record 也没有指向具体某次比价的键,服务端只能用 savings_record.shop_name == comparison_record.store_name 做店名匹配 → 同店所有比价一并命中。属"先有省钱记账、后蹭已下单"的历史债(见 docs/database/savings_record.md 的 Join Key 注释)。 方案 把 trace_id 从比价会话打通到下单上报,「已下单」改为按 trace_id 精确对齐到那一条比价: 下单上报带上本次比价 trace_id → 落 savings_record.trace_id; 「已下单」过滤/打标(C 端 + admin)统一改成 savings_record.trace_id == comparison_record.trace_id(comparison 侧 trace_id 本就是 NOT NULL UNIQUE); 不做店名回退、不回填历史:没有 trace_id 的订单(历史/老客户端)对齐不上任何记录 → 不进「已下单」。 行为变化(评审必看) 上线后「已下单」只反映"更新版客户端下的新单":历史订单 + 老客户端过渡期订单没 trace_id,不再进「已下单」,随客户端铺量回血。 失败/终止比价不再进「已下单」(本就没真正下单;trace_id 只会指向有价可点的成功比价)。 后台看板「下单数」会掉:历史 savings_record 全是 trace_id=NULL,period_ordered_count 上线当天明显下跌——是口径变化、非回归,请知会看数据的同学。 必须与客户端一起上:只上服务端 → 订单永远不带 trace_id → 任何订单都对不上 → 「已下单」永远空。见关联 android PR。 --------- Co-authored-by: guke <guke@autohome.com.cn> Reviewed-on: #224
533 lines
23 KiB
Python
533 lines
23 KiB
Python
"""比价记录测试:上报派生 / 幂等 / 分页 / 详情 / 鉴权 / 失败帧。
|
||
|
||
用 sms mock 登录拿 token,再跑各接口闭环。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
|
||
def _login(client, phone: str) -> str:
|
||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||
assert r.status_code == 200, r.text
|
||
return r.json()["access_token"]
|
||
|
||
|
||
def _auth(token: str) -> dict[str, str]:
|
||
return {"Authorization": f"Bearer {token}"}
|
||
|
||
|
||
def _food_payload(trace_id: str) -> dict:
|
||
"""一次淘宝闪购→美团/京东 的成功比价(美团最便宜)。"""
|
||
return {
|
||
"trace_id": trace_id,
|
||
"business_type": "food",
|
||
"device_id": "dev-abc",
|
||
"store_name": "海底捞(朝阳店)",
|
||
"source_platform_id": "taobao_flash",
|
||
"source_platform_name": "淘宝闪购",
|
||
"source_package": "com.taobao.taobao",
|
||
"source_price": 128.50,
|
||
"items": [
|
||
{"name": "麻辣午餐肉", "qty": 2},
|
||
{"name": "黑牛肉卷", "qty": 1, "specs": ["大份"]},
|
||
],
|
||
"comparison_results": [
|
||
{"platform_id": "meituan", "platform_name": "美团", "package": "com.sankuai.meituan",
|
||
"price": 123.50, "is_source": False, "rank": 1,
|
||
"coupon_saved": 7.0, "coupon_name": "外卖大额神券"},
|
||
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "package": "com.taobao.taobao",
|
||
"price": 128.50, "is_source": True, "rank": 2},
|
||
{"platform_id": "jd_waimai", "platform_name": "京东外卖", "package": "com.jingdong.app.mall",
|
||
"price": 130.00, "is_source": False, "rank": 3},
|
||
],
|
||
"skipped_dish_count": 1,
|
||
"skipped_dish_names": ["黑牛肉卷"],
|
||
"total_dish_count": 3,
|
||
"information": "在美团找到同店,到手价 ¥123.50",
|
||
"total_ms": 12345,
|
||
}
|
||
|
||
|
||
def test_report_and_derive(client) -> None:
|
||
"""上报成功比价:服务端派生 best/saved/is_source_best/分,详情可读回。"""
|
||
token = _login(client, "13800002001")
|
||
|
||
r = client.post("/api/v1/compare/record", json=_food_payload("trace-1"), headers=_auth(token))
|
||
assert r.status_code == 200, r.text
|
||
rec_id = r.json()["id"]
|
||
assert isinstance(rec_id, int)
|
||
|
||
r = client.get(f"/api/v1/compare/records/{rec_id}", headers=_auth(token))
|
||
assert r.status_code == 200, r.text
|
||
d = r.json()
|
||
# 金额转分
|
||
assert d["source_price_cents"] == 12850
|
||
assert d["best_price_cents"] == 12350
|
||
# 省 = 源价 - 最优价 = 5.00 元
|
||
assert d["saved_amount_cents"] == 500
|
||
# 最优是美团(rank=1),非源平台 → 没"源平台最便宜"
|
||
assert d["best_platform_id"] == "meituan"
|
||
assert d["is_source_best"] is False
|
||
assert d["status"] == "success"
|
||
assert d["information"] == "在美团找到同店,到手价 ¥123.50"
|
||
assert d["store_name"] == "海底捞(朝阳店)"
|
||
assert d["total_dish_count"] == 3
|
||
assert d["total_ms"] == 12345
|
||
assert d["raw_payload"]["total_ms"] == 12345
|
||
assert d["skipped_dish_count"] == 1
|
||
assert d["skipped_dish_names"] == ["黑牛肉卷"]
|
||
assert len(d["comparison_results"]) == 3
|
||
assert len(d["items"]) == 2
|
||
# 详情带 raw_payload 全量
|
||
assert d["raw_payload"]["trace_id"] == "trace-1"
|
||
|
||
|
||
def test_coupon_saved_passthrough(client) -> None:
|
||
"""红包优惠额 coupon_saved + 来源名 coupon_name 上报→落库→读出;仅目标平台带值,源平台不带(null)。"""
|
||
token = _login(client, "13800002010")
|
||
rid = client.post(
|
||
"/api/v1/compare/record", json=_food_payload("trace-coupon"), headers=_auth(token)
|
||
).json()["id"]
|
||
d = client.get(f"/api/v1/compare/records/{rid}", headers=_auth(token)).json()
|
||
by_pid = {x["platform_id"]: x for x in d["comparison_results"]}
|
||
assert by_pid["meituan"]["coupon_saved"] == 7.0 # 目标平台带值
|
||
assert by_pid["meituan"]["coupon_name"] == "外卖大额神券" # 来源名透传(pricebot#38)
|
||
assert by_pid["taobao_flash"].get("coupon_saved") is None # 源平台不带/为 null
|
||
assert by_pid["taobao_flash"].get("coupon_name") is None # 源平台无来源名
|
||
|
||
|
||
def test_source_is_cheapest_no_saving(client) -> None:
|
||
"""源平台本来就最便宜:saved<=0,is_source_best=True,仍记一条 success。"""
|
||
token = _login(client, "13800002002")
|
||
payload = _food_payload("trace-2")
|
||
# 把源平台价改到最低 + rank=1
|
||
payload["source_price"] = 100.00
|
||
payload["comparison_results"] = [
|
||
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "package": "com.taobao.taobao",
|
||
"price": 100.00, "is_source": True, "rank": 1},
|
||
{"platform_id": "meituan", "platform_name": "美团", "package": "com.sankuai.meituan",
|
||
"price": 123.50, "is_source": False, "rank": 2},
|
||
]
|
||
r = client.post("/api/v1/compare/record", json=payload, headers=_auth(token))
|
||
rec_id = r.json()["id"]
|
||
d = client.get(f"/api/v1/compare/records/{rec_id}", headers=_auth(token)).json()
|
||
assert d["best_platform_id"] == "taobao_flash"
|
||
assert d["is_source_best"] is True
|
||
assert d["saved_amount_cents"] == 0 # 100 - 100
|
||
assert d["status"] == "success" # 有非源有效价(美团)
|
||
|
||
|
||
def test_failed_comparison_recorded(client) -> None:
|
||
"""比价失败(只有源、无目标有效价):status=failed 也落一条。"""
|
||
token = _login(client, "13800002003")
|
||
payload = _food_payload("trace-3")
|
||
payload["comparison_results"] = [
|
||
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "package": "com.taobao.taobao",
|
||
"price": 128.50, "is_source": True, "rank": 1},
|
||
]
|
||
r = client.post("/api/v1/compare/record", json=payload, headers=_auth(token))
|
||
rec_id = r.json()["id"]
|
||
d = client.get(f"/api/v1/compare/records/{rec_id}", headers=_auth(token)).json()
|
||
assert d["status"] == "failed"
|
||
assert d["best_platform_id"] == "taobao_flash" # 唯一一条
|
||
# 源价有、最优价=源价 → saved=0(没采到更便宜目标)
|
||
assert d["saved_amount_cents"] == 0
|
||
|
||
|
||
def test_idempotent_same_trace(client) -> None:
|
||
"""同 trace_id 重复上报:返回同一 id,只一条记录,后报覆盖。"""
|
||
token = _login(client, "13800002004")
|
||
p = _food_payload("trace-dup")
|
||
r1 = client.post("/api/v1/compare/record", json=p, headers=_auth(token))
|
||
id1 = r1.json()["id"]
|
||
|
||
# 改个值再报一次(同 trace)
|
||
p["store_name"] = "改名后的店"
|
||
r2 = client.post("/api/v1/compare/record", json=p, headers=_auth(token))
|
||
id2 = r2.json()["id"]
|
||
assert id1 == id2 # 同一条
|
||
|
||
# 列表里只有一条
|
||
page = client.get("/api/v1/compare/records", headers=_auth(token)).json()
|
||
assert len(page["items"]) == 1
|
||
# 覆盖生效
|
||
assert page["items"][0]["store_name"] == "改名后的店"
|
||
|
||
|
||
def test_records_pagination_and_isolation(client) -> None:
|
||
"""分页 id 倒序 + 游标;只看得到自己的记录。"""
|
||
token_a = _login(client, "13800002005")
|
||
for i in range(5):
|
||
client.post("/api/v1/compare/record", json=_food_payload(f"a-{i}"), headers=_auth(token_a))
|
||
|
||
# 另一个用户的记录不应混入
|
||
token_b = _login(client, "13800002006")
|
||
client.post("/api/v1/compare/record", json=_food_payload("b-0"), headers=_auth(token_b))
|
||
|
||
r = client.get("/api/v1/compare/records?limit=3", headers=_auth(token_a))
|
||
page1 = r.json()
|
||
assert len(page1["items"]) == 3
|
||
assert page1["next_cursor"] is not None
|
||
ids = [it["id"] for it in page1["items"]]
|
||
assert ids == sorted(ids, reverse=True)
|
||
|
||
r = client.get(
|
||
f"/api/v1/compare/records?limit=3&cursor={page1['next_cursor']}",
|
||
headers=_auth(token_a),
|
||
)
|
||
page2 = r.json()
|
||
assert len(page2["items"]) == 2 # A 共 5 条
|
||
assert max(it["id"] for it in page2["items"]) < min(ids)
|
||
|
||
# B 只看到自己那一条
|
||
pb = client.get("/api/v1/compare/records", headers=_auth(token_b)).json()
|
||
assert len(pb["items"]) == 1
|
||
|
||
|
||
def test_detail_cross_user_404(client) -> None:
|
||
"""读他人记录详情 → 404(限本人)。"""
|
||
token_a = _login(client, "13800002007")
|
||
rid = client.post(
|
||
"/api/v1/compare/record", json=_food_payload("x-0"), headers=_auth(token_a)
|
||
).json()["id"]
|
||
|
||
token_b = _login(client, "13800002008")
|
||
r = client.get(f"/api/v1/compare/records/{rid}", headers=_auth(token_b))
|
||
assert r.status_code == 404
|
||
|
||
|
||
def test_stats_compare_count_and_saved(client) -> None:
|
||
"""比价口径战绩:完成比价数 + 累计发现可省(各成功比价 saved 之和)。"""
|
||
token = _login(client, "13800002011")
|
||
# 新用户:无记录 → 全 0(配合「我的」页未比价锁定态)
|
||
s0 = client.get("/api/v1/compare/stats", headers=_auth(token)).json()
|
||
assert s0["compare_count"] == 0
|
||
assert s0["discovered_saved_cents"] == 0
|
||
|
||
# 报 2 条成功比价(各省 128.50−123.50=5.00 元=500 分)
|
||
client.post("/api/v1/compare/record", json=_food_payload("stat-1"), headers=_auth(token))
|
||
client.post("/api/v1/compare/record", json=_food_payload("stat-2"), headers=_auth(token))
|
||
s = client.get("/api/v1/compare/stats", headers=_auth(token)).json()
|
||
assert s["compare_count"] == 2
|
||
assert s["discovered_saved_cents"] == 1000
|
||
|
||
# 失败帧(只有源平台)不计入成功比价数
|
||
fail = _food_payload("stat-fail")
|
||
fail["comparison_results"] = [
|
||
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购",
|
||
"package": "com.taobao.taobao", "price": 128.50, "is_source": True, "rank": 1},
|
||
]
|
||
client.post("/api/v1/compare/record", json=fail, headers=_auth(token))
|
||
s2 = client.get("/api/v1/compare/stats", headers=_auth(token)).json()
|
||
assert s2["compare_count"] == 2 # 仍 2(failed 不计)
|
||
|
||
|
||
def test_records_ordered_flag(client) -> None:
|
||
"""「已下单」按 trace_id 标记:下单上报带上本次比价的 trace_id,精确命中该条记录才 True。
|
||
|
||
覆盖 list_records 只按**本页 trace_id**反查 savings 的写法(不把该用户全部下单 trace_id 捞回
|
||
内存再取交集)——两种写法结果必须一致,故这里逐条断言。
|
||
"""
|
||
token = _login(client, "13800002010")
|
||
|
||
# 两条比价记录:一条海底捞(稍后会有对应下单),一条没下过单的店
|
||
client.post("/api/v1/compare/record", json=_food_payload("ord-1"), headers=_auth(token))
|
||
other = _food_payload("ord-2")
|
||
other["store_name"] = "没下过单的店"
|
||
client.post("/api/v1/compare/record", json=other, headers=_auth(token))
|
||
|
||
# 下单前:两条都不该带「已下单」。按 trace_id 断言本测试自己的两条(不受同库其它用例数据干扰)。
|
||
flags = {
|
||
it["trace_id"]: it["ordered"]
|
||
for it in client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
|
||
}
|
||
assert flags["ord-1"] is False
|
||
assert flags["ord-2"] is False
|
||
|
||
# 对海底捞那次比价真实下单一笔:上报带上该次比价的 trace_id(order/report 写 source='compare')
|
||
r = client.post(
|
||
"/api/v1/order/report",
|
||
json={
|
||
"client_event_id": "evt-ordered-flag",
|
||
"platform": "美团",
|
||
"platform_package": "com.sankuai.meituan",
|
||
"pay_channel": "wechat",
|
||
"compared_price_cents": 12350,
|
||
"paid_amount_cents": 12350,
|
||
"shop_name": "海底捞(朝阳店)",
|
||
"original_price_cents": 12850,
|
||
"trace_id": "ord-1",
|
||
},
|
||
headers=_auth(token),
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
|
||
# 下单后:只有命中 trace_id(ord-1)那条翻成 True,同店的另一次比价(ord-2)不受影响
|
||
flags = {
|
||
it["trace_id"]: it["ordered"]
|
||
for it in client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
|
||
}
|
||
assert flags["ord-1"] is True
|
||
assert flags["ord-2"] is False
|
||
|
||
# 别人的下单不该影响本人标记(_ordered_trace_ids 按 user_id 过滤)
|
||
token_b = _login(client, "13800002011")
|
||
client.post("/api/v1/compare/record", json=_food_payload("ord-b"), headers=_auth(token_b))
|
||
flags_b = {
|
||
it["trace_id"]: it["ordered"]
|
||
for it in client.get("/api/v1/compare/records", headers=_auth(token_b)).json()["items"]
|
||
}
|
||
assert flags_b["ord-b"] is False
|
||
|
||
|
||
def test_records_list_omits_raw_payload(client) -> None:
|
||
"""列表出参不含 raw_payload(仓库层 defer 掉了重型 JSON 列);详情接口照常返回。
|
||
|
||
defer 的列一旦被 ORM 实例读到会触发逐行懒加载(N+1),而列表 schema 本就不该带 raw_payload
|
||
—— 这条同时守住「列表不泄露上报体全量」和「没人不小心把它加回出参」。
|
||
"""
|
||
token = _login(client, "13800002012")
|
||
rid = client.post(
|
||
"/api/v1/compare/record", json=_food_payload("no-raw"), headers=_auth(token)
|
||
).json()["id"]
|
||
|
||
items = client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
|
||
assert len(items) == 1
|
||
assert "raw_payload" not in items[0]
|
||
# 概要字段照常齐全(defer 没误伤列表要用的列)
|
||
assert items[0]["store_name"] == "海底捞(朝阳店)"
|
||
assert items[0]["best_platform_id"] == "meituan"
|
||
assert items[0]["comparison_results"] and items[0]["items"]
|
||
|
||
# 详情不 defer:raw_payload 全量还在
|
||
d = client.get(f"/api/v1/compare/records/{rid}", headers=_auth(token)).json()
|
||
assert d["raw_payload"]["trace_id"] == "no-raw"
|
||
|
||
|
||
def test_records_ordered_filter(client) -> None:
|
||
"""ordered=true 只出「已下单」的记录,且过滤结果自身能翻页。
|
||
|
||
这个筛选必须在服务端做:客户端早先是对「已经拉回来的那一页」做 filter,分页之后一页里
|
||
很可能一条已下单都没有 ——「已下单」tab 就会看着像空的,得手动翻很多页才蹦出一条。
|
||
"""
|
||
token = _login(client, "13800002013")
|
||
|
||
# 3 条「下过单的店」+ 2 条没下过单的店,交错写入,确保过滤不是靠顺序碰巧对上
|
||
for i in range(3):
|
||
p = _food_payload(f"of-ordered-{i}")
|
||
p["store_name"] = "下过单的店"
|
||
client.post("/api/v1/compare/record", json=p, headers=_auth(token))
|
||
if i < 2:
|
||
q = _food_payload(f"of-plain-{i}")
|
||
q["store_name"] = "没下过单的店"
|
||
client.post("/api/v1/compare/record", json=q, headers=_auth(token))
|
||
|
||
# 对这 3 条「下过单的店」比价分别真实下单(各带自己的 trace_id);2 条「没下过单的店」不下单
|
||
for i in range(3):
|
||
r = client.post(
|
||
"/api/v1/order/report",
|
||
json={
|
||
"client_event_id": f"evt-ordered-filter-{i}",
|
||
"platform": "美团",
|
||
"platform_package": "com.sankuai.meituan",
|
||
"pay_channel": "wechat",
|
||
"compared_price_cents": 12350,
|
||
"paid_amount_cents": 12350,
|
||
"shop_name": "下过单的店",
|
||
"original_price_cents": 12850,
|
||
"trace_id": f"of-ordered-{i}",
|
||
},
|
||
headers=_auth(token),
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
|
||
# 不传 ordered:5 条全出(「全部记录」tab 口径不变)
|
||
assert len(client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]) == 5
|
||
|
||
# ordered=true:只出那 3 条,且每条都自带 ordered=True
|
||
page = client.get("/api/v1/compare/records?ordered=true", headers=_auth(token)).json()
|
||
assert [it["store_name"] for it in page["items"]] == ["下过单的店"] * 3
|
||
assert all(it["ordered"] for it in page["items"])
|
||
assert page["next_cursor"] is None
|
||
|
||
# 游标只在「已下单」集合内走 —— 不会把没下单的记录算进一页的 limit 里
|
||
p1 = client.get(
|
||
"/api/v1/compare/records?ordered=true&limit=2", headers=_auth(token)
|
||
).json()
|
||
assert len(p1["items"]) == 2
|
||
assert p1["next_cursor"] is not None
|
||
p2 = client.get(
|
||
f"/api/v1/compare/records?ordered=true&limit=2&cursor={p1['next_cursor']}",
|
||
headers=_auth(token),
|
||
).json()
|
||
assert [it["store_name"] for it in p2["items"]] == ["下过单的店"]
|
||
# 两页不重叠,合起来正好 3 条
|
||
assert len({it["id"] for it in p1["items"] + p2["items"]}) == 3
|
||
|
||
# 别人的下单不该让本人记录进「已下单」
|
||
token_b = _login(client, "13800002014")
|
||
pb = _food_payload("of-b")
|
||
pb["store_name"] = "下过单的店"
|
||
client.post("/api/v1/compare/record", json=pb, headers=_auth(token_b))
|
||
assert client.get(
|
||
"/api/v1/compare/records?ordered=true", headers=_auth(token_b)
|
||
).json()["items"] == []
|
||
|
||
|
||
def test_records_keyword_search(client) -> None:
|
||
"""keyword 按店名 / 菜名模糊搜(忽略大小写),LIKE 通配符只当字面量;搜索结果也能翻页。
|
||
|
||
菜名走写路径派生的 product_names 文本列 —— items 是 JSON,SQLite 下中文被 ensure_ascii
|
||
转义,直接 LIKE 搜不到。
|
||
"""
|
||
token = _login(client, "13800002015")
|
||
|
||
b = _food_payload("kw-b")
|
||
b["store_name"] = "Pizza Hut"
|
||
b["items"] = [{"name": "榴莲比萨", "qty": 1}]
|
||
client.post("/api/v1/compare/record", json=b, headers=_auth(token))
|
||
|
||
c = _food_payload("kw-c")
|
||
c["store_name"] = "100%纯牛肉汉堡"
|
||
c["items"] = [{"name": "双层牛肉堡", "qty": 1}]
|
||
client.post("/api/v1/compare/record", json=c, headers=_auth(token))
|
||
|
||
# 默认 payload 的店名是「海底捞(朝阳店)」
|
||
client.post("/api/v1/compare/record", json=_food_payload("kw-a"), headers=_auth(token))
|
||
|
||
def _search(kw: str, **extra) -> list[str]:
|
||
r = client.get(
|
||
"/api/v1/compare/records",
|
||
params={"keyword": kw, **extra},
|
||
headers=_auth(token),
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
return [it["store_name"] for it in r.json()["items"]]
|
||
|
||
assert _search("海底捞") == ["海底捞(朝阳店)"] # 店名命中
|
||
assert _search("榴莲") == ["Pizza Hut"] # 菜名命中(product_names)
|
||
assert _search("pizza") == ["Pizza Hut"] # 忽略大小写
|
||
assert _search("PIZZA") == ["Pizza Hut"]
|
||
assert _search("不存在的店") == [] # 没命中就是空
|
||
# 通配符只当普通字符:搜 % 不该把整表拉回来,搜 _ 也不该匹配任意单字符
|
||
assert _search("%") == ["100%纯牛肉汉堡"]
|
||
assert _search("_") == []
|
||
# 纯空白等同不传 → 不过滤
|
||
assert len(_search(" ")) == 3
|
||
|
||
# 搜索结果自身可翻页
|
||
for i in range(3):
|
||
p = _food_payload(f"kw-page-{i}")
|
||
p["store_name"] = "连锁烤鱼店"
|
||
client.post("/api/v1/compare/record", json=p, headers=_auth(token))
|
||
p1 = client.get(
|
||
"/api/v1/compare/records",
|
||
params={"keyword": "烤鱼", "limit": 2},
|
||
headers=_auth(token),
|
||
).json()
|
||
assert len(p1["items"]) == 2
|
||
assert p1["next_cursor"] is not None
|
||
p2 = client.get(
|
||
"/api/v1/compare/records",
|
||
params={"keyword": "烤鱼", "limit": 2, "cursor": p1["next_cursor"]},
|
||
headers=_auth(token),
|
||
).json()
|
||
assert [it["store_name"] for it in p2["items"]] == ["连锁烤鱼店"]
|
||
assert len({it["id"] for it in p1["items"] + p2["items"]}) == 3
|
||
|
||
|
||
def test_requires_auth(client) -> None:
|
||
"""不带 token 统一 401。"""
|
||
assert client.post("/api/v1/compare/record", json={"trace_id": "t"}).status_code == 401
|
||
assert client.get("/api/v1/compare/records").status_code == 401
|
||
assert client.get("/api/v1/compare/records/1").status_code == 401
|
||
assert client.get("/api/v1/compare/stats").status_code == 401
|
||
|
||
|
||
def test_trace_id_required(client) -> None:
|
||
"""缺 trace_id → 422(pydantic 校验)。"""
|
||
token = _login(client, "13800002009")
|
||
r = client.post("/api/v1/compare/record", json={"business_type": "food"}, headers=_auth(token))
|
||
assert r.status_code == 422
|
||
|
||
|
||
def test_records_ordered_by_trace_id(client) -> None:
|
||
"""「已下单」精确到单次比价:同一家店比价多次,只有真实下单的那一次(下单上报带 trace_id)才算已下单。
|
||
|
||
下单上报带上本次比价的 trace_id,服务端按 trace_id 对齐 —— 同店其它比价(哪怕成功)不再被一并
|
||
标「已下单」。这是从"店级"改"单次级"的核心契约。
|
||
"""
|
||
token = _login(client, "13800002016")
|
||
|
||
# 同一家店(默认 payload 店名都是「海底捞(朝阳店)」)比价两次,不同 trace_id
|
||
client.post("/api/v1/compare/record", json=_food_payload("ord-trace-a"), headers=_auth(token))
|
||
client.post("/api/v1/compare/record", json=_food_payload("ord-trace-b"), headers=_auth(token))
|
||
|
||
# 只对其中一次(trace-a)真实下单,上报带上该次比价的 trace_id
|
||
r = client.post(
|
||
"/api/v1/order/report",
|
||
json={
|
||
"client_event_id": "evt-trace-a",
|
||
"platform": "美团",
|
||
"platform_package": "com.sankuai.meituan",
|
||
"pay_channel": "wechat",
|
||
"compared_price_cents": 12350,
|
||
"paid_amount_cents": 12350,
|
||
"shop_name": "海底捞(朝阳店)",
|
||
"original_price_cents": 12850,
|
||
"trace_id": "ord-trace-a",
|
||
},
|
||
headers=_auth(token),
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
|
||
# ordered=true 只出下单的那条(trace-a);同店没下单的 trace-b 不进来
|
||
page = client.get("/api/v1/compare/records?ordered=true", headers=_auth(token)).json()
|
||
assert [it["trace_id"] for it in page["items"]] == ["ord-trace-a"]
|
||
|
||
# 全部记录里,只有 trace-a 带 ordered=true,同店的 trace-b 仍是 False
|
||
items = client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
|
||
assert {it["trace_id"]: it["ordered"] for it in items} == {
|
||
"ord-trace-a": True,
|
||
"ord-trace-b": False,
|
||
}
|
||
|
||
|
||
def test_records_ordered_ignores_order_without_trace_id(client) -> None:
|
||
"""无 trace_id 的下单(老客户端/历史)即使店名相同也不进「已下单」—— 锁定「无店名回退」契约。
|
||
|
||
下单上报不带 trace_id → savings_record.trace_id 为空 → 对齐不上任何比价记录。老逻辑靠店名
|
||
会把同店比价一并标已下单,这条正是要防它被改回去。
|
||
"""
|
||
token = _login(client, "13800002017")
|
||
|
||
# 一条比价记录(默认店名「海底捞(朝阳店)」)
|
||
client.post("/api/v1/compare/record", json=_food_payload("no-trace-order"), headers=_auth(token))
|
||
|
||
# 同店名、但**不带 trace_id** 的下单(模拟老客户端)
|
||
r = client.post(
|
||
"/api/v1/order/report",
|
||
json={
|
||
"client_event_id": "evt-no-trace",
|
||
"platform": "美团",
|
||
"platform_package": "com.sankuai.meituan",
|
||
"pay_channel": "wechat",
|
||
"compared_price_cents": 12350,
|
||
"paid_amount_cents": 12350,
|
||
"shop_name": "海底捞(朝阳店)",
|
||
"original_price_cents": 12850,
|
||
},
|
||
headers=_auth(token),
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
|
||
# 店名虽同,但订单没 trace_id → 该记录不该被标「已下单」,也不进 ordered=true(无店名回退)
|
||
flags = {
|
||
it["trace_id"]: it["ordered"]
|
||
for it in client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
|
||
}
|
||
assert flags["no-trace-order"] is False
|
||
assert client.get(
|
||
"/api/v1/compare/records?ordered=true", headers=_auth(token)
|
||
).json()["items"] == []
|