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_applies_the_mug_discount_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: 145/155 covered lines
runoldnew±codecov
2323}
2424
2626 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2727 match self {
2828 Self::InvalidSku => write!(
2929 formatter,
3030 "sku must be {MINIMUM_SKU_LENGTH}-{MAXIMUM_SKU_LENGTH} uppercase letters, digits or dashes"
3131 ),
3232 Self::InvalidQuantity => {
3333 write!(formatter, "quantity must be between 1 and {MAXIMUM_LINE_QUANTITY}")
3434 }
3535 Self::InvalidMoney => write!(formatter, "amount must not be negative"),
3636 Self::InvalidDiscount => {
3737 write!(formatter, "discount percent must be an integer from 0 through 100")
3838 }
3939 Self::InvalidCartId => write!(formatter, "cart id must be a UUID"),
4040 Self::AmountOverflow => write!(formatter, "order total exceeds the supported range"),
4141 Self::ProductNotFound(sku) => write!(formatter, "product {sku} does not exist"),
4242 Self::CartNotFound(cart_id) => write!(formatter, "cart {cart_id} does not exist"),
4343 Self::CartClosed => write!(formatter, "cart is already checked out"),
4444 Self::EmptyCart => write!(formatter, "cart has no items"),
4545 Self::OutOfStock(sku) => write!(formatter, "not enough stock for {sku}"),
4646 }
4747 }
4848}
4949
5050impl std::error::Error for DomainError {}
…
5353pub struct Money(i64);
5454
5858 return Err(DomainError::InvalidMoney);
5959 }
6161 }
6262 pub fn zero() -> Self {
6363 Self(0)
6464 }
6565 pub fn cents(self) -> i64 {
6666 self.0
6767 }
6969 self.0
7171 .map(Self)
7272 .ok_or(DomainError::AmountOverflow)
7373 }
7575 self.0
7676 .checked_add(other.0)
7777 .map(Self)
7878 .ok_or(DomainError::AmountOverflow)
7979 }
8181 let reduction =
8383 Self(self.0 - reduction as i64)
8484 }
8585}
8686
8787#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
8888pub struct Sku(String);
8989
9191 pub fn parse(raw: &str) -> Result<Self, DomainError> {
9292 let length_valid = (MINIMUM_SKU_LENGTH..=MAXIMUM_SKU_LENGTH).contains(&raw.len());
9393 let characters_valid = raw.chars().all(|character| {
9494 character.is_ascii_uppercase() || character.is_ascii_digit() || character == '-'
9595 });
9696 if !length_valid || !characters_valid {
9797 return Err(DomainError::InvalidSku);
9898 }
9999 Ok(Self(raw.to_owned()))
100100 }
101101 pub fn as_str(&self) -> &str {
102102 &self.0
103103 }
104104}
105105
106106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107107pub struct DiscountPercent(u8);
108108
110110 pub const fn none() -> Self {
111111 Self(0)
112112 }
113113 pub fn parse(percent: i64) -> Result<Self, DomainError> {
114114 match u8::try_from(percent) {
116116 _ => Err(DomainError::InvalidDiscount),
117117 }
118118 }
119119 pub fn value(self) -> u8 {
120120 self.0
121121 }
122122}
123123
125125 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
126126 formatter.write_str(&self.0)
127127 }
128128}
129129
130130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131131pub struct Quantity(u32);
132132
136136 .ok()
137137 .filter(|candidate| (1..=MAXIMUM_LINE_QUANTITY).contains(candidate))
138138 .map(Self)
139139 .ok_or(DomainError::InvalidQuantity)
140140 }
141141 pub fn value(self) -> u32 {
142142 self.0
143143 }
144144}
145145
146146#[derive(Debug, Clone, PartialEq, Eq)]
147147pub struct CartId(String);
148148
150150 pub fn parse(raw: &str) -> Result<Self, DomainError> {
151151 let shape_valid = raw.len() == CART_ID_LENGTH
152152 && raw.char_indices().all(|(position, character)| {
153153 if CART_ID_DASH_POSITIONS.contains(&position) {
154154 character == '-'
155155 } else {
156156 character.is_ascii_hexdigit()
157157 }
158158 });
159159 if !shape_valid {
160160 return Err(DomainError::InvalidCartId);
161161 }
162162 Ok(Self(raw.to_ascii_lowercase()))
163163 }
164164 pub fn as_str(&self) -> &str {
165165 &self.0
166166 }
167167}
168168
170170 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
171171 formatter.write_str(&self.0)
172172 }
173173}
174174
175175#[derive(Debug, Clone, PartialEq, Eq)]
…
182182}
183183
184184impl Product {
187187 return Err(DomainError::OutOfStock(self.sku.clone()));
188188 }
189189 Ok(())
190190 }
191191}
192192
193193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
…
232232}
233233
234234impl Cart {
237237 let existing = self
238238 .lines
239239 .iter()
245245 sku: product.sku.clone(),
246246 quantity: merged,
247247 })
248248 }
249249 pub fn checkout(&self, products: &[Product]) -> Result<OrderDraft, DomainError> {
251251 if self.lines.is_empty() {
252252 return Err(DomainError::EmptyCart);
253253 }
254254 let lines = self
255255 .lines
256256 .iter()
258258 .collect::<Result<Vec<_>, _>>()?;
261261 })?;
262262 Ok(OrderDraft {
263263 cart_id: self.id.clone(),
264264 lines,
265265 total,
266266 })
267267 }
268268 fn ensure_open(&self) -> Result<(), DomainError> {
269269 match self.status {
270270 CartStatus::Open => Ok(()),
271271 CartStatus::CheckedOut => Err(DomainError::CartClosed),
272272 }
273273 }
275275 let product = products
276276 .iter()
278278 .ok_or_else(|| DomainError::ProductNotFound(line.sku.clone()))?;
280280 Ok(OrderLine {
281281 sku: line.sku.clone(),
282282 quantity: line.quantity,
284284 })
285285 }
286286}
287287
288288#[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: 86/98 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> {
4646}
4747
4848fn product_from_parts(
4949 sku: &str,
5050 name: String,
5151 price_cents: i64,
5252 discount_percent: i64,
5353 stock: i32,
5454) -> Result<Product, RepositoryError> {
5555 Ok(Product {
5757 name,
6060 .map_err(RepositoryError::Rejected)?,
6161 stock: u32::try_from(stock)
6262 .map_err(|_| RepositoryError::Rejected(DomainError::InvalidQuantity))?,
6363 })
6464}
6565
6666pub struct PostgresProductRepository {
6767 pool: PgPool,
6868}
6969
7171 pub fn new(pool: PgPool) -> Self {
7272 Self { pool }
7373 }
7474}
7575
7676#[async_trait]
7878 async fn list(&self) -> Result<Vec<Product>, RepositoryError> {
8080 sqlx::query(
8181 r#"SELECT SKU, NAME, PRICE_CENTS, DISCOUNT_PERCENT, STOCK FROM product ORDER BY SKU"#,
…
8585 "list_products",
8686 )?;
8888 }
9191 sqlx::query(
9292 r#"SELECT SKU, NAME, PRICE_CENTS, DISCOUNT_PERCENT, STOCK FROM product WHERE SKU = $1"#,
…
9797 "find_product",
9898 )?;
100100 }
102102 let values: Vec<String> = skus.iter().map(|sku| sku.as_str().to_owned()).collect();
104104 sqlx::query(
105105 r#"SELECT SKU, NAME, PRICE_CENTS, DISCOUNT_PERCENT, STOCK FROM product WHERE SKU = ANY($1)"#,
…
110110 "find_products",
111111 )?;
113113 }
114114}
115115
116116pub struct PostgresCartRepository {
…
118118}
119119
121121 pub fn new(pool: PgPool) -> Self {
122122 Self { pool }
123123 }
124124}
125125
126126fn cart_status(raw: &str) -> Result<CartStatus, RepositoryError> {
127127 match raw {
130130 other => {
131131 tracing::error!(upstream = "postgres", status = other, "unknown cart status");
132132 Err(RepositoryError::Unavailable("decode_cart".to_owned()))
133133 }
134134 }
135135}
136136
141141}
142142
147147 })
148148}
149149
150150fn quantity_column(quantity: u32) -> Result<i32, RepositoryError> {
151151 i32::try_from(quantity).map_err(|_| RepositoryError::Rejected(DomainError::InvalidQuantity))
152152}
153153
154154fn close_outcome(rows_affected: u64) -> Result<(), RepositoryError> {
155155 if rows_affected == 0 {
156156 Err(RepositoryError::Rejected(DomainError::CartClosed))
157157 } else {
158158 Ok(())
159159 }
160160}
161161
163163 if rows_affected == 0 {
164164 Err(RepositoryError::Rejected(DomainError::OutOfStock(
165165 sku.clone(),
166166 )))
167167 } else {
168168 Ok(())
169169 }
170170}
171171
172172#[async_trait]
174174 async fn create(&self) -> Result<Cart, RepositoryError> {
176176 sqlx::query(r#"INSERT INTO cart (STATUS) VALUES ($1) RETURNING ID::text AS id"#)
…
185185 status: CartStatus::Open,
187187 })
188188 }
191191 sqlx::query(r#"SELECT STATUS FROM cart WHERE ID = $1::uuid"#)
192192 .bind(cart_id.as_str())
…
216216 .collect::<Result<_, _>>()?,
217217 }))
218218 }
219219 async fn save_line(&self, cart_id: &CartId, line: &CartLine) -> Result<(), RepositoryError> {
222222 sqlx::query(
…
231231 "save_cart_line",
232232 )?;
233233 Ok(())
234234 }
235235}
236236
237237pub struct PostgresOrderRepository {
…
239239}
240240
242242 pub fn new(pool: PgPool) -> Self {
243243 Self { pool }
244244 }
245245}
246246
247247#[async_trait]
249249 async fn place(&self, draft: &OrderDraft) -> Result<Order, RepositoryError> {
252252 sqlx::query(r#"UPDATE cart SET STATUS = $2 WHERE ID = $1::uuid AND STATUS = $3"#)
…
303303 lines: draft.lines.clone(),
304304 total: draft.total,
305305 })
306306 }
307307}
308308
309309#[cfg(test)]
shop-rs-api/src/infrastructure/web.rs: 149/167 covered lines
runoldnew±codecov
3131}
3232
3333impl From<Product> for ProductResponse {
3434 fn from(product: Product) -> Self {
3535 Self {
3636 sku: product.sku.as_str().to_owned(),
3737 name: product.name,
3838 price_cents: product.price.cents(),
3939 discount_percent: product.discount_percent.value(),
4040 stock: product.stock,
4141 }
4242 }
4343}
4444
4545#[derive(Debug, Serialize, ToSchema)]
…
5555}
5656
5959 Self {
6060 sku: line.sku.as_str().to_owned(),
6161 quantity: line.quantity.value(),
6262 }
6363 }
6464}
6565
6666#[derive(Debug, Serialize, ToSchema)]
…
7171}
7272
7373impl From<CartStatus> for CartStatusResponse {
7474 fn from(status: CartStatus) -> Self {
7575 match status {
7676 CartStatus::Open => Self::Open,
7777 CartStatus::CheckedOut => Self::CheckedOut,
7878 }
7979 }
8080}
8181
8282#[derive(Debug, Serialize, ToSchema)]
…
8787}
8888
8989impl From<Cart> for CartResponse {
9090 fn from(cart: Cart) -> Self {
9191 Self {
9292 id: cart.id.as_str().to_owned(),
9393 status: cart.status.into(),
9595 }
9696 }
9797}
9898
9999#[derive(Debug, Serialize, ToSchema)]
…
104104}
105105
106106impl From<OrderLine> for OrderLineResponse {
107107 fn from(line: OrderLine) -> Self {
108108 Self {
109109 sku: line.sku.as_str().to_owned(),
110110 quantity: line.quantity.value(),
111111 unit_price_cents: line.unit_price.cents(),
112112 }
113113 }
114114}
115115
116116#[derive(Debug, Serialize, ToSchema)]
…
122122}
123123
124124impl From<Order> for OrderResponse {
125125 fn from(order: Order) -> Self {
126126 Self {
127127 id: order.id,
128128 cart_id: order.cart_id.as_str().to_owned(),
129129 lines: order
130130 .lines
131131 .into_iter()
133133 .collect(),
134134 total_cents: order.total.cents(),
135135 }
136136 }
137137}
138138
139139#[derive(Debug, Deserialize, ToSchema)]
…
161161}
162162
163163impl From<DomainError> for ApiError {
164164 fn from(error: DomainError) -> Self {
165165 let (status, code) = match &error {
166166 DomainError::InvalidSku => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_sku"),
167167 DomainError::InvalidQuantity => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_quantity"),
168168 DomainError::InvalidMoney => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_money"),
169169 DomainError::InvalidDiscount => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_discount"),
170170 DomainError::InvalidCartId => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_cart_id"),
171171 DomainError::AmountOverflow => (StatusCode::UNPROCESSABLE_ENTITY, "amount_overflow"),
172172 DomainError::EmptyCart => (StatusCode::UNPROCESSABLE_ENTITY, "empty_cart"),
173173 DomainError::ProductNotFound(_) => (StatusCode::NOT_FOUND, "product_not_found"),
174174 DomainError::CartNotFound(_) => (StatusCode::NOT_FOUND, "cart_not_found"),
175175 DomainError::CartClosed => (StatusCode::CONFLICT, "cart_closed"),
176176 DomainError::OutOfStock(_) => (StatusCode::CONFLICT, "out_of_stock"),
177177 };
178178 Self {
179179 status,
180180 code,
181181 message: error.to_string(),
182182 }
183183 }
184184}
185185
186186impl From<ShopError> for ApiError {
187187 fn from(error: ShopError) -> Self {
188188 match error {
189189 ShopError::Domain(domain_error) => domain_error.into(),
190190 ShopError::Unavailable(operation) => {
191191 tracing::error!(operation = %operation, "request failed on storage");
192192 Self {
193193 status: StatusCode::INTERNAL_SERVER_ERROR,
194194 code: "storage_unavailable",
195195 message: "storage is unavailable".to_owned(),
196196 }
197197 }
198198 }
199199 }
200200}
201201
202202impl From<JsonRejection> for ApiError {
203203 fn from(rejection: JsonRejection) -> Self {
204204 Self {
205205 status: StatusCode::UNPROCESSABLE_ENTITY,
206206 code: "invalid_body",
207207 message: rejection.body_text(),
208208 }
209209 }
210210}
211211
212212impl IntoResponse for ApiError {
213213 fn into_response(self) -> Response {
214214 let body = ErrorResponse {
215215 error: ErrorBody {
216216 code: self.code,
217217 message: self.message,
218218 },
219219 };
221221 }
222222}
223223
224224#[utoipa::path(get, path = "/health", tag = "Health", responses((status = 200, description = "Service is up")))]
225225async fn health() -> StatusCode {
226226 StatusCode::OK
227227}
228228
229229#[utoipa::path(get, path = "/v1/products", tag = "Catalog",
230230 responses((status = 200, body = ProductListResponse), (status = 500, body = ErrorResponse)))]
231231async fn list_products(
232232 State(state): State<AppState>,
233233) -> Result<Json<ProductListResponse>, ApiError> {
234234 let items: Vec<ProductResponse> = state
235235 .catalog
237237 .await?
238238 .into_iter()
240240 .collect();
241241 let total = items.len();
242242 Ok(Json(ProductListResponse { items, total }))
243243}
244244
245245#[utoipa::path(get, path = "/v1/products/{sku}", tag = "Catalog",
246246 params(("sku" = String, Path, description = "Product SKU, e.g. MUG-STONE")),
247247 responses((status = 200, body = ProductResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
248248async fn find_product(
249249 State(state): State<AppState>,
250250 Path(sku): Path<String>,
251251) -> Result<Json<ProductResponse>, ApiError> {
254254}
255255
256256#[utoipa::path(post, path = "/v1/carts", tag = "Cart",
257257 responses((status = 201, body = CartResponse), (status = 500, body = ErrorResponse)))]
258258async fn open_cart(
259259 State(state): State<AppState>,
260260) -> Result<(StatusCode, Json<CartResponse>), ApiError> {
262262 Ok((StatusCode::CREATED, Json(cart.into())))
263263}
264264
265265#[utoipa::path(get, path = "/v1/carts/{id}", tag = "Cart",
266266 params(("id" = String, Path, description = "Cart UUID")),
267267 responses((status = 200, body = CartResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
268268async fn find_cart(
269269 State(state): State<AppState>,
270270 Path(cart_id): Path<String>,
271271) -> Result<Json<CartResponse>, ApiError> {
274274}
275275
276276#[utoipa::path(post, path = "/v1/carts/{id}/items", tag = "Cart",
277277 params(("id" = String, Path, description = "Cart UUID")),
278278 request_body = AddCartItemRequest,
279279 responses((status = 200, body = CartResponse), (status = 404, body = ErrorResponse),
280280 (status = 409, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
281281async fn add_cart_item(
282282 State(state): State<AppState>,
283283 Path(cart_id): Path<String>,
284284 payload: Result<Json<AddCartItemRequest>, JsonRejection>,
285285) -> Result<Json<CartResponse>, ApiError> {
286286 let Json(request) = payload?;
290290 Ok(Json(
291291 state
292292 .carts
294294 .await?
295295 .into(),
296296 ))
297297}
298298
299299#[utoipa::path(post, path = "/v1/carts/{id}/checkout", tag = "Checkout",
300300 params(("id" = String, Path, description = "Cart UUID")),
301301 responses((status = 201, body = OrderResponse), (status = 404, body = ErrorResponse),
302302 (status = 409, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
303303async fn checkout(
304304 State(state): State<AppState>,
305305 Path(cart_id): Path<String>,
306306) -> Result<(StatusCode, Json<OrderResponse>), ApiError> {
309309 tracing::info!(order_id = order.id, cart_id = %order.cart_id, total_cents = order.total.cents(), "order placed");
310310 Ok((StatusCode::CREATED, Json(order.into())))
311311}
312312
313313#[derive(OpenApi)]
314314#[openapi(
…
341341)]
342342struct ApiDocument;
343343
344344async fn openapi() -> Json<utoipa::openapi::OpenApi> {
346346}
347347
348348pub fn router(state: AppState) -> Router {
349349 Router::new()
358358 .with_state(state)
359359}
360360
361361#[cfg(test)]
362362mod 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:?}"))?;
3333 let state = AppState {
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::merges_quantity_with_existing_line (0 ms)
- 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 application::tests::add_to_cart::rejects_quantity_beyond_stock (0 ms)
- Passed application::tests::catalog_service::returns_not_found_for_unknown_sku (0 ms)
- Passed application::tests::add_to_cart::rejects_unknown_cart (0 ms)
- Passed application::tests::catalog_service::keeps_a_rejected_domain_error (0 ms)
- Passed application::tests::catalog_service::surfaces_unavailable_storage (0 ms)
- Passed application::tests::add_to_cart::persists_merged_line (0 ms)
- Passed application::tests::checkout::does_not_place_order_for_empty_cart (0 ms)
- Passed domain::model::tests::add_to_cart::rejects_quantity_beyond_stock (0 ms)
- Passed application::tests::checkout::places_order_with_priced_total (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::applies_a_discount_percent_before_the_quantity (0 ms)
- Passed domain::model::tests::checkout::rejects_a_line_whose_product_is_missing (0 ms)
- Passed domain::model::tests::checkout::rejects_a_total_that_overflows (0 ms)
- Passed domain::model::tests::checkout::rejects_checked_out_cart (0 ms)
- Passed domain::model::tests::checkout::rejects_empty_cart (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)
- Failed application::tests::catalog_service::reports_a_price_mismatch (0 ms)
assertion `left == right` failed: listed price did not match the expected 1500 left: 1800 right: 1500
- 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::catalog_reports_storage_failure (0 ms)
- Passed infrastructure::web::tests::cart_storage_failure_is_a_server_error (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 / 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: 28/47 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
2323export class InvalidDiscountError extends Error {
2424 constructor(readonly percent: number) {
2525 super(`discount percent must be an integer from 0 through 100, got ${percent}`);
2626 this.name = "InvalidDiscountError";
2727 }
2828}
2929
3030const MINIMUM_QUANTITY = 1;
3131const MAXIMUM_QUANTITY = 99;
3232const CENTS_PER_UNIT = 100;
3333const FULL_DISCOUNT_PERCENT = 100;
3434
3535export function cents(amount: number): Cents {
3636 if (!Number.isSafeInteger(amount) || amount < 0) {
3838 }
3939 return amount as Cents;
4040}
4141
4242export function discountedUnitCents(listPriceCents: Cents, discountPercent: number): Cents {
4343 if (!Number.isInteger(discountPercent) || discountPercent < 0 || discountPercent > FULL_DISCOUNT_PERCENT) {
4545 }
4646 const reduction = Math.floor((listPriceCents * discountPercent) / FULL_DISCOUNT_PERCENT);
4848}
4949
5151 if (!Number.isInteger(line.quantity) || line.quantity < MINIMUM_QUANTITY || line.quantity > MAXIMUM_QUANTITY) {
5353 }
5555}
5656
5959}
6060
6161export function formatCents(amount: Cents, currency: string, locale: string): string {
6262 return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount / CENTS_PER_UNIT);
6363}
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 discountedUnitCents leaves a list price unchanged at zero percent (0 ms)
- Passed discountedUnitCents takes ten percent off before the quantity is applied (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 (6 ms)
- Passed formatCents formats dollars for a US locale (0 ms)
shop-ts-lib-cart/src/cart.ts: 42/47 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
2323export class InvalidDiscountError extends Error {
2424 constructor(readonly percent: number) {
2525 super(`discount percent must be an integer from 0 through 100, got ${percent}`);
2626 this.name = "InvalidDiscountError";
2727 }
2828}
2929
3030const MINIMUM_QUANTITY = 1;
3131const MAXIMUM_QUANTITY = 99;
3232const CENTS_PER_UNIT = 100;
3333const FULL_DISCOUNT_PERCENT = 100;
3434
3535export function cents(amount: number): Cents {
3636 if (!Number.isSafeInteger(amount) || amount < 0) {
3838 }
3939 return amount as Cents;
4040}
4141
4242export function discountedUnitCents(listPriceCents: Cents, discountPercent: number): Cents {
4343 if (!Number.isInteger(discountPercent) || discountPercent < 0 || discountPercent > FULL_DISCOUNT_PERCENT) {
4545 }
4646 const reduction = Math.floor((listPriceCents * discountPercent) / FULL_DISCOUNT_PERCENT);
4848}
4949
5151 if (!Number.isInteger(line.quantity) || line.quantity < MINIMUM_QUANTITY || line.quantity > MAXIMUM_QUANTITY) {
5353 }
5555}
5656
5959}
6060
6161export function formatCents(amount: Cents, currency: string, locale: string): string {
6262 return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount / CENTS_PER_UNIT);
6363}
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 (282 ms)
- Passed rejects a zero quantity (199 ms)
- Passed rejects checkout of an empty cart (190 ms)
- Passed checks out two mugs and a second empty cart (237 ms)
- Passed rejects a lamp quantity above the stock on hand (132 ms)
- Passed shows an unknown product when the catalog row disappears (200 ms)
shop-ts-lib-cart/src/cart.ts: 13/13 covered lines
runoldnew±codecov
2727 }
2828}
2929
3030const MINIMUM_QUANTITY = 1;
3131const MAXIMUM_QUANTITY = 99;
3232const CENTS_PER_UNIT = 100;
3333const FULL_DISCOUNT_PERCENT = 100;
3434
3535export function cents(amount: number): Cents {
3636 if (!Number.isSafeInteger(amount) || amount < 0) {
3737 throw new InvalidAmountError(amount);
3838 }
3939 return amount as Cents;
4040}
4141
4242export function discountedUnitCents(listPriceCents: Cents, discountPercent: number): Cents {
4343 if (!Number.isInteger(discountPercent) || discountPercent < 0 || discountPercent > FULL_DISCOUNT_PERCENT) {
4444 throw new InvalidDiscountError(discountPercent);
4545 }
4646 const reduction = Math.floor((listPriceCents * discountPercent) / FULL_DISCOUNT_PERCENT);
4848}
4949
5151 if (!Number.isInteger(line.quantity) || line.quantity < MINIMUM_QUANTITY || line.quantity > MAXIMUM_QUANTITY) {
5252 throw new InvalidQuantityError(line.sku, line.quantity);
5353 }
5555}
5656
5959}
6060
6161export function formatCents(amount: Cents, currency: string, locale: string): string {
6262 return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount / CENTS_PER_UNIT);
6363}
shop-ts-web-react/src/Shop.tsx: 49/52 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
1616 const discountPercent = product?.discountPercent ?? 0;
1717 return { sku: line.sku, quantity: line.quantity, unitPriceCents: discountedUnitCents(listPriceCents, discountPercent) };
1818}
1919
2020function messageOf(error: unknown): string {
2222 return "something went wrong";
2323}
2424
2929 const [quantities, setQuantities] = useState<Record<string, string>>({});
3030 const [error, setError] = useState<string | null>(null);
3131 const [busy, setBusy] = useState(false);
3232
3333 useEffect(() => {
3434 let active = true;
3636 .then((loaded) => {
3737 if (active) setProducts(loaded);
3838 })
3939 .catch((failure: unknown) => {
4141 });
4242 return () => {
4343 active = false;
4444 };
4545 }, [apiBase]);
4646
4848 const parsed = Number(quantities[product.sku] ?? "1");
4949 setBusy(true);
5050 setError(null);
5151 setOrder(null);
5252 try {
5555 setCart(updated);
5757 setProducts(refreshed);
5858 } catch (failure: unknown) {
6060 } finally {
6161 setBusy(false);
6262 }
6363 }
6464
6565 async function checkout() {
6666 setBusy(true);
6767 setError(null);
6868 try {
7171 setOrder(placed);
7272 setCart(null);
7474 } catch (failure: unknown) {
7676 } finally {
7777 setBusy(false);
7878 }
7979 }
8080
8383
8484 return (
8787 {products === null && error === null ? <p>Loading the catalog.</p> : null}
8888 {error ? <p role="alert">{error}</p> : null}
8989 <ul>
9090 {(products ?? []).map((product) => (
9191 <li key={product.sku}>
9292 <span>{product.name}</span>
9494 <span>{product.stock} in stock</span>
…
9797 <input
9898 aria-label={`Quantity for ${product.name}`}
9999 value={quantities[product.sku] ?? "1"}
100100 onChange={(event) => setQuantities({ ...quantities, [product.sku]: event.target.value })}
101101 />
102102 </label>
104104 Add {product.name}
105105 </button>
106106 </li>
…
111111 {lines.length === 0 ? <p>The cart is empty.</p> : null}
112112 <ul>
113113 {lines.map((line) => {
115115 return (
116116 <li key={line.sku}>
117117 <span>{product?.name ?? line.sku}</span>
118118 <span>{line.quantity}</span>
…
122122 })}
123123 </ul>
126126 Checkout
127127 </button>
128128 </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
2929 readonly code: string,
3030 message: string,
3131 ) {
3232 super(message);
3333 this.name = "ShopRequestError";
3434 }
3535}
3636
…
5555}
5656
5858 return {
5959 sku: body.sku,
6060 name: body.name,
6161 priceCents: body.price_cents,
…
6565}
6666
6868 return {
6969 id: body.id,
7070 status: body.status,
7171 lines: body.lines.map((line) => ({ sku: line.sku, quantity: line.quantity })),
7272 };
7373}
7474
7676 let code = "request_failed";
7777 let message = response.statusText;
7878 try {
7979 const body = (await response.json()) as { error?: { code?: string; message?: string } };
8080 if (body.error?.code) code = body.error.code;
8181 if (body.error?.message) message = body.error.message;
8282 } catch {
8383 message = "the shop could not read the response";
8484 }
8686}
8787
8888async function requestJson<T>(apiBase: string, path: string, init?: RequestInit): Promise<T> {
8989 let response: Response;
9090 try {
9191 response = await fetch(`${apiBase}${path}`, init);
9292 } catch {
9494 }
9696 return (await response.json()) as T;
9797}
9898
102102}
103103
106106}
107107
108108export async function addCartItem(apiBase: string, cartId: string, sku: string, quantity: number): Promise<Cart> {
111111 method: "POST",
112112 headers: { "content-type": "application/json" },
…
116116}
117117
119119 const body = await requestJson<OrderBody>(apiBase, `/v1/carts/${cartId}/checkout`, { method: "POST" });
120120 return { id: body.id, cartId: body.cart_id, totalCents: body.total_cents };
121121}
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 (40 ms)
- Passed Shop shows the catalog and an empty cart (27 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 (18 ms)
- Passed Shop shows a generic message when the catalog body is not a list (3 ms)
- Passed Shop shows a network failure (4 ms)
- Passed Shop adds two mugs and shows the line total (38 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 (31 ms)
- Passed Shop shows an empty-cart error and a second checkout error (18 ms)
- Passed Shop shows a zero quantity error (24 ms)
- Passed Shop shows an out of stock error (25 ms)
- Passed Shop shows an unreadable add failure (13 ms)
- Passed shop API maps catalog rows into products (2 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: 116/116 covered lines
runoldnew±codecov
11import { useEffect, useState } from "react";
22import { cartTotal, cents, discountedUnitCents, 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
1616 const discountPercent = product?.discountPercent ?? 0;
1717 return { sku: line.sku, quantity: line.quantity, unitPriceCents: discountedUnitCents(listPriceCents, discountPercent) };
1818}
1919
2020function messageOf(error: unknown): string {
2222 return "something went wrong";
2323}
2424
2929 const [quantities, setQuantities] = useState<Record<string, string>>({});
3030 const [error, setError] = useState<string | null>(null);
3131 const [busy, setBusy] = useState(false);
3232
3333 useEffect(() => {
3434 let active = true;
3636 .then((loaded) => {
3737 if (active) setProducts(loaded);
3838 })
3939 .catch((failure: unknown) => {
4141 });
4242 return () => {
4343 active = false;
4444 };
4545 }, [apiBase]);
4646
4848 const parsed = Number(quantities[product.sku] ?? "1");
4949 setBusy(true);
5050 setError(null);
5151 setOrder(null);
5252 try {
5555 setCart(updated);
5757 setProducts(refreshed);
5858 } catch (failure: unknown) {
6060 } finally {
6161 setBusy(false);
6262 }
6363 }
6464
6565 async function checkout() {
6666 setBusy(true);
6767 setError(null);
6868 try {
7171 setOrder(placed);
7272 setCart(null);
7474 } catch (failure: unknown) {
7676 } finally {
7777 setBusy(false);
7878 }
7979 }
8080
8383
8484 return (
8787 {products === null && error === null ? <p>Loading the catalog.</p> : null}
8888 {error ? <p role="alert">{error}</p> : null}
8989 <ul>
9090 {(products ?? []).map((product) => (
9191 <li key={product.sku}>
9292 <span>{product.name}</span>
9494 <span>{product.stock} in stock</span>
9595 <label>
9797 <input
9898 aria-label={`Quantity for ${product.name}`}
9999 value={quantities[product.sku] ?? "1"}
100100 onChange={(event) => setQuantities({ ...quantities, [product.sku]: event.target.value })}
101101 />
102102 </label>
104104 Add {product.name}
105105 </button>
106106 </li>
107107 ))}
108108 </ul>
109109 <section aria-label="Cart">
111111 {lines.length === 0 ? <p>The cart is empty.</p> : null}
112112 <ul>
113113 {lines.map((line) => {
115115 return (
116116 <li key={line.sku}>
117117 <span>{product?.name ?? line.sku}</span>
118118 <span>{line.quantity}</span>
120120 </li>
121121 );
122122 })}
123123 </ul>
126126 Checkout
127127 </button>
128128 </section>
129129 {order ? (
130130 <p role="status">
132132 </p>
133133 ) : null}
135135 );
136136}
137137
138138
shop-ts-web-react/src/mount.tsx: 5/5 covered lines
shop-ts-web-react/src/shopApi.ts: 68/68 covered lines
runoldnew±codecov
2323 readonly totalCents: number;
2424}
2525
2626export class ShopRequestError extends Error {
2727 constructor(
2828 readonly status: number,
2929 readonly code: string,
3030 message: string,
3131 ) {
3232 super(message);
3333 this.name = "ShopRequestError";
3434 }
3535}
3636
3737interface ProductBody {
3838 readonly sku: string;
…
5454 readonly total_cents: number;
5555}
5656
5858 return {
5959 sku: body.sku,
6060 name: body.name,
6161 priceCents: body.price_cents,
6262 discountPercent: body.discount_percent,
6363 stock: body.stock,
6464 };
6565}
6666
6868 return {
6969 id: body.id,
7070 status: body.status,
7171 lines: body.lines.map((line) => ({ sku: line.sku, quantity: line.quantity })),
7272 };
7373}
7474
7676 let code = "request_failed";
7777 let message = response.statusText;
7878 try {
7979 const body = (await response.json()) as { error?: { code?: string; message?: string } };
8080 if (body.error?.code) code = body.error.code;
8181 if (body.error?.message) message = body.error.message;
8282 } catch {
8383 message = "the shop could not read the response";
8484 }
8686}
8787
8888async function requestJson<T>(apiBase: string, path: string, init?: RequestInit): Promise<T> {
8989 let response: Response;
9090 try {
9191 response = await fetch(`${apiBase}${path}`, init);
9292 } catch {
9494 }
9696 return (await response.json()) as T;
9797}
9898
102102}
103103
106106}
107107
108108export async function addCartItem(apiBase: string, cartId: string, sku: string, quantity: number): Promise<Cart> {
111111 method: "POST",
112112 headers: { "content-type": "application/json" },
113113 body: JSON.stringify({ sku, quantity }),
114114 }),
115115 );
116116}
117117
119119 const body = await requestJson<OrderBody>(apiBase, `/v1/carts/${cartId}/checkout`, { method: "POST" });
120120 return { id: body.id, cartId: body.cart_id, totalCents: body.total_cents };
121121}
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 (470 ms)
- Passed empty checkout (375 ms)
- Passed cart lines (304 ms)
- Passed order placed (346 ms)
shop-ts-lib-cart/src/cart.ts: 13/13 covered lines
runoldnew±codecov
2727 }
2828}
2929
3030const MINIMUM_QUANTITY = 1;
3131const MAXIMUM_QUANTITY = 99;
3232const CENTS_PER_UNIT = 100;
3333const FULL_DISCOUNT_PERCENT = 100;
3434
3535export function cents(amount: number): Cents {
3636 if (!Number.isSafeInteger(amount) || amount < 0) {
3737 throw new InvalidAmountError(amount);
3838 }
3939 return amount as Cents;
4040}
4141
4242export function discountedUnitCents(listPriceCents: Cents, discountPercent: number): Cents {
4343 if (!Number.isInteger(discountPercent) || discountPercent < 0 || discountPercent > FULL_DISCOUNT_PERCENT) {
4444 throw new InvalidDiscountError(discountPercent);
4545 }
4646 const reduction = Math.floor((listPriceCents * discountPercent) / FULL_DISCOUNT_PERCENT);
4848}
4949
5151 if (!Number.isInteger(line.quantity) || line.quantity < MINIMUM_QUANTITY || line.quantity > MAXIMUM_QUANTITY) {
5252 throw new InvalidQuantityError(line.sku, line.quantity);
5353 }
5555}
5656
5959}
6060
6161export function formatCents(amount: Cents, currency: string, locale: string): string {
6262 return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount / CENTS_PER_UNIT);
6363}
shop-ts-web-react/src/Shop.tsx: 48/52 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
1616 const discountPercent = product?.discountPercent ?? 0;
1717 return { sku: line.sku, quantity: line.quantity, unitPriceCents: discountedUnitCents(listPriceCents, discountPercent) };
1818}
1919
2020function messageOf(error: unknown): string {
2222 return "something went wrong";
2323}
2424
2929 const [quantities, setQuantities] = useState<Record<string, string>>({});
3030 const [error, setError] = useState<string | null>(null);
3131 const [busy, setBusy] = useState(false);
3232
3333 useEffect(() => {
3434 let active = true;
3636 .then((loaded) => {
3737 if (active) setProducts(loaded);
3838 })
3939 .catch((failure: unknown) => {
4141 });
4242 return () => {
4343 active = false;
4444 };
4545 }, [apiBase]);
4646
4848 const parsed = Number(quantities[product.sku] ?? "1");
4949 setBusy(true);
5050 setError(null);
5151 setOrder(null);
5252 try {
5555 setCart(updated);
5757 setProducts(refreshed);
5858 } catch (failure: unknown) {
6060 } finally {
6161 setBusy(false);
6262 }
6363 }
6464
6565 async function checkout() {
6666 setBusy(true);
6767 setError(null);
6868 try {
7171 setOrder(placed);
7272 setCart(null);
7474 } catch (failure: unknown) {
7676 } finally {
7777 setBusy(false);
7878 }
7979 }
8080
8383
8484 return (
8787 {products === null && error === null ? <p>Loading the catalog.</p> : null}
8888 {error ? <p role="alert">{error}</p> : null}
8989 <ul>
9090 {(products ?? []).map((product) => (
9191 <li key={product.sku}>
9292 <span>{product.name}</span>
9494 <span>{product.stock} in stock</span>
…
9797 <input
9898 aria-label={`Quantity for ${product.name}`}
9999 value={quantities[product.sku] ?? "1"}
100100 onChange={(event) => setQuantities({ ...quantities, [product.sku]: event.target.value })}
101101 />
102102 </label>
104104 Add {product.name}
105105 </button>
106106 </li>
…
111111 {lines.length === 0 ? <p>The cart is empty.</p> : null}
112112 <ul>
113113 {lines.map((line) => {
115115 return (
116116 <li key={line.sku}>
117117 <span>{product?.name ?? line.sku}</span>
118118 <span>{line.quantity}</span>
…
122122 })}
123123 </ul>
126126 Checkout
127127 </button>
128128 </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
2929 readonly code: string,
3030 message: string,
3131 ) {
3232 super(message);
3333 this.name = "ShopRequestError";
3434 }
3535}
3636
…
5555}
5656
5858 return {
5959 sku: body.sku,
6060 name: body.name,
6161 priceCents: body.price_cents,
…
6565}
6666
6868 return {
6969 id: body.id,
7070 status: body.status,
7171 lines: body.lines.map((line) => ({ sku: line.sku, quantity: line.quantity })),
7272 };
7373}
7474
7676 let code = "request_failed";
7777 let message = response.statusText;
7878 try {
7979 const body = (await response.json()) as { error?: { code?: string; message?: string } };
8080 if (body.error?.code) code = body.error.code;
8181 if (body.error?.message) message = body.error.message;
8282 } catch {
8383 message = "the shop could not read the response";
8484 }
8686}
8787
8888async function requestJson<T>(apiBase: string, path: string, init?: RequestInit): Promise<T> {
8989 let response: Response;
9090 try {
9191 response = await fetch(`${apiBase}${path}`, init);
9292 } catch {
9494 }
9696 return (await response.json()) as T;
9797}
9898
102102}
103103
106106}
107107
108108export async function addCartItem(apiBase: string, cartId: string, sku: string, quantity: number): Promise<Cart> {
111111 method: "POST",
112112 headers: { "content-type": "application/json" },
…
116116}
117117
119119 const body = await requestJson<OrderBody>(apiBase, `/v1/carts/${cartId}/checkout`, { method: "POST" });
120120 return { id: body.id, cartId: body.cart_id, totalCents: body.total_cents };
121121}