import hashlib import hmac import json import os import time from datetime import datetime, timedelta from unittest.mock import patch from fastapi.testclient import TestClient from database import SessionLocal from main import app, seed from models import HuihuiOrgPaymentNotice, InvoiceApplication, PaymentRefund, TokenAccount, TokenPaymentOrder, TokenPlan, User from services.token_billing import DEFAULT_TOKEN_GRANT client = TestClient(app) def _huihui_virtual_headers(secret, body, timestamp=None): timestamp = str(timestamp if timestamp is not None else int(time.time())) signature = hmac.new(secret.encode(), timestamp.encode() + b"\n" + body, hashlib.sha256).hexdigest() return {"X-Huihui-Timestamp": timestamp, "X-Huihui-Signature": signature} def test_huihui_virtual_callback_requires_signature_and_exact_order_details(authorization_context): seed() context = authorization_context db = SessionLocal() try: user = db.query(User).filter(User.id == context["owner"].id).one() user.wechat_mp_openid = "openid-huihui-callback" user.wechat_mp_session_key = "session-huihui-callback" plan = db.query(TokenPlan).filter(TokenPlan.id == "Basic").one() db.commit() finally: db.close() secret = "huihui-virtual-callback-test-secret-123456" env = { "WECHAT_VIRTUAL_ENV": "sandbox", "WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key", "WECHAT_VIRTUAL_OFFER_ID": "offer-1", "HUIHUI_VIRTUAL_CALLBACK_SECRET": secret, } with patch.dict(os.environ, env): created = client.post( "/api/token/charge", headers=context["owner_headers"], json={"planId": "Basic", "paymentMethod": "wechat", "payScene": "LITE"}, ).json()["data"] payload = { "orderNo": created["orderNo"], "paymentNo": f"huihui-virtual-payment-{context['suffix']}", "status": "SUCCESS", "amountCents": 1000, "productId": "Basic", "openId": "openid-huihui-callback", "env": 1, } path = "/api/token/payment/huihui/virtual/notify" body = json.dumps(payload, separators=(",", ":")).encode() assert client.post(path, content=body, headers=_huihui_virtual_headers(secret, body, 1)).status_code == 401 assert client.post(path, content=body, headers={"X-Huihui-Timestamp": str(int(time.time())), "X-Huihui-Signature": "bad"}).status_code == 401 wrong = json.dumps({**payload, "amountCents": 999}, separators=(",", ":")).encode() assert client.post(path, content=wrong, headers=_huihui_virtual_headers(secret, wrong)).status_code == 422 first = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body)) second = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body)) assert first.json()["data"] == {"received": True, "paid": True} assert second.json()["data"] == {"received": True, "duplicate": True} db = SessionLocal() try: order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == created["orderNo"]).one() account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one() assert order.status == "paid" assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000 finally: db.close() def test_huihui_org_callback_verifies_provider_before_granting_points(authorization_context): seed() context = authorization_context provider_no = "26102010414409000001" payment_no = f"WV-{context['suffix']}" order_no = f"AV-{context['suffix']}" db = SessionLocal() try: user = db.query(User).filter(User.id == context["owner"].id).one() user.huihui_token = "test-huihui-token" db.add(TokenPaymentOrder( order_no=order_no, provider_order_no=provider_no, user_id=user.id, plan_id="Basic", payment_method="wechat", pay_type="WECHAT", pay_way="LITE", provider="huihui_org", points_amount=2_000_000, price_cents=100, status="pending", )) db.commit() finally: db.close() secret = "abcdefghijklmnop" path = "/api/token/payment/huihui/org/notify" payload = { "orderNo": provider_no, "paymentNo": payment_no, "status": "SUCCESS", "amountCents": 100, "productId": "Basic", } provider_data = { "orderNo": provider_no, "paymentNo": payment_no, "payStatus": 1, "chargeType": 9, "chargeAmount": 1, "productId": "Basic", "userId": context["owner"].huihui_user_id, } def send(body_payload, *, signature=True, callback_path=path): body = json.dumps(body_payload, separators=(",", ":")).encode() headers = _huihui_virtual_headers(secret, body) if signature else {"X-Huihui-Signature": "bad"} return client.post(callback_path, content=body, headers=headers) with patch.dict(os.environ, { "HUIHUI_ORG_CALLBACK_SECRET": secret, "HUIHUI_VIRTUAL_CALLBACK_SECRET": "", }), patch( "routers.tokens.HuihuiOrgPaymentClient" ) as client_factory: provider = client_factory.return_value provider.get_order.return_value = provider_data assert send(payload, signature=False).status_code == 401 legacy_path = "/api/token/payment/huihui/virtual/notify" assert send(payload, signature=False, callback_path=legacy_path).status_code == 401 assert send({**payload, "orderNo": "unknown"}, callback_path=legacy_path).status_code == 404 assert send({**payload, "amountCents": 99}).status_code == 422 pending = send(payload, callback_path=legacy_path) assert pending.status_code == 200 assert pending.json()["data"] == {"received": True, "paid": False, "awaitingProvider": True} provider.get_order.assert_not_called() db = SessionLocal() try: assert db.get(HuihuiOrgPaymentNotice, order_no).status == "awaiting_provider" assert db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).one().status == "pending" finally: db.close() assert send({**payload, "paymentNo": "wrong"}).status_code == 409 db = SessionLocal() try: notice = db.get(HuihuiOrgPaymentNotice, order_no) notice.next_check_at = datetime.utcnow() - timedelta(seconds=1) db.commit() from routers.tokens import reconcile_huihui_org_notices provider.get_order.return_value = {**provider_data, "payStatus": 2, "chargeAmount": 2} assert reconcile_huihui_org_notices(db) == 0 assert db.get(HuihuiOrgPaymentNotice, order_no).status == "rejected" assert db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).one().status == "pending" assert send(payload).status_code == 200 notice.next_check_at = datetime.utcnow() - timedelta(seconds=1) db.commit() provider.get_order.return_value = provider_data assert reconcile_huihui_org_notices(db) == 0 assert db.get(HuihuiOrgPaymentNotice, order_no).attempts == 1 assert db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).one().status == "pending" notice.next_check_at = datetime.utcnow() - timedelta(seconds=1) db.commit() provider.get_order.return_value = {**provider_data, "payStatus": 2} assert reconcile_huihui_org_notices(db) == 1 assert db.get(HuihuiOrgPaymentNotice, order_no).status == "confirmed" finally: db.close() first = send(payload, callback_path=legacy_path) second = send(payload) assert first.status_code == 200 assert first.json()["data"] == {"received": True, "duplicate": True} assert second.json()["data"] == {"received": True, "duplicate": True} db = SessionLocal() try: order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).one() account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one() assert order.status == "paid" assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000 assert provider.get_order.call_count >= 3 finally: db.close() def _signature(token, timestamp, nonce): return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest() def test_virtual_payment_callback_and_refund_are_idempotent(authorization_context): seed() context = authorization_context db = SessionLocal() try: user = db.query(User).filter(User.id == context["owner"].id).one() user.wechat_mp_openid = "openid-flow" user.wechat_mp_session_key = "session-flow" plan = db.query(TokenPlan).filter(TokenPlan.id == "Basic").one() db.commit() finally: db.close() env = { "WECHAT_VIRTUAL_ENV": "sandbox", "WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key", "WECHAT_VIRTUAL_OFFER_ID": "offer-1", "WECHAT_VIRTUAL_CALLBACK_TOKEN": "callback-token", "AVATAR_FINANCE_ADMIN_SECRET": "finance-admin-secret-123", } with patch.dict(os.environ, env): created = client.post( "/api/token/charge", headers=context["owner_headers"], json={"planId": "Basic", "paymentMethod": "wechat", "payScene": "LITE"}, ).json()["data"] assert created["provider"] == "wechat_virtual" params = json.loads(created["payMessage"]) assert params["mode"] == "short_series_goods" assert "session-flow" not in created["payMessage"] notify = { "Event": "xpay_goods_deliver_notify", "OutTradeNo": created["orderNo"], "OpenId": "openid-flow", "Env": 1, "GoodsInfo": json.dumps({"ProductId": "Basic", "ActualPrice": 1000}), "WeChatPayInfo": json.dumps({"TransactionId": "wx-transaction-1"}), } query = {"timestamp": "100", "nonce": "nonce", "signature": _signature("callback-token", "100", "nonce")} assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=notify).json()["ErrCode"] == 0 assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=notify).json()["ErrCode"] == 0 invoice = client.post( f"/api/token/orders/{created['orderNo']}/invoice", headers=context["owner_headers"], json={"title": "测试用户", "invoiceType": "personal", "email": "test@example.com"}, ).json()["data"] assert invoice["status"] == "pending" with patch("routers.tokens.request_wechat_virtual_refund", return_value={"errcode": 0}): refund_response = client.post( f"/api/token/admin/orders/{created['orderNo']}/refund", headers={"X-Avatar-Finance-Key": "finance-admin-secret-123"}, json={"reason": "用户申请退款", "operator": "tester"}, ) assert refund_response.json()["data"]["status"] == "processing" refund_no = refund_response.json()["data"]["refundNo"] refund_notify = { "Event": "xpay_refund_notify", "MchOrderId": created["orderNo"], "MchRefundId": refund_no, "WxRefundId": "wx-refund-1", "RefundFee": 1000, "RetCode": 0, } assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=refund_notify).json()["ErrCode"] == 0 assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=refund_notify).json()["ErrCode"] == 0 assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=notify).json()["ErrCode"] == 0 db = SessionLocal() try: order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == created["orderNo"]).one() account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one() refund = db.query(PaymentRefund).filter(PaymentRefund.refund_no == refund_no).one() invoice = db.query(InvoiceApplication).filter(InvoiceApplication.order_no == created["orderNo"]).one() assert order.status == "refunded" assert refund.status == "succeeded" assert invoice.status == "cancelled" assert account.balance == DEFAULT_TOKEN_GRANT finally: db.close()