diff --git a/internal/config/types.go b/internal/config/types.go index 80a650e8..1a5312f3 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -246,15 +246,20 @@ type PurchasePlan struct { Unassigned bool `json:"unassigned,omitempty" dynamodbav:"unassigned,omitempty"` } +type RampAllocationMode string + +const RampAllocationEqualCount RampAllocationMode = "equal_count" + // RampSchedule defines how purchases are spread over time. type RampSchedule struct { - Type string `json:"type" dynamodbav:"type"` // immediate, weekly, monthly, custom - PercentPerStep float64 `json:"percent_per_step" dynamodbav:"percent_per_step"` - StepIntervalDays int `json:"step_interval_days" dynamodbav:"step_interval_days"` - CurrentStep int `json:"current_step" dynamodbav:"current_step"` - TotalSteps int `json:"total_steps" dynamodbav:"total_steps"` - StartDate time.Time `json:"start_date" dynamodbav:"start_date"` - DatesManaged bool `json:"dates_managed,omitempty" dynamodbav:"dates_managed,omitempty"` + Type string `json:"type" dynamodbav:"type"` // immediate, weekly, monthly, custom + PercentPerStep float64 `json:"percent_per_step" dynamodbav:"percent_per_step"` + StepIntervalDays int `json:"step_interval_days" dynamodbav:"step_interval_days"` + CurrentStep int `json:"current_step" dynamodbav:"current_step"` + TotalSteps int `json:"total_steps" dynamodbav:"total_steps"` + StartDate time.Time `json:"start_date" dynamodbav:"start_date"` + DatesManaged bool `json:"dates_managed,omitempty" dynamodbav:"dates_managed,omitempty"` + AllocationMode RampAllocationMode `json:"allocation_mode,omitempty" dynamodbav:"allocation_mode,omitempty"` } // PresetRampSchedules provides common ramp-up configurations. @@ -285,6 +290,9 @@ func (r *RampSchedule) CumulativePercent(step int) float64 { if r.Type == "immediate" { return 100 } + if r.AllocationMode == RampAllocationEqualCount && r.TotalSteps > 0 { + return 100 * float64(min(step, r.TotalSteps)) / float64(r.TotalSteps) + } return math.Min(100, float64(step)*r.PercentPerStep) } diff --git a/internal/config/validation.go b/internal/config/validation.go index 96af8a79..e8f3ab45 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -627,6 +627,9 @@ func (p *PurchasePlan) CheckNoMultiStepSavingsPlans() error { // Validate validates the RampSchedule. func (r *RampSchedule) Validate() error { + if err := r.ValidateAllocationMode(); err != nil { + return err + } if r.Type != "" && !isValidRampScheduleType(r.Type) { return fmt.Errorf("invalid ramp schedule type: %s (valid: %s)", r.Type, strings.Join(ValidRampScheduleTypes, ", ")) } @@ -645,6 +648,20 @@ func (r *RampSchedule) Validate() error { return nil } +func (r *RampSchedule) ValidateAllocationMode() error { + switch r.AllocationMode { + case "": + return nil + case RampAllocationEqualCount: + if r.TotalSteps < 1 || r.TotalSteps > MaxTotalSteps || (r.Type == "immediate" && r.TotalSteps != 1) { + return fmt.Errorf("invalid equal-count total steps: %d", r.TotalSteps) + } + return nil + default: + return fmt.Errorf("unsupported ramp allocation mode: %q", r.AllocationMode) + } +} + // validatePercentPerStep checks PercentPerStep bounds based on ramp type. // Step-based types require > 0; "immediate" ignores it; unset type allows 0. func (r *RampSchedule) validatePercentPerStep() error { diff --git a/internal/planstep/allocation.go b/internal/planstep/allocation.go new file mode 100644 index 00000000..94b0a64f --- /dev/null +++ b/internal/planstep/allocation.go @@ -0,0 +1,65 @@ +package planstep + +import ( + "fmt" + "math" + "math/big" + "strconv" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" +) + +func (r *ramp) validateCounts(counts ...int) error { + if r.mode == config.RampAllocationEqualCount { + for _, count := range counts { + if count < 0 { + return fmt.Errorf("negative allocation count: %d", count) + } + } + } + return nil +} + +func decimalRatio(v float64) (*big.Rat, error) { + if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 { + return nil, fmt.Errorf("invalid allocation quantity: %v", v) + } + r, ok := new(big.Rat).SetString(strconv.FormatFloat(v, 'g', -1, 64)) + if !ok { + return nil, fmt.Errorf("invalid allocation decimal: %v", v) + } + return r, nil +} + +func (r *ramp) equalCountShare(coverage float64, step int) (*big.Rat, error) { + share, err := decimalRatio(coverage) + if err != nil { + return nil, err + } + return share.Mul(share, big.NewRat(int64(min(step, r.count)), int64(100*r.count))), nil +} + +func floorAllocation(v *big.Rat) (int, error) { + n := new(big.Int).Quo(v.Num(), v.Denom()) + if !n.IsInt64() || n.Sign() < 0 || n.Cmp(big.NewInt(math.MaxInt)) > 0 { + return 0, fmt.Errorf("allocation exceeds whole-unit range") + } + return int(n.Int64()), nil +} + +func exactSPHourly(hourly float64, share *big.Rat) (float64, error) { + h, err := decimalRatio(hourly) + if err != nil { + return 0, err + } + h.Mul(h, share).Mul(h, big.NewRat(100, 1)) + cents := new(big.Int).Quo(h.Num(), h.Denom()) + sized, _ := new(big.Rat).SetFrac(cents, big.NewInt(100)).Float64() + for _, text := range []string{strconv.FormatFloat(sized, 'g', -1, 64), fmt.Sprintf("%.2f", sized)} { + decimal, ok := new(big.Rat).SetString(text) + if !ok || decimal.Mul(decimal, big.NewRat(100, 1)).Cmp(new(big.Rat).SetInt(cents)) != 0 { + return 0, fmt.Errorf("SP allocation cannot represent whole cents") + } + } + return sized, nil +} diff --git a/internal/planstep/allocation_test.go b/internal/planstep/allocation_test.go new file mode 100644 index 00000000..67fe8060 --- /dev/null +++ b/internal/planstep/allocation_test.go @@ -0,0 +1,121 @@ +package planstep + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Fixture evidence: actual Resolve with in-memory recommendation reads. +func equalCountRamp(t *testing.T, p *config.PurchasePlan, n int, percent float64) { + t.Helper() + require.NoError(t, json.Unmarshal(fmt.Appendf(nil, `{"type":"custom","allocation_mode":"equal_count","total_steps":%d,"percent_per_step":%v}`, n, percent), &p.RampSchedule)) +} + +func TestEqualCountResolve(t *testing.T) { + p := cumPlan(100, 100.0/3, "custom", 3) + equalCountRamp(t, p, 3, 100.0/3) + assert.Equal(t, []int{32, 32, 33}, runRamp(t, p, 97, 3, suppressed(97))) + equalCountRamp(t, p, 97, 100.0/97) + got := runRamp(t, p, 97, 97, suppressed(97)) + for step, n := range got { + assert.Equal(t, 1, n, "step %d", step+1) + } + equalCountRamp(t, p, 3, 33) + p.Services["azure:compute"] = azurePlan(50.5, 33).Services["azure:compute"] + assert.Equal(t, []int{16, 16, 16}, runRamp(t, p, 97, 3, suppressed(97))) + assert.Equal(t, []int{33, 33, 33}, runRamp(t, cumPlan(100, 33, "custom", 3), 100, 3, suppressed(100))) + assert.InDelta(t, 100.0/3, p.RampSchedule.CumulativePercent(1), 1e-12) + row := azureRec(97, 1000) + key := cumKey(t, row) + shown := row + shown.Count = 2 + res, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{shown}}, fakeStore{rows: []config.RecommendationRecord{row}}, p, accts, 2, config.PlanRampBaseline{key: {Count: 97}}, map[string]config.PlanRampQuantity{key: {Count: 17}}) + require.NoError(t, err) + assert.Equal(t, 2, res.Recs[0].Count, "own ledger gap clamps to shown") + _, err = Resolve(t.Context(), fakeSource{}, fakeStore{}, p, accts, 2, config.PlanRampBaseline{key: {Count: 97}}, map[string]config.PlanRampQuantity{key: {Count: 32}}) + require.ErrorIs(t, err, ErrTargetMet) +} + +func TestEqualCountSavingsPlan(t *testing.T) { + for _, tc := range []struct { + count int + hourly, want string + percent float64 + }{{1, "1.234", "1.23", 33}, {1, "1.234", "1.23", 0}, {3, "1.234", "0.41", 33}, {97, "0.97", "0.01", 33}, {1, "1e14", "100000000000000", 33}, {1, "1e20", "100000000000000000000", 33}} { + t.Run(fmt.Sprint(tc.count), func(t *testing.T) { + p := spPlan(100, 33) + equalCountRamp(t, p, tc.count, tc.percent) + if tc.count == 1 { + p.RampSchedule.Type = "immediate" + } + row := spRec(`{"hourly_commitment":` + tc.hourly + `,"extra":9007199254740993}`) + got, err := resolveStep1(t, fakeSource{shown: []config.RecommendationRecord{row}}, fakeStore{rows: []config.RecommendationRecord{row}}, p, accts) + require.NoError(t, err) + var details map[string]json.RawMessage + require.NoError(t, json.Unmarshal(got[0].Details, &details)) + assert.Equal(t, tc.want, string(details["hourly_commitment"])) + assert.Equal(t, "9007199254740993", string(details["extra"])) + if tc.count > 1 { + p.Enabled, p.AutoPurchase = true, true + require.ErrorIs(t, Precheck(p), config.ErrMultiStepSavingsPlans) + } + }) + } +} + +func TestEqualCountImmediateOmittedPercent(t *testing.T) { + p := azurePlan(100, 0) + equalCountRamp(t, p, 1, 0) + p.RampSchedule.Type = "immediate" + require.NoError(t, p.RampSchedule.Validate()) + row := azureRec(97, 1000) + got, err := resolveStep1(t, fakeSource{shown: []config.RecommendationRecord{row}}, fakeStore{rows: []config.RecommendationRecord{row}}, p, accts) + require.NoError(t, err) + assert.Equal(t, 97, got[0].Count) + require.NoError(t, json.Unmarshal([]byte(`{"allocation_mode":""}`), &p.RampSchedule)) + _, err = resolveStep1(t, nil, nil, p, accts) + require.ErrorIs(t, err, ErrUnsupportedPlan) +} + +func TestEqualCountRejectsBeforeReads(t *testing.T) { + for _, raw := range []string{`{"allocation_mode":"future"}`, `{"allocation_mode":"equal_count","total_steps":0}`, `{"allocation_mode":"equal_count","total_steps":101}`, `{"allocation_mode":"equal_count","total_steps":2,"type":"immediate"}`} { + p := azurePlan(100, 33) + require.NoError(t, json.Unmarshal([]byte(raw), &p.RampSchedule)) + require.Error(t, p.RampSchedule.Validate()) + _, err := resolveStep1(t, nil, nil, p, accts) + require.ErrorIs(t, err, ErrUnsupportedPlan) + } +} + +func TestEqualCountInvalidQuantities(t *testing.T) { + p := cumPlan(100, 33, "custom", 3) + equalCountRamp(t, p, 3, 33) + row := azureRec(97, 1000) + key := cumKey(t, row) + for _, counts := range [][3]int{{-1, 97, 0}, {97, -1, 0}, {97, 97, -1}} { + shown := row + shown.Count = counts[0] + _, err := Resolve(t.Context(), fakeSource{shown: []config.RecommendationRecord{shown}}, fakeStore{rows: []config.RecommendationRecord{row}}, p, accts, 2, config.PlanRampBaseline{key: {Count: counts[1]}}, map[string]config.PlanRampQuantity{key: {Count: counts[2]}}) + require.Error(t, err) + assert.NotErrorIs(t, err, ErrNoMatch) + } + for _, tc := range []struct { + hourly string + coverage float64 + want string + }{ + {"90071992547409", 99, "cannot represent whole cents"}, + {"100000000000000.1", 100, "cannot represent whole cents"}, + } { + p = spPlan(tc.coverage, 33) + equalCountRamp(t, p, 1, 33) + row = spRec(`{"hourly_commitment":` + tc.hourly + `}`) + _, err := resolveStep1(t, fakeSource{shown: []config.RecommendationRecord{row}}, fakeStore{rows: []config.RecommendationRecord{row}}, p, accts) + require.ErrorContains(t, err, tc.want) + } +} diff --git a/internal/planstep/precheck.go b/internal/planstep/precheck.go index 72d5dd84..693d56fe 100644 --- a/internal/planstep/precheck.go +++ b/internal/planstep/precheck.go @@ -22,6 +22,9 @@ var ErrUnsupportedPlan = errors.New("plan settings are not supported by recommen // 100 (an immediate ramp ignores that field when validated but Resolve sizes // with it). func Precheck(plan *config.PurchasePlan) error { + if err := plan.RampSchedule.ValidateAllocationMode(); err != nil { + return fmt.Errorf("%w: plan %s: %w", ErrUnsupportedPlan, plan.ID, err) + } keys := make([]string, 0, len(plan.Services)) for k := range plan.Services { keys = append(keys, k) @@ -33,7 +36,7 @@ func Precheck(plan *config.PurchasePlan) error { return fmt.Errorf("%w: plan %s service %q sets %s", ErrUnsupportedPlan, plan.ID, k, field) } } - if plan.RampSchedule.Type == "immediate" && plan.RampSchedule.PercentPerStep != 100 { + if plan.RampSchedule.AllocationMode == "" && plan.RampSchedule.Type == "immediate" && plan.RampSchedule.PercentPerStep != 100 { return fmt.Errorf("%w: plan %s has an immediate ramp with percent_per_step %v (must be 100)", ErrUnsupportedPlan, plan.ID, plan.RampSchedule.PercentPerStep) } diff --git a/internal/planstep/resolve.go b/internal/planstep/resolve.go index 379642de..80ebda6d 100644 --- a/internal/planstep/resolve.go +++ b/internal/planstep/resolve.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "math" + "math/big" "sort" "strconv" "strings" @@ -66,6 +67,8 @@ type Result struct { type ramp struct { step int cum float64 + mode config.RampAllocationMode + count int baseline config.PlanRampBaseline committed map[string]config.PlanRampQuantity out Result @@ -106,7 +109,7 @@ func Resolve(ctx context.Context, src Source, store Store, plan *config.Purchase for _, id := range accountIDs { allowed[id] = struct{}{} } - r := &ramp{step: step, cum: plan.RampSchedule.CumulativePercent(step), baseline: baseline, committed: committed} + r := &ramp{step: step, cum: plan.RampSchedule.CumulativePercent(step), mode: plan.RampSchedule.AllocationMode, count: plan.RampSchedule.TotalSteps, baseline: baseline, committed: committed} if step == 1 { r.out.Baseline = config.PlanRampBaseline{} } @@ -124,7 +127,11 @@ func (r *ramp) emptyErr(plan *config.PurchasePlan) error { if len(r.out.Baseline) == 0 { r.out.Baseline = nil // an empty baseline is never frozen } - if r.targetMet(plan) { + met, err := r.targetMet(plan) + if err != nil { + return err + } + if met { return ErrTargetMet } return ErrNoMatch @@ -155,9 +162,9 @@ func (r *ramp) resolveServices(ctx context.Context, src Source, store Store, pla // baselined identity of an enabled plan service has its cumulative target // committed. It reads no `shown`: an identity that is merely hidden (still has // a gap) is not met, so it ends as ErrNoMatch, never a silent no-op advance. -func (r *ramp) targetMet(plan *config.PurchasePlan) bool { +func (r *ramp) targetMet(plan *config.PurchasePlan) (bool, error) { if len(r.baseline) == 0 { - return false + return false, nil } judged, target, committed := 0, 0, 0 for key, base := range r.baseline { @@ -166,14 +173,21 @@ func (r *ramp) targetMet(plan *config.PurchasePlan) bool { continue } judged++ - t, c := r.target(base.Count, svc.Coverage), r.committed[key].Count + t, err := r.target(base.Count, svc.Coverage) + if err != nil { + return false, err + } + c := r.committed[key].Count + if err := r.validateCounts(c); err != nil { + return false, err + } if t > c { - return false + return false, nil } target, committed = target+t, committed+c } r.out.Target, r.out.Committed = target, committed - return judged > 0 + return judged > 0, nil } // planServiceOf finds the enabled plan service an identity key belongs to @@ -192,10 +206,19 @@ func planServiceOf(plan *config.PurchasePlan, key string) (config.ServiceConfig, return config.ServiceConfig{}, false } -// target is floor(base * coverage * cum / 10000); coverage*cum is multiplied -// before the base so 100*10 over 10000 stays exact. -func (r *ramp) target(base int, coverage float64) int { - return int(math.Floor(float64(base) * (coverage * r.cum) / 10000)) +// Legacy targets multiply coverage*cum first; marked targets use exact shares. +func (r *ramp) target(base int, coverage float64) (int, error) { + if r.mode == config.RampAllocationEqualCount { + if err := r.validateCounts(base); err != nil { + return 0, err + } + share, err := r.equalCountShare(coverage, r.step) + if err != nil { + return 0, err + } + return floorAllocation(share.Mul(share, big.NewRat(int64(base), 1))) + } + return int(math.Floor(float64(base) * (coverage * r.cum) / 10000)), nil } func sortedKeys(m map[string]config.ServiceConfig) []string { @@ -225,7 +248,7 @@ func claimIdentities(seen map[string]struct{}, recs []config.RecommendationRecor func resolveService(ctx context.Context, src Source, store Store, svc *config.ServiceConfig, percentPerStep float64, r *ramp, accountIDs []string, allowed map[string]struct{}, enabledProviders []string) ([]config.RecommendationRecord, error) { - wantPayment, err := validateInputs(svc, percentPerStep) + wantPayment, err := r.validateInputs(svc, percentPerStep) if err != nil { return nil, err } @@ -262,8 +285,8 @@ func resolveService(ctx context.Context, src Source, store Store, svc *config.Se // validateInputs rejects sizing inputs outside (0,100] (custom ramps too) and // returns the plan payment normalized for the provider. -func validateInputs(svc *config.ServiceConfig, percentPerStep float64) (string, error) { - if svc.Coverage <= 0 || svc.Coverage > 100 || percentPerStep <= 0 || percentPerStep > 100 || svc.Term <= 0 { +func (r *ramp) validateInputs(svc *config.ServiceConfig, percentPerStep float64) (string, error) { + if svc.Coverage <= 0 || svc.Coverage > 100 || svc.Term <= 0 || (r.mode == "" && (percentPerStep <= 0 || percentPerStep > 100)) { return "", fmt.Errorf("invalid sizing inputs (coverage %v, percent per step %v, term %d)", svc.Coverage, percentPerStep, svc.Term) } payment, ok := config.NormalizePaymentOption(svc.Provider, svc.Payment) @@ -322,42 +345,56 @@ func (r *ramp) sizeFromStored(shown *config.RecommendationRecord, stored map[str return config.RecommendationRecord{}, false, errors.New("no stored row to price from (cache changed mid-read)") } if common.IsSavingsPlan(common.ServiceType(base.Service)) { - return size(shown, base, coverage*percentPerStep, 0) + var share *big.Rat + if r.mode == config.RampAllocationEqualCount { + share, err = r.equalCountShare(coverage, 1) + if err != nil { + return config.RecommendationRecord{}, false, err + } + } + return size(shown, base, coverage*percentPerStep, 0, share) } - n, ok := r.countToBuy(key, shown.Count, coverage) - if !ok { - return config.RecommendationRecord{}, false, nil + n, ok, err := r.countToBuy(key, shown.Count, coverage) + if err != nil || !ok { + return config.RecommendationRecord{}, false, err } - return size(shown, base, 0, n) + return size(shown, base, 0, n, nil) } // countToBuy applies the cumulative formula to one count identity and records // the baseline entry (step 1, shown > 0). ok=false means skip: no baseline at // step > 1. -func (r *ramp) countToBuy(key string, shown int, coverage float64) (int, bool) { +func (r *ramp) countToBuy(key string, shown int, coverage float64) (buy int, ok bool, err error) { + if validationErr := r.validateCounts(shown, r.committed[key].Count); validationErr != nil { + return 0, false, validationErr + } base, frozen := r.baseline[key] switch { case !frozen && r.step == 1: base = config.PlanRampQuantity{Count: shown} case !frozen: r.out.Skipped = append(r.out.Skipped, key) - return 0, false + return 0, false, nil } if r.step == 1 && base.Count > 0 { r.out.Baseline[key] = base } - gap := r.target(base.Count, coverage) - r.committed[key].Count + target, err := r.target(base.Count, coverage) + if err != nil { + return 0, false, err + } + gap := target - r.committed[key].Count if gap <= 0 { - return 0, true + return 0, true, nil } - return min(gap, shown), true + return min(gap, shown), true, nil } // size returns shown sized to n units (count recs) or to product/10000 of its // hourly commitment (Savings Plans; product = coverage*percentPerStep, // multiplied before dividing) and priced from the unsuppressed stored row. // keep=false means the rec sized to nothing. -func size(shown, stored *config.RecommendationRecord, product float64, n int) (config.RecommendationRecord, bool, error) { +func size(shown, stored *config.RecommendationRecord, product float64, n int, share *big.Rat) (config.RecommendationRecord, bool, error) { if stored.Count <= 0 { return config.RecommendationRecord{}, false, fmt.Errorf("stored count %d cannot price a share", stored.Count) } @@ -371,7 +408,7 @@ func size(shown, stored *config.RecommendationRecord, product float64, n int) (c var ratio float64 var err error if common.IsSavingsPlan(common.ServiceType(stored.Service)) { - ratio, err = sizeSP(&out, stored, product) + ratio, err = sizeSP(&out, stored, product, share) } else { ratio = sizeCount(&out, stored, n) } @@ -401,12 +438,18 @@ func sizeCount(out, stored *config.RecommendationRecord, n int) float64 { // sizeSP rewrites out's hourly_commitment (rounded down to whole cents, never // up) and returns the ratio to the stored commitment; 0 means below one cent. -func sizeSP(out, stored *config.RecommendationRecord, product float64) (float64, error) { +func sizeSP(out, stored *config.RecommendationRecord, product float64, share *big.Rat) (float64, error) { keys, hourly, err := decodeHourly(stored.Details) if err != nil { return 0, err } sized := math.Floor(hourly*product/100) / 100 + if share != nil { + sized, err = exactSPHourly(hourly, share) + if err != nil { + return 0, err + } + } if sized < spHourlyStep { return 0, nil } diff --git a/internal/purchase/plan_step_allocation_integration_test.go b/internal/purchase/plan_step_allocation_integration_test.go new file mode 100644 index 00000000..9e218f2b --- /dev/null +++ b/internal/purchase/plan_step_allocation_integration_test.go @@ -0,0 +1,53 @@ +//go:build integration + +package purchase + +import ( + "testing" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Native PostgreSQL, actual Manager attachment; provider completion simulated. +func testEqualCountAttach(t *testing.T, count int, want []int) { + f := newCumFixture(t, 97) + percent := 100.0 / float64(count) + if count == 3 { + percent = 33 + } + _, err := f.pool.Exec(f.ctx, `UPDATE purchase_plans SET ramp_schedule = ramp_schedule || jsonb_build_object('allocation_mode','equal_count','total_steps',$2::int,'percent_per_step',$3::float8), services = jsonb_set(services,'{ec2,coverage}','100') WHERE id=$1`, f.planID, count, percent) + require.NoError(t, err) + f.shownSuppressed(97) + for i, n := range want { + exec, tickErr := f.tick() + require.NoError(t, tickErr, "exact equal-count attachment") + assert.Equal(t, n, totalCount(exec), "exact equal-count allocation step %d", i+1) + f.buy(i + 1) + } + b, err := f.baseline() + require.NoError(t, err) + for _, q := range b { + assert.Equal(t, 97, q.Count) + } + var held map[string]config.PlanRampQuantity + require.NoError(t, f.store.WithTx(f.ctx, func(tx pgx.Tx) error { + var err error + held, err = f.store.ListPlanCommittedTx(f.ctx, tx, f.planID) + return err + })) + require.Len(t, b, 1) + require.Len(t, held, 1) + total := 0 + for _, n := range want { + total += n + } + for _, q := range held { + assert.Equal(t, total, q.Count) + } +} + +func TestEqualCountAttachThree(t *testing.T) { testEqualCountAttach(t, 3, []int{32, 32, 33}) } +func TestEqualCountAttachNinetySeven(t *testing.T) { testEqualCountAttach(t, 97, []int{1}) }