feat(purchase): allow absolute price ceiling (#180)
This commit is contained in:
@@ -313,7 +313,7 @@ func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem {
|
||||
return s.previewFromDataset(ctx, id, dataset, true, purchasecontract.EffectivePriceGuard(currentRule))
|
||||
}
|
||||
|
||||
func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard purchasecontract.PriceGuard) BatchPreviewItem {
|
||||
func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard purchasecontract.PriceLimits) BatchPreviewItem {
|
||||
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
|
||||
if err != nil {
|
||||
item := BatchPreviewItem{SYBProductID: id}
|
||||
@@ -343,7 +343,7 @@ func taskPointerValue(value *uint64) uint64 {
|
||||
return *value
|
||||
}
|
||||
|
||||
func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset batchPreviewDataset, allowAI bool, guard purchasecontract.PriceGuard) BatchPreviewItem {
|
||||
func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset batchPreviewDataset, allowAI bool, guard purchasecontract.PriceLimits) BatchPreviewItem {
|
||||
item := BatchPreviewItem{SYBProductID: id}
|
||||
syb, found := dataset.sybByID[id]
|
||||
if !found {
|
||||
@@ -431,7 +431,7 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
|
||||
return item
|
||||
}
|
||||
|
||||
func purchasePriceRange(raw, mappedColor string, guard purchasecontract.PriceGuard) (reference, minPrice, maxPrice int64, err error) {
|
||||
func purchasePriceRange(raw, mappedColor string, guard purchasecontract.PriceLimits) (reference, minPrice, maxPrice int64, err error) {
|
||||
var dimensions []productspec.Dimension
|
||||
if json.Unmarshal([]byte(raw), &dimensions) != nil {
|
||||
return 0, 0, 0, errors.New("拼多多商品规格数据无效,请先重新采集")
|
||||
@@ -458,6 +458,9 @@ func purchasePriceRange(raw, mappedColor string, guard purchasecontract.PriceGua
|
||||
}
|
||||
sort.Slice(prices, func(i, j int) bool { return prices[i] < prices[j] })
|
||||
low, high := prices[0], prices[len(prices)-1]
|
||||
if !guard.Enabled {
|
||||
return high, 0, guard.AbsoluteMaxUnitPriceCent, nil
|
||||
}
|
||||
minRatio, maxRatio := int64(guard.MinRatio), int64(guard.MaxRatio)
|
||||
return high, low * minRatio / purchasecontract.RatioScale, (high*maxRatio + purchasecontract.RatioScale - 1) / purchasecontract.RatioScale, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
@@ -43,18 +44,56 @@ func TestBatchPreviewUsesPDDPriceAndExplainsIneligibleRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateFreezesAbsolutePriceCeilingWhenRatiosAreDisabled(t *testing.T) {
|
||||
db := testDB(t)
|
||||
fixture := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, fixture.pdd.ID)
|
||||
var rule map[string]any
|
||||
if err := json.Unmarshal(purchasecontract.DefaultLiveRule(), &rule); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule["priceGuard"] = map[string]any{"enabled": false, "absoluteMaxUnitPriceCent": 5000}
|
||||
rawRule, err := json.Marshal(rule)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.PurchaseRule{}).Where("deleted_at IS NULL").Update("content_json", string(rawRule)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID}, DeviceID: &fixture.device.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(preview.Items) != 1 || !preview.Items[0].Eligible || preview.Items[0].MinUnitPriceCent != 0 || preview.Items[0].MaxUnitPriceCent != 5000 {
|
||||
t.Fatalf("disabled preview range mismatch: %+v", preview)
|
||||
}
|
||||
created, err := testService(db).BatchCreate(context.Background(), BatchCreateRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{fixture.syb.ID}, DeviceID: &fixture.device.ID})
|
||||
if err != nil || created.CreatedCount != 1 || created.Items[0].TaskID == nil {
|
||||
t.Fatalf("disabled batch create failed: %+v %v", created, err)
|
||||
}
|
||||
var task models.PurchaseTask
|
||||
if err := db.First(&task, *created.Items[0].TaskID).Error; err != nil || task.MinUnitPriceCent != 0 || task.MaxUnitPriceCent != 5000 {
|
||||
t.Fatalf("absolute task range was not frozen: %+v %v", task, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchasePriceRangeUsesExactConfiguredRatiosAndLegacyDefaults(t *testing.T) {
|
||||
raw := `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1001},{"name":"白色","selectable":true,"priceCent":1003}]}]`
|
||||
legacy := purchasecontract.PriceGuard{MinRatio: purchasecontract.Ratio(purchasecontract.DefaultMinRatio), MaxRatio: purchasecontract.Ratio(purchasecontract.DefaultMaxRatio)}
|
||||
legacy := purchasecontract.PriceLimits{Enabled: true, MinRatio: purchasecontract.Ratio(purchasecontract.DefaultMinRatio), MaxRatio: purchasecontract.Ratio(purchasecontract.DefaultMaxRatio)}
|
||||
ref, minPrice, maxPrice, err := purchasePriceRange(raw, "", legacy)
|
||||
if err != nil || ref != 1003 || minPrice != 200 || maxPrice != 1505 {
|
||||
t.Fatalf("legacy range=%d,%d,%d err=%v", ref, minPrice, maxPrice, err)
|
||||
}
|
||||
custom := purchasecontract.PriceGuard{MinRatio: purchasecontract.Ratio(3333), MaxRatio: purchasecontract.Ratio(22500)}
|
||||
custom := purchasecontract.PriceLimits{Enabled: true, MinRatio: purchasecontract.Ratio(3333), MaxRatio: purchasecontract.Ratio(22500)}
|
||||
ref, minPrice, maxPrice, err = purchasePriceRange(raw, "", custom)
|
||||
if err != nil || ref != 1003 || minPrice != 333 || maxPrice != 2257 {
|
||||
t.Fatalf("custom range=%d,%d,%d err=%v", ref, minPrice, maxPrice, err)
|
||||
}
|
||||
disabled := purchasecontract.PriceLimits{Enabled: false, AbsoluteMaxUnitPriceCent: 5000}
|
||||
ref, minPrice, maxPrice, err = purchasePriceRange(raw, "", disabled)
|
||||
if err != nil || ref != 1003 || minPrice != 0 || maxPrice != 5000 {
|
||||
t.Fatalf("absolute range=%d,%d,%d err=%v", ref, minPrice, maxPrice, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreviewUsesLinkActionBeforePDDMapping(t *testing.T) {
|
||||
|
||||
@@ -386,6 +386,7 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
|
||||
a.Status = models.PurchaseAttemptStatusFailed
|
||||
t.ErrorCode = &req.ErrorCode
|
||||
t.ErrorMessage = &req.ErrorMessage
|
||||
t.ActualUnitPriceCent = req.ActualUnitPriceCent
|
||||
default:
|
||||
return TaskPayload{}, fail(CodeInvalidRequest, "resultType 无效")
|
||||
}
|
||||
|
||||
@@ -81,11 +81,11 @@ func (s *Service) ProcessStages(ctx context.Context, ids []uint64) (map[uint64]P
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) readOnlyPriceGuard(ctx context.Context) (purchasecontract.PriceGuard, string, error) {
|
||||
func (s *Service) readOnlyPriceGuard(ctx context.Context) (purchasecontract.PriceLimits, string, error) {
|
||||
_, _, currentRule, err := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
|
||||
return readOnlyRuleResult(currentRule, err)
|
||||
}
|
||||
func readOnlyRuleResult(currentRule purchasecontract.RuleSnapshot, err error) (purchasecontract.PriceGuard, string, error) {
|
||||
func readOnlyRuleResult(currentRule purchasecontract.RuleSnapshot, err error) (purchasecontract.PriceLimits, string, error) {
|
||||
if err == nil {
|
||||
return purchasecontract.EffectivePriceGuard(currentRule), "", nil
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func readOnlyRuleResult(currentRule purchasecontract.RuleSnapshot, err error) (p
|
||||
if errors.As(err, &serviceErr) && (serviceErr.Code == purchaserule.CodeCurrentRuleMissing || serviceErr.Code == purchaserule.CodeRuleInvalid) {
|
||||
return purchasecontract.EffectivePriceGuard(purchasecontract.RuleSnapshot{}), serviceErr.Message, nil
|
||||
}
|
||||
return purchasecontract.PriceGuard{}, "", err
|
||||
return purchasecontract.PriceLimits{}, "", err
|
||||
}
|
||||
|
||||
func (item *BatchPreviewItem) applyProcessStage(stage ProcessStageResult) {
|
||||
|
||||
@@ -247,6 +247,35 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedResultStoresObservedUnitPrice(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task, err := createLive(t, s, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
started, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual := int64(368)
|
||||
result, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
|
||||
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "failed",
|
||||
ErrorCode: "PURCHASE_PRICE_OUT_OF_RANGE", ErrorMessage: "当前商品单价超出允许范围", ActualUnitPriceCent: &actual,
|
||||
}, f.token)
|
||||
if err != nil || result.Status != models.PurchaseTaskStatusFailed {
|
||||
t.Fatalf("failed result rejected: %+v %v", result, err)
|
||||
}
|
||||
var stored models.PurchaseTask
|
||||
if err := db.First(&stored, task.ID).Error; err != nil || stored.ActualUnitPriceCent == nil || *stored.ActualUnitPriceCent != actual {
|
||||
t.Fatalf("failed actual unit price was not stored: %+v %v", stored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityMismatchAtCreateAndClaim(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseLiveV1}, true)
|
||||
|
||||
@@ -87,16 +87,56 @@ type RuleSnapshot struct {
|
||||
}
|
||||
|
||||
const (
|
||||
RatioScale = int64(10_000)
|
||||
DefaultMinRatio = int64(2_000)
|
||||
DefaultMaxRatio = int64(15_000)
|
||||
RatioScale = int64(10_000)
|
||||
DefaultMinRatio = int64(2_000)
|
||||
DefaultMaxRatio = int64(15_000)
|
||||
MaxAbsoluteUnitPriceCent = int64(100_000)
|
||||
)
|
||||
|
||||
// PriceGuard uses fixed basis points internally so cent calculations never
|
||||
// depend on binary floating-point rounding. JSON remains a regular decimal.
|
||||
type PriceGuard struct {
|
||||
MinRatio Ratio `json:"minRatio"`
|
||||
MaxRatio Ratio `json:"maxRatio"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
MinRatio *Ratio `json:"minRatio,omitempty"`
|
||||
MaxRatio *Ratio `json:"maxRatio,omitempty"`
|
||||
AbsoluteMaxUnitPriceCent *int64 `json:"absoluteMaxUnitPriceCent,omitempty"`
|
||||
enabledSet bool
|
||||
minRatioSet bool
|
||||
maxRatioSet bool
|
||||
absoluteMaxSet bool
|
||||
}
|
||||
|
||||
func (g *PriceGuard) UnmarshalJSON(raw []byte) error {
|
||||
type wire PriceGuard
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
var value wire
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureEOF(decoder); err != nil {
|
||||
return err
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return err
|
||||
}
|
||||
if enabled, exists := fields["enabled"]; exists && bytes.Equal(bytes.TrimSpace(enabled), []byte("null")) {
|
||||
return errors.New("priceGuard.enabled 必须是布尔值")
|
||||
}
|
||||
*g = PriceGuard(value)
|
||||
_, g.enabledSet = fields["enabled"]
|
||||
_, g.minRatioSet = fields["minRatio"]
|
||||
_, g.maxRatioSet = fields["maxRatio"]
|
||||
_, g.absoluteMaxSet = fields["absoluteMaxUnitPriceCent"]
|
||||
return nil
|
||||
}
|
||||
|
||||
type PriceLimits struct {
|
||||
Enabled bool
|
||||
MinRatio Ratio
|
||||
MaxRatio Ratio
|
||||
AbsoluteMaxUnitPriceCent int64
|
||||
}
|
||||
|
||||
type Ratio int64
|
||||
@@ -118,11 +158,16 @@ func (r Ratio) MarshalJSON() ([]byte, error) {
|
||||
return []byte(strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.4f", float64(r)/float64(RatioScale)), "0"), ".")), nil
|
||||
}
|
||||
|
||||
func EffectivePriceGuard(rule RuleSnapshot) PriceGuard {
|
||||
func EffectivePriceGuard(rule RuleSnapshot) PriceLimits {
|
||||
if rule.PriceGuard == nil {
|
||||
return PriceGuard{MinRatio: Ratio(DefaultMinRatio), MaxRatio: Ratio(DefaultMaxRatio)}
|
||||
return PriceLimits{Enabled: true, MinRatio: Ratio(DefaultMinRatio), MaxRatio: Ratio(DefaultMaxRatio)}
|
||||
}
|
||||
return *rule.PriceGuard
|
||||
guard := rule.PriceGuard
|
||||
enabled := guard.Enabled == nil || *guard.Enabled
|
||||
if !enabled {
|
||||
return PriceLimits{Enabled: false, AbsoluteMaxUnitPriceCent: *guard.AbsoluteMaxUnitPriceCent}
|
||||
}
|
||||
return PriceLimits{Enabled: true, MinRatio: *guard.MinRatio, MaxRatio: *guard.MaxRatio}
|
||||
}
|
||||
|
||||
func Validate(raw []byte, executionMode string) (RuleSnapshot, error) {
|
||||
@@ -145,15 +190,31 @@ func Validate(raw []byte, executionMode string) (RuleSnapshot, error) {
|
||||
return rule, errors.New("actions 必须包含 1..64 个类型化动作")
|
||||
}
|
||||
if rule.PriceGuard != nil {
|
||||
minRatio, maxRatio := int64(rule.PriceGuard.MinRatio), int64(rule.PriceGuard.MaxRatio)
|
||||
if minRatio < 1_000 || minRatio > 10_000 {
|
||||
return rule, errors.New("priceGuard.minRatio 必须在 0.1..1.0 之间")
|
||||
}
|
||||
if maxRatio < 10_000 || maxRatio > 30_000 {
|
||||
return rule, errors.New("priceGuard.maxRatio 必须在 1.0..3.0 之间")
|
||||
}
|
||||
if minRatio > maxRatio {
|
||||
return rule, errors.New("priceGuard.minRatio 不能大于 maxRatio")
|
||||
enabled := rule.PriceGuard.Enabled == nil || *rule.PriceGuard.Enabled
|
||||
if enabled {
|
||||
if rule.PriceGuard.MinRatio == nil || rule.PriceGuard.MaxRatio == nil {
|
||||
return rule, errors.New("启用价格倍率保护时必须提供 priceGuard.minRatio 和 priceGuard.maxRatio")
|
||||
}
|
||||
if rule.PriceGuard.absoluteMaxSet {
|
||||
return rule, errors.New("启用价格倍率保护时不能提供 priceGuard.absoluteMaxUnitPriceCent")
|
||||
}
|
||||
minRatio, maxRatio := int64(*rule.PriceGuard.MinRatio), int64(*rule.PriceGuard.MaxRatio)
|
||||
if minRatio < 1_000 || minRatio > 10_000 {
|
||||
return rule, errors.New("priceGuard.minRatio 必须在 0.1..1.0 之间")
|
||||
}
|
||||
if maxRatio < 10_000 || maxRatio > 30_000 {
|
||||
return rule, errors.New("priceGuard.maxRatio 必须在 1.0..3.0 之间")
|
||||
}
|
||||
if minRatio > maxRatio {
|
||||
return rule, errors.New("priceGuard.minRatio 不能大于 maxRatio")
|
||||
}
|
||||
} else {
|
||||
if rule.PriceGuard.minRatioSet || rule.PriceGuard.maxRatioSet {
|
||||
return rule, errors.New("关闭价格倍率保护时不能提供 priceGuard.minRatio 或 priceGuard.maxRatio")
|
||||
}
|
||||
if rule.PriceGuard.AbsoluteMaxUnitPriceCent == nil || *rule.PriceGuard.AbsoluteMaxUnitPriceCent < 1 || *rule.PriceGuard.AbsoluteMaxUnitPriceCent > MaxAbsoluteUnitPriceCent {
|
||||
return rule, fmt.Errorf("关闭价格倍率保护时 priceGuard.absoluteMaxUnitPriceCent 必须在 1..%d 之间", MaxAbsoluteUnitPriceCent)
|
||||
}
|
||||
}
|
||||
}
|
||||
capabilities := make(map[string]bool, len(rule.RequiredCapabilities))
|
||||
|
||||
@@ -24,11 +24,30 @@ func TestSharedLiveRuleFixtureMatchesServerContract(t *testing.T) {
|
||||
t.Fatalf("shared live rule rejected: %v", err)
|
||||
}
|
||||
guard := EffectivePriceGuard(rule)
|
||||
if int64(guard.MinRatio) != 2000 || int64(guard.MaxRatio) != 20000 {
|
||||
if !guard.Enabled || int64(guard.MinRatio) != 2000 || int64(guard.MaxRatio) != 20000 {
|
||||
t.Fatalf("shared price guard=%+v", guard)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedDisabledPriceGuardFixtureMatchesServerContract(t *testing.T) {
|
||||
_, currentFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("cannot locate shared purchase rule fixture")
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(filepath.Dir(currentFile), "../../../../contracts/purchase-rule-live-price-guard-disabled-v1.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule, err := Validate(raw, "live")
|
||||
if err != nil {
|
||||
t.Fatalf("shared disabled price guard rule rejected: %v", err)
|
||||
}
|
||||
guard := EffectivePriceGuard(rule)
|
||||
if guard.Enabled || guard.AbsoluteMaxUnitPriceCent != 5000 {
|
||||
t.Fatalf("shared disabled price guard=%+v", guard)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRehearsalRuleAcceptsBoundedParameters(t *testing.T) {
|
||||
raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1","purchase.spec-probe.v1"],"actions":[{"type":"openProduct","textAliases":["打开拼多多APP","打开"],"waitAfterMs":1000},{"type":"openSpecPanel","textAliases":["选择规格"],"swipeAfter":{"direction":"up","count":2,"durationMs":500,"intervalMs":1000}},{"type":"probeSpecs","dimensionAliases":{"color":["颜色分类","组合"],"size":["尺码"]},"swipeAfter":{"direction":"left","count":3,"durationMs":300}}]}`)
|
||||
rule, err := Validate(raw, "rehearsal")
|
||||
@@ -64,7 +83,7 @@ func TestPriceGuardDefaultsAndExactDecimals(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if guard := EffectivePriceGuard(legacy); int64(guard.MinRatio) != 2000 || int64(guard.MaxRatio) != 15000 {
|
||||
if guard := EffectivePriceGuard(legacy); !guard.Enabled || int64(guard.MinRatio) != 2000 || int64(guard.MaxRatio) != 15000 {
|
||||
t.Fatalf("legacy defaults=%+v", guard)
|
||||
}
|
||||
raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1"],"priceGuard":{"minRatio":0.3333,"maxRatio":2.25},"actions":[{"type":"openProduct"}]}`)
|
||||
@@ -73,7 +92,7 @@ func TestPriceGuardDefaultsAndExactDecimals(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guard := EffectivePriceGuard(rule)
|
||||
if int64(guard.MinRatio) != 3333 || int64(guard.MaxRatio) != 22500 {
|
||||
if !guard.Enabled || int64(guard.MinRatio) != 3333 || int64(guard.MaxRatio) != 22500 {
|
||||
t.Fatalf("fixed ratios=%+v", guard)
|
||||
}
|
||||
encoded, err := json.Marshal(rule)
|
||||
@@ -82,6 +101,42 @@ func TestPriceGuardDefaultsAndExactDecimals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceGuardCanDisableRatiosWithAbsoluteCeiling(t *testing.T) {
|
||||
raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1"],"priceGuard":{"enabled":false,"absoluteMaxUnitPriceCent":5000},"actions":[{"type":"openProduct"}]}`)
|
||||
rule, err := Validate(raw, "live")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guard := EffectivePriceGuard(rule)
|
||||
if guard.Enabled || guard.AbsoluteMaxUnitPriceCent != 5000 || guard.MinRatio != 0 || guard.MaxRatio != 0 {
|
||||
t.Fatalf("disabled price guard=%+v", guard)
|
||||
}
|
||||
encoded, err := json.Marshal(rule)
|
||||
if err != nil || !strings.Contains(string(encoded), `"enabled":false`) || !strings.Contains(string(encoded), `"absoluteMaxUnitPriceCent":5000`) {
|
||||
t.Fatalf("encoded=%s err=%v", encoded, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceGuardRejectsUnsafeDisabledShapes(t *testing.T) {
|
||||
for _, guard := range []string{
|
||||
`{"enabled":false}`,
|
||||
`{"enabled":null,"minRatio":0.2,"maxRatio":1.5}`,
|
||||
`{"enabled":"false","absoluteMaxUnitPriceCent":5000}`,
|
||||
`{"enabled":false,"absoluteMaxUnitPriceCent":0}`,
|
||||
`{"enabled":false,"absoluteMaxUnitPriceCent":100001}`,
|
||||
`{"enabled":false,"absoluteMaxUnitPriceCent":50.5}`,
|
||||
`{"enabled":false,"absoluteMaxUnitPriceCent":5000,"minRatio":0.2}`,
|
||||
`{"enabled":false,"absoluteMaxUnitPriceCent":5000,"minRatio":null}`,
|
||||
`{"enabled":true,"absoluteMaxUnitPriceCent":5000,"minRatio":0.2,"maxRatio":1.5}`,
|
||||
`{"enabled":true,"absoluteMaxUnitPriceCent":null,"minRatio":0.2,"maxRatio":1.5}`,
|
||||
} {
|
||||
raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1"],"priceGuard":` + guard + `,"actions":[{"type":"openProduct"}]}`)
|
||||
if _, err := Validate(raw, "live"); err == nil {
|
||||
t.Fatalf("accepted price guard %s", guard)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceGuardRejectsUnsafeBounds(t *testing.T) {
|
||||
for _, guard := range []string{`{"minRatio":0.09,"maxRatio":1.5}`, `{"minRatio":0.2,"maxRatio":3.01}`, `{"minRatio":1,"maxRatio":0.9}`, `{"minRatio":0.12345,"maxRatio":1.5}`} {
|
||||
raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1"],"priceGuard":` + guard + `,"actions":[{"type":"openProduct"}]}`)
|
||||
|
||||
Reference in New Issue
Block a user