diff --git a/internal/server/handler_ri_exchange_reservation_integration_test.go b/internal/server/handler_ri_exchange_reservation_integration_test.go index 5bb5a760..6b650883 100644 --- a/internal/server/handler_ri_exchange_reservation_integration_test.go +++ b/internal/server/handler_ri_exchange_reservation_integration_test.go @@ -4,8 +4,8 @@ package server import ( "context" + "math" "math/big" - "strconv" "testing" "time" @@ -36,10 +36,24 @@ func TestRIExchangeAutoWorkers_ConcreteStoreAndFixtureClient(t *testing.T) { require.NoError(t, conn.QueryRow(ctx, "SELECT gen_random_uuid()::text").Scan(&testRunID)) standaloneRIID, ladderRIID := "ri-standalone-"+testRunID, "ri-ladder-"+testRunID store := config.NewPostgresStore(conn) - baselineText, err := store.GetRIExchangeDailySpend(ctx, time.Now().UTC()) - require.NoError(t, err) - baseline, err := strconv.ParseFloat(baselineText, 64) - require.NoError(t, err) + // Each run's daily cap is distinct from the 1000 USD per-exchange cap so + // only one of them can bind (#666). The standalone run leaves 1500+ USD of + // daily headroom, so the per-exchange cap must set its 1000 USD ceiling; + // the ladder run leaves under 1000 USD, so the daily cap must set it. + // Caps are today's spend rounded up to whole dollars plus a whole-dollar + // margin: a float64 holding an integer converts to the six-decimal cap + // string exactly, so a fractional-micro spend can't floor a cap a micro + // below its intended value. + todaySpendUSD := func() (exact *big.Rat, wholeDollars float64) { + spendText, spendErr := store.GetRIExchangeDailySpend(ctx, time.Now().UTC()) + require.NoError(t, spendErr) + spend, ok := new(big.Rat).SetString(spendText) + require.True(t, ok, "daily spend %q is not a decimal", spendText) + approx, _ := spend.Float64() + return spend, math.Ceil(approx) + } + baselineSpend, baselineWholeDollars := todaySpendUSD() + wantCeiling := map[string]string{standaloneRIID: "1000.000000"} quoteClient := func(riID string, executions *int) *mockExchangeClient { return &mockExchangeClient{ @@ -48,14 +62,14 @@ func TestRIExchangeAutoWorkers_ConcreteStoreAndFixtureClient(t *testing.T) { }, executeFunc: func(executeCtx context.Context, req exchange.ExchangeExecuteRequest) (string, *exchange.ExchangeQuoteSummary, error) { require.Equal(t, riID, req.ReservedIDs[0]) - require.Equal(t, "1000.000000", req.MaxPaymentDueUSD.FloatString(6)) + require.Equal(t, wantCeiling[riID], req.MaxPaymentDueUSD.FloatString(6)) var reservationID, status, payment string require.NoError(t, conn.QueryRow(executeCtx, "SELECT id::text, status, payment_due::text FROM ri_exchange_history WHERE $1 = ANY(source_ri_ids)", riID).Scan(&reservationID, &status, &payment)) require.NotEmpty(t, reservationID) require.Equal(t, "processing", status) - require.Equal(t, "1000.000000", payment) + require.Equal(t, wantCeiling[riID], payment) *executions++ return "accepted-" + riID, &exchange.ExchangeQuoteSummary{IsValidExchange: true, PaymentDueUSD: big.NewRat(950, 1), PaymentDueUSDStr: "950.000000", CurrencyCode: "USD"}, nil }, @@ -72,7 +86,7 @@ func TestRIExchangeAutoWorkers_ConcreteStoreAndFixtureClient(t *testing.T) { standaloneExecutions := 0 app := &Application{Config: store, Email: &mockEmailSender{sendCompletedFunc: func(context.Context, email.RIExchangeNotificationData) error { return nil }}} standaloneCfg := &config.GlobalConfig{RIExchangeEnabled: true, RIExchangeMode: "auto", RIExchangeUtilizationThreshold: 95, - RIExchangeMaxPerExchangeUSD: 1000, RIExchangeMaxDailyUSD: baseline + 1000, RIExchangeLookbackDays: 30} + RIExchangeMaxPerExchangeUSD: 1000, RIExchangeMaxDailyUSD: baselineWholeDollars + 1500, RIExchangeLookbackDays: 30} standalone, err := app.executeRIExchangeReshape(ctx, standaloneCfg, riExchangeClients{ listConvertibleRIs: func(context.Context) ([]ec2svc.ConvertibleRI, error) { return ri(standaloneRIID), nil }, getRIUtilization: func(context.Context, int) ([]recommendations.RIUtilization, error) { return util(standaloneRIID), nil }, @@ -91,6 +105,13 @@ func TestRIExchangeAutoWorkers_ConcreteStoreAndFixtureClient(t *testing.T) { require.Equal(t, "completed", row.Status) require.Equal(t, "950.000000", row.PaymentDue) + // Today's spend now includes the standalone exchange's 950 USD, so the + // ladder's ceiling is its daily cap minus that spend, between 980 and 981 + // USD: below the per-exchange cap and above the 900 USD initial quote. + ladderSpend, ladderSpendWholeDollars := todaySpendUSD() + require.Equal(t, "950.000000", new(big.Rat).Sub(ladderSpend, baselineSpend).FloatString(6)) + ladderDailyCapUSD := ladderSpendWholeDollars + 980 + wantCeiling[ladderRIID] = new(big.Rat).Sub(new(big.Rat).SetFloat64(ladderDailyCapUSD), ladderSpend).FloatString(6) var ladderRunID string require.NoError(t, conn.QueryRow(ctx, "INSERT INTO ladder_runs (status) VALUES ('executing') RETURNING id::text").Scan(&ladderRunID)) ladderExecutions := 0 @@ -101,7 +122,7 @@ func TestRIExchangeAutoWorkers_ConcreteStoreAndFixtureClient(t *testing.T) { return "offering-test", nil }, RIs: riInfos, Utilization: utilInfos, RIMetadata: metadata, - Config: exchange.RIExchangeConfig{Mode: "auto", UtilizationThreshold: 95, MaxPaymentPerExchangeUSD: 1000, MaxPaymentDailyUSD: baseline + 2000}, + Config: exchange.RIExchangeConfig{Mode: "auto", UtilizationThreshold: 95, MaxPaymentPerExchangeUSD: 1000, MaxPaymentDailyUSD: ladderDailyCapUSD}, AccountID: "666666666666", Region: "eu-west-1", LadderRunID: &ladderRunID, }) require.NoError(t, err) diff --git a/internal/server/handler_ri_exchange_test.go b/internal/server/handler_ri_exchange_test.go index 6d4d998b..de77e818 100644 --- a/internal/server/handler_ri_exchange_test.go +++ b/internal/server/handler_ri_exchange_test.go @@ -195,6 +195,10 @@ func TestConvertForAutoExchange(t *testing.T) { func TestConfigExchangeStoreAdapter(t *testing.T) { savedRecord := (*config.RIExchangeRecord)(nil) completedID := "" + // The adapter must pass both caps through unchanged and in order (#666): + // the store treats the first as the daily cap and the second as the + // per-exchange cap, so a swap would pass a mock that ignores them. + var gotDailyCap, gotPerExchangeCap string mockStore := &mockConfigStoreForExchange{ saveRIExchangeRecordFunc: func(ctx context.Context, record *config.RIExchangeRecord) error { savedRecord = record @@ -202,6 +206,7 @@ func TestConfigExchangeStoreAdapter(t *testing.T) { return nil }, reserveRIExchangeFunc: func(ctx context.Context, record *config.RIExchangeRecord, dailyCapUSD, perExchangeCapUSD string) (string, error) { + gotDailyCap, gotPerExchangeCap = dailyCapUSD, perExchangeCapUSD savedRecord = record record.ID = "reserved-id" record.PaymentDue = "10.000000" @@ -255,6 +260,8 @@ func TestConfigExchangeStoreAdapter(t *testing.T) { record := &exchange.ExchangeRecord{PaymentDue: "5", Status: "processing", Mode: "auto", LadderRunID: &ladderRunID} ceiling, err := adapter.ReserveRIExchange(context.Background(), record, "20", "10") testutil.AssertNoError(t, err) + testutil.AssertEqual(t, "20", gotDailyCap) + testutil.AssertEqual(t, "10", gotPerExchangeCap) testutil.AssertEqual(t, "10.000000", ceiling) testutil.AssertEqual(t, "reserved-id", record.ID) testutil.AssertEqual(t, ceiling, record.PaymentDue)