package shuncheng import ( "context" "encoding/json" "net/http" "net/http/httptest" "strconv" "testing" ) func TestSignUsesDocumentedBase64LowerMD5(t *testing.T) { params := map[string]string{ "service": "get_tdcode", "merchant_id": "1234567890", "third_order_id": "NO1", "amount": "100", "nonce_str": "abc", "req_serial_no": "req001", "empty": "", "sign": "ignored", } got := SignStrings(params, "secret") want := "ZTM5YTQ1NDk4YmRhODJlYTYyOWQzMGI2N2M5YzFjYjQ=" if got != want { t.Fatalf("SignStrings() = %s, want %s", got, want) } baseString := SignBaseString(stringMapToAny(params)) wantBaseString := "amount=100&merchant_id=1234567890&nonce_str=abc&req_serial_no=req001&service=get_tdcode&third_order_id=NO1" if baseString != wantBaseString { t.Fatalf("SignBaseString() = %s, want %s", baseString, wantBaseString) } } func TestSignSortsNestedObjectLikeDocs(t *testing.T) { params := map[string]any{ "reqSerialNo": "20260318110810747", "data": map[string]any{ "merchantId": "M1", "applyAmount": 10000, "reqId": "R1", "ignored": nil, }, } baseString := SignBaseString(params) wantBaseString := `data={"applyAmount":10000,"merchantId":"M1","reqId":"R1"}&reqSerialNo=20260318110810747` if baseString != wantBaseString { t.Fatalf("SignBaseString() = %s, want %s", baseString, wantBaseString) } got := Sign(params, "secret") want := "N2VlOWY3OWJlZmU4YThiNjEwN2Y1NWYxNzI0ZjY0NTY=" if got != want { t.Fatalf("Sign() = %s, want %s", got, want) } } func TestParsePayloadNormalizesXMLAliases(t *testing.T) { params, err := ParsePayload([]byte(` `)) if err != nil { t.Fatalf("ParsePayload() error = %v", err) } if params["provider_order_id"] != "SC1" { t.Fatalf("provider_order_id = %q, want SC1", params["provider_order_id"]) } if params["provider_refund_id"] != "RF1" { t.Fatalf("provider_refund_id = %q, want RF1", params["provider_refund_id"]) } } func TestParsePayloadExtractsEscapedXMLFromHTML(t *testing.T) { params, err := ParsePayload([]byte(`<schc><resp_code>200</resp_code><result_code>0</result_code><third_order_id>PAY1</third_order_id><schc_order_id>SC1</schc_order_id><jspay_url>https://pay.example/sc1</jspay_url></schc>`)) if err != nil { t.Fatalf("ParsePayload() error = %v", err) } if params["provider_order_id"] != "SC1" || params["jspay_url"] != "https://pay.example/sc1" { t.Fatalf("ParsePayload() = %#v, want extracted shuncheng xml", params) } } func TestParsePayloadAcceptsEscapedURLQueryInHTMLXML(t *testing.T) { params, err := ParsePayload([]byte(`<schc><resp_code>200</resp_code><result_code>0</result_code><third_order_id>PAY1</third_order_id><schc_order_id>SC1</schc_order_id><jspay_url>https://pay.example/sc1?mid=1&pu=2</jspay_url></schc>`)) if err != nil { t.Fatalf("ParsePayload() error = %v", err) } if params["jspay_url"] != "https://pay.example/sc1?mid=1&pu=2" { t.Fatalf("jspay_url = %q, want query decoded", params["jspay_url"]) } } func TestParsePayloadAcceptsJSONStringXML(t *testing.T) { xmlText := `200516911845000EFB11258D694634F727A8FCCF922904MD5PAY1https://pay.example/sc1?mid=1&pu=20SC1` body, err := json.Marshal(xmlText) if err != nil { t.Fatalf("json marshal xml text: %v", err) } params, err := ParsePayload(body) if err != nil { t.Fatalf("ParsePayload() error = %v", err) } if params["provider_order_id"] != "SC1" || params["jspay_url"] != "https://pay.example/sc1?mid=1&pu=2" { t.Fatalf("ParsePayload() = %#v, want xml string decoded", params) } } func TestParsePayloadTrimsBOMBeforeXML(t *testing.T) { params, err := ParsePayload([]byte("\xef\xbb\xbf2000PAY1SC1")) if err != nil { t.Fatalf("ParsePayload() error = %v", err) } if params["provider_order_id"] != "SC1" { t.Fatalf("provider_order_id = %q, want SC1", params["provider_order_id"]) } } func TestParsePayloadRejectsPlainHTML(t *testing.T) { if _, err := ParsePayload([]byte(`invalid semicolon; separator`)); err == nil { t.Fatal("ParsePayload() error = nil, want unsupported html error") } } func TestVerifyNotifyAcceptsDocumentedAndLegacySignatures(t *testing.T) { client := NewClient(Config{NotifyKey: "notify-secret"}) params := map[string]string{ "merchant_id": "1234567890", "third_order_id": "NO1", "schc_order_id": "SC1", "amount": "100", "status": "2", "sign_type": "MD5", } params["sign"] = SignStrings(params, "notify-secret") if result := client.VerifyNotifyDetail(params); !result.OK { t.Fatalf("VerifyNotifyDetail().OK = false, got=%s expected=%v", result.Got, result.Expected) } params["sign"] = SignStringsUpperMD5(params, "notify-secret") if result := client.VerifyNotifyDetail(params); !result.OK { t.Fatalf("VerifyNotifyDetail() legacy upper md5 OK = false, got=%s expected=%v", result.Got, result.Expected) } params["amount"] = "101" if client.VerifyNotify(params) { t.Fatal("VerifyNotify() = true after amount changed, want false") } } func TestCreatePaymentFetchesTokenOnceAndPostsSignedJSON(t *testing.T) { resetTokenCacheForTest() var tokenCount int var createCount int var captured map[string]string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case endpointToken: tokenCount++ w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"respCode":"200","data":"token-1"}`)) case endpointSimplePay: createCount++ if r.Header.Get("Authorization") != "Bearer token-1" { t.Fatalf("Authorization = %q, want Bearer token-1", r.Header.Get("Authorization")) } if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { t.Fatalf("decode create payment request: %v", err) } if captured["sign"] != SignStrings(captured, "secret-key") { t.Fatalf("sign = %q, want recalculated %q", captured["sign"], SignStrings(captured, "secret-key")) } w.Header().Set("Content-Type", "application/xml") _, _ = w.Write([]byte(`2000PAY1SC1https://pay.example/sc1`)) default: t.Fatalf("unexpected path %s", r.URL.Path) } })) defer server.Close() client := NewClient(Config{ GatewayURL: server.URL, MerchantID: "merchant-1", SecretID: "secret-id", SecretKey: "secret-key", NotifyURL: "https://example.com/notify", }) for i := 0; i < 2; i++ { resp, err := client.CreatePayment(context.Background(), CreatePaymentRequest{ ThirdOrderID: "PAY1", AmountCent: 100, Body: "租号订单", }) if err != nil { t.Fatalf("CreatePayment() error = %v", err) } if !resp.OK || resp.ProviderOrderID != "SC1" || resp.JSPayURL == "" { t.Fatalf("CreatePayment() response = %+v", resp) } } if tokenCount != 1 { t.Fatalf("tokenCount = %d, want 1", tokenCount) } if createCount != 2 { t.Fatalf("createCount = %d, want 2", createCount) } if captured["notify_url"] != "https://example.com/notify" { t.Fatalf("notify_url = %q, want configured notify url", captured["notify_url"]) } } func TestUnauthorizedRefreshesTokenOnce(t *testing.T) { resetTokenCacheForTest() var tokenCount int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case endpointToken: tokenCount++ w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"respCode":"200","data":"token-` + strconv.Itoa(tokenCount) + `"}`)) case endpointPaymentQuery: if r.Header.Get("Authorization") == "Bearer token-1" { w.WriteHeader(http.StatusUnauthorized) return } if r.Header.Get("Authorization") != "Bearer token-2" { t.Fatalf("Authorization = %q, want Bearer token-2", r.Header.Get("Authorization")) } w.Header().Set("Content-Type", "application/xml") _, _ = w.Write([]byte(`2000PAY1SC12`)) default: t.Fatalf("unexpected path %s", r.URL.Path) } })) defer server.Close() client := NewClient(Config{ GatewayURL: server.URL, MerchantID: "merchant-1", SecretID: "secret-id", SecretKey: "secret-key", }) resp, err := client.QueryPayment(context.Background(), "PAY1", "") if err != nil { t.Fatalf("QueryPayment() error = %v", err) } if !resp.OK || resp.Status != "paid" || resp.ProviderOrderID != "SC1" { t.Fatalf("QueryPayment() response = %+v", resp) } if tokenCount != 2 { t.Fatalf("tokenCount = %d, want 2", tokenCount) } } func resetTokenCacheForTest() { tokenCache.Lock() tokenCache.items = map[string]tokenEntry{} tokenCache.Unlock() }