"""任务奖励领取端到端(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