"""看激励视频发奖测试(穿山甲 S2S 回调)。

回调无 JWT,靠验签——测试用配置里的 mock 密钥自签自验。覆盖:发奖到账、幂等、
验签失败、每日上限、未知用户、客户端进度查询、回调未配置。
"""
from __future__ import annotations

import json

from sqlalchemy import select

from app.core.config import settings
from app.integrations import pangle
from app.core.rewards import (
    DAILY_AD_REWARD_LIMIT,
    VIDEO_ROUND_COOLDOWN_SECONDS,
    VIDEO_ROUND_REQUIRED_COUNT,
    calculate_ad_reward_coin,
)
from app.db.session import SessionLocal
from app.models.ad_reward import AdRewardRecord
from app.models.user import User


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 _user_id(phone: str) -> int:
    db = SessionLocal()
    try:
        return db.execute(select(User.id).where(User.phone == phone)).scalar_one()
    finally:
        db.close()


def _signed(user_id: int, trans_id: str, **extra: str) -> dict[str, str]:
    """构造带合法签名的回调参数(模拟穿山甲服务器)。sign 只对 trans_id 签(穿山甲规范)。"""
    params = {"user_id": str(user_id), "trans_id": trans_id, **extra}
    params["sign"] = pangle.build_sign(trans_id, settings.PANGLE_REWARD_SECRET)
    return params


def _callback(client, params: dict[str, str]):
    return client.get("/api/v1/ad/pangle-callback", params=params)


def _coin_balance(client, token: str) -> int:
    return client.get("/api/v1/wallet/account", headers=_auth(token)).json()["coin_balance"]


def test_callback_grants_coins(client) -> None:
    """验签通过且带 eCPM → 按数值公式发金币到账 + 计数 +1。"""
    phone = "13800003001"
    token = _login(client, phone)
    uid = _user_id(phone)

    r = _callback(client, _signed(uid, "trans_a1", reward_name="金币", ecpm="200"))
    assert r.status_code == 200, r.text
    assert r.json() == {"is_verify": True, "reason": 0}

    assert _coin_balance(client, token) == calculate_ad_reward_coin("200", 1)

    st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
    assert st["used_today"] == 1
    assert st["daily_limit"] == DAILY_AD_REWARD_LIMIT
    assert st["remaining"] == DAILY_AD_REWARD_LIMIT - 1
    assert st["coin_per_ad"] == 0


def test_callback_without_ecpm_records_exception(client) -> None:
    """S2S 和客户端会话都没有 eCPM → 不发金币,只记录异常状态。"""
    phone = "13800003011"
    token = _login(client, phone)
    uid = _user_id(phone)

    assert _callback(client, _signed(uid, "ecpm_missing")).status_code == 200
    assert _coin_balance(client, token) == 0
    st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
    assert st["used_today"] == 0


def test_callback_idempotent(client) -> None:
    """同一 trans_id 二次回调 → 只发一次金币(穿山甲重试不重复发)。"""
    phone = "13800003002"
    token = _login(client, phone)
    uid = _user_id(phone)

    p = _signed(uid, "trans_dup", ecpm="200")
    assert _callback(client, p).status_code == 200
    assert _callback(client, p).status_code == 200  # 重试

    assert _coin_balance(client, token) == calculate_ad_reward_coin("200", 1)  # 没翻倍
    st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
    assert st["used_today"] == 1


def test_callback_bad_sign(client) -> None:
    """签名错误 → 403,不发金币。"""
    phone = "13800003003"
    token = _login(client, phone)
    uid = _user_id(phone)

    params = {"user_id": str(uid), "trans_id": "trans_bad", "sign": "deadbeef"}
    r = _callback(client, params)
    assert r.status_code == 403, r.text
    assert _coin_balance(client, token) == 0


