use ruststream::codec::JsonCodec;
use ruststream::memory::prelude::*;
use ruststream::schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::notifications::{Notice, Notices};
use crate::orders::{Line, within_bounds};
use crate::payments::{INTERNAL, Paid};
use crate::state::Shop;
const PARCEL_CAPACITY: u32 = 10;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
pub(crate) struct Parcel {
pub(crate) items: Vec<(String, u32)>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Outgoing)]
#[outgoing(name = "shipping.packed")]
pub(crate) struct Packed {
pub(crate) order: u64,
pub(crate) customer: String,
pub(crate) parcels: Vec<Parcel>,
}
pub(crate) fn plan(lines: &[Line]) -> Vec<Parcel> {
let mut parcels: Vec<Parcel> = Vec::new();
let mut room = 0;
for line in lines {
let mut left = line.quantity;
while left > 0 {
if room == 0 {
parcels.push(Parcel { items: Vec::new() });
room = PARCEL_CAPACITY;
}
let take = left.min(room);
if let Some(parcel) = parcels.last_mut() {
parcel.items.push((line.sku.clone(), take));
}
left -= take;
room -= take;
}
}
parcels
}
async fn book_courier(packed: &Packed) -> String {
tokio::task::yield_now().await;
format!("TRK-{}-{}", packed.order, packed.parcels.len())
}
#[subscriber("payments.settled", threads(2), reply)]
async fn pack(paid: &Paid) -> Result<Packed, HandlerOutcome> {
if !within_bounds(&paid.lines) {
tracing::warn!(
order = paid.order,
"a paid order past the shop's bounds, dropped"
);
return Err(HandlerOutcome::drop());
}
Ok(Packed {
order: paid.order,
customer: paid.customer.clone(),
parcels: plan(&paid.lines),
})
}
#[subscriber("shipping.packed", workers(4))]
async fn dispatch(packed: &Packed, Out(notices): Out<impl Publisher, Notices>) -> HandlerOutcome {
let tracking = book_courier(packed).await;
let notice = Notice {
customer: packed.customer.clone(),
text: format!("Order {} is on its way: {tracking}.", packed.order),
};
match notices.message(¬ice).publish().await {
Ok(()) => HandlerOutcome::ack(),
Err(_) => HandlerOutcome::retry(),
}
}
pub(crate) fn routes() -> impl RouterDef<MemoryBroker, Shop> {
Router::new()
.with_codec(INTERNAL)
.include(pack)
.out_reply(Publish)
.codec(INTERNAL)
.build()
.include(dispatch)
.out(Notices, Publish)
.codec(JsonCodec)
.build()
}
#[cfg(test)]
mod tests {
use std::error::Error;
use ruststream::codec::CborCodec;
use ruststream::memory::prelude::*;
use ruststream::testing::TestApp;
use super::{Line, Packed, Parcel, plan};
use crate::orders::MAX_QUANTITY;
use crate::payments::Paid;
use crate::service::{shop, test_broker};
fn line(sku: &str, quantity: u32) -> Line {
Line {
sku: sku.to_owned(),
quantity,
price_cents: 100,
}
}
#[test]
fn a_line_larger_than_a_parcel_spills_into_the_next() {
let parcels = plan(&[line("mug", 4), line("tea", 12)]);
assert_eq!(
parcels,
[
Parcel {
items: vec![("mug".to_owned(), 4), ("tea".to_owned(), 6)],
},
Parcel {
items: vec![("tea".to_owned(), 6)],
},
]
);
}
#[tokio::test]
async fn a_paid_order_past_the_bounds_is_not_packed() -> Result<(), Box<dyn Error>> {
let tb = TestApp::start(shop(test_broker())).await?;
let broker = tb.broker::<MemoryBroker>();
let paid = Paid {
order: 12,
customer: "ada@example.com".to_owned(),
lines: vec![line("mug", MAX_QUANTITY + 1)],
};
broker
.message(&paid)
.with_codec(CborCodec)
.publish()
.await?;
broker
.subscriber("payments.settled")
.assert_called_once()
.settled(HandlerOutcome::drop());
broker
.published::<Packed>("shipping.packed")
.assert_not_called();
Ok(())
}
}