easypay_refund_test.go 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. package provider
  2. import (
  3. "context"
  4. "net/http"
  5. "net/http/httptest"
  6. "net/url"
  7. "strings"
  8. "testing"
  9. "unicode/utf8"
  10. "github.com/Wei-Shaw/sub2api/internal/payment"
  11. )
  12. func TestNormalizeEasyPayAPIBase(t *testing.T) {
  13. t.Parallel()
  14. tests := []struct {
  15. input string
  16. want string
  17. }{
  18. {input: "https://zpayz.cn", want: "https://zpayz.cn"},
  19. {input: "https://zpayz.cn/", want: "https://zpayz.cn"},
  20. {input: "https://zpayz.cn/mapi.php", want: "https://zpayz.cn"},
  21. {input: "https://zpayz.cn/submit.php", want: "https://zpayz.cn"},
  22. {input: "https://zpayz.cn/api.php", want: "https://zpayz.cn"},
  23. {input: "https://zpayz.cn/api.php?act=refund", want: "https://zpayz.cn"},
  24. }
  25. for _, tt := range tests {
  26. t.Run(tt.input, func(t *testing.T) {
  27. t.Parallel()
  28. if got := normalizeEasyPayAPIBase(tt.input); got != tt.want {
  29. t.Fatalf("normalizeEasyPayAPIBase(%q) = %q, want %q", tt.input, got, tt.want)
  30. }
  31. })
  32. }
  33. }
  34. func TestEasyPayRefundNormalizesAPIBaseAndSendsOutTradeNoOnly(t *testing.T) {
  35. t.Parallel()
  36. var gotPath string
  37. var gotQuery url.Values
  38. var gotForm url.Values
  39. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  40. gotPath = r.URL.Path
  41. gotQuery = r.URL.Query()
  42. if err := r.ParseForm(); err != nil {
  43. t.Errorf("ParseForm: %v", err)
  44. }
  45. gotForm = r.PostForm
  46. w.Header().Set("Content-Type", "application/json")
  47. _, _ = w.Write([]byte(`{"code":1,"msg":"ok"}`))
  48. }))
  49. defer server.Close()
  50. provider := newTestEasyPay(t, server.URL+"/mapi.php")
  51. resp, err := provider.Refund(context.Background(), payment.RefundRequest{
  52. TradeNo: "trade-123",
  53. OrderID: "out-456",
  54. Amount: "1.50",
  55. })
  56. if err != nil {
  57. t.Fatalf("Refund returned error: %v", err)
  58. }
  59. if resp == nil || resp.Status != payment.ProviderStatusSuccess {
  60. t.Fatalf("Refund response = %+v, want success", resp)
  61. }
  62. if gotPath != "/api.php" {
  63. t.Fatalf("refund path = %q, want /api.php", gotPath)
  64. }
  65. if gotQuery.Get("act") != "refund" {
  66. t.Fatalf("refund act query = %q, want refund", gotQuery.Get("act"))
  67. }
  68. for key, want := range map[string]string{
  69. "pid": "pid-1",
  70. "key": "pkey-1",
  71. "out_trade_no": "out-456",
  72. "money": "1.50",
  73. } {
  74. if got := gotForm.Get(key); got != want {
  75. t.Fatalf("form[%s] = %q, want %q (form=%v)", key, got, want, gotForm)
  76. }
  77. }
  78. if got := gotForm.Get("trade_no"); got != "" {
  79. t.Fatalf("form[trade_no] = %q, want empty (form=%v)", got, gotForm)
  80. }
  81. }
  82. func TestEasyPayRefundRetriesWithTradeNoWhenOutTradeNoNotFound(t *testing.T) {
  83. t.Parallel()
  84. var gotForms []url.Values
  85. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  86. if r.URL.Path != "/api.php" {
  87. t.Errorf("refund path = %q, want /api.php", r.URL.Path)
  88. }
  89. if r.URL.Query().Get("act") != "refund" {
  90. t.Errorf("refund act query = %q, want refund", r.URL.Query().Get("act"))
  91. }
  92. if err := r.ParseForm(); err != nil {
  93. t.Errorf("ParseForm: %v", err)
  94. }
  95. gotForms = append(gotForms, r.PostForm)
  96. w.Header().Set("Content-Type", "application/json")
  97. if len(gotForms) == 1 {
  98. _, _ = w.Write([]byte(`{"code":0,"msg":"订单编号不存在!"}`))
  99. return
  100. }
  101. _, _ = w.Write([]byte(`{"code":1,"msg":"ok"}`))
  102. }))
  103. defer server.Close()
  104. provider := newTestEasyPay(t, server.URL+"/mapi.php")
  105. resp, err := provider.Refund(context.Background(), payment.RefundRequest{
  106. TradeNo: "trade-123",
  107. OrderID: "out-456",
  108. Amount: "1.50",
  109. })
  110. if err != nil {
  111. t.Fatalf("Refund returned error: %v", err)
  112. }
  113. if resp == nil || resp.Status != payment.ProviderStatusSuccess || resp.RefundID != "trade-123" {
  114. t.Fatalf("Refund response = %+v, want success with trade refund id", resp)
  115. }
  116. if len(gotForms) != 2 {
  117. t.Fatalf("refund attempts = %d, want 2", len(gotForms))
  118. }
  119. if got := gotForms[0].Get("out_trade_no"); got != "out-456" {
  120. t.Fatalf("first form[out_trade_no] = %q, want out-456 (form=%v)", got, gotForms[0])
  121. }
  122. if got := gotForms[0].Get("trade_no"); got != "" {
  123. t.Fatalf("first form[trade_no] = %q, want empty (form=%v)", got, gotForms[0])
  124. }
  125. if got := gotForms[1].Get("trade_no"); got != "trade-123" {
  126. t.Fatalf("second form[trade_no] = %q, want trade-123 (form=%v)", got, gotForms[1])
  127. }
  128. if got := gotForms[1].Get("out_trade_no"); got != "" {
  129. t.Fatalf("second form[out_trade_no] = %q, want empty (form=%v)", got, gotForms[1])
  130. }
  131. }
  132. func TestEasyPayRefundResponseErrors(t *testing.T) {
  133. t.Parallel()
  134. tests := []struct {
  135. name string
  136. statusCode int
  137. body string
  138. want string
  139. }{
  140. {name: "html response", statusCode: http.StatusOK, body: "<html>bad config</html>", want: "non-JSON response (HTTP 200): <html>bad config</html>"},
  141. {name: "non json response", statusCode: http.StatusOK, body: "not json", want: "non-JSON response (HTTP 200): not json"},
  142. {name: "non 2xx response", statusCode: http.StatusBadGateway, body: "bad gateway", want: "HTTP 502: bad gateway"},
  143. {name: "empty response", statusCode: http.StatusOK, body: "", want: "empty response (HTTP 200): <empty>"},
  144. }
  145. for _, tt := range tests {
  146. t.Run(tt.name, func(t *testing.T) {
  147. t.Parallel()
  148. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  149. w.WriteHeader(tt.statusCode)
  150. _, _ = w.Write([]byte(tt.body))
  151. }))
  152. defer server.Close()
  153. provider := newTestEasyPay(t, server.URL)
  154. _, err := provider.Refund(context.Background(), payment.RefundRequest{
  155. OrderID: "out-456",
  156. Amount: "1.50",
  157. })
  158. if err == nil {
  159. t.Fatal("Refund returned nil error")
  160. }
  161. if !strings.Contains(err.Error(), tt.want) {
  162. t.Fatalf("Refund error = %q, want substring %q", err.Error(), tt.want)
  163. }
  164. })
  165. }
  166. }
  167. func TestSummarizeEasyPayResponsePreservesUTF8(t *testing.T) {
  168. t.Parallel()
  169. summary := summarizeEasyPayResponse([]byte(strings.Repeat("错", 171)))
  170. if !utf8.ValidString(summary) {
  171. t.Fatalf("summarizeEasyPayResponse returned invalid UTF-8: %q", summary)
  172. }
  173. if !strings.HasSuffix(summary, "...") {
  174. t.Fatalf("summarizeEasyPayResponse() = %q, want truncated suffix", summary)
  175. }
  176. }
  177. func TestEasyPayCustomMethodsUseConfiguredUpstreamType(t *testing.T) {
  178. t.Parallel()
  179. provider, err := NewEasyPay("test-instance", map[string]string{
  180. "pid": "pid-1",
  181. "pkey": "pkey-1",
  182. "apiBase": "https://pay.example.com",
  183. "notifyUrl": "https://example.com/notify",
  184. "returnUrl": "https://example.com/return",
  185. "paymentMode": paymentModePopup,
  186. "customMethods": `[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":"USDT-TRC20"}]`,
  187. })
  188. if err != nil {
  189. t.Fatalf("NewEasyPay: %v", err)
  190. }
  191. resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
  192. OrderID: "sub2-custom-1",
  193. Amount: "1.00",
  194. PaymentType: "usdt_trc20",
  195. Subject: "Custom EasyPay",
  196. })
  197. if err != nil {
  198. t.Fatalf("CreatePayment: %v", err)
  199. }
  200. payURL, err := url.Parse(resp.PayURL)
  201. if err != nil {
  202. t.Fatalf("parse pay url: %v", err)
  203. }
  204. if got := payURL.Query().Get("type"); got != "usdt" {
  205. t.Fatalf("pay url type = %q, want usdt (%s)", got, resp.PayURL)
  206. }
  207. }
  208. func TestEasyPayCustomMethodsResolveCIDFromConfiguredUpstreamType(t *testing.T) {
  209. t.Parallel()
  210. provider, err := NewEasyPay("test-instance", map[string]string{
  211. "pid": "pid-1",
  212. "pkey": "pkey-1",
  213. "apiBase": "https://pay.example.com",
  214. "notifyUrl": "https://example.com/notify",
  215. "returnUrl": "https://example.com/return",
  216. "paymentMode": paymentModePopup,
  217. "cidAlipay": "cid-alipay",
  218. "cidWxpay": "cid-wxpay",
  219. "customMethods": `[{"type":"ldc","upstreamType":"alipay","displayName":"LDC"}]`,
  220. })
  221. if err != nil {
  222. t.Fatalf("NewEasyPay: %v", err)
  223. }
  224. resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
  225. OrderID: "sub2-custom-cid",
  226. Amount: "1.00",
  227. PaymentType: "ldc",
  228. Subject: "Custom EasyPay CID",
  229. })
  230. if err != nil {
  231. t.Fatalf("CreatePayment: %v", err)
  232. }
  233. payURL, err := url.Parse(resp.PayURL)
  234. if err != nil {
  235. t.Fatalf("parse pay url: %v", err)
  236. }
  237. if got := payURL.Query().Get("type"); got != "alipay" {
  238. t.Fatalf("pay url type = %q, want alipay (%s)", got, resp.PayURL)
  239. }
  240. if got := payURL.Query().Get("cid"); got != "cid-alipay" {
  241. t.Fatalf("pay url cid = %q, want cid-alipay (%s)", got, resp.PayURL)
  242. }
  243. }
  244. func TestEasyPaySupportedTypesIncludeCustomMethods(t *testing.T) {
  245. t.Parallel()
  246. provider, err := NewEasyPay("test-instance", map[string]string{
  247. "pid": "pid-1",
  248. "pkey": "pkey-1",
  249. "apiBase": "https://pay.example.com",
  250. "notifyUrl": "https://example.com/notify",
  251. "returnUrl": "https://example.com/return",
  252. "customMethods": `[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":"USDT-TRC20"}]`,
  253. })
  254. if err != nil {
  255. t.Fatalf("NewEasyPay: %v", err)
  256. }
  257. got := strings.Join(provider.SupportedTypes(), ",")
  258. for _, want := range []string{"alipay", "wxpay", "ldc", "usdt_trc20"} {
  259. if !strings.Contains(got, want) {
  260. t.Fatalf("SupportedTypes() = %q, want it to include %q", got, want)
  261. }
  262. }
  263. }
  264. func newTestEasyPay(t *testing.T, apiBase string) *EasyPay {
  265. t.Helper()
  266. provider, err := NewEasyPay("test-instance", map[string]string{
  267. "pid": "pid-1",
  268. "pkey": "pkey-1",
  269. "apiBase": apiBase,
  270. "notifyUrl": "https://example.com/notify",
  271. "returnUrl": "https://example.com/return",
  272. })
  273. if err != nil {
  274. t.Fatalf("NewEasyPay: %v", err)
  275. }
  276. return provider
  277. }