package shunyunbao import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strconv" "strings" "testing" "cmroubao/backend-api/internal/domain" ) func TestSessionManagerQueryOrderUsesVerifiedSessionAndAllowlist(t *testing.T) { calls := make([]string, 0, 8) server := httptest.NewServer(http.HandlerFunc(func( writer http.ResponseWriter, request *http.Request, ) { calls = append(calls, request.URL.Path) switch request.URL.Path { case CaptchaPath: http.SetCookie(writer, &http.Cookie{Name: "captcha", Value: "ready", Path: "/"}) writer.Header().Set("Content-Type", "image/png") _, _ = writer.Write([]byte("captcha")) case LoginPath: assertERPHeaders(t, request) if cookie, err := request.Cookie("captcha"); err != nil || cookie.Value != "ready" { t.Fatalf("login captcha cookie = %v / %v", cookie, err) } http.SetCookie(writer, &http.Cookie{Name: "authenticated", Value: "yes", Path: "/"}) _, _ = writer.Write([]byte(`{"status":true,"data":{"user":{"id":1,"username":"test-user"}}}`)) case UserPath: if request.URL.Query().Get("id") != "1" { t.Fatalf("user query = %q", request.URL.RawQuery) } if cookie, err := request.Cookie("authenticated"); err != nil || cookie.Value != "yes" { t.Fatalf("user cookie = %v / %v", cookie, err) } _, _ = writer.Write([]byte(`{"status":true,"data":{"id":1,"username":"test-user"}}`)) case StockListTotalPath: assertStockPayload(t, request, "SOURCE-12", 0, 1, 20) _, _ = writer.Write([]byte(`{"status":true,"data":1}`)) case StockListPath: assertStockPayload(t, request, "SOURCE-12", 0, 1, 20) _, _ = writer.Write([]byte(`{"status":true,"data":{"list":[{"id":12,"code":"SOURCE-12","orderCode":"PLATFORM-12","receiver":"private-recipient","receiverTel":"private-phone"}]}}`)) case StockDetailPath: if request.URL.Query().Get("hist") != "0" { t.Fatalf("detail query = %q", request.URL.RawQuery) } assertDetailPayload(t, request, []uint64{12}) _, _ = writer.Write([]byte(`{"status":true,"data":{"list":[{"id":12,"shopName":"测试店铺","created":"2026-07-28 08:00:00","details":[{"id":88,"productTitle":"商品一","productSpec":"黑色,L","sku":"IGNORED-1","productQty":2,"productPrice":129.5,"productThumb":190,"receiverTel":"private-item-phone"},{"id":89,"productTitle":"商品二","productSpec":"黑色,XL","variationSku":"IGNORED-2","productQty":1,"productPrice":"88","productThumb":"191"}]}]}}`)) default: writer.WriteHeader(http.StatusNotFound) } })) defer server.Close() manager := testSessionManager(t, server.URL, "test-user", "test-password") loginForSource(t, manager) result, err := manager.QueryOrder(context.Background(), "SOURCE-12") if err != nil { t.Fatalf("QueryOrder() error = %v", err) } if result.SchemaVersion != 1 || result.Query.Mode != domain.FreightSyncOrderNumber || len(result.Orders) != 1 || result.Orders[0].ExternalStockID != "12" || result.Orders[0].ShopName == nil || *result.Orders[0].ShopName != "测试店铺" || len(result.Orders[0].Items) != 2 || result.Orders[0].Items[0].SKU != "黑色,L" || result.Orders[0].Items[1].SKU != "黑色,XL" || result.Orders[0].Items[0].OriginalUnitPriceMinor == nil || *result.Orders[0].Items[0].OriginalUnitPriceMinor != 12950 { t.Fatalf("QueryOrder() = %#v", result) } encoded, err := json.Marshal(result) if err != nil { t.Fatalf("marshal result: %v", err) } for _, forbidden := range []string{"private-recipient", "private-phone", "private-item-phone"} { if strings.Contains(string(encoded), forbidden) { t.Fatalf("result leaked raw ERP value %q: %s", forbidden, encoded) } } wantCalls := []string{ CaptchaPath, LoginPath, UserPath, UserPath, StockListTotalPath, StockListPath, StockDetailPath, } if strings.Join(calls, ",") != strings.Join(wantCalls, ",") { t.Fatalf("endpoint calls = %#v, want %#v", calls, wantCalls) } } func TestSessionManagerQueryCreatedRangePaginatesAndDeduplicates(t *testing.T) { listCalls := 0 detailIDs := make([]uint64, 0) server := httptest.NewServer(http.HandlerFunc(func( writer http.ResponseWriter, request *http.Request, ) { switch request.URL.Path { case CaptchaPath: http.SetCookie(writer, &http.Cookie{Name: "captcha", Value: "ready", Path: "/"}) writer.Header().Set("Content-Type", "image/png") _, _ = writer.Write([]byte("captcha")) case LoginPath: http.SetCookie(writer, &http.Cookie{Name: "authenticated", Value: "yes", Path: "/"}) _, _ = writer.Write([]byte(`{"status":true,"data":{"user":{"id":1,"username":"test-user"}}}`)) case UserPath: if request.URL.Query().Get("id") != "1" { t.Fatalf("user query = %q", request.URL.RawQuery) } _, _ = writer.Write([]byte(`{"status":true,"data":{"id":1,"username":"test-user"}}`)) case StockListTotalPath: assertStockPayload(t, request, "2026-07-22,2026-07-28", 0, 1, 20) _, _ = writer.Write([]byte(`{"status":true,"data":21}`)) case StockListPath: listCalls++ if listCalls == 1 { assertStockPayload(t, request, "2026-07-22,2026-07-28", 0, 1, 20) _, _ = writer.Write(stockListEnvelope(1, 20)) return } assertStockPayload(t, request, "2026-07-22,2026-07-28", 20, 2, 20) _, _ = writer.Write(stockListIDsEnvelope([]int{20})) case StockDetailPath: ids := decodeDetailPayload(t, request) detailIDs = append(detailIDs, ids...) _, _ = writer.Write(stockDetailEnvelope(ids)) default: writer.WriteHeader(http.StatusNotFound) } })) defer server.Close() manager := testSessionManager(t, server.URL, "test-user", "test-password") loginForSource(t, manager) result, err := manager.QueryCreatedRange( context.Background(), "2026-07-22", "2026-07-28", ) if err != nil { t.Fatalf("QueryCreatedRange() error = %v", err) } if listCalls != 2 || len(detailIDs) != 20 || len(result.Orders) != 20 || result.Query.CreatedFrom == nil || *result.Query.CreatedFrom != "2026-07-22" || result.Query.CreatedTo == nil || *result.Query.CreatedTo != "2026-07-28" { t.Fatalf("range query result = %#v, list calls = %d, detail IDs = %#v", result, listCalls, detailIDs) } seen := make(map[string]struct{}, len(result.Orders)) for _, order := range result.Orders { if _, exists := seen[order.ExternalStockID]; exists { t.Fatalf("duplicate normalized order = %q", order.ExternalStockID) } seen[order.ExternalStockID] = struct{}{} } } func TestSessionManagerQueryRequiresConfiguredAuthenticatedSession(t *testing.T) { manager := testSessionManager(t, "http://127.0.0.1:1", "", "") if _, err := manager.QueryOrder(context.Background(), "SOURCE-12"); !errors.Is(err, domain.ErrFreightSourceNotConfigured) { t.Fatalf("unconfigured QueryOrder() error = %v", err) } manager = testSessionManager(t, "http://127.0.0.1:1", "test-user", "test-password") if _, err := manager.QueryOrder(context.Background(), "SOURCE-12"); !errors.Is(err, domain.ErrFreightSourceSessionNeeded) { t.Fatalf("unauthenticated QueryOrder() error = %v", err) } } func loginForSource(t *testing.T, manager *SessionManager) { t.Helper() status, err := manager.FetchCaptcha(context.Background()) if err != nil { t.Fatalf("FetchCaptcha() error = %v", err) } if _, err := manager.Login(context.Background(), status.CaptchaTicket, "1234"); err != nil { t.Fatalf("Login() error = %v", err) } } func assertStockPayload( t *testing.T, request *http.Request, wantQuery string, wantStart, wantPage, wantLength int, ) { t.Helper() assertERPHeaders(t, request) if request.Method != http.MethodPost { t.Fatalf("stock method = %s", request.Method) } var payload struct { Length int `json:"length"` Start int `json:"start"` PageIndex int `json:"pageIndex"` Queries []struct { Value string `json:"dvalue"` } `json:"queries"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Fatalf("decode stock payload: %v", err) } if payload.Length != wantLength || payload.Start != wantStart || payload.PageIndex != wantPage || len(payload.Queries) != 1 || payload.Queries[0].Value != wantQuery { t.Fatalf("stock payload = %#v", payload) } } func assertDetailPayload(t *testing.T, request *http.Request, want []uint64) { t.Helper() got := decodeDetailPayload(t, request) if len(got) != len(want) { t.Fatalf("detail ids = %#v, want %#v", got, want) } for index := range want { if got[index] != want[index] { t.Fatalf("detail ids = %#v, want %#v", got, want) } } } func decodeDetailPayload(t *testing.T, request *http.Request) []uint64 { t.Helper() assertERPHeaders(t, request) var payload struct { IDs []uint64 `json:"ids"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Fatalf("decode detail payload: %v", err) } return payload.IDs } func stockListEnvelope(first, last int) []byte { ids := make([]int, 0, last-first+1) for value := first; value <= last; value++ { ids = append(ids, value) } return stockListIDsEnvelope(ids) } func stockListIDsEnvelope(ids []int) []byte { rows := make([]map[string]any, 0, len(ids)) for _, id := range ids { rows = append(rows, map[string]any{"id": id, "code": "SOURCE-" + strconv.Itoa(id)}) } content, _ := json.Marshal(map[string]any{ "status": true, "data": map[string]any{"list": rows}, }) return content } func stockDetailEnvelope(ids []uint64) []byte { rows := make([]map[string]any, 0, len(ids)) for _, id := range ids { rows = append(rows, map[string]any{ "id": id, "details": []any{}, }) } content, _ := json.Marshal(map[string]any{ "status": true, "data": map[string]any{"list": rows}, }) return content }