use sha2::{Digest, Sha256};
use time::OffsetDateTime;
use super::{BudgetLedger, BudgetLedgerEvent};
use crate::value_objects::{
BudgetAccountId, BudgetDimension, BudgetLimits, BudgetMeasurement, BudgetOperationId,
BudgetQuantities, BudgetReconciliationId, BudgetReservationEstimate, BudgetReservationId,
BudgetTokenCount, CostMicros, ExecutionDuration, ExecutionOperationId,
MeasuredBudgetQuantities, ToolCallCount,
};
use crate::BudgetError;
fn estimate(tokens: u64) -> BudgetReservationEstimate {
BudgetReservationEstimate::new(
BudgetMeasurement::Estimated(ExecutionDuration::from_micros(100)),
BudgetMeasurement::Estimated(BudgetTokenCount::new(tokens)),
BudgetMeasurement::Estimated(CostMicros::new(10)),
BudgetMeasurement::Estimated(ToolCallCount::new(1)),
)
}
fn limits() -> BudgetLimits {
BudgetLimits::new(
BudgetQuantities::new(
ExecutionDuration::from_micros(1_000),
BudgetTokenCount::new(100),
CostMicros::new(100),
ToolCallCount::new(10),
),
Some(crate::value_objects::CurrencyCode::new("EUR").unwrap()),
)
.unwrap()
}
fn account() -> BudgetAccountId {
BudgetAccountId::new("root").unwrap()
}
fn budget_operation(label: &str) -> Result<BudgetOperationId, crate::DomainError> {
let encoded = format!("{:x}", Sha256::digest(label.as_bytes()));
ExecutionOperationId::new(encoded).map(|id| BudgetOperationId::for_execution(&id))
}
fn open() -> BudgetLedger {
BudgetLedger::rehydrate(&[BudgetLedgerEvent::Opened {
account_id: account(),
limits: limits(),
opened_at: OffsetDateTime::UNIX_EPOCH,
}])
.unwrap()
}
#[test]
fn two_operations_cannot_reserve_past_the_shared_limit() {
let mut ledger = open();
let event = ledger
.decide_reserve(
budget_operation("parent").unwrap(),
estimate(60),
OffsetDateTime::UNIX_EPOCH,
)
.unwrap()
.unwrap();
ledger.apply(event).unwrap();
let error = ledger
.decide_reserve(
budget_operation("child").unwrap(),
estimate(60),
OffsetDateTime::UNIX_EPOCH,
)
.unwrap_err();
assert!(matches!(
error,
BudgetError::Exhausted {
dimension: BudgetDimension::Tokens,
..
}
));
}
#[test]
fn retry_returns_existing_without_an_event() {
let mut ledger = open();
let operation = budget_operation("same-operation").unwrap();
let event = ledger
.decide_reserve(operation.clone(), estimate(25), OffsetDateTime::UNIX_EPOCH)
.unwrap()
.unwrap();
ledger.apply(event).unwrap();
assert_eq!(
ledger
.decide_reserve(operation, estimate(25), OffsetDateTime::UNIX_EPOCH)
.unwrap(),
None
);
assert_eq!(ledger.balance().unwrap().reserved().tokens().value(), 25);
}
#[test]
fn unknown_stays_charged_and_observed_overrun_is_honest() {
let mut ledger = open();
let operation = budget_operation("effect").unwrap();
let reservation_id = BudgetReservationId::for_operation(&account(), &operation);
ledger
.apply(
ledger
.decide_reserve(operation, estimate(80), OffsetDateTime::UNIX_EPOCH)
.unwrap()
.unwrap(),
)
.unwrap();
let measured = MeasuredBudgetQuantities::new(
BudgetMeasurement::Unknown,
BudgetMeasurement::Observed(BudgetTokenCount::new(120)),
BudgetMeasurement::Estimated(CostMicros::new(9)),
BudgetMeasurement::Observed(ToolCallCount::new(1)),
);
ledger
.apply(
ledger
.decide_reconcile(
reservation_id,
BudgetReconciliationId::new("receipt").unwrap(),
measured,
OffsetDateTime::UNIX_EPOCH,
)
.unwrap()
.unwrap(),
)
.unwrap();
let balance = ledger.balance().unwrap();
assert_eq!(balance.unconfirmed().duration().as_micros(), 100);
assert_eq!(balance.observed().tokens().value(), 120);
assert_eq!(balance.estimated().cost().value(), 9);
assert_eq!(balance.overrun().tokens().value(), 20);
assert_eq!(balance.available().tokens().value(), 0);
}
#[test]
fn a_reconciliation_replay_is_idempotent_but_a_change_conflicts() {
let mut ledger = open();
let operation = budget_operation("effect").unwrap();
let reservation_id = BudgetReservationId::for_operation(&account(), &operation);
ledger
.apply(
ledger
.decide_reserve(operation, estimate(20), OffsetDateTime::UNIX_EPOCH)
.unwrap()
.unwrap(),
)
.unwrap();
let id = BudgetReconciliationId::new("receipt").unwrap();
let measured = MeasuredBudgetQuantities::new(
BudgetMeasurement::Unknown,
BudgetMeasurement::Observed(BudgetTokenCount::new(10)),
BudgetMeasurement::Unknown,
BudgetMeasurement::Unknown,
);
ledger
.apply(
ledger
.decide_reconcile(
reservation_id.clone(),
id.clone(),
measured,
OffsetDateTime::UNIX_EPOCH,
)
.unwrap()
.unwrap(),
)
.unwrap();
assert_eq!(
ledger
.decide_reconcile(
reservation_id.clone(),
id.clone(),
measured,
OffsetDateTime::UNIX_EPOCH
)
.unwrap(),
None
);
let changed = MeasuredBudgetQuantities::new(
BudgetMeasurement::Unknown,
BudgetMeasurement::Observed(BudgetTokenCount::new(11)),
BudgetMeasurement::Unknown,
BudgetMeasurement::Unknown,
);
assert!(matches!(
ledger.decide_reconcile(reservation_id, id, changed, OffsetDateTime::UNIX_EPOCH),
Err(BudgetError::ReconciliationConflict(_))
));
}
#[test]
fn reconciliation_can_advance_knowledge_without_changing_its_receipt_identity() {
let mut ledger = open();
let operation = budget_operation("later-observed").unwrap();
let reservation_id = BudgetReservationId::for_operation(&account(), &operation);
let event = ledger
.decide_reserve(operation, estimate(20), OffsetDateTime::UNIX_EPOCH)
.unwrap()
.unwrap();
ledger.apply(event).unwrap();
let id = BudgetReconciliationId::new("receipt-later").unwrap();
let unknown = MeasuredBudgetQuantities::new(
BudgetMeasurement::Unknown,
BudgetMeasurement::Unknown,
BudgetMeasurement::Unknown,
BudgetMeasurement::Unknown,
);
let event = ledger
.decide_reconcile(
reservation_id.clone(),
id.clone(),
unknown,
OffsetDateTime::UNIX_EPOCH,
)
.unwrap()
.unwrap();
ledger.apply(event).unwrap();
let observed = MeasuredBudgetQuantities::new(
BudgetMeasurement::Unknown,
BudgetMeasurement::Observed(BudgetTokenCount::new(18)),
BudgetMeasurement::Unknown,
BudgetMeasurement::Unknown,
);
let event = ledger
.decide_reconcile(reservation_id, id, observed, OffsetDateTime::UNIX_EPOCH)
.unwrap()
.unwrap();
ledger.apply(event).unwrap();
assert_eq!(ledger.balance().unwrap().observed().tokens().value(), 18);
assert_eq!(ledger.balance().unwrap().unconfirmed().tokens().value(), 0);
}
#[test]
fn applying_a_duplicate_reservation_does_not_replace_reconciled_state() {
let mut ledger = open();
let operation = budget_operation("duplicate-event").unwrap();
let reservation_id = BudgetReservationId::for_operation(&account(), &operation);
let reserved = ledger
.decide_reserve(operation, estimate(20), OffsetDateTime::UNIX_EPOCH)
.unwrap()
.unwrap();
ledger.apply(reserved.clone()).unwrap();
let reconciled = ledger
.decide_reconcile(
reservation_id,
BudgetReconciliationId::new("receipt").unwrap(),
MeasuredBudgetQuantities::new(
BudgetMeasurement::Unknown,
BudgetMeasurement::Observed(BudgetTokenCount::new(17)),
BudgetMeasurement::Unknown,
BudgetMeasurement::Unknown,
),
OffsetDateTime::UNIX_EPOCH,
)
.unwrap()
.unwrap();
ledger.apply(reconciled).unwrap();
let before = ledger.clone();
assert!(matches!(
ledger.apply(reserved),
Err(BudgetError::Persistence(
crate::DomainError::AlreadyExists {
what: "budget_reservation"
}
))
));
assert_eq!(ledger, before);
assert_eq!(ledger.balance().unwrap().observed().tokens().value(), 17);
}
#[test]
fn any_overrun_blocks_admission_even_when_the_request_uses_another_dimension() {
let mut ledger = open();
let operation = budget_operation("overrun").unwrap();
let reservation_id = BudgetReservationId::for_operation(&account(), &operation);
let reserved = ledger
.decide_reserve(operation, estimate(80), OffsetDateTime::UNIX_EPOCH)
.unwrap()
.unwrap();
ledger.apply(reserved).unwrap();
let reconciled = ledger
.decide_reconcile(
reservation_id,
BudgetReconciliationId::new("overrun-receipt").unwrap(),
MeasuredBudgetQuantities::new(
BudgetMeasurement::Observed(ExecutionDuration::from_micros(100)),
BudgetMeasurement::Observed(BudgetTokenCount::new(120)),
BudgetMeasurement::Observed(CostMicros::new(10)),
BudgetMeasurement::Observed(ToolCallCount::new(1)),
),
OffsetDateTime::UNIX_EPOCH,
)
.unwrap()
.unwrap();
ledger.apply(reconciled).unwrap();
let other_dimension_only = BudgetReservationEstimate::new(
BudgetMeasurement::Estimated(ExecutionDuration::from_micros(1)),
BudgetMeasurement::Estimated(BudgetTokenCount::new(0)),
BudgetMeasurement::Estimated(CostMicros::new(0)),
BudgetMeasurement::Estimated(ToolCallCount::new(0)),
);
assert!(matches!(
ledger.decide_reserve(
budget_operation("after-overrun").unwrap(),
other_dimension_only,
OffsetDateTime::UNIX_EPOCH
),
Err(BudgetError::Exhausted {
dimension: BudgetDimension::Tokens,
..
})
));
}
#[test]
fn unknown_limited_dimension_is_rejected_before_a_reservation_event_exists() {
let ledger = open();
let incomplete = BudgetReservationEstimate::new(
BudgetMeasurement::Estimated(ExecutionDuration::from_micros(10)),
BudgetMeasurement::Unknown,
BudgetMeasurement::Estimated(CostMicros::new(1)),
BudgetMeasurement::Estimated(ToolCallCount::new(1)),
);
assert!(matches!(
ledger.decide_reserve(
budget_operation("unknown-tokens").unwrap(),
incomplete,
OffsetDateTime::UNIX_EPOCH
),
Err(BudgetError::MissingReservationEstimate(
BudgetDimension::Tokens
))
));
assert_eq!(ledger.reservations().count(), 0);
}