Files
meida_backend/tests/unit/test_task_bonus_rules.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

63 lines
2.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.
"""settings.task_bonus_rules 的形状与 env 通路(FR-BILL-03/04).
协调提交已把 ``task_bonus_rules`` 纳入 ``_parse_json_object`` 共享校验器,
解析逻辑不再单独测;这里测默认值形状(每条配置必须能被 claim 路径安全消费)
以及集成测试要依赖的 ``TASK_BONUS_RULES_JSON`` 覆盖通路。
"""
from __future__ import annotations
import json
import pytest
from packages.domain.billing.quota import PERIOD_TYPES
from packages.shared.config import Settings
#: FR-BILL-03 的五类任务:签到、完善资料、反馈、分享、邀请。
_FR_BILL_03_TASKS = (
"sign_in",
"profile_complete",
"report_feedback",
"share_report",
"invite_friend",
)
def _rules_without_env() -> dict[str, dict[str, object]]:
# _env_file=None:不读 .env,只看进程环境,保证断言的是代码内默认值。
return dict(Settings(_env_file=None).task_bonus_rules)
def test_default_rules_cover_all_five_frbill03_tasks() -> None:
rules = _rules_without_env()
for task_key in _FR_BILL_03_TASKS:
assert task_key in rules, task_key
def test_every_default_rule_entry_has_a_valid_shape() -> None:
for task_key, conf in _rules_without_env().items():
assert isinstance(conf, dict), task_key
quota_key = conf.get("quota_key")
assert isinstance(quota_key, str) and quota_key, task_key
amount = conf.get("amount")
assert isinstance(amount, int) and not isinstance(amount, bool), task_key
assert amount > 0, task_key # 占位值 1,正式数值由产品定稿后改 env
assert conf.get("claim_period") in PERIOD_TYPES, task_key
def test_rules_json_env_reaches_settings_via_alias(monkeypatch: pytest.MonkeyPatch) -> None:
# 集成测试用同一通路把 amount 覆盖成超限值验证封顶,这里先证明通路本身可用。
raw = json.dumps({"sign_in": {"quota_key": "agent_chat", "amount": 11, "claim_period": "day"}})
monkeypatch.setenv("TASK_BONUS_RULES_JSON", raw)
settings = Settings(_env_file=None)
assert settings.task_bonus_rules["sign_in"]["amount"] == 11
def test_empty_rules_object_env_means_no_tasks(monkeypatch: pytest.MonkeyPatch) -> None:
# 关闭任务奖励的受支持形态是 "{}":解析为空注册表,所有 claim 返回 40410,
# 服务照常启动。(空白字符串不是合法 JSON,pydantic-settings 在 Settings
# 构造时就地失败——那是共享校验器的行为,不归本任务改。)
monkeypatch.setenv("TASK_BONUS_RULES_JSON", "{}")
assert Settings(_env_file=None).task_bonus_rules == {}