"""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" )