Files
Aether/tests/services/test_payment_service.py

329 lines
9.6 KiB
Python
Raw Normal View History

from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from src.models.database import PaymentOrder, Wallet
from src.services.payment.gateway import get_payment_gateway
from src.services.payment import PaymentService
CALLBACK_SECRET = "test-callback-secret"
def _sign_payload(payload: dict[str, object]) -> str:
gateway = get_payment_gateway("alipay")
signature = gateway.build_callback_signature(payload=payload, callback_secret=CALLBACK_SECRET)
assert signature is not None
return signature
def test_create_recharge_order_creates_pending_order(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
user = SimpleNamespace(id="u1")
wallet = SimpleNamespace(id="w1", status="active")
monkeypatch.setattr(
"src.services.payment.service.WalletService.get_or_create_wallet",
lambda _db, user: wallet,
)
order = PaymentService.create_recharge_order(
db,
user=user,
amount_usd="12.5",
payment_method="alipay",
pay_amount="88.00",
pay_currency="CNY",
exchange_rate="7.04",
)
db.add.assert_called_once()
db.flush.assert_called_once()
assert order.wallet_id == "w1"
assert order.user_id == "u1"
assert order.status == "pending"
assert order.payment_method == "alipay"
assert order.gateway_order_id == f"ali_{order.order_no}"
assert isinstance(order.gateway_response, dict)
assert order.gateway_response["gateway"] == "alipay"
assert Decimal(order.amount_usd) == Decimal("12.50000000")
assert Decimal(order.refundable_amount_usd) == Decimal("0")
def test_refresh_order_status_marks_expired_pending_order() -> None:
order = PaymentOrder(
id="po-expired",
order_no="order-expired",
wallet_id="w1",
user_id="u1",
amount_usd=Decimal("2.00000000"),
refunded_amount_usd=Decimal("0"),
refundable_amount_usd=Decimal("2.00000000"),
payment_method="wechat",
status="pending",
)
order.expires_at = datetime(2000, 1, 1, tzinfo=timezone.utc)
changed = PaymentService.refresh_order_status(order)
assert changed is True
assert order.status == "expired"
def test_handle_callback_is_idempotent_for_processed_callback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
callback = SimpleNamespace(status="processed", payment_order_id="po1")
monkeypatch.setattr(
"src.services.payment.service.PaymentService.log_callback",
lambda *args, **kwargs: (callback, False),
)
result = PaymentService.handle_callback(
db,
payment_method="alipay",
callback_key="cb1",
payload={"foo": "bar"},
callback_signature=_sign_payload({"foo": "bar"}),
callback_secret=CALLBACK_SECRET,
order_no="order-1",
)
assert result["ok"] is True
assert result["duplicate"] is True
assert result["credited"] is False
assert result["order_id"] == "po1"
def test_handle_callback_fails_on_amount_mismatch(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
callback = SimpleNamespace(
status="received",
payment_order_id=None,
order_no=None,
gateway_order_id=None,
error_message=None,
processed_at=None,
)
order = SimpleNamespace(
id="po1",
order_no="order-1",
gateway_order_id=None,
amount_usd=Decimal("10.00000000"),
)
monkeypatch.setattr(
"src.services.payment.service.PaymentService.log_callback",
lambda *args, **kwargs: (callback, True),
)
monkeypatch.setattr(
"src.services.payment.service.PaymentService.get_order",
lambda *args, **kwargs: order,
)
result = PaymentService.handle_callback(
db,
payment_method="wechat",
callback_key="cb2",
payload={"foo": "bar"},
callback_signature=_sign_payload({"foo": "bar"}),
callback_secret=CALLBACK_SECRET,
order_no="order-1",
amount_usd="9.99",
)
assert result["ok"] is False
assert "mismatch" in result["error"]
assert callback.status == "failed"
def test_handle_callback_rejects_invalid_signature() -> None:
db = MagicMock()
result = PaymentService.handle_callback(
db,
payment_method="alipay",
callback_key="cb-invalid-signature",
payload={"foo": "bar"},
callback_signature="invalid-signature",
callback_secret=CALLBACK_SECRET,
order_no="order-1",
amount_usd="9.99",
)
assert result["ok"] is False
assert "signature" in result["error"]
def test_credit_order_applies_wallet_recharge_once(monkeypatch: pytest.MonkeyPatch) -> None:
order = PaymentOrder(
id="po1",
order_no="order-1",
wallet_id="w1",
user_id="u1",
amount_usd=Decimal("5.00000000"),
refunded_amount_usd=Decimal("0"),
refundable_amount_usd=Decimal("0"),
payment_method="alipay",
status="pending",
)
wallet = Wallet(
id="w1",
user_id="u1",
balance=Decimal("1.00000000"),
status="active",
total_recharged=Decimal("1.00000000"),
total_consumed=Decimal("0"),
total_refunded=Decimal("0"),
total_adjusted=Decimal("0"),
currency="USD",
)
class DummyOrderQuery:
def filter(self, *args: object, **kwargs: object) -> "DummyOrderQuery":
return self
def with_for_update(self) -> "DummyOrderQuery":
return self
def one_or_none(self) -> PaymentOrder:
return order
class DummyWalletQuery:
def filter(self, *args: object, **kwargs: object) -> "DummyWalletQuery":
return self
def first(self) -> Wallet:
return wallet
db = MagicMock()
db.query.side_effect = [DummyOrderQuery(), DummyWalletQuery()]
create_tx = MagicMock()
monkeypatch.setattr(
"src.services.payment.service.WalletService.create_wallet_transaction",
create_tx,
)
updated, credited = PaymentService.credit_order(
db,
order=order,
gateway_order_id="gw-1",
pay_amount="36.00",
pay_currency="CNY",
exchange_rate="7.20",
)
assert credited is True
assert updated.status == "credited"
assert updated.gateway_order_id == "gw-1"
assert updated.paid_at is not None
assert updated.credited_at is not None
assert Decimal(updated.refundable_amount_usd) == Decimal("5.00000000")
create_tx.assert_called_once()
def test_expire_order_marks_pending_order_expired() -> None:
order = PaymentOrder(
id="po2",
order_no="order-2",
wallet_id="w1",
user_id="u1",
amount_usd=Decimal("3.00000000"),
refunded_amount_usd=Decimal("0"),
refundable_amount_usd=Decimal("3.00000000"),
payment_method="wechat",
status="pending",
)
order.expires_at = datetime.now(timezone.utc) + timedelta(minutes=30)
class DummyOrderQuery:
def filter(self, *args: object, **kwargs: object) -> "DummyOrderQuery":
return self
def with_for_update(self) -> "DummyOrderQuery":
return self
def one_or_none(self) -> PaymentOrder:
return order
db = MagicMock()
db.query.return_value = DummyOrderQuery()
updated, changed = PaymentService.expire_order(db, order=order, reason="ops_close")
assert changed is True
assert updated.status == "expired"
assert updated.gateway_response["expire_reason"] == "ops_close"
assert "expired_at" in updated.gateway_response
def test_expire_order_is_idempotent_for_existing_expired_order() -> None:
order = PaymentOrder(
id="po3",
order_no="order-3",
wallet_id="w1",
user_id="u1",
amount_usd=Decimal("1.00000000"),
refunded_amount_usd=Decimal("0"),
refundable_amount_usd=Decimal("1.00000000"),
payment_method="alipay",
status="expired",
)
class DummyOrderQuery:
def filter(self, *args: object, **kwargs: object) -> "DummyOrderQuery":
return self
def with_for_update(self) -> "DummyOrderQuery":
return self
def one_or_none(self) -> PaymentOrder:
return order
db = MagicMock()
db.query.return_value = DummyOrderQuery()
updated, changed = PaymentService.expire_order(db, order=order)
assert changed is False
assert updated.status == "expired"
def test_list_orders_expires_overdue_pending_before_filtered_count() -> None:
expiry_query = MagicMock()
expiry_query.filter.return_value = expiry_query
expiry_query.update.return_value = 2
list_query = MagicMock()
list_query.filter.return_value = list_query
list_query.count.return_value = 1
ordered_query = MagicMock()
list_query.order_by.return_value = ordered_query
ordered_query.offset.return_value = ordered_query
ordered_query.limit.return_value = ordered_query
ordered_query.all.return_value = ["order-1"]
db = MagicMock()
db.query.side_effect = [expiry_query, list_query]
items, total, changed = PaymentService.list_orders(
db,
status="pending",
payment_method="alipay",
limit=5,
offset=10,
)
assert items == ["order-1"]
assert total == 1
assert changed is True
expiry_query.update.assert_called_once()
ordered_query.offset.assert_called_once_with(10)
ordered_query.limit.assert_called_once_with(5)