def test_daily_count_cap(client, monkeypatch) -> None:
    """达到每日发奖**次数兜底上限**后继续回调 → 受理但不发(capped),余额封顶。

    次数上限现为时长闸的兜底(默认 200),这里 monkeypatch 调小到 3 加速(不真跑 200 次);
    本用例不上报观看时长 → 时长主闸不触发,纯验次数兜底。
    """
    # 次数兜底现走 app_config getter(rebase 合 main 的运营可配后);patch getter 调小到 3
    monkeypatch.setattr("app.core.rewards.get_ad_daily_limit", lambda db: 3)
    phone = "13800003004"
    token = _login(client, phone)
    uid = _user_id(phone)

    for i in range(3):
        r = _callback(client, _signed(uid, f"trans_cap_{i}", ecpm="200"))
        assert r.status_code == 200, r.text

    # 第 4 次:capped,仍 is_verify(不让穿山甲重试),但不加币
    r = _callback(client, _signed(uid, "trans_cap_over", ecpm="200"))
    assert r.status_code == 200
    assert r.json() == {"is_verify": True, "reason": 0}

    assert _coin_balance(client, token) == sum(calculate_ad_reward_coin("200", i) for i in range(1, 4))
    st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
    assert st["used_today"] == 3
    assert st["remaining"] == 0


def test_daily_watch_time_cap(client, monkeypatch) -> None:
    """看广告累计观看时长达每日上限(主闸,这里调小到 100s)后,再回调发奖 → capped 不发金币。

    时长由前端 watch-report 上报累计;到顶后 ① watch-report/reward-status remaining=0(客户端不再展示),
    ② 后端发奖也因时长闸记 capped(双闸,前端绕不过)。次数兜底不动它(此时只看了 1 次,远没到次数上限)。
    """
    # 时长上限两处引用都要 patch:api 用 rewards.X、grant 闸/today_status 用 ad_reward 内绑定的 X
    monkeypatch.setattr("app.core.rewards.DAILY_AD_WATCH_SECONDS_LIMIT", 100)
    monkeypatch.setattr("app.repositories.ad_reward.DAILY_AD_WATCH_SECONDS_LIMIT", 100)
    phone = "13800003201"
    token = _login(client, phone)
    uid = _user_id(phone)

    # 上报一次 100s 观看 → 当日累计达上限,remaining=0
    r = client.post("/api/v1/ad/watch-report", json={"seconds": 100}, headers=_auth(token))
    assert r.status_code == 200, r.text
    assert r.json()["watched_seconds_today"] == 100
    assert r.json()["watch_seconds_remaining"] == 0

    # 此时回调发奖 → 时长闸命中 → capped(is_verify 仍 true,不让重试),不加金币
    before = _coin_balance(client, token)
    r = _callback(client, _signed(uid, "trans_time_cap", ecpm="200"))
    assert r.status_code == 200
    assert r.json() == {"is_verify": True, "reason": 0}
    assert _coin_balance(client, token) == before  # 未加币

    st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
    assert st["watched_seconds_today"] == 100
    assert st["watch_seconds_limit"] == 100
    assert st["watch_seconds_remaining"] == 0


def test_callback_unknown_user(client) -> None:
    """验签过但 user_id 不存在 → 不发奖(is_verify false + reason),不崩。"""
    params = _signed(999999, "trans_ghost")
    r = _callback(client, params)
    assert r.status_code == 200, r.text
    assert r.json() == {"is_verify": False, "reason": 2}


def test_callback_foreign_env_skipped(client) -> None:
    """回调 srv_env 与本服 APP_ENV 不符(测试包的 S2S 打到生产)→ 受理但不发币、不串号。

    防的是跨库串号:同一 user_id 在测试库 / 正式库是两个人,回调地址只配生产,测试包(dev)看广告
    的 S2S 也会打到生产。测试环境 APP_ENV=dev:srv_env=prod 视为「别的环境」→ 跳过,余额不变、
    不写记录;srv_env=dev(本环境)照常发。srv_env 走 gromoreExtra 透传(穿山甲带回 mediaExtra 的字段)。
    兼容:不带 srv_env 的旧客户端按本环境处理(其余用例已覆盖)。
    """
    phone = "13800003501"
    token = _login(client, phone)
    uid = _user_id(phone)

    # 外环境(prod)回调:受理(is_verify=true 防穿山甲重试)但不发币、不留记录
    foreign = _signed(uid, "trans_foreign_env", ecpm="200",
                      gromoreExtra=json.dumps({"srv_env": "prod"}))
    r = _callback(client, foreign)
    assert r.status_code == 200, r.text
    assert r.json() == {"is_verify": True, "reason": 0}
    assert _coin_balance(client, token) == 0
    st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
    assert st["used_today"] == 0

    # 本环境(dev)回调:照常发币
    same = _signed(uid, "trans_same_env", ecpm="200",
                   gromoreExtra=json.dumps({"srv_env": "dev"}))
    r = _callback(client, same)
    assert r.status_code == 200, r.text
    assert _coin_balance(client, token) == calculate_ad_reward_coin("200", 1)
    st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
    assert st["used_today"] == 1


