"""任务换额度完成校验端到端(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) == []