Files
meida_backend/tests/integration/test_task_bonus.py
T
xsl 76a0502ea3 feat(billing): 任务换额度定稿闭环——claim 凭据校验(40311)+ 定稿 4 任务目录
- claim_task_bonus 在幂等快捷路径前做完成凭据校验:report_feedback 查当日 rp_feedback、first_report 查 succeeded rp_report,无凭据 40311 且不打流水;sign_in/share_report 不校验(产品决策:签到行为即领取,分享内测期接受端侧自报,hard_cap 锁死刷量收益)
- task_bonus_rules 默认值与 .env.example 同步产品定稿(产品方案 §3.2,2026-09-11):sign_in 对话+10/day、report_feedback 发型+2/day、share_report 换装+3/day、first_report 重生成+1/lifetime;下架 invite_friend/profile_complete
- billing/repo 新增只读凭据探测 has_feedback_between/has_completed_report(import-linter 禁 billing→report,直查 shared.models 的 rp_* 表,永远只读)
- 测试:test_task_bonus_eligibility 5 用例(40311 两分支/凭据后 200/40910/40410/零流水)

偏差说明:test_task_bonus 与 test_task_bonus_rules 断言数值随定稿更新(发放/幂等/封顶语义未动);quota.py docstring「占位值」说法顺手更正。
2026-09-12 00:23:43 +08:00

