merge: T2 任务奖励领取(POST /quota/tasks/{task_key}/claim,FR-BILL-03)
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
"""grant_bonus 纯域函数:封顶边界与不变量(FR-BILL-03)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.billing import quota
|
||||
|
||||
|
||||
def test_grant_up_to_the_cap_exactly_is_allowed() -> None:
|
||||
# bonus + amount == max:恰好到顶,允许。
|
||||
decision = quota.grant_bonus(quota.QuotaState(used=0, base_limit=10, bonus=9), 1, 10)
|
||||
assert decision.allowed
|
||||
assert decision.bonus == 10
|
||||
assert decision.effective_limit == 20
|
||||
assert decision.remaining == 20
|
||||
|
||||
|
||||
def test_grant_one_over_the_cap_is_rejected_as_a_whole() -> None:
|
||||
# bonus + amount == max + 1:整体拒绝,不做部分发放。
|
||||
state = quota.QuotaState(used=4, base_limit=10, bonus=9)
|
||||
decision = quota.grant_bonus(state, 2, 10)
|
||||
assert not decision.allowed
|
||||
# 拒绝时保持发放前的值 —— 调用方不得据此落库。
|
||||
assert decision.bonus == 9
|
||||
assert decision.remaining == state.remaining
|
||||
assert decision.effective_limit == state.effective_limit
|
||||
|
||||
|
||||
def test_zero_cap_rejects_every_grant() -> None:
|
||||
# hd_export / report_basic 的 task_bonus_max=0:任务奖励一律拒绝。
|
||||
decision = quota.grant_bonus(quota.QuotaState(used=0, base_limit=1), 1, 0)
|
||||
assert not decision.allowed
|
||||
|
||||
|
||||
def test_grant_rejects_non_positive_amounts() -> None:
|
||||
state = quota.QuotaState(used=0, base_limit=10)
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
quota.grant_bonus(state, 0, 10)
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
quota.grant_bonus(state, -1, 10)
|
||||
|
||||
|
||||
def test_grant_never_touches_used_or_base_limit() -> None:
|
||||
# 任务奖励抬高的是额度不是用量:remaining 的增量恰好等于 amount。
|
||||
state = quota.QuotaState(used=7, base_limit=10, bonus=2)
|
||||
decision = quota.grant_bonus(state, 3, 10)
|
||||
assert decision.allowed
|
||||
assert decision.remaining - state.remaining == 3
|
||||
assert decision.bonus == 5
|
||||
|
||||
|
||||
def test_task_bonus_cap_is_independent_of_hard_cap() -> None:
|
||||
# 两道天花板独立生效:task_bonus_max 放行、hard_cap 把 effective_limit 削回来。
|
||||
state = quota.QuotaState(used=0, base_limit=3, bonus=3, hard_cap=5)
|
||||
decision = quota.grant_bonus(state, 3, 10)
|
||||
assert decision.allowed # bonus 6 <= task_bonus_max 10
|
||||
assert decision.effective_limit == 5 # base 3 + bonus 6 被 hard_cap 削到 5
|
||||
assert decision.remaining == 5
|
||||
|
||||
|
||||
def test_grant_key_is_derived_per_user_task_and_claim_period() -> None:
|
||||
key = quota.grant_idempotency_key(
|
||||
user_id=7, task_key="sign_in", claim_period_key="d:2026-08-14"
|
||||
)
|
||||
assert key == "grant:7:sign_in:d:2026-08-14"
|
||||
# 派生键确定:同一周期重复 claim 得到同一个键,唯一约束才能兜底。
|
||||
assert key == quota.grant_idempotency_key(
|
||||
user_id=7, task_key="sign_in", claim_period_key="d:2026-08-14"
|
||||
)
|
||||
# 换周期换键:下个周期可以再领。
|
||||
assert key != quota.grant_idempotency_key(
|
||||
user_id=7, task_key="sign_in", claim_period_key="d:2026-08-15"
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""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 == {}
|
||||
Reference in New Issue
Block a user