Files
meida_backend/tests/integration/test_task_bonus_eligibility.py
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

214 lines
8.0 KiB
Python
Raw Permalink 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.
"""任务换额度完成校验端到端(T2b / FR-BILL-03):40311 凭据闸门 + 定稿数值.
本机 .env 的 TASK_BONUS_RULES_JSON 可能是旧占位目录,本文件用 monkeypatch
把规则钉死为产品方案 §3.2 定稿目录(4 项,invite_friend 下架),断言与
.env 取值解耦。既有 test_task_bonus.py 仍按 .env 现状断言占位行为,两个
文件各管各的口径。
"""
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",
),
]
#: 产品方案 §3.2 定稿目录(2026-09-11):签到+10对话/反馈+2发型/分享+3换装/
#: 首测+1重生成;invite_friend 不下发即下架。
_FINAL_RULES = {
"sign_in": {"quota_key": "agent_chat", "amount": 10, "claim_period": "day"},
"report_feedback": {"quota_key": "hairstyle", "amount": 2, "claim_period": "day"},
"share_report": {"quota_key": "tryon", "amount": 3, "claim_period": "day"},
"first_report": {"quota_key": "report_regen", "amount": 1, "claim_period": "lifetime"},
}
@pytest.fixture
def final_rules(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""把 TASK_BONUS_RULES_JSON 钉为定稿目录(进程 env 优先于 .env)."""
from packages.shared.config import get_settings
monkeypatch.setenv("TASK_BONUS_RULES_JSON", json.dumps(_FINAL_RULES))
get_settings.cache_clear()
try:
yield
finally:
monkeypatch.undo()
get_settings.cache_clear()
@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"elig-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 _grant_rows(user_id: int) -> list[Any]:
"""纯 DB 断言用:直接读 bl_ledger 的 system_grant 流水,不经过 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 _create_report(user_id: int, *, status: str = "succeeded") -> int:
"""直接落一行 rp_report 作为完成凭据(不跑流水线,只借表结构)."""
from packages.domain.ids import next_id
from packages.shared.db import session_scope
from packages.shared.models import RpReport
report_id = next_id()
async with session_scope() as session:
session.add(RpReport(report_id=report_id, user_id=user_id, status=status))
return report_id
async def _create_feedback(user_id: int, report_id: int) -> None:
"""落一条当日 rp_feedback(created_at 走 server_default,即“今天”)."""
from packages.domain.ids import next_id
from packages.shared.db import session_scope
from packages.shared.models import RpFeedback
async with session_scope() as session:
session.add(
RpFeedback(
feedback_id=next_id(),
report_id=report_id,
user_id=user_id,
dimension_key="overall",
verdict="accurate",
)
)
async def test_first_report_requires_completed_report(final_rules: None, client: Any) -> None:
headers, user_id = await _login(client)
# 无任何报告 → 40311,且不写流水(验收:拒绝路径不落 bl_ledger)。
resp = await client.post("/api/v1/quota/tasks/first_report/claim", headers=headers)
assert resp.status_code == 403
assert resp.json()["code"] == 40311
assert await _grant_rows(user_id) == []
# 未完成(pending)的报告不算凭据。
await _create_report(user_id, status="pending")
resp = await client.post("/api/v1/quota/tasks/first_report/claim", headers=headers)
assert resp.status_code == 403
assert resp.json()["code"] == 40311
assert await _grant_rows(user_id) == []
# 有已完成报告 → 200,定稿数值:report_regen +1。
await _create_report(user_id, status="succeeded")
ok_resp = await client.post("/api/v1/quota/tasks/first_report/claim", headers=headers)
assert ok_resp.status_code == 200
data = ok_resp.json()["data"]
assert data["task_key"] == "first_report"
assert data["quota_key"] == "report_regen"
assert data["amount"] == 1
assert data["bonus_granted"] == 1
# lifetime 周期再 claim → 40910。
again = await client.post("/api/v1/quota/tasks/first_report/claim", headers=headers)
assert again.status_code == 409
assert again.json()["code"] == 40910
assert len(await _grant_rows(user_id)) == 1
async def test_report_feedback_requires_same_day_feedback(final_rules: None, client: Any) -> None:
headers, user_id = await _login(client)
# 无反馈 → 40311,不写流水。
resp = await client.post("/api/v1/quota/tasks/report_feedback/claim", headers=headers)
assert resp.status_code == 403
assert resp.json()["code"] == 40311
assert await _grant_rows(user_id) == []
# 提交一条当日反馈后 → 200,定稿数值:hairstyle +2。
report_id = await _create_report(user_id)
await _create_feedback(user_id, report_id)
ok_resp = await client.post("/api/v1/quota/tasks/report_feedback/claim", headers=headers)
assert ok_resp.status_code == 200
data = ok_resp.json()["data"]
assert data["quota_key"] == "hairstyle"
assert data["amount"] == 2
assert data["bonus_granted"] == 2
assert len(await _grant_rows(user_id)) == 1
async def test_sign_in_final_amount_and_same_day_repeat(final_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 == 200
data = resp.json()["data"]
assert data["quota_key"] == "agent_chat"
assert data["amount"] == 10
assert data["bonus_granted"] == 10
assert data["remaining"] == 20 # base_limit 10 + bonus 10
again = await client.post("/api/v1/quota/tasks/sign_in/claim", headers=headers)
assert again.status_code == 409
assert again.json()["code"] == 40910
async def test_share_report_self_reported(final_rules: None, client: Any) -> None:
"""share_report 内测期端侧自报、无服务端校验(§3.2 决策理由 4)."""
headers, _ = await _login(client)
resp = await client.post("/api/v1/quota/tasks/share_report/claim", headers=headers)
assert resp.status_code == 200
data = resp.json()["data"]
assert data["quota_key"] == "tryon"
assert data["amount"] == 3
assert data["bonus_granted"] == 3
async def test_invite_friend_off_shelf_returns_40410(final_rules: None, client: Any) -> None:
"""invite_friend 不在定稿目录:配置化下架,claim 走 40410,不写死分支."""
headers, user_id = await _login(client)
resp = await client.post("/api/v1/quota/tasks/invite_friend/claim", headers=headers)
assert resp.status_code == 404
assert resp.json()["code"] == 40410
assert await _grant_rows(user_id) == []