Back to report

Tests and coverage by phase

Markers distinguish covered lines, uncovered lines, and lines with no coverage measurement. Each measured run is shown with the lines above and below it.

shop / shop-rs-api / integration

probe-bind.stderr adapter.stdout tests.stdout adapter.stderr api.stderr probe-missing-database.stderr build.log
shop-rs-api/src/application/service.rs: 55/60 covered lines
runoldnew±codecov
1212}
1313
1414impl From<RepositoryError> for ShopError {
1515 fn from(error: RepositoryError) -> Self {
1616 match error {
1717 RepositoryError::Rejected(domain) => Self::Domain(domain),
1818 RepositoryError::Unavailable(reason) => Self::Unavailable(reason),
1919 }
2020 }
2121}
2222
2323pub struct CatalogService {
…
2525}
2626
2727impl CatalogService {
2828 pub fn new(products: Arc<dyn ProductRepository>) -> Self {
2929 Self { products }
3030 }
3131 pub async fn list_products(&self) -> Result<Vec<Product>, ShopError> {
3232 Ok(self.products.list().await?)
3333 }
3434 pub async fn find_product(&self, sku: &Sku) -> Result<Product, ShopError> {
3535 self.products
3636 .find_by_sku(sku)
3737 .await?
3838 .ok_or_else(|| ShopError::Domain(DomainError::ProductNotFound(sku.clone())))
3939 }
4040}
4141
4242pub struct CartService {
…
4646}
4747
4848impl CartService {
4949 pub fn new(
5050 products: Arc<dyn ProductRepository>,
5151 carts: Arc<dyn CartRepository>,
5252 orders: Arc<dyn OrderRepository>,
5353 ) -> Self {
5454 Self {
5555 products,
5656 carts,
5757 orders,
5858 }
5959 }
6060 pub async fn open_cart(&self) -> Result<Cart, ShopError> {
6161 Ok(self.carts.create().await?)
6262 }
6363 pub async fn find_cart(&self, cart_id: &CartId) -> Result<Cart, ShopError> {
6464 self.carts
6565 .find(cart_id)
6666 .await?
6767 .ok_or_else(|| ShopError::Domain(DomainError::CartNotFound(cart_id.clone())))
6868 }
6969 pub async fn add_to_cart(
7070 &self,
7171 cart_id: &CartId,
7272 sku: &Sku,
7373 quantity: Quantity,
7474 ) -> Result<Cart, ShopError> {
7575 let cart = self.find_cart(cart_id).await?;
7676 let product = self
7777 .products
7878 .find_by_sku(sku)
7979 .await?
8080 .ok_or_else(|| ShopError::Domain(DomainError::ProductNotFound(sku.clone())))?;
8181 let line = cart.add(&product, quantity)?;
8282 self.carts.save_line(cart_id, &line).await?;
8383 self.find_cart(cart_id).await
8484 }
8585 pub async fn checkout(&self, cart_id: &CartId) -> Result<Order, ShopError> {
8686 let cart = self.find_cart(cart_id).await?;
8787 let skus: Vec<Sku> = cart.lines.iter().map(|line| line.sku.clone()).collect();
8888 let products = self.products.find_by_skus(&skus).await?;
8989 let draft = cart.checkout(&products)?;
9090 Ok(self.orders.place(&draft).await?)
9191 }
9292}
shop-rs-api/src/domain/model.rs: 133/138 covered lines
runoldnew±codecov
2121}
2222
2323impl fmt::Display for DomainError {
2424 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2525 match self {
2626 Self::InvalidSku => write!(
2727 formatter,
2828 "sku must be {MINIMUM_SKU_LENGTH}-{MAXIMUM_SKU_LENGTH} uppercase letters, digits or dashes"
2929 ),
3030 Self::InvalidQuantity => {
3131 write!(formatter, "quantity must be between 1 and {MAXIMUM_LINE_QUANTITY}")
3232 }
3333 Self::InvalidMoney => write!(formatter, "amount must not be negative"),
3434 Self::InvalidCartId => write!(formatter, "cart id must be a UUID"),
3535 Self::AmountOverflow => write!(formatter, "order total exceeds the supported range"),
3636 Self::ProductNotFound(sku) => write!(formatter, "product {sku} does not exist"),
3737 Self::CartNotFound(cart_id) => write!(formatter, "cart {cart_id} does not exist"),
3838 Self::CartClosed => write!(formatter, "cart is already checked out"),
3939 Self::EmptyCart => write!(formatter, "cart has no items"),
4040 Self::OutOfStock(sku) => write!(formatter, "not enough stock for {sku}"),
4141 }
4242 }
4343}
4444
4545impl std::error::Error for DomainError {}
…
4848pub struct Money(i64);
4949
5050impl Money {
5151 pub fn from_cents(cents: i64) -> Result<Self, DomainError> {
5252 if cents < 0 {
5353 return Err(DomainError::InvalidMoney);
5454 }
5555 Ok(Self(cents))
5656 }
5757 pub fn zero() -> Self {
5858 Self(0)
5959 }
6060 pub fn cents(self) -> i64 {
6161 self.0
6262 }
6363 pub fn times(self, quantity: Quantity) -> Result<Self, DomainError> {
6464 self.0
6565 .checked_mul(i64::from(quantity.value()))
6666 .map(Self)
6767 .ok_or(DomainError::AmountOverflow)
6868 }
6969 pub fn plus(self, other: Money) -> Result<Self, DomainError> {
7070 self.0
7171 .checked_add(other.0)
7272 .map(Self)
7373 .ok_or(DomainError::AmountOverflow)
7474 }
7575}
7676
7777#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
7878pub struct Sku(String);
7979
8080impl Sku {
8181 pub fn parse(raw: &str) -> Result<Self, DomainError> {
8282 let length_valid = (MINIMUM_SKU_LENGTH..=MAXIMUM_SKU_LENGTH).contains(&raw.len());
8383 let characters_valid = raw.chars().all(|character| {
8484 character.is_ascii_uppercase() || character.is_ascii_digit() || character == '-'
8585 });
8686 if !length_valid || !characters_valid {
8787 return Err(DomainError::InvalidSku);
8888 }
8989 Ok(Self(raw.to_owned()))
9090 }
9191 pub fn as_str(&self) -> &str {
9292 &self.0
9393 }
9494}
9595
9696impl fmt::Display for Sku {
9797 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
9898 formatter.write_str(&self.0)
9999 }
100100}
101101
102102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103103pub struct Quantity(u32);
104104
105105impl Quantity {
106106 pub fn new(value: i64) -> Result<Self, DomainError> {
107107 u32::try_from(value)
108108 .ok()
109109 .filter(|candidate| (1..=MAXIMUM_LINE_QUANTITY).contains(candidate))
110110 .map(Self)
111111 .ok_or(DomainError::InvalidQuantity)
112112 }
113113 pub fn value(self) -> u32 {
114114 self.0
115115 }
116116}
117117
118118#[derive(Debug, Clone, PartialEq, Eq)]
119119pub struct CartId(String);
120120
121121impl CartId {
122122 pub fn parse(raw: &str) -> Result<Self, DomainError> {
123123 let shape_valid = raw.len() == CART_ID_LENGTH
124124 && raw.char_indices().all(|(position, character)| {
125125 if CART_ID_DASH_POSITIONS.contains(&position) {
126126 character == '-'
127127 } else {
128128 character.is_ascii_hexdigit()
129129 }
130130 });
131131 if !shape_valid {
132132 return Err(DomainError::InvalidCartId);
133133 }
134134 Ok(Self(raw.to_ascii_lowercase()))
135135 }
136136 pub fn as_str(&self) -> &str {
137137 &self.0
138138 }
139139}
140140
141141impl fmt::Display for CartId {
142142 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
143143 formatter.write_str(&self.0)
144144 }
145145}
146146
147147#[derive(Debug, Clone, PartialEq, Eq)]
…
153153}
154154
155155impl Product {
156156 pub fn ensure_available(&self, quantity: Quantity) -> Result<(), DomainError> {
157157 if quantity.value() > self.stock {
158158 return Err(DomainError::OutOfStock(self.sku.clone()));
159159 }
160160 Ok(())
161161 }
162162}
163163
164164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
…
203203}
204204
205205impl Cart {
206206 pub fn add(&self, product: &Product, quantity: Quantity) -> Result<CartLine, DomainError> {
207207 self.ensure_open()?;
208208 let existing = self
209209 .lines
210210 .iter()
211211 .find(|line| line.sku == product.sku)
212212 .map_or(0, |line| line.quantity.value());
213213 let merged = Quantity::new(i64::from(existing) + i64::from(quantity.value()))?;
214214 product.ensure_available(merged)?;
215215 Ok(CartLine {
216216 sku: product.sku.clone(),
217217 quantity: merged,
218218 })
219219 }
220220 pub fn checkout(&self, products: &[Product]) -> Result<OrderDraft, DomainError> {
221221 self.ensure_open()?;
222222 if self.lines.is_empty() {
223223 return Err(DomainError::EmptyCart);
224224 }
225225 let lines = self
226226 .lines
227227 .iter()
228228 .map(|line| Self::price_line(line, products))
229229 .collect::<Result<Vec<_>, _>>()?;
230230 let total = lines.iter().try_fold(Money::zero(), |total, line| {
231231 total.plus(line.unit_price.times(line.quantity)?)
232232 })?;
233233 Ok(OrderDraft {
234234 cart_id: self.id.clone(),
235235 lines,
236236 total,
237237 })
238238 }
239239 fn ensure_open(&self) -> Result<(), DomainError> {
240240 match self.status {
241241 CartStatus::Open => Ok(()),
242242 CartStatus::CheckedOut => Err(DomainError::CartClosed),
243243 }
244244 }
245245 fn price_line(line: &CartLine, products: &[Product]) -> Result<OrderLine, DomainError> {
246246 let product = products
247247 .iter()
248248 .find(|product| product.sku == line.sku)
249249 .ok_or_else(|| DomainError::ProductNotFound(line.sku.clone()))?;
250250 product.ensure_available(line.quantity)?;
251251 Ok(OrderLine {
252252 sku: line.sku.clone(),
253253 quantity: line.quantity,
254254 unit_price: product.price,
255255 })
256256 }
257257}
258258
259259#[cfg(test)]
shop-rs-api/src/domain/port.rs: 0/5 covered lines
runoldnew±codecov
1111}
1212
1313impl fmt::Display for RepositoryError {
1414 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1515 match self {
1616 Self::Rejected(error) => write!(formatter, "{error}"),
1717 Self::Unavailable(reason) => write!(formatter, "storage unavailable: {reason}"),
1818 }
1919 }
2020}
2121
2222impl std::error::Error for RepositoryError {}
shop-rs-api/src/infrastructure/postgres.rs: 82/94 covered lines
runoldnew±codecov
1212const CART_STATUS_OPEN: &str = "open";
1313const CART_STATUS_CHECKED_OUT: &str = "checked_out";
1414
1515pub async fn connect(database_url: &str) -> Result<PgPool, RepositoryError> {
1616 let pool = checked(
1717 PgPoolOptions::new()
1818 .max_connections(MAXIMUM_POOL_CONNECTIONS)
1919 .connect(database_url)
2020 .await,
2121 "connect",
2222 )?;
2323 checked(sqlx::migrate!("./migrations").run(&pool).await, "migrate")?;
2424 Ok(pool)
2525}
2626
2727fn unavailable(operation: &'static str, error: &dyn std::error::Error) -> RepositoryError {
2828 tracing::error!(upstream = "postgres", operation, error = %error, "storage operation failed");
2929 RepositoryError::Unavailable(operation.to_owned())
3030}
3131
3232fn checked<T, E>(result: Result<T, E>, operation: &'static str) -> Result<T, RepositoryError>
3333where
3434 E: std::error::Error,
3535{
3636 result.map_err(|error| unavailable(operation, &error))
3737}
3838
3939fn product_from_row(row: &PgRow) -> Result<Product, RepositoryError> {
4040 let sku: String = checked(row.try_get("sku"), "decode_product")?;
4141 let name: String = checked(row.try_get("name"), "decode_product")?;
4242 let price_cents: i64 = checked(row.try_get("price_cents"), "decode_product")?;
4343 let stock: i32 = checked(row.try_get("stock"), "decode_product")?;
4444 product_from_parts(&sku, name, price_cents, stock)
4545}
4646
4747fn product_from_parts(
4848 sku: &str,
4949 name: String,
5050 price_cents: i64,
5151 stock: i32,
5252) -> Result<Product, RepositoryError> {
5353 Ok(Product {
5454 sku: Sku::parse(sku).map_err(RepositoryError::Rejected)?,
5555 name,
5656 price: Money::from_cents(price_cents).map_err(RepositoryError::Rejected)?,
5757 stock: u32::try_from(stock)
5858 .map_err(|_| RepositoryError::Rejected(DomainError::InvalidQuantity))?,
5959 })
6060}
6161
6262pub struct PostgresProductRepository {
6363 pool: PgPool,
6464}
6565
6767 pub fn new(pool: PgPool) -> Self {
6868 Self { pool }
6969 }
7070}
7171
7272#[async_trait]
7373impl ProductRepository for PostgresProductRepository {
7474 async fn list(&self) -> Result<Vec<Product>, RepositoryError> {
7575 let rows = checked(
7676 sqlx::query(r#"SELECT SKU, NAME, PRICE_CENTS, STOCK FROM product ORDER BY SKU"#)
7777 .fetch_all(&self.pool)
…
7979 "list_products",
8080 )?;
8181 rows.iter().map(product_from_row).collect()
8282 }
8383 async fn find_by_sku(&self, sku: &Sku) -> Result<Option<Product>, RepositoryError> {
8484 let row = checked(
8585 sqlx::query(r#"SELECT SKU, NAME, PRICE_CENTS, STOCK FROM product WHERE SKU = $1"#)
8686 .bind(sku.as_str())
…
8989 "find_product",
9090 )?;
9191 row.as_ref().map(product_from_row).transpose()
9292 }
9393 async fn find_by_skus(&self, skus: &[Sku]) -> Result<Vec<Product>, RepositoryError> {
9494 let values: Vec<String> = skus.iter().map(|sku| sku.as_str().to_owned()).collect();
9595 let rows = checked(
9696 sqlx::query(r#"SELECT SKU, NAME, PRICE_CENTS, STOCK FROM product WHERE SKU = ANY($1)"#)
9797 .bind(&values)
…
100100 "find_products",
101101 )?;
102102 rows.iter().map(product_from_row).collect()
103103 }
104104}
105105
106106pub struct PostgresCartRepository {
…
108108}
109109
111111 pub fn new(pool: PgPool) -> Self {
112112 Self { pool }
113113 }
114114}
115115
116116fn cart_status(raw: &str) -> Result<CartStatus, RepositoryError> {
117117 match raw {
118118 CART_STATUS_OPEN => Ok(CartStatus::Open),
119119 CART_STATUS_CHECKED_OUT => Ok(CartStatus::CheckedOut),
120120 other => {
121121 tracing::error!(upstream = "postgres", status = other, "unknown cart status");
122122 Err(RepositoryError::Unavailable("decode_cart".to_owned()))
123123 }
124124 }
125125}
126126
127127fn cart_line_from_row(row: &PgRow) -> Result<CartLine, RepositoryError> {
128128 let sku: String = checked(row.try_get("sku"), "decode_cart_line")?;
129129 let quantity: i32 = checked(row.try_get("quantity"), "decode_cart_line")?;
130130 cart_line_from_parts(&sku, quantity)
131131}
132132
133133fn cart_line_from_parts(sku: &str, quantity: i32) -> Result<CartLine, RepositoryError> {
134134 Ok(CartLine {
135135 sku: Sku::parse(sku).map_err(RepositoryError::Rejected)?,
136136 quantity: Quantity::new(i64::from(quantity)).map_err(RepositoryError::Rejected)?,
137137 })
138138}
139139
140140fn quantity_column(quantity: u32) -> Result<i32, RepositoryError> {
141141 i32::try_from(quantity).map_err(|_| RepositoryError::Rejected(DomainError::InvalidQuantity))
142142}
143143
144144fn close_outcome(rows_affected: u64) -> Result<(), RepositoryError> {
145145 if rows_affected == 0 {
146146 Err(RepositoryError::Rejected(DomainError::CartClosed))
147147 } else {
148148 Ok(())
149149 }
150150}
151151
152152fn reserve_outcome(rows_affected: u64, sku: &Sku) -> Result<(), RepositoryError> {
153153 if rows_affected == 0 {
154154 Err(RepositoryError::Rejected(DomainError::OutOfStock(
155155 sku.clone(),
156156 )))
157157 } else {
158158 Ok(())
159159 }
160160}
161161
162162#[async_trait]
163163impl CartRepository for PostgresCartRepository {
164164 async fn create(&self) -> Result<Cart, RepositoryError> {
165165 let row = checked(
166166 sqlx::query(r#"INSERT INTO cart (STATUS) VALUES ($1) RETURNING ID::text AS id"#)
167167 .bind(CART_STATUS_OPEN)
…
175175 status: CartStatus::Open,
176176 lines: Vec::new(),
177177 })
178178 }
179179 async fn find(&self, cart_id: &CartId) -> Result<Option<Cart>, RepositoryError> {
180180 let Some(row) = checked(
181181 sqlx::query(r#"SELECT STATUS FROM cart WHERE ID = $1::uuid"#)
182182 .bind(cart_id.as_str())
…
205205 .map(cart_line_from_row)
206206 .collect::<Result<_, _>>()?,
207207 }))
208208 }
209209 async fn save_line(&self, cart_id: &CartId, line: &CartLine) -> Result<(), RepositoryError> {
210210 let quantity = quantity_column(line.quantity.value())?;
211211 checked(
212212 sqlx::query(
…
221221 "save_cart_line",
222222 )?;
223223 Ok(())
224224 }
225225}
226226
227227pub struct PostgresOrderRepository {
…
229229}
230230
232232 pub fn new(pool: PgPool) -> Self {
233233 Self { pool }
234234 }
235235}
236236
237237#[async_trait]
238238impl OrderRepository for PostgresOrderRepository {
239239 async fn place(&self, draft: &OrderDraft) -> Result<Order, RepositoryError> {
240240 let mut transaction = checked(self.pool.begin().await, "begin_order")?;
241241 let closed = checked(
242242 sqlx::query(r#"UPDATE cart SET STATUS = $2 WHERE ID = $1::uuid AND STATUS = $3"#)
…
293293 lines: draft.lines.clone(),
294294 total: draft.total,
295295 })
296296 }
297297}
298298
299299#[cfg(test)]
shop-rs-api/src/infrastructure/web.rs: 148/165 covered lines
runoldnew±codecov
2929}
3030
3131impl From<Product> for ProductResponse {
3232 fn from(product: Product) -> Self {
3333 Self {
3434 sku: product.sku.as_str().to_owned(),
3535 name: product.name,
3636 price_cents: product.price.cents(),
3737 stock: product.stock,
3838 }
3939 }
4040}
4141
4242#[derive(Debug, Serialize, ToSchema)]
…
5252}
5353
5454impl From<CartLine> for CartLineResponse {
5555 fn from(line: CartLine) -> Self {
5656 Self {
5757 sku: line.sku.as_str().to_owned(),
5858 quantity: line.quantity.value(),
5959 }
6060 }
6161}
6262
6363#[derive(Debug, Serialize, ToSchema)]
…
6868}
6969
7070impl From<CartStatus> for CartStatusResponse {
7171 fn from(status: CartStatus) -> Self {
7272 match status {
7373 CartStatus::Open => Self::Open,
7474 CartStatus::CheckedOut => Self::CheckedOut,
7575 }
7676 }
7777}
7878
7979#[derive(Debug, Serialize, ToSchema)]
…
8484}
8585
8686impl From<Cart> for CartResponse {
8787 fn from(cart: Cart) -> Self {
8888 Self {
8989 id: cart.id.as_str().to_owned(),
9090 status: cart.status.into(),
9191 lines: cart.lines.into_iter().map(CartLineResponse::from).collect(),
9292 }
9393 }
9494}
9595
9696#[derive(Debug, Serialize, ToSchema)]
…
101101}
102102
103103impl From<OrderLine> for OrderLineResponse {
104104 fn from(line: OrderLine) -> Self {
105105 Self {
106106 sku: line.sku.as_str().to_owned(),
107107 quantity: line.quantity.value(),
108108 unit_price_cents: line.unit_price.cents(),
109109 }
110110 }
111111}
112112
113113#[derive(Debug, Serialize, ToSchema)]
…
119119}
120120
121121impl From<Order> for OrderResponse {
122122 fn from(order: Order) -> Self {
123123 Self {
124124 id: order.id,
125125 cart_id: order.cart_id.as_str().to_owned(),
126126 lines: order
127127 .lines
128128 .into_iter()
129129 .map(OrderLineResponse::from)
130130 .collect(),
131131 total_cents: order.total.cents(),
132132 }
133133 }
134134}
135135
136136#[derive(Debug, Deserialize, ToSchema)]
…
158158}
159159
160160impl From<DomainError> for ApiError {
161161 fn from(error: DomainError) -> Self {
162162 let (status, code) = match &error {
163163 DomainError::InvalidSku => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_sku"),
164164 DomainError::InvalidQuantity => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_quantity"),
165165 DomainError::InvalidMoney => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_money"),
166166 DomainError::InvalidCartId => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_cart_id"),
167167 DomainError::AmountOverflow => (StatusCode::UNPROCESSABLE_ENTITY, "amount_overflow"),
168168 DomainError::EmptyCart => (StatusCode::UNPROCESSABLE_ENTITY, "empty_cart"),
169169 DomainError::ProductNotFound(_) => (StatusCode::NOT_FOUND, "product_not_found"),
170170 DomainError::CartNotFound(_) => (StatusCode::NOT_FOUND, "cart_not_found"),
171171 DomainError::CartClosed => (StatusCode::CONFLICT, "cart_closed"),
172172 DomainError::OutOfStock(_) => (StatusCode::CONFLICT, "out_of_stock"),
173173 };
174174 Self {
175175 status,
176176 code,
177177 message: error.to_string(),
178178 }
179179 }
180180}
181181
182182impl From<ShopError> for ApiError {
183183 fn from(error: ShopError) -> Self {
184184 match error {
185185 ShopError::Domain(domain_error) => domain_error.into(),
186186 ShopError::Unavailable(operation) => {
187187 tracing::error!(operation = %operation, "request failed on storage");
188188 Self {
189189 status: StatusCode::INTERNAL_SERVER_ERROR,
190190 code: "storage_unavailable",
191191 message: "storage is unavailable".to_owned(),
192192 }
193193 }
194194 }
195195 }
196196}
197197
198198impl From<JsonRejection> for ApiError {
199199 fn from(rejection: JsonRejection) -> Self {
200200 Self {
201201 status: StatusCode::UNPROCESSABLE_ENTITY,
202202 code: "invalid_body",
203203 message: rejection.body_text(),
204204 }
205205 }
206206}
207207
208208impl IntoResponse for ApiError {
209209 fn into_response(self) -> Response {
210210 let body = ErrorResponse {
211211 error: ErrorBody {
212212 code: self.code,
213213 message: self.message,
214214 },
215215 };
216216 (self.status, Json(body)).into_response()
217217 }
218218}
219219
220220#[utoipa::path(get, path = "/health", tag = "Health", responses((status = 200, description = "Service is up")))]
221221async fn health() -> StatusCode {
222222 StatusCode::OK
223223}
224224
225225#[utoipa::path(get, path = "/v1/products", tag = "Catalog",
226226 responses((status = 200, body = ProductListResponse), (status = 500, body = ErrorResponse)))]
227227async fn list_products(
228228 State(state): State<AppState>,
229229) -> Result<Json<ProductListResponse>, ApiError> {
230230 let items: Vec<ProductResponse> = state
231231 .catalog
232232 .list_products()
233233 .await?
234234 .into_iter()
235235 .map(ProductResponse::from)
236236 .collect();
237237 let total = items.len();
238238 Ok(Json(ProductListResponse { items, total }))
239239}
240240
241241#[utoipa::path(get, path = "/v1/products/{sku}", tag = "Catalog",
242242 params(("sku" = String, Path, description = "Product SKU, e.g. MUG-STONE")),
243243 responses((status = 200, body = ProductResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
244244async fn find_product(
245245 State(state): State<AppState>,
246246 Path(sku): Path<String>,
247247) -> Result<Json<ProductResponse>, ApiError> {
248248 let sku = Sku::parse(&sku)?;
249249 Ok(Json(state.catalog.find_product(&sku).await?.into()))
250250}
251251
252252#[utoipa::path(post, path = "/v1/carts", tag = "Cart",
253253 responses((status = 201, body = CartResponse), (status = 500, body = ErrorResponse)))]
254254async fn open_cart(
255255 State(state): State<AppState>,
256256) -> Result<(StatusCode, Json<CartResponse>), ApiError> {
257257 let cart = state.carts.open_cart().await?;
258258 Ok((StatusCode::CREATED, Json(cart.into())))
259259}
260260
261261#[utoipa::path(get, path = "/v1/carts/{id}", tag = "Cart",
262262 params(("id" = String, Path, description = "Cart UUID")),
263263 responses((status = 200, body = CartResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
264264async fn find_cart(
265265 State(state): State<AppState>,
266266 Path(cart_id): Path<String>,
267267) -> Result<Json<CartResponse>, ApiError> {
268268 let cart_id = CartId::parse(&cart_id)?;
269269 Ok(Json(state.carts.find_cart(&cart_id).await?.into()))
270270}
271271
272272#[utoipa::path(post, path = "/v1/carts/{id}/items", tag = "Cart",
273273 params(("id" = String, Path, description = "Cart UUID")),
274274 request_body = AddCartItemRequest,
275275 responses((status = 200, body = CartResponse), (status = 404, body = ErrorResponse),
276276 (status = 409, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
277277async fn add_cart_item(
278278 State(state): State<AppState>,
279279 Path(cart_id): Path<String>,
280280 payload: Result<Json<AddCartItemRequest>, JsonRejection>,
281281) -> Result<Json<CartResponse>, ApiError> {
282282 let Json(request) = payload?;
283283 let cart_id = CartId::parse(&cart_id)?;
284284 let sku = Sku::parse(&request.sku)?;
285285 let quantity = Quantity::new(request.quantity)?;
286286 Ok(Json(
287287 state
288288 .carts
289289 .add_to_cart(&cart_id, &sku, quantity)
290290 .await?
291291 .into(),
292292 ))
293293}
294294
295295#[utoipa::path(post, path = "/v1/carts/{id}/checkout", tag = "Checkout",
296296 params(("id" = String, Path, description = "Cart UUID")),
297297 responses((status = 201, body = OrderResponse), (status = 404, body = ErrorResponse),
298298 (status = 409, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
299299async fn checkout(
300300 State(state): State<AppState>,
301301 Path(cart_id): Path<String>,
302302) -> Result<(StatusCode, Json<OrderResponse>), ApiError> {
303303 let cart_id = CartId::parse(&cart_id)?;
304304 let order = state.carts.checkout(&cart_id).await?;
305305 tracing::info!(order_id = order.id, cart_id = %order.cart_id, total_cents = order.total.cents(), "order placed");
306306 Ok((StatusCode::CREATED, Json(order.into())))
307307}
308308
309309#[derive(OpenApi)]
310310#[openapi(
…
337337)]
338338struct ApiDocument;
339339
340340async fn openapi() -> Json<utoipa::openapi::OpenApi> {
341341 Json(ApiDocument::openapi())
342342}
343343
344344pub fn router(state: AppState) -> Router {
345345 Router::new()
346346 .route("/health", get(health))
347347 .route("/openapi.json", get(openapi))
348348 .route("/v1/products", get(list_products))
349349 .route("/v1/products/{sku}", get(find_product))
350350 .route("/v1/carts", post(open_cart))
351351 .route("/v1/carts/{id}", get(find_cart))
352352 .route("/v1/carts/{id}/items", post(add_cart_item))
353353 .route("/v1/carts/{id}/checkout", post(checkout))
354354 .with_state(state)
355355}
356356
357357#[cfg(test)]
358358mod tests {
shop-rs-api/src/main.rs: 41/41 covered lines
runoldnew±codecov
1212const DEFAULT_ADDRESS: &str = "0.0.0.0:3000";
1313
1414#[tokio::main]
1515async fn main() {
1616 tracing_subscriber::fmt()
1717 .with_env_filter(EnvFilter::from_default_env())
1818 .init();
1919 if let Err(message) = run().await {
2020 error!(error = %message, "shop-rs-api failed");
2121 std::process::exit(1);
2222 }
2323}
2424
2525async fn run() -> Result<(), String> {
2626 let address = std::env::var("SHOP_API_ADDR").unwrap_or_else(|_| DEFAULT_ADDRESS.to_owned());
2727 let database_url =
2828 std::env::var("DATABASE_URL").map_err(|_| "DATABASE_URL is not set".to_owned())?;
2929 let pool = connect(&database_url)
3030 .await
3131 .map_err(|error| format!("database: {error:?}"))?;
3232 let products = Arc::new(PostgresProductRepository::new(pool.clone()));
3333 let state = AppState {
3434 catalog: Arc::new(CatalogService::new(products.clone())),
3535 carts: Arc::new(CartService::new(
3636 products,
3737 Arc::new(PostgresCartRepository::new(pool.clone())),
3838 Arc::new(PostgresOrderRepository::new(pool)),
3939 )),
4040 };
4141 let listener = TcpListener::bind(&address)
4242 .await
4343 .map_err(|error| bind_failure(&address, &error))?;
4444 tracing::info!(%address, "shop-rs-api listening");
4545 axum::serve(listener, router(state))
4646 .with_graceful_shutdown(wait_for_shutdown())
4747 .await
4848 .map_err(|error| format!("serve: {error}"))
4949}
5050
5151fn bind_failure(address: &str, error: &std::io::Error) -> String {
5252 error!(error = %error, %address, "bind failed");
5353 format!("bind {address}: {error}")
5454}
5555
5656#[rustfmt::skip]
5757async fn wait_for_shutdown() {
5858 #[cfg(unix)]
5959 if let Ok(mut terminate) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { terminate.recv().await; }
6060}

shop / shop-rs-api / unit

adapter.stdout tests.stderr tests.stdout adapter.stderr
shop-rs-api/src/application/service.rs: 60/60 covered lines
runoldnew±codecov
1212}
1313
1414impl From<RepositoryError> for ShopError {
1515 fn from(error: RepositoryError) -> Self {
1616 match error {
1717 RepositoryError::Rejected(domain) => Self::Domain(domain),
1818 RepositoryError::Unavailable(reason) => Self::Unavailable(reason),
1919 }
2020 }
2121}
2222
2323pub struct CatalogService {
…
2525}
2626
2727impl CatalogService {
2828 pub fn new(products: Arc<dyn ProductRepository>) -> Self {
2929 Self { products }
3030 }
3131 pub async fn list_products(&self) -> Result<Vec<Product>, ShopError> {
3232 Ok(self.products.list().await?)
3333 }
3434 pub async fn find_product(&self, sku: &Sku) -> Result<Product, ShopError> {
3535 self.products
3636 .find_by_sku(sku)
3737 .await?
3838 .ok_or_else(|| ShopError::Domain(DomainError::ProductNotFound(sku.clone())))
3939 }
4040}
4141
4242pub struct CartService {
…
4646}
4747
4848impl CartService {
4949 pub fn new(
5050 products: Arc<dyn ProductRepository>,
5151 carts: Arc<dyn CartRepository>,
5252 orders: Arc<dyn OrderRepository>,
5353 ) -> Self {
5454 Self {
5555 products,
5656 carts,
5757 orders,
5858 }
5959 }
6060 pub async fn open_cart(&self) -> Result<Cart, ShopError> {
6161 Ok(self.carts.create().await?)
6262 }
6363 pub async fn find_cart(&self, cart_id: &CartId) -> Result<Cart, ShopError> {
6464 self.carts
6565 .find(cart_id)
6666 .await?
6767 .ok_or_else(|| ShopError::Domain(DomainError::CartNotFound(cart_id.clone())))
6868 }
6969 pub async fn add_to_cart(
7070 &self,
7171 cart_id: &CartId,
7272 sku: &Sku,
7373 quantity: Quantity,
7474 ) -> Result<Cart, ShopError> {
7575 let cart = self.find_cart(cart_id).await?;
7676 let product = self
7777 .products
7878 .find_by_sku(sku)
7979 .await?
8080 .ok_or_else(|| ShopError::Domain(DomainError::ProductNotFound(sku.clone())))?;
8181 let line = cart.add(&product, quantity)?;
8282 self.carts.save_line(cart_id, &line).await?;
8383 self.find_cart(cart_id).await
8484 }
8585 pub async fn checkout(&self, cart_id: &CartId) -> Result<Order, ShopError> {
8686 let cart = self.find_cart(cart_id).await?;
8787 let skus: Vec<Sku> = cart.lines.iter().map(|line| line.sku.clone()).collect();
8888 let products = self.products.find_by_skus(&skus).await?;
8989 let draft = cart.checkout(&products)?;
9090 Ok(self.orders.place(&draft).await?)
9191 }
9292}
shop-rs-api/src/domain/model.rs: 277/277 covered lines
runoldnew±codecov
2121}
2222
2323impl fmt::Display for DomainError {
2424 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2525 match self {
2626 Self::InvalidSku => write!(
2727 formatter,
2828 "sku must be {MINIMUM_SKU_LENGTH}-{MAXIMUM_SKU_LENGTH} uppercase letters, digits or dashes"
2929 ),
3030 Self::InvalidQuantity => {
3131 write!(formatter, "quantity must be between 1 and {MAXIMUM_LINE_QUANTITY}")
3232 }
3333 Self::InvalidMoney => write!(formatter, "amount must not be negative"),
3434 Self::InvalidCartId => write!(formatter, "cart id must be a UUID"),
3535 Self::AmountOverflow => write!(formatter, "order total exceeds the supported range"),
3636 Self::ProductNotFound(sku) => write!(formatter, "product {sku} does not exist"),
3737 Self::CartNotFound(cart_id) => write!(formatter, "cart {cart_id} does not exist"),
3838 Self::CartClosed => write!(formatter, "cart is already checked out"),
3939 Self::EmptyCart => write!(formatter, "cart has no items"),
4040 Self::OutOfStock(sku) => write!(formatter, "not enough stock for {sku}"),
4141 }
4242 }
4343}
4444
4545impl std::error::Error for DomainError {}
…
4848pub struct Money(i64);
4949
5050impl Money {
5151 pub fn from_cents(cents: i64) -> Result<Self, DomainError> {
5252 if cents < 0 {
5353 return Err(DomainError::InvalidMoney);
5454 }
5555 Ok(Self(cents))
5656 }
5757 pub fn zero() -> Self {
5858 Self(0)
5959 }
6060 pub fn cents(self) -> i64 {
6161 self.0
6262 }
6363 pub fn times(self, quantity: Quantity) -> Result<Self, DomainError> {
6464 self.0
6565 .checked_mul(i64::from(quantity.value()))
6666 .map(Self)
6767 .ok_or(DomainError::AmountOverflow)
6868 }
6969 pub fn plus(self, other: Money) -> Result<Self, DomainError> {
7070 self.0
7171 .checked_add(other.0)
7272 .map(Self)
7373 .ok_or(DomainError::AmountOverflow)
7474 }
7575}
7676
7777#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
7878pub struct Sku(String);
7979
8080impl Sku {
8181 pub fn parse(raw: &str) -> Result<Self, DomainError> {
8282 let length_valid = (MINIMUM_SKU_LENGTH..=MAXIMUM_SKU_LENGTH).contains(&raw.len());
8383 let characters_valid = raw.chars().all(|character| {
8484 character.is_ascii_uppercase() || character.is_ascii_digit() || character == '-'
8585 });
8686 if !length_valid || !characters_valid {
8787 return Err(DomainError::InvalidSku);
8888 }
8989 Ok(Self(raw.to_owned()))
9090 }
9191 pub fn as_str(&self) -> &str {
9292 &self.0
9393 }
9494}
9595
9696impl fmt::Display for Sku {
9797 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
9898 formatter.write_str(&self.0)
9999 }
100100}
101101
102102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103103pub struct Quantity(u32);
104104
105105impl Quantity {
106106 pub fn new(value: i64) -> Result<Self, DomainError> {
107107 u32::try_from(value)
108108 .ok()
109109 .filter(|candidate| (1..=MAXIMUM_LINE_QUANTITY).contains(candidate))
110110 .map(Self)
111111 .ok_or(DomainError::InvalidQuantity)
112112 }
113113 pub fn value(self) -> u32 {
114114 self.0
115115 }
116116}
117117
118118#[derive(Debug, Clone, PartialEq, Eq)]
119119pub struct CartId(String);
120120
121121impl CartId {
122122 pub fn parse(raw: &str) -> Result<Self, DomainError> {
123123 let shape_valid = raw.len() == CART_ID_LENGTH
124124 && raw.char_indices().all(|(position, character)| {
125125 if CART_ID_DASH_POSITIONS.contains(&position) {
126126 character == '-'
127127 } else {
128128 character.is_ascii_hexdigit()
129129 }
130130 });
131131 if !shape_valid {
132132 return Err(DomainError::InvalidCartId);
133133 }
134134 Ok(Self(raw.to_ascii_lowercase()))
135135 }
136136 pub fn as_str(&self) -> &str {
137137 &self.0
138138 }
139139}
140140
141141impl fmt::Display for CartId {
142142 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
143143 formatter.write_str(&self.0)
144144 }
145145}
146146
147147#[derive(Debug, Clone, PartialEq, Eq)]
…
153153}
154154
155155impl Product {
156156 pub fn ensure_available(&self, quantity: Quantity) -> Result<(), DomainError> {
157157 if quantity.value() > self.stock {
158158 return Err(DomainError::OutOfStock(self.sku.clone()));
159159 }
160160 Ok(())
161161 }
162162}
163163
164164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
…
203203}
204204
205205impl Cart {
206206 pub fn add(&self, product: &Product, quantity: Quantity) -> Result<CartLine, DomainError> {
207207 self.ensure_open()?;
208208 let existing = self
209209 .lines
210210 .iter()
211211 .find(|line| line.sku == product.sku)
212212 .map_or(0, |line| line.quantity.value());
213213 let merged = Quantity::new(i64::from(existing) + i64::from(quantity.value()))?;
214214 product.ensure_available(merged)?;
215215 Ok(CartLine {
216216 sku: product.sku.clone(),
217217 quantity: merged,
218218 })
219219 }
220220 pub fn checkout(&self, products: &[Product]) -> Result<OrderDraft, DomainError> {
221221 self.ensure_open()?;
222222 if self.lines.is_empty() {
223223 return Err(DomainError::EmptyCart);
224224 }
225225 let lines = self
226226 .lines
227227 .iter()
228228 .map(|line| Self::price_line(line, products))
229229 .collect::<Result<Vec<_>, _>>()?;
230230 let total = lines.iter().try_fold(Money::zero(), |total, line| {
231231 total.plus(line.unit_price.times(line.quantity)?)
232232 })?;
233233 Ok(OrderDraft {
234234 cart_id: self.id.clone(),
235235 lines,
236236 total,
237237 })
238238 }
239239 fn ensure_open(&self) -> Result<(), DomainError> {
240240 match self.status {
241241 CartStatus::Open => Ok(()),
242242 CartStatus::CheckedOut => Err(DomainError::CartClosed),
243243 }
244244 }
245245 fn price_line(line: &CartLine, products: &[Product]) -> Result<OrderLine, DomainError> {
246246 let product = products
247247 .iter()
248248 .find(|product| product.sku == line.sku)
249249 .ok_or_else(|| DomainError::ProductNotFound(line.sku.clone()))?;
250250 product.ensure_available(line.quantity)?;
251251 Ok(OrderLine {
252252 sku: line.sku.clone(),
253253 quantity: line.quantity,
254254 unit_price: product.price,
255255 })
256256 }
257257}
258258
259259#[cfg(test)]
260260mod tests {
261261 use super::*;
262262
263263 fn sku(raw: &str) -> Sku {
264264 Sku::parse(raw).unwrap()
265265 }
266266
267267 fn product(raw_sku: &str, price_cents: i64, stock: u32) -> Product {
268268 Product {
269269 sku: sku(raw_sku),
270270 name: raw_sku.to_owned(),
271271 price: Money::from_cents(price_cents).unwrap(),
272272 stock,
273273 }
274274 }
275275
276276 fn cart(status: CartStatus, lines: Vec<CartLine>) -> Cart {
277277 Cart {
278278 id: CartId::parse("0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0c").unwrap(),
279279 status,
280280 lines,
281281 }
282282 }
283283
284284 fn line(raw_sku: &str, quantity: i64) -> CartLine {
285285 CartLine {
286286 sku: sku(raw_sku),
287287 quantity: Quantity::new(quantity).unwrap(),
288288 }
289289 }
290290
291291 mod sku_parsing {
292292 use super::*;
293293
294294 #[test]
295295 fn accepts_uppercase_digits_and_dashes() {
296296 assert_eq!(
297297 Sku::parse("MUG-001").map(|parsed| parsed.to_string()),
298298 Ok("MUG-001".to_owned())
299299 );
300300 }
301301
302302 #[test]
303303 fn rejects_lowercase() {
304304 assert_eq!(Sku::parse("mug-001"), Err(DomainError::InvalidSku));
305305 }
306306
307307 #[test]
308308 fn rejects_too_short() {
309309 assert_eq!(Sku::parse("AB"), Err(DomainError::InvalidSku));
310310 }
311311 }
312312
313313 mod quantity_bounds {
314314 use super::*;
315315
316316 #[test]
317317 fn rejects_zero() {
318318 assert_eq!(Quantity::new(0), Err(DomainError::InvalidQuantity));
319319 }
320320
321321 #[test]
322322 fn rejects_above_line_maximum() {
323323 assert_eq!(Quantity::new(100), Err(DomainError::InvalidQuantity));
324324 }
325325
326326 #[test]
327327 fn accepts_every_value_in_range() {
328328 assert!((1..=99).all(|value| Quantity::new(value).is_ok()));
329329 }
330330 }
331331
332332 mod cart_id_parsing {
333333 use super::*;
334334
335335 #[test]
336336 fn normalizes_to_lowercase() {
337337 let parsed = CartId::parse("0B8F6C1E-4A2D-4E1F-9C3B-2D5E7F8A9B0C").unwrap();
338338 assert_eq!(parsed.as_str(), "0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0c");
339339 }
340340
341341 #[test]
342342 fn rejects_non_uuid() {
343343 assert_eq!(CartId::parse("42"), Err(DomainError::InvalidCartId));
344344 }
345345 }
346346
347347 mod add_to_cart {
348348 use super::*;
349349
350350 #[test]
351351 fn merges_quantity_with_existing_line() {
352352 let result = cart(CartStatus::Open, vec![line("MUG-001", 2)])
353353 .add(&product("MUG-001", 1800, 10), Quantity::new(3).unwrap());
354354 assert_eq!(result, Ok(line("MUG-001", 5)));
355355 }
356356
357357 #[test]
358358 fn rejects_quantity_beyond_stock() {
359359 let result = cart(CartStatus::Open, vec![])
360360 .add(&product("LAMP-1", 12900, 2), Quantity::new(3).unwrap());
361361 assert_eq!(result, Err(DomainError::OutOfStock(sku("LAMP-1"))));
362362 }
363363
364364 #[test]
365365 fn rejects_merged_quantity_beyond_line_maximum() {
366366 let result = cart(CartStatus::Open, vec![line("MUG-001", 98)])
367367 .add(&product("MUG-001", 1800, 1000), Quantity::new(2).unwrap());
368368 assert_eq!(result, Err(DomainError::InvalidQuantity));
369369 }
370370
371371 #[test]
372372 fn rejects_checked_out_cart() {
373373 let result = cart(CartStatus::CheckedOut, vec![])
374374 .add(&product("MUG-001", 1800, 10), Quantity::new(1).unwrap());
375375 assert_eq!(result, Err(DomainError::CartClosed));
376376 }
377377 }
378378
379379 mod checkout {
380380 use super::*;
381381
382382 #[test]
383383 fn totals_every_line_in_cents() {
384384 let products = [product("MUG-001", 1800, 10), product("TEE-M", 2900, 10)];
385385 let draft = cart(CartStatus::Open, vec![line("MUG-001", 2), line("TEE-M", 1)])
386386 .checkout(&products)
387387 .unwrap();
388388 assert_eq!(draft.total.cents(), 6500);
389389 }
390390
391391 #[test]
392392 fn rejects_empty_cart() {
393393 let result = cart(CartStatus::Open, vec![]).checkout(&[]);
394394 assert_eq!(result, Err(DomainError::EmptyCart));
395395 }
396396
397397 #[test]
398398 fn rejects_line_that_exceeds_current_stock() {
399399 let result = cart(CartStatus::Open, vec![line("LAMP-1", 2)])
400400 .checkout(&[product("LAMP-1", 12900, 1)]);
401401 assert_eq!(result, Err(DomainError::OutOfStock(sku("LAMP-1"))));
402402 }
403403
404404 #[test]
405405 fn rejects_checked_out_cart() {
406406 let result = cart(CartStatus::CheckedOut, vec![line("MUG-001", 1)])
407407 .checkout(&[product("MUG-001", 1800, 10)]);
408408 assert_eq!(result, Err(DomainError::CartClosed));
409409 }
410410
411411 #[test]
412412 fn rejects_a_line_whose_product_is_missing() {
413413 let result = cart(CartStatus::Open, vec![line("MUG-001", 1)]).checkout(&[]);
414414 assert_eq!(result, Err(DomainError::ProductNotFound(sku("MUG-001"))));
415415 }
416416
417417 #[test]
418418 fn rejects_a_total_that_overflows() {
419419 let products = [product("MUG-001", i64::MAX, 10)];
420420 let result = cart(CartStatus::Open, vec![line("MUG-001", 2)]).checkout(&products);
421421 assert_eq!(result, Err(DomainError::AmountOverflow));
422422 }
423423 }
424424
425425 #[test]
426426 fn money_rejects_a_negative_amount_and_an_overflowing_sum() {
427427 assert_eq!(Money::from_cents(-1), Err(DomainError::InvalidMoney));
428428 let max = Money::from_cents(i64::MAX).unwrap();
429429 assert_eq!(
430430 max.plus(Money::from_cents(1).unwrap()),
431431 Err(DomainError::AmountOverflow)
432432 );
433433 assert_eq!(
434434 max.times(Quantity::new(2).unwrap()),
435435 Err(DomainError::AmountOverflow)
436436 );
437437 }
438438
439439 #[test]
440440 fn sku_accepts_the_longest_code_and_exposes_it() {
441441 let longest = "A".repeat(32);
442442 let parsed = Sku::parse(&longest).unwrap();
443443 assert_eq!(parsed.as_str(), longest);
444444 assert_eq!(Sku::parse(&"A".repeat(33)), Err(DomainError::InvalidSku));
445445 }
446446
447447 #[test]
448448 fn cart_id_rejects_a_dash_in_the_wrong_place() {
449449 assert_eq!(
450450 CartId::parse("0b8f6c1e4a2d-4e1f-9c3b-2d5e7f8a9b0c"),
451451 Err(DomainError::InvalidCartId)
452452 );
453453 }
454454
455455 #[test]
456456 fn every_domain_error_names_the_problem() {
457457 let sku = sku("MUG-001");
458458 let cart_id = CartId::parse("0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0c").unwrap();
459459 let messages = [
460460 DomainError::InvalidSku.to_string(),
461461 DomainError::InvalidQuantity.to_string(),
462462 DomainError::InvalidMoney.to_string(),
463463 DomainError::InvalidCartId.to_string(),
464464 DomainError::AmountOverflow.to_string(),
465465 DomainError::ProductNotFound(sku.clone()).to_string(),
466466 DomainError::CartNotFound(cart_id.clone()).to_string(),
467467 DomainError::CartClosed.to_string(),
468468 DomainError::EmptyCart.to_string(),
469469 DomainError::OutOfStock(sku).to_string(),
470470 cart_id.to_string(),
471471 ];
472472 assert!(messages.iter().all(|message| !message.is_empty()));
473473 let as_error: &dyn std::error::Error = &DomainError::InvalidSku;
474474 assert_eq!(as_error.to_string(), DomainError::InvalidSku.to_string());
475475 }
476476}
shop-rs-api/src/domain/port.rs: 13/13 covered lines
runoldnew±codecov
1111}
1212
1313impl fmt::Display for RepositoryError {
1414 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1515 match self {
1616 Self::Rejected(error) => write!(formatter, "{error}"),
1717 Self::Unavailable(reason) => write!(formatter, "storage unavailable: {reason}"),
1818 }
1919 }
2020}
2121
2222impl std::error::Error for RepositoryError {}
…
2727 use crate::domain::model::DomainError;
2828
2929 #[test]
3030 fn display_includes_the_domain_message_or_the_storage_reason() {
3131 let rejected = RepositoryError::Rejected(DomainError::EmptyCart);
3232 let unavailable = RepositoryError::Unavailable("connect".to_owned());
3333 assert_eq!(rejected.to_string(), DomainError::EmptyCart.to_string());
3434 assert_eq!(unavailable.to_string(), "storage unavailable: connect");
3535 let as_error: &dyn std::error::Error = &rejected;
3636 assert!(!as_error.to_string().is_empty());
3737 }
3838}
3939
4040#[async_trait]
shop-rs-api/src/infrastructure/postgres.rs: 106/144 covered lines
runoldnew±codecov
1212const CART_STATUS_OPEN: &str = "open";
1313const CART_STATUS_CHECKED_OUT: &str = "checked_out";
1414
1515pub async fn connect(database_url: &str) -> Result<PgPool, RepositoryError> {
1616 let pool = checked(
1717 PgPoolOptions::new()
1818 .max_connections(MAXIMUM_POOL_CONNECTIONS)
1919 .connect(database_url)
2020 .await,
2121 "connect",
2222 )?;
2323 checked(sqlx::migrate!("./migrations").run(&pool).await, "migrate")?;
2424 Ok(pool)
2525}
2626
2727fn unavailable(operation: &'static str, error: &dyn std::error::Error) -> RepositoryError {
2828 tracing::error!(upstream = "postgres", operation, error = %error, "storage operation failed");
2929 RepositoryError::Unavailable(operation.to_owned())
3030}
3131
3232fn checked<T, E>(result: Result<T, E>, operation: &'static str) -> Result<T, RepositoryError>
3333where
3434 E: std::error::Error,
3535{
3636 result.map_err(|error| unavailable(operation, &error))
3737}
3838
3939fn product_from_row(row: &PgRow) -> Result<Product, RepositoryError> {
4040 let sku: String = checked(row.try_get("sku"), "decode_product")?;
4141 let name: String = checked(row.try_get("name"), "decode_product")?;
4242 let price_cents: i64 = checked(row.try_get("price_cents"), "decode_product")?;
4343 let stock: i32 = checked(row.try_get("stock"), "decode_product")?;
4444 product_from_parts(&sku, name, price_cents, stock)
4545}
4646
4747fn product_from_parts(
4848 sku: &str,
4949 name: String,
5050 price_cents: i64,
5151 stock: i32,
5252) -> Result<Product, RepositoryError> {
5353 Ok(Product {
5454 sku: Sku::parse(sku).map_err(RepositoryError::Rejected)?,
5555 name,
5656 price: Money::from_cents(price_cents).map_err(RepositoryError::Rejected)?,
5757 stock: u32::try_from(stock)
5858 .map_err(|_| RepositoryError::Rejected(DomainError::InvalidQuantity))?,
5959 })
6060}
6161
6262pub struct PostgresProductRepository {
6363 pool: PgPool,
6464}
6565
6767 pub fn new(pool: PgPool) -> Self {
6868 Self { pool }
6969 }
7070}
7171
7272#[async_trait]
7373impl ProductRepository for PostgresProductRepository {
7474 async fn list(&self) -> Result<Vec<Product>, RepositoryError> {
7575 let rows = checked(
7676 sqlx::query(r#"SELECT SKU, NAME, PRICE_CENTS, STOCK FROM product ORDER BY SKU"#)
7777 .fetch_all(&self.pool)
…
7979 "list_products",
8080 )?;
8181 rows.iter().map(product_from_row).collect()
8282 }
8383 async fn find_by_sku(&self, sku: &Sku) -> Result<Option<Product>, RepositoryError> {
8484 let row = checked(
8585 sqlx::query(r#"SELECT SKU, NAME, PRICE_CENTS, STOCK FROM product WHERE SKU = $1"#)
8686 .bind(sku.as_str())
…
8989 "find_product",
9090 )?;
9191 row.as_ref().map(product_from_row).transpose()
9292 }
9393 async fn find_by_skus(&self, skus: &[Sku]) -> Result<Vec<Product>, RepositoryError> {
9494 let values: Vec<String> = skus.iter().map(|sku| sku.as_str().to_owned()).collect();
9595 let rows = checked(
9696 sqlx::query(r#"SELECT SKU, NAME, PRICE_CENTS, STOCK FROM product WHERE SKU = ANY($1)"#)
9797 .bind(&values)
…
100100 "find_products",
101101 )?;
102102 rows.iter().map(product_from_row).collect()
103103 }
104104}
105105
106106pub struct PostgresCartRepository {
…
108108}
109109
111111 pub fn new(pool: PgPool) -> Self {
112112 Self { pool }
113113 }
114114}
115115
116116fn cart_status(raw: &str) -> Result<CartStatus, RepositoryError> {
117117 match raw {
118118 CART_STATUS_OPEN => Ok(CartStatus::Open),
119119 CART_STATUS_CHECKED_OUT => Ok(CartStatus::CheckedOut),
120120 other => {
121121 tracing::error!(upstream = "postgres", status = other, "unknown cart status");
122122 Err(RepositoryError::Unavailable("decode_cart".to_owned()))
123123 }
124124 }
125125}
126126
127127fn cart_line_from_row(row: &PgRow) -> Result<CartLine, RepositoryError> {
128128 let sku: String = checked(row.try_get("sku"), "decode_cart_line")?;
129129 let quantity: i32 = checked(row.try_get("quantity"), "decode_cart_line")?;
130130 cart_line_from_parts(&sku, quantity)
131131}
132132
133133fn cart_line_from_parts(sku: &str, quantity: i32) -> Result<CartLine, RepositoryError> {
134134 Ok(CartLine {
135135 sku: Sku::parse(sku).map_err(RepositoryError::Rejected)?,
136136 quantity: Quantity::new(i64::from(quantity)).map_err(RepositoryError::Rejected)?,
137137 })
138138}
139139
140140fn quantity_column(quantity: u32) -> Result<i32, RepositoryError> {
141141 i32::try_from(quantity).map_err(|_| RepositoryError::Rejected(DomainError::InvalidQuantity))
142142}
143143
144144fn close_outcome(rows_affected: u64) -> Result<(), RepositoryError> {
145145 if rows_affected == 0 {
146146 Err(RepositoryError::Rejected(DomainError::CartClosed))
147147 } else {
148148 Ok(())
149149 }
150150}
151151
152152fn reserve_outcome(rows_affected: u64, sku: &Sku) -> Result<(), RepositoryError> {
153153 if rows_affected == 0 {
154154 Err(RepositoryError::Rejected(DomainError::OutOfStock(
155155 sku.clone(),
156156 )))
157157 } else {
158158 Ok(())
159159 }
160160}
161161
162162#[async_trait]
163163impl CartRepository for PostgresCartRepository {
164164 async fn create(&self) -> Result<Cart, RepositoryError> {
165165 let row = checked(
166166 sqlx::query(r#"INSERT INTO cart (STATUS) VALUES ($1) RETURNING ID::text AS id"#)
167167 .bind(CART_STATUS_OPEN)
…
175175 status: CartStatus::Open,
176176 lines: Vec::new(),
177177 })
178178 }
179179 async fn find(&self, cart_id: &CartId) -> Result<Option<Cart>, RepositoryError> {
180180 let Some(row) = checked(
181181 sqlx::query(r#"SELECT STATUS FROM cart WHERE ID = $1::uuid"#)
182182 .bind(cart_id.as_str())
…
205205 .map(cart_line_from_row)
206206 .collect::<Result<_, _>>()?,
207207 }))
208208 }
209209 async fn save_line(&self, cart_id: &CartId, line: &CartLine) -> Result<(), RepositoryError> {
210210 let quantity = quantity_column(line.quantity.value())?;
211211 checked(
212212 sqlx::query(
…
221221 "save_cart_line",
222222 )?;
223223 Ok(())
224224 }
225225}
226226
227227pub struct PostgresOrderRepository {
…
229229}
230230
232232 pub fn new(pool: PgPool) -> Self {
233233 Self { pool }
234234 }
235235}
236236
237237#[async_trait]
238238impl OrderRepository for PostgresOrderRepository {
239239 async fn place(&self, draft: &OrderDraft) -> Result<Order, RepositoryError> {
240240 let mut transaction = checked(self.pool.begin().await, "begin_order")?;
241241 let closed = checked(
242242 sqlx::query(r#"UPDATE cart SET STATUS = $2 WHERE ID = $1::uuid AND STATUS = $3"#)
…
293293 lines: draft.lines.clone(),
294294 total: draft.total,
295295 })
296296 }
297297}
298298
299299#[cfg(test)]
…
301301 use super::*;
302302
303303 #[tokio::test]
304304 async fn connect_reports_unavailable_when_the_server_refuses() {
305305 let error = connect("postgres://shop:shop@127.0.0.1:1/shop")
306306 .await
307307 .unwrap_err();
308308 assert_eq!(error, RepositoryError::Unavailable("connect".to_owned()));
309309 assert!(error.to_string().contains("connect"));
310310 }
311311
312312 #[test]
313313 fn cart_status_accepts_both_stored_values_and_rejects_anything_else() {
314314 assert_eq!(cart_status(CART_STATUS_OPEN).unwrap(), CartStatus::Open);
315315 assert_eq!(
317317 CartStatus::CheckedOut
318318 );
319319 assert_eq!(
320320 cart_status("archived").unwrap_err(),
321321 RepositoryError::Unavailable("decode_cart".to_owned())
322322 );
323323 }
324324
325325 #[test]
326326 fn product_parts_reject_values_the_schema_also_forbids() {
327327 assert!(product_from_parts("MUG-STONE", "Mug".to_owned(), 1800, 4).is_ok());
328328 assert!(matches!(
329329 product_from_parts("no", "Mug".to_owned(), 1800, 4),
330330 Err(RepositoryError::Rejected(DomainError::InvalidSku))
331331 ));
332332 assert!(matches!(
333333 product_from_parts("MUG-STONE", "Mug".to_owned(), -1, 4),
334334 Err(RepositoryError::Rejected(DomainError::InvalidMoney))
335335 ));
336336 assert!(matches!(
337337 product_from_parts("MUG-STONE", "Mug".to_owned(), 1800, -1),
338338 Err(RepositoryError::Rejected(DomainError::InvalidQuantity))
339339 ));
340340 }
341341
342342 #[test]
343343 fn cart_line_parts_reject_a_bad_sku_or_quantity() {
344344 assert_eq!(
345345 cart_line_from_parts("MUG-STONE", 2)
346346 .unwrap()
347347 .quantity
348348 .value(),
349349 2
350350 );
351351 assert!(matches!(
352352 cart_line_from_parts("no", 2),
353353 Err(RepositoryError::Rejected(DomainError::InvalidSku))
354354 ));
355355 assert!(matches!(
356356 cart_line_from_parts("MUG-STONE", 0),
357357 Err(RepositoryError::Rejected(DomainError::InvalidQuantity))
358358 ));
359359 }
360360
361361 #[test]
362362 fn quantity_column_rejects_a_value_that_does_not_fit_the_database() {
363363 assert_eq!(quantity_column(2).unwrap(), 2);
364364 assert!(matches!(
365365 quantity_column(u32::MAX),
366366 Err(RepositoryError::Rejected(DomainError::InvalidQuantity))
367367 ));
368368 }
369369
370370 #[test]
371371 fn order_outcomes_distinguish_a_closed_cart_and_a_missed_reservation() {
372372 let sku = Sku::parse("LAMP-DESK").unwrap();
373373 assert!(close_outcome(1).is_ok());
374374 assert_eq!(
375375 close_outcome(0).unwrap_err(),
376376 RepositoryError::Rejected(DomainError::CartClosed)
377377 );
378378 assert!(reserve_outcome(1, &sku).is_ok());
379379 assert_eq!(
380380 reserve_outcome(0, &sku).unwrap_err(),
381381 RepositoryError::Rejected(DomainError::OutOfStock(sku))
382382 );
383383 }
384384}
shop-rs-api/src/infrastructure/web.rs: 517/517 covered lines
runoldnew±codecov
2929}
3030
3131impl From<Product> for ProductResponse {
3232 fn from(product: Product) -> Self {
3333 Self {
3434 sku: product.sku.as_str().to_owned(),
3535 name: product.name,
3636 price_cents: product.price.cents(),
3737 stock: product.stock,
3838 }
3939 }
4040}
4141
4242#[derive(Debug, Serialize, ToSchema)]
…
5252}
5353
5454impl From<CartLine> for CartLineResponse {
5555 fn from(line: CartLine) -> Self {
5656 Self {
5757 sku: line.sku.as_str().to_owned(),
5858 quantity: line.quantity.value(),
5959 }
6060 }
6161}
6262
6363#[derive(Debug, Serialize, ToSchema)]
…
6868}
6969
7070impl From<CartStatus> for CartStatusResponse {
7171 fn from(status: CartStatus) -> Self {
7272 match status {
7373 CartStatus::Open => Self::Open,
7474 CartStatus::CheckedOut => Self::CheckedOut,
7575 }
7676 }
7777}
7878
7979#[derive(Debug, Serialize, ToSchema)]
…
8484}
8585
8686impl From<Cart> for CartResponse {
8787 fn from(cart: Cart) -> Self {
8888 Self {
8989 id: cart.id.as_str().to_owned(),
9090 status: cart.status.into(),
9191 lines: cart.lines.into_iter().map(CartLineResponse::from).collect(),
9292 }
9393 }
9494}
9595
9696#[derive(Debug, Serialize, ToSchema)]
…
101101}
102102
103103impl From<OrderLine> for OrderLineResponse {
104104 fn from(line: OrderLine) -> Self {
105105 Self {
106106 sku: line.sku.as_str().to_owned(),
107107 quantity: line.quantity.value(),
108108 unit_price_cents: line.unit_price.cents(),
109109 }
110110 }
111111}
112112
113113#[derive(Debug, Serialize, ToSchema)]
…
119119}
120120
121121impl From<Order> for OrderResponse {
122122 fn from(order: Order) -> Self {
123123 Self {
124124 id: order.id,
125125 cart_id: order.cart_id.as_str().to_owned(),
126126 lines: order
127127 .lines
128128 .into_iter()
129129 .map(OrderLineResponse::from)
130130 .collect(),
131131 total_cents: order.total.cents(),
132132 }
133133 }
134134}
135135
136136#[derive(Debug, Deserialize, ToSchema)]
…
158158}
159159
160160impl From<DomainError> for ApiError {
161161 fn from(error: DomainError) -> Self {
162162 let (status, code) = match &error {
163163 DomainError::InvalidSku => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_sku"),
164164 DomainError::InvalidQuantity => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_quantity"),
165165 DomainError::InvalidMoney => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_money"),
166166 DomainError::InvalidCartId => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_cart_id"),
167167 DomainError::AmountOverflow => (StatusCode::UNPROCESSABLE_ENTITY, "amount_overflow"),
168168 DomainError::EmptyCart => (StatusCode::UNPROCESSABLE_ENTITY, "empty_cart"),
169169 DomainError::ProductNotFound(_) => (StatusCode::NOT_FOUND, "product_not_found"),
170170 DomainError::CartNotFound(_) => (StatusCode::NOT_FOUND, "cart_not_found"),
171171 DomainError::CartClosed => (StatusCode::CONFLICT, "cart_closed"),
172172 DomainError::OutOfStock(_) => (StatusCode::CONFLICT, "out_of_stock"),
173173 };
174174 Self {
175175 status,
176176 code,
177177 message: error.to_string(),
178178 }
179179 }
180180}
181181
182182impl From<ShopError> for ApiError {
183183 fn from(error: ShopError) -> Self {
184184 match error {
185185 ShopError::Domain(domain_error) => domain_error.into(),
186186 ShopError::Unavailable(operation) => {
187187 tracing::error!(operation = %operation, "request failed on storage");
188188 Self {
189189 status: StatusCode::INTERNAL_SERVER_ERROR,
190190 code: "storage_unavailable",
191191 message: "storage is unavailable".to_owned(),
192192 }
193193 }
194194 }
195195 }
196196}
197197
198198impl From<JsonRejection> for ApiError {
199199 fn from(rejection: JsonRejection) -> Self {
200200 Self {
201201 status: StatusCode::UNPROCESSABLE_ENTITY,
202202 code: "invalid_body",
203203 message: rejection.body_text(),
204204 }
205205 }
206206}
207207
208208impl IntoResponse for ApiError {
209209 fn into_response(self) -> Response {
210210 let body = ErrorResponse {
211211 error: ErrorBody {
212212 code: self.code,
213213 message: self.message,
214214 },
215215 };
216216 (self.status, Json(body)).into_response()
217217 }
218218}
219219
220220#[utoipa::path(get, path = "/health", tag = "Health", responses((status = 200, description = "Service is up")))]
221221async fn health() -> StatusCode {
222222 StatusCode::OK
223223}
224224
225225#[utoipa::path(get, path = "/v1/products", tag = "Catalog",
226226 responses((status = 200, body = ProductListResponse), (status = 500, body = ErrorResponse)))]
227227async fn list_products(
228228 State(state): State<AppState>,
229229) -> Result<Json<ProductListResponse>, ApiError> {
230230 let items: Vec<ProductResponse> = state
231231 .catalog
232232 .list_products()
233233 .await?
234234 .into_iter()
235235 .map(ProductResponse::from)
236236 .collect();
237237 let total = items.len();
238238 Ok(Json(ProductListResponse { items, total }))
239239}
240240
241241#[utoipa::path(get, path = "/v1/products/{sku}", tag = "Catalog",
242242 params(("sku" = String, Path, description = "Product SKU, e.g. MUG-STONE")),
243243 responses((status = 200, body = ProductResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
244244async fn find_product(
245245 State(state): State<AppState>,
246246 Path(sku): Path<String>,
247247) -> Result<Json<ProductResponse>, ApiError> {
248248 let sku = Sku::parse(&sku)?;
249249 Ok(Json(state.catalog.find_product(&sku).await?.into()))
250250}
251251
252252#[utoipa::path(post, path = "/v1/carts", tag = "Cart",
253253 responses((status = 201, body = CartResponse), (status = 500, body = ErrorResponse)))]
254254async fn open_cart(
255255 State(state): State<AppState>,
256256) -> Result<(StatusCode, Json<CartResponse>), ApiError> {
257257 let cart = state.carts.open_cart().await?;
258258 Ok((StatusCode::CREATED, Json(cart.into())))
259259}
260260
261261#[utoipa::path(get, path = "/v1/carts/{id}", tag = "Cart",
262262 params(("id" = String, Path, description = "Cart UUID")),
263263 responses((status = 200, body = CartResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
264264async fn find_cart(
265265 State(state): State<AppState>,
266266 Path(cart_id): Path<String>,
267267) -> Result<Json<CartResponse>, ApiError> {
268268 let cart_id = CartId::parse(&cart_id)?;
269269 Ok(Json(state.carts.find_cart(&cart_id).await?.into()))
270270}
271271
272272#[utoipa::path(post, path = "/v1/carts/{id}/items", tag = "Cart",
273273 params(("id" = String, Path, description = "Cart UUID")),
274274 request_body = AddCartItemRequest,
275275 responses((status = 200, body = CartResponse), (status = 404, body = ErrorResponse),
276276 (status = 409, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
277277async fn add_cart_item(
278278 State(state): State<AppState>,
279279 Path(cart_id): Path<String>,
280280 payload: Result<Json<AddCartItemRequest>, JsonRejection>,
281281) -> Result<Json<CartResponse>, ApiError> {
282282 let Json(request) = payload?;
283283 let cart_id = CartId::parse(&cart_id)?;
284284 let sku = Sku::parse(&request.sku)?;
285285 let quantity = Quantity::new(request.quantity)?;
286286 Ok(Json(
287287 state
288288 .carts
289289 .add_to_cart(&cart_id, &sku, quantity)
290290 .await?
291291 .into(),
292292 ))
293293}
294294
295295#[utoipa::path(post, path = "/v1/carts/{id}/checkout", tag = "Checkout",
296296 params(("id" = String, Path, description = "Cart UUID")),
297297 responses((status = 201, body = OrderResponse), (status = 404, body = ErrorResponse),
298298 (status = 409, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
299299async fn checkout(
300300 State(state): State<AppState>,
301301 Path(cart_id): Path<String>,
302302) -> Result<(StatusCode, Json<OrderResponse>), ApiError> {
303303 let cart_id = CartId::parse(&cart_id)?;
304304 let order = state.carts.checkout(&cart_id).await?;
305305 tracing::info!(order_id = order.id, cart_id = %order.cart_id, total_cents = order.total.cents(), "order placed");
306306 Ok((StatusCode::CREATED, Json(order.into())))
307307}
308308
309309#[derive(OpenApi)]
310310#[openapi(
…
337337)]
338338struct ApiDocument;
339339
340340async fn openapi() -> Json<utoipa::openapi::OpenApi> {
341341 Json(ApiDocument::openapi())
342342}
343343
344344pub fn router(state: AppState) -> Router {
345345 Router::new()
346346 .route("/health", get(health))
347347 .route("/openapi.json", get(openapi))
348348 .route("/v1/products", get(list_products))
349349 .route("/v1/products/{sku}", get(find_product))
350350 .route("/v1/carts", post(open_cart))
351351 .route("/v1/carts/{id}", get(find_cart))
352352 .route("/v1/carts/{id}/items", post(add_cart_item))
353353 .route("/v1/carts/{id}/checkout", post(checkout))
354354 .with_state(state)
355355}
356356
357357#[cfg(test)]
358358mod tests {
…
383383
384384 #[async_trait]
385385 impl ProductRepository for Products {
386386 async fn list(&self) -> Result<Vec<Product>, RepositoryError> {
387387 match &self.fail {
388388 Some(error) => Err(error.clone()),
389389 None => Ok(self.products.clone()),
390390 }
391391 }
392392 async fn find_by_sku(&self, sku: &Sku) -> Result<Option<Product>, RepositoryError> {
393393 match &self.fail {
394394 Some(error) => Err(error.clone()),
395395 None => Ok(self
396396 .products
397397 .iter()
398398 .find(|product| &product.sku == sku)
399399 .cloned()),
400400 }
401401 }
402402 async fn find_by_skus(&self, skus: &[Sku]) -> Result<Vec<Product>, RepositoryError> {
403403 match &self.fail {
404404 Some(error) => Err(error.clone()),
405405 None => Ok(self
406406 .products
407407 .iter()
408408 .filter(|product| skus.contains(&product.sku))
409409 .cloned()
410410 .collect()),
411411 }
412412 }
413413 }
414414
415415 struct Carts {
…
419419
420420 #[async_trait]
421421 impl CartRepository for Carts {
422422 async fn create(&self) -> Result<Cart, RepositoryError> {
423423 if let Some(error) = &self.fail {
424424 return Err(error.clone());
425425 }
…
430430 };
431431 self.carts.lock().unwrap().push(cart.clone());
432432 Ok(cart)
433433 }
434434 async fn find(&self, cart_id: &CartId) -> Result<Option<Cart>, RepositoryError> {
435435 if let Some(error) = &self.fail {
436436 return Err(error.clone());
437437 }
…
440440 .lock()
441441 .unwrap()
442442 .iter()
443443 .find(|cart| &cart.id == cart_id)
444444 .cloned())
445445 }
446446 async fn save_line(
447447 &self,
448448 cart_id: &CartId,
449449 line: &CartLine,
450450 ) -> Result<(), RepositoryError> {
451451 if let Some(error) = &self.fail {
452452 return Err(error.clone());
453453 }
454454 let mut carts = self.carts.lock().unwrap();
455455 let cart = carts.iter_mut().find(|cart| &cart.id == cart_id).unwrap();
456456 cart.lines.retain(|existing| existing.sku != line.sku);
457457 cart.lines.push(line.clone());
458458 Ok(())
459459 }
460460 }
461461
462462 struct Orders {
…
465465
466466 #[async_trait]
467467 impl OrderRepository for Orders {
468468 async fn place(&self, draft: &OrderDraft) -> Result<Order, RepositoryError> {
469469 if let Some(error) = &self.fail {
470470 return Err(error.clone());
471471 }
…
475475 lines: draft.lines.clone(),
476476 total: draft.total,
477477 })
478478 }
479479 }
480480
481481 fn sku(raw: &str) -> Sku {
482482 Sku::parse(raw).unwrap()
483483 }
484484
485485 fn product(raw: &str, price: i64, stock: u32) -> Product {
486486 Product {
487487 sku: sku(raw),
488488 name: raw.to_owned(),
489489 price: Money::from_cents(price).unwrap(),
490490 stock,
491491 }
492492 }
493493
494494 fn state(
495495 products: Vec<Product>,
496496 carts: Vec<Cart>,
497497 product_fail: Option<RepositoryError>,
498498 cart_fail: Option<RepositoryError>,
499499 order_fail: Option<RepositoryError>,
500500 ) -> AppState {
501501 AppState {
502502 catalog: Arc::new(CatalogService::new(Arc::new(Products {
503503 fail: product_fail,
504504 products: products.clone(),
505505 }))),
506506 carts: Arc::new(CartService::new(
507507 Arc::new(Products {
508508 fail: None,
509509 products,
510510 }),
511511 Arc::new(Carts {
512512 carts: Mutex::new(carts),
513513 fail: cart_fail,
514514 }),
515515 Arc::new(Orders { fail: order_fail }),
516516 )),
517517 }
518518 }
519519
520520 fn open_cart_model() -> Cart {
521521 Cart {
522522 id: CartId::parse(CART_ID).unwrap(),
523523 status: CartStatus::Open,
524524 lines: Vec::new(),
525525 }
526526 }
527527
528528 async fn send(
529529 state: AppState,
530530 method: &str,
531531 path: &str,
532532 body: Option<&str>,
533533 ) -> (StatusCode, String) {
534534 let mut builder = Request::builder().method(method).uri(path);
535535 if body.is_some() {
536536 builder = builder.header("content-type", "application/json");
537537 }
538538 let request = builder
539539 .body(Body::from(body.unwrap_or("").to_owned()))
540540 .unwrap();
541541 let response = router(state).oneshot(request).await.unwrap();
542542 let status = response.status();
543543 let bytes = response.into_body().collect().await.unwrap().to_bytes();
544544 (status, String::from_utf8(bytes.to_vec()).unwrap())
545545 }
546546
547547 #[test]
548548 fn every_domain_error_has_an_http_status() {
549549 let cases = [
550550 (
551551 DomainError::InvalidSku,
552552 StatusCode::UNPROCESSABLE_ENTITY,
553553 "invalid_sku",
554554 ),
555555 (
556556 DomainError::InvalidQuantity,
557557 StatusCode::UNPROCESSABLE_ENTITY,
558558 "invalid_quantity",
559559 ),
560560 (
561561 DomainError::InvalidMoney,
562562 StatusCode::UNPROCESSABLE_ENTITY,
563563 "invalid_money",
564564 ),
565565 (
566566 DomainError::InvalidCartId,
567567 StatusCode::UNPROCESSABLE_ENTITY,
568568 "invalid_cart_id",
569569 ),
570570 (
571571 DomainError::AmountOverflow,
572572 StatusCode::UNPROCESSABLE_ENTITY,
573573 "amount_overflow",
574574 ),
575575 (
576576 DomainError::EmptyCart,
577577 StatusCode::UNPROCESSABLE_ENTITY,
578578 "empty_cart",
579579 ),
580580 (
581581 DomainError::ProductNotFound(sku("MUG-STONE")),
582582 StatusCode::NOT_FOUND,
583583 "product_not_found",
584584 ),
585585 (
586586 DomainError::CartNotFound(CartId::parse(CART_ID).unwrap()),
587587 StatusCode::NOT_FOUND,
588588 "cart_not_found",
589589 ),
590590 (DomainError::CartClosed, StatusCode::CONFLICT, "cart_closed"),
591591 (
592592 DomainError::OutOfStock(sku("LAMP-DESK")),
593593 StatusCode::CONFLICT,
594594 "out_of_stock",
595595 ),
596596 ];
597597 for (error, status, code) in cases {
598598 let api = ApiError::from(error);
599599 assert_eq!(api.status, status);
600600 assert_eq!(api.code, code);
601601 let response = api.into_response();
602602 assert_eq!(response.status(), status);
603603 }
604604 let unavailable = ApiError::from(ShopError::Unavailable("connect".to_owned()));
605605 assert_eq!(unavailable.status, StatusCode::INTERNAL_SERVER_ERROR);
606606 assert_eq!(unavailable.code, "storage_unavailable");
607607 }
608608
609609 #[tokio::test]
610610 async fn health_and_openapi_respond() {
611611 let app = state(
612612 vec![product("MUG-STONE", 1800, 10)],
613613 vec![],
614614 None,
615615 None,
616616 None,
617617 );
618618 let (status, _) = send(app.clone(), "GET", "/health", None).await;
619619 assert_eq!(status, StatusCode::OK);
620620 let (status, body) = send(app, "GET", "/openapi.json", None).await;
621621 assert_eq!(status, StatusCode::OK);
622622 assert!(body.contains("shop-rs-api"));
623623 }
624624
625625 #[tokio::test]
626626 async fn catalog_lists_and_finds_products() {
627627 let app = state(
628628 vec![product("MUG-STONE", 1800, 10)],
629629 vec![],
630630 None,
631631 None,
632632 None,
633633 );
634634 let (status, body) = send(app.clone(), "GET", "/v1/products", None).await;
635635 assert_eq!(status, StatusCode::OK);
636636 assert!(body.contains("MUG-STONE"));
637637 let (status, body) = send(app.clone(), "GET", "/v1/products/MUG-STONE", None).await;
638638 assert_eq!(status, StatusCode::OK);
639639 assert!(body.contains("1800"));
640640 let (status, body) = send(app.clone(), "GET", "/v1/products/no", None).await;
641641 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
642642 assert!(body.contains("invalid_sku"));
643643 let (status, body) = send(app, "GET", "/v1/products/NOPE-1", None).await;
644644 assert_eq!(status, StatusCode::NOT_FOUND);
645645 assert!(body.contains("product_not_found"));
646646 }
647647
648648 #[tokio::test]
649649 async fn catalog_reports_storage_failure() {
650650 let app = state(
651651 vec![],
652652 vec![],
653653 Some(RepositoryError::Unavailable("connect".to_owned())),
654654 None,
655655 None,
656656 );
657657 let (status, body) = send(app, "GET", "/v1/products", None).await;
658658 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
659659 assert!(body.contains("storage_unavailable"));
660660 }
661661
662662 #[tokio::test]
663663 async fn cart_opens_adds_and_checks_out() {
664664 let app = state(
665665 vec![product("MUG-STONE", 1800, 10)],
666666 vec![],
667667 None,
668668 None,
669669 None,
670670 );
671671 let (status, body) = send(app.clone(), "POST", "/v1/carts", None).await;
672672 assert_eq!(status, StatusCode::CREATED);
673673 assert!(body.contains(CART_ID));
674674 let (status, body) = send(
675675 app.clone(),
676676 "POST",
677677 &format!("/v1/carts/{CART_ID}/items"),
678678 Some(r#"{"sku":"MUG-STONE","quantity":2}"#),
679679 )
680680 .await;
681681 assert_eq!(status, StatusCode::OK);
682682 assert!(body.contains("MUG-STONE"));
683683 let (status, body) = send(
684684 app.clone(),
685685 "POST",
686686 &format!("/v1/carts/{CART_ID}/items"),
687687 Some(r#"{"sku":"MUG-STONE","quantity":1}"#),
688688 )
689689 .await;
690690 assert_eq!(status, StatusCode::OK);
691691 assert!(body.contains("\"quantity\":3"));
692692 let (status, body) = send(app.clone(), "GET", &format!("/v1/carts/{CART_ID}"), None).await;
693693 assert_eq!(status, StatusCode::OK);
694694 assert!(body.contains("open"));
695695 let (status, body) =
696696 send(app, "POST", &format!("/v1/carts/{CART_ID}/checkout"), None).await;
697697 assert_eq!(status, StatusCode::CREATED);
698698 assert!(body.contains("5400"));
699699 }
700700
701701 #[tokio::test]
702702 async fn cart_rejects_bad_input_and_conflicts() {
703703 let closed = Cart {
704704 id: CartId::parse(CART_ID).unwrap(),
705705 status: CartStatus::CheckedOut,
706706 lines: vec![CartLine {
707707 sku: sku("MUG-STONE"),
708708 quantity: Quantity::new(1).unwrap(),
709709 }],
710710 };
711711 let app = state(
712712 vec![
713713 product("MUG-STONE", 1800, 10),
714714 product("LAMP-DESK", 12900, 1),
715715 ],
716716 vec![closed.clone()],
717717 None,
718718 None,
719719 None,
720720 );
721721 let (status, _) = send(app.clone(), "GET", "/v1/carts/not-a-uuid", None).await;
722722 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
723723 let (status, _) = send(
724724 app.clone(),
725725 "GET",
726726 "/v1/carts/0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0d",
727727 None,
728728 )
729729 .await;
730730 assert_eq!(status, StatusCode::NOT_FOUND);
731731 let (status, body) = send(app.clone(), "GET", &format!("/v1/carts/{CART_ID}"), None).await;
732732 assert_eq!(status, StatusCode::OK);
733733 assert!(body.contains("checked_out"));
734734 let (status, _) = send(
735735 app.clone(),
736736 "POST",
737737 &format!("/v1/carts/{CART_ID}/items"),
738738 Some("not-json"),
739739 )
740740 .await;
741741 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
742742 let (status, body) = send(
743743 app.clone(),
744744 "POST",
745745 &format!("/v1/carts/{CART_ID}/items"),
746746 Some(r#"{"sku":"NO","quantity":1}"#),
747747 )
748748 .await;
749749 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
750750 assert!(body.contains("invalid_sku"));
751751 let (status, body) = send(
752752 app.clone(),
753753 "POST",
754754 &format!("/v1/carts/{CART_ID}/items"),
755755 Some(r#"{"sku":"MUG-STONE","quantity":0}"#),
756756 )
757757 .await;
758758 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
759759 assert!(body.contains("invalid_quantity"));
760760 let (status, _) = send(
761761 app.clone(),
762762 "POST",
763763 "/v1/carts/0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0d/checkout",
764764 None,
765765 )
766766 .await;
767767 assert_eq!(status, StatusCode::NOT_FOUND);
768768 let (status, body) =
769769 send(app, "POST", &format!("/v1/carts/{CART_ID}/checkout"), None).await;
770770 assert_eq!(status, StatusCode::CONFLICT);
771771 assert!(body.contains("cart_closed"));
772772 }
773773
774774 #[tokio::test]
775775 async fn checkout_reports_an_empty_cart_stock_conflict_and_overflow() {
776776 let empty = open_cart_model();
777777 let app = state(
778778 vec![
779779 product("MUG-STONE", 1800, 10),
780780 product("LAMP-DESK", i64::MAX, 10),
781781 ],
782782 vec![empty],
783783 None,
784784 None,
785785 None,
786786 );
787787 let (status, body) = send(
788788 app.clone(),
789789 "POST",
790790 &format!("/v1/carts/{CART_ID}/checkout"),
791791 None,
792792 )
793793 .await;
794794 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
795795 assert!(body.contains("empty_cart"));
796796 let (status, _) = send(
797797 app.clone(),
798798 "POST",
799799 &format!("/v1/carts/{CART_ID}/items"),
800800 Some(r#"{"sku":"LAMP-DESK","quantity":2}"#),
801801 )
802802 .await;
803803 assert_eq!(status, StatusCode::OK);
804804 let (status, body) = send(
805805 app.clone(),
806806 "POST",
807807 &format!("/v1/carts/{CART_ID}/checkout"),
808808 None,
809809 )
810810 .await;
811811 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
812812 assert!(body.contains("amount_overflow"));
813813 let stock = state(
814814 vec![product("LAMP-DESK", 12900, 1)],
815815 vec![open_cart_model()],
816816 None,
817817 None,
818818 None,
819819 );
820820 let (status, _) = send(
821821 stock.clone(),
822822 "POST",
823823 &format!("/v1/carts/{CART_ID}/items"),
824824 Some(r#"{"sku":"LAMP-DESK","quantity":3}"#),
825825 )
826826 .await;
827827 assert_eq!(status, StatusCode::CONFLICT);
828828 let missing = state(
829829 vec![product("MUG-STONE", 1800, 10)],
830830 vec![],
831831 None,
832832 None,
833833 None,
834834 );
835835 let (status, _) = send(
836836 missing,
837837 "POST",
838838 &format!("/v1/carts/{CART_ID}/items"),
839839 Some(r#"{"sku":"MUG-STONE","quantity":1}"#),
840840 )
841841 .await;
842842 assert_eq!(status, StatusCode::NOT_FOUND);
843843 }
844844
845845 #[tokio::test]
846846 async fn cart_storage_failure_is_a_server_error() {
847847 let app = state(
848848 vec![product("MUG-STONE", 1800, 10)],
849849 vec![],
850850 None,
851851 Some(RepositoryError::Unavailable("connect".to_owned())),
852852 None,
853853 );
854854 let (status, body) = send(app, "POST", "/v1/carts", None).await;
855855 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
856856 assert!(body.contains("storage_unavailable"));
857857 }
858858}

shop / shop-ts-lib-cart / integration

adapter.stdout tests.stderr tests.stdout adapter.stderr
shop-ts-lib-cart/src/cart.ts: 23/33 covered lines
runoldnew±codecov
66 readonly unitPriceCents: Cents;
77}
88
99export class InvalidAmountError extends Error {
1010 constructor(readonly amount: number) {
1111 super(`amount must be a non-negative safe integer of cents, got ${amount}`);
1212 this.name = "InvalidAmountError";
1313 }
1414}
1515
1616export class InvalidQuantityError extends Error {
1717 constructor(readonly sku: string, readonly quantity: number) {
1818 super(`quantity for ${sku} must be an integer between 1 and 99, got ${quantity}`);
1919 this.name = "InvalidQuantityError";
2020 }
2121}
2222
2323const MINIMUM_QUANTITY = 1;
2424const MAXIMUM_QUANTITY = 99;
2525const CENTS_PER_UNIT = 100;
2626
2727export function cents(amount: number): Cents {
2828 if (!Number.isSafeInteger(amount) || amount < 0) {
2929 throw new InvalidAmountError(amount);
3030 }
3131 return amount as Cents;
3232}
3333
3434export function lineTotal(line: CartLine): Cents {
3535 if (!Number.isInteger(line.quantity) || line.quantity < MINIMUM_QUANTITY || line.quantity > MAXIMUM_QUANTITY) {
3636 throw new InvalidQuantityError(line.sku, line.quantity);
3737 }
3838 return cents(line.unitPriceCents * line.quantity);
3939}
4040
4141export function cartTotal(lines: readonly CartLine[]): Cents {
4242 return lines.reduce<Cents>((total, line) => cents(total + lineTotal(line)), cents(0));
4343}
4444
4545export function formatCents(amount: Cents, currency: string, locale: string): string {
4646 return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount / CENTS_PER_UNIT);
4747}

shop / shop-ts-lib-cart / unit

adapter.stdout tests.stderr tests.stdout adapter.stderr
shop-ts-lib-cart/src/cart.ts: 33/33 covered lines
runoldnew±codecov
66 readonly unitPriceCents: Cents;
77}
88
99export class InvalidAmountError extends Error {
1010 constructor(readonly amount: number) {
1111 super(`amount must be a non-negative safe integer of cents, got ${amount}`);
1212 this.name = "InvalidAmountError";
1313 }
1414}
1515
1616export class InvalidQuantityError extends Error {
1717 constructor(readonly sku: string, readonly quantity: number) {
1818 super(`quantity for ${sku} must be an integer between 1 and 99, got ${quantity}`);
1919 this.name = "InvalidQuantityError";
2020 }
2121}
2222
2323const MINIMUM_QUANTITY = 1;
2424const MAXIMUM_QUANTITY = 99;
2525const CENTS_PER_UNIT = 100;
2626
2727export function cents(amount: number): Cents {
2828 if (!Number.isSafeInteger(amount) || amount < 0) {
2929 throw new InvalidAmountError(amount);
3030 }
3131 return amount as Cents;
3232}
3333
3434export function lineTotal(line: CartLine): Cents {
3535 if (!Number.isInteger(line.quantity) || line.quantity < MINIMUM_QUANTITY || line.quantity > MAXIMUM_QUANTITY) {
3636 throw new InvalidQuantityError(line.sku, line.quantity);
3737 }
3838 return cents(line.unitPriceCents * line.quantity);
3939}
4040
4141export function cartTotal(lines: readonly CartLine[]): Cents {
4242 return lines.reduce<Cents>((total, line) => cents(total + lineTotal(line)), cents(0));
4343}
4444
4545export function formatCents(amount: Cents, currency: string, locale: string): string {
4646 return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount / CENTS_PER_UNIT);
4747}

shop / shop-ts-web-react / functional

probe-bind.stderr adapter.stdout tests.stdout adapter.stderr vite.stderr api.stderr probe-missing-database.stderr build.log
shop-ts-lib-cart/src/cart.ts: 9/9 covered lines
runoldnew±codecov
2020 }
2121}
2222
2323const MINIMUM_QUANTITY = 1;
2424const MAXIMUM_QUANTITY = 99;
2525const CENTS_PER_UNIT = 100;
2626
2727export function cents(amount: number): Cents {
2828 if (!Number.isSafeInteger(amount) || amount < 0) {
2929 throw new InvalidAmountError(amount);
3030 }
3131 return amount as Cents;
3232}
3333
3434export function lineTotal(line: CartLine): Cents {
3535 if (!Number.isInteger(line.quantity) || line.quantity < MINIMUM_QUANTITY || line.quantity > MAXIMUM_QUANTITY) {
3636 throw new InvalidQuantityError(line.sku, line.quantity);
3737 }
3838 return cents(line.unitPriceCents * line.quantity);
3939}
4040
4141export function cartTotal(lines: readonly CartLine[]): Cents {
4242 return lines.reduce<Cents>((total, line) => cents(total + lineTotal(line)), cents(0));
4343}
4444
4545export function formatCents(amount: Cents, currency: string, locale: string): string {
4646 return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount / CENTS_PER_UNIT);
4747}
shop-ts-web-react/src/Shop.tsx: 47/50 covered lines
runoldnew±codecov
33
44import { addCartItem, checkoutCart, listProducts, openCart, ShopRequestError, type Cart, type Order, type Product } from "./shopApi";
55
66const CURRENCY = "USD";
77const LOCALE = "en-US";
88
99interface Props {
1010 readonly apiBase: string;
1111}
1212
1313function pricedLine(line: Cart["lines"][number], products: readonly Product[]): PricedLine {
1414 const product = products.find((candidate) => candidate.sku === line.sku);
1515 return { sku: line.sku, quantity: line.quantity, unitPriceCents: cents(product?.priceCents ?? 0) };
1616}
1717
1818function messageOf(error: unknown): string {
1919 if (error instanceof ShopRequestError) return error.message;
2020 return "something went wrong";
2121}
2222
2323export function Shop({ apiBase }: Props) {
2424 const [products, setProducts] = useState<readonly Product[] | null>(null);
2525 const [cart, setCart] = useState<Cart | null>(null);
2626 const [order, setOrder] = useState<Order | null>(null);
2727 const [quantities, setQuantities] = useState<Record<string, string>>({});
2828 const [error, setError] = useState<string | null>(null);
2929 const [busy, setBusy] = useState(false);
3030
3131 useEffect(() => {
3232 let active = true;
3333 listProducts(apiBase)
3434 .then((loaded) => {
3535 if (active) setProducts(loaded);
3636 })
3737 .catch((failure: unknown) => {
3838 if (active) setError(messageOf(failure));
3939 });
4040 return () => {
4141 active = false;
4242 };
4343 }, [apiBase]);
4444
4545 async function add(product: Product) {
4646 const parsed = Number(quantities[product.sku] ?? "1");
4747 setBusy(true);
4848 setError(null);
4949 setOrder(null);
5050 try {
5151 const current = cart ?? (await openCart(apiBase));
5252 const updated = await addCartItem(apiBase, current.id, product.sku, parsed);
5353 setCart(updated);
5454 const refreshed = await listProducts(apiBase);
5555 setProducts(refreshed);
5656 } catch (failure: unknown) {
5757 setError(messageOf(failure));
5858 } finally {
5959 setBusy(false);
6060 }
6161 }
6262
6363 async function checkout() {
6464 setBusy(true);
6565 setError(null);
6666 try {
6767 const current = cart ?? (await openCart(apiBase));
6868 const placed = await checkoutCart(apiBase, current.id);
6969 setOrder(placed);
7070 setCart(null);
7171 setProducts(await listProducts(apiBase));
7272 } catch (failure: unknown) {
7373 setError(messageOf(failure));
7474 } finally {
7575 setBusy(false);
7676 }
7777 }
7878
7979 const lines = cart && products ? cart.lines.map((line) => pricedLine(line, products)) : [];
8080 const total = lines.length > 0 ? cartTotal(lines) : cents(0);
8181
8282 return (
8383 <main aria-busy={busy}>
8484 <h1>Shop</h1>
8585 {products === null && error === null ? <p>Loading the catalog.</p> : null}
8686 {error ? <p role="alert">{error}</p> : null}
8787 <ul>
8888 {(products ?? []).map((product) => (
8989 <li key={product.sku}>
9090 <span>{product.name}</span>
9191 <span>{formatCents(cents(product.priceCents), CURRENCY, LOCALE)}</span>
9292 <span>{product.stock} in stock</span>
…
9595 <input
9696 aria-label={`Quantity for ${product.name}`}
9797 value={quantities[product.sku] ?? "1"}
9898 onChange={(event) => setQuantities({ ...quantities, [product.sku]: event.target.value })}
9999 />
100100 </label>
101101 <button type="button" disabled={busy} onClick={() => void add(product)}>
102102 Add {product.name}
103103 </button>
104104 </li>
…
109109 {lines.length === 0 ? <p>The cart is empty.</p> : null}
110110 <ul>
111111 {lines.map((line) => {
112112 const product = products?.find((candidate) => candidate.sku === line.sku);
113113 return (
114114 <li key={line.sku}>
115115 <span>{product?.name ?? line.sku}</span>
116116 <span>{line.quantity}</span>
…
120120 })}
121121 </ul>
122122 <p>Total {formatCents(total, CURRENCY, LOCALE)}</p>
123123 <button type="button" disabled={busy} onClick={() => void checkout()}>
124124 Checkout
125125 </button>
126126 </section>
shop-ts-web-react/src/main.tsx: 1/1 covered lines
runoldnew±codecov
11import { mountShop } from "./mount";
22import "./style.css";
33
44mountShop(document.getElementById("root"), "");
shop-ts-web-react/src/mount.tsx: 2/2 covered lines
runoldnew±codecov
33import { Shop } from "./Shop";
44
55export function mountShop(root: HTMLElement | null, apiBase: string) {
66 if (!root) throw new Error("root element is missing");
77 createRoot(root).render(<Shop apiBase={apiBase} />);
88}
shop-ts-web-react/src/shopApi.ts: 22/24 covered lines
runoldnew±codecov
2828 readonly code: string,
2929 message: string,
3030 ) {
3131 super(message);
3232 this.name = "ShopRequestError";
3333 }
3434}
3535
…
5353}
5454
5555function productFromBody(body: ProductBody): Product {
5656 return { sku: body.sku, name: body.name, priceCents: body.price_cents, stock: body.stock };
5757}
5858
5959function cartFromBody(body: CartBody): Cart {
6060 return {
6161 id: body.id,
6262 status: body.status,
6363 lines: body.lines.map((line) => ({ sku: line.sku, quantity: line.quantity })),
6464 };
6565}
6666
6767async function readError(response: Response): Promise<ShopRequestError> {
6868 let code = "request_failed";
6969 let message = response.statusText;
7070 try {
7171 const body = (await response.json()) as { error?: { code?: string; message?: string } };
7272 if (body.error?.code) code = body.error.code;
7373 if (body.error?.message) message = body.error.message;
7474 } catch {
7575 message = "the shop could not read the response";
7676 }
7777 return new ShopRequestError(response.status, code, message);
7878}
7979
8080async function requestJson<T>(apiBase: string, path: string, init?: RequestInit): Promise<T> {
8181 let response: Response;
8282 try {
8383 response = await fetch(`${apiBase}${path}`, init);
8484 } catch {
8585 throw new ShopRequestError(0, "network", "the shop API is unreachable");
8686 }
8787 if (!response.ok) throw await readError(response);
8888 return (await response.json()) as T;
8989}
9090
9191export async function listProducts(apiBase: string): Promise<readonly Product[]> {
9292 const body = await requestJson<{ items: ProductBody[] }>(apiBase, "/v1/products");
9393 return body.items.map(productFromBody);
9494}
9595
9696export async function openCart(apiBase: string): Promise<Cart> {
9797 return cartFromBody(await requestJson<CartBody>(apiBase, "/v1/carts", { method: "POST" }));
9898}
9999
100100export async function addCartItem(apiBase: string, cartId: string, sku: string, quantity: number): Promise<Cart> {
101101 return cartFromBody(
102102 await requestJson<CartBody>(apiBase, `/v1/carts/${cartId}/items`, {
103103 method: "POST",
104104 headers: { "content-type": "application/json" },
…
108108}
109109
110110export async function checkoutCart(apiBase: string, cartId: string): Promise<Order> {
111111 const body = await requestJson<OrderBody>(apiBase, `/v1/carts/${cartId}/checkout`, { method: "POST" });
112112 return { id: body.id, cartId: body.cart_id, totalCents: body.total_cents };
113113}

shop / shop-ts-web-react / unit

adapter.stdout tests.stderr tests.stdout adapter.stderr
shop-ts-web-react/src/Shop.tsx: 114/114 covered lines
runoldnew±codecov
11import { useEffect, useState } from "react";
22import { cartTotal, cents, formatCents, lineTotal, type CartLine as PricedLine } from "shop-ts-lib-cart";
33
44import { addCartItem, checkoutCart, listProducts, openCart, ShopRequestError, type Cart, type Order, type Product } from "./shopApi";
55
66const CURRENCY = "USD";
77const LOCALE = "en-US";
88
99interface Props {
1010 readonly apiBase: string;
1111}
1212
1313function pricedLine(line: Cart["lines"][number], products: readonly Product[]): PricedLine {
1414 const product = products.find((candidate) => candidate.sku === line.sku);
1515 return { sku: line.sku, quantity: line.quantity, unitPriceCents: cents(product?.priceCents ?? 0) };
1616}
1717
1818function messageOf(error: unknown): string {
1919 if (error instanceof ShopRequestError) return error.message;
2020 return "something went wrong";
2121}
2222
2323export function Shop({ apiBase }: Props) {
2424 const [products, setProducts] = useState<readonly Product[] | null>(null);
2525 const [cart, setCart] = useState<Cart | null>(null);
2626 const [order, setOrder] = useState<Order | null>(null);
2727 const [quantities, setQuantities] = useState<Record<string, string>>({});
2828 const [error, setError] = useState<string | null>(null);
2929 const [busy, setBusy] = useState(false);
3030
3131 useEffect(() => {
3232 let active = true;
3333 listProducts(apiBase)
3434 .then((loaded) => {
3535 if (active) setProducts(loaded);
3636 })
3737 .catch((failure: unknown) => {
3838 if (active) setError(messageOf(failure));
3939 });
4040 return () => {
4141 active = false;
4242 };
4343 }, [apiBase]);
4444
4545 async function add(product: Product) {
4646 const parsed = Number(quantities[product.sku] ?? "1");
4747 setBusy(true);
4848 setError(null);
4949 setOrder(null);
5050 try {
5151 const current = cart ?? (await openCart(apiBase));
5252 const updated = await addCartItem(apiBase, current.id, product.sku, parsed);
5353 setCart(updated);
5454 const refreshed = await listProducts(apiBase);
5555 setProducts(refreshed);
5656 } catch (failure: unknown) {
5757 setError(messageOf(failure));
5858 } finally {
5959 setBusy(false);
6060 }
6161 }
6262
6363 async function checkout() {
6464 setBusy(true);
6565 setError(null);
6666 try {
6767 const current = cart ?? (await openCart(apiBase));
6868 const placed = await checkoutCart(apiBase, current.id);
6969 setOrder(placed);
7070 setCart(null);
7171 setProducts(await listProducts(apiBase));
7272 } catch (failure: unknown) {
7373 setError(messageOf(failure));
7474 } finally {
7575 setBusy(false);
7676 }
7777 }
7878
7979 const lines = cart && products ? cart.lines.map((line) => pricedLine(line, products)) : [];
8080 const total = lines.length > 0 ? cartTotal(lines) : cents(0);
8181
8282 return (
8383 <main aria-busy={busy}>
8484 <h1>Shop</h1>
8585 {products === null && error === null ? <p>Loading the catalog.</p> : null}
8686 {error ? <p role="alert">{error}</p> : null}
8787 <ul>
8888 {(products ?? []).map((product) => (
8989 <li key={product.sku}>
9090 <span>{product.name}</span>
9191 <span>{formatCents(cents(product.priceCents), CURRENCY, LOCALE)}</span>
9292 <span>{product.stock} in stock</span>
9393 <label>
9595 <input
9696 aria-label={`Quantity for ${product.name}`}
9797 value={quantities[product.sku] ?? "1"}
9898 onChange={(event) => setQuantities({ ...quantities, [product.sku]: event.target.value })}
9999 />
100100 </label>
101101 <button type="button" disabled={busy} onClick={() => void add(product)}>
102102 Add {product.name}
103103 </button>
104104 </li>
105105 ))}
106106 </ul>
107107 <section aria-label="Cart">
108108 <h2>Cart</h2>
109109 {lines.length === 0 ? <p>The cart is empty.</p> : null}
110110 <ul>
111111 {lines.map((line) => {
112112 const product = products?.find((candidate) => candidate.sku === line.sku);
113113 return (
114114 <li key={line.sku}>
115115 <span>{product?.name ?? line.sku}</span>
116116 <span>{line.quantity}</span>
117117 <span>{formatCents(lineTotal(line), CURRENCY, LOCALE)}</span>
118118 </li>
119119 );
120120 })}
121121 </ul>
122122 <p>Total {formatCents(total, CURRENCY, LOCALE)}</p>
123123 <button type="button" disabled={busy} onClick={() => void checkout()}>
124124 Checkout
125125 </button>
126126 </section>
127127 {order ? (
128128 <p role="status">
129129 Order {order.id} placed for {formatCents(cents(order.totalCents), CURRENCY, LOCALE)}
130130 </p>
131131 ) : null}
132132 </main>
133133 );
134134}
135135
136136
shop-ts-web-react/src/mount.tsx: 5/5 covered lines
runoldnew±codecov
11import { createRoot } from "react-dom/client";
22
33import { Shop } from "./Shop";
44
55export function mountShop(root: HTMLElement | null, apiBase: string) {
66 if (!root) throw new Error("root element is missing");
77 createRoot(root).render(<Shop apiBase={apiBase} />);
88}
shop-ts-web-react/src/shopApi.ts: 62/62 covered lines
runoldnew±codecov
2222 readonly totalCents: number;
2323}
2424
2525export class ShopRequestError extends Error {
2626 constructor(
2727 readonly status: number,
2828 readonly code: string,
2929 message: string,
3030 ) {
3131 super(message);
3232 this.name = "ShopRequestError";
3333 }
3434}
3535
3636interface ProductBody {
3737 readonly sku: string;
…
5252 readonly total_cents: number;
5353}
5454
5555function productFromBody(body: ProductBody): Product {
5656 return { sku: body.sku, name: body.name, priceCents: body.price_cents, stock: body.stock };
5757}
5858
5959function cartFromBody(body: CartBody): Cart {
6060 return {
6161 id: body.id,
6262 status: body.status,
6363 lines: body.lines.map((line) => ({ sku: line.sku, quantity: line.quantity })),
6464 };
6565}
6666
6767async function readError(response: Response): Promise<ShopRequestError> {
6868 let code = "request_failed";
6969 let message = response.statusText;
7070 try {
7171 const body = (await response.json()) as { error?: { code?: string; message?: string } };
7272 if (body.error?.code) code = body.error.code;
7373 if (body.error?.message) message = body.error.message;
7474 } catch {
7575 message = "the shop could not read the response";
7676 }
7777 return new ShopRequestError(response.status, code, message);
7878}
7979
8080async function requestJson<T>(apiBase: string, path: string, init?: RequestInit): Promise<T> {
8181 let response: Response;
8282 try {
8383 response = await fetch(`${apiBase}${path}`, init);
8484 } catch {
8585 throw new ShopRequestError(0, "network", "the shop API is unreachable");
8686 }
8787 if (!response.ok) throw await readError(response);
8888 return (await response.json()) as T;
8989}
9090
9191export async function listProducts(apiBase: string): Promise<readonly Product[]> {
9292 const body = await requestJson<{ items: ProductBody[] }>(apiBase, "/v1/products");
9393 return body.items.map(productFromBody);
9494}
9595
9696export async function openCart(apiBase: string): Promise<Cart> {
9797 return cartFromBody(await requestJson<CartBody>(apiBase, "/v1/carts", { method: "POST" }));
9898}
9999
100100export async function addCartItem(apiBase: string, cartId: string, sku: string, quantity: number): Promise<Cart> {
101101 return cartFromBody(
102102 await requestJson<CartBody>(apiBase, `/v1/carts/${cartId}/items`, {
103103 method: "POST",
104104 headers: { "content-type": "application/json" },
105105 body: JSON.stringify({ sku, quantity }),
106106 }),
107107 );
108108}
109109
110110export async function checkoutCart(apiBase: string, cartId: string): Promise<Order> {
111111 const body = await requestJson<OrderBody>(apiBase, `/v1/carts/${cartId}/checkout`, { method: "POST" });
112112 return { id: body.id, cartId: body.cart_id, totalCents: body.total_cents };
113113}

shop / shop-ts-web-react / visual

probe-bind.stderr adapter.stdout tests.stdout adapter.stderr vite.stderr api.stderr probe-missing-database.stderr build.log
shop-ts-lib-cart/src/cart.ts: 9/9 covered lines
runoldnew±codecov
2020 }
2121}
2222
2323const MINIMUM_QUANTITY = 1;
2424const MAXIMUM_QUANTITY = 99;
2525const CENTS_PER_UNIT = 100;
2626
2727export function cents(amount: number): Cents {
2828 if (!Number.isSafeInteger(amount) || amount < 0) {
2929 throw new InvalidAmountError(amount);
3030 }
3131 return amount as Cents;
3232}
3333
3434export function lineTotal(line: CartLine): Cents {
3535 if (!Number.isInteger(line.quantity) || line.quantity < MINIMUM_QUANTITY || line.quantity > MAXIMUM_QUANTITY) {
3636 throw new InvalidQuantityError(line.sku, line.quantity);
3737 }
3838 return cents(line.unitPriceCents * line.quantity);
3939}
4040
4141export function cartTotal(lines: readonly CartLine[]): Cents {
4242 return lines.reduce<Cents>((total, line) => cents(total + lineTotal(line)), cents(0));
4343}
4444
4545export function formatCents(amount: Cents, currency: string, locale: string): string {
4646 return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount / CENTS_PER_UNIT);
4747}
shop-ts-web-react/src/Shop.tsx: 46/50 covered lines
runoldnew±codecov
33
44import { addCartItem, checkoutCart, listProducts, openCart, ShopRequestError, type Cart, type Order, type Product } from "./shopApi";
55
66const CURRENCY = "USD";
77const LOCALE = "en-US";
88
99interface Props {
1010 readonly apiBase: string;
1111}
1212
1313function pricedLine(line: Cart["lines"][number], products: readonly Product[]): PricedLine {
1414 const product = products.find((candidate) => candidate.sku === line.sku);
1515 return { sku: line.sku, quantity: line.quantity, unitPriceCents: cents(product?.priceCents ?? 0) };
1616}
1717
1818function messageOf(error: unknown): string {
1919 if (error instanceof ShopRequestError) return error.message;
2020 return "something went wrong";
2121}
2222
2323export function Shop({ apiBase }: Props) {
2424 const [products, setProducts] = useState<readonly Product[] | null>(null);
2525 const [cart, setCart] = useState<Cart | null>(null);
2626 const [order, setOrder] = useState<Order | null>(null);
2727 const [quantities, setQuantities] = useState<Record<string, string>>({});
2828 const [error, setError] = useState<string | null>(null);
2929 const [busy, setBusy] = useState(false);
3030
3131 useEffect(() => {
3232 let active = true;
3333 listProducts(apiBase)
3434 .then((loaded) => {
3535 if (active) setProducts(loaded);
3636 })
3737 .catch((failure: unknown) => {
3838 if (active) setError(messageOf(failure));
3939 });
4040 return () => {
4141 active = false;
4242 };
4343 }, [apiBase]);
4444
4545 async function add(product: Product) {
4646 const parsed = Number(quantities[product.sku] ?? "1");
4747 setBusy(true);
4848 setError(null);
4949 setOrder(null);
5050 try {
5151 const current = cart ?? (await openCart(apiBase));
5252 const updated = await addCartItem(apiBase, current.id, product.sku, parsed);
5353 setCart(updated);
5454 const refreshed = await listProducts(apiBase);
5555 setProducts(refreshed);
5656 } catch (failure: unknown) {
5757 setError(messageOf(failure));
5858 } finally {
5959 setBusy(false);
6060 }
6161 }
6262
6363 async function checkout() {
6464 setBusy(true);
6565 setError(null);
6666 try {
6767 const current = cart ?? (await openCart(apiBase));
6868 const placed = await checkoutCart(apiBase, current.id);
6969 setOrder(placed);
7070 setCart(null);
7171 setProducts(await listProducts(apiBase));
7272 } catch (failure: unknown) {
7373 setError(messageOf(failure));
7474 } finally {
7575 setBusy(false);
7676 }
7777 }
7878
7979 const lines = cart && products ? cart.lines.map((line) => pricedLine(line, products)) : [];
8080 const total = lines.length > 0 ? cartTotal(lines) : cents(0);
8181
8282 return (
8383 <main aria-busy={busy}>
8484 <h1>Shop</h1>
8585 {products === null && error === null ? <p>Loading the catalog.</p> : null}
8686 {error ? <p role="alert">{error}</p> : null}
8787 <ul>
8888 {(products ?? []).map((product) => (
8989 <li key={product.sku}>
9090 <span>{product.name}</span>
9191 <span>{formatCents(cents(product.priceCents), CURRENCY, LOCALE)}</span>
9292 <span>{product.stock} in stock</span>
…
9595 <input
9696 aria-label={`Quantity for ${product.name}`}
9797 value={quantities[product.sku] ?? "1"}
9898 onChange={(event) => setQuantities({ ...quantities, [product.sku]: event.target.value })}
9999 />
100100 </label>
101101 <button type="button" disabled={busy} onClick={() => void add(product)}>
102102 Add {product.name}
103103 </button>
104104 </li>
…
109109 {lines.length === 0 ? <p>The cart is empty.</p> : null}
110110 <ul>
111111 {lines.map((line) => {
112112 const product = products?.find((candidate) => candidate.sku === line.sku);
113113 return (
114114 <li key={line.sku}>
115115 <span>{product?.name ?? line.sku}</span>
116116 <span>{line.quantity}</span>
…
120120 })}
121121 </ul>
122122 <p>Total {formatCents(total, CURRENCY, LOCALE)}</p>
123123 <button type="button" disabled={busy} onClick={() => void checkout()}>
124124 Checkout
125125 </button>
126126 </section>
shop-ts-web-react/src/main.tsx: 1/1 covered lines
runoldnew±codecov
11import { mountShop } from "./mount";
22import "./style.css";
33
44mountShop(document.getElementById("root"), "");
shop-ts-web-react/src/mount.tsx: 2/2 covered lines
runoldnew±codecov
33import { Shop } from "./Shop";
44
55export function mountShop(root: HTMLElement | null, apiBase: string) {
66 if (!root) throw new Error("root element is missing");
77 createRoot(root).render(<Shop apiBase={apiBase} />);
88}
shop-ts-web-react/src/shopApi.ts: 22/24 covered lines
runoldnew±codecov
2828 readonly code: string,
2929 message: string,
3030 ) {
3131 super(message);
3232 this.name = "ShopRequestError";
3333 }
3434}
3535
…
5353}
5454
5555function productFromBody(body: ProductBody): Product {
5656 return { sku: body.sku, name: body.name, priceCents: body.price_cents, stock: body.stock };
5757}
5858
5959function cartFromBody(body: CartBody): Cart {
6060 return {
6161 id: body.id,
6262 status: body.status,
6363 lines: body.lines.map((line) => ({ sku: line.sku, quantity: line.quantity })),
6464 };
6565}
6666
6767async function readError(response: Response): Promise<ShopRequestError> {
6868 let code = "request_failed";
6969 let message = response.statusText;
7070 try {
7171 const body = (await response.json()) as { error?: { code?: string; message?: string } };
7272 if (body.error?.code) code = body.error.code;
7373 if (body.error?.message) message = body.error.message;
7474 } catch {
7575 message = "the shop could not read the response";
7676 }
7777 return new ShopRequestError(response.status, code, message);
7878}
7979
8080async function requestJson<T>(apiBase: string, path: string, init?: RequestInit): Promise<T> {
8181 let response: Response;
8282 try {
8383 response = await fetch(`${apiBase}${path}`, init);
8484 } catch {
8585 throw new ShopRequestError(0, "network", "the shop API is unreachable");
8686 }
8787 if (!response.ok) throw await readError(response);
8888 return (await response.json()) as T;
8989}
9090
9191export async function listProducts(apiBase: string): Promise<readonly Product[]> {
9292 const body = await requestJson<{ items: ProductBody[] }>(apiBase, "/v1/products");
9393 return body.items.map(productFromBody);
9494}
9595
9696export async function openCart(apiBase: string): Promise<Cart> {
9797 return cartFromBody(await requestJson<CartBody>(apiBase, "/v1/carts", { method: "POST" }));
9898}
9999
100100export async function addCartItem(apiBase: string, cartId: string, sku: string, quantity: number): Promise<Cart> {
101101 return cartFromBody(
102102 await requestJson<CartBody>(apiBase, `/v1/carts/${cartId}/items`, {
103103 method: "POST",
104104 headers: { "content-type": "application/json" },
…
108108}
109109
110110export async function checkoutCart(apiBase: string, cartId: string): Promise<Order> {
111111 const body = await requestJson<OrderBody>(apiBase, `/v1/carts/${cartId}/checkout`, { method: "POST" });
112112 return { id: body.id, cartId: body.cart_id, totalCents: body.total_cents };
113113}