Files
shaguabijia-app-server/tests/test_welfare.py
T
guke 4c1821363c test(wallet): 防残组测试补跨游标前置断言(游标须落在会话两成员 id 间)
否则 seeding 顺序若变,用例会退化成非跨游标却仍通过、失守护意义。
(code-review Task 5 的建议)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-07 16:10:02 +08:00

597 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""福利模块测试:钱包 / 签到 / 一次性任务。
用 sms mock 登录拿 token,再跑各接口闭环。
"""
from __future__ import annotations
import json
from sqlalchemy import select, text
from app.core.config import settings
from app.core.rewards import (
COIN_PER_CENT,
COIN_PER_YUAN,
MIN_EXCHANGE_COIN,
SIGNIN_REWARDS,
TASK_ENABLE_NOTIFICATION,
TASK_REWARDS,
)
from app.db.session import SessionLocal
from app.integrations import pangle
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.wallet import CoinTransaction
from app.repositories import ad_feed_reward as crud_feed
from app.repositories import wallet as crud_wallet
from app.repositories.user import get_user_by_phone
def _login(client, phone: str) -> str:
"""sms mock 登录,返回 access_token。"""
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 _signed_ad_callback(user_id: int, trans_id: str, **params: str) -> dict[str, str]:
body = {"user_id": str(user_id), "trans_id": trans_id, **params}
body["sign"] = pangle.build_sign(trans_id, settings.PANGLE_REWARD_SECRET)
return body
def test_account_auto_created_empty(client) -> None:
"""首次访问账户:自动建空账户,余额全 0。"""
token = _login(client, "13800001001")
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.status_code == 200, r.text
body = r.json()
assert body == {
"coin_balance": 0,
"cash_balance_cents": 0,
"invite_cash_balance_cents": 0, # v2 账户隔离新增(邀请奖励金,与现金隔离)
"total_coin_earned": 0,
}
def test_signin_flow(client) -> None:
"""签到状态 → 签到 → 余额到账 → 重复签到 409 → 流水有记录。"""
token = _login(client, "13800001002")
# 1. 签到前状态:未签、可签、今天是第 1 档
r = client.get("/api/v1/signin/status", headers=_auth(token))
assert r.status_code == 200, r.text
st = r.json()
assert st["today_signed"] is False
assert st["can_claim"] is True
assert st["today_cycle_day"] == 1
assert st["today_coin"] == SIGNIN_REWARDS[0]
assert len(st["steps"]) == len(SIGNIN_REWARDS)
assert st["steps"][0]["status"] == "today"
# 2. 执行签到,发第 1 档金币
r = client.post("/api/v1/signin", headers=_auth(token))
assert r.status_code == 200, r.text
res = r.json()
assert res["cycle_day"] == 1
assert res["streak"] == 1
assert res["coin_awarded"] == SIGNIN_REWARDS[0]
assert res["coin_balance"] == SIGNIN_REWARDS[0]
# 3. 账户余额到账
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.json()["coin_balance"] == SIGNIN_REWARDS[0]
# 4. 状态变为已签
r = client.get("/api/v1/signin/status", headers=_auth(token))
st = r.json()
assert st["today_signed"] is True
assert st["can_claim"] is False
assert st["consecutive_days"] == 1
assert st["steps"][0]["status"] == "claimed"
# 5. 重复签到 → 409
r = client.post("/api/v1/signin", headers=_auth(token))
assert r.status_code == 409
# 6. 金币流水有一笔 signin 入账
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
assert r.status_code == 200, r.text
page = r.json()
assert page["next_cursor"] is None
assert len(page["items"]) == 1
txn = page["items"][0]
assert txn["amount"] == SIGNIN_REWARDS[0]
assert txn["biz_type"] == "signin"
assert txn["balance_after"] == SIGNIN_REWARDS[0]
def test_task_claim_flow(client) -> None:
"""打开消息提醒=可重复任务:每次领取金额减半(750/375/188),claimed 恒 False,余额累加。"""
token = _login(client, "13800001003")
base = TASK_REWARDS[TASK_ENABLE_NOTIFICATION] # 750
# 1. 任务列表:可重复任务 claimed 恒 False,coin=首次金额(750)
r = client.get("/api/v1/tasks", headers=_auth(token))
assert r.status_code == 200, r.text
items = {it["task_key"]: it for it in r.json()["items"]}
assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is False
assert items[TASK_ENABLE_NOTIFICATION]["coin"] == base # 750
# 2. 第一次领 → 750,余额 750
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
assert r.status_code == 200, r.text
assert r.json()["coin_awarded"] == 750
assert r.json()["coin_balance"] == 750
# 3. 列表:下一次金额减半=375,仍 claimed False(可重复)
r = client.get("/api/v1/tasks", headers=_auth(token))
items = {it["task_key"]: it for it in r.json()["items"]}
assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is False
assert items[TASK_ENABLE_NOTIFICATION]["coin"] == 375
# 4. 第二次领 → 375(余额 1125),第三次 → 188(四舍五入)
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
assert r.status_code == 200 and r.json()["coin_awarded"] == 375
assert r.json()["coin_balance"] == 1125
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
assert r.status_code == 200 and r.json()["coin_awarded"] == 188
# 5. 未知任务 → 404
r = client.post("/api/v1/tasks/no_such_task/claim", headers=_auth(token))
assert r.status_code == 404
def test_task_notification_claim_capped(client) -> None:
"""可重复任务领满(减半到底)后封顶:不再可领 → 409,列表 claimed 置 True。"""
from app.core.rewards import notification_max_claims
token = _login(client, "13800001009")
base = TASK_REWARDS[TASK_ENABLE_NOTIFICATION]
cap = notification_max_claims(base) # 750 → 11
# 领满 cap 次,每次都应 200(最后几次发 1 金币)
last_coin = None
for _ in range(cap):
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
assert r.status_code == 200, r.text
last_coin = r.json()["coin_awarded"]
assert last_coin == 1 # 末次是减半到底的 1
# 再领 → 409(封顶,不再发放)
r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token))
assert r.status_code == 409, r.text
# 列表:领满后 claimed 置 True(客户端据此隐藏)
r = client.get("/api/v1/tasks", headers=_auth(token))
items = {it["task_key"]: it for it in r.json()["items"]}
assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is True
def test_exchange_info(client) -> None:
token = _login(client, "13800001004")
r = client.get("/api/v1/wallet/exchange-info", headers=_auth(token))
assert r.status_code == 200, r.text
body = r.json()
assert body["coin_per_yuan"] == COIN_PER_YUAN
assert body["min_coin"] == MIN_EXCHANGE_COIN
assert body["step_coin"] == COIN_PER_CENT
def test_exchange_flow(client) -> None:
"""先供款够兑 1 元的金币 → 兑换 1 元 → 金币扣、现金加 → 现金流水有记录。
兑换下限已降到 1 分(MIN_EXCHANGE_COIN = COIN_PER_CENT = 100), 但本用例验证兑换 1 元,
直接 grant_coins 注入 COIN_PER_YUAN(= 10000)当种子(够兑 1 元)。
"""
phone = "13800001005"
token = _login(client, phone)
# 供款: 直接注入 1 元额度金币(替代原先靠 notification=10000 领任务供款)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
crud_wallet.grant_coins(db, user.id, COIN_PER_YUAN, biz_type="test_seed", remark="测试供款")
db.commit()
# 兑换 10000 金币 → 100 分
r = client.post(
"/api/v1/wallet/exchange",
json={"coin_amount": COIN_PER_YUAN},
headers=_auth(token),
)
assert r.status_code == 200, r.text
res = r.json()
assert res["coin_amount"] == COIN_PER_YUAN
assert res["cash_added_cents"] == 100
assert res["coin_balance"] == 0
assert res["cash_balance_cents"] == 100
# 账户余额同步
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.json()["coin_balance"] == 0
assert r.json()["cash_balance_cents"] == 100
# 金币流水多了一笔 exchange_out(负)
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
types = [t["biz_type"] for t in r.json()["items"]]
assert "exchange_out" in types
out_txn = next(t for t in r.json()["items"] if t["biz_type"] == "exchange_out")
assert out_txn["amount"] == -COIN_PER_YUAN
# 现金流水有一笔 exchange_in(正)
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
page = r.json()
assert len(page["items"]) == 1
assert page["items"][0]["amount_cents"] == 100
assert page["items"][0]["biz_type"] == "exchange_in"
def test_exchange_min_floor_one_cent(client) -> None:
"""锁定本次下调的兑换下限:正好兑下限额(MIN_EXCHANGE_COIN=100 金币=1 分)应成功。
这是本 PR 的核心新能力(下限 10000→100,可兑 1 分起)。test_exchange_flow 兑的是
1 元(10000),在旧下限下也通过,证明不了新下限;本用例供款并兑换正好等于下限的
100 金币,断言到账 1 分。若有人把 MIN_EXCHANGE_COIN 改回 10000,100 会因低于下限被
判 400,本用例随之失败,从而把新下限值钉死。
"""
phone = "13800001010"
token = _login(client, phone)
# 供款: 正好注入一个下限额度的金币(=COIN_PER_CENT=100)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
crud_wallet.grant_coins(
db, user.id, MIN_EXCHANGE_COIN, biz_type="test_seed", remark="测试供款"
)
db.commit()
# 兑换下限额 100 金币 → 1 分
r = client.post(
"/api/v1/wallet/exchange",
json={"coin_amount": MIN_EXCHANGE_COIN},
headers=_auth(token),
)
assert r.status_code == 200, r.text
res = r.json()
assert res["coin_amount"] == MIN_EXCHANGE_COIN
assert res["cash_added_cents"] == 1 # coins_to_cents(100) == 1
assert res["coin_balance"] == 0
assert res["cash_balance_cents"] == 1
# 金币流水有一笔 exchange_out(=-100)
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
out_txn = next(t for t in r.json()["items"] if t["biz_type"] == "exchange_out")
assert out_txn["amount"] == -MIN_EXCHANGE_COIN
# 现金流水有且仅有一笔 exchange_in(=+1 分)
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
cash = r.json()["items"]
assert len(cash) == 1
assert cash[0]["amount_cents"] == 1
assert cash[0]["biz_type"] == "exchange_in"
def test_exchange_insufficient_and_invalid(client) -> None:
token = _login(client, "13800001006")
# 余额 0 → 兑换 1 元 → 409 余额不足
r = client.post(
"/api/v1/wallet/exchange",
json={"coin_amount": COIN_PER_YUAN},
headers=_auth(token),
)
assert r.status_code == 409
# 低于最小额(下限现为 1 分=100;取 MIN_EXCHANGE_COIN-1=99,既低于下限又非整分)→ 400
r = client.post(
"/api/v1/wallet/exchange",
json={"coin_amount": MIN_EXCHANGE_COIN - 1},
headers=_auth(token),
)
assert r.status_code == 400
# 非整分倍数(带零头)→ 400
r = client.post(
"/api/v1/wallet/exchange",
json={"coin_amount": COIN_PER_YUAN + 1},
headers=_auth(token),
)
assert r.status_code == 400
def _report_savings(client, token: str, n: int, *, original=5000, paid=4200) -> None:
"""上报 n 笔真实比价记录(source='compare'),省额 = original paid。"""
for i in range(n):
client.post(
"/api/v1/order/report",
json={
"client_event_id": f"evt-{token[-6:]}-{i}",
"platform": "美团外卖",
"pay_channel": "wechat",
"compared_price_cents": paid,
"paid_amount_cents": paid,
"original_price_cents": original,
"shop_name": f"测试门店{i}",
"dishes": ["招牌菜"],
},
headers=_auth(token),
)
def test_savings_summary_and_battle(client) -> None:
"""聚合接口对真实上报记录返回真实算出的数字(demo 兜底已停用)。"""
token = _login(client, "13800001007")
_report_savings(client, token, 2) # 2 笔,各省 50004200=800
r = client.get("/api/v1/savings/summary", headers=_auth(token))
assert r.status_code == 200, r.text
s = r.json()
assert s["order_count"] == 2
assert s["total_saved_cents"] == 1600
# 平均每单 = 总额 // 单数
assert s["avg_saved_cents"] == s["total_saved_cents"] // s["order_count"]
r = client.get("/api/v1/savings/battle", headers=_auth(token))
assert r.status_code == 200, r.text
b = r.json()
assert b["streak_days"] >= 1 # 今日上报 → 至少连续 1 天
assert b["week_saved_cents"] >= 0
assert 0 <= b["beat_percent"] <= 100
# 完成比价次数与 summary 的 order_count 同源(均 = 真实记录数)
assert b["compare_count"] == s["order_count"]
def test_savings_empty_for_new_user(client) -> None:
"""新用户无真实上报时全 0(demo 兜底已停用),两次读一致。"""
token = _login(client, "13800001008")
first = client.get("/api/v1/savings/summary", headers=_auth(token)).json()
second = client.get("/api/v1/savings/summary", headers=_auth(token)).json()
assert first == second
assert first["order_count"] == 0
assert first["total_saved_cents"] == 0
def test_savings_records_pagination(client) -> None:
token = _login(client, "13800001009")
_report_savings(client, token, 12) # 12 笔真实记录撑起分页
# 第一页
r = client.get("/api/v1/savings/records?limit=5", headers=_auth(token))
assert r.status_code == 200, r.text
page1 = r.json()
assert len(page1["items"]) == 5
assert page1["next_cursor"] is not None
# id 倒序
ids = [it["id"] for it in page1["items"]]
assert ids == sorted(ids, reverse=True)
# 第二页接着游标走,不与第一页重叠
r = client.get(
f"/api/v1/savings/records?limit=5&cursor={page1['next_cursor']}",
headers=_auth(token),
)
page2 = r.json()
assert max(it["id"] for it in page2["items"]) < min(ids)
def test_endpoints_require_auth(client) -> None:
"""不带 token 的福利接口统一 401。"""
assert client.get("/api/v1/wallet/account").status_code == 401
assert client.get("/api/v1/signin/status").status_code == 401
assert client.post("/api/v1/signin").status_code == 401
assert client.get("/api/v1/tasks").status_code == 401
assert client.get("/api/v1/wallet/cash-transactions").status_code == 401
assert client.post("/api/v1/wallet/exchange", json={"coin_amount": 10000}).status_code == 401
assert client.get("/api/v1/savings/summary").status_code == 401
assert client.get("/api/v1/savings/battle").status_code == 401
assert client.get("/api/v1/savings/records").status_code == 401
def test_grant_coins_persists_trace_id(client) -> None:
"""grant_coins 传 trace_id 落库;不传则为 None。"""
phone = "13800002001"
_login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
_, txn1 = crud_wallet.grant_coins(
db, user.id, 5, biz_type="feed_ad_reward_comparison",
ref_id="evt1", remark="比价奖励", trace_id="trace-A",
)
_, txn2 = crud_wallet.grant_coins(
db, user.id, 30, biz_type="signin", remark="每日签到奖励",
)
db.commit()
assert txn1.trace_id == "trace-A"
assert txn2.trace_id is None
def test_backfill_coin_trace_id_from_ad_record(client) -> None:
"""回填:coin_transaction(trace_id 空)按 ref_id==client_event_id 从 ad_feed_reward_record 补 trace_id;
只补比价/领券两类,无关类型与无匹配的不动。SQL 与迁移 coin_transaction_trace_id 保持同步。"""
phone = "13800002002"
_login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
db.add(AdFeedRewardRecord(
client_event_id="evt-cmp", user_id=user.id, reward_date="2026-08-07",
duration_seconds=10, unit_count=1, ecpm_raw="1000",
feed_scene="comparison", trace_id="trace-CMP", coin=5, status="granted",
))
db.add(AdFeedRewardRecord(
client_event_id="evt-cpn", user_id=user.id, reward_date="2026-08-07",
duration_seconds=10, unit_count=1, ecpm_raw="1000",
feed_scene="coupon", trace_id="trace-CPN", coin=7, status="granted",
))
# 广告行存在但 trace_id 为空(2026-07-15 前的老比价广告):回填必须跳过、保持 NULL(EXISTS 守护)。
db.add(AdFeedRewardRecord(
client_event_id="evt-null", user_id=user.id, reward_date="2026-08-07",
duration_seconds=10, unit_count=1, ecpm_raw="1000",
feed_scene="comparison", trace_id=None, coin=5, status="granted",
))
db.commit()
_, c1 = crud_wallet.grant_coins(db, user.id, 5, biz_type="feed_ad_reward_comparison", ref_id="evt-cmp", remark="比价奖励")
_, c2 = crud_wallet.grant_coins(db, user.id, 7, biz_type="feed_ad_reward_coupon", ref_id="evt-cpn", remark="领券奖励")
_, c3 = crud_wallet.grant_coins(db, user.id, 30, biz_type="signin", remark="每日签到奖励")
_, c4 = crud_wallet.grant_coins(db, user.id, 5, biz_type="feed_ad_reward_comparison", ref_id="evt-null", remark="比价奖励")
db.commit()
assert c1.trace_id is None and c2.trace_id is None
db.execute(text(
"""
UPDATE coin_transaction SET trace_id = (
SELECT r.trace_id FROM ad_feed_reward_record r
WHERE r.client_event_id = coin_transaction.ref_id)
WHERE biz_type IN ('feed_ad_reward_comparison', 'feed_ad_reward_coupon')
AND trace_id IS NULL
AND EXISTS (SELECT 1 FROM ad_feed_reward_record r2
WHERE r2.client_event_id = coin_transaction.ref_id
AND r2.trace_id IS NOT NULL)
"""
))
db.commit()
db.refresh(c1)
db.refresh(c2)
db.refresh(c3)
db.refresh(c4)
assert c1.trace_id == "trace-CMP"
assert c2.trace_id == "trace-CPN"
assert c3.trace_id is None # 无关类型不动
assert c4.trace_id is None # 广告行 trace_id 为空 → EXISTS 守护跳过、保持 NULL
def test_grant_feed_reward_sets_coin_trace_id(client) -> None:
"""grant_feed_reward(comparison) 把 trace_id 透传给 grant_coins,coin_transaction 带上本场 trace_id。"""
phone = "13800002003"
_login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
rec = crud_feed.grant_feed_reward(
db, user.id,
client_event_id="evt-fr-1", ecpm="1000", duration_seconds=10,
feed_scene="comparison", trace_id="trace-FR", display_coin=5,
)
assert rec.status == "granted", rec.status
txn = db.execute(
select(CoinTransaction).where(
CoinTransaction.user_id == user.id,
CoinTransaction.ref_id == "evt-fr-1",
)
).scalar_one()
assert txn.biz_type == "feed_ad_reward_comparison"
assert txn.trace_id == "trace-FR"
def _seed_coin(db, user_id, amount, biz_type, *, trace_id=None, ref_id=None, remark=None):
crud_wallet.grant_coins(
db, user_id, amount, biz_type=biz_type, ref_id=ref_id, remark=remark, trace_id=trace_id
)
def test_coin_transactions_aggregate_by_trace(client) -> None:
"""一次比价的多条广告金币聚合成一条:金额合计、merged_count=条数、balance_after 取最后一条。"""
phone = "13800002004"
token = _login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
_seed_coin(db, user.id, 5, "feed_ad_reward_comparison", trace_id="t1", ref_id="e1", remark="比价奖励")
_seed_coin(db, user.id, 3, "feed_ad_reward_comparison", trace_id="t1", ref_id="e2", remark="比价奖励")
_seed_coin(db, user.id, 4, "feed_ad_reward_comparison", trace_id="t1", ref_id="e3", remark="比价奖励")
db.commit()
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
assert r.status_code == 200, r.text
items = r.json()["items"]
assert len(items) == 1
row = items[0]
assert row["biz_type"] == "feed_ad_reward_comparison"
assert row["amount"] == 12
assert row["merged_count"] == 3
assert row["balance_after"] == 12
def test_coin_transactions_distinct_traces_stay_separate(client) -> None:
"""不同 trace(两次比价 / 一次领券)各成一条;不同会话不合并。"""
phone = "13800002005"
token = _login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
_seed_coin(db, user.id, 5, "feed_ad_reward_comparison", trace_id="cmpA", ref_id="a1", remark="比价奖励")
_seed_coin(db, user.id, 6, "feed_ad_reward_comparison", trace_id="cmpB", ref_id="b1", remark="比价奖励")
_seed_coin(db, user.id, 6, "feed_ad_reward_comparison", trace_id="cmpB", ref_id="b2", remark="比价奖励")
_seed_coin(db, user.id, 7, "feed_ad_reward_coupon", trace_id="cpnC", ref_id="c1", remark="领券奖励")
db.commit()
items = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token)).json()["items"]
assert len(items) == 3
assert sorted(i["amount"] for i in items) == [5, 7, 12]
cpn = next(i for i in items if i["biz_type"] == "feed_ad_reward_coupon")
assert cpn["amount"] == 7 and cpn["merged_count"] == 1
def test_coin_transactions_non_session_rows_stay_per_row(client) -> None:
"""签到 / 无 trace 的通用信息流各自一行,不被聚合;夹在比价广告中间的签到不影响比价聚合。"""
phone = "13800002006"
token = _login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
_seed_coin(db, user.id, 5, "feed_ad_reward_comparison", trace_id="t1", ref_id="e1", remark="比价奖励")
_seed_coin(db, user.id, 30, "signin", remark="每日签到奖励")
_seed_coin(db, user.id, 4, "feed_ad_reward_comparison", trace_id="t1", ref_id="e2", remark="比价奖励")
_seed_coin(db, user.id, 8, "feed_ad_reward", trace_id=None, ref_id="w1", remark="信息流广告奖励")
db.commit()
items = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token)).json()["items"]
assert len(items) == 3
cmp_row = next(i for i in items if i["biz_type"] == "feed_ad_reward_comparison")
assert cmp_row["amount"] == 9 and cmp_row["merged_count"] == 2
signin_row = next(i for i in items if i["biz_type"] == "signin")
assert signin_row["amount"] == 30 and signin_row["merged_count"] == 1
feed_row = next(i for i in items if i["biz_type"] == "feed_ad_reward")
assert feed_row["amount"] == 8 and feed_row["merged_count"] == 1
def test_coin_transactions_pagination_no_phantom_regroup(client) -> None:
"""交错跨游标不产生残组:会话广告成员被其它记录隔开、rep_id 在游标上、成员在游标下时,
翻到下一页该会话不得以「残组」重复出现(锁死 spec §11 反面优化警示)。"""
phone = "13800002007"
token = _login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
_seed_coin(db, user.id, 5, "feed_ad_reward_comparison", trace_id="t1", ref_id="e1", remark="比价奖励") # id=n+1
_seed_coin(db, user.id, 30, "signin", remark="每日签到奖励") # id=n+2
_seed_coin(db, user.id, 40, "signin", remark="每日签到奖励") # id=n+3
_seed_coin(db, user.id, 4, "feed_ad_reward_comparison", trace_id="t1", ref_id="e2", remark="比价奖励") # id=n+4 = t1 的 rep
db.commit()
t1_ids = db.execute(
select(CoinTransaction.id)
.where(CoinTransaction.user_id == user.id, CoinTransaction.trace_id == "t1")
.order_by(CoinTransaction.id)
).scalars().all()
# 第 1 页(limit=2):按 rep 降序 = [t1(rep=n+4, 合计 9), signin(n+3, 40)]
p1 = client.get("/api/v1/wallet/coin-transactions?limit=2", headers=_auth(token)).json()
assert len(p1["items"]) == 2
assert p1["items"][0]["biz_type"] == "feed_ad_reward_comparison"
assert p1["items"][0]["amount"] == 9 and p1["items"][0]["merged_count"] == 2
assert p1["items"][1]["biz_type"] == "signin" and p1["items"][1]["amount"] == 40
assert p1["next_cursor"] is not None
# 前置条件:游标(第1页末条 rep)必须严格落在 t1 两成员 id 之间,才是真正的「跨游标」场景;
# 否则用例会退化成非跨游标、悄悄测错形状仍通过,失去回归守护意义。
assert t1_ids[0] < p1["next_cursor"] < t1_ids[-1]
# 第 2 页:只剩另一条 signin(30);t1 的 rep 在游标上,成员虽在游标下也不得成残组重复
p2 = client.get(
f"/api/v1/wallet/coin-transactions?limit=2&cursor={p1['next_cursor']}",
headers=_auth(token),
).json()
assert len(p2["items"]) == 1
assert p2["items"][0]["biz_type"] == "signin" and p2["items"][0]["amount"] == 30
assert all(i["biz_type"] != "feed_ad_reward_comparison" for i in p2["items"])
assert p2["next_cursor"] is None