- 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「占位值」说法顺手更正。
215 lines
7.7 KiB
Python
215 lines
7.7 KiB
Python
"""任务奖励领取端到端(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
|