feat(t239): persist freight item metadata

This commit is contained in:
QiuSW
2026-07-29 14:54:58 +08:00
parent 42006acedb
commit 3bd9726ddc
23 changed files with 476 additions and 158 deletions
@@ -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"
}