@@ -3,11 +3,13 @@ package api
33import (
44 "context"
55 "fmt"
6+ "math/big"
67 "testing"
78 "time"
89
910 "github.com/LeanerCloud/CUDly/internal/auth"
1011 "github.com/LeanerCloud/CUDly/internal/config"
12+ "github.com/LeanerCloud/CUDly/pkg/exchange"
1113 "github.com/LeanerCloud/CUDly/providers/aws/recommendations"
1214 ec2svc "github.com/LeanerCloud/CUDly/providers/aws/services/ec2"
1315 azurecompute "github.com/LeanerCloud/CUDly/providers/azure/services/compute"
@@ -1582,3 +1584,190 @@ func TestRejectRIExchange_TokenPathActorIsNil(t *testing.T) {
15821584 _ , err := (& Handler {config : mockStore }).rejectRIExchange (ctx , id , "tok" )
15831585 require .NoError (t , err )
15841586}
1587+
1588+ // ─── H1: checkDailyCap fails closed on unparseable paymentDue ─────────────────
1589+
1590+ // TestCheckDailyCap_UnparseablePaymentDue_FailsClosed is the regression test
1591+ // for H1: checkDailyCap must return a blocking reason when paymentDueStr cannot
1592+ // be parsed, never treat it as $0 (which would allow an unknown-cost exchange
1593+ // to proceed when the daily cap might be exceeded).
1594+ func TestCheckDailyCap_UnparseablePaymentDue_FailsClosed (t * testing.T ) {
1595+ t .Parallel ()
1596+ cases := []struct {
1597+ name string
1598+ paymentDueStr string
1599+ }{
1600+ {"not-a-number" , "not-a-number" },
1601+ {"empty string" , "" },
1602+ }
1603+ for _ , tc := range cases {
1604+ tc := tc
1605+ t .Run (tc .name , func (t * testing.T ) {
1606+ t .Parallel ()
1607+ reason := checkDailyCap ("100.00" , tc .paymentDueStr , 500.0 )
1608+ assert .NotEmpty (t , reason ,
1609+ "checkDailyCap must fail closed when paymentDueStr=%q cannot be parsed" , tc .paymentDueStr )
1610+ assert .Contains (t , reason , "could not parse payment due" ,
1611+ "reason must reference the payment-due parsing failure, not treat it as $0" )
1612+ })
1613+ }
1614+ }
1615+
1616+ // TestCheckDailyCap_ValidPaymentDue_WithinCap_Passes verifies the happy path:
1617+ // valid parseable amounts within the daily cap must return an empty reason.
1618+ func TestCheckDailyCap_ValidPaymentDue_WithinCap_Passes (t * testing.T ) {
1619+ t .Parallel ()
1620+ reason := checkDailyCap ("100.00" , "50.00" , 500.0 )
1621+ assert .Empty (t , reason , "within-cap exchange must not be blocked" )
1622+ }
1623+
1624+ // ─── H2: effectiveCap bounded by daily headroom ───────────────────────────────
1625+
1626+ // TestExecuteApprovedExchange_EffectiveCap_BoundedByDailyHeadroom is the
1627+ // regression test for H2: when dailyCap-dailySpent < perExchangeCap, the cap
1628+ // passed to Execute (MaxPaymentDueUSD) must be the daily headroom, not the full
1629+ // perExchangeCap. This prevents a fresh re-quote from accepting an amount that
1630+ // exceeds the daily budget.
1631+ func TestExecuteApprovedExchange_EffectiveCap_BoundedByDailyHeadroom (t * testing.T ) {
1632+ ctx := context .Background ()
1633+ const id = "550e8400-e29b-41d4-a716-000000000012"
1634+
1635+ mockStore := new (MockConfigStore )
1636+ t .Cleanup (func () { mockStore .AssertExpectations (t ) })
1637+
1638+ // dailySpent=$450, dailyCap=$500, perExchangeCap=$100 -> headroom=$50
1639+ mockStore .On ("GetRIExchangeDailySpend" , mock .Anything , mock .Anything ).Return ("450.00" , nil )
1640+ mockStore .On ("GetGlobalConfig" , ctx ).Return (& config.GlobalConfig {
1641+ RIExchangeMaxDailyUSD : 500 ,
1642+ RIExchangeMaxPerExchangeUSD : 100 ,
1643+ }, nil )
1644+
1645+ var capturedReq exchange.ExchangeExecuteRequest
1646+ h := & Handler {
1647+ config : mockStore ,
1648+ executeExchangeFn : func (_ context.Context , req exchange.ExchangeExecuteRequest ) (string , * exchange.ExchangeQuoteSummary , error ) {
1649+ capturedReq = req
1650+ return "exch-h2-handler" , & exchange.ExchangeQuoteSummary {PaymentDueUSDStr : "40.000000" }, nil
1651+ },
1652+ }
1653+
1654+ mockStore .On ("CompleteRIExchangeWithPayment" , ctx , id , "exch-h2-handler" , "40.000000" ).Return (nil )
1655+
1656+ record := & config.RIExchangeRecord {
1657+ ID : id ,
1658+ Region : "us-east-1" ,
1659+ SourceRIIDs : []string {"ri-1" },
1660+ TargetOfferingID : "offering-1" ,
1661+ TargetCount : 1 ,
1662+ PaymentDue : "40.00" ,
1663+ }
1664+
1665+ _ , err := h .executeApprovedExchange (ctx , id , record )
1666+ require .NoError (t , err )
1667+
1668+ // MaxPaymentDueUSD must equal headroom ($50), not perExchangeCap ($100).
1669+ require .NotNil (t , capturedReq .MaxPaymentDueUSD ,
1670+ "MaxPaymentDueUSD must be set on the Execute request" )
1671+ expectedCap := new (big.Rat ).SetFrac64 (50 , 1 )
1672+ assert .Equal (t , 0 , capturedReq .MaxPaymentDueUSD .Cmp (expectedCap ),
1673+ "MaxPaymentDueUSD must be daily headroom ($50), got $%s" ,
1674+ capturedReq .MaxPaymentDueUSD .FloatString (2 ))
1675+ }
1676+
1677+ // ─── H3: ledger records fresh accepted amount ─────────────────────────────────
1678+
1679+ // TestExecuteApprovedExchange_AcceptedAmountFromFreshQuote is the regression
1680+ // test for H3: CompleteRIExchangeWithPayment must be called with the amount
1681+ // AWS confirmed during Execute (freshQ.PaymentDueUSDStr), not the stale
1682+ // record.PaymentDue stored from the pre-execution quote.
1683+ func TestExecuteApprovedExchange_AcceptedAmountFromFreshQuote (t * testing.T ) {
1684+ ctx := context .Background ()
1685+ const id = "550e8400-e29b-41d4-a716-000000000013"
1686+
1687+ mockStore := new (MockConfigStore )
1688+ t .Cleanup (func () { mockStore .AssertExpectations (t ) })
1689+
1690+ mockStore .On ("GetRIExchangeDailySpend" , mock .Anything , mock .Anything ).Return ("0" , nil )
1691+ mockStore .On ("GetGlobalConfig" , ctx ).Return (& config.GlobalConfig {
1692+ RIExchangeMaxDailyUSD : 1000 ,
1693+ RIExchangeMaxPerExchangeUSD : 500 ,
1694+ }, nil )
1695+
1696+ // Execute returns a fresh quote ($35.50) different from pre-execution ($30.00).
1697+ h := & Handler {
1698+ config : mockStore ,
1699+ executeExchangeFn : func (_ context.Context , _ exchange.ExchangeExecuteRequest ) (string , * exchange.ExchangeQuoteSummary , error ) {
1700+ return "exch-h3-fresh" , & exchange.ExchangeQuoteSummary {
1701+ PaymentDueUSDStr : "35.500000" ,
1702+ }, nil
1703+ },
1704+ }
1705+
1706+ // The FRESH amount "35.500000" must reach CompleteRIExchangeWithPayment,
1707+ // not the stale "30.00" from record.PaymentDue.
1708+ mockStore .On ("CompleteRIExchangeWithPayment" , ctx , id , "exch-h3-fresh" , "35.500000" ).Return (nil )
1709+
1710+ record := & config.RIExchangeRecord {
1711+ ID : id ,
1712+ Region : "us-east-1" ,
1713+ SourceRIIDs : []string {"ri-1" },
1714+ TargetOfferingID : "offering-1" ,
1715+ TargetCount : 1 ,
1716+ PaymentDue : "30.00" , // stale pre-execution quote
1717+ }
1718+
1719+ resp , err := h .executeApprovedExchange (ctx , id , record )
1720+ require .NoError (t , err )
1721+ respMap , ok := resp .(map [string ]any )
1722+ require .True (t , ok )
1723+ assert .Equal (t , "completed" , respMap ["status" ])
1724+ }
1725+
1726+ // ─── H4: persistent ledger failure returns error ──────────────────────────────
1727+
1728+ // TestExecuteApprovedExchange_LedgerWriteFailure_ReturnsError is the regression
1729+ // test for H4: when CompleteRIExchangeWithPayment fails persistently (all retry
1730+ // attempts exhausted) after money has already moved, executeApprovedExchange must
1731+ // return an error rather than silently logging and returning a success response.
1732+ // The caller (approveRIExchange) must see this as an error so it can return
1733+ // HTTP 500 and the operator knows the ledger is inconsistent.
1734+ func TestExecuteApprovedExchange_LedgerWriteFailure_ReturnsError (t * testing.T ) {
1735+ ctx := context .Background ()
1736+ const id = "550e8400-e29b-41d4-a716-000000000014"
1737+
1738+ mockStore := new (MockConfigStore )
1739+ t .Cleanup (func () { mockStore .AssertExpectations (t ) })
1740+
1741+ mockStore .On ("GetRIExchangeDailySpend" , mock .Anything , mock .Anything ).Return ("0" , nil )
1742+ mockStore .On ("GetGlobalConfig" , ctx ).Return (& config.GlobalConfig {
1743+ RIExchangeMaxDailyUSD : 1000 ,
1744+ RIExchangeMaxPerExchangeUSD : 500 ,
1745+ }, nil )
1746+
1747+ h := & Handler {
1748+ config : mockStore ,
1749+ executeExchangeFn : func (_ context.Context , _ exchange.ExchangeExecuteRequest ) (string , * exchange.ExchangeQuoteSummary , error ) {
1750+ return "exch-h4-test" , & exchange.ExchangeQuoteSummary {PaymentDueUSDStr : "0" }, nil
1751+ },
1752+ }
1753+
1754+ // Fail all three retry attempts.
1755+ mockStore .On ("CompleteRIExchangeWithPayment" , ctx , id , "exch-h4-test" , "0" ).
1756+ Return (fmt .Errorf ("DB write failed" )).Times (3 )
1757+
1758+ record := & config.RIExchangeRecord {
1759+ ID : id ,
1760+ Region : "us-east-1" ,
1761+ SourceRIIDs : []string {"ri-1" },
1762+ TargetOfferingID : "offering-1" ,
1763+ TargetCount : 1 ,
1764+ PaymentDue : "0" ,
1765+ }
1766+
1767+ _ , err := h .executeApprovedExchange (ctx , id , record )
1768+ require .Error (t , err , "persistent ledger failure must propagate as a non-nil error" )
1769+ assert .Contains (t , err .Error (), "ledger update failed" ,
1770+ "error must describe the ledger failure for operator triage" )
1771+ assert .Contains (t , err .Error (), "exch-h4-test" ,
1772+ "error must include the exchange ID so operators can correlate with AWS" )
1773+ }
0 commit comments