use rust_decimal::Decimal;
use sqlx::{PgPool, Row};
use uuid::Uuid;
use backbone_inventory::application::service::{
AvailabilityScopeError, AvailabilityScopePort, AvailabilityScopeRead, CheckoutDemand,
InventoryWriteService, NewDelivery, NewReceipt, NewWarehouse, ReceiptLine, DeliveryLine,
};
struct StubGl;
#[async_trait::async_trait]
impl backbone_inventory::application::service::inventory_gl::GlPostSink for StubGl {
async fn post(
&self,
_e: &backbone_inventory::application::service::inventory_gl::AccountingPostEnvelope,
) -> Result<
backbone_inventory::application::service::inventory_gl::GlPostAck,
backbone_inventory::application::service::inventory_gl::GlPostRejected,
> {
Ok(backbone_inventory::application::service::inventory_gl::GlPostAck {
post_id: Uuid::new_v4(),
journal_id: Uuid::new_v4(),
idempotent_reuse: false,
})
}
}
fn d(s: &str) -> Decimal {
Decimal::from_str_exact(s).unwrap()
}
fn day() -> chrono::NaiveDate {
chrono::NaiveDate::from_ymd_opt(2026, 9, 4).unwrap()
}
fn uq(p: &str) -> String {
format!("{p}-{}", &Uuid::new_v4().simple().to_string()[..8])
}
async fn pool() -> PgPool {
let url = std::env::var("DATABASE_URL").unwrap_or_else(|_| {
"postgresql://postgres:postgres@localhost:5433/backbone_inventory".to_string()
});
PgPool::connect(&url).await.expect("connect DB")
}
async fn warehouse(w: &InventoryWriteService, is_group: bool) -> Uuid {
w.create_warehouse(NewWarehouse {
code: uq("WH"),
name: uq("Main"),
warehouse_type: None,
parent_warehouse_id: None,
is_group,
})
.await
.unwrap()
}
async fn receive(w: &InventoryWriteService, wh: Uuid, item: Uuid, qty: Decimal) {
let rid = w
.create_purchase_receipt(NewReceipt {
receipt_number: uq("PR"),
branch_id: None,
supplier_id: Uuid::new_v4(),
source_po_id: None,
warehouse_id: wh,
posting_date: day(),
currency: "IDR".into(),
inventory_account_id: Uuid::new_v4(),
grir_account_id: Uuid::new_v4(),
lines: vec![ReceiptLine {
item_id: item,
quantity: qty,
rate: d("100"),
is_landed_costs_line: false,
}],
})
.await
.unwrap();
w.submit_purchase_receipt(rid, &StubGl).await.unwrap();
}
async fn deliver(w: &InventoryWriteService, wh: Uuid, item: Uuid, qty: Decimal) {
let did = w
.create_delivery_note(NewDelivery {
delivery_number: uq("DN"),
branch_id: None,
customer_id: Uuid::new_v4(),
source_so_id: None,
warehouse_id: wh,
posting_date: day(),
currency: "IDR".into(),
cogs_account_id: Uuid::new_v4(),
inventory_account_id: Uuid::new_v4(),
lines: vec![DeliveryLine { item_id: item, quantity: qty }],
})
.await
.unwrap();
w.submit_delivery_note(did, &StubGl).await.unwrap();
}
#[tokio::test]
async fn sold_out_verdict_evaluates_every_variant() {
let pool = pool().await;
let w = InventoryWriteService::new(pool.clone());
let read = AvailabilityScopeRead::new(pool.clone());
let wh = warehouse(&w, false).await;
let (v1, v2, v3) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4());
receive(&w, wh, v2, d("5")).await;
let verdict = read.sold_out(&[v1, v2, v3], wh).await.unwrap();
assert!(!verdict.sold_out, "v2 holds stock: the set is not sold out");
assert_eq!(verdict.per_item.len(), 3);
assert_eq!(verdict.per_item[0].item_id, v1);
assert_eq!(verdict.per_item[0].available_qty, d("0"));
assert_eq!(verdict.per_item[1].item_id, v2);
assert_eq!(verdict.per_item[1].available_qty, d("5"));
assert_eq!(verdict.per_item[2].item_id, v3);
assert_eq!(verdict.per_item[2].available_qty, d("0"));
deliver(&w, wh, v2, d("5")).await;
let exhausted = read.sold_out(&[v1, v2, v3], wh).await.unwrap();
assert!(exhausted.sold_out, "no variant holds availability");
}
#[tokio::test]
async fn display_and_checkout_scopes_differ_and_stay_fresh() {
let pool = pool().await;
let w = InventoryWriteService::new(pool.clone());
let read = AvailabilityScopeRead::new(pool.clone());
let wh = warehouse(&w, false).await;
let item = Uuid::new_v4();
receive(&w, wh, item, d("10")).await;
let display = read
.display_availability(&[item], wh)
.await
.unwrap();
assert_eq!(display[0].on_hand_qty, d("10"));
assert_eq!(display[0].available_qty, d("10"));
let with_six_held = read
.checkout_free_qty(
&[CheckoutDemand { item_id: item, held_qty: d("6") }],
wh,
)
.await
.unwrap();
assert_eq!(with_six_held[0].available_qty, d("10"));
assert_eq!(with_six_held[0].free_qty, d("4"));
let over_held = read
.checkout_free_qty(
&[CheckoutDemand { item_id: item, held_qty: d("12") }],
wh,
)
.await
.unwrap();
assert_eq!(over_held[0].free_qty, d("0"));
deliver(&w, wh, item, d("4")).await;
let display_after = read
.display_availability(&[item], wh)
.await
.unwrap();
assert_eq!(display_after[0].on_hand_qty, d("6"), "read tracks the estate per call");
let checkout_after = read
.checkout_free_qty(
&[CheckoutDemand { item_id: item, held_qty: d("6") }],
wh,
)
.await
.unwrap();
assert_eq!(checkout_after[0].available_qty, d("6"));
assert_eq!(checkout_after[0].free_qty, d("0"));
}
#[tokio::test]
async fn no_all_warehouses_fallback_at_the_pivot() {
let pool = pool().await;
let w = InventoryWriteService::new(pool.clone());
let read = AvailabilityScopeRead::new(pool.clone());
let w1 = warehouse(&w, false).await;
let w2 = warehouse(&w, false).await;
let item = Uuid::new_v4();
receive(&w, w2, item, d("10")).await;
let at_shop = read.display_availability(&[item], w1).await.unwrap();
assert_eq!(at_shop[0].available_qty, d("0"), "W2's stock must not leak into W1");
let verdict = read.sold_out(&[item], w1).await.unwrap();
assert!(verdict.sold_out, "sold out at the shop pivot despite stock in W2");
let at_w2 = read.display_availability(&[item], w2).await.unwrap();
assert_eq!(at_w2[0].available_qty, d("10"));
}
#[tokio::test]
async fn pivot_refusals_are_typed() {
let pool = pool().await;
let w = InventoryWriteService::new(pool.clone());
let read = AvailabilityScopeRead::new(pool.clone());
let wh = warehouse(&w, false).await;
let item = Uuid::new_v4();
let bogus = Uuid::new_v4();
assert!(matches!(
read.display_availability(&[item], bogus).await,
Err(AvailabilityScopeError::UnknownWarehouse(w)) if w == bogus
));
let group = warehouse(&w, true).await;
assert!(matches!(
read.display_availability(&[item], group).await,
Err(AvailabilityScopeError::GroupPivot(g)) if g == group
));
assert!(matches!(
read.display_availability(&[], wh).await,
Err(AvailabilityScopeError::EmptyItemSet)
));
assert!(matches!(
read.sold_out(&[], wh).await,
Err(AvailabilityScopeError::EmptyItemSet)
));
assert!(matches!(
read.checkout_free_qty(&[], wh).await,
Err(AvailabilityScopeError::EmptyItemSet)
));
assert!(matches!(
read.display_availability(&[item, item], wh).await,
Err(AvailabilityScopeError::DuplicateItem(i)) if i == item
));
assert!(matches!(
read.checkout_free_qty(
&[
CheckoutDemand { item_id: item, held_qty: d("1") },
CheckoutDemand { item_id: item, held_qty: d("2") },
],
wh
)
.await,
Err(AvailabilityScopeError::DuplicateItem(i)) if i == item
));
assert!(matches!(
read.checkout_free_qty(
&[CheckoutDemand { item_id: item, held_qty: d("-1") }],
wh
)
.await,
Err(AvailabilityScopeError::NegativeHeld(i)) if i == item
));
}
#[tokio::test]
async fn no_materialized_readiness_state_and_reads_write_nothing() {
let pool = pool().await;
let w = InventoryWriteService::new(pool.clone());
let read = AvailabilityScopeRead::new(pool.clone());
let wh = warehouse(&w, false).await;
let item = Uuid::new_v4();
receive(&w, wh, item, d("3")).await;
let persisted: i64 = sqlx::query_scalar(
r#"SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'inventory'
AND (column_name ILIKE '%shop_warning%'
OR column_name ILIKE '%readiness%'
OR column_name ILIKE '%sold_out%')"#,
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(persisted, 0, "no readiness/warning/sold-out column may exist");
let before: (i64, Option<String>) = sqlx::query(
r#"SELECT COUNT(*), MAX(metadata->>'updated_at')::text FROM inventory.stock_quants
WHERE item_id = $1"#,
)
.bind(item)
.fetch_one(&pool)
.await
.map(|r| (r.get(0), r.get(1)))
.unwrap();
for _ in 0..3 {
let _ = read.display_availability(&[item], wh).await.unwrap();
let _ = read
.checkout_free_qty(
&[CheckoutDemand { item_id: item, held_qty: d("1") }],
wh,
)
.await
.unwrap();
let _ = read.sold_out(&[item], wh).await.unwrap();
}
let after: (i64, Option<String>) = sqlx::query(
r#"SELECT COUNT(*), MAX(metadata->>'updated_at')::text FROM inventory.stock_quants
WHERE item_id = $1"#,
)
.bind(item)
.fetch_one(&pool)
.await
.map(|r| (r.get(0), r.get(1)))
.unwrap();
assert_eq!(before, after, "availability reads must not write anything");
}