feat(t227): query ERP directly from Go
This commit is contained in:
@@ -14,7 +14,6 @@ import (
|
||||
"cmroubao/backend-api/internal/config"
|
||||
"cmroubao/backend-api/internal/platform/assetstore"
|
||||
"cmroubao/backend-api/internal/platform/database"
|
||||
"cmroubao/backend-api/internal/platform/erpconnector"
|
||||
"cmroubao/backend-api/internal/platform/migration"
|
||||
"cmroubao/backend-api/internal/platform/password"
|
||||
"cmroubao/backend-api/internal/platform/shunyunbao"
|
||||
@@ -219,18 +218,6 @@ func buildRouter(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
erpTimeout := cfg.ERPConnectorTimeout
|
||||
if erpTimeout <= 0 {
|
||||
erpTimeout = 90 * time.Second
|
||||
}
|
||||
erpClient, err := erpconnector.New(
|
||||
cfg.ERPConnectorURL,
|
||||
cfg.ERPConnectorAPIKey,
|
||||
erpTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
erpSession, err := shunyunbao.NewSessionManager(shunyunbao.SessionConfig{
|
||||
BaseURL: cfg.ShunyunbaoURL,
|
||||
Username: cfg.ShunyunbaoUsername,
|
||||
@@ -242,10 +229,10 @@ func buildRouter(
|
||||
}
|
||||
freight, err := usecase.NewFreightService(
|
||||
store,
|
||||
erpClient,
|
||||
erpSession,
|
||||
clock,
|
||||
ids,
|
||||
erpTimeout,
|
||||
90*time.Second,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -212,6 +212,7 @@ func (manager *SessionManager) Login(
|
||||
LoginPath,
|
||||
payload,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
return manager.statusLocked(), err
|
||||
@@ -251,6 +252,7 @@ func (manager *SessionManager) validateLocked(ctx context.Context) (any, error)
|
||||
UserPath,
|
||||
nil,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -266,6 +268,7 @@ func (manager *SessionManager) requestJSONLocked(
|
||||
method, path string,
|
||||
body []byte,
|
||||
loginRequest bool,
|
||||
requireSession bool,
|
||||
) (any, error) {
|
||||
var content io.Reader
|
||||
if body != nil {
|
||||
@@ -300,19 +303,28 @@ func (manager *SessionManager) requestJSONLocked(
|
||||
return nil, domain.ErrFreightSourceUnavailable
|
||||
}
|
||||
var envelope struct {
|
||||
Status bool `json:"status"`
|
||||
Status *bool `json:"status"`
|
||||
Code json.RawMessage `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(contentBytes))
|
||||
if err := decoder.Decode(&envelope); err != nil || len(envelope.Data) == 0 {
|
||||
if err := decoder.Decode(&envelope); err != nil || envelope.Status == nil ||
|
||||
len(envelope.Data) == 0 {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
if !envelope.Status {
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
if !*envelope.Status {
|
||||
if loginRequest {
|
||||
return nil, ErrLoginRejected
|
||||
}
|
||||
manager.authenticated = false
|
||||
return nil, domain.ErrFreightSourceSessionNeeded
|
||||
if requireSession || unauthenticatedCode(envelope.Code) {
|
||||
manager.authenticated = false
|
||||
return nil, domain.ErrFreightSourceSessionNeeded
|
||||
}
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
var data any
|
||||
dataDecoder := json.NewDecoder(bytes.NewReader(envelope.Data))
|
||||
@@ -320,9 +332,29 @@ func (manager *SessionManager) requestJSONLocked(
|
||||
if err := dataDecoder.Decode(&data); err != nil {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
if err := dataDecoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func unauthenticatedCode(raw json.RawMessage) bool {
|
||||
var value any
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
if len(raw) == 0 || decoder.Decode(&value) != nil {
|
||||
return false
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
return typed == "-2"
|
||||
case string:
|
||||
return strings.TrimSpace(typed) == "-2"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *SessionManager) applyHeaders(request *http.Request) {
|
||||
for name, values := range manager.headers {
|
||||
request.Header[name] = append([]string(nil), values...)
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
package shunyunbao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
sourcePageSize = 20
|
||||
sourceMaxMatches = 100
|
||||
)
|
||||
|
||||
// QueryOrder implements the freight source port using the manager's single
|
||||
// in-memory ERP session. It never exposes the ERP list or detail responses.
|
||||
func (manager *SessionManager) QueryOrder(
|
||||
ctx context.Context,
|
||||
orderNumber string,
|
||||
) (domain.FreightSourceBatch, error) {
|
||||
query, err := OrderNumberQuery(orderNumber)
|
||||
if err != nil {
|
||||
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
records, err := manager.queryRecords(ctx, query)
|
||||
if err != nil {
|
||||
return domain.FreightSourceBatch{}, err
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return domain.FreightSourceBatch{}, domain.ErrFreightSourceNotFound
|
||||
}
|
||||
return NormalizeFreightResult(
|
||||
RawQuery{Mode: domain.FreightSyncOrderNumber},
|
||||
records,
|
||||
)
|
||||
}
|
||||
|
||||
// QueryCreatedRange implements the bounded Asia/Shanghai date-window source
|
||||
// port. The caller owns larger-window splitting and watermark semantics.
|
||||
func (manager *SessionManager) QueryCreatedRange(
|
||||
ctx context.Context,
|
||||
createdFrom, createdTo string,
|
||||
) (domain.FreightSourceBatch, error) {
|
||||
query, err := CreatedRangeQuery(createdFrom, createdTo)
|
||||
if err != nil {
|
||||
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
records, err := manager.queryRecords(ctx, query)
|
||||
if err != nil {
|
||||
return domain.FreightSourceBatch{}, err
|
||||
}
|
||||
return NormalizeFreightResult(
|
||||
RawQuery{
|
||||
Mode: domain.FreightSyncCreatedRange,
|
||||
CreatedFrom: createdFrom,
|
||||
CreatedTo: createdTo,
|
||||
},
|
||||
records,
|
||||
)
|
||||
}
|
||||
|
||||
func (manager *SessionManager) queryRecords(
|
||||
ctx context.Context,
|
||||
query QueryCondition,
|
||||
) ([]RawRecord, error) {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
if !manager.configuredLocked() {
|
||||
return nil, domain.ErrFreightSourceNotConfigured
|
||||
}
|
||||
if !manager.authenticated {
|
||||
return nil, domain.ErrFreightSourceSessionNeeded
|
||||
}
|
||||
if _, err := manager.validateLocked(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stocks, err := manager.queryStocksLocked(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(stocks) == 0 {
|
||||
return []RawRecord{}, nil
|
||||
}
|
||||
details, err := manager.queryDetailsLocked(ctx, stocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records := make([]RawRecord, 0, len(stocks))
|
||||
for _, stock := range stocks {
|
||||
stockID, err := sourceExternalID(stock["id"])
|
||||
if err != nil {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
detail, exists := details[stockID]
|
||||
if !exists {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
records = append(records, RawRecord{Stock: stock, Detail: detail})
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (manager *SessionManager) queryStocksLocked(
|
||||
ctx context.Context,
|
||||
query QueryCondition,
|
||||
) ([]map[string]any, error) {
|
||||
firstPayload, err := StockListPayload(query, 0, 1, sourcePageSize)
|
||||
if err != nil {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
firstBody, err := json.Marshal(firstPayload)
|
||||
if err != nil {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
totalData, err := manager.requestJSONLocked(
|
||||
ctx,
|
||||
"POST",
|
||||
StockListTotalPath,
|
||||
firstBody,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
total, err := sourceTotal(totalData)
|
||||
if err != nil || total > sourceMaxMatches {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
if total == 0 {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
|
||||
rows := make([]map[string]any, 0, total)
|
||||
for start := 0; start < total; start += sourcePageSize {
|
||||
payload, err := StockListPayload(
|
||||
query,
|
||||
start,
|
||||
start/sourcePageSize+1,
|
||||
sourcePageSize,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
data, err := manager.requestJSONLocked(
|
||||
ctx,
|
||||
"POST",
|
||||
StockListPath,
|
||||
body,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page, err := sourceObjectList(data)
|
||||
if err != nil || len(page) > sourcePageSize {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
rows = append(rows, page...)
|
||||
}
|
||||
if len(rows) != total {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
stocksByID := make(map[string]map[string]any, len(rows))
|
||||
stocks := make([]map[string]any, 0, len(rows))
|
||||
for _, stock := range rows {
|
||||
stockID, err := sourceExternalID(stock["id"])
|
||||
if err != nil {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
if existing, exists := stocksByID[stockID]; exists {
|
||||
if !reflect.DeepEqual(existing, stock) {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
continue
|
||||
}
|
||||
stocksByID[stockID] = stock
|
||||
stocks = append(stocks, stock)
|
||||
}
|
||||
return stocks, nil
|
||||
}
|
||||
|
||||
func (manager *SessionManager) queryDetailsLocked(
|
||||
ctx context.Context,
|
||||
stocks []map[string]any,
|
||||
) (map[string]map[string]any, error) {
|
||||
ids := make([]string, 0, len(stocks))
|
||||
for _, stock := range stocks {
|
||||
stockID, err := sourceExternalID(stock["id"])
|
||||
if err != nil {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
ids = append(ids, stockID)
|
||||
}
|
||||
detailsByID := make(map[string]map[string]any, len(ids))
|
||||
requestedIDs := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
requestedIDs[id] = struct{}{}
|
||||
}
|
||||
for start := 0; start < len(ids); start += maxDetailIDs {
|
||||
end := start + maxDetailIDs
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
payload, err := StockDetailPayload(ids[start:end])
|
||||
if err != nil {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
data, err := manager.requestJSONLocked(
|
||||
ctx,
|
||||
"POST",
|
||||
StockDetailPath+"?hist=0",
|
||||
body,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values, err := sourceObjectList(data)
|
||||
if err != nil {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
for _, detail := range values {
|
||||
detailID, err := sourceExternalID(detail["id"])
|
||||
if err != nil {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
if _, requested := requestedIDs[detailID]; !requested {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
if existing, exists := detailsByID[detailID]; exists &&
|
||||
!reflect.DeepEqual(existing, detail) {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
detailsByID[detailID] = detail
|
||||
}
|
||||
}
|
||||
if len(detailsByID) != len(ids) {
|
||||
return nil, domain.ErrFreightSourceProtocol
|
||||
}
|
||||
return detailsByID, nil
|
||||
}
|
||||
|
||||
func sourceTotal(value any) (int, error) {
|
||||
var text string
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
text = string(typed)
|
||||
case string:
|
||||
text = typed
|
||||
default:
|
||||
return 0, strconv.ErrSyntax
|
||||
}
|
||||
parsed, err := strconv.ParseInt(text, 10, 0)
|
||||
if err != nil || parsed < 0 || parsed > int64(sourceMaxMatches) {
|
||||
return 0, strconv.ErrSyntax
|
||||
}
|
||||
return int(parsed), nil
|
||||
}
|
||||
|
||||
func sourceObjectList(value any) ([]map[string]any, error) {
|
||||
data, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, strconv.ErrSyntax
|
||||
}
|
||||
rawList, ok := data["list"].([]any)
|
||||
if !ok {
|
||||
return nil, strconv.ErrSyntax
|
||||
}
|
||||
result := make([]map[string]any, 0, len(rawList))
|
||||
for _, value := range rawList {
|
||||
row, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, strconv.ErrSyntax
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func sourceExternalID(value any) (string, error) {
|
||||
return externalID(value)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
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}}}`))
|
||||
case UserPath:
|
||||
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}}`))
|
||||
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":"BLACK-L","productQty":2,"receiverTel":"private-item-phone"},{"id":89,"productTitle":"商品二","productSpec":"黑色,XL","sku":"BLACK-XL","productQty":1}]}]}}`))
|
||||
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 != "BLACK-L" ||
|
||||
result.Orders[0].Items[1].SKU != "BLACK-XL" {
|
||||
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}}}`))
|
||||
case UserPath:
|
||||
_, _ = writer.Write([]byte(`{"status":true,"data":{"id":1}}`))
|
||||
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
|
||||
}
|
||||
@@ -672,7 +672,7 @@ func daysInclusive(start, end time.Time) int {
|
||||
func freightSourceErrorCode(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrFreightSourceNotConfigured):
|
||||
return "ERP_CONNECTOR_NOT_CONFIGURED"
|
||||
return "ERP_NOT_CONFIGURED"
|
||||
case errors.Is(err, domain.ErrFreightSourceSessionNeeded):
|
||||
return "ERP_SESSION_REQUIRED"
|
||||
case errors.Is(err, domain.ErrFreightSourceNotFound):
|
||||
@@ -680,7 +680,7 @@ func freightSourceErrorCode(err error) string {
|
||||
case errors.Is(err, domain.ErrFreightSourceProtocol):
|
||||
return "ERP_RESPONSE_INVALID"
|
||||
default:
|
||||
return "ERP_CONNECTOR_UNAVAILABLE"
|
||||
return "ERP_UNAVAILABLE"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,24 @@ func TestFreightNormalizationRejectsConflictingIdentityAndInvalidTime(
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreightSourceErrorCodesAreSourceNeutral(t *testing.T) {
|
||||
cases := []struct {
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{domain.ErrFreightSourceNotConfigured, "ERP_NOT_CONFIGURED"},
|
||||
{domain.ErrFreightSourceSessionNeeded, "ERP_SESSION_REQUIRED"},
|
||||
{domain.ErrFreightSourceNotFound, "ERP_FREIGHT_NOT_FOUND"},
|
||||
{domain.ErrFreightSourceProtocol, "ERP_RESPONSE_INVALID"},
|
||||
{errors.New("temporary source failure"), "ERP_UNAVAILABLE"},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
if got := freightSourceErrorCode(testCase.err); got != testCase.want {
|
||||
t.Fatalf("freightSourceErrorCode(%v) = %q, want %q", testCase.err, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreightDateQuerySplitsIntoSevenDayWindows(t *testing.T) {
|
||||
source := &recordingDateSource{}
|
||||
service := &FreightService{source: source}
|
||||
|
||||
Reference in New Issue
Block a user