test: add integration test suite for budget consumption edge cases - #132
Merged
Cjay-Cyber-2 merged 2 commits intoSep 1, 2026
Merged
Conversation
Make budget consumption fail safe under concurrency: wire the previously unused BudgetReservationService into the pipeline so reserve() persists the reservation atomically under a distributed lock (spent is incremented before any funds move), settle on completion, and release on cancellation or failed execution. Removes the racy check-then-act assertWithinBudget pre-flight. Adds integration tests under src/modules/budgets/tests/ covering concurrent agent requests against a single budget, 7-dp precision boundaries, time-frame (day/limit) boundaries, the reserve/consume/release lifecycle, and the canonical error envelopes (BUDGET_EXCEEDED / CONFLICT / VALIDATION_ERROR and Prisma P-codes) produced by the real exception filter and Zod pipe. Closes ASTROIDX556#6
|
@Bogunrot Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
|
MergeKeeper review Scope: in scope for linked issue Successfully added integration tests and the required reservation-based concurrency and budget consumption logic fulfilling issue #6. Reviewed commit: |
Integrate RollingWindowBudgetService from main while preserving the reservation-based budget consumption approach from the feature branch. - budget.module.ts: add RollingWindowBudgetService alongside BudgetReservationService + RedisLock - budget.repository.ts: keep decrementSpent (for release), drop upstream's reserveBudget (superseded) - budget.service.ts: inject both BudgetReservationService and RedisLock, keep reservation-based reserve/consume flow - transaction.service.ts: keep try/catch budget release on persist failure, adopt memoValue fix from main - budget.service-lock.spec.ts: update tests for settlement-only consume and reservation delegation - budget-consumption.integration.spec.ts: fix buildService to pass RedisLock mock 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
1. Linked Issue
Closes #6
2. Problem Statement (The Bug)
The acceptance criteria demand tests proving that concurrent agent requests do not bypass budget checks — but the budget consumption path could not pass that test, because it was genuinely racy:
BudgetService.assertWithinBudgetwas a read-then-check pre-flight that did not mutate state, andconsumeincrementedspentafterwards. Two concurrent agent requests both readspent = 0against a1000limit, both passed the check, and both incremented — ending at1200 > 1000.BudgetReservationService(the distributed-lock reservation mechanism) existed but was never wired into any module or pipeline — it was dead code. Even its own check was non-persisting, so a lock alone could not stop the race.This cannot be fixed with a local patch to the test: writing a test that asserts "concurrent requests do not bypass checks" against the current code would fail, because the bypass is real. The check had to become authoritative and persisted before any funds move.
3. Solution Comparison and Decision (Why reservation, and not the alternatives)
spent, still passes, and both consume → overspend. Treats the symptom.consumethe authoritative atomic check (UPDATE ... WHERE spent + amount <= limit)BudgetReservationServiceunwired and test it in isolationDecision — reservation-based consumption (Option D): wire
BudgetReservationServiceinto the pipeline and makereservepersist the reservation atomically (spent is incremented) under the distributed lock, at creation time, before any funds can move.consumebecomes a settle step (no double-count) andreleasereturns headroom on cancellation or failed execution. This is the only option where the budget is checked-and-booked as a single atomic step in the request path.4. The Change (Code modifications)
budget-reservation.service.ts—reservenow checks the limit and persists the reservation (incrementSpent) inside the lock; over-limit throwsBudgetExceededException(422BUDGET_EXCEEDED), missing budget / lock failure throwConflictException(409CONFLICT).budget.service.ts—reserve(authoritative),consume(settle only — the spend was already booked),release(clamped, never negative). RacyassertWithinBudgetremoved; nothing references it anymore.budget.repository.ts— addeddecrementSpent(atomicspent: { decrement }).budget.module.ts—BudgetReservationService+RedisLockare now provided (previously dead code).transaction.service.ts—createreserves before persisting (releasing on persist failure);executesettles on success and releases when funds never moved;cancel(also the approval-rejection path) releases.event-names.ts—budget.released.Core reservation:
Behavioral comparison across entry points:
TransactionService.create(budgeted)reservebooks the amount atomically; the second concurrent request getsBUDGET_EXCEEDED(422)TransactionService.executesuccessconsumeincremented spent (after on-chain)consumesettles the already-booked reservation — no double countTransactionService.executefailurereleasereturns the headroom (only when funds never moved)TransactionService.cancel/ approval rejectionreleasereturns the headroomBudgetReservationServiceBudgetModule, used by the pipelineCONFLICT409 from reservation specBUDGET_EXCEEDED422 with limit/spent/attempted details5. Compatibility Note (On INTERFACE_VERSION)
No interface version exists in this project (the API version is the stable
api/v1prefix) and none is modified. HTTP surface is unchanged — same routes, same request/response shapes. The one intentional behavioral change is that a budgeted transaction that breaches the limit now fails earlier (at creation) with the sameBUDGET_EXCEEDEDerror the pre-flight used to throw, plus a newbudget.releasedevent. The removedassertWithinBudgethad no callers outside the transaction pipeline (verified by grep) — it is replaced, not broken.6. Incidental Fixes (Two things the issue called out that also got fixed)
BudgetReservationServicewas defined, spec'd, and never wired into a module or the pipeline. It is now provided inBudgetModuleand used by every budgeted transaction.7. Testing (Proving it works)
The core behavior is verified by integration tests in
src/modules/budgets/tests/that exercise the realBudgetService→BudgetReservationService→BudgetRepositorychain (mocked Prisma/Redis stand in for the external stores):budget-consumption.integration.spec.tsdoes not let concurrent agent requests overspend the budget (lock serialises)— 5 concurrent 300-unit spends on a 1000 budget: exactly 3 succeed, 2 fail withBudgetExceededException, spent never exceeds 900.the DB-level atomic precondition also prevents overspend if the lock is bypassed— same scenario with serialization removed: the conditional increment still caps spend at the limit (3/5 succeed, rest fail with a typedConflictException).allows a spend landing exactly on the limit at 7-dp precision/rejects a spend one 7-dp unit past the limit with exact details— Decimal arithmetic, no float drift.emits a warning exactly at the 80% utilisation boundary, and not below itandattributes daily-limit spend correctly at the exact day boundary— time-frame boundaries.returns reserved headroom after a release,never drives spent below zero,settles without double-counting.error-envelope.integration.spec.ts— the realAllExceptionsFilter+ realZodValidationPipe:BudgetExceededException→ 422{ success:false, error:{ code:'BUDGET_EXCEEDED', … }, requestId }ConflictException→ 409CONFLICT; Zod rejection of an 8-dp amount → 422VALIDATION_ERRORwithlimitAmountdetailsP2002→ 409CONFLICT,P2025→ 404NOT_FOUND,P1000→ 400BAD_REQUEST(typed envelope, raw error never leaks)Results: before this PR the concurrency scenario would overspend; after,
Test Files 12 passed, Tests 128 passed, plusnpm run typecheck,npm run lintandnpm run buildall clean. The pre-existing suite (117 tests onmain) had no failures; the 11 new tests are additive.8. Additional Notes (Scope)
Single commit on
feat/budget-consumption-edge-cases. Scope is the budget consumption path + the transaction pipeline's budget hooks; wallets, policies, risk, stellar, orgs and the developer API-key module are untouched.