def test_reward_status_requires_auth(client) -> None:
    """客户端进度接口需登录。"""
    r = client.get("/api/v1/ad/reward-status")
    assert r.status_code == 401


def test_reward_status_initial_round_state(client) -> None:
    """新用户没看广告 → round_count=0, cooldown_until=None。"""
    phone = "13800003101"
    token = _login(client, phone)
    st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
    assert st["used_today"] == 0
    assert st["round_count"] == 0
    assert st["cooldown_until"] is None


def test_reward_status_mid_round(client) -> None:
    """当前 round_size=1:看 1 次 → round_count=0, cooldown_until 进入 3 秒短冷却。"""
    phone = "13800003102"
    token = _login(client, phone)
    uid = _user_id(phone)
    _callback(client, _signed(uid, "trans_mid_1", ecpm="200"))
    st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
    assert st["used_today"] == 1
    assert st["round_count"] == 0
    assert st["cooldown_until"] is not None


def test_reward_status_round_complete_enters_cooldown(client) -> None:
    """刚看完一条广告 → round_count=0(下一轮起点) + cooldown_until 是未来 ~3 秒。"""
    from datetime import datetime, timezone

    phone = "13800003103"
    token = _login(client, phone)
    uid = _user_id(phone)
    for i in range(VIDEO_ROUND_REQUIRED_COUNT):
        _callback(client, _signed(uid, f"trans_rc_{i}", ecpm="200"))

    st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
    assert st["used_today"] == VIDEO_ROUND_REQUIRED_COUNT
    assert st["round_count"] == 0
    assert st["cooldown_until"] is not None
    cd = datetime.fromisoformat(st["cooldown_until"].replace("Z", "+00:00"))
    if cd.tzinfo is None:
        cd = cd.replace(tzinfo=timezone.utc)
    now = datetime.now(timezone.utc)
    delta = (cd - now).total_seconds()
    # 配置 3s,允许 ±30s 容差(本机 / CI 慢 IO)
    assert 0 < delta <= VIDEO_ROUND_COOLDOWN_SECONDS + 30


def test_reward_status_cooldown_expired(client) -> None:
    """看完一条广告后冷却已过(手动改 created_at 到冷却前)→ cooldown_until 回到 None。"""
    from datetime import datetime, timedelta, timezone
    from sqlalchemy import select, update

    phone = "13800003104"
    token = _login(client, phone)
    uid = _user_id(phone)
    for i in range(VIDEO_ROUND_REQUIRED_COUNT):
        _callback(client, _signed(uid, f"trans_exp_{i}", ecpm="200"))

    # 把当日所有 granted 记录的 created_at 推到 11 分钟前(覆盖冷却末尾那条)
    db = SessionLocal()
    try:
        eleven_min_ago = datetime.now(timezone.utc) - timedelta(seconds=VIDEO_ROUND_COOLDOWN_SECONDS + 60)
        db.execute(
            update(AdRewardRecord)
            .where(AdRewardRecord.user_id == uid)
            .values(created_at=eleven_min_ago)
        )
        db.commit()
    finally:
        db.close()

    st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
    assert st["used_today"] == VIDEO_ROUND_REQUIRED_COUNT
    assert st["round_count"] == 0
    assert st["cooldown_until"] is None


def test_feed_reward_grants_by_10_second_units(client) -> None:
    """信息流广告完成后:每满 10 秒一份奖励,同一 client_event_id 幂等。"""
    phone = "13800003301"
    token = _login(client, phone)

    payload = {
        "client_event_id": "feed_evt_0001",
        "ecpm": "200",
        "duration_seconds": 30,
        "adn": "pangle",
        "slot_id": "slot_feed",
        "display_coin": 4,
    }
    r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token))
    assert r.status_code == 200, r.text
    body = r.json()
    expected = 4
    assert body["granted"] is True
    assert body["status"] == "granted"
    assert body["unit_count"] == 3
    assert body["coin"] == expected
    assert _coin_balance(client, token) == expected

    # 重试同一个事件不重复发
    r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token))
    assert r.status_code == 200, r.text
    assert r.json()["coin"] == expected
    assert _coin_balance(client, token) == expected


