alipay_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. //go:build unit
  2. package provider
  3. import (
  4. "context"
  5. "errors"
  6. "net/url"
  7. "strings"
  8. "testing"
  9. "github.com/Wei-Shaw/sub2api/internal/payment"
  10. "github.com/smartwalle/alipay/v3"
  11. )
  12. func TestIsTradeNotExist(t *testing.T) {
  13. t.Parallel()
  14. tests := []struct {
  15. name string
  16. err error
  17. want bool
  18. }{
  19. {
  20. name: "nil error returns false",
  21. err: nil,
  22. want: false,
  23. },
  24. {
  25. name: "error containing ACQ.TRADE_NOT_EXIST returns true",
  26. err: errors.New("alipay: sub_code=ACQ.TRADE_NOT_EXIST, sub_msg=交易不存在"),
  27. want: true,
  28. },
  29. {
  30. name: "error not containing the code returns false",
  31. err: errors.New("alipay: sub_code=ACQ.SYSTEM_ERROR, sub_msg=系统错误"),
  32. want: false,
  33. },
  34. {
  35. name: "error with only partial match returns false",
  36. err: errors.New("ACQ.TRADE_NOT"),
  37. want: false,
  38. },
  39. {
  40. name: "error with exact constant value returns true",
  41. err: errors.New(alipayErrTradeNotExist),
  42. want: true,
  43. },
  44. }
  45. for _, tt := range tests {
  46. t.Run(tt.name, func(t *testing.T) {
  47. t.Parallel()
  48. got := isTradeNotExist(tt.err)
  49. if got != tt.want {
  50. t.Errorf("isTradeNotExist(%v) = %v, want %v", tt.err, got, tt.want)
  51. }
  52. })
  53. }
  54. }
  55. func TestNewAlipay(t *testing.T) {
  56. t.Parallel()
  57. validConfig := map[string]string{
  58. "appId": "2021001234567890",
  59. "privateKey": "MIIEvQIBADANBgkqhkiG9w0BAQEFAASC...",
  60. }
  61. // helper to clone and override config fields
  62. withOverride := func(overrides map[string]string) map[string]string {
  63. cfg := make(map[string]string, len(validConfig))
  64. for k, v := range validConfig {
  65. cfg[k] = v
  66. }
  67. for k, v := range overrides {
  68. cfg[k] = v
  69. }
  70. return cfg
  71. }
  72. tests := []struct {
  73. name string
  74. config map[string]string
  75. wantErr bool
  76. errSubstr string
  77. }{
  78. {
  79. name: "valid config succeeds",
  80. config: validConfig,
  81. wantErr: false,
  82. },
  83. {
  84. name: "missing appId",
  85. config: withOverride(map[string]string{"appId": ""}),
  86. wantErr: true,
  87. errSubstr: "appId",
  88. },
  89. {
  90. name: "missing privateKey",
  91. config: withOverride(map[string]string{"privateKey": ""}),
  92. wantErr: true,
  93. errSubstr: "privateKey",
  94. },
  95. {
  96. name: "nil config map returns error for appId",
  97. config: map[string]string{},
  98. wantErr: true,
  99. errSubstr: "appId",
  100. },
  101. }
  102. for _, tt := range tests {
  103. t.Run(tt.name, func(t *testing.T) {
  104. t.Parallel()
  105. got, err := NewAlipay("test-instance", tt.config)
  106. if tt.wantErr {
  107. if err == nil {
  108. t.Fatal("expected error, got nil")
  109. }
  110. if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
  111. t.Errorf("error %q should contain %q", err.Error(), tt.errSubstr)
  112. }
  113. return
  114. }
  115. if err != nil {
  116. t.Fatalf("unexpected error: %v", err)
  117. }
  118. if got == nil {
  119. t.Fatal("expected non-nil Alipay instance")
  120. }
  121. if got.instanceID != "test-instance" {
  122. t.Errorf("instanceID = %q, want %q", got.instanceID, "test-instance")
  123. }
  124. })
  125. }
  126. }
  127. func TestCreateTradeUsesPagePayForDesktop(t *testing.T) {
  128. origPreCreate := alipayTradePreCreate
  129. origPagePay := alipayTradePagePay
  130. origWapPay := alipayTradeWapPay
  131. t.Cleanup(func() {
  132. alipayTradePreCreate = origPreCreate
  133. alipayTradePagePay = origPagePay
  134. alipayTradeWapPay = origWapPay
  135. })
  136. preCreateCalls := 0
  137. pagePayCalls := 0
  138. wapPayCalls := 0
  139. alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
  140. preCreateCalls++
  141. return nil, errors.New("merchant does not have FACE_TO_FACE_PAYMENT")
  142. }
  143. alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
  144. pagePayCalls++
  145. if param.OutTradeNo != "sub2_100" {
  146. t.Fatalf("out_trade_no = %q, want %q", param.OutTradeNo, "sub2_100")
  147. }
  148. if param.NotifyURL != "https://merchant.example.com/api/v1/payment/webhook/alipay" {
  149. t.Fatalf("notify_url = %q", param.NotifyURL)
  150. }
  151. return url.Parse("https://openapi.alipay.com/gateway.do?page-pay")
  152. }
  153. alipayTradeWapPay = func(client *alipay.Client, param alipay.TradeWapPay) (*url.URL, error) {
  154. wapPayCalls++
  155. return url.Parse("https://openapi.alipay.com/gateway.do?wap-pay")
  156. }
  157. provider := &Alipay{}
  158. resp, err := provider.createDesktopTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{
  159. OrderID: "sub2_100",
  160. Amount: "88.00",
  161. Subject: "Balance recharge",
  162. }, "https://merchant.example.com/api/v1/payment/webhook/alipay", "https://merchant.example.com/payment/result")
  163. if err != nil {
  164. t.Fatalf("unexpected error: %v", err)
  165. }
  166. if preCreateCalls != 1 {
  167. t.Fatalf("precreate calls = %d, want 1", preCreateCalls)
  168. }
  169. if pagePayCalls != 1 {
  170. t.Fatalf("page pay calls = %d, want 1", pagePayCalls)
  171. }
  172. if wapPayCalls != 0 {
  173. t.Fatalf("wap pay calls = %d, want 0", wapPayCalls)
  174. }
  175. if resp.PayURL == "" {
  176. t.Fatal("expected pay_url for desktop page pay")
  177. }
  178. // page.pay returns a checkout page URL, not a scannable QR payload —
  179. // it must never be exposed via QRCode (the frontend would render an
  180. // unscannable image from it).
  181. if resp.QRCode != "" {
  182. t.Fatalf("qr_code = %q, want empty for page pay", resp.QRCode)
  183. }
  184. }
  185. // When the provider instance is configured with paymentMode == "redirect",
  186. // the desktop flow must skip precreate and go straight to page.pay.
  187. func TestCreateTradeRedirectModeSkipsPrecreate(t *testing.T) {
  188. origPreCreate := alipayTradePreCreate
  189. origPagePay := alipayTradePagePay
  190. t.Cleanup(func() {
  191. alipayTradePreCreate = origPreCreate
  192. alipayTradePagePay = origPagePay
  193. })
  194. preCreateCalls := 0
  195. pagePayCalls := 0
  196. alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
  197. preCreateCalls++
  198. return &alipay.TradePreCreateRsp{
  199. Error: alipay.Error{Code: alipay.CodeSuccess},
  200. QRCode: "https://qr.alipay.example.com/precreate-token",
  201. }, nil
  202. }
  203. alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
  204. pagePayCalls++
  205. if param.ProductCode != alipayProductCodePagePay {
  206. t.Fatalf("product_code = %q, want %q", param.ProductCode, alipayProductCodePagePay)
  207. }
  208. return url.Parse("https://openapi.alipay.com/gateway.do?page-pay")
  209. }
  210. provider := &Alipay{
  211. config: map[string]string{"paymentMode": "redirect"},
  212. }
  213. resp, err := provider.createDesktopTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{
  214. OrderID: "sub2_103",
  215. Amount: "12.00",
  216. Subject: "Balance recharge",
  217. }, "https://merchant.example.com/api/v1/payment/webhook/alipay", "https://merchant.example.com/payment/result")
  218. if err != nil {
  219. t.Fatalf("unexpected error: %v", err)
  220. }
  221. if preCreateCalls != 0 {
  222. t.Fatalf("precreate calls = %d, want 0 (redirect mode must skip precreate)", preCreateCalls)
  223. }
  224. if pagePayCalls != 1 {
  225. t.Fatalf("page pay calls = %d, want 1", pagePayCalls)
  226. }
  227. if resp.PayURL == "" {
  228. t.Fatal("expected pay_url for redirect mode")
  229. }
  230. if resp.QRCode != "" {
  231. t.Fatalf("qr_code = %q, want empty for redirect mode", resp.QRCode)
  232. }
  233. }
  234. func TestCreateTradeUsesWapPayForMobile(t *testing.T) {
  235. origWapPay := alipayTradeWapPay
  236. t.Cleanup(func() {
  237. alipayTradeWapPay = origWapPay
  238. })
  239. wapPayCalls := 0
  240. alipayTradeWapPay = func(client *alipay.Client, param alipay.TradeWapPay) (*url.URL, error) {
  241. wapPayCalls++
  242. if param.ReturnURL != "https://merchant.example.com/payment/result" {
  243. t.Fatalf("return_url = %q", param.ReturnURL)
  244. }
  245. return url.Parse("https://openapi.alipay.com/gateway.do?wap-pay")
  246. }
  247. provider := &Alipay{}
  248. resp, err := provider.createWapTrade(&alipay.Client{}, payment.CreatePaymentRequest{
  249. OrderID: "sub2_101",
  250. Amount: "18.00",
  251. Subject: "Balance recharge",
  252. IsMobile: true,
  253. }, "https://merchant.example.com/api/v1/payment/webhook/alipay", "https://merchant.example.com/payment/result")
  254. if err != nil {
  255. t.Fatalf("unexpected error: %v", err)
  256. }
  257. if wapPayCalls != 1 {
  258. t.Fatalf("wap pay calls = %d, want 1", wapPayCalls)
  259. }
  260. if resp.PayURL == "" {
  261. t.Fatal("expected pay_url for mobile wap pay")
  262. }
  263. }
  264. func TestCreatePaymentUsesPrecreateForMobileWhenEnabled(t *testing.T) {
  265. origPreCreate := alipayTradePreCreate
  266. origWapPay := alipayTradeWapPay
  267. t.Cleanup(func() {
  268. alipayTradePreCreate = origPreCreate
  269. alipayTradeWapPay = origWapPay
  270. })
  271. precreateCalls := 0
  272. wapPayCalls := 0
  273. alipayTradePreCreate = func(_ context.Context, _ *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
  274. precreateCalls++
  275. if param.OutTradeNo != "sub2_mobile_precreate" {
  276. t.Fatalf("out_trade_no = %q", param.OutTradeNo)
  277. }
  278. if param.ProductCode != alipayProductCodePreCreate {
  279. t.Fatalf("product_code = %q, want %q", param.ProductCode, alipayProductCodePreCreate)
  280. }
  281. return &alipay.TradePreCreateRsp{
  282. Error: alipay.Error{Code: alipay.CodeSuccess},
  283. QRCode: "https://qr.alipay.example.com/mobile-dynamic-token",
  284. }, nil
  285. }
  286. alipayTradeWapPay = func(_ *alipay.Client, _ alipay.TradeWapPay) (*url.URL, error) {
  287. wapPayCalls++
  288. return url.Parse("https://openapi.alipay.com/gateway.do?wap-pay")
  289. }
  290. provider := &Alipay{client: &alipay.Client{}, config: map[string]string{}}
  291. resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
  292. OrderID: "sub2_mobile_precreate",
  293. Amount: "28.00",
  294. Subject: "Balance recharge",
  295. IsMobile: true,
  296. AlipayMobilePrecreate: true,
  297. })
  298. if err != nil {
  299. t.Fatalf("unexpected error: %v", err)
  300. }
  301. if precreateCalls != 1 || wapPayCalls != 0 {
  302. t.Fatalf("precreate calls = %d, wap calls = %d; want 1, 0", precreateCalls, wapPayCalls)
  303. }
  304. if resp.QRCode != "https://qr.alipay.example.com/mobile-dynamic-token" || resp.PayURL != "" {
  305. t.Fatalf("unexpected response: qr_code=%q pay_url=%q", resp.QRCode, resp.PayURL)
  306. }
  307. }
  308. func TestCreatePaymentKeepsWapPayForMobileWhenPrecreateDisabled(t *testing.T) {
  309. origPreCreate := alipayTradePreCreate
  310. origWapPay := alipayTradeWapPay
  311. t.Cleanup(func() {
  312. alipayTradePreCreate = origPreCreate
  313. alipayTradeWapPay = origWapPay
  314. })
  315. precreateCalls := 0
  316. wapPayCalls := 0
  317. alipayTradePreCreate = func(_ context.Context, _ *alipay.Client, _ alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
  318. precreateCalls++
  319. return nil, errors.New("unexpected precreate call")
  320. }
  321. alipayTradeWapPay = func(_ *alipay.Client, _ alipay.TradeWapPay) (*url.URL, error) {
  322. wapPayCalls++
  323. return url.Parse("https://openapi.alipay.com/gateway.do?wap-pay")
  324. }
  325. provider := &Alipay{client: &alipay.Client{}, config: map[string]string{}}
  326. resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
  327. OrderID: "sub2_mobile_wap",
  328. Amount: "18.00",
  329. Subject: "Balance recharge",
  330. IsMobile: true,
  331. })
  332. if err != nil {
  333. t.Fatalf("unexpected error: %v", err)
  334. }
  335. if precreateCalls != 0 || wapPayCalls != 1 {
  336. t.Fatalf("precreate calls = %d, wap calls = %d; want 0, 1", precreateCalls, wapPayCalls)
  337. }
  338. if resp.PayURL == "" || resp.QRCode != "" {
  339. t.Fatalf("unexpected response: qr_code=%q pay_url=%q", resp.QRCode, resp.PayURL)
  340. }
  341. }
  342. func TestCreateTradeUsesPrecreateForDesktopWhenAvailable(t *testing.T) {
  343. origPreCreate := alipayTradePreCreate
  344. origPagePay := alipayTradePagePay
  345. t.Cleanup(func() {
  346. alipayTradePreCreate = origPreCreate
  347. alipayTradePagePay = origPagePay
  348. })
  349. preCreateCalls := 0
  350. pagePayCalls := 0
  351. alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
  352. preCreateCalls++
  353. if param.ProductCode != alipayProductCodePreCreate {
  354. t.Fatalf("product_code = %q, want %q", param.ProductCode, alipayProductCodePreCreate)
  355. }
  356. return &alipay.TradePreCreateRsp{
  357. Error: alipay.Error{Code: alipay.CodeSuccess},
  358. QRCode: "https://qr.alipay.example.com/precreate-token",
  359. }, nil
  360. }
  361. alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
  362. pagePayCalls++
  363. return url.Parse("https://openapi.alipay.com/gateway.do?page-pay")
  364. }
  365. provider := &Alipay{}
  366. resp, err := provider.createDesktopTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{
  367. OrderID: "sub2_102",
  368. Amount: "66.00",
  369. Subject: "Balance recharge",
  370. }, "https://merchant.example.com/api/v1/payment/webhook/alipay", "https://merchant.example.com/payment/result")
  371. if err != nil {
  372. t.Fatalf("unexpected error: %v", err)
  373. }
  374. if preCreateCalls != 1 {
  375. t.Fatalf("precreate calls = %d, want 1", preCreateCalls)
  376. }
  377. if pagePayCalls != 0 {
  378. t.Fatalf("page pay calls = %d, want 0", pagePayCalls)
  379. }
  380. if resp.QRCode != "https://qr.alipay.example.com/precreate-token" {
  381. t.Fatalf("qr_code = %q", resp.QRCode)
  382. }
  383. if resp.PayURL != "" {
  384. t.Fatalf("pay_url = %q, want empty for precreate", resp.PayURL)
  385. }
  386. }
  387. func TestAlipayMerchantIdentityMetadata(t *testing.T) {
  388. t.Parallel()
  389. provider := &Alipay{
  390. config: map[string]string{
  391. "appId": "2021001234567890",
  392. },
  393. }
  394. metadata := provider.MerchantIdentityMetadata()
  395. if metadata["app_id"] != "2021001234567890" {
  396. t.Fatalf("app_id = %q, want %q", metadata["app_id"], "2021001234567890")
  397. }
  398. }
  399. func TestParseAlipayAmount(t *testing.T) {
  400. t.Parallel()
  401. amount, err := parseAlipayAmount("", "88.00", "77.00")
  402. if err != nil {
  403. t.Fatalf("unexpected error: %v", err)
  404. }
  405. if amount != 88 {
  406. t.Fatalf("amount = %v, want 88", amount)
  407. }
  408. if _, err := parseAlipayAmount("", "not-a-number"); err == nil {
  409. t.Fatal("expected error when no valid amount field exists")
  410. }
  411. }