Files
eryao/backend/tests/unit/payments/test_payment_service.py
T

618 lines
23 KiB
Python
Raw Normal View History

from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID, uuid4
import pytest
from core.http.errors import ApiProblemError
from models.apple_iap_transaction import AppleIapTransaction
from models.register_bonus_claims import RegisterBonusClaims
from schemas.domain.points import ApplyPointsChangeCommand
from v1.payments.apple_verifier import VerificationError, VerifiedTransaction
from v1.payments.schemas import VerifyTransactionRequest
from v1.payments.service import PaymentService
@dataclass
class _FakeAccount:
balance: int = 0
frozen_balance: int = 0
lifetime_earned: int = 0
lifetime_spent: int = 0
version: int = 0
class _FakePaymentRepository:
def __init__(self, *, existing_transaction: AppleIapTransaction | None = None) -> None:
self.account = _FakeAccount()
self.existing_transaction = existing_transaction
self.inserted_transactions: list[AppleIapTransaction] = []
self.claim: RegisterBonusClaims | None = None
self.claim_starter_pack_called: bool = False
async def get_or_create_user_points_for_update(self, *, user_id: UUID) -> _FakeAccount:
return self.account
async def get_transaction_by_transaction_id(self, *, transaction_id: str) -> AppleIapTransaction | None:
return self.existing_transaction
async def insert_transaction(self, *, transaction: AppleIapTransaction) -> None:
self.inserted_transactions.append(transaction)
async def get_register_bonus_claim(self, *, email_hash: str) -> RegisterBonusClaims | None:
return self.claim
async def upsert_register_bonus_claim_for_starter_pack(
self, *, email_hash: str, user_email_snapshot: str, first_user_id_snapshot: UUID
) -> RegisterBonusClaims:
self.claim_starter_pack_called = True
if self.claim is None:
self.claim = RegisterBonusClaims(
email_hash=email_hash,
user_email_snapshot=user_email_snapshot,
first_user_id_snapshot=first_user_id_snapshot,
grant_event_id="starter_pack_purchase:test",
has_purchased_starter_pack=True,
)
else:
self.claim.has_purchased_starter_pack = True
return self.claim
class _FakePointsRepository:
def __init__(self) -> None:
self.appended_ledger: list[ApplyPointsChangeCommand] = []
async def append_ledger(self, *, command: ApplyPointsChangeCommand, balance_after: int) -> None:
self.appended_ledger.append(command)
class _FakeVerifier:
def __init__(self, *, result: VerifiedTransaction | VerificationError) -> None:
self._result = result
def verify_signed_transaction(
self,
signed_transaction_info: str,
*,
expected_bundle_id: str,
expected_product_id: str,
) -> VerifiedTransaction | VerificationError:
return self._result
def _make_verified_transaction(
*,
transaction_id: str = "2000000123456789",
product_id: str = "com.meeyao.qianwen.basic_pack",
environment: str = "Sandbox",
) -> VerifiedTransaction:
return VerifiedTransaction(
transaction_id=transaction_id,
original_transaction_id=transaction_id,
web_order_line_item_id=None,
bundle_id="com.meeyao.qianwen",
product_id=product_id,
purchase_date=1700000000000,
revocation_date=None,
environment=environment,
app_account_token=None,
raw_payload={},
)
class TestPaymentServiceProductNotFound:
@pytest.mark.asyncio
async def test_raises_product_not_found(self) -> None:
service = PaymentService(
payment_repo=_FakePaymentRepository(),
points_repo=_FakePointsRepository(),
verifier=_FakeVerifier(result=_make_verified_transaction()),
)
request = VerifyTransactionRequest(
productCode="nonexistent_pack",
appStoreProductId="com.meeyao.qianwen.nonexistent",
transactionId="2000000123456789",
signedTransactionInfo="fake_jws",
)
with pytest.raises(ApiProblemError) as exc_info:
await service.verify_and_grant(
user_id=uuid4(),
user_email="test@example.com",
request=request,
)
assert exc_info.value.code == "PAYMENT_PRODUCT_NOT_FOUND"
class TestPaymentServiceProductMismatch:
@pytest.mark.asyncio
async def test_raises_product_mismatch_when_ids_differ(self) -> None:
service = PaymentService(
payment_repo=_FakePaymentRepository(),
points_repo=_FakePointsRepository(),
verifier=_FakeVerifier(result=_make_verified_transaction()),
)
request = VerifyTransactionRequest(
productCode="basic_pack",
appStoreProductId="com.meeyao.qianwen.wrong_pack",
transactionId="2000000123456789",
signedTransactionInfo="fake_jws",
)
with pytest.raises(ApiProblemError) as exc_info:
await service.verify_and_grant(
user_id=uuid4(),
user_email="test@example.com",
request=request,
)
assert exc_info.value.code == "PAYMENT_PRODUCT_MISMATCH"
class TestPaymentServiceVerificationFailed:
@pytest.mark.asyncio
async def test_raises_when_verifier_returns_error(self) -> None:
service = PaymentService(
payment_repo=_FakePaymentRepository(),
points_repo=_FakePointsRepository(),
verifier=_FakeVerifier(
result=VerificationError(
code="PAYMENT_TRANSACTION_INVALID",
detail="bad signature",
)
),
)
request = VerifyTransactionRequest(
productCode="basic_pack",
appStoreProductId="com.meeyao.qianwen.basic_pack",
transactionId="2000000123456789",
signedTransactionInfo="fake_jws",
)
with pytest.raises(ApiProblemError) as exc_info:
await service.verify_and_grant(
user_id=uuid4(),
user_email="test@example.com",
request=request,
)
assert exc_info.value.code == "PAYMENT_TRANSACTION_INVALID"
class TestPaymentServiceAlreadyGranted:
@pytest.mark.asyncio
async def test_returns_already_granted_for_same_user(self) -> None:
user_id = uuid4()
existing = AppleIapTransaction(
id=uuid4(),
user_id=user_id,
product_code="basic_pack",
app_store_product_id="com.meeyao.qianwen.basic_pack",
transaction_id="2000000123456789",
original_transaction_id="2000000123456789",
environment="Sandbox",
bundle_id="com.meeyao.qianwen",
purchase_date="1700000000000",
status="granted",
credits=100,
signed_transaction_info="fake",
apple_payload_json={},
ledger_event_id="payment.apple_iap:2000000123456789",
)
service = PaymentService(
payment_repo=_FakePaymentRepository(existing_transaction=existing),
points_repo=_FakePointsRepository(),
verifier=_FakeVerifier(result=_make_verified_transaction()),
)
request = VerifyTransactionRequest(
productCode="basic_pack",
appStoreProductId="com.meeyao.qianwen.basic_pack",
transactionId="2000000123456789",
signedTransactionInfo="fake_jws",
)
result = await service.verify_and_grant(
user_id=user_id,
user_email="test@example.com",
request=request,
)
assert result.status == "already_granted"
assert result.credits_added == 0
class TestPaymentServiceTransactionConflict:
@pytest.mark.asyncio
async def test_raises_conflict_for_different_user(self) -> None:
existing = AppleIapTransaction(
id=uuid4(),
user_id=uuid4(),
product_code="basic_pack",
app_store_product_id="com.meeyao.qianwen.basic_pack",
transaction_id="2000000123456789",
original_transaction_id="2000000123456789",
environment="Sandbox",
bundle_id="com.meeyao.qianwen",
purchase_date="1700000000000",
status="granted",
credits=100,
signed_transaction_info="fake",
apple_payload_json={},
ledger_event_id="payment.apple_iap:2000000123456789",
)
service = PaymentService(
payment_repo=_FakePaymentRepository(existing_transaction=existing),
points_repo=_FakePointsRepository(),
verifier=_FakeVerifier(result=_make_verified_transaction()),
)
request = VerifyTransactionRequest(
productCode="basic_pack",
appStoreProductId="com.meeyao.qianwen.basic_pack",
transactionId="2000000123456789",
signedTransactionInfo="fake_jws",
)
with pytest.raises(ApiProblemError) as exc_info:
await service.verify_and_grant(
user_id=uuid4(),
user_email="test@example.com",
request=request,
)
assert exc_info.value.code == "PAYMENT_TRANSACTION_CONFLICT"
class TestPaymentServiceSuccessfulGrant:
@pytest.mark.asyncio
async def test_grants_credits_for_new_transaction(self) -> None:
payment_repo = _FakePaymentRepository()
points_repo = _FakePointsRepository()
service = PaymentService(
payment_repo=payment_repo,
points_repo=points_repo,
verifier=_FakeVerifier(result=_make_verified_transaction()),
)
request = VerifyTransactionRequest(
productCode="basic_pack",
appStoreProductId="com.meeyao.qianwen.basic_pack",
transactionId="2000000123456789",
signedTransactionInfo="fake_jws",
)
result = await service.verify_and_grant(
user_id=uuid4(),
user_email="test@example.com",
request=request,
)
assert result.status == "granted"
assert result.credits_added == 100
assert result.new_balance == 100
assert result.ledger_event_id == "payment.apple_iap:2000000123456789"
assert len(points_repo.appended_ledger) == 1
assert len(payment_repo.inserted_transactions) == 1
class TestPaymentServiceStarterPackIneligible:
@pytest.mark.asyncio
async def test_raises_when_starter_pack_already_purchased(self) -> None:
claim = RegisterBonusClaims(
email_hash="fake_hash",
user_email_snapshot="test@example.com",
first_user_id_snapshot=uuid4(),
grant_event_id="register.bonus:test",
has_purchased_starter_pack=True,
)
payment_repo = _FakePaymentRepository()
payment_repo.claim = claim
service = PaymentService(
payment_repo=payment_repo,
points_repo=_FakePointsRepository(),
verifier=_FakeVerifier(
result=_make_verified_transaction(
product_id="com.meeyao.qianwen.new_user_pack"
)
),
)
request = VerifyTransactionRequest(
productCode="new_user_pack",
appStoreProductId="com.meeyao.qianwen.new_user_pack",
transactionId="2000000123456789",
signedTransactionInfo="fake_jws",
)
with pytest.raises(ApiProblemError) as exc_info:
await service.verify_and_grant(
user_id=uuid4(),
user_email="test@example.com",
request=request,
)
assert exc_info.value.code == "PAYMENT_STARTER_PACK_INELIGIBLE"
class TestPaymentServiceStarterPackSuccess:
@pytest.mark.asyncio
async def test_grants_starter_pack_and_updates_claim(self) -> None:
payment_repo = _FakePaymentRepository()
service = PaymentService(
payment_repo=payment_repo,
points_repo=_FakePointsRepository(),
verifier=_FakeVerifier(
result=_make_verified_transaction(
product_id="com.meeyao.qianwen.new_user_pack"
)
),
)
request = VerifyTransactionRequest(
productCode="new_user_pack",
appStoreProductId="com.meeyao.qianwen.new_user_pack",
transactionId="2000000123456789",
signedTransactionInfo="fake_jws",
)
result = await service.verify_and_grant(
user_id=uuid4(),
user_email="test@example.com",
request=request,
)
assert result.status == "granted"
assert result.credits_added == 60
assert payment_repo.claim_starter_pack_called
class _FakeAccountForRefund:
def __init__(self, balance: int = 100, lifetime_earned: int = 100) -> None:
self.balance: int = balance
self.frozen_balance: int = 0
self.lifetime_earned: int = lifetime_earned
self.lifetime_spent: int = 0
self.version: int = 1
class _FakePaymentRepoForRefund:
def __init__(
self,
*,
transaction: AppleIapTransaction | None = None,
account: _FakeAccountForRefund | None = None,
) -> None:
self._transaction = transaction
self.account = account or _FakeAccountForRefund()
self.inserted_transactions: list[AppleIapTransaction] = []
async def get_transaction_by_transaction_id(self, *, transaction_id: str) -> AppleIapTransaction | None:
return self._transaction
async def get_user_points_for_update(self, *, user_id: UUID) -> _FakeAccountForRefund:
return self.account
async def get_or_create_user_points_for_update(self, *, user_id: UUID) -> _FakeAccountForRefund:
return self.account
async def insert_transaction(self, *, transaction: AppleIapTransaction) -> None:
self.inserted_transactions.append(transaction)
async def get_register_bonus_claim(self, *, email_hash: str) -> None:
return None
async def upsert_register_bonus_claim_for_starter_pack(
self, *, email_hash: str, user_email_snapshot: str, first_user_id_snapshot: UUID
) -> None:
pass
class TestProcessRefundUnknownTransaction:
@pytest.mark.asyncio
async def test_skips_silently_for_unknown_transaction(self) -> None:
service = PaymentService(
payment_repo=_FakePaymentRepoForRefund(transaction=None),
points_repo=_FakePointsRepository(),
verifier=_FakeVerifier(result=_make_verified_transaction()),
)
await service.process_refund(transaction_id="nonexistent")
class TestProcessRefundNotGranted:
@pytest.mark.asyncio
async def test_skips_for_non_granted_transaction(self) -> None:
txn = AppleIapTransaction(
id=uuid4(),
user_id=uuid4(),
product_code="basic_pack",
app_store_product_id="com.meeyao.qianwen.basic_pack",
transaction_id="2000000999999999",
original_transaction_id="2000000999999999",
environment="Sandbox",
bundle_id="com.meeyao.qianwen",
purchase_date="1700000000000",
status="verified",
credits=100,
signed_transaction_info="fake",
apple_payload_json={},
)
service = PaymentService(
payment_repo=_FakePaymentRepoForRefund(transaction=txn),
points_repo=_FakePointsRepository(),
verifier=_FakeVerifier(result=_make_verified_transaction()),
)
await service.process_refund(transaction_id="2000000999999999")
assert txn.status == "verified"
class TestProcessRefundSufficientBalance:
@pytest.mark.asyncio
async def test_deducts_credits_and_writes_refund_ledger(self) -> None:
user_id = uuid4()
txn = AppleIapTransaction(
id=uuid4(),
user_id=user_id,
product_code="basic_pack",
app_store_product_id="com.meeyao.qianwen.basic_pack",
transaction_id="2000000999999999",
original_transaction_id="2000000999999999",
environment="Sandbox",
bundle_id="com.meeyao.qianwen",
purchase_date="1700000000000",
status="granted",
credits=100,
signed_transaction_info="fake",
apple_payload_json={},
ledger_event_id="payment.apple_iap:2000000999999999",
)
account = _FakeAccountForRefund(balance=150, lifetime_earned=200)
points_repo = _FakePointsRepository()
service = PaymentService(
payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=account),
points_repo=points_repo,
verifier=_FakeVerifier(result=_make_verified_transaction()),
)
await service.process_refund(transaction_id="2000000999999999")
assert txn.status == "refunded"
assert account.balance == 50
assert account.lifetime_earned == 100
assert len(points_repo.appended_ledger) == 1
ledger = points_repo.appended_ledger[0]
assert ledger.change_type.value == "refund"
assert ledger.direction == -1
assert ledger.amount == 100
class TestProcessRefundInsufficientBalance:
@pytest.mark.asyncio
async def test_deducts_to_zero_and_sets_insufficient_status(self) -> None:
user_id = uuid4()
txn = AppleIapTransaction(
id=uuid4(),
user_id=user_id,
product_code="basic_pack",
app_store_product_id="com.meeyao.qianwen.basic_pack",
transaction_id="2000000999999998",
original_transaction_id="2000000999999998",
environment="Sandbox",
bundle_id="com.meeyao.qianwen",
purchase_date="1700000000000",
status="granted",
credits=100,
signed_transaction_info="fake",
apple_payload_json={},
ledger_event_id="payment.apple_iap:2000000999999998",
)
account = _FakeAccountForRefund(balance=30, lifetime_earned=100)
points_repo = _FakePointsRepository()
service = PaymentService(
payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=account),
points_repo=points_repo,
verifier=_FakeVerifier(result=_make_verified_transaction()),
)
await service.process_refund(transaction_id="2000000999999998")
assert txn.status == "refunded_insufficient"
assert txn.failure_code == "INSUFFICIENT_BALANCE"
assert account.balance == 0
assert len(points_repo.appended_ledger) == 1
ledger = points_repo.appended_ledger[0]
assert ledger.amount == 30
class TestProcessRefundIdempotency:
@pytest.mark.asyncio
async def test_second_refund_is_noop(self) -> None:
user_id = uuid4()
txn = AppleIapTransaction(
id=uuid4(),
user_id=user_id,
product_code="basic_pack",
app_store_product_id="com.meeyao.qianwen.basic_pack",
transaction_id="2000000999999997",
original_transaction_id="2000000999999997",
environment="Sandbox",
bundle_id="com.meeyao.qianwen",
purchase_date="1700000000000",
status="refunded",
credits=100,
signed_transaction_info="fake",
apple_payload_json={},
)
points_repo = _FakePointsRepository()
service = PaymentService(
payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=_FakeAccountForRefund(balance=50)),
points_repo=points_repo,
verifier=_FakeVerifier(result=_make_verified_transaction()),
)
await service.process_refund(transaction_id="2000000999999997")
assert len(points_repo.appended_ledger) == 0
assert txn.status == "refunded"
class TestHandleServerNotificationRefund:
@pytest.mark.asyncio
async def test_processes_refund_notification(self) -> None:
user_id = uuid4()
txn = AppleIapTransaction(
id=uuid4(),
user_id=user_id,
product_code="basic_pack",
app_store_product_id="com.meeyao.qianwen.basic_pack",
transaction_id="2000000999999001",
original_transaction_id="2000000999999001",
environment="Sandbox",
bundle_id="com.meeyao.qianwen",
purchase_date="1700000000000",
status="granted",
credits=100,
signed_transaction_info="fake",
apple_payload_json={},
ledger_event_id="payment.apple_iap:2000000999999001",
)
account = _FakeAccountForRefund(balance=200, lifetime_earned=200)
points_repo = _FakePointsRepository()
service = PaymentService(
payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=account),
points_repo=points_repo,
verifier=_FakeVerifier(result=_make_verified_transaction()),
)
import base64
import json
signed_txn = _make_fake_signed_transaction(transaction_id="2000000999999001")
notification_payload = json.dumps({
"notificationType": "REFUND",
"data": {"signedTransactionInfo": signed_txn},
})
signed_payload = _make_fake_jws(notification_payload)
await service.handle_server_notification(signed_payload=signed_payload)
assert txn.status == "refunded"
assert account.balance == 100
@pytest.mark.asyncio
async def test_ignores_empty_payload(self) -> None:
service = PaymentService(
payment_repo=_FakePaymentRepoForRefund(),
points_repo=_FakePointsRepository(),
verifier=_FakeVerifier(result=_make_verified_transaction()),
)
await service.handle_server_notification(signed_payload="")
@pytest.mark.asyncio
async def test_ignores_non_refund_notification(self) -> None:
import json
notification_payload = json.dumps({
"notificationType": "DID_RENEW",
"data": {},
})
signed_payload = _make_fake_jws(notification_payload)
service = PaymentService(
payment_repo=_FakePaymentRepoForRefund(),
points_repo=_FakePointsRepository(),
verifier=_FakeVerifier(result=_make_verified_transaction()),
)
await service.handle_server_notification(signed_payload=signed_payload)
def _make_fake_jws(payload_str: str) -> str:
import base64
h = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b"=").decode()
p = base64.urlsafe_b64encode(payload_str.encode()).rstrip(b"=").decode()
return f"{h}.{p}.fake_signature"
def _make_fake_signed_transaction(transaction_id: str) -> str:
import base64
import json
txn_payload = json.dumps({"transactionId": transaction_id})
h = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b"=").decode()
p = base64.urlsafe_b64encode(txn_payload.encode()).rstrip(b"=").decode()
return f"{h}.{p}.fake_signature"