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- Passed shop_project_cart_suite_adds_line_to_cart_test (0 ms)
- Passed shop_project_cart_suite_rejects_quantity_beyond_stock_test (0 ms)
- Passed shop_project_cart_suite_rejects_zero_quantity_test (0 ms)
- Passed shop_project_cart_suite_unknown_cart_not_found_test (0 ms)
- Passed shop_project_catalog_suite_lists_seeded_products_test (0 ms)
- Passed shop_project_catalog_suite_rejects_invalid_sku_test (0 ms)
- Passed shop_project_catalog_suite_serves_openapi_test (0 ms)
- Passed shop_project_catalog_suite_unknown_sku_not_found_test (0 ms)
- Passed shop_project_checkout_suite_places_order_and_decrements_stock_test (0 ms)
- Passed shop_project_checkout_suite_rejects_empty_cart_test (0 ms)
- Passed shop_project_checkout_suite_rejects_second_checkout_test (0 ms)
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
2828 pub fn new(products: Arc<dyn ProductRepository>) -> Self {
2929 Self { products }
3030 }
3131 pub async fn list_products(&self) -> Result<Vec<Product>, ShopError> {
3333 }
3535 self.products
3737 .await?
3838 .ok_or_else(|| ShopError::Domain(DomainError::ProductNotFound(sku.clone())))
3939 }
4040}
4141
4242pub struct CartService {
…
4646}
4747
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> {
6262 }
6464 self.carts
6666 .await?
6767 .ok_or_else(|| ShopError::Domain(DomainError::CartNotFound(cart_id.clone())))
6868 }
6969 pub async fn add_to_cart(
7070 &self,
7474 ) -> Result<Cart, ShopError> {
7676 let product = self
7777 .products
7979 .await?
8080 .ok_or_else(|| ShopError::Domain(DomainError::ProductNotFound(sku.clone())))?;
8484 }
9191 }
9292}
shop-rs-api/src/domain/model.rs: 133/138 covered lines
runoldnew±codecov
2121}
2222
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
5353 return Err(DomainError::InvalidMoney);
5454 }
5656 }
5757 pub fn zero() -> Self {
5858 Self(0)
5959 }
6060 pub fn cents(self) -> i64 {
6161 self.0
6262 }
6464 self.0
6666 .map(Self)
6767 .ok_or(DomainError::AmountOverflow)
6868 }
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
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
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
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
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
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 {
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 {
208208 let existing = self
209209 .lines
210210 .iter()
216216 sku: product.sku.clone(),
217217 quantity: merged,
218218 })
219219 }
220220 pub fn checkout(&self, products: &[Product]) -> Result<OrderDraft, DomainError> {
222222 if self.lines.is_empty() {
223223 return Err(DomainError::EmptyCart);
224224 }
225225 let lines = self
226226 .lines
227227 .iter()
229229 .collect::<Result<Vec<_>, _>>()?;
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 }
246246 let product = products
247247 .iter()
251251 Ok(OrderLine {
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
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> {
1818 .max_connections(MAXIMUM_POOL_CONNECTIONS)
2020 .await,
2121 "connect",
2222 )?;
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{
3737}
3838
3939fn product_from_row(row: &PgRow) -> Result<Product, RepositoryError> {
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 {
5555 name,
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]
7474 async fn list(&self) -> Result<Vec<Product>, RepositoryError> {
7676 sqlx::query(r#"SELECT SKU, NAME, PRICE_CENTS, STOCK FROM product ORDER BY SKU"#)
7777 .fetch_all(&self.pool)
…
7979 "list_products",
8080 )?;
8282 }
8585 sqlx::query(r#"SELECT SKU, NAME, PRICE_CENTS, STOCK FROM product WHERE SKU = $1"#)
8686 .bind(sku.as_str())
…
8989 "find_product",
9090 )?;
9292 }
9494 let values: Vec<String> = skus.iter().map(|sku| sku.as_str().to_owned()).collect();
9696 sqlx::query(r#"SELECT SKU, NAME, PRICE_CENTS, STOCK FROM product WHERE SKU = ANY($1)"#)
9797 .bind(&values)
…
100100 "find_products",
101101 )?;
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 {
120120 other => {
121121 tracing::error!(upstream = "postgres", status = other, "unknown cart status");
122122 Err(RepositoryError::Unavailable("decode_cart".to_owned()))
123123 }
124124 }
125125}
126126
131131}
132132
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
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]
164164 async fn create(&self) -> Result<Cart, RepositoryError> {
166166 sqlx::query(r#"INSERT INTO cart (STATUS) VALUES ($1) RETURNING ID::text AS id"#)
…
175175 status: CartStatus::Open,
177177 })
178178 }
181181 sqlx::query(r#"SELECT STATUS FROM cart WHERE ID = $1::uuid"#)
182182 .bind(cart_id.as_str())
…
206206 .collect::<Result<_, _>>()?,
207207 }))
208208 }
209209 async fn save_line(&self, cart_id: &CartId, line: &CartLine) -> Result<(), RepositoryError> {
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]
239239 async fn place(&self, draft: &OrderDraft) -> Result<Order, RepositoryError> {
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
5656 Self {
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 {
8888 Self {
9292 }
9393 }
9494}
9595
9696#[derive(Debug, Serialize, ToSchema)]
…
101101}
102102
103103impl From<OrderLine> for OrderLineResponse {
105105 Self {
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()
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 };
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(
229229) -> Result<Json<ProductListResponse>, ApiError> {
231231 .catalog
233233 .await?
234234 .into_iter()
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(
246246 Path(sku): Path<String>,
247247) -> Result<Json<ProductResponse>, ApiError> {
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(
256256) -> Result<(StatusCode, Json<CartResponse>), ApiError> {
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(
266266 Path(cart_id): Path<String>,
267267) -> Result<Json<CartResponse>, ApiError> {
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(
279279 Path(cart_id): Path<String>,
280280 payload: Result<Json<AddCartItemRequest>, JsonRejection>,
281281) -> Result<Json<CartResponse>, ApiError> {
282282 let Json(request) = payload?;
286286 Ok(Json(
288288 .carts
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(
301301 Path(cart_id): Path<String>,
302302) -> Result<(StatusCode, Json<OrderResponse>), ApiError> {
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> {
342342}
343343
345345 Router::new()
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();
2020 error!(error = %message, "shop-rs-api failed");
2121 std::process::exit(1);
2222 }
2323}
2424
2525async fn run() -> Result<(), String> {
2727 let database_url =
2828 std::env::var("DATABASE_URL").map_err(|_| "DATABASE_URL is not set".to_owned())?;
3030 .await
3131 .map_err(|error| format!("database: {error:?}"))?;
3636 products,
3939 )),
4040 };
4141 let listener = TcpListener::bind(&address)
4242 .await
4444 tracing::info!(%address, "shop-rs-api listening");
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- Passed domain::model::tests::add_to_cart::rejects_checked_out_cart (0 ms)
- Passed domain::model::tests::add_to_cart::rejects_merged_quantity_beyond_line_maximum (0 ms)
- Passed domain::model::tests::add_to_cart::merges_quantity_with_existing_line (0 ms)
- Passed domain::model::tests::add_to_cart::rejects_quantity_beyond_stock (0 ms)
- Passed domain::model::tests::cart_id_parsing::normalizes_to_lowercase (0 ms)
- Passed domain::model::tests::cart_id_parsing::rejects_non_uuid (0 ms)
- Passed domain::model::tests::cart_id_rejects_a_dash_in_the_wrong_place (0 ms)
- Passed domain::model::tests::checkout::rejects_a_line_whose_product_is_missing (0 ms)
- Passed application::tests::catalog_service::keeps_a_rejected_domain_error (0 ms)
- Passed application::tests::catalog_service::returns_not_found_for_unknown_sku (0 ms)
- Passed application::tests::catalog_service::surfaces_unavailable_storage (0 ms)
- Passed domain::model::tests::checkout::rejects_checked_out_cart (0 ms)
- Passed domain::model::tests::checkout::rejects_a_total_that_overflows (0 ms)
- Passed application::tests::checkout::does_not_place_order_for_empty_cart (0 ms)
- Passed application::tests::add_to_cart::rejects_unknown_cart (0 ms)
- Passed application::tests::add_to_cart::rejects_quantity_beyond_stock (0 ms)
- Passed domain::model::tests::checkout::rejects_empty_cart (0 ms)
- Passed application::tests::add_to_cart::persists_merged_line (0 ms)
- Passed domain::model::tests::checkout::rejects_line_that_exceeds_current_stock (0 ms)
- Passed domain::model::tests::checkout::totals_every_line_in_cents (0 ms)
- Passed application::tests::checkout::places_order_with_priced_total (0 ms)
- Passed domain::model::tests::money_rejects_a_negative_amount_and_an_overflowing_sum (0 ms)
- Passed domain::model::tests::every_domain_error_names_the_problem (0 ms)
- Passed domain::model::tests::quantity_bounds::accepts_every_value_in_range (0 ms)
- Passed domain::model::tests::quantity_bounds::rejects_above_line_maximum (0 ms)
- Passed domain::model::tests::quantity_bounds::rejects_zero (0 ms)
- Passed domain::model::tests::sku_accepts_the_longest_code_and_exposes_it (0 ms)
- Passed domain::model::tests::sku_parsing::accepts_uppercase_digits_and_dashes (0 ms)
- Passed domain::model::tests::sku_parsing::rejects_lowercase (0 ms)
- Passed domain::model::tests::sku_parsing::rejects_too_short (0 ms)
- Passed domain::port::tests::display_includes_the_domain_message_or_the_storage_reason (0 ms)
- Passed infrastructure::postgres::tests::cart_line_parts_reject_a_bad_sku_or_quantity (0 ms)
- Passed infrastructure::postgres::tests::cart_status_accepts_both_stored_values_and_rejects_anything_else (0 ms)
- Passed infrastructure::postgres::tests::order_outcomes_distinguish_a_closed_cart_and_a_missed_reservation (0 ms)
- Passed infrastructure::postgres::tests::product_parts_reject_values_the_schema_also_forbids (0 ms)
- Passed infrastructure::postgres::tests::quantity_column_rejects_a_value_that_does_not_fit_the_database (0 ms)
- Passed infrastructure::web::tests::every_domain_error_has_an_http_status (0 ms)
- Passed infrastructure::web::tests::cart_storage_failure_is_a_server_error (0 ms)
- Passed infrastructure::web::tests::catalog_reports_storage_failure (0 ms)
- Passed infrastructure::web::tests::catalog_lists_and_finds_products (0 ms)
- Passed infrastructure::web::tests::cart_opens_adds_and_checks_out (0 ms)
- Passed infrastructure::web::tests::checkout_reports_an_empty_cart_stock_conflict_and_overflow (0 ms)
- Passed infrastructure::web::tests::cart_rejects_bad_input_and_conflicts (0 ms)
- Passed infrastructure::web::tests::health_and_openapi_respond (0 ms)
- Passed infrastructure::postgres::tests::connect_reports_unavailable_when_the_server_refuses (0 ms)
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
2828 pub fn new(products: Arc<dyn ProductRepository>) -> Self {
2929 Self { products }
3030 }
3232 Ok(self.products.list().await?)
3333 }
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
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 }
6161 Ok(self.carts.create().await?)
6262 }
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,
7676 let product = self
7777 .products
7878 .find_by_sku(sku)
7979 .await?
8080 .ok_or_else(|| ShopError::Domain(DomainError::ProductNotFound(sku.clone())))?;
8484 }
8888 let products = self.products.find_by_skus(&skus).await?;
9090 Ok(self.orders.place(&draft).await?)
9191 }
9292}
shop-rs-api/src/domain/model.rs: 277/277 covered lines
runoldnew±codecov
2121}
2222
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"),
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"),
4141 }
4242 }
4343}
4444
4545impl std::error::Error for DomainError {}
…
4848pub struct Money(i64);
4949
5353 return Err(DomainError::InvalidMoney);
5454 }
5656 }
5757 pub fn zero() -> Self {
5858 Self(0)
5959 }
6060 pub fn cents(self) -> i64 {
6161 self.0
6262 }
6464 self.0
6666 .map(Self)
6767 .ok_or(DomainError::AmountOverflow)
6868 }
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
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
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
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
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
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
159159 }
160160 Ok(())
161161 }
162162}
163163
164164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
…
203203}
204204
208208 let existing = self
209209 .lines
210210 .iter()
217217 quantity: merged,
218218 })
219219 }
222222 if self.lines.is_empty() {
223223 return Err(DomainError::EmptyCart);
224224 }
225225 let lines = self
226226 .lines
227227 .iter()
229229 .collect::<Result<Vec<_>, _>>()?;
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 }
247247 .iter()
251251 Ok(OrderLine {
255255 })
256256 }
257257}
258258
259259#[cfg(test)]
260260mod tests {
261261 use super::*;
262262
265265 }
266266
270270 name: raw_sku.to_owned(),
272272 stock,
273273 }
274274 }
275275
279279 status,
280280 lines,
281281 }
282282 }
283283
288288 }
289289 }
290290
291291 mod sku_parsing {
292292 use super::*;
293293
294294 #[test]
295295 fn accepts_uppercase_digits_and_dashes() {
296296 assert_eq!(
298298 Ok("MUG-001".to_owned())
299299 );
300300 }
301301
302302 #[test]
303303 fn rejects_lowercase() {
305305 }
306306
307307 #[test]
308308 fn rejects_too_short() {
310310 }
311311 }
312312
313313 mod quantity_bounds {
314314 use super::*;
315315
316316 #[test]
317317 fn rejects_zero() {
319319 }
320320
321321 #[test]
322322 fn rejects_above_line_maximum() {
324324 }
325325
326326 #[test]
327327 fn accepts_every_value_in_range() {
329329 }
330330 }
331331
332332 mod cart_id_parsing {
333333 use super::*;
334334
335335 #[test]
336336 fn normalizes_to_lowercase() {
339339 }
340340
341341 #[test]
342342 fn rejects_non_uuid() {
344344 }
345345 }
346346
347347 mod add_to_cart {
348348 use super::*;
349349
350350 #[test]
351351 fn merges_quantity_with_existing_line() {
355355 }
356356
357357 #[test]
358358 fn rejects_quantity_beyond_stock() {
362362 }
363363
364364 #[test]
365365 fn rejects_merged_quantity_beyond_line_maximum() {
368368 assert_eq!(result, Err(DomainError::InvalidQuantity));
369369 }
370370
371371 #[test]
372372 fn rejects_checked_out_cart() {
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() {
387387 .unwrap();
389389 }
390390
391391 #[test]
392392 fn rejects_empty_cart() {
394394 assert_eq!(result, Err(DomainError::EmptyCart));
395395 }
396396
397397 #[test]
398398 fn rejects_line_that_exceeds_current_stock() {
402402 }
403403
404404 #[test]
405405 fn rejects_checked_out_cart() {
408408 assert_eq!(result, Err(DomainError::CartClosed));
409409 }
410410
411411 #[test]
412412 fn rejects_a_line_whose_product_is_missing() {
415415 }
416416
417417 #[test]
418418 fn rejects_a_total_that_overflows() {
421421 assert_eq!(result, Err(DomainError::AmountOverflow));
422422 }
423423 }
424424
425425 #[test]
426426 fn money_rejects_a_negative_amount_and_an_overflowing_sum() {
429429 assert_eq!(
431431 Err(DomainError::AmountOverflow)
432432 );
433433 assert_eq!(
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);
445445 }
446446
447447 #[test]
448448 fn cart_id_rejects_a_dash_in_the_wrong_place() {
449449 assert_eq!(
451451 Err(DomainError::InvalidCartId)
452452 );
453453 }
454454
455455 #[test]
456456 fn every_domain_error_names_the_problem() {
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(),
466466 DomainError::CartNotFound(cart_id.clone()).to_string(),
467467 DomainError::CartClosed.to_string(),
468468 DomainError::EmptyCart.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
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);
3333 assert_eq!(rejected.to_string(), DomainError::EmptyCart.to_string());
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> {
1818 .max_connections(MAXIMUM_POOL_CONNECTIONS)
2020 .await,
2121 "connect",
2222 )?;
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{
3737}
3838
4545}
4646
4747fn product_from_parts(
4848 sku: &str,
4949 name: String,
5050 price_cents: i64,
5151 stock: i32,
5555 name,
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]
7676 sqlx::query(r#"SELECT SKU, NAME, PRICE_CENTS, STOCK FROM product ORDER BY SKU"#)
7777 .fetch_all(&self.pool)
…
7979 "list_products",
8080 )?;
8282 }
8585 sqlx::query(r#"SELECT SKU, NAME, PRICE_CENTS, STOCK FROM product WHERE SKU = $1"#)
8686 .bind(sku.as_str())
…
8989 "find_product",
9090 )?;
9292 }
9494 let values: Vec<String> = skus.iter().map(|sku| sku.as_str().to_owned()).collect();
9696 sqlx::query(r#"SELECT SKU, NAME, PRICE_CENTS, STOCK FROM product WHERE SKU = ANY($1)"#)
9797 .bind(&values)
…
100100 "find_products",
101101 )?;
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 {
120120 other => {
121121 tracing::error!(upstream = "postgres", status = other, "unknown cart status");
122122 Err(RepositoryError::Unavailable("decode_cart".to_owned()))
123123 }
124124 }
125125}
126126
131131}
132132
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
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]
166166 sqlx::query(r#"INSERT INTO cart (STATUS) VALUES ($1) RETURNING ID::text AS id"#)
…
175175 status: CartStatus::Open,
177177 })
178178 }
181181 sqlx::query(r#"SELECT STATUS FROM cart WHERE ID = $1::uuid"#)
182182 .bind(cart_id.as_str())
…
206206 .collect::<Result<_, _>>()?,
207207 }))
208208 }
209209 async fn save_line(&self, cart_id: &CartId, line: &CartLine) -> Result<(), RepositoryError> {
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]
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() {
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() {
315315 assert_eq!(
317317 CartStatus::CheckedOut
318318 );
319319 assert_eq!(
321321 RepositoryError::Unavailable("decode_cart".to_owned())
322322 );
323323 }
324324
325325 #[test]
326326 fn product_parts_reject_values_the_schema_also_forbids() {
328328 assert!(matches!(
330330 Err(RepositoryError::Rejected(DomainError::InvalidSku))
331331 ));
332332 assert!(matches!(
334334 Err(RepositoryError::Rejected(DomainError::InvalidMoney))
335335 ));
336336 assert!(matches!(
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!(
346346 .unwrap()
347347 .quantity
349349 2
350350 );
351351 assert!(matches!(
353353 Err(RepositoryError::Rejected(DomainError::InvalidSku))
354354 ));
355355 assert!(matches!(
357357 Err(RepositoryError::Rejected(DomainError::InvalidQuantity))
358358 ));
359359 }
360360
361361 #[test]
362362 fn quantity_column_rejects_a_value_that_does_not_fit_the_database() {
364364 assert!(matches!(
366366 Err(RepositoryError::Rejected(DomainError::InvalidQuantity))
367367 ));
368368 }
369369
370370 #[test]
371371 fn order_outcomes_distinguish_a_closed_cart_and_a_missed_reservation() {
374374 assert_eq!(
376376 RepositoryError::Rejected(DomainError::CartClosed)
377377 );
379379 assert_eq!(
381381 RepositoryError::Rejected(DomainError::OutOfStock(sku))
382382 );
383383 }
384384}
shop-rs-api/src/infrastructure/web.rs: 517/517 covered lines
runoldnew±codecov
2929}
3030
3333 Self {
3838 }
3939 }
4040}
4141
4242#[derive(Debug, Serialize, ToSchema)]
…
5252}
5353
5656 Self {
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
8888 Self {
9292 }
9393 }
9494}
9595
9696#[derive(Debug, Serialize, ToSchema)]
…
101101}
102102
103103impl From<OrderLine> for OrderLineResponse {
105105 Self {
109109 }
110110 }
111111}
112112
113113#[derive(Debug, Serialize, ToSchema)]
…
119119}
120120
123123 Self {
124124 id: order.id,
125125 cart_id: order.cart_id.as_str().to_owned(),
126126 lines: order
127127 .lines
128128 .into_iter()
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 };
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(
229229) -> Result<Json<ProductListResponse>, ApiError> {
231231 .catalog
233233 .await?
234234 .into_iter()
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(
247247) -> Result<Json<ProductResponse>, ApiError> {
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(
256256) -> Result<(StatusCode, Json<CartResponse>), ApiError> {
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(
266266 Path(cart_id): Path<String>,
267267) -> Result<Json<CartResponse>, ApiError> {
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(
279279 Path(cart_id): Path<String>,
280280 payload: Result<Json<AddCartItemRequest>, JsonRejection>,
281281) -> Result<Json<CartResponse>, ApiError> {
282282 let Json(request) = payload?;
286286 Ok(Json(
288288 .carts
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(
301301 Path(cart_id): Path<String>,
302302) -> Result<(StatusCode, Json<OrderResponse>), ApiError> {
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> {
342342}
343343
345345 Router::new()
355355}
356356
357357#[cfg(test)]
358358mod tests {
…
383383
384384 #[async_trait]
385385 impl ProductRepository for Products {
387387 match &self.fail {
388388 Some(error) => Err(error.clone()),
389389 None => Ok(self.products.clone()),
390390 }
391391 }
393393 match &self.fail {
394394 Some(error) => Err(error.clone()),
395395 None => Ok(self
396396 .products
397397 .iter()
399399 .cloned()),
400400 }
401401 }
403403 match &self.fail {
404404 Some(error) => Err(error.clone()),
405405 None => Ok(self
406406 .products
407407 .iter()
409409 .cloned()
410410 .collect()),
411411 }
412412 }
413413 }
414414
415415 struct Carts {
…
419419
420420 #[async_trait]
423423 if let Some(error) = &self.fail {
424424 return Err(error.clone());
425425 }
…
430430 };
433433 }
435435 if let Some(error) = &self.fail {
436436 return Err(error.clone());
437437 }
…
440440 .lock()
441441 .unwrap()
442442 .iter()
444444 .cloned())
445445 }
446446 async fn save_line(
447447 &self,
450450 ) -> Result<(), RepositoryError> {
451451 if let Some(error) = &self.fail {
452452 return Err(error.clone());
453453 }
454454 let mut carts = self.carts.lock().unwrap();
458458 Ok(())
459459 }
460460 }
461461
462462 struct Orders {
…
465465
466466 #[async_trait]
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
483483 }
484484
488488 name: raw.to_owned(),
490490 stock,
491491 }
492492 }
493493
494494 fn state(
497497 product_fail: Option<RepositoryError>,
498498 cart_fail: Option<RepositoryError>,
499499 order_fail: Option<RepositoryError>,
500500 ) -> AppState {
501501 AppState {
503503 fail: product_fail,
504504 products: products.clone(),
505505 }))),
507507 Arc::new(Products {
508508 fail: None,
509509 products,
510510 }),
512512 carts: Mutex::new(carts),
513513 fail: cart_fail,
514514 }),
516516 )),
517517 }
518518 }
519519
523523 status: CartStatus::Open,
524524 lines: Vec::new(),
525525 }
526526 }
527527
528528 async fn send(
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
540540 .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 (
582582 StatusCode::NOT_FOUND,
583583 "product_not_found",
584584 ),
585585 (
587587 StatusCode::NOT_FOUND,
588588 "cart_not_found",
589589 ),
590590 (DomainError::CartClosed, StatusCode::CONFLICT, "cart_closed"),
591591 (
593593 StatusCode::CONFLICT,
594594 "out_of_stock",
595595 ),
596596 ];
597597 for (error, status, code) in cases {
599599 assert_eq!(api.status, status);
600600 assert_eq!(api.code, code);
602602 assert_eq!(response.status(), status);
603603 }
607607 }
608608
609609 #[tokio::test]
610610 async fn health_and_openapi_respond() {
613613 vec![],
614614 None,
615615 None,
616616 None,
617617 );
619619 assert_eq!(status, StatusCode::OK);
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() {
629629 vec![],
630630 None,
631631 None,
632632 None,
633633 );
635635 assert_eq!(status, StatusCode::OK);
636636 assert!(body.contains("MUG-STONE"));
638638 assert_eq!(status, StatusCode::OK);
639639 assert!(body.contains("1800"));
641641 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
642642 assert!(body.contains("invalid_sku"));
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() {
651651 vec![],
652652 vec![],
653653 Some(RepositoryError::Unavailable("connect".to_owned())),
654654 None,
655655 None,
656656 );
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() {
666666 vec![],
667667 None,
668668 None,
669669 None,
670670 );
672672 assert_eq!(status, StatusCode::CREATED);
673673 assert!(body.contains(CART_ID));
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"));
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"));
693693 assert_eq!(status, StatusCode::OK);
694694 assert!(body.contains("open"));
695695 let (status, body) =
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() {
705705 status: CartStatus::CheckedOut,
709709 }],
710710 };
712712 vec![
715715 ],
716716 vec![closed.clone()],
717717 None,
718718 None,
719719 None,
720720 );
722722 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
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);
732732 assert_eq!(status, StatusCode::OK);
733733 assert!(body.contains("checked_out"));
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);
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"));
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"));
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) =
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() {
778778 vec![
781781 ],
782782 vec![empty],
783783 None,
784784 None,
785785 None,
786786 );
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"));
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);
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"));
816816 None,
817817 None,
818818 None,
819819 );
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);
830830 vec![],
831831 None,
832832 None,
833833 None,
834834 );
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() {
849849 vec![],
850850 None,
851851 Some(RepositoryError::Unavailable("connect".to_owned())),
852852 None,
853853 );
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- Passed published cart entry prices two mugs through the package export (7 ms)
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) {
3030 }
3131 return amount as Cents;
3232}
3333
3535 if (!Number.isInteger(line.quantity) || line.quantity < MINIMUM_QUANTITY || line.quantity > MAXIMUM_QUANTITY) {
3737 }
3939}
4040
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- Passed cents accepts zero (0 ms)
- Passed cents rejects negative amounts (0 ms)
- Passed cents rejects fractional amounts (0 ms)
- Passed lineTotal multiplies unit price by quantity (0 ms)
- Passed lineTotal rejects a zero quantity (0 ms)
- Passed lineTotal rejects a quantity above the cart limit (0 ms)
- Passed cartTotal is zero for an empty cart (0 ms)
- Passed cartTotal sums every line (0 ms)
- Passed formatCents formats euros for an Italian locale (24 ms)
- Passed formatCents formats dollars for a US locale (0 ms)
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) {
3030 }
3131 return amount as Cents;
3232}
3333
3535 if (!Number.isInteger(line.quantity) || line.quantity < MINIMUM_QUANTITY || line.quantity > MAXIMUM_QUANTITY) {
3737 }
3939}
4040
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- Passed shows the seeded catalog (297 ms)
- Passed rejects a zero quantity (201 ms)
- Passed rejects checkout of an empty cart (191 ms)
- Passed checks out two mugs and a second empty cart (316 ms)
- Passed rejects a lamp quantity above the stock on hand (152 ms)
- Passed shows an unknown product when the catalog row disappears (235 ms)
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
3535 if (!Number.isInteger(line.quantity) || line.quantity < MINIMUM_QUANTITY || line.quantity > MAXIMUM_QUANTITY) {
3737 }
3939}
4040
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
1515 return { sku: line.sku, quantity: line.quantity, unitPriceCents: cents(product?.priceCents ?? 0) };
1616}
1717
1818function messageOf(error: unknown): string {
2020 return "something went wrong";
2121}
2222
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;
3434 .then((loaded) => {
3535 if (active) setProducts(loaded);
3636 })
3737 .catch((failure: unknown) => {
3939 });
4040 return () => {
4141 active = false;
4242 };
4343 }, [apiBase]);
4444
4646 const parsed = Number(quantities[product.sku] ?? "1");
4747 setBusy(true);
4848 setError(null);
4949 setOrder(null);
5050 try {
5353 setCart(updated);
5555 setProducts(refreshed);
5656 } catch (failure: unknown) {
5858 } finally {
5959 setBusy(false);
6060 }
6161 }
6262
6363 async function checkout() {
6464 setBusy(true);
6565 setError(null);
6666 try {
6969 setOrder(placed);
7070 setCart(null);
7272 } catch (failure: unknown) {
7474 } finally {
7575 setBusy(false);
7676 }
7777 }
7878
8181
8282 return (
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>
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>
102102 Add {product.name}
103103 </button>
104104 </li>
…
110110 <ul>
113113 return (
…
120120 })}
121121 </ul>
124124 Checkout
125125 </button>
126126 </section>
shop-ts-web-react/src/main.tsx: 1/1 covered lines
shop-ts-web-react/src/mount.tsx: 2/2 covered lines
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
5656 return { sku: body.sku, name: body.name, priceCents: body.price_cents, stock: body.stock };
5757}
5858
6060 return {
6161 id: body.id,
6262 status: body.status,
6464 };
6565}
6666
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 }
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 {
8686 }
8888 return (await response.json()) as T;
8989}
9090
9494}
9595
9898}
9999
100100export async function addCartItem(apiBase: string, cartId: string, sku: string, quantity: number): Promise<Cart> {
103103 method: "POST",
104104 headers: { "content-type": "application/json" },
…
108108}
109109
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- Passed mountShop rejects a missing root (0 ms)
- Passed mountShop renders the shop into the root element (41 ms)
- Passed Shop shows the catalog and an empty cart (29 ms)
- Passed Shop shows a loading line until the catalog arrives (4 ms)
- Passed Shop ignores a catalog response that arrives after unmount (1 ms)
- Passed Shop shows a storage failure from the catalog (17 ms)
- Passed Shop shows a generic message when the catalog body is not a list (4 ms)
- Passed Shop shows a network failure (3 ms)
- Passed Shop adds two mugs and shows the line total (39 ms)
- Passed Shop disables actions while an add is in flight (15 ms)
- Passed Shop shows the sku when a cart line is no longer in the catalog (14 ms)
- Passed Shop places an order and rejects the next empty checkout (33 ms)
- Passed Shop shows an empty-cart error and a second checkout error (21 ms)
- Passed Shop shows a zero quantity error (26 ms)
- Passed Shop shows an out of stock error (24 ms)
- Passed Shop shows an unreadable add failure (12 ms)
- Passed shop API maps catalog rows into products (3 ms)
- Passed shop API opens a cart (0 ms)
- Passed shop API adds a line and returns the cart (0 ms)
- Passed shop API maps a placed order (0 ms)
- Passed shop API reads an API error (0 ms)
- Passed shop API keeps the status text when the error body has no code (0 ms)
- Passed shop API reports an unreadable error body (0 ms)
- Passed shop API reports a network failure (0 ms)
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
1515 return { sku: line.sku, quantity: line.quantity, unitPriceCents: cents(product?.priceCents ?? 0) };
1616}
1717
1818function messageOf(error: unknown): string {
2020 return "something went wrong";
2121}
2222
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;
3434 .then((loaded) => {
3535 if (active) setProducts(loaded);
3636 })
3737 .catch((failure: unknown) => {
3939 });
4040 return () => {
4141 active = false;
4242 };
4343 }, [apiBase]);
4444
4646 const parsed = Number(quantities[product.sku] ?? "1");
4747 setBusy(true);
4848 setError(null);
4949 setOrder(null);
5050 try {
5353 setCart(updated);
5555 setProducts(refreshed);
5656 } catch (failure: unknown) {
5858 } finally {
5959 setBusy(false);
6060 }
6161 }
6262
6363 async function checkout() {
6464 setBusy(true);
6565 setError(null);
6666 try {
6969 setOrder(placed);
7070 setCart(null);
7272 } catch (failure: unknown) {
7474 } finally {
7575 setBusy(false);
7676 }
7777 }
7878
8181
8282 return (
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>
9292 <span>{product.stock} in stock</span>
9393 <label>
9595 <input
9696 aria-label={`Quantity for ${product.name}`}
9999 />
100100 </label>
102102 Add {product.name}
103103 </button>
104104 </li>
105105 ))}
106106 </ul>
107107 <section aria-label="Cart">
110110 <ul>
113113 return (
118118 </li>
119119 );
120120 })}
121121 </ul>
124124 Checkout
125125 </button>
126126 </section>
127127 {order ? (
128128 <p role="status">
130130 </p>
131131 ) : null}
133133 );
134134}
135135
136136
shop-ts-web-react/src/mount.tsx: 5/5 covered lines
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
5656 return { sku: body.sku, name: body.name, priceCents: body.price_cents, stock: body.stock };
5757}
5858
6060 return {
6161 id: body.id,
6262 status: body.status,
6464 };
6565}
6666
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 }
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 {
8686 }
8888 return (await response.json()) as T;
8989}
9090
9494}
9595
9898}
9999
100100export async function addCartItem(apiBase: string, cartId: string, sku: string, quantity: number): Promise<Cart> {
103103 method: "POST",
104104 headers: { "content-type": "application/json" },
105105 body: JSON.stringify({ sku, quantity }),
106106 }),
107107 );
108108}
109109
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- Passed catalog (463 ms)
- Passed empty checkout (445 ms)
- Passed cart lines (350 ms)
- Passed order placed (399 ms)
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
3535 if (!Number.isInteger(line.quantity) || line.quantity < MINIMUM_QUANTITY || line.quantity > MAXIMUM_QUANTITY) {
3737 }
3939}
4040
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
1515 return { sku: line.sku, quantity: line.quantity, unitPriceCents: cents(product?.priceCents ?? 0) };
1616}
1717
1818function messageOf(error: unknown): string {
2020 return "something went wrong";
2121}
2222
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;
3434 .then((loaded) => {
3535 if (active) setProducts(loaded);
3636 })
3737 .catch((failure: unknown) => {
3939 });
4040 return () => {
4141 active = false;
4242 };
4343 }, [apiBase]);
4444
4646 const parsed = Number(quantities[product.sku] ?? "1");
4747 setBusy(true);
4848 setError(null);
4949 setOrder(null);
5050 try {
5353 setCart(updated);
5555 setProducts(refreshed);
5656 } catch (failure: unknown) {
5858 } finally {
5959 setBusy(false);
6060 }
6161 }
6262
6363 async function checkout() {
6464 setBusy(true);
6565 setError(null);
6666 try {
6969 setOrder(placed);
7070 setCart(null);
7272 } catch (failure: unknown) {
7474 } finally {
7575 setBusy(false);
7676 }
7777 }
7878
8181
8282 return (
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>
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>
102102 Add {product.name}
103103 </button>
104104 </li>
…
110110 <ul>
113113 return (
…
120120 })}
121121 </ul>
124124 Checkout
125125 </button>
126126 </section>
shop-ts-web-react/src/main.tsx: 1/1 covered lines
shop-ts-web-react/src/mount.tsx: 2/2 covered lines
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
5656 return { sku: body.sku, name: body.name, priceCents: body.price_cents, stock: body.stock };
5757}
5858
6060 return {
6161 id: body.id,
6262 status: body.status,
6464 };
6565}
6666
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 }
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 {
8686 }
8888 return (await response.json()) as T;
8989}
9090
9494}
9595
9898}
9999
100100export async function addCartItem(apiBase: string, cartId: string, sku: string, quantity: number): Promise<Cart> {
103103 method: "POST",
104104 headers: { "content-type": "application/json" },
…
108108}
109109
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}