2026-04-10 12:28:18 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
2026-04-28 17:19:08 +08:00
|
|
|
from datetime import datetime, timezone
|
2026-04-10 12:28:18 +08:00
|
|
|
from decimal import Decimal
|
|
|
|
|
from uuid import UUID, uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from core.config.settings import config
|
2026-04-13 11:28:58 +08:00
|
|
|
from models.register_bonus_claims import RegisterBonusClaims
|
2026-04-10 12:28:18 +08:00
|
|
|
from schemas.domain.points import AppendAuditLedgerCommand, ApplyPointsChangeCommand
|
|
|
|
|
from schemas.domain.points import PointsChargeSnapshot
|
|
|
|
|
from v1.points.service import PointsService
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class _FakeAccount:
|
|
|
|
|
balance: int = 100
|
|
|
|
|
frozen_balance: int = 0
|
|
|
|
|
lifetime_earned: int = 0
|
|
|
|
|
lifetime_spent: int = 0
|
|
|
|
|
version: int = 0
|
|
|
|
|
|
|
|
|
|
|
2026-04-28 17:19:08 +08:00
|
|
|
@dataclass
|
|
|
|
|
class _FakeLedgerRow:
|
|
|
|
|
id: UUID
|
|
|
|
|
direction: int
|
|
|
|
|
amount: int
|
|
|
|
|
balance_after: int
|
|
|
|
|
change_type: str
|
|
|
|
|
created_at: datetime
|
|
|
|
|
|
|
|
|
|
|
2026-04-10 12:28:18 +08:00
|
|
|
class _FakePointsRepository:
|
|
|
|
|
def __init__(self, *, usage_snapshot: PointsChargeSnapshot | None) -> None:
|
|
|
|
|
self.account = _FakeAccount()
|
|
|
|
|
self.usage_snapshot = usage_snapshot
|
|
|
|
|
self.appended_ledger: list[ApplyPointsChangeCommand] = []
|
|
|
|
|
self.appended_audit: list[AppendAuditLedgerCommand] = []
|
2026-04-28 17:19:08 +08:00
|
|
|
self.ledger_rows: list[_FakeLedgerRow] = []
|
2026-04-10 12:28:18 +08:00
|
|
|
self.claimed: bool = False
|
2026-04-13 11:28:58 +08:00
|
|
|
self.claim: RegisterBonusClaims | None = None
|
2026-04-10 12:28:18 +08:00
|
|
|
|
|
|
|
|
async def get_or_create_user_points_for_update(
|
|
|
|
|
self, *, user_id: UUID
|
|
|
|
|
) -> _FakeAccount:
|
|
|
|
|
del user_id
|
|
|
|
|
return self.account
|
|
|
|
|
|
|
|
|
|
async def has_ledger_event(self, *, user_id: UUID, event_id: str) -> bool:
|
|
|
|
|
del user_id, event_id
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
async def append_ledger(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
command: ApplyPointsChangeCommand,
|
|
|
|
|
balance_after: int,
|
|
|
|
|
) -> None:
|
|
|
|
|
del balance_after
|
|
|
|
|
self.appended_ledger.append(command)
|
|
|
|
|
|
|
|
|
|
async def append_audit_ledger(self, *, command: AppendAuditLedgerCommand) -> None:
|
|
|
|
|
self.appended_audit.append(command)
|
|
|
|
|
|
|
|
|
|
async def has_audit_event(self, *, event_id: str) -> bool:
|
|
|
|
|
del event_id
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
async def get_run_usage_snapshot(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
session_id: UUID,
|
|
|
|
|
run_id: str,
|
|
|
|
|
) -> PointsChargeSnapshot | None:
|
|
|
|
|
del session_id, run_id
|
|
|
|
|
return self.usage_snapshot
|
|
|
|
|
|
|
|
|
|
async def claim_register_bonus(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
email_hash: str,
|
|
|
|
|
user_email_snapshot: str,
|
2026-04-13 11:28:58 +08:00
|
|
|
first_user_id_snapshot: UUID,
|
2026-04-10 12:28:18 +08:00
|
|
|
grant_event_id: str,
|
|
|
|
|
) -> bool:
|
2026-04-13 11:28:58 +08:00
|
|
|
del email_hash, user_email_snapshot, first_user_id_snapshot, grant_event_id
|
2026-04-10 12:28:18 +08:00
|
|
|
if self.claimed:
|
|
|
|
|
return False
|
|
|
|
|
self.claimed = True
|
|
|
|
|
return True
|
|
|
|
|
|
2026-04-13 11:28:58 +08:00
|
|
|
async def get_register_bonus_claim(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
email_hash: str,
|
|
|
|
|
) -> RegisterBonusClaims | None:
|
|
|
|
|
del email_hash
|
|
|
|
|
return self.claim
|
|
|
|
|
|
2026-04-28 17:19:08 +08:00
|
|
|
async def list_ledger(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
user_id: UUID,
|
|
|
|
|
limit: int,
|
|
|
|
|
cursor: datetime | None = None,
|
|
|
|
|
) -> tuple[list[_FakeLedgerRow], bool]:
|
|
|
|
|
del user_id, cursor
|
|
|
|
|
return (self.ledger_rows[:limit], len(self.ledger_rows) > limit)
|
|
|
|
|
|
2026-04-10 12:28:18 +08:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_consume_successful_run_points_writes_real_usage_to_audit() -> None:
|
|
|
|
|
usage = PointsChargeSnapshot(
|
|
|
|
|
message_id=uuid4(),
|
|
|
|
|
message_seq=3,
|
|
|
|
|
model_code="doubao-1.5-pro",
|
|
|
|
|
input_tokens=123,
|
|
|
|
|
output_tokens=456,
|
|
|
|
|
cost=Decimal("0.023456"),
|
|
|
|
|
)
|
|
|
|
|
repository = _FakePointsRepository(usage_snapshot=usage)
|
|
|
|
|
service = PointsService(repository=repository) # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
user_id = uuid4()
|
|
|
|
|
session_id = uuid4()
|
|
|
|
|
run_id = "run_123"
|
|
|
|
|
result = await service.consume_successful_run_points(
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
run_id=run_id,
|
|
|
|
|
operator_id=user_id,
|
|
|
|
|
user_email="User@Example.com",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.charged is True
|
|
|
|
|
assert result.amount == 20
|
|
|
|
|
assert repository.account.balance == 80
|
|
|
|
|
assert len(repository.appended_ledger) == 1
|
|
|
|
|
assert len(repository.appended_audit) == 1
|
|
|
|
|
|
|
|
|
|
audit = repository.appended_audit[0]
|
|
|
|
|
assert audit.billed_to == "user"
|
|
|
|
|
assert audit.input_tokens == 123
|
|
|
|
|
assert audit.output_tokens == 456
|
|
|
|
|
assert audit.cost == Decimal("0.023456")
|
|
|
|
|
assert audit.direction == -1
|
|
|
|
|
assert audit.amount == 20
|
|
|
|
|
assert audit.user_email_snapshot == "user@example.com"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_record_failed_run_platform_cost_writes_platform_audit_only() -> None:
|
|
|
|
|
usage = PointsChargeSnapshot(
|
|
|
|
|
message_id=uuid4(),
|
|
|
|
|
message_seq=1,
|
|
|
|
|
model_code="doubao-1.5-pro",
|
|
|
|
|
input_tokens=100,
|
|
|
|
|
output_tokens=20,
|
|
|
|
|
cost=Decimal("0.012300"),
|
|
|
|
|
)
|
|
|
|
|
repository = _FakePointsRepository(usage_snapshot=usage)
|
|
|
|
|
service = PointsService(repository=repository) # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
result = await service.record_failed_run_platform_cost(
|
|
|
|
|
user_id=uuid4(),
|
|
|
|
|
session_id=uuid4(),
|
|
|
|
|
run_id="run_failed",
|
|
|
|
|
operator_id=None,
|
|
|
|
|
user_email="test@example.com",
|
|
|
|
|
failure_kind="failed",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.audited is True
|
|
|
|
|
assert result.cost == Decimal("0.012300")
|
|
|
|
|
assert len(repository.appended_ledger) == 0
|
|
|
|
|
assert len(repository.appended_audit) == 1
|
|
|
|
|
|
|
|
|
|
audit = repository.appended_audit[0]
|
|
|
|
|
assert audit.billed_to == "platform"
|
|
|
|
|
assert audit.direction == 0
|
|
|
|
|
assert audit.amount == 0
|
|
|
|
|
assert audit.cost == Decimal("0.012300")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_grant_register_bonus_if_eligible_first_time_grants() -> None:
|
|
|
|
|
repository = _FakePointsRepository(usage_snapshot=None)
|
|
|
|
|
service = PointsService(repository=repository) # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
result = await service.grant_register_bonus_if_eligible(
|
|
|
|
|
user_id=uuid4(),
|
|
|
|
|
user_email="NewUser@Example.com",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expected_bonus = int(config.points_policy.register_bonus_points)
|
|
|
|
|
assert result.granted is True
|
|
|
|
|
assert result.amount == expected_bonus
|
|
|
|
|
assert repository.account.balance == 100 + expected_bonus
|
|
|
|
|
assert len(repository.appended_ledger) == 1
|
|
|
|
|
assert len(repository.appended_audit) == 1
|
|
|
|
|
assert repository.appended_audit[0].billed_to == "user"
|
|
|
|
|
assert repository.appended_audit[0].change_type.value == "register"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_grant_register_bonus_if_eligible_second_time_skips() -> None:
|
|
|
|
|
repository = _FakePointsRepository(usage_snapshot=None)
|
|
|
|
|
repository.claimed = True
|
|
|
|
|
service = PointsService(repository=repository) # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
result = await service.grant_register_bonus_if_eligible(
|
|
|
|
|
user_id=uuid4(),
|
|
|
|
|
user_email="dup@example.com",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.granted is False
|
|
|
|
|
assert result.amount == 0
|
|
|
|
|
assert len(repository.appended_ledger) == 0
|
|
|
|
|
assert len(repository.appended_audit) == 0
|
2026-04-13 11:28:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_grant_register_bonus_if_eligible_restores_balance_snapshot() -> None:
|
|
|
|
|
repository = _FakePointsRepository(usage_snapshot=None)
|
|
|
|
|
repository.account.balance = 0
|
|
|
|
|
repository.claim = RegisterBonusClaims(
|
|
|
|
|
email_hash="abc",
|
|
|
|
|
user_email_snapshot="restore@example.com",
|
|
|
|
|
first_user_id_snapshot=uuid4(),
|
|
|
|
|
balance_snapshot=35,
|
|
|
|
|
grant_event_id="event",
|
|
|
|
|
)
|
|
|
|
|
service = PointsService(repository=repository) # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
result = await service.grant_register_bonus_if_eligible(
|
|
|
|
|
user_id=uuid4(),
|
|
|
|
|
user_email="restore@example.com",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.granted is False
|
|
|
|
|
assert result.amount == 0
|
|
|
|
|
assert result.balance_after == 35
|
|
|
|
|
assert repository.account.balance == 35
|
|
|
|
|
assert repository.claimed is False
|
|
|
|
|
assert len(repository.appended_ledger) == 0
|
|
|
|
|
assert len(repository.appended_audit) == 0
|
2026-04-28 17:19:08 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_ledger_list_returns_items_and_next_cursor() -> None:
|
|
|
|
|
created_at = datetime(2026, 4, 28, 8, 30, tzinfo=timezone.utc)
|
|
|
|
|
repository = _FakePointsRepository(usage_snapshot=None)
|
|
|
|
|
repository.ledger_rows = [
|
|
|
|
|
_FakeLedgerRow(
|
|
|
|
|
id=uuid4(),
|
|
|
|
|
direction=1,
|
|
|
|
|
amount=60,
|
|
|
|
|
balance_after=160,
|
|
|
|
|
change_type="purchase",
|
|
|
|
|
created_at=created_at,
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
service = PointsService(repository=repository) # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
items, next_cursor, has_more = await service.get_ledger_list(
|
|
|
|
|
user_id=uuid4(),
|
|
|
|
|
limit=1,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert has_more is False
|
|
|
|
|
assert next_cursor is None
|
|
|
|
|
assert len(items) == 1
|
|
|
|
|
assert items[0].amount == 60
|
|
|
|
|
assert items[0].balance_after == 160
|
|
|
|
|
assert items[0].change_type == "purchase"
|
|
|
|
|
assert items[0].created_at == created_at.isoformat()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_ledger_list_sets_next_cursor_when_more_rows_exist() -> None:
|
|
|
|
|
first_created_at = datetime(2026, 4, 28, 8, 30, tzinfo=timezone.utc)
|
|
|
|
|
second_created_at = datetime(2026, 4, 27, 8, 30, tzinfo=timezone.utc)
|
|
|
|
|
repository = _FakePointsRepository(usage_snapshot=None)
|
|
|
|
|
repository.ledger_rows = [
|
|
|
|
|
_FakeLedgerRow(
|
|
|
|
|
id=uuid4(),
|
|
|
|
|
direction=1,
|
|
|
|
|
amount=60,
|
|
|
|
|
balance_after=160,
|
|
|
|
|
change_type="purchase",
|
|
|
|
|
created_at=first_created_at,
|
|
|
|
|
),
|
|
|
|
|
_FakeLedgerRow(
|
|
|
|
|
id=uuid4(),
|
|
|
|
|
direction=-1,
|
|
|
|
|
amount=20,
|
|
|
|
|
balance_after=140,
|
|
|
|
|
change_type="consume",
|
|
|
|
|
created_at=second_created_at,
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
service = PointsService(repository=repository) # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
items, next_cursor, has_more = await service.get_ledger_list(
|
|
|
|
|
user_id=uuid4(),
|
|
|
|
|
limit=1,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert has_more is True
|
|
|
|
|
assert len(items) == 1
|
|
|
|
|
assert next_cursor == first_created_at.isoformat()
|