def test_feed_reward_daily_display_cap(client, monkeypatch) -> None:
    """信息流展示次数到每日上限后继续完成 → capped,不发金币。"""
    monkeypatch.setattr("app.core.rewards.get_ad_daily_limit", lambda db: 1)
    phone = "13800003302"
    token = _login(client, phone)

    first = {
        "client_event_id": "feed_evt_cap_1",
        "ecpm": "201",
        "duration_seconds": 10,
    }
    second = {
        "client_event_id": "feed_evt_cap_2",
        "ecpm": "201",
        "duration_seconds": 10,
    }
    assert client.post("/api/v1/ad/feed-reward", json=first, headers=_auth(token)).status_code == 200
    before = _coin_balance(client, token)
    r = client.post("/api/v1/ad/feed-reward", json=second, headers=_auth(token))
    assert r.status_code == 200, r.text
    assert r.json()["granted"] is False
    assert r.json()["status"] == "capped"
    assert r.json()["coin"] == 0
    assert _coin_balance(client, token) == before


def test_callback_disabled_returns_503(client, monkeypatch) -> None:
    """未配置回调(开关关)时 → 503。"""
    monkeypatch.setattr(settings, "PANGLE_CALLBACK_ENABLED", False)
    # 503 发生在验签/发奖之前,不需要真实用户
    r = _callback(client, _signed(1, "trans_disabled"))
    assert r.status_code == 503, r.text


# ===== 按 ad_session_id 查权威发奖结果(GET /reward-result/{ad_session_id})=====
# 客户端看完广告轮询它拿弹窗金额:只认 status='granted' 且 coin>0,其余一律不弹。


def _reward_result(client, token: str, session_id: str):
    return client.get(f"/api/v1/ad/reward-result/{session_id}", headers=_auth(token))


def _session_extra(session_id: str, **kv: str) -> str:
    return json.dumps({"ad_session_id": session_id, **kv})


def test_reward_result_pending_when_s2s_not_arrived(client) -> None:
    """S2S 还没回调 → 200 + pending(**不是 404**),客户端据此继续轮询。"""
    token = _login(client, "13800003601")

    r = _reward_result(client, token, "sess-not-yet-arrived")
    assert r.status_code == 200, r.text
    assert r.json() == {
        "ad_session_id": "sess-not-yet-arrived",
        "status": "pending",
        "coin": None,
        # 没记录 → 连属于哪一轮都不知道,累计值一并为 null(不是 0,0 会被读成"本轮没赚到")
        "round_coin": None,
    }


def test_reward_result_returns_granted_coin(client) -> None:
    """S2S 发奖后按会话查 → granted + 本次真实到账额(与钱包入账一致)。"""
    phone = "13800003602"
    token = _login(client, phone)
    uid = _user_id(phone)
    session_id = "sess-granted-1"

    r = _callback(
        client,
        _signed(uid, "trans_rr_1", ecpm="200", extra=_session_extra(session_id)),
    )
    assert r.json() == {"is_verify": True, "reason": 0}

    expected = calculate_ad_reward_coin("200", 1)
    body = _reward_result(client, token, session_id).json()
    assert body["status"] == "granted"
    assert body["coin"] == expected
    # 弹窗金额必须等于真实入账,这正是本接口存在的意义(不用余额差估算)
    assert _coin_balance(client, token) == expected


def test_reward_result_prefers_granted_over_earlier_noshow(client) -> None:
    """竞态:客户端先报 closed_early、S2S 随后才到 → 同一会话两条记录,必须返回 granted 那条。

    只按 created_at 取最近一条是不够的(SQLite 下两条可能同一时间戳),故仓储层显式优先 granted。
    """
    phone = "13800003603"
    token = _login(client, phone)
    uid = _user_id(phone)
    session_id = "sess-race-noshow"

    # 1) 客户端以为没发奖,先留痕
    r = client.post(
        "/api/v1/ad/reward-noshow",
        json={"ad_session_id": session_id, "watched_seconds": 3},
        headers=_auth(token),
    )
    assert r.status_code == 200, r.text
    assert r.json()["status"] == "closed_early"
    assert _reward_result(client, token, session_id).json()["status"] == "closed_early"

    # 2) S2S 姗姗来迟,真发了钱
    _callback(client, _signed(uid, "trans_rr_race", ecpm="200", extra=_session_extra(session_id)))

    body = _reward_result(client, token, session_id).json()
    assert body["status"] == "granted"
    assert body["coin"] == calculate_ad_reward_coin("200", 1)


def test_reward_result_capped_reports_zero_not_popup(client) -> None:
    """达每日上限 → capped + coin=0;客户端不弹「获得 0 金币」。"""
    phone = "13800003604"
    token = _login(client, phone)
    uid = _user_id(phone)
    session_id = "sess-capped-1"

    db = SessionLocal()
    try:
        db.add(
            AdRewardRecord(
                trans_id="trans_rr_capped", user_id=_user_id(phone), coin=0, status="capped",
                reward_scene="reward_video", ad_session_id=session_id, reward_date="2026-07-17",
            )
        )
        db.commit()
    finally:
        db.close()
    assert uid  # 记录挂在该用户名下

    body = _reward_result(client, token, session_id).json()
    assert body["status"] == "capped"
    assert body["coin"] == 0


def test_reward_result_scoped_to_owner(client) -> None:
    """别人的会话查不到(按 user_id 收窄)→ pending,不泄漏他人发奖结果。"""
    phone_a = "13800003605"
    token_a = _login(client, phone_a)
    uid_a = _user_id(phone_a)
    token_b = _login(client, "13800003606")
    session_id = "sess-owner-only"

    _callback(client, _signed(uid_a, "trans_rr_owner", ecpm="200", extra=_session_extra(session_id)))

    assert _reward_result(client, token_a, session_id).json()["status"] == "granted"
    assert _reward_result(client, token_b, session_id).json()["status"] == "pending"


def test_reward_result_requires_auth(client) -> None:
    """无 Bearer → 401,不裸奔。"""
    assert client.get("/api/v1/ad/reward-result/sess-anon-1").status_code == 401


# ===== 膨胀轮累计(boost_round_id → reward-result.round_coin)=====
# 不变量:弹窗数字 == 本轮实际到账之和 == 余额涨幅。三者对不上用户就认为少发了钱。


def _round_extra(session_id: str, round_id: str | None = None, **kv: str) -> str:
    data = {"ad_session_id": session_id, **kv}
    if round_id is not None:
        data["boost_round_id"] = round_id
    return json.dumps(data)


def test_round_coin_accumulates_across_ads_in_same_round(client) -> None:
    """一轮连看两条 → round_coin 逐条累计,且等于余额涨幅(第七节验收 1、2 步)。"""
    phone = "13800003701"
    token = _login(client, phone)
    uid = _user_id(phone)
    round_id = "b7e1c93a4f6d802b"

    _callback(client, _signed(
        uid, "trans_round_1", ecpm="200", extra=_round_extra("sess-r1-a", round_id)))
    first = calculate_ad_reward_coin("200", 1)
    body = _reward_result(client, token, "sess-r1-a").json()
    assert body["coin"] == first
    assert body["round_coin"] == first  # 第 1 条:本轮累计 == 本条

    _callback(client, _signed(
        uid, "trans_round_2", ecpm="200", extra=_round_extra("sess-r1-b", round_id)))
    second = calculate_ad_reward_coin("200", 2)  # LT 因子递减,第 2 条比第 1 条少
    body = _reward_result(client, token, "sess-r1-b").json()
    assert body["coin"] == second
    assert body["round_coin"] == first + second  # 累计 = 两条之和

    # 弹窗数字必须等于真实余额涨幅 —— 这条不变量是整个方案的目的
    assert _coin_balance(client, token) == first + second


def test_new_round_restarts_accumulation(client) -> None:
    """换新轮 id → round_coin 从头累计,不接着上一轮往上加(第七节最后一句验收)。"""
    phone = "13800003702"
    token = _login(client, phone)
    uid = _user_id(phone)

    _callback(client, _signed(uid, "trans_r2_old", ecpm="200", extra=_round_extra("sess-r2-a", "round-old")))
    old = _reward_result(client, token, "sess-r2-a").json()["round_coin"]
    assert old > 0

    _callback(client, _signed(uid, "trans_r2_new", ecpm="200", extra=_round_extra("sess-r2-b", "round-new")))
    body = _reward_result(client, token, "sess-r2-b").json()
    assert body["round_coin"] == body["coin"]  # 新轮 = 只有本条
    assert body["round_coin"] != old + body["coin"]


def test_round_coin_null_without_round_id(client) -> None:
    """extra 没带 boost_round_id(老客户端 / GroMore 丢字段)→ round_coin=null,客户端退回显示单条。"""
    phone = "13800003703"
    token = _login(client, phone)
    uid = _user_id(phone)

    _callback(client, _signed(uid, "trans_r3", ecpm="200", extra=_round_extra("sess-r3-noround", None)))
    body = _reward_result(client, token, "sess-r3-noround").json()
    assert body["coin"] == calculate_ad_reward_coin("200", 1)
    assert body["round_coin"] is None


def test_round_coin_null_when_pending(client) -> None:
    """S2S 未到账 → 没有记录 → 连轮 id 都不知道,round_coin 也是 null(不是 0)。"""
    token = _login(client, "13800003704")
    body = _reward_result(client, token, "sess-r4-pending").json()
    assert body == {
        "ad_session_id": "sess-r4-pending",
        "status": "pending",
        "coin": None,
        "round_coin": None,
    }


def test_round_coin_returned_on_capped(client) -> None:
    """撞每日上限那条不是 granted,但 round_coin **仍返本轮累计**(该条按 0 计)。

    客户端的限额 toast 要显示前面几条已到账的总额,不能是空。
    """
    phone = "13800003705"
    token = _login(client, phone)
    uid = _user_id(phone)
    round_id = "round-capped"

    _callback(client, _signed(uid, "trans_cap_ok", ecpm="200", extra=_round_extra("sess-cap-a", round_id)))
    earned = _reward_result(client, token, "sess-cap-a").json()["round_coin"]
    assert earned > 0

    # 手插一条同轮的 capped 记录(跑满 500 次太慢),模拟第 N 条撞上限
    db = SessionLocal()
    try:
        db.add(AdRewardRecord(
            trans_id="trans_cap_hit", user_id=uid, coin=0, status="capped",
            reward_scene="reward_video", ad_session_id="sess-cap-b",
            reward_date="2026-07-20", boost_round_id=round_id,
        ))
        db.commit()
    finally:
        db.close()

    body = _reward_result(client, token, "sess-cap-b").json()
    assert body["status"] == "capped"
    assert body["coin"] == 0          # 这条没发钱
    assert body["round_coin"] == earned  # 但本轮累计照常返回


def test_round_coin_scoped_to_owner(client) -> None:
    """轮 id 是客户端生成的,不能跨用户信任:拿别人的轮 id 查不到别人的金币。"""
    phone_a = "13800003706"
    token_a = _login(client, phone_a)
    uid_a = _user_id(phone_a)
    phone_b = "13800003707"
    token_b = _login(client, phone_b)
    uid_b = _user_id(phone_b)
    shared_round = "round-collision"

    _callback(client, _signed(uid_a, "trans_own_a", ecpm="200", extra=_round_extra("sess-own-a", shared_round)))
    a_total = _reward_result(client, token_a, "sess-own-a").json()["round_coin"]

    # B 用同一个轮 id(伪造或碰撞)看一条:B 的累计里不能混进 A 的钱
    _callback(client, _signed(uid_b, "trans_own_b", ecpm="200", extra=_round_extra("sess-own-b", shared_round)))
    b_body = _reward_result(client, token_b, "sess-own-b").json()
    assert b_body["round_coin"] == b_body["coin"]
    assert b_body["round_coin"] < a_total + b_body["coin"]


def test_test_grant_accepts_boost_round_id(client, monkeypatch) -> None:
    """debug 的 test-grant 不经 S2S、拿不到 mediaExtra,轮 id 由 body 补 → 本地也能验累计。"""
    monkeypatch.setattr(settings, "AD_REWARD_TEST_GRANT_ENABLED", True)
    token = _login(client, "13800003708")
    round_id = "round-testgrant"

    coins = []
    for i in range(2):
        r = client.post(
            "/api/v1/ad/test-grant",
            json={"reward_scene": "reward_video", "boost_round_id": round_id,
                  "ad_session_id": f"sess-tg-{i}-padding"},
            headers=_auth(token),
        )
        assert r.status_code == 200, r.text
        coins.append(r.json()["coin"])

    body = _reward_result(client, token, "sess-tg-1-padding").json()
    assert body["round_coin"] == sum(coins)
