Files
meida_backend/tests/unit/test_quota_domain.py
T
xsl 2009aa938c fix(billing): make quota seed and column names match the product SSOT
The product plan is the only source for quota numbers, but the 0001 seed
used a second set (agent_chat 20/50, tryon hard 10) and omitted hairstyle,
hd_export and report regen. Migration 0003 replaces the seed and renames
points_reserved → quota_reserved and bonus_value → bonus_granted so the
docs and the schema stop contradicting each other.

Effective limit now clips at hard_cap. CI runs the integration slice
against the Postgres/Redis services it already starts.
2026-08-14 11:28:35 +08:00

121 lines
4.4 KiB
Python
Executable File

"""Quota three-phase arithmetic (架构文档 §7.4, FR-BILL-01/05/06)."""
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from packages.domain.billing import quota
from packages.domain.errors import QuotaExceededError
def test_period_key_day_uses_utc_plus_8() -> None:
# 17:00Z is already 01:00 the next day in UTC+8.
assert quota.period_key(datetime(2026, 8, 13, 17, 0, tzinfo=UTC), "day") == "d:2026-08-14"
assert quota.period_key(datetime(2026, 8, 13, 15, 0, tzinfo=UTC), "day") == "d:2026-08-13"
def test_period_key_month_and_lifetime() -> None:
now = datetime(2026, 8, 13, 1, 0, tzinfo=UTC)
assert quota.period_key(now, "month") == "m:2026-08"
assert quota.period_key(now, "lifetime") == "life"
def test_unknown_period_type_is_rejected() -> None:
with pytest.raises(ValueError, match="unknown period_type"):
quota.period_key(datetime.now(UTC), "weekly") # type: ignore[arg-type]
def test_period_reset_at_rolls_to_the_next_local_midnight() -> None:
reset = quota.period_reset_at(datetime(2026, 8, 13, 1, 0, tzinfo=UTC), "day")
assert reset is not None
assert reset == datetime(2026, 8, 13, 16, 0, tzinfo=UTC) # 2026-08-14 00:00 +08
def test_period_reset_at_handles_december_rollover() -> None:
reset = quota.period_reset_at(datetime(2026, 12, 20, 1, 0, tzinfo=UTC), "month")
assert reset is not None
assert reset.year == 2026 # 2027-01-01 00:00 +08 == 2026-12-31 16:00 UTC
assert reset.month == 12
def test_lifetime_quota_never_resets() -> None:
assert quota.period_reset_at(datetime.now(UTC), "lifetime") is None
def test_task_bonus_raises_the_effective_limit() -> None:
state = quota.QuotaState(used=20, base_limit=20, bonus=3)
assert state.effective_limit == 23
assert state.remaining == 3
assert quota.evaluate(state, 1).allowed
def test_reserve_freezes_the_amount() -> None:
state = quota.reserve(quota.QuotaState(used=0, base_limit=20), 1)
assert state.used == 1
assert state.remaining == 19
def test_reserve_beyond_the_limit_is_rejected_server_side() -> None:
state = quota.QuotaState(used=20, base_limit=20)
with pytest.raises(QuotaExceededError) as excinfo:
quota.reserve(state, 1)
assert excinfo.value.remaining == 0
assert excinfo.value.required == 1
def test_reserve_rejects_a_batch_that_would_overshoot() -> None:
with pytest.raises(QuotaExceededError):
quota.reserve(quota.QuotaState(used=18, base_limit=20), 3)
def test_non_positive_amounts_are_programming_errors() -> None:
with pytest.raises(ValueError, match="positive"):
quota.evaluate(quota.QuotaState(used=0, base_limit=1), 0)
with pytest.raises(ValueError, match="positive"):
quota.refund(quota.QuotaState(used=1, base_limit=1), -1)
def test_refund_releases_the_reservation() -> None:
reserved = quota.reserve(quota.QuotaState(used=5, base_limit=20), 1)
assert quota.refund(reserved, 1).used == 5
def test_refund_never_drives_usage_negative() -> None:
assert quota.refund(quota.QuotaState(used=0, base_limit=20), 5).used == 0
def test_balance_after_records_remaining_quota() -> None:
assert quota.balance_after(quota.QuotaState(used=3, base_limit=20)) == 17
def test_refund_key_is_derived_so_a_retry_cannot_double_refund() -> None:
reserve_key = quota.reserve_idempotency_key(
user_id=7, quota_key="agent_chat", request_key="req-1"
)
assert reserve_key == "rsv:7:agent_chat:req-1"
assert quota.refund_idempotency_key(reserve_key) == "rfd:rsv:7:agent_chat:req-1"
assert quota.refund_idempotency_key(reserve_key) == quota.refund_idempotency_key(reserve_key)
def test_ledger_can_recompute_the_balance() -> None:
"""流水是真相,余额是缓存 (架构文档 §6.1 原则 4)."""
state = quota.QuotaState(used=0, base_limit=20)
deltas = []
for _ in range(3):
state = quota.reserve(state, 1)
deltas.append(-1)
state = quota.refund(state, 1)
deltas.append(1)
assert state.used == -sum(deltas)
def test_hard_cap_clips_base_plus_bonus() -> None:
# 产品方案 §3.2:有效额度 = min(base + bonus, hard_cap)
state = quota.QuotaState(used=0, base_limit=10, bonus=10, hard_cap=40)
assert state.effective_limit == 20
clipped = quota.QuotaState(used=0, base_limit=10, bonus=40, hard_cap=40)
assert clipped.effective_limit == 40
assert not quota.evaluate(clipped, 41).allowed