219 lines
8.8 KiB
Go
219 lines
8.8 KiB
Go
package erpgo
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"cmsp/internal/config"
|
|
"cmsp/internal/store"
|
|
)
|
|
|
|
const fictionalKey = "fictional-api-key-for-tests"
|
|
|
|
func writeResponse(w http.ResponseWriter, status int, data any, code string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"code": status, "data": data, "errorCode": code, "requestId": "demo-request-001", "msg": fictionalKey})
|
|
}
|
|
|
|
func sampleProduct(id string) product {
|
|
return product{ID: id, ItemID: "demo-item-" + id, PlatformShopID: "demo-shop", ItemStatus: "NORMAL", VideoDiagnosis: "missing", Diagnoses: []store.Diagnosis{{Field: "ALL", Type: "缺少视频", Solution: "上传视频"}}}
|
|
}
|
|
|
|
func samplePage(current int, records ...product) productPage {
|
|
return productPage{metadata: metadata{Source: "huohanhan", FetchedAt: "2026-09-28T10:00:00+08:00"}, PlatformShopID: "demo-shop", ItemStatus: "NORMAL", Current: current, Size: pageSize, Total: 201, Pages: 2, Records: records}
|
|
}
|
|
|
|
func testClient(t *testing.T, handler http.HandlerFunc) *Client {
|
|
t.Helper()
|
|
server := httptest.NewServer(handler)
|
|
t.Cleanup(server.Close)
|
|
client, err := NewClient(config.ERPGoConfig{BaseURL: server.URL, APIKey: fictionalKey}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return client
|
|
}
|
|
|
|
func assertCode(t *testing.T, err error, code string) {
|
|
t.Helper()
|
|
var e *Error
|
|
if !errors.As(err, &e) || e.Code != code {
|
|
t.Fatalf("expected %s, got %v", code, err)
|
|
}
|
|
if strings.Contains(err.Error(), fictionalKey) || strings.Contains(LogSummary(err), fictionalKey) {
|
|
t.Fatal("error exposed fictional credential")
|
|
}
|
|
}
|
|
|
|
func TestSequentialPaginationDeduplicatesAndMapsFields(t *testing.T) {
|
|
requests := []int{}
|
|
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "GET" || r.URL.Path != "/api/v1/integrations/huohanhan/products" || r.Header.Get("X-API-Key") != fictionalKey || r.URL.Query().Get("size") != "200" || r.URL.Query().Get("platformShopId") != "demo-shop" {
|
|
t.Errorf("unexpected query request")
|
|
}
|
|
current, _ := strconv.Atoi(r.URL.Query().Get("current"))
|
|
requests = append(requests, current)
|
|
p := sampleProduct("one")
|
|
p.CreateTime = "2026-09-01 10:00:00"
|
|
if current == 1 {
|
|
p.ItemName = "old fictional title"
|
|
writeResponse(w, 200, samplePage(current, p), "")
|
|
} else {
|
|
p.ItemName = "new fictional title"
|
|
writeResponse(w, 200, samplePage(current, p, sampleProduct("two")), "")
|
|
}
|
|
})
|
|
progress := 0
|
|
items, diagnoses, err := client.DownloadAllProducts(context.Background(), "demo-shop", func(current, total int) {
|
|
progress++
|
|
if current != progress || total != 2 {
|
|
t.Error("incorrect progress")
|
|
}
|
|
})
|
|
if err != nil || len(items) != 2 || len(requests) != 2 || requests[0] != 1 || requests[1] != 2 || progress != 2 {
|
|
t.Fatalf("pagination failed: %v, count=%d", err, len(items))
|
|
}
|
|
if items[0].ID != "one" || items[0].ItemID != "demo-item-one" || items[0].CreatedAt != "2026-09-01 10:00:00" || items[0].ItemName != "new fictional title" || diagnoses["one"][0].ProductID != "one" {
|
|
t.Fatal("IDs, timestamp, duplicate or diagnosis conversion changed")
|
|
}
|
|
}
|
|
|
|
func TestSecondPageFailureReturnsNoPartialData(t *testing.T) {
|
|
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Query().Get("current") == "1" {
|
|
writeResponse(w, 200, samplePage(1, sampleProduct("one")), "")
|
|
} else {
|
|
writeResponse(w, 502, nil, "HHH_UPSTREAM_ERROR")
|
|
}
|
|
})
|
|
items, diagnoses, err := client.DownloadAllProducts(context.Background(), "demo-shop", nil)
|
|
assertCode(t, err, "HHH_UPSTREAM_ERROR")
|
|
if items != nil || diagnoses != nil {
|
|
t.Fatal("partial sync data escaped")
|
|
}
|
|
}
|
|
|
|
func TestInvalidPagesAndRecordsFailClosed(t *testing.T) {
|
|
cases := map[string]func(*productPage){
|
|
"wrong page": func(p *productPage) { p.Current = 2 },
|
|
"wrong page size": func(p *productPage) { p.Size = 0 },
|
|
"over limit": func(p *productPage) { p.Pages = 201; p.Total = 40200 },
|
|
"negative total": func(p *productPage) { p.Total = -1 },
|
|
"inconsistent metadata": func(p *productPage) { p.Pages = 1 },
|
|
"null records": func(p *productPage) { p.Records = nil },
|
|
"premature empty": func(p *productPage) { p.Records = []product{} },
|
|
"wrong shop": func(p *productPage) { p.PlatformShopID = "other" },
|
|
"cross-shop record": func(p *productPage) { p.Records[0].PlatformShopID = "other" },
|
|
"missing ID": func(p *productPage) { p.Records[0].ID = "" },
|
|
"missing item ID": func(p *productPage) { p.Records[0].ItemID = "" },
|
|
"non-selling": func(p *productPage) { p.Records[0].ItemStatus = "UNLIST" },
|
|
"invalid diagnosis": func(p *productPage) { p.Records[0].VideoDiagnosis = "unknown" },
|
|
"null diagnoses": func(p *productPage) { p.Records[0].Diagnoses = nil },
|
|
"invalid source": func(p *productPage) { p.Source = "cache" },
|
|
"invalid timestamp": func(p *productPage) { p.FetchedAt = "yesterday" },
|
|
}
|
|
for name, mutate := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
client := testClient(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
p := samplePage(1, sampleProduct("one"))
|
|
mutate(&p)
|
|
writeResponse(w, 200, p, "")
|
|
})
|
|
items, diagnoses, err := client.DownloadAllProducts(context.Background(), "demo-shop", nil)
|
|
assertCode(t, err, "INVALID_RESPONSE")
|
|
if items != nil || diagnoses != nil {
|
|
t.Fatal("invalid data escaped")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEmptyShopAndNoDiagnosis(t *testing.T) {
|
|
for _, empty := range []bool{true, false} {
|
|
client := testClient(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
p := samplePage(1)
|
|
p.Pages, p.Total, p.Records = 0, 0, []product{}
|
|
if !empty {
|
|
item := sampleProduct("one")
|
|
item.VideoDiagnosis, item.Diagnoses = "ok", []store.Diagnosis{}
|
|
p.Pages, p.Total, p.Records = 1, 1, []product{item}
|
|
}
|
|
writeResponse(w, 200, p, "")
|
|
})
|
|
items, diagnoses, err := client.DownloadAllProducts(context.Background(), "demo-shop", nil)
|
|
if err != nil || items == nil || diagnoses == nil || (!empty && (items[0].VideoDiagnosis != "ok" || len(diagnoses["one"]) != 0)) {
|
|
t.Fatalf("legal empty data failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHTTPErrorCodesAndRedaction(t *testing.T) {
|
|
for code, status := range map[string]int{"INVALID_ARGUMENT": 400, "API_KEY_INVALID": 401, "SHOP_ACCESS_DENIED": 403, "HHH_AUTH_FAILED": 502, "HHH_UPSTREAM_ERROR": 502, "SERVICE_UNAVAILABLE": 503, "HHH_UPSTREAM_TIMEOUT": 504, "RATE_LIMITED": 429, "INTERNAL_ERROR": 500} {
|
|
t.Run(code, func(t *testing.T) {
|
|
client := testClient(t, func(w http.ResponseWriter, _ *http.Request) { writeResponse(w, status, nil, code) })
|
|
_, err := client.ListShops(context.Background())
|
|
assertCode(t, err, code)
|
|
var e *Error
|
|
_ = errors.As(err, &e)
|
|
if e.Status != status || e.RequestID != "demo-request-001" {
|
|
t.Fatal("lost error reference")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestShopsAndEmptyShops(t *testing.T) {
|
|
for _, empty := range []bool{true, false} {
|
|
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "GET" || r.URL.Path != "/api/v1/integrations/huohanhan/shops" || r.Header.Get("X-API-Key") != fictionalKey {
|
|
t.Error("unexpected shop query")
|
|
}
|
|
items := []Shop{}
|
|
if !empty {
|
|
items = append(items, Shop{ID: "internal-shop", PlatformShopID: "demo-shop", ShopName: "虚构测试店铺", Platform: "0"})
|
|
}
|
|
writeResponse(w, 200, map[string]any{"source": "huohanhan", "fetchedAt": "2026-09-28T10:00:00+08:00", "items": items}, "")
|
|
})
|
|
shops, err := client.ListShops(context.Background())
|
|
if err != nil || shops == nil || (!empty && shops[0].PlatformShopID != "demo-shop") {
|
|
t.Fatalf("shop mapping failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMalformedResponseRedirectAndCancellation(t *testing.T) {
|
|
for _, raw := range []string{"not-json", `{"code":200,"data":null}`, `{"code":200,"data":{"items":null}}`} {
|
|
client := testClient(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(raw)) })
|
|
_, err := client.ListShops(context.Background())
|
|
assertCode(t, err, "INVALID_RESPONSE")
|
|
}
|
|
targetRequests := 0
|
|
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { targetRequests++ }))
|
|
defer target.Close()
|
|
client := testClient(t, func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, 302) })
|
|
_, err := client.ListShops(context.Background())
|
|
assertCode(t, err, "INVALID_RESPONSE")
|
|
if targetRequests != 0 {
|
|
t.Fatal("redirect followed with credential")
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
_, err = client.ListShops(ctx)
|
|
assertCode(t, err, "REQUEST_CANCELLED")
|
|
if safeRequestID(fictionalKey, fictionalKey) != "" {
|
|
t.Fatal("request ID exposed credential")
|
|
}
|
|
}
|
|
|
|
func TestMissingConfig(t *testing.T) {
|
|
_, err := NewClient(config.ERPGoConfig{}, nil)
|
|
assertCode(t, err, "ERPGo_NOT_CONFIGURED")
|
|
}
|