feat(t239): persist freight item metadata
This commit is contained in:
@@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("initial Up() error = %v", err)
|
||||
} else if applied != 14 {
|
||||
t.Fatalf("initial Up() applied = %d, want 14", applied)
|
||||
} else if applied != 15 {
|
||||
t.Fatalf("initial Up() applied = %d, want 15", applied)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("initial Down(v15) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("initial Down(v14) error = %v", err)
|
||||
@@ -71,9 +74,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
seedClaimsHistoricalFixture(t, db)
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("Up(v5-v14) over historical data error = %v", err)
|
||||
} else if applied != 10 {
|
||||
t.Fatalf("Up(v5-v14) applied = %d, want 10", applied)
|
||||
t.Fatalf("Up(v5-v15) over historical data error = %v", err)
|
||||
} else if applied != 11 {
|
||||
t.Fatalf("Up(v5-v15) applied = %d, want 11", applied)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v15) with compatible history error = %v", err)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
|
||||
@@ -133,9 +141,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
assertClaimsHistory(t, db, false)
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("final Up(v4-v14) error = %v", err)
|
||||
} else if applied != 11 {
|
||||
t.Fatalf("final Up(v4-v14) applied = %d, want 11", applied)
|
||||
t.Fatalf("final Up(v4-v15) error = %v", err)
|
||||
} else if applied != 12 {
|
||||
t.Fatalf("final Up(v4-v15) applied = %d, want 12", applied)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
}
|
||||
@@ -371,6 +379,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
|
||||
t.Fatalf("insert v4 audit event: %v", err)
|
||||
}
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v15) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v14) error = %v", err)
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Up() error = %v", err)
|
||||
}
|
||||
if applied != 14 {
|
||||
t.Fatalf("Up() applied = %d, want 14", applied)
|
||||
if applied != 15 {
|
||||
t.Fatalf("Up() applied = %d, want 15", applied)
|
||||
}
|
||||
assertStatuses(t, runner, map[int64]bool{
|
||||
1: true,
|
||||
@@ -45,6 +45,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
12: true,
|
||||
13: true,
|
||||
14: true,
|
||||
15: true,
|
||||
})
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
@@ -72,7 +73,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
11: true,
|
||||
12: true,
|
||||
13: true,
|
||||
14: false,
|
||||
14: true,
|
||||
15: false,
|
||||
})
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
@@ -97,6 +99,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
12: true,
|
||||
13: true,
|
||||
14: true,
|
||||
15: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -156,21 +156,109 @@ func normalizeItem(value map[string]any) (domain.FreightSourceItem, error) {
|
||||
if title == "" {
|
||||
title = text(value["detailProductName"])
|
||||
}
|
||||
sku := text(value["sku"])
|
||||
if sku == "" {
|
||||
sku = text(value["variationSku"])
|
||||
productSpec := text(value["productSpec"])
|
||||
productThumbRef, err := optionalExternalID(value["productThumb"])
|
||||
if err != nil {
|
||||
return domain.FreightSourceItem{}, err
|
||||
}
|
||||
originalUnitPriceMinor, err := priceMinor(value["productPrice"])
|
||||
if err != nil {
|
||||
return domain.FreightSourceItem{}, err
|
||||
}
|
||||
return domain.FreightSourceItem{
|
||||
ExternalItemID: externalItemID,
|
||||
Title: title,
|
||||
ProductSpec: text(value["productSpec"]),
|
||||
SKU: sku,
|
||||
Quantity: positiveInt(value["productQty"]),
|
||||
ProductThumbRef: optionalScalar(value["productThumb"]),
|
||||
PurchaseStatus: optionalScalar(value["purchaseStatus"]),
|
||||
ExternalItemID: externalItemID,
|
||||
Title: title,
|
||||
ProductSpec: productSpec,
|
||||
SKU: productSpec,
|
||||
Quantity: positiveInt(value["productQty"]),
|
||||
ProductThumbRef: productThumbRef,
|
||||
OriginalUnitPriceMinor: originalUnitPriceMinor,
|
||||
OriginalCurrency: domain.FreightCurrencyTWD,
|
||||
PurchaseStatus: optionalScalar(value["purchaseStatus"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func optionalExternalID(value any) (*string, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if typed, ok := value.(string); ok && strings.TrimSpace(typed) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
normalized, err := externalID(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &normalized, nil
|
||||
}
|
||||
|
||||
func priceMinor(value any) (*int64, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var raw string
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
raw = strings.TrimSpace(typed)
|
||||
case json.Number:
|
||||
raw = string(typed)
|
||||
case float64:
|
||||
if math.IsNaN(typed) || math.IsInf(typed, 0) || typed < 0 {
|
||||
return nil, errInvalidProtocolInput
|
||||
}
|
||||
raw = strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
case int:
|
||||
raw = strconv.Itoa(typed)
|
||||
case int64:
|
||||
raw = strconv.FormatInt(typed, 10)
|
||||
case uint64:
|
||||
raw = strconv.FormatUint(typed, 10)
|
||||
default:
|
||||
return nil, errInvalidProtocolInput
|
||||
}
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
whole, fraction, hasFraction := strings.Cut(raw, ".")
|
||||
if whole == "" || strings.Contains(fraction, ".") ||
|
||||
!decimalDigits(whole) || (hasFraction && !decimalDigits(fraction)) ||
|
||||
len(fraction) > 2 {
|
||||
return nil, errInvalidProtocolInput
|
||||
}
|
||||
wholeValue, err := strconv.ParseUint(whole, 10, 64)
|
||||
if err != nil || wholeValue > uint64(math.MaxInt64)/100 {
|
||||
return nil, errInvalidProtocolInput
|
||||
}
|
||||
minor := wholeValue * 100
|
||||
if hasFraction {
|
||||
fractionValue, err := strconv.ParseUint(fraction, 10, 8)
|
||||
if err != nil {
|
||||
return nil, errInvalidProtocolInput
|
||||
}
|
||||
if len(fraction) == 1 {
|
||||
fractionValue *= 10
|
||||
}
|
||||
minor += fractionValue
|
||||
}
|
||||
if minor > uint64(math.MaxInt64) {
|
||||
return nil, errInvalidProtocolInput
|
||||
}
|
||||
result := int64(minor)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func decimalDigits(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if char < '0' || char > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func externalID(value any) (string, error) {
|
||||
var normalized uint64
|
||||
switch typed := value.(type) {
|
||||
|
||||
@@ -86,8 +86,11 @@ func TestNormalizeFreightFixtureUsesOnlyAllowlist(t *testing.T) {
|
||||
order := result.Orders[0]
|
||||
if order.ExternalStockID != "12" || order.SourceCode != "SANITIZED-CODE-12" ||
|
||||
order.ShopName == nil || *order.ShopName != "Sanitized shop" ||
|
||||
order.Items[0].ExternalItemID != "88" || order.Items[0].SKU != "BLACK-L" ||
|
||||
order.Items[0].Quantity == nil || *order.Items[0].Quantity != 2 {
|
||||
order.Items[0].ExternalItemID != "88" || order.Items[0].SKU != "Black,L" ||
|
||||
order.Items[0].Quantity == nil || *order.Items[0].Quantity != 2 ||
|
||||
order.Items[0].OriginalUnitPriceMinor == nil ||
|
||||
*order.Items[0].OriginalUnitPriceMinor != 12950 ||
|
||||
order.Items[0].OriginalCurrency != domain.FreightCurrencyTWD {
|
||||
t.Fatalf("allowlist result = %#v", order)
|
||||
}
|
||||
encoded, err := json.Marshal(result)
|
||||
@@ -105,6 +108,77 @@ func TestNormalizeFreightFixtureUsesOnlyAllowlist(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFreightPriceAndThumbContract(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
price any
|
||||
thumb any
|
||||
wantMinor *int64
|
||||
wantThumb *string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "integer", price: json.Number("12"), thumb: json.Number("190"), wantMinor: int64Pointer(1200), wantThumb: stringPointer("190")},
|
||||
{name: "one decimal", price: "12.3", thumb: "0190", wantMinor: int64Pointer(1230), wantThumb: stringPointer("190")},
|
||||
{name: "two decimals", price: json.Number("12.34"), wantMinor: int64Pointer(1234)},
|
||||
{name: "missing", price: "", thumb: "", wantMinor: nil, wantThumb: nil},
|
||||
{name: "negative", price: "-1", wantErr: true},
|
||||
{name: "exponent", price: json.Number("1e2"), wantErr: true},
|
||||
{name: "excess precision", price: "1.001", wantErr: true},
|
||||
{name: "overflow", price: "92233720368547758.08", wantErr: true},
|
||||
{name: "invalid thumb text", price: "1", thumb: "https://invalid.example/image", wantErr: true},
|
||||
{name: "invalid thumb zero", price: "1", thumb: 0, wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := NormalizeFreightResult(
|
||||
RawQuery{Mode: domain.FreightSyncOrderNumber},
|
||||
[]RawRecord{{
|
||||
Stock: map[string]any{"id": 12, "code": "SANITIZED"},
|
||||
Detail: map[string]any{
|
||||
"id": 12,
|
||||
"details": []any{map[string]any{
|
||||
"id": 88,
|
||||
"productSpec": "Black,L",
|
||||
"productPrice": test.price,
|
||||
"productThumb": test.thumb,
|
||||
}},
|
||||
},
|
||||
}},
|
||||
)
|
||||
if test.wantErr {
|
||||
if !errors.Is(err, domain.ErrFreightSourceProtocol) {
|
||||
t.Fatalf("NormalizeFreightResult() error = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeFreightResult() error = %v", err)
|
||||
}
|
||||
item := result.Orders[0].Items[0]
|
||||
if !equalInt64Pointer(item.OriginalUnitPriceMinor, test.wantMinor) ||
|
||||
!equalStringPointer(item.ProductThumbRef, test.wantThumb) ||
|
||||
item.ProductSpec != "Black,L" || item.SKU != "Black,L" ||
|
||||
item.OriginalCurrency != domain.FreightCurrencyTWD {
|
||||
t.Fatalf("normalized item = %#v", item)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func int64Pointer(value int64) *int64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
func equalInt64Pointer(left, right *int64) bool {
|
||||
return left == nil && right == nil ||
|
||||
left != nil && right != nil && *left == *right
|
||||
}
|
||||
|
||||
func equalStringPointer(left, right *string) bool {
|
||||
return left == nil && right == nil ||
|
||||
left != nil && right != nil && *left == *right
|
||||
}
|
||||
|
||||
func TestNormalizeFreightNeverLeaksRawValuesInErrors(t *testing.T) {
|
||||
_, err := NormalizeFreightResult(
|
||||
RawQuery{Mode: domain.FreightSyncOrderNumber},
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestSessionManagerQueryOrderUsesVerifiedSessionAndAllowlist(t *testing.T) {
|
||||
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}]}]}}`))
|
||||
_, _ = 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)
|
||||
}
|
||||
@@ -67,8 +67,10 @@ func TestSessionManagerQueryOrderUsesVerifiedSessionAndAllowlist(t *testing.T) {
|
||||
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" {
|
||||
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)
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"sku": "BLACK-L",
|
||||
"productQty": 2,
|
||||
"productThumb": 190,
|
||||
"productPrice": "129.50",
|
||||
"purchaseStatus": 0,
|
||||
"receiverTel": "item-phone-not-allowed"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user