Files
meida_backend/tests/integration/test_task_bonus.py
T
xsl ecdfbb64c3 feat(billing): task bonus claim endpoint with idempotency and cap
POST /quota/tasks/{task_key}/claim:配置驱动任务注册表、
grant_bonus 纯域函数、ledger 幂等键防重、task_bonus_max 封顶(FR-BILL-03/04)。

实现:packages/domain/billing/quota.py 新增 BonusDecision/grant_bonus(mypy strict,
恰好到顶允许、超一格整体拒绝)、grant_idempotency_key、PERIOD_TYPES;
packages/billing/service.py 新增 claim_task_bonus(幂等快捷路径 → 行锁 → 封顶判定
→ 同事务更新 bonus_granted 并写 system_grant 流水,delta=0 且 balance_after 按
remaining 口径;IntegrityError 并发兜底回滚为 40910);响应模型 TaskBonusClaimResponse
放新建的 packages/billing/schemas.py;路由挂在 apps/api/routers/quota.py,
rules 由 settings.task_bonus_rules 注入(amount 为占位值,产品定稿后改 env)。
测试:新增单测 11 个(封顶边界、amount<=0、不动 used、hard_cap 独立生效、
默认规则形状与 env 覆盖通路);新增集成 4 个(领取+重复 40910+纯 DB 流水断言、
五类任务全部到账、未知任务 40410、amount=11>max=10 → 40310 且无新流水)。
门禁全绿:ruff check / ruff format --check / mypy 两段 / lint-imports;
单测 180 passed,集成 7 passed。

偏差说明:1) 任务文档提到的 BillingService._load_rule 实际名为 _require_rule,
   直接复用现有助手;2) packages/billing/repo.py 无需新增函数(现有
   get_ledger_by_idempotency_key/lock_or_create_quota/add_ledger 已覆盖本任务);
   3) 文档设想的「TASK_BONUS_RULES_JSON 空值容错」经 env 源实测不成立——
   pydantic-settings 对 dict 字段先做 JSON 解码,空白字符串在 Settings 构造时即
   报错(共享校验器/config.py 属禁改,未动);单测改为覆盖受支持的空配置形态 "{}"。
2026-08-14 16:23:14 +08:00

191 lines
6.6 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": 1, # 占位值;正式数值由产品定稿后改 TASK_BONUS_RULES_JSON
"bonus_granted": 1,
"remaining": 11, # base_limit 10 + bonus 1 - used 0
}
# 发放即到账:额度视图立刻可见。
view = await _quota_view(client, headers, "agent_chat")
assert view["bonus_granted"] == 1
assert view["remaining"] == 11
# 同一任务同一周期重复 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 + 1
assert rows[0].idempotency_key.startswith(f"grant:{user_id}:sign_in:")
# 幂等拒绝不改变账面。
view = await _quota_view(client, headers, "agent_chat")
assert view["bonus_granted"] == 1
async def test_all_five_frbill03_tasks_land_on_their_quotas(client: Any) -> None:
headers, user_id = await _login(client)
all_tasks = (
"sign_in",
"profile_complete",
"report_feedback",
"share_report",
"invite_friend",
)
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
# agent_chat 吃两个任务(sign_in + profile_complete);tryon 吃两个;report_regen 一个。
chat = await _quota_view(client, headers, "agent_chat")
assert chat["bonus_granted"] == 2
assert chat["remaining"] == 12
tryon = await _quota_view(client, headers, "tryon")
assert tryon["bonus_granted"] == 2
assert tryon["remaining"] == 5 # base 3 + bonus 2(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) == 5
assert len({row.idempotency_key for row in rows}) == 5
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