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: 145/155 covered lines
runoldnew±codecov
2323}
2424
2525impl fmt::Display for DomainError {
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
5555impl Money {
5656 pub fn from_cents(cents: i64) -> Result<Self, DomainError> {
5757 if cents < 0 {
5858 return Err(DomainError::InvalidMoney);
5959 }
6060 Ok(Self(cents))
6161 }
6262 pub fn zero() -> Self {
6363 Self(0)
6464 }
6565 pub fn cents(self) -> i64 {
6666 self.0
6767 }
6868 pub fn times(self, quantity: Quantity) -> Result<Self, DomainError> {
6969 self.0
7070 .checked_mul(i64::from(quantity.value()))
7171 .map(Self)
7272 .ok_or(DomainError::AmountOverflow)
7373 }
7474 pub fn plus(self, other: Money) -> Result<Self, DomainError> {
7575 self.0
7676 .checked_add(other.0)
7777 .map(Self)
7878 .ok_or(DomainError::AmountOverflow)
7979 }
8080 pub fn discounted(self, percent: DiscountPercent) -> Self {
8181 let reduction =
8282 i128::from(self.0) * i128::from(percent.value()) / i128::from(MAXIMUM_DISCOUNT_PERCENT);
8383 Self(self.0 - reduction as i64)
8484 }
8585}
8686
8787#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
8888pub struct Sku(String);
8989
9090impl Sku {
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
109109impl DiscountPercent {
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) {
115115 Ok(value) if value <= MAXIMUM_DISCOUNT_PERCENT => Ok(Self(value)),
116116 _ => Err(DomainError::InvalidDiscount),
117117 }
118118 }
119119 pub fn value(self) -> u8 {
120120 self.0
121121 }
122122}
123123
124124impl fmt::Display for Sku {
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
133133impl Quantity {
134134 pub fn new(value: i64) -> Result<Self, DomainError> {
135135 u32::try_from(value)
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
149149impl CartId {
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
169169impl fmt::Display for CartId {
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 {
185185 pub fn ensure_available(&self, quantity: Quantity) -> Result<(), DomainError> {
186186 if quantity.value() > self.stock {
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 {
235235 pub fn add(&self, product: &Product, quantity: Quantity) -> Result<CartLine, DomainError> {
236236 self.ensure_open()?;
237237 let existing = self
238238 .lines
239239 .iter()
240240 .find(|line| line.sku == product.sku)
241241 .map_or(0, |line| line.quantity.value());
242242 let merged = Quantity::new(i64::from(existing) + i64::from(quantity.value()))?;
243243 product.ensure_available(merged)?;
244244 Ok(CartLine {
245245 sku: product.sku.clone(),
246246 quantity: merged,
247247 })
248248 }
249249 pub fn checkout(&self, products: &[Product]) -> Result<OrderDraft, DomainError> {
250250 self.ensure_open()?;
251251 if self.lines.is_empty() {
252252 return Err(DomainError::EmptyCart);
253253 }
254254 let lines = self
255255 .lines
256256 .iter()
257257 .map(|line| Self::price_line(line, products))
258258 .collect::<Result<Vec<_>, _>>()?;
259259 let total = lines.iter().try_fold(Money::zero(), |total, line| {
260260 total.plus(line.unit_price.times(line.quantity)?)
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 }
274274 fn price_line(line: &CartLine, products: &[Product]) -> Result<OrderLine, DomainError> {
275275 let product = products
276276 .iter()
277277 .find(|product| product.sku == line.sku)
278278 .ok_or_else(|| DomainError::ProductNotFound(line.sku.clone()))?;
279279 product.ensure_available(line.quantity)?;
280280 Ok(OrderLine {
281281 sku: line.sku.clone(),
282282 quantity: line.quantity,
283283 unit_price: product.price.discounted(product.discount_percent),
284284 })
285285 }
286286}
287287
288288#[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: 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> {
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 discount_percent: i32 = checked(row.try_get("discount_percent"), "decode_product")?;
4444 let stock: i32 = checked(row.try_get("stock"), "decode_product")?;
4545 product_from_parts(&sku, name, price_cents, i64::from(discount_percent), stock)
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 {
5656 sku: Sku::parse(sku).map_err(RepositoryError::Rejected)?,
5757 name,
5858 price: Money::from_cents(price_cents).map_err(RepositoryError::Rejected)?,
5959 discount_percent: DiscountPercent::parse(discount_percent)
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]
7777impl ProductRepository for PostgresProductRepository {
7878 async fn list(&self) -> Result<Vec<Product>, RepositoryError> {
7979 let rows = checked(
8080 sqlx::query(
8181 r#"SELECT SKU, NAME, PRICE_CENTS, DISCOUNT_PERCENT, STOCK FROM product ORDER BY SKU"#,
…
8585 "list_products",
8686 )?;
8787 rows.iter().map(product_from_row).collect()
8888 }
8989 async fn find_by_sku(&self, sku: &Sku) -> Result<Option<Product>, RepositoryError> {
9090 let row = checked(
9191 sqlx::query(
9292 r#"SELECT SKU, NAME, PRICE_CENTS, DISCOUNT_PERCENT, STOCK FROM product WHERE SKU = $1"#,
…
9797 "find_product",
9898 )?;
9999 row.as_ref().map(product_from_row).transpose()
100100 }
101101 async fn find_by_skus(&self, skus: &[Sku]) -> Result<Vec<Product>, RepositoryError> {
102102 let values: Vec<String> = skus.iter().map(|sku| sku.as_str().to_owned()).collect();
103103 let rows = checked(
104104 sqlx::query(
105105 r#"SELECT SKU, NAME, PRICE_CENTS, DISCOUNT_PERCENT, STOCK FROM product WHERE SKU = ANY($1)"#,
…
110110 "find_products",
111111 )?;
112112 rows.iter().map(product_from_row).collect()
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 {
128128 CART_STATUS_OPEN => Ok(CartStatus::Open),
129129 CART_STATUS_CHECKED_OUT => Ok(CartStatus::CheckedOut),
130130 other => {
131131 tracing::error!(upstream = "postgres", status = other, "unknown cart status");
132132 Err(RepositoryError::Unavailable("decode_cart".to_owned()))
133133 }
134134 }
135135}
136136
137137fn cart_line_from_row(row: &PgRow) -> Result<CartLine, RepositoryError> {
138138 let sku: String = checked(row.try_get("sku"), "decode_cart_line")?;
139139 let quantity: i32 = checked(row.try_get("quantity"), "decode_cart_line")?;
140140 cart_line_from_parts(&sku, quantity)
141141}
142142
143143fn cart_line_from_parts(sku: &str, quantity: i32) -> Result<CartLine, RepositoryError> {
144144 Ok(CartLine {
145145 sku: Sku::parse(sku).map_err(RepositoryError::Rejected)?,
146146 quantity: Quantity::new(i64::from(quantity)).map_err(RepositoryError::Rejected)?,
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
162162fn reserve_outcome(rows_affected: u64, sku: &Sku) -> Result<(), RepositoryError> {
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]
173173impl CartRepository for PostgresCartRepository {
174174 async fn create(&self) -> Result<Cart, RepositoryError> {
175175 let row = checked(
176176 sqlx::query(r#"INSERT INTO cart (STATUS) VALUES ($1) RETURNING ID::text AS id"#)
177177 .bind(CART_STATUS_OPEN)
…
185185 status: CartStatus::Open,
186186 lines: Vec::new(),
187187 })
188188 }
189189 async fn find(&self, cart_id: &CartId) -> Result<Option<Cart>, RepositoryError> {
190190 let Some(row) = checked(
191191 sqlx::query(r#"SELECT STATUS FROM cart WHERE ID = $1::uuid"#)
192192 .bind(cart_id.as_str())
…
215215 .map(cart_line_from_row)
216216 .collect::<Result<_, _>>()?,
217217 }))
218218 }
219219 async fn save_line(&self, cart_id: &CartId, line: &CartLine) -> Result<(), RepositoryError> {
220220 let quantity = quantity_column(line.quantity.value())?;
221221 checked(
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]
248248impl OrderRepository for PostgresOrderRepository {
249249 async fn place(&self, draft: &OrderDraft) -> Result<Order, RepositoryError> {
250250 let mut transaction = checked(self.pool.begin().await, "begin_order")?;
251251 let closed = checked(
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
5757impl From<CartLine> for CartLineResponse {
5858 fn from(line: CartLine) -> Self {
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(),
9494 lines: cart.lines.into_iter().map(CartLineResponse::from).collect(),
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()
132132 .map(OrderLineResponse::from)
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 };
220220 (self.status, Json(body)).into_response()
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
236236 .list_products()
237237 .await?
238238 .into_iter()
239239 .map(ProductResponse::from)
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> {
252252 let sku = Sku::parse(&sku)?;
253253 Ok(Json(state.catalog.find_product(&sku).await?.into()))
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> {
261261 let cart = state.carts.open_cart().await?;
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> {
272272 let cart_id = CartId::parse(&cart_id)?;
273273 Ok(Json(state.carts.find_cart(&cart_id).await?.into()))
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?;
287287 let cart_id = CartId::parse(&cart_id)?;
288288 let sku = Sku::parse(&request.sku)?;
289289 let quantity = Quantity::new(request.quantity)?;
290290 Ok(Json(
291291 state
292292 .carts
293293 .add_to_cart(&cart_id, &sku, quantity)
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> {
307307 let cart_id = CartId::parse(&cart_id)?;
308308 let order = state.carts.checkout(&cart_id).await?;
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> {
345345 Json(ApiDocument::openapi())
346346}
347347
348348pub fn router(state: AppState) -> Router {
349349 Router::new()
350350 .route("/health", get(health))
351351 .route("/openapi.json", get(openapi))
352352 .route("/v1/products", get(list_products))
353353 .route("/v1/products/{sku}", get(find_product))
354354 .route("/v1/carts", post(open_cart))
355355 .route("/v1/carts/{id}", get(find_cart))
356356 .route("/v1/carts/{id}/items", post(add_cart_item))
357357 .route("/v1/carts/{id}/checkout", post(checkout))
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();
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: 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) {
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);
4747 return cents(listPriceCents - reduction);
4848}
4949
5050export function lineTotal(line: CartLine): Cents {
5151 if (!Number.isInteger(line.quantity) || line.quantity < MINIMUM_QUANTITY || line.quantity > MAXIMUM_QUANTITY) {
5252 throw new InvalidQuantityError(line.sku, line.quantity);
5353 }
5454 return cents(line.unitPriceCents * line.quantity);
5555}
5656
5757export function cartTotal(lines: readonly CartLine[]): Cents {
5858 return lines.reduce<Cents>((total, line) => cents(total + lineTotal(line)), cents(0));
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
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) {
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);
4747 return cents(listPriceCents - reduction);
4848}
4949
5050export function lineTotal(line: CartLine): Cents {
5151 if (!Number.isInteger(line.quantity) || line.quantity < MINIMUM_QUANTITY || line.quantity > MAXIMUM_QUANTITY) {
5252 throw new InvalidQuantityError(line.sku, line.quantity);
5353 }
5454 return cents(line.unitPriceCents * line.quantity);
5555}
5656
5757export function cartTotal(lines: readonly CartLine[]): Cents {
5858 return lines.reduce<Cents>((total, line) => cents(total + lineTotal(line)), cents(0));
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
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);
4747 return cents(listPriceCents - reduction);
4848}
4949
5050export function lineTotal(line: CartLine): Cents {
5151 if (!Number.isInteger(line.quantity) || line.quantity < MINIMUM_QUANTITY || line.quantity > MAXIMUM_QUANTITY) {
5252 throw new InvalidQuantityError(line.sku, line.quantity);
5353 }
5454 return cents(line.unitPriceCents * line.quantity);
5555}
5656
5757export function cartTotal(lines: readonly CartLine[]): Cents {
5858 return lines.reduce<Cents>((total, line) => cents(total + lineTotal(line)), cents(0));
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
1313function pricedLine(line: Cart["lines"][number], products: readonly Product[]): PricedLine {
1414 const product = products.find((candidate) => candidate.sku === line.sku);
1515 const listPriceCents = cents(product?.priceCents ?? 0);
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 {
2121 if (error instanceof ShopRequestError) return error.message;
2222 return "something went wrong";
2323}
2424
2525export function Shop({ apiBase }: Props) {
2626 const [products, setProducts] = useState<readonly Product[] | null>(null);
2727 const [cart, setCart] = useState<Cart | null>(null);
2828 const [order, setOrder] = useState<Order | null>(null);
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;
3535 listProducts(apiBase)
3636 .then((loaded) => {
3737 if (active) setProducts(loaded);
3838 })
3939 .catch((failure: unknown) => {
4040 if (active) setError(messageOf(failure));
4141 });
4242 return () => {
4343 active = false;
4444 };
4545 }, [apiBase]);
4646
4747 async function add(product: Product) {
4848 const parsed = Number(quantities[product.sku] ?? "1");
4949 setBusy(true);
5050 setError(null);
5151 setOrder(null);
5252 try {
5353 const current = cart ?? (await openCart(apiBase));
5454 const updated = await addCartItem(apiBase, current.id, product.sku, parsed);
5555 setCart(updated);
5656 const refreshed = await listProducts(apiBase);
5757 setProducts(refreshed);
5858 } catch (failure: unknown) {
5959 setError(messageOf(failure));
6060 } finally {
6161 setBusy(false);
6262 }
6363 }
6464
6565 async function checkout() {
6666 setBusy(true);
6767 setError(null);
6868 try {
6969 const current = cart ?? (await openCart(apiBase));
7070 const placed = await checkoutCart(apiBase, current.id);
7171 setOrder(placed);
7272 setCart(null);
7373 setProducts(await listProducts(apiBase));
7474 } catch (failure: unknown) {
7575 setError(messageOf(failure));
7676 } finally {
7777 setBusy(false);
7878 }
7979 }
8080
8181 const lines = cart && products ? cart.lines.map((line) => pricedLine(line, products)) : [];
8282 const total = lines.length > 0 ? cartTotal(lines) : cents(0);
8383
8484 return (
8585 <main aria-busy={busy}>
8686 <h1>Shop</h1>
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>
9393 <span>{formatCents(cents(product.priceCents), CURRENCY, LOCALE)}</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>
103103 <button type="button" disabled={busy} onClick={() => void add(product)}>
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) => {
114114 const product = products?.find((candidate) => candidate.sku === line.sku);
115115 return (
116116 <li key={line.sku}>
117117 <span>{product?.name ?? line.sku}</span>
118118 <span>{line.quantity}</span>
…
122122 })}
123123 </ul>
124124 <p>Total {formatCents(total, CURRENCY, LOCALE)}</p>
125125 <button type="button" disabled={busy} onClick={() => void checkout()}>
126126 Checkout
127127 </button>
128128 </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
2929 readonly code: string,
3030 message: string,
3131 ) {
3232 super(message);
3333 this.name = "ShopRequestError";
3434 }
3535}
3636
…
5555}
5656
5757function productFromBody(body: ProductBody): Product {
5858 return {
5959 sku: body.sku,
6060 name: body.name,
6161 priceCents: body.price_cents,
…
6565}
6666
6767function cartFromBody(body: CartBody): Cart {
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
7575async function readError(response: Response): Promise<ShopRequestError> {
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 }
8585 return new ShopRequestError(response.status, code, message);
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 {
9393 throw new ShopRequestError(0, "network", "the shop API is unreachable");
9494 }
9595 if (!response.ok) throw await readError(response);
9696 return (await response.json()) as T;
9797}
9898
9999export async function listProducts(apiBase: string): Promise<readonly Product[]> {
100100 const body = await requestJson<{ items: ProductBody[] }>(apiBase, "/v1/products");
101101 return body.items.map(productFromBody);
102102}
103103
104104export async function openCart(apiBase: string): Promise<Cart> {
105105 return cartFromBody(await requestJson<CartBody>(apiBase, "/v1/carts", { method: "POST" }));
106106}
107107
108108export async function addCartItem(apiBase: string, cartId: string, sku: string, quantity: number): Promise<Cart> {
109109 return cartFromBody(
110110 await requestJson<CartBody>(apiBase, `/v1/carts/${cartId}/items`, {
111111 method: "POST",
112112 headers: { "content-type": "application/json" },
…
116116}
117117
118118export async function checkoutCart(apiBase: string, cartId: string): Promise<Order> {
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
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
1313function pricedLine(line: Cart["lines"][number], products: readonly Product[]): PricedLine {
1414 const product = products.find((candidate) => candidate.sku === line.sku);
1515 const listPriceCents = cents(product?.priceCents ?? 0);
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 {
2121 if (error instanceof ShopRequestError) return error.message;
2222 return "something went wrong";
2323}
2424
2525export function Shop({ apiBase }: Props) {
2626 const [products, setProducts] = useState<readonly Product[] | null>(null);
2727 const [cart, setCart] = useState<Cart | null>(null);
2828 const [order, setOrder] = useState<Order | null>(null);
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;
3535 listProducts(apiBase)
3636 .then((loaded) => {
3737 if (active) setProducts(loaded);
3838 })
3939 .catch((failure: unknown) => {
4040 if (active) setError(messageOf(failure));
4141 });
4242 return () => {
4343 active = false;
4444 };
4545 }, [apiBase]);
4646
4747 async function add(product: Product) {
4848 const parsed = Number(quantities[product.sku] ?? "1");
4949 setBusy(true);
5050 setError(null);
5151 setOrder(null);
5252 try {
5353 const current = cart ?? (await openCart(apiBase));
5454 const updated = await addCartItem(apiBase, current.id, product.sku, parsed);
5555 setCart(updated);
5656 const refreshed = await listProducts(apiBase);
5757 setProducts(refreshed);
5858 } catch (failure: unknown) {
5959 setError(messageOf(failure));
6060 } finally {
6161 setBusy(false);
6262 }
6363 }
6464
6565 async function checkout() {
6666 setBusy(true);
6767 setError(null);
6868 try {
6969 const current = cart ?? (await openCart(apiBase));
7070 const placed = await checkoutCart(apiBase, current.id);
7171 setOrder(placed);
7272 setCart(null);
7373 setProducts(await listProducts(apiBase));
7474 } catch (failure: unknown) {
7575 setError(messageOf(failure));
7676 } finally {
7777 setBusy(false);
7878 }
7979 }
8080
8181 const lines = cart && products ? cart.lines.map((line) => pricedLine(line, products)) : [];
8282 const total = lines.length > 0 ? cartTotal(lines) : cents(0);
8383
8484 return (
8585 <main aria-busy={busy}>
8686 <h1>Shop</h1>
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>
9393 <span>{formatCents(cents(product.priceCents), CURRENCY, LOCALE)}</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>
103103 <button type="button" disabled={busy} onClick={() => void add(product)}>
104104 Add {product.name}
105105 </button>
106106 </li>
107107 ))}
108108 </ul>
109109 <section aria-label="Cart">
110110 <h2>Cart</h2>
111111 {lines.length === 0 ? <p>The cart is empty.</p> : null}
112112 <ul>
113113 {lines.map((line) => {
114114 const product = products?.find((candidate) => candidate.sku === line.sku);
115115 return (
116116 <li key={line.sku}>
117117 <span>{product?.name ?? line.sku}</span>
118118 <span>{line.quantity}</span>
119119 <span>{formatCents(lineTotal(line), CURRENCY, LOCALE)}</span>
120120 </li>
121121 );
122122 })}
123123 </ul>
124124 <p>Total {formatCents(total, CURRENCY, LOCALE)}</p>
125125 <button type="button" disabled={busy} onClick={() => void checkout()}>
126126 Checkout
127127 </button>
128128 </section>
129129 {order ? (
130130 <p role="status">
131131 Order {order.id} placed for {formatCents(cents(order.totalCents), CURRENCY, LOCALE)}
132132 </p>
133133 ) : null}
134134 </main>
135135 );
136136}
137137
138138
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: 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
5757function productFromBody(body: ProductBody): Product {
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
6767function cartFromBody(body: CartBody): Cart {
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
7575async function readError(response: Response): Promise<ShopRequestError> {
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 }
8585 return new ShopRequestError(response.status, code, message);
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 {
9393 throw new ShopRequestError(0, "network", "the shop API is unreachable");
9494 }
9595 if (!response.ok) throw await readError(response);
9696 return (await response.json()) as T;
9797}
9898
9999export async function listProducts(apiBase: string): Promise<readonly Product[]> {
100100 const body = await requestJson<{ items: ProductBody[] }>(apiBase, "/v1/products");
101101 return body.items.map(productFromBody);
102102}
103103
104104export async function openCart(apiBase: string): Promise<Cart> {
105105 return cartFromBody(await requestJson<CartBody>(apiBase, "/v1/carts", { method: "POST" }));
106106}
107107
108108export async function addCartItem(apiBase: string, cartId: string, sku: string, quantity: number): Promise<Cart> {
109109 return cartFromBody(
110110 await requestJson<CartBody>(apiBase, `/v1/carts/${cartId}/items`, {
111111 method: "POST",
112112 headers: { "content-type": "application/json" },
113113 body: JSON.stringify({ sku, quantity }),
114114 }),
115115 );
116116}
117117
118118export async function checkoutCart(apiBase: string, cartId: string): Promise<Order> {
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
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);
4747 return cents(listPriceCents - reduction);
4848}
4949
5050export function lineTotal(line: CartLine): Cents {
5151 if (!Number.isInteger(line.quantity) || line.quantity < MINIMUM_QUANTITY || line.quantity > MAXIMUM_QUANTITY) {
5252 throw new InvalidQuantityError(line.sku, line.quantity);
5353 }
5454 return cents(line.unitPriceCents * line.quantity);
5555}
5656
5757export function cartTotal(lines: readonly CartLine[]): Cents {
5858 return lines.reduce<Cents>((total, line) => cents(total + lineTotal(line)), cents(0));
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
1313function pricedLine(line: Cart["lines"][number], products: readonly Product[]): PricedLine {
1414 const product = products.find((candidate) => candidate.sku === line.sku);
1515 const listPriceCents = cents(product?.priceCents ?? 0);
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 {
2121 if (error instanceof ShopRequestError) return error.message;
2222 return "something went wrong";
2323}
2424
2525export function Shop({ apiBase }: Props) {
2626 const [products, setProducts] = useState<readonly Product[] | null>(null);
2727 const [cart, setCart] = useState<Cart | null>(null);
2828 const [order, setOrder] = useState<Order | null>(null);
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;
3535 listProducts(apiBase)
3636 .then((loaded) => {
3737 if (active) setProducts(loaded);
3838 })
3939 .catch((failure: unknown) => {
4040 if (active) setError(messageOf(failure));
4141 });
4242 return () => {
4343 active = false;
4444 };
4545 }, [apiBase]);
4646
4747 async function add(product: Product) {
4848 const parsed = Number(quantities[product.sku] ?? "1");
4949 setBusy(true);
5050 setError(null);
5151 setOrder(null);
5252 try {
5353 const current = cart ?? (await openCart(apiBase));
5454 const updated = await addCartItem(apiBase, current.id, product.sku, parsed);
5555 setCart(updated);
5656 const refreshed = await listProducts(apiBase);
5757 setProducts(refreshed);
5858 } catch (failure: unknown) {
5959 setError(messageOf(failure));
6060 } finally {
6161 setBusy(false);
6262 }
6363 }
6464
6565 async function checkout() {
6666 setBusy(true);
6767 setError(null);
6868 try {
6969 const current = cart ?? (await openCart(apiBase));
7070 const placed = await checkoutCart(apiBase, current.id);
7171 setOrder(placed);
7272 setCart(null);
7373 setProducts(await listProducts(apiBase));
7474 } catch (failure: unknown) {
7575 setError(messageOf(failure));
7676 } finally {
7777 setBusy(false);
7878 }
7979 }
8080
8181 const lines = cart && products ? cart.lines.map((line) => pricedLine(line, products)) : [];
8282 const total = lines.length > 0 ? cartTotal(lines) : cents(0);
8383
8484 return (
8585 <main aria-busy={busy}>
8686 <h1>Shop</h1>
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>
9393 <span>{formatCents(cents(product.priceCents), CURRENCY, LOCALE)}</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>
103103 <button type="button" disabled={busy} onClick={() => void add(product)}>
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) => {
114114 const product = products?.find((candidate) => candidate.sku === line.sku);
115115 return (
116116 <li key={line.sku}>
117117 <span>{product?.name ?? line.sku}</span>
118118 <span>{line.quantity}</span>
…
122122 })}
123123 </ul>
124124 <p>Total {formatCents(total, CURRENCY, LOCALE)}</p>
125125 <button type="button" disabled={busy} onClick={() => void checkout()}>
126126 Checkout
127127 </button>
128128 </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
2929 readonly code: string,
3030 message: string,
3131 ) {
3232 super(message);
3333 this.name = "ShopRequestError";
3434 }
3535}
3636
…
5555}
5656
5757function productFromBody(body: ProductBody): Product {
5858 return {
5959 sku: body.sku,
6060 name: body.name,
6161 priceCents: body.price_cents,
…
6565}
6666
6767function cartFromBody(body: CartBody): Cart {
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
7575async function readError(response: Response): Promise<ShopRequestError> {
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 }
8585 return new ShopRequestError(response.status, code, message);
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 {
9393 throw new ShopRequestError(0, "network", "the shop API is unreachable");
9494 }
9595 if (!response.ok) throw await readError(response);
9696 return (await response.json()) as T;
9797}
9898
9999export async function listProducts(apiBase: string): Promise<readonly Product[]> {
100100 const body = await requestJson<{ items: ProductBody[] }>(apiBase, "/v1/products");
101101 return body.items.map(productFromBody);
102102}
103103
104104export async function openCart(apiBase: string): Promise<Cart> {
105105 return cartFromBody(await requestJson<CartBody>(apiBase, "/v1/carts", { method: "POST" }));
106106}
107107
108108export async function addCartItem(apiBase: string, cartId: string, sku: string, quantity: number): Promise<Cart> {
109109 return cartFromBody(
110110 await requestJson<CartBody>(apiBase, `/v1/carts/${cartId}/items`, {
111111 method: "POST",
112112 headers: { "content-type": "application/json" },
…
116116}
117117
118118export async function checkoutCart(apiBase: string, cartId: string): Promise<Order> {
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}