215 lines
7.7 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.
"""任务奖励领取端到端(FR-BILL-03):发放、幂等、封顶、流水.
只依赖 PostgreSQL(额度与账本都在库里),不经过 Redis,也不触发任何 LLM
调用 —— 与 test_vertical_slice.py 的 FakeProvider 思路一致:集成用例验证
配额链路,不该花钱也不该受网络影响。
"""
from __future__ import annotations
import json
import os
import uuid
from collections.abc import AsyncIterator, Iterator
from typing import Any
import pytest
def _infra_available() -> bool:
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not _infra_available(),
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
),
]
@pytest.fixture
async def client() -> AsyncIterator[Any]:
import httpx
from apps.api.main import create_app
from packages.shared.config import get_settings
app = create_app(get_settings())
transport = httpx.ASGITransport(app=app)
async with (
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
app.router.lifespan_context(app),
):
yield http
async def _login(client: Any) -> tuple[dict[str, str], int]:
"""dev-token:每个用例用独立 device_key,拿到的就是全新用户."""
resp = await client.post(
"/api/v1/auth/dev-token", json={"device_key": f"bonus-it-{uuid.uuid4().hex[:8]}"}
)
assert resp.json()["code"] == 0
data = resp.json()["data"]
return {"Authorization": f"Bearer {data['access_token']}"}, int(data["user_id"])
async def _system_grant_rows(user_id: int) -> list[Any]:
"""纯 DB 断言用:直接读 bl_ledger,不经过 HTTP 视图."""
from sqlalchemy import select
from packages.shared.db import session_scope
from packages.shared.models import Ledger
async with session_scope() as session:
result = await session.execute(
select(Ledger).where(Ledger.user_id == user_id, Ledger.tx_type == "system_grant")
)
return list(result.scalars().all())
async def _quota_view(client: Any, headers: dict[str, str], quota_key: str) -> dict[str, Any]:
resp = await client.get("/api/v1/quota", headers=headers, params={"quota_key": quota_key})
assert resp.json()["code"] == 0
data: dict[str, Any] = resp.json()["data"]
return data
async def test_claim_grants_immediately_and_repeats_are_rejected(client: Any) -> None:
headers, user_id = await _login(client)
claim = await client.post("/api/v1/quota/tasks/sign_in/claim", headers=headers)
assert claim.status_code == 200
body = claim.json()
assert body["code"] == 0
assert body["data"] == {
"task_key": "sign_in",
"quota_key": "agent_chat",
"amount": 10, # §3.2 定稿值(T2b);改 TASK_BONUS_RULES_JSON 即生效
"bonus_granted": 10,
"remaining": 20, # base_limit 10 + bonus 10 - used 0
}
# 发放即到账:额度视图立刻可见。
view = await _quota_view(client, headers, "agent_chat")
assert view["bonus_granted"] == 10
assert view["remaining"] == 20
# 同一任务同一周期重复 claim → 40910(FR-BILL-03 发放幂等)。
again = await client.post("/api/v1/quota/tasks/sign_in/claim", headers=headers)
assert again.status_code == 409
assert again.json()["code"] == 40910
# 纯 DB 断言:恰好一条 system_grant 流水,balance_after = base + bonus。
rows = await _system_grant_rows(user_id)
assert len(rows) == 1
assert rows[0].delta == 0
assert rows[0].status == "committed"
assert rows[0].reason == "sign_in"
assert rows[0].ref_type == "task_bonus"
assert rows[0].balance_after == 10 + 10
assert rows[0].idempotency_key.startswith(f"grant:{user_id}:sign_in:")
# 幂等拒绝不改变账面。
view = await _quota_view(client, headers, "agent_chat")
assert view["bonus_granted"] == 10
async def test_final_catalog_tasks_land_on_their_quotas(client: Any) -> None:
headers, user_id = await _login(client)
# T2b 起 report_feedback 需当日 rp_feedback 凭据、first_report 需已完成
# rp_report 凭据(否则 40311):先落齐两行。本用例聚焦「各任务落到各自
# 配额」的发放语义,不覆盖凭据闸门(见 test_task_bonus_eligibility.py)。
from packages.domain.ids import next_id
from packages.shared.db import session_scope
from packages.shared.models import RpFeedback, RpReport
async with session_scope() as session:
report_id = next_id()
session.add(RpReport(report_id=report_id, user_id=user_id, status="succeeded"))
session.add(
RpFeedback(
feedback_id=next_id(),
report_id=report_id,
user_id=user_id,
dimension_key="overall",
verdict="accurate",
)
)
# §3.2 定稿目录(T2b):invite_friend/profile_complete 已下架,不在 env。
all_tasks = (
"sign_in",
"report_feedback",
"share_report",
"first_report",
)
for task_key in all_tasks:
resp = await client.post(f"/api/v1/quota/tasks/{task_key}/claim", headers=headers)
assert resp.status_code == 200, task_key
assert resp.json()["code"] == 0, task_key
# 定稿数值:sign_in→agent_chat+10、report_feedback→hairstyle+2、
# share_report→tryon+3、first_report→report_regen+1,各自落各自配额。
chat = await _quota_view(client, headers, "agent_chat")
assert chat["bonus_granted"] == 10
assert chat["remaining"] == 20
hairstyle = await _quota_view(client, headers, "hairstyle")
assert hairstyle["bonus_granted"] == 2
assert hairstyle["remaining"] == 5 # base 3 + bonus 2(task_bonus_max=2 触顶即止)
tryon = await _quota_view(client, headers, "tryon")
assert tryon["bonus_granted"] == 3
assert tryon["remaining"] == 6 # base 3 + bonus 3(task_bonus_max=3 未触顶)
regen = await _quota_view(client, headers, "report_regen")
assert regen["bonus_granted"] == 1
assert regen["remaining"] == 2
rows = await _system_grant_rows(user_id)
assert len(rows) == 4
assert len({row.idempotency_key for row in rows}) == 4
async def test_unknown_task_returns_40410(client: Any) -> None:
headers, _ = await _login(client)
resp = await client.post("/api/v1/quota/tasks/not_a_real_task/claim", headers=headers)
assert resp.status_code == 404
assert resp.json()["code"] == 40410
@pytest.fixture
def capped_rules(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""把 sign_in 的 amount 覆盖成 11(> agent_chat 的 task_bonus_max=10).
monkeypatch.setenv + get_settings.cache_clear()(结束恢复),与 T1 的
settings 覆盖写法一致;依赖该 fixture 的用例会把 client 建在覆盖之后。
"""
from packages.shared.config import get_settings
monkeypatch.setenv(
"TASK_BONUS_RULES_JSON",
json.dumps({"sign_in": {"quota_key": "agent_chat", "amount": 11, "claim_period": "day"}}),
)
get_settings.cache_clear()
try:
yield
finally:
monkeypatch.undo()
get_settings.cache_clear()
async def test_claim_over_the_cap_is_rejected_without_any_ledger(
capped_rules: None, client: Any
) -> None:
headers, user_id = await _login(client)
resp = await client.post("/api/v1/quota/tasks/sign_in/claim", headers=headers)
assert resp.status_code == 403
assert resp.json()["code"] == 40310
# 拒绝路径不写任何流水,bonus 也不动(FR-BILL-03 验收 3)。
assert await _system_grant_rows(user_id) == []
view = await _quota_view(client, headers, "agent_chat")
assert view["bonus_granted"] == 0