"""比价记录测试:上报派生 / 幂等 / 分页 / 详情 / 鉴权 / 失败帧。 用 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: """「已下单」店级标记:店名命中该用户 source='compare' 的下单记录才 True。 覆盖 list_records 只按**本页店名**反查 savings 的写法(原来是把该用户全部下单店名捞回内存 再取交集,随下单量线性变慢)——两种写法结果必须一致,故这里按店名逐条断言。 """ 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)) # 下单前:两条都不该带「已下单」 items = client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"] assert {it["store_name"]: it["ordered"] for it in items} == { "海底捞(朝阳店)": False, "没下过单的店": False, } # 对海底捞真实下单一笔(order/report 写 source='compare' 的 savings_record) 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, }, headers=_auth(token), ) assert r.status_code == 200, r.text # 下单后:只有同店名那条翻成 True,另一条不受影响 items = client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"] assert {it["store_name"]: it["ordered"] for it in items} == { "海底捞(朝阳店)": True, "没下过单的店": False, } # 别人的下单不该影响本人标记(_ordered_shop_names 按 user_id 过滤) token_b = _login(client, "13800002011") client.post("/api/v1/compare/record", json=_food_payload("ord-b"), headers=_auth(token_b)) items_b = client.get("/api/v1/compare/records", headers=_auth(token_b)).json()["items"] assert [it["ordered"] for it in items_b] == [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)) client.post( "/api/v1/order/report", json={ "client_event_id": "evt-ordered-filter", "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), ) # 不传 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