Back to report

Tests and coverage by phase

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

shop / shop-rs-api / integration

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

shop / shop-rs-api / unit

adapter.stdout tests.stderr tests.stdout adapter.stderr

shop / shop-ts-lib-cart / integration

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

shop / shop-ts-lib-cart / unit

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

shop / shop-ts-web-react / functional

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

shop / shop-ts-web-react / unit

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

shop / shop-ts-web-react / visual

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