fix: preserve points balance across account re-registration

Persist a per-email balance snapshot before account deletion and restore it on same-email re-registration, preventing both unintended balance reset and repeated signup bonus grants.
This commit is contained in:
qzl
2026-04-13 11:28:58 +08:00
parent ed8c2e3058
commit c55be6d3fd
9 changed files with 223 additions and 21 deletions
@@ -170,7 +170,7 @@ async def test_register_run_delete_reregister_keeps_bonus_single_use(
)
reregister_balance.raise_for_status()
re_data = reregister_balance.json()
assert int(re_data["balance"]) == 0
assert int(re_data["balance"]) == int(after_data["balance"])
async with AsyncSessionLocal() as session:
points2 = (
@@ -217,3 +217,54 @@ async def test_register_run_delete_reregister_keeps_bonus_single_use(
).scalars()
)
assert len(claim_rows) == 1
assert claim_rows[0].balance_snapshot == int(after_data["balance"])
@pytest.mark.asyncio
async def test_register_delete_reregister_restores_unspent_balance(
api_client: httpx.AsyncClient,
test_identity: IdentityData,
) -> None:
email = str(test_identity["email"]).strip().lower()
bonus = int(config.points_policy.register_bonus_points)
first = await _create_email_session(
api_client,
email=email,
code=str(test_identity["code"]),
)
user1 = first.get("user")
assert isinstance(user1, dict)
user1_id = str(user1["id"])
token1 = str(first["access_token"])
headers1 = {"Authorization": f"Bearer {token1}"}
first_balance = await api_client.get("/api/v1/points/balance", headers=headers1)
first_balance.raise_for_status()
first_data = first_balance.json()
assert int(first_data["balance"]) == bonus
delete_resp = await api_client.delete("/api/v1/users/me", headers=headers1)
assert delete_resp.status_code == 204
second = await _create_email_session(
api_client,
email=email,
code=str(test_identity["code"]),
)
user2 = second.get("user")
assert isinstance(user2, dict)
user2_id = str(user2["id"])
assert user1_id != user2_id
token2 = str(second["access_token"])
headers2 = {"Authorization": f"Bearer {token2}"}
reregister_balance = await api_client.get(
"/api/v1/points/balance", headers=headers2
)
reregister_balance.raise_for_status()
re_data = reregister_balance.json()
assert int(re_data["balance"]) == bonus
cleanup_resp = await api_client.delete("/api/v1/users/me", headers=headers2)
assert cleanup_resp.status_code == 204
@@ -7,6 +7,7 @@ from uuid import UUID, uuid4
import pytest
from core.config.settings import config
from models.register_bonus_claims import RegisterBonusClaims
from schemas.domain.points import AppendAuditLedgerCommand, ApplyPointsChangeCommand
from schemas.domain.points import PointsChargeSnapshot
from v1.points.service import PointsService
@@ -28,6 +29,7 @@ class _FakePointsRepository:
self.appended_ledger: list[ApplyPointsChangeCommand] = []
self.appended_audit: list[AppendAuditLedgerCommand] = []
self.claimed: bool = False
self.claim: RegisterBonusClaims | None = None
async def get_or_create_user_points_for_update(
self, *, user_id: UUID
@@ -69,15 +71,23 @@ class _FakePointsRepository:
*,
email_hash: str,
user_email_snapshot: str,
first_user_id: UUID,
first_user_id_snapshot: UUID,
grant_event_id: str,
) -> bool:
del email_hash, user_email_snapshot, first_user_id, grant_event_id
del email_hash, user_email_snapshot, first_user_id_snapshot, grant_event_id
if self.claimed:
return False
self.claimed = True
return True
async def get_register_bonus_claim(
self,
*,
email_hash: str,
) -> RegisterBonusClaims | None:
del email_hash
return self.claim
@pytest.mark.asyncio
async def test_consume_successful_run_points_writes_real_usage_to_audit() -> None:
@@ -188,3 +198,30 @@ async def test_grant_register_bonus_if_eligible_second_time_skips() -> None:
assert result.amount == 0
assert len(repository.appended_ledger) == 0
assert len(repository.appended_audit) == 0
@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
@@ -10,7 +10,7 @@ from v1.users.service import UserService
class _NoopRepository:
pass
session = None
class _FakeStorage:
@@ -28,7 +28,7 @@ class _FakeStorage:
@pytest.mark.asyncio
async def test_delete_account_success_calls_storage_cleanup_and_auth_delete() -> None:
user = CurrentUser(id=uuid4(), email="test@example.com")
user = CurrentUser(id=uuid4(), email=None)
storage = _FakeStorage()
service = UserService(
current_user=user,
@@ -46,7 +46,7 @@ async def test_delete_account_success_calls_storage_cleanup_and_auth_delete() ->
async def test_delete_account_raises_profile_delete_failed_on_storage_cleanup_error() -> (
None
):
user = CurrentUser(id=uuid4(), email="test@example.com")
user = CurrentUser(id=uuid4(), email=None)
class _FailingStorage(_FakeStorage):
async def delete_prefix(self, *, bucket: str, prefix: str) -> int:
@@ -72,7 +72,7 @@ async def test_delete_account_raises_profile_delete_failed_on_storage_cleanup_er
async def test_delete_account_raises_profile_delete_failed_on_auth_delete_error() -> (
None
):
user = CurrentUser(id=uuid4(), email="test@example.com")
user = CurrentUser(id=uuid4(), email=None)
class _FailingStorage(_FakeStorage):
async def delete_auth_user(self, *, user_id: str) -> None: