cherry-test FAIL | pkg 2/3 pkg-fail:1 | tests run:106 skip:0 fail:1 ok:99% | all:92.8% unit:97.9% it:88.0% | diff:721/776 · 721 cov · 55 unc · 92.9%

projects

projectpackageunitunit_fnitit_fnmergedstatus
shopshop-rs-api--476/526 · 476 cov · 50 unc · 90.5%113/137 · 82.5%476/526 · 476 cov · 50 unc · 90.5%FAIL
shopshop-ts-lib-cart42/47 · 42 cov · 5 unc · 89.4%7/8 · 87.5%28/47 · 28 cov · 19 unc · 59.6%4/8 · 50.0%42/47 · 42 cov · 5 unc · 89.4%ok
shopshop-ts-web-react189/189 · 189 cov · 0 unc · 100.0%17/17 · 100.0%--203/203 · 203 cov · 0 unc · 100.0%ok
ALL(changed)231/236 · 231 cov · 5 unc · 97.9%24/25 · 96.0%504/573 · 504 cov · 69 unc · 88.0%117/145 · 80.7%721/776 · 721 cov · 55 unc · 92.9%FAIL

Test phases, screenshots, logs and source coverage

tests

shop106 run · 105 ok · 1 fail · 0 skip · 3106ms total · 83ms avg · 1ms fastest · 470ms slowest
cart4 run · 4 ok · 0 fail · 0 skip · 58ms total · 19ms avg · 1ms fastest · 53ms slowest
adds_line_to_cart · scope shop_checkout · target shop.apiok · 53ms
8383 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8484 response.assert_status(422);
8585 assert_eq!(
8686 response.response_body_json()?["error"]["code"],
8787 "invalid_quantity"
8888 );
8989}
9090
9191#[cherry_test(scope = "shop_checkout")]
9292async fn shop_project_cart_suite_rejects_quantity_beyond_stock_test(env: TestEnv) {
9393 let cart_id = open_cart(&env).await?;
9494 let response = add_item(&env, &cart_id, "LAMP-DESK", 3).await?;
9595 response.assert_status(409);
9696 assert_eq!(
9797 response.response_body_json()?["error"]["code"],
9898 "out_of_stock"
9999 );
100100}
101101
102102#[cherry_test(scope = "shop_checkout")]
103103async fn shop_project_cart_suite_adds_line_to_cart_test(env: TestEnv) {
104104 let cart_id = open_cart(&env).await?;
105105 let response = add_item(&env, &cart_id, "MUG-STONE", 2).await?;
106106 response.assert_status(200);
107107 let cart = env
108108 .project("shop")
109109 .service("api")?
110110 .get(&format!("/v1/carts/{cart_id}"))
111111 .send()
112112 .await;
113113 cart.assert_status(200);
114114 let body = cart.response_body_json()?;
115115 assert_eq!(body["status"], "open");
116116 assert_eq!(body["lines"][0]["sku"], "MUG-STONE");
117117 assert_eq!(body["lines"][0]["quantity"], 2);
118118}
119119
120120#[cherry_test(scope = "shop_checkout")]
121121async fn shop_project_checkout_suite_places_order_and_decrements_stock_test(env: TestEnv) {
122122 let stock_before = stock_of(&env, "NB-DOT-A5").await?;
123123 let cart_id = open_cart(&env).await?;
rejects_quantity_beyond_stock · scope shop_checkout · target shop.apiok · 4ms
7272 .await;
7373 response.assert_status(404);
7474 assert_eq!(
7575 response.response_body_json()?["error"]["code"],
7676 "product_not_found"
7777 );
7878}
7979
8080#[cherry_test(scope = "shop_checkout")]
8181async fn shop_project_cart_suite_rejects_zero_quantity_test(env: TestEnv) {
8282 let cart_id = open_cart(&env).await?;
8383 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8484 response.assert_status(422);
8585 assert_eq!(
8686 response.response_body_json()?["error"]["code"],
8787 "invalid_quantity"
8888 );
8989}
9090
9191#[cherry_test(scope = "shop_checkout")]
9292async fn shop_project_cart_suite_rejects_quantity_beyond_stock_test(env: TestEnv) {
9393 let cart_id = open_cart(&env).await?;
9494 let response = add_item(&env, &cart_id, "LAMP-DESK", 3).await?;
9595 response.assert_status(409);
9696 assert_eq!(
9797 response.response_body_json()?["error"]["code"],
9898 "out_of_stock"
9999 );
100100}
101101
102102#[cherry_test(scope = "shop_checkout")]
103103async fn shop_project_cart_suite_adds_line_to_cart_test(env: TestEnv) {
104104 let cart_id = open_cart(&env).await?;
105105 let response = add_item(&env, &cart_id, "MUG-STONE", 2).await?;
106106 response.assert_status(200);
107107 let cart = env
108108 .project("shop")
109109 .service("api")?
110110 .get(&format!("/v1/carts/{cart_id}"))
111111 .send()
112112 .await;
rejects_zero_quantity · scope shop_checkout · target shop.apiok · 1ms
6161 assert_eq!(body["items"][1]["sku"], "MUG-STONE");
6262 assert_eq!(body["items"][1]["discount_percent"], 10);
6363}
6464
6565#[cherry_test(scope = "shop_checkout")]
6666async fn shop_project_catalog_suite_unknown_sku_not_found_test(env: TestEnv) {
6767 let response = env
6868 .project("shop")
6969 .service("api")?
7070 .get("/v1/products/NOPE-0000")
7171 .send()
7272 .await;
7373 response.assert_status(404);
7474 assert_eq!(
7575 response.response_body_json()?["error"]["code"],
7676 "product_not_found"
7777 );
7878}
7979
8080#[cherry_test(scope = "shop_checkout")]
8181async fn shop_project_cart_suite_rejects_zero_quantity_test(env: TestEnv) {
8282 let cart_id = open_cart(&env).await?;
8383 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8484 response.assert_status(422);
8585 assert_eq!(
8686 response.response_body_json()?["error"]["code"],
8787 "invalid_quantity"
8888 );
8989}
9090
9191#[cherry_test(scope = "shop_checkout")]
9292async fn shop_project_cart_suite_rejects_quantity_beyond_stock_test(env: TestEnv) {
9393 let cart_id = open_cart(&env).await?;
9494 let response = add_item(&env, &cart_id, "LAMP-DESK", 3).await?;
9595 response.assert_status(409);
9696 assert_eq!(
9797 response.response_body_json()?["error"]["code"],
9898 "out_of_stock"
9999 );
100100}
101101
unknown_cart_not_found · scope shop_checkout · target shop.apiok · 0ms
179179}
180180
181181#[cherry_test(scope = "shop_checkout")]
182182async fn shop_project_catalog_suite_serves_openapi_test(env: TestEnv) {
183183 let response = env
184184 .project("shop")
185185 .service("api")?
186186 .get("/openapi.json")
187187 .send()
188188 .await;
189189 response.assert_status(200);
190190 assert!(
191191 response
192192 .response_body_text()
193193 .contains("\"title\":\"shop-rs-api\"")
194194 || response.response_body_text().contains("shop-rs-api")
195195 );
196196}
197197
198198#[cherry_test(scope = "shop_checkout")]
199199async fn shop_project_cart_suite_unknown_cart_not_found_test(env: TestEnv) {
200200 let response = env
201201 .project("shop")
202202 .service("api")?
203203 .get("/v1/carts/0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0d")
204204 .send()
205205 .await;
206206 response.assert_status(404);
207207 assert_eq!(
208208 response.response_body_json()?["error"]["code"],
209209 "cart_not_found"
210210 );
211211}
212212
213213#[cherry_test(scope = "shop_checkout")]
214214async fn shop_project_checkout_suite_rejects_empty_cart_test(env: TestEnv) {
215215 let cart_id = open_cart(&env).await?;
216216 let response = env
217217 .project("shop")
218218 .service("api")?
219219 .post(&format!("/v1/carts/{cart_id}/checkout"))
catalog4 run · 4 ok · 0 fail · 0 skip · 2ms total · 1ms avg · 1ms fastest · 1ms slowest
lists_seeded_products · scope shop_checkout · target shop.apiok · 1ms
2828 .post(&format!("/v1/carts/{cart_id}/items"))
2929 .json_body(&body)?
3030 .send()
3131 .await)
3232}
3333
3434async fn stock_of(env: &TestEnv, sku: &str) -> anyhow::Result<i64> {
3535 let response = env
3636 .project("shop")
3737 .service("api")?
3838 .get(&format!("/v1/products/{sku}"))
3939 .send()
4040 .await;
4141 response.assert_status(200);
4242 response.response_body_json()?["stock"]
4343 .as_i64()
4444 .ok_or_else(|| anyhow::anyhow!("stock missing for {sku}"))
4545}
4646
4747#[cherry_test(scope = "shop_checkout")]
4848async fn shop_project_catalog_suite_lists_seeded_products_test(env: TestEnv) {
4949 let response = env
5050 .project("shop")
5151 .service("api")?
5252 .get("/v1/products")
5353 .send()
5454 .await;
5555 response.assert_status(200);
5656 let body = response.response_body_json()?;
5757 assert_eq!(body["total"], 4);
5858 assert_eq!(body["items"][0]["sku"], "LAMP-DESK");
5959 assert_eq!(body["items"][0]["price_cents"], 12900);
6060 assert_eq!(body["items"][0]["discount_percent"], 0);
6161 assert_eq!(body["items"][1]["sku"], "MUG-STONE");
6262 assert_eq!(body["items"][1]["discount_percent"], 10);
6363}
6464
6565#[cherry_test(scope = "shop_checkout")]
6666async fn shop_project_catalog_suite_unknown_sku_not_found_test(env: TestEnv) {
6767 let response = env
6868 .project("shop")
rejects_invalid_sku · scope shop_checkout · target shop.apiok · 0ms
147147
148148#[cherry_test(scope = "shop_checkout")]
149149async fn shop_project_checkout_suite_applies_the_mug_discount_test(env: TestEnv) {
150150 let cart_id = open_cart(&env).await?;
151151 add_item(&env, &cart_id, "MUG-STONE", 2)
152152 .await?
153153 .assert_status(200);
154154 let response = env
155155 .project("shop")
156156 .service("api")?
157157 .post(&format!("/v1/carts/{cart_id}/checkout"))
158158 .send()
159159 .await;
160160 response.assert_status(201);
161161 let order = response.response_body_json()?;
162162 assert_eq!(order["lines"][0]["unit_price_cents"], 1620);
163163 assert_eq!(order["total_cents"], 3240);
164164}
165165
166166#[cherry_test(scope = "shop_checkout")]
167167async fn shop_project_catalog_suite_rejects_invalid_sku_test(env: TestEnv) {
168168 let response = env
169169 .project("shop")
170170 .service("api")?
171171 .get("/v1/products/no")
172172 .send()
173173 .await;
174174 response.assert_status(422);
175175 assert_eq!(
176176 response.response_body_json()?["error"]["code"],
177177 "invalid_sku"
178178 );
179179}
180180
181181#[cherry_test(scope = "shop_checkout")]
182182async fn shop_project_catalog_suite_serves_openapi_test(env: TestEnv) {
183183 let response = env
184184 .project("shop")
185185 .service("api")?
186186 .get("/openapi.json")
187187 .send()
serves_openapi · scope shop_checkout · target shop.apiok · 0ms
162162 assert_eq!(order["lines"][0]["unit_price_cents"], 1620);
163163 assert_eq!(order["total_cents"], 3240);
164164}
165165
166166#[cherry_test(scope = "shop_checkout")]
167167async fn shop_project_catalog_suite_rejects_invalid_sku_test(env: TestEnv) {
168168 let response = env
169169 .project("shop")
170170 .service("api")?
171171 .get("/v1/products/no")
172172 .send()
173173 .await;
174174 response.assert_status(422);
175175 assert_eq!(
176176 response.response_body_json()?["error"]["code"],
177177 "invalid_sku"
178178 );
179179}
180180
181181#[cherry_test(scope = "shop_checkout")]
182182async fn shop_project_catalog_suite_serves_openapi_test(env: TestEnv) {
183183 let response = env
184184 .project("shop")
185185 .service("api")?
186186 .get("/openapi.json")
187187 .send()
188188 .await;
189189 response.assert_status(200);
190190 assert!(
191191 response
192192 .response_body_text()
193193 .contains("\"title\":\"shop-rs-api\"")
194194 || response.response_body_text().contains("shop-rs-api")
195195 );
196196}
197197
198198#[cherry_test(scope = "shop_checkout")]
199199async fn shop_project_cart_suite_unknown_cart_not_found_test(env: TestEnv) {
200200 let response = env
201201 .project("shop")
202202 .service("api")?
unknown_sku_not_found · scope shop_checkout · target shop.apiok · 1ms
4646
4747#[cherry_test(scope = "shop_checkout")]
4848async fn shop_project_catalog_suite_lists_seeded_products_test(env: TestEnv) {
4949 let response = env
5050 .project("shop")
5151 .service("api")?
5252 .get("/v1/products")
5353 .send()
5454 .await;
5555 response.assert_status(200);
5656 let body = response.response_body_json()?;
5757 assert_eq!(body["total"], 4);
5858 assert_eq!(body["items"][0]["sku"], "LAMP-DESK");
5959 assert_eq!(body["items"][0]["price_cents"], 12900);
6060 assert_eq!(body["items"][0]["discount_percent"], 0);
6161 assert_eq!(body["items"][1]["sku"], "MUG-STONE");
6262 assert_eq!(body["items"][1]["discount_percent"], 10);
6363}
6464
6565#[cherry_test(scope = "shop_checkout")]
6666async fn shop_project_catalog_suite_unknown_sku_not_found_test(env: TestEnv) {
6767 let response = env
6868 .project("shop")
6969 .service("api")?
7070 .get("/v1/products/NOPE-0000")
7171 .send()
7272 .await;
7373 response.assert_status(404);
7474 assert_eq!(
7575 response.response_body_json()?["error"]["code"],
7676 "product_not_found"
7777 );
7878}
7979
8080#[cherry_test(scope = "shop_checkout")]
8181async fn shop_project_cart_suite_rejects_zero_quantity_test(env: TestEnv) {
8282 let cart_id = open_cart(&env).await?;
8383 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8484 response.assert_status(422);
8585 assert_eq!(
8686 response.response_body_json()?["error"]["code"],
checkout4 run · 4 ok · 0 fail · 0 skip · 21ms total · 5ms avg · 2ms fastest · 9ms slowest
applies_the_mug_discount · scope shop_checkout · target shop.apiok · 9ms
129129 .service("api")?
130130 .post(&format!("/v1/carts/{cart_id}/checkout"))
131131 .send()
132132 .await;
133133 response.assert_status(201);
134134 let order = response.response_body_json()?;
135135 assert_eq!(order["total_cents"], 4200);
136136 assert_eq!(order["cart_id"], cart_id.as_str());
137137 assert_eq!(stock_of(&env, "NB-DOT-A5").await?, stock_before - 3);
138138 let cart = env
139139 .project("shop")
140140 .service("api")?
141141 .get(&format!("/v1/carts/{cart_id}"))
142142 .send()
143143 .await;
144144 cart.assert_status(200);
145145 assert_eq!(cart.response_body_json()?["status"], "checked_out");
146146}
147147
148148#[cherry_test(scope = "shop_checkout")]
149149async fn shop_project_checkout_suite_applies_the_mug_discount_test(env: TestEnv) {
150150 let cart_id = open_cart(&env).await?;
151151 add_item(&env, &cart_id, "MUG-STONE", 2)
152152 .await?
153153 .assert_status(200);
154154 let response = env
155155 .project("shop")
156156 .service("api")?
157157 .post(&format!("/v1/carts/{cart_id}/checkout"))
158158 .send()
159159 .await;
160160 response.assert_status(201);
161161 let order = response.response_body_json()?;
162162 assert_eq!(order["lines"][0]["unit_price_cents"], 1620);
163163 assert_eq!(order["total_cents"], 3240);
164164}
165165
166166#[cherry_test(scope = "shop_checkout")]
167167async fn shop_project_catalog_suite_rejects_invalid_sku_test(env: TestEnv) {
168168 let response = env
169169 .project("shop")
places_order_and_decrements_stock · scope shop_checkout · target shop.apiok · 4ms
101101
102102#[cherry_test(scope = "shop_checkout")]
103103async fn shop_project_cart_suite_adds_line_to_cart_test(env: TestEnv) {
104104 let cart_id = open_cart(&env).await?;
105105 let response = add_item(&env, &cart_id, "MUG-STONE", 2).await?;
106106 response.assert_status(200);
107107 let cart = env
108108 .project("shop")
109109 .service("api")?
110110 .get(&format!("/v1/carts/{cart_id}"))
111111 .send()
112112 .await;
113113 cart.assert_status(200);
114114 let body = cart.response_body_json()?;
115115 assert_eq!(body["status"], "open");
116116 assert_eq!(body["lines"][0]["sku"], "MUG-STONE");
117117 assert_eq!(body["lines"][0]["quantity"], 2);
118118}
119119
120120#[cherry_test(scope = "shop_checkout")]
121121async fn shop_project_checkout_suite_places_order_and_decrements_stock_test(env: TestEnv) {
122122 let stock_before = stock_of(&env, "NB-DOT-A5").await?;
123123 let cart_id = open_cart(&env).await?;
124124 add_item(&env, &cart_id, "NB-DOT-A5", 3)
125125 .await?
126126 .assert_status(200);
127127 let response = env
128128 .project("shop")
129129 .service("api")?
130130 .post(&format!("/v1/carts/{cart_id}/checkout"))
131131 .send()
132132 .await;
133133 response.assert_status(201);
134134 let order = response.response_body_json()?;
135135 assert_eq!(order["total_cents"], 4200);
136136 assert_eq!(order["cart_id"], cart_id.as_str());
137137 assert_eq!(stock_of(&env, "NB-DOT-A5").await?, stock_before - 3);
138138 let cart = env
139139 .project("shop")
140140 .service("api")?
141141 .get(&format!("/v1/carts/{cart_id}"))
rejects_empty_cart · scope shop_checkout · target shop.apiok · 2ms
194194 || response.response_body_text().contains("shop-rs-api")
195195 );
196196}
197197
198198#[cherry_test(scope = "shop_checkout")]
199199async fn shop_project_cart_suite_unknown_cart_not_found_test(env: TestEnv) {
200200 let response = env
201201 .project("shop")
202202 .service("api")?
203203 .get("/v1/carts/0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0d")
204204 .send()
205205 .await;
206206 response.assert_status(404);
207207 assert_eq!(
208208 response.response_body_json()?["error"]["code"],
209209 "cart_not_found"
210210 );
211211}
212212
213213#[cherry_test(scope = "shop_checkout")]
214214async fn shop_project_checkout_suite_rejects_empty_cart_test(env: TestEnv) {
215215 let cart_id = open_cart(&env).await?;
216216 let response = env
217217 .project("shop")
218218 .service("api")?
219219 .post(&format!("/v1/carts/{cart_id}/checkout"))
220220 .send()
221221 .await;
222222 response.assert_status(422);
223223 assert_eq!(
224224 response.response_body_json()?["error"]["code"],
225225 "empty_cart"
226226 );
227227}
228228
229229#[cherry_test(scope = "shop_checkout")]
230230async fn shop_project_checkout_suite_rejects_second_checkout_test(env: TestEnv) {
231231 let cart_id = open_cart(&env).await?;
232232 add_item(&env, &cart_id, "TEE-CREW-M", 1)
233233 .await?
234234 .assert_status(200);
rejects_second_checkout · scope shop_checkout · target shop.apiok · 6ms
210210 );
211211}
212212
213213#[cherry_test(scope = "shop_checkout")]
214214async fn shop_project_checkout_suite_rejects_empty_cart_test(env: TestEnv) {
215215 let cart_id = open_cart(&env).await?;
216216 let response = env
217217 .project("shop")
218218 .service("api")?
219219 .post(&format!("/v1/carts/{cart_id}/checkout"))
220220 .send()
221221 .await;
222222 response.assert_status(422);
223223 assert_eq!(
224224 response.response_body_json()?["error"]["code"],
225225 "empty_cart"
226226 );
227227}
228228
229229#[cherry_test(scope = "shop_checkout")]
230230async fn shop_project_checkout_suite_rejects_second_checkout_test(env: TestEnv) {
231231 let cart_id = open_cart(&env).await?;
232232 add_item(&env, &cart_id, "TEE-CREW-M", 1)
233233 .await?
234234 .assert_status(200);
235235 let checkout_path = format!("/v1/carts/{cart_id}/checkout");
236236 env.project("shop")
237237 .service("api")?
238238 .post(&checkout_path)
239239 .send()
240240 .await
241241 .assert_status(201);
242242 let response = env
243243 .project("shop")
244244 .service("api")?
245245 .post(&checkout_path)
246246 .send()
247247 .await;
248248 response.assert_status(409);
249249 assert_eq!(
250250 response.response_body_json()?["error"]["code"],
functional6 run · 6 ok · 0 fail · 0 skip · 1240ms total · 206ms avg · 132ms fastest · 282ms slowest
shop-ts-web-react: checks out two mugs and a second empty cart · scope none · target shop.shop-ts-web-reactok · 237ms
shop-ts-web-react: rejects a lamp quantity above the stock on hand · scope none · target shop.shop-ts-web-reactok · 132ms
shop-ts-web-react: rejects a zero quantity · scope none · target shop.shop-ts-web-reactok · 199ms
shop-ts-web-react: rejects checkout of an empty cart · scope none · target shop.shop-ts-web-reactok · 190ms
shop-ts-web-react: shows an unknown product when the catalog row disappears · scope none · target shop.shop-ts-web-reactok · 200ms
shop-ts-web-react: shows the seeded catalog · scope none · target shop.shop-ts-web-reactok · 282ms
integration1 run · 1 ok · 0 fail · 0 skip · 7ms total · 7ms avg · 7ms fastest · 7ms slowest
shop-ts-lib-cart: published cart entry prices two mugs through the package export · scope none · target shop.shop-ts-lib-cartok · 7ms
unit83 run · 82 ok · 1 fail · 0 skip · 283ms total · 16ms avg · 1ms fastest · 40ms slowest
shop-rs-api: application::tests::add_to_cart::persists_merged_line · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: application::tests::add_to_cart::rejects_quantity_beyond_stock · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: application::tests::add_to_cart::rejects_unknown_cart · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: application::tests::catalog_service::keeps_a_rejected_domain_error · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: application::tests::catalog_service::reports_a_price_mismatch · scope none · target shop.shop-rs-apiassertion `left == right` failed: listed price did not match the expected 1500 left: 1800 right: 1500 FAIL · 0ms
reason: assertion `left == right` failed: listed price did not match the expected 1500 left: 1800 right: 1500
213213 }));
214214 let products = service.list_products().await.unwrap();
215215 let mug = products
216216 .iter()
217217 .find(|product| product.sku == sku("MUG-STONE"))
218218 .unwrap();
219219 assert_eq!(
220220 mug.price.cents(),
221221 1500,
222222 "listed price did not match the expected 1500"
223223 );
224224 }
225225}
shop-rs-api: application::tests::catalog_service::returns_not_found_for_unknown_sku · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: application::tests::catalog_service::surfaces_unavailable_storage · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: application::tests::checkout::does_not_place_order_for_empty_cart · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: application::tests::checkout::places_order_with_priced_total · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::add_to_cart::merges_quantity_with_existing_line · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::add_to_cart::rejects_checked_out_cart · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::add_to_cart::rejects_merged_quantity_beyond_line_maximum · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::add_to_cart::rejects_quantity_beyond_stock · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::cart_id_parsing::normalizes_to_lowercase · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::cart_id_parsing::rejects_non_uuid · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::cart_id_rejects_a_dash_in_the_wrong_place · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::checkout::applies_a_discount_percent_before_the_quantity · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::checkout::rejects_a_line_whose_product_is_missing · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::checkout::rejects_a_total_that_overflows · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::checkout::rejects_checked_out_cart · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::checkout::rejects_empty_cart · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::checkout::rejects_line_that_exceeds_current_stock · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::checkout::totals_every_line_in_cents · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::every_domain_error_names_the_problem · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::money_rejects_a_negative_amount_and_an_overflowing_sum · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::quantity_bounds::accepts_every_value_in_range · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::quantity_bounds::rejects_above_line_maximum · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::quantity_bounds::rejects_zero · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::sku_accepts_the_longest_code_and_exposes_it · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::sku_parsing::accepts_uppercase_digits_and_dashes · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::sku_parsing::rejects_lowercase · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::model::tests::sku_parsing::rejects_too_short · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: domain::port::tests::display_includes_the_domain_message_or_the_storage_reason · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: infrastructure::postgres::tests::cart_line_parts_reject_a_bad_sku_or_quantity · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: infrastructure::postgres::tests::cart_status_accepts_both_stored_values_and_rejects_anything_else · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: infrastructure::postgres::tests::connect_reports_unavailable_when_the_server_refuses · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: infrastructure::postgres::tests::order_outcomes_distinguish_a_closed_cart_and_a_missed_reservation · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: infrastructure::postgres::tests::product_parts_reject_values_the_schema_also_forbids · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: infrastructure::postgres::tests::quantity_column_rejects_a_value_that_does_not_fit_the_database · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: infrastructure::web::tests::cart_opens_adds_and_checks_out · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: infrastructure::web::tests::cart_rejects_bad_input_and_conflicts · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: infrastructure::web::tests::cart_storage_failure_is_a_server_error · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: infrastructure::web::tests::catalog_lists_and_finds_products · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: infrastructure::web::tests::catalog_reports_storage_failure · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: infrastructure::web::tests::checkout_reports_an_empty_cart_stock_conflict_and_overflow · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: infrastructure::web::tests::every_domain_error_has_an_http_status · scope none · target shop.shop-rs-apiok · 0ms
shop-rs-api: infrastructure::web::tests::health_and_openapi_respond · scope none · target shop.shop-rs-apiok · 0ms
shop-ts-lib-cart: cartTotal is zero for an empty cart · scope none · target shop.shop-ts-lib-cartok · 0ms
shop-ts-lib-cart: cartTotal sums every line · scope none · target shop.shop-ts-lib-cartok · 0ms
shop-ts-lib-cart: cents accepts zero · scope none · target shop.shop-ts-lib-cartok · 0ms
shop-ts-lib-cart: cents rejects fractional amounts · scope none · target shop.shop-ts-lib-cartok · 0ms
shop-ts-lib-cart: cents rejects negative amounts · scope none · target shop.shop-ts-lib-cartok · 0ms
shop-ts-lib-cart: discountedUnitCents leaves a list price unchanged at zero percent · scope none · target shop.shop-ts-lib-cartok · 0ms
shop-ts-lib-cart: discountedUnitCents takes ten percent off before the quantity is applied · scope none · target shop.shop-ts-lib-cartok · 0ms
shop-ts-lib-cart: formatCents formats dollars for a US locale · scope none · target shop.shop-ts-lib-cartok · 0ms
shop-ts-lib-cart: formatCents formats euros for an Italian locale · scope none · target shop.shop-ts-lib-cartok · 6ms
shop-ts-lib-cart: lineTotal multiplies unit price by quantity · scope none · target shop.shop-ts-lib-cartok · 0ms
shop-ts-lib-cart: lineTotal rejects a quantity above the cart limit · scope none · target shop.shop-ts-lib-cartok · 0ms
shop-ts-lib-cart: lineTotal rejects a zero quantity · scope none · target shop.shop-ts-lib-cartok · 0ms
shop-ts-web-react: Shop adds two mugs and shows the line total · scope none · target shop.shop-ts-web-reactok · 38ms
shop-ts-web-react: Shop disables actions while an add is in flight · scope none · target shop.shop-ts-web-reactok · 15ms
shop-ts-web-react: Shop ignores a catalog response that arrives after unmount · scope none · target shop.shop-ts-web-reactok · 1ms
shop-ts-web-react: Shop places an order and rejects the next empty checkout · scope none · target shop.shop-ts-web-reactok · 31ms
shop-ts-web-react: Shop shows a generic message when the catalog body is not a list · scope none · target shop.shop-ts-web-reactok · 3ms
shop-ts-web-react: Shop shows a loading line until the catalog arrives · scope none · target shop.shop-ts-web-reactok · 4ms
shop-ts-web-react: Shop shows a network failure · scope none · target shop.shop-ts-web-reactok · 4ms
shop-ts-web-react: Shop shows a storage failure from the catalog · scope none · target shop.shop-ts-web-reactok · 18ms
shop-ts-web-react: Shop shows a zero quantity error · scope none · target shop.shop-ts-web-reactok · 24ms
shop-ts-web-react: Shop shows an empty-cart error and a second checkout error · scope none · target shop.shop-ts-web-reactok · 18ms
shop-ts-web-react: Shop shows an out of stock error · scope none · target shop.shop-ts-web-reactok · 25ms
shop-ts-web-react: Shop shows an unreadable add failure · scope none · target shop.shop-ts-web-reactok · 13ms
shop-ts-web-react: Shop shows the catalog and an empty cart · scope none · target shop.shop-ts-web-reactok · 27ms
shop-ts-web-react: Shop shows the sku when a cart line is no longer in the catalog · scope none · target shop.shop-ts-web-reactok · 14ms
shop-ts-web-react: mountShop rejects a missing root · scope none · target shop.shop-ts-web-reactok · 0ms
shop-ts-web-react: mountShop renders the shop into the root element · scope none · target shop.shop-ts-web-reactok · 40ms
shop-ts-web-react: shop API adds a line and returns the cart · scope none · target shop.shop-ts-web-reactok · 0ms
shop-ts-web-react: shop API keeps the status text when the error body has no code · scope none · target shop.shop-ts-web-reactok · 0ms
shop-ts-web-react: shop API maps a placed order · scope none · target shop.shop-ts-web-reactok · 0ms
shop-ts-web-react: shop API maps catalog rows into products · scope none · target shop.shop-ts-web-reactok · 2ms
shop-ts-web-react: shop API opens a cart · scope none · target shop.shop-ts-web-reactok · 0ms
shop-ts-web-react: shop API reads an API error · scope none · target shop.shop-ts-web-reactok · 0ms
shop-ts-web-react: shop API reports a network failure · scope none · target shop.shop-ts-web-reactok · 0ms
shop-ts-web-react: shop API reports an unreadable error body · scope none · target shop.shop-ts-web-reactok · 0ms
visual4 run · 4 ok · 0 fail · 0 skip · 1495ms total · 373ms avg · 304ms fastest · 470ms slowest
shop-ts-web-react: cart lines · scope none · target shop.shop-ts-web-reactok · 304ms
shop-ts-web-react: catalog · scope none · target shop.shop-ts-web-reactok · 470ms
shop-ts-web-react: empty checkout · scope none · target shop.shop-ts-web-reactok · 375ms
shop-ts-web-react: order placed · scope none · target shop.shop-ts-web-reactok · 346ms
shop > cart > adds_line_to_cart · scope shop_checkout · target shop.apiok · 53ms
8383 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8484 response.assert_status(422);
8585 assert_eq!(
8686 response.response_body_json()?["error"]["code"],
8787 "invalid_quantity"
8888 );
8989}
9090
9191#[cherry_test(scope = "shop_checkout")]
9292async fn shop_project_cart_suite_rejects_quantity_beyond_stock_test(env: TestEnv) {
9393 let cart_id = open_cart(&env).await?;
9494 let response = add_item(&env, &cart_id, "LAMP-DESK", 3).await?;
9595 response.assert_status(409);
9696 assert_eq!(
9797 response.response_body_json()?["error"]["code"],
9898 "out_of_stock"
9999 );
100100}
101101
102102#[cherry_test(scope = "shop_checkout")]
103103async fn shop_project_cart_suite_adds_line_to_cart_test(env: TestEnv) {
104104 let cart_id = open_cart(&env).await?;
105105 let response = add_item(&env, &cart_id, "MUG-STONE", 2).await?;
106106 response.assert_status(200);
107107 let cart = env
108108 .project("shop")
109109 .service("api")?
110110 .get(&format!("/v1/carts/{cart_id}"))
111111 .send()
112112 .await;
113113 cart.assert_status(200);
114114 let body = cart.response_body_json()?;
115115 assert_eq!(body["status"], "open");
116116 assert_eq!(body["lines"][0]["sku"], "MUG-STONE");
117117 assert_eq!(body["lines"][0]["quantity"], 2);
118118}
119119
120120#[cherry_test(scope = "shop_checkout")]
121121async fn shop_project_checkout_suite_places_order_and_decrements_stock_test(env: TestEnv) {
122122 let stock_before = stock_of(&env, "NB-DOT-A5").await?;
123123 let cart_id = open_cart(&env).await?;
shop > cart > rejects_quantity_beyond_stock · scope shop_checkout · target shop.apiok · 4ms
7272 .await;
7373 response.assert_status(404);
7474 assert_eq!(
7575 response.response_body_json()?["error"]["code"],
7676 "product_not_found"
7777 );
7878}
7979
8080#[cherry_test(scope = "shop_checkout")]
8181async fn shop_project_cart_suite_rejects_zero_quantity_test(env: TestEnv) {
8282 let cart_id = open_cart(&env).await?;
8383 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8484 response.assert_status(422);
8585 assert_eq!(
8686 response.response_body_json()?["error"]["code"],
8787 "invalid_quantity"
8888 );
8989}
9090
9191#[cherry_test(scope = "shop_checkout")]
9292async fn shop_project_cart_suite_rejects_quantity_beyond_stock_test(env: TestEnv) {
9393 let cart_id = open_cart(&env).await?;
9494 let response = add_item(&env, &cart_id, "LAMP-DESK", 3).await?;
9595 response.assert_status(409);
9696 assert_eq!(
9797 response.response_body_json()?["error"]["code"],
9898 "out_of_stock"
9999 );
100100}
101101
102102#[cherry_test(scope = "shop_checkout")]
103103async fn shop_project_cart_suite_adds_line_to_cart_test(env: TestEnv) {
104104 let cart_id = open_cart(&env).await?;
105105 let response = add_item(&env, &cart_id, "MUG-STONE", 2).await?;
106106 response.assert_status(200);
107107 let cart = env
108108 .project("shop")
109109 .service("api")?
110110 .get(&format!("/v1/carts/{cart_id}"))
111111 .send()
112112 .await;
shop > cart > rejects_zero_quantity · scope shop_checkout · target shop.apiok · 1ms
6161 assert_eq!(body["items"][1]["sku"], "MUG-STONE");
6262 assert_eq!(body["items"][1]["discount_percent"], 10);
6363}
6464
6565#[cherry_test(scope = "shop_checkout")]
6666async fn shop_project_catalog_suite_unknown_sku_not_found_test(env: TestEnv) {
6767 let response = env
6868 .project("shop")
6969 .service("api")?
7070 .get("/v1/products/NOPE-0000")
7171 .send()
7272 .await;
7373 response.assert_status(404);
7474 assert_eq!(
7575 response.response_body_json()?["error"]["code"],
7676 "product_not_found"
7777 );
7878}
7979
8080#[cherry_test(scope = "shop_checkout")]
8181async fn shop_project_cart_suite_rejects_zero_quantity_test(env: TestEnv) {
8282 let cart_id = open_cart(&env).await?;
8383 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8484 response.assert_status(422);
8585 assert_eq!(
8686 response.response_body_json()?["error"]["code"],
8787 "invalid_quantity"
8888 );
8989}
9090
9191#[cherry_test(scope = "shop_checkout")]
9292async fn shop_project_cart_suite_rejects_quantity_beyond_stock_test(env: TestEnv) {
9393 let cart_id = open_cart(&env).await?;
9494 let response = add_item(&env, &cart_id, "LAMP-DESK", 3).await?;
9595 response.assert_status(409);
9696 assert_eq!(
9797 response.response_body_json()?["error"]["code"],
9898 "out_of_stock"
9999 );
100100}
101101
shop > cart > unknown_cart_not_found · scope shop_checkout · target shop.apiok · 0ms
179179}
180180
181181#[cherry_test(scope = "shop_checkout")]
182182async fn shop_project_catalog_suite_serves_openapi_test(env: TestEnv) {
183183 let response = env
184184 .project("shop")
185185 .service("api")?
186186 .get("/openapi.json")
187187 .send()
188188 .await;
189189 response.assert_status(200);
190190 assert!(
191191 response
192192 .response_body_text()
193193 .contains("\"title\":\"shop-rs-api\"")
194194 || response.response_body_text().contains("shop-rs-api")
195195 );
196196}
197197
198198#[cherry_test(scope = "shop_checkout")]
199199async fn shop_project_cart_suite_unknown_cart_not_found_test(env: TestEnv) {
200200 let response = env
201201 .project("shop")
202202 .service("api")?
203203 .get("/v1/carts/0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0d")
204204 .send()
205205 .await;
206206 response.assert_status(404);
207207 assert_eq!(
208208 response.response_body_json()?["error"]["code"],
209209 "cart_not_found"
210210 );
211211}
212212
213213#[cherry_test(scope = "shop_checkout")]
214214async fn shop_project_checkout_suite_rejects_empty_cart_test(env: TestEnv) {
215215 let cart_id = open_cart(&env).await?;
216216 let response = env
217217 .project("shop")
218218 .service("api")?
219219 .post(&format!("/v1/carts/{cart_id}/checkout"))
shop > catalog > lists_seeded_products · scope shop_checkout · target shop.apiok · 1ms
2828 .post(&format!("/v1/carts/{cart_id}/items"))
2929 .json_body(&body)?
3030 .send()
3131 .await)
3232}
3333
3434async fn stock_of(env: &TestEnv, sku: &str) -> anyhow::Result<i64> {
3535 let response = env
3636 .project("shop")
3737 .service("api")?
3838 .get(&format!("/v1/products/{sku}"))
3939 .send()
4040 .await;
4141 response.assert_status(200);
4242 response.response_body_json()?["stock"]
4343 .as_i64()
4444 .ok_or_else(|| anyhow::anyhow!("stock missing for {sku}"))
4545}
4646
4747#[cherry_test(scope = "shop_checkout")]
4848async fn shop_project_catalog_suite_lists_seeded_products_test(env: TestEnv) {
4949 let response = env
5050 .project("shop")
5151 .service("api")?
5252 .get("/v1/products")
5353 .send()
5454 .await;
5555 response.assert_status(200);
5656 let body = response.response_body_json()?;
5757 assert_eq!(body["total"], 4);
5858 assert_eq!(body["items"][0]["sku"], "LAMP-DESK");
5959 assert_eq!(body["items"][0]["price_cents"], 12900);
6060 assert_eq!(body["items"][0]["discount_percent"], 0);
6161 assert_eq!(body["items"][1]["sku"], "MUG-STONE");
6262 assert_eq!(body["items"][1]["discount_percent"], 10);
6363}
6464
6565#[cherry_test(scope = "shop_checkout")]
6666async fn shop_project_catalog_suite_unknown_sku_not_found_test(env: TestEnv) {
6767 let response = env
6868 .project("shop")
shop > catalog > rejects_invalid_sku · scope shop_checkout · target shop.apiok · 0ms
147147
148148#[cherry_test(scope = "shop_checkout")]
149149async fn shop_project_checkout_suite_applies_the_mug_discount_test(env: TestEnv) {
150150 let cart_id = open_cart(&env).await?;
151151 add_item(&env, &cart_id, "MUG-STONE", 2)
152152 .await?
153153 .assert_status(200);
154154 let response = env
155155 .project("shop")
156156 .service("api")?
157157 .post(&format!("/v1/carts/{cart_id}/checkout"))
158158 .send()
159159 .await;
160160 response.assert_status(201);
161161 let order = response.response_body_json()?;
162162 assert_eq!(order["lines"][0]["unit_price_cents"], 1620);
163163 assert_eq!(order["total_cents"], 3240);
164164}
165165
166166#[cherry_test(scope = "shop_checkout")]
167167async fn shop_project_catalog_suite_rejects_invalid_sku_test(env: TestEnv) {
168168 let response = env
169169 .project("shop")
170170 .service("api")?
171171 .get("/v1/products/no")
172172 .send()
173173 .await;
174174 response.assert_status(422);
175175 assert_eq!(
176176 response.response_body_json()?["error"]["code"],
177177 "invalid_sku"
178178 );
179179}
180180
181181#[cherry_test(scope = "shop_checkout")]
182182async fn shop_project_catalog_suite_serves_openapi_test(env: TestEnv) {
183183 let response = env
184184 .project("shop")
185185 .service("api")?
186186 .get("/openapi.json")
187187 .send()
shop > catalog > serves_openapi · scope shop_checkout · target shop.apiok · 0ms
162162 assert_eq!(order["lines"][0]["unit_price_cents"], 1620);
163163 assert_eq!(order["total_cents"], 3240);
164164}
165165
166166#[cherry_test(scope = "shop_checkout")]
167167async fn shop_project_catalog_suite_rejects_invalid_sku_test(env: TestEnv) {
168168 let response = env
169169 .project("shop")
170170 .service("api")?
171171 .get("/v1/products/no")
172172 .send()
173173 .await;
174174 response.assert_status(422);
175175 assert_eq!(
176176 response.response_body_json()?["error"]["code"],
177177 "invalid_sku"
178178 );
179179}
180180
181181#[cherry_test(scope = "shop_checkout")]
182182async fn shop_project_catalog_suite_serves_openapi_test(env: TestEnv) {
183183 let response = env
184184 .project("shop")
185185 .service("api")?
186186 .get("/openapi.json")
187187 .send()
188188 .await;
189189 response.assert_status(200);
190190 assert!(
191191 response
192192 .response_body_text()
193193 .contains("\"title\":\"shop-rs-api\"")
194194 || response.response_body_text().contains("shop-rs-api")
195195 );
196196}
197197
198198#[cherry_test(scope = "shop_checkout")]
199199async fn shop_project_cart_suite_unknown_cart_not_found_test(env: TestEnv) {
200200 let response = env
201201 .project("shop")
202202 .service("api")?
shop > catalog > unknown_sku_not_found · scope shop_checkout · target shop.apiok · 1ms
4646
4747#[cherry_test(scope = "shop_checkout")]
4848async fn shop_project_catalog_suite_lists_seeded_products_test(env: TestEnv) {
4949 let response = env
5050 .project("shop")
5151 .service("api")?
5252 .get("/v1/products")
5353 .send()
5454 .await;
5555 response.assert_status(200);
5656 let body = response.response_body_json()?;
5757 assert_eq!(body["total"], 4);
5858 assert_eq!(body["items"][0]["sku"], "LAMP-DESK");
5959 assert_eq!(body["items"][0]["price_cents"], 12900);
6060 assert_eq!(body["items"][0]["discount_percent"], 0);
6161 assert_eq!(body["items"][1]["sku"], "MUG-STONE");
6262 assert_eq!(body["items"][1]["discount_percent"], 10);
6363}
6464
6565#[cherry_test(scope = "shop_checkout")]
6666async fn shop_project_catalog_suite_unknown_sku_not_found_test(env: TestEnv) {
6767 let response = env
6868 .project("shop")
6969 .service("api")?
7070 .get("/v1/products/NOPE-0000")
7171 .send()
7272 .await;
7373 response.assert_status(404);
7474 assert_eq!(
7575 response.response_body_json()?["error"]["code"],
7676 "product_not_found"
7777 );
7878}
7979
8080#[cherry_test(scope = "shop_checkout")]
8181async fn shop_project_cart_suite_rejects_zero_quantity_test(env: TestEnv) {
8282 let cart_id = open_cart(&env).await?;
8383 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8484 response.assert_status(422);
8585 assert_eq!(
8686 response.response_body_json()?["error"]["code"],
shop > checkout > applies_the_mug_discount · scope shop_checkout · target shop.apiok · 9ms
129129 .service("api")?
130130 .post(&format!("/v1/carts/{cart_id}/checkout"))
131131 .send()
132132 .await;
133133 response.assert_status(201);
134134 let order = response.response_body_json()?;
135135 assert_eq!(order["total_cents"], 4200);
136136 assert_eq!(order["cart_id"], cart_id.as_str());
137137 assert_eq!(stock_of(&env, "NB-DOT-A5").await?, stock_before - 3);
138138 let cart = env
139139 .project("shop")
140140 .service("api")?
141141 .get(&format!("/v1/carts/{cart_id}"))
142142 .send()
143143 .await;
144144 cart.assert_status(200);
145145 assert_eq!(cart.response_body_json()?["status"], "checked_out");
146146}
147147
148148#[cherry_test(scope = "shop_checkout")]
149149async fn shop_project_checkout_suite_applies_the_mug_discount_test(env: TestEnv) {
150150 let cart_id = open_cart(&env).await?;
151151 add_item(&env, &cart_id, "MUG-STONE", 2)
152152 .await?
153153 .assert_status(200);
154154 let response = env
155155 .project("shop")
156156 .service("api")?
157157 .post(&format!("/v1/carts/{cart_id}/checkout"))
158158 .send()
159159 .await;
160160 response.assert_status(201);
161161 let order = response.response_body_json()?;
162162 assert_eq!(order["lines"][0]["unit_price_cents"], 1620);
163163 assert_eq!(order["total_cents"], 3240);
164164}
165165
166166#[cherry_test(scope = "shop_checkout")]
167167async fn shop_project_catalog_suite_rejects_invalid_sku_test(env: TestEnv) {
168168 let response = env
169169 .project("shop")
shop > checkout > places_order_and_decrements_stock · scope shop_checkout · target shop.apiok · 4ms
101101
102102#[cherry_test(scope = "shop_checkout")]
103103async fn shop_project_cart_suite_adds_line_to_cart_test(env: TestEnv) {
104104 let cart_id = open_cart(&env).await?;
105105 let response = add_item(&env, &cart_id, "MUG-STONE", 2).await?;
106106 response.assert_status(200);
107107 let cart = env
108108 .project("shop")
109109 .service("api")?
110110 .get(&format!("/v1/carts/{cart_id}"))
111111 .send()
112112 .await;
113113 cart.assert_status(200);
114114 let body = cart.response_body_json()?;
115115 assert_eq!(body["status"], "open");
116116 assert_eq!(body["lines"][0]["sku"], "MUG-STONE");
117117 assert_eq!(body["lines"][0]["quantity"], 2);
118118}
119119
120120#[cherry_test(scope = "shop_checkout")]
121121async fn shop_project_checkout_suite_places_order_and_decrements_stock_test(env: TestEnv) {
122122 let stock_before = stock_of(&env, "NB-DOT-A5").await?;
123123 let cart_id = open_cart(&env).await?;
124124 add_item(&env, &cart_id, "NB-DOT-A5", 3)
125125 .await?
126126 .assert_status(200);
127127 let response = env
128128 .project("shop")
129129 .service("api")?
130130 .post(&format!("/v1/carts/{cart_id}/checkout"))
131131 .send()
132132 .await;
133133 response.assert_status(201);
134134 let order = response.response_body_json()?;
135135 assert_eq!(order["total_cents"], 4200);
136136 assert_eq!(order["cart_id"], cart_id.as_str());
137137 assert_eq!(stock_of(&env, "NB-DOT-A5").await?, stock_before - 3);
138138 let cart = env
139139 .project("shop")
140140 .service("api")?
141141 .get(&format!("/v1/carts/{cart_id}"))
shop > checkout > rejects_empty_cart · scope shop_checkout · target shop.apiok · 2ms
194194 || response.response_body_text().contains("shop-rs-api")
195195 );
196196}
197197
198198#[cherry_test(scope = "shop_checkout")]
199199async fn shop_project_cart_suite_unknown_cart_not_found_test(env: TestEnv) {
200200 let response = env
201201 .project("shop")
202202 .service("api")?
203203 .get("/v1/carts/0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0d")
204204 .send()
205205 .await;
206206 response.assert_status(404);
207207 assert_eq!(
208208 response.response_body_json()?["error"]["code"],
209209 "cart_not_found"
210210 );
211211}
212212
213213#[cherry_test(scope = "shop_checkout")]
214214async fn shop_project_checkout_suite_rejects_empty_cart_test(env: TestEnv) {
215215 let cart_id = open_cart(&env).await?;
216216 let response = env
217217 .project("shop")
218218 .service("api")?
219219 .post(&format!("/v1/carts/{cart_id}/checkout"))
220220 .send()
221221 .await;
222222 response.assert_status(422);
223223 assert_eq!(
224224 response.response_body_json()?["error"]["code"],
225225 "empty_cart"
226226 );
227227}
228228
229229#[cherry_test(scope = "shop_checkout")]
230230async fn shop_project_checkout_suite_rejects_second_checkout_test(env: TestEnv) {
231231 let cart_id = open_cart(&env).await?;
232232 add_item(&env, &cart_id, "TEE-CREW-M", 1)
233233 .await?
234234 .assert_status(200);
shop > checkout > rejects_second_checkout · scope shop_checkout · target shop.apiok · 6ms
210210 );
211211}
212212
213213#[cherry_test(scope = "shop_checkout")]
214214async fn shop_project_checkout_suite_rejects_empty_cart_test(env: TestEnv) {
215215 let cart_id = open_cart(&env).await?;
216216 let response = env
217217 .project("shop")
218218 .service("api")?
219219 .post(&format!("/v1/carts/{cart_id}/checkout"))
220220 .send()
221221 .await;
222222 response.assert_status(422);
223223 assert_eq!(
224224 response.response_body_json()?["error"]["code"],
225225 "empty_cart"
226226 );
227227}
228228
229229#[cherry_test(scope = "shop_checkout")]
230230async fn shop_project_checkout_suite_rejects_second_checkout_test(env: TestEnv) {
231231 let cart_id = open_cart(&env).await?;
232232 add_item(&env, &cart_id, "TEE-CREW-M", 1)
233233 .await?
234234 .assert_status(200);
235235 let checkout_path = format!("/v1/carts/{cart_id}/checkout");
236236 env.project("shop")
237237 .service("api")?
238238 .post(&checkout_path)
239239 .send()
240240 .await
241241 .assert_status(201);
242242 let response = env
243243 .project("shop")
244244 .service("api")?
245245 .post(&checkout_path)
246246 .send()
247247 .await;
248248 response.assert_status(409);
249249 assert_eq!(
250250 response.response_body_json()?["error"]["code"],
shop > functional > shop-ts-web-react: checks out two mugs and a second empty cart · scope none · target shop.shop-ts-web-reactok · 237ms
shop > functional > shop-ts-web-react: rejects a lamp quantity above the stock on hand · scope none · target shop.shop-ts-web-reactok · 132ms
shop > functional > shop-ts-web-react: rejects a zero quantity · scope none · target shop.shop-ts-web-reactok · 199ms
shop > functional > shop-ts-web-react: rejects checkout of an empty cart · scope none · target shop.shop-ts-web-reactok · 190ms
shop > functional > shop-ts-web-react: shows an unknown product when the catalog row disappears · scope none · target shop.shop-ts-web-reactok · 200ms
shop > functional > shop-ts-web-react: shows the seeded catalog · scope none · target shop.shop-ts-web-reactok · 282ms
shop > integration > shop-ts-lib-cart: published cart entry prices two mugs through the package export · scope none · target shop.shop-ts-lib-cartok · 7ms
shop > unit > shop-rs-api: application::tests::add_to_cart::persists_merged_line · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: application::tests::add_to_cart::rejects_quantity_beyond_stock · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: application::tests::add_to_cart::rejects_unknown_cart · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: application::tests::catalog_service::keeps_a_rejected_domain_error · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: application::tests::catalog_service::reports_a_price_mismatch · scope none · target shop.shop-rs-apiassertion `left == right` failed: listed price did not match the expected 1500 left: 1800 right: 1500 FAIL · 0ms
reason: assertion `left == right` failed: listed price did not match the expected 1500 left: 1800 right: 1500
213213 }));
214214 let products = service.list_products().await.unwrap();
215215 let mug = products
216216 .iter()
217217 .find(|product| product.sku == sku("MUG-STONE"))
218218 .unwrap();
219219 assert_eq!(
220220 mug.price.cents(),
221221 1500,
222222 "listed price did not match the expected 1500"
223223 );
224224 }
225225}
shop > unit > shop-rs-api: application::tests::catalog_service::returns_not_found_for_unknown_sku · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: application::tests::catalog_service::surfaces_unavailable_storage · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: application::tests::checkout::does_not_place_order_for_empty_cart · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: application::tests::checkout::places_order_with_priced_total · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::add_to_cart::merges_quantity_with_existing_line · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::add_to_cart::rejects_checked_out_cart · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::add_to_cart::rejects_merged_quantity_beyond_line_maximum · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::add_to_cart::rejects_quantity_beyond_stock · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::cart_id_parsing::normalizes_to_lowercase · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::cart_id_parsing::rejects_non_uuid · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::cart_id_rejects_a_dash_in_the_wrong_place · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::checkout::applies_a_discount_percent_before_the_quantity · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::checkout::rejects_a_line_whose_product_is_missing · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::checkout::rejects_a_total_that_overflows · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::checkout::rejects_checked_out_cart · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::checkout::rejects_empty_cart · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::checkout::rejects_line_that_exceeds_current_stock · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::checkout::totals_every_line_in_cents · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::every_domain_error_names_the_problem · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::money_rejects_a_negative_amount_and_an_overflowing_sum · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::quantity_bounds::accepts_every_value_in_range · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::quantity_bounds::rejects_above_line_maximum · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::quantity_bounds::rejects_zero · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::sku_accepts_the_longest_code_and_exposes_it · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::sku_parsing::accepts_uppercase_digits_and_dashes · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::sku_parsing::rejects_lowercase · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::model::tests::sku_parsing::rejects_too_short · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: domain::port::tests::display_includes_the_domain_message_or_the_storage_reason · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: infrastructure::postgres::tests::cart_line_parts_reject_a_bad_sku_or_quantity · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: infrastructure::postgres::tests::cart_status_accepts_both_stored_values_and_rejects_anything_else · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: infrastructure::postgres::tests::connect_reports_unavailable_when_the_server_refuses · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: infrastructure::postgres::tests::order_outcomes_distinguish_a_closed_cart_and_a_missed_reservation · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: infrastructure::postgres::tests::product_parts_reject_values_the_schema_also_forbids · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: infrastructure::postgres::tests::quantity_column_rejects_a_value_that_does_not_fit_the_database · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: infrastructure::web::tests::cart_opens_adds_and_checks_out · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: infrastructure::web::tests::cart_rejects_bad_input_and_conflicts · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: infrastructure::web::tests::cart_storage_failure_is_a_server_error · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: infrastructure::web::tests::catalog_lists_and_finds_products · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: infrastructure::web::tests::catalog_reports_storage_failure · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: infrastructure::web::tests::checkout_reports_an_empty_cart_stock_conflict_and_overflow · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: infrastructure::web::tests::every_domain_error_has_an_http_status · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-rs-api: infrastructure::web::tests::health_and_openapi_respond · scope none · target shop.shop-rs-apiok · 0ms
shop > unit > shop-ts-lib-cart: cartTotal is zero for an empty cart · scope none · target shop.shop-ts-lib-cartok · 0ms
shop > unit > shop-ts-lib-cart: cartTotal sums every line · scope none · target shop.shop-ts-lib-cartok · 0ms
shop > unit > shop-ts-lib-cart: cents accepts zero · scope none · target shop.shop-ts-lib-cartok · 0ms
shop > unit > shop-ts-lib-cart: cents rejects fractional amounts · scope none · target shop.shop-ts-lib-cartok · 0ms
shop > unit > shop-ts-lib-cart: cents rejects negative amounts · scope none · target shop.shop-ts-lib-cartok · 0ms
shop > unit > shop-ts-lib-cart: discountedUnitCents leaves a list price unchanged at zero percent · scope none · target shop.shop-ts-lib-cartok · 0ms
shop > unit > shop-ts-lib-cart: discountedUnitCents takes ten percent off before the quantity is applied · scope none · target shop.shop-ts-lib-cartok · 0ms
shop > unit > shop-ts-lib-cart: formatCents formats dollars for a US locale · scope none · target shop.shop-ts-lib-cartok · 0ms
shop > unit > shop-ts-lib-cart: formatCents formats euros for an Italian locale · scope none · target shop.shop-ts-lib-cartok · 6ms
shop > unit > shop-ts-lib-cart: lineTotal multiplies unit price by quantity · scope none · target shop.shop-ts-lib-cartok · 0ms
shop > unit > shop-ts-lib-cart: lineTotal rejects a quantity above the cart limit · scope none · target shop.shop-ts-lib-cartok · 0ms
shop > unit > shop-ts-lib-cart: lineTotal rejects a zero quantity · scope none · target shop.shop-ts-lib-cartok · 0ms
shop > unit > shop-ts-web-react: Shop adds two mugs and shows the line total · scope none · target shop.shop-ts-web-reactok · 38ms
shop > unit > shop-ts-web-react: Shop disables actions while an add is in flight · scope none · target shop.shop-ts-web-reactok · 15ms
shop > unit > shop-ts-web-react: Shop ignores a catalog response that arrives after unmount · scope none · target shop.shop-ts-web-reactok · 1ms
shop > unit > shop-ts-web-react: Shop places an order and rejects the next empty checkout · scope none · target shop.shop-ts-web-reactok · 31ms
shop > unit > shop-ts-web-react: Shop shows a generic message when the catalog body is not a list · scope none · target shop.shop-ts-web-reactok · 3ms
shop > unit > shop-ts-web-react: Shop shows a loading line until the catalog arrives · scope none · target shop.shop-ts-web-reactok · 4ms
shop > unit > shop-ts-web-react: Shop shows a network failure · scope none · target shop.shop-ts-web-reactok · 4ms
shop > unit > shop-ts-web-react: Shop shows a storage failure from the catalog · scope none · target shop.shop-ts-web-reactok · 18ms
shop > unit > shop-ts-web-react: Shop shows a zero quantity error · scope none · target shop.shop-ts-web-reactok · 24ms
shop > unit > shop-ts-web-react: Shop shows an empty-cart error and a second checkout error · scope none · target shop.shop-ts-web-reactok · 18ms
shop > unit > shop-ts-web-react: Shop shows an out of stock error · scope none · target shop.shop-ts-web-reactok · 25ms
shop > unit > shop-ts-web-react: Shop shows an unreadable add failure · scope none · target shop.shop-ts-web-reactok · 13ms
shop > unit > shop-ts-web-react: Shop shows the catalog and an empty cart · scope none · target shop.shop-ts-web-reactok · 27ms
shop > unit > shop-ts-web-react: Shop shows the sku when a cart line is no longer in the catalog · scope none · target shop.shop-ts-web-reactok · 14ms
shop > unit > shop-ts-web-react: mountShop rejects a missing root · scope none · target shop.shop-ts-web-reactok · 0ms
shop > unit > shop-ts-web-react: mountShop renders the shop into the root element · scope none · target shop.shop-ts-web-reactok · 40ms
shop > unit > shop-ts-web-react: shop API adds a line and returns the cart · scope none · target shop.shop-ts-web-reactok · 0ms
shop > unit > shop-ts-web-react: shop API keeps the status text when the error body has no code · scope none · target shop.shop-ts-web-reactok · 0ms
shop > unit > shop-ts-web-react: shop API maps a placed order · scope none · target shop.shop-ts-web-reactok · 0ms
shop > unit > shop-ts-web-react: shop API maps catalog rows into products · scope none · target shop.shop-ts-web-reactok · 2ms
shop > unit > shop-ts-web-react: shop API opens a cart · scope none · target shop.shop-ts-web-reactok · 0ms
shop > unit > shop-ts-web-react: shop API reads an API error · scope none · target shop.shop-ts-web-reactok · 0ms
shop > unit > shop-ts-web-react: shop API reports a network failure · scope none · target shop.shop-ts-web-reactok · 0ms
shop > unit > shop-ts-web-react: shop API reports an unreadable error body · scope none · target shop.shop-ts-web-reactok · 0ms
shop > visual > shop-ts-web-react: cart lines · scope none · target shop.shop-ts-web-reactok · 304ms
shop > visual > shop-ts-web-react: catalog · scope none · target shop.shop-ts-web-reactok · 470ms
shop > visual > shop-ts-web-react: empty checkout · scope none · target shop.shop-ts-web-reactok · 375ms
shop > visual > shop-ts-web-react: order placed · scope none · target shop.shop-ts-web-reactok · 346ms

Tests on this change

Recorded tests whose source file is in the diff.

shop > cart > adds_line_to_cart · scope shop_checkout · target shop.apiok · 53ms
8383 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8484 response.assert_status(422);
8585 assert_eq!(
8686 response.response_body_json()?["error"]["code"],
8787 "invalid_quantity"
8888 );
8989}
9090
9191#[cherry_test(scope = "shop_checkout")]
9292async fn shop_project_cart_suite_rejects_quantity_beyond_stock_test(env: TestEnv) {
9393 let cart_id = open_cart(&env).await?;
9494 let response = add_item(&env, &cart_id, "LAMP-DESK", 3).await?;
9595 response.assert_status(409);
9696 assert_eq!(
9797 response.response_body_json()?["error"]["code"],
9898 "out_of_stock"
9999 );
100100}
101101
102102#[cherry_test(scope = "shop_checkout")]
103103async fn shop_project_cart_suite_adds_line_to_cart_test(env: TestEnv) {
104104 let cart_id = open_cart(&env).await?;
105105 let response = add_item(&env, &cart_id, "MUG-STONE", 2).await?;
106106 response.assert_status(200);
107107 let cart = env
108108 .project("shop")
109109 .service("api")?
110110 .get(&format!("/v1/carts/{cart_id}"))
111111 .send()
112112 .await;
113113 cart.assert_status(200);
114114 let body = cart.response_body_json()?;
115115 assert_eq!(body["status"], "open");
116116 assert_eq!(body["lines"][0]["sku"], "MUG-STONE");
117117 assert_eq!(body["lines"][0]["quantity"], 2);
118118}
119119
120120#[cherry_test(scope = "shop_checkout")]
121121async fn shop_project_checkout_suite_places_order_and_decrements_stock_test(env: TestEnv) {
122122 let stock_before = stock_of(&env, "NB-DOT-A5").await?;
123123 let cart_id = open_cart(&env).await?;
shop > cart > rejects_quantity_beyond_stock · scope shop_checkout · target shop.apiok · 4ms
7272 .await;
7373 response.assert_status(404);
7474 assert_eq!(
7575 response.response_body_json()?["error"]["code"],
7676 "product_not_found"
7777 );
7878}
7979
8080#[cherry_test(scope = "shop_checkout")]
8181async fn shop_project_cart_suite_rejects_zero_quantity_test(env: TestEnv) {
8282 let cart_id = open_cart(&env).await?;
8383 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8484 response.assert_status(422);
8585 assert_eq!(
8686 response.response_body_json()?["error"]["code"],
8787 "invalid_quantity"
8888 );
8989}
9090
9191#[cherry_test(scope = "shop_checkout")]
9292async fn shop_project_cart_suite_rejects_quantity_beyond_stock_test(env: TestEnv) {
9393 let cart_id = open_cart(&env).await?;
9494 let response = add_item(&env, &cart_id, "LAMP-DESK", 3).await?;
9595 response.assert_status(409);
9696 assert_eq!(
9797 response.response_body_json()?["error"]["code"],
9898 "out_of_stock"
9999 );
100100}
101101
102102#[cherry_test(scope = "shop_checkout")]
103103async fn shop_project_cart_suite_adds_line_to_cart_test(env: TestEnv) {
104104 let cart_id = open_cart(&env).await?;
105105 let response = add_item(&env, &cart_id, "MUG-STONE", 2).await?;
106106 response.assert_status(200);
107107 let cart = env
108108 .project("shop")
109109 .service("api")?
110110 .get(&format!("/v1/carts/{cart_id}"))
111111 .send()
112112 .await;
shop > cart > rejects_zero_quantity · scope shop_checkout · target shop.apiok · 1ms
6161 assert_eq!(body["items"][1]["sku"], "MUG-STONE");
6262 assert_eq!(body["items"][1]["discount_percent"], 10);
6363}
6464
6565#[cherry_test(scope = "shop_checkout")]
6666async fn shop_project_catalog_suite_unknown_sku_not_found_test(env: TestEnv) {
6767 let response = env
6868 .project("shop")
6969 .service("api")?
7070 .get("/v1/products/NOPE-0000")
7171 .send()
7272 .await;
7373 response.assert_status(404);
7474 assert_eq!(
7575 response.response_body_json()?["error"]["code"],
7676 "product_not_found"
7777 );
7878}
7979
8080#[cherry_test(scope = "shop_checkout")]
8181async fn shop_project_cart_suite_rejects_zero_quantity_test(env: TestEnv) {
8282 let cart_id = open_cart(&env).await?;
8383 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8484 response.assert_status(422);
8585 assert_eq!(
8686 response.response_body_json()?["error"]["code"],
8787 "invalid_quantity"
8888 );
8989}
9090
9191#[cherry_test(scope = "shop_checkout")]
9292async fn shop_project_cart_suite_rejects_quantity_beyond_stock_test(env: TestEnv) {
9393 let cart_id = open_cart(&env).await?;
9494 let response = add_item(&env, &cart_id, "LAMP-DESK", 3).await?;
9595 response.assert_status(409);
9696 assert_eq!(
9797 response.response_body_json()?["error"]["code"],
9898 "out_of_stock"
9999 );
100100}
101101
shop > cart > unknown_cart_not_found · scope shop_checkout · target shop.apiok · 0ms
179179}
180180
181181#[cherry_test(scope = "shop_checkout")]
182182async fn shop_project_catalog_suite_serves_openapi_test(env: TestEnv) {
183183 let response = env
184184 .project("shop")
185185 .service("api")?
186186 .get("/openapi.json")
187187 .send()
188188 .await;
189189 response.assert_status(200);
190190 assert!(
191191 response
192192 .response_body_text()
193193 .contains("\"title\":\"shop-rs-api\"")
194194 || response.response_body_text().contains("shop-rs-api")
195195 );
196196}
197197
198198#[cherry_test(scope = "shop_checkout")]
199199async fn shop_project_cart_suite_unknown_cart_not_found_test(env: TestEnv) {
200200 let response = env
201201 .project("shop")
202202 .service("api")?
203203 .get("/v1/carts/0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0d")
204204 .send()
205205 .await;
206206 response.assert_status(404);
207207 assert_eq!(
208208 response.response_body_json()?["error"]["code"],
209209 "cart_not_found"
210210 );
211211}
212212
213213#[cherry_test(scope = "shop_checkout")]
214214async fn shop_project_checkout_suite_rejects_empty_cart_test(env: TestEnv) {
215215 let cart_id = open_cart(&env).await?;
216216 let response = env
217217 .project("shop")
218218 .service("api")?
219219 .post(&format!("/v1/carts/{cart_id}/checkout"))
shop > catalog > lists_seeded_products · scope shop_checkout · target shop.apiok · 1ms
2828 .post(&format!("/v1/carts/{cart_id}/items"))
2929 .json_body(&body)?
3030 .send()
3131 .await)
3232}
3333
3434async fn stock_of(env: &TestEnv, sku: &str) -> anyhow::Result<i64> {
3535 let response = env
3636 .project("shop")
3737 .service("api")?
3838 .get(&format!("/v1/products/{sku}"))
3939 .send()
4040 .await;
4141 response.assert_status(200);
4242 response.response_body_json()?["stock"]
4343 .as_i64()
4444 .ok_or_else(|| anyhow::anyhow!("stock missing for {sku}"))
4545}
4646
4747#[cherry_test(scope = "shop_checkout")]
4848async fn shop_project_catalog_suite_lists_seeded_products_test(env: TestEnv) {
4949 let response = env
5050 .project("shop")
5151 .service("api")?
5252 .get("/v1/products")
5353 .send()
5454 .await;
5555 response.assert_status(200);
5656 let body = response.response_body_json()?;
5757 assert_eq!(body["total"], 4);
5858 assert_eq!(body["items"][0]["sku"], "LAMP-DESK");
5959 assert_eq!(body["items"][0]["price_cents"], 12900);
6060 assert_eq!(body["items"][0]["discount_percent"], 0);
6161 assert_eq!(body["items"][1]["sku"], "MUG-STONE");
6262 assert_eq!(body["items"][1]["discount_percent"], 10);
6363}
6464
6565#[cherry_test(scope = "shop_checkout")]
6666async fn shop_project_catalog_suite_unknown_sku_not_found_test(env: TestEnv) {
6767 let response = env
6868 .project("shop")
shop > catalog > rejects_invalid_sku · scope shop_checkout · target shop.apiok · 0ms
147147
148148#[cherry_test(scope = "shop_checkout")]
149149async fn shop_project_checkout_suite_applies_the_mug_discount_test(env: TestEnv) {
150150 let cart_id = open_cart(&env).await?;
151151 add_item(&env, &cart_id, "MUG-STONE", 2)
152152 .await?
153153 .assert_status(200);
154154 let response = env
155155 .project("shop")
156156 .service("api")?
157157 .post(&format!("/v1/carts/{cart_id}/checkout"))
158158 .send()
159159 .await;
160160 response.assert_status(201);
161161 let order = response.response_body_json()?;
162162 assert_eq!(order["lines"][0]["unit_price_cents"], 1620);
163163 assert_eq!(order["total_cents"], 3240);
164164}
165165
166166#[cherry_test(scope = "shop_checkout")]
167167async fn shop_project_catalog_suite_rejects_invalid_sku_test(env: TestEnv) {
168168 let response = env
169169 .project("shop")
170170 .service("api")?
171171 .get("/v1/products/no")
172172 .send()
173173 .await;
174174 response.assert_status(422);
175175 assert_eq!(
176176 response.response_body_json()?["error"]["code"],
177177 "invalid_sku"
178178 );
179179}
180180
181181#[cherry_test(scope = "shop_checkout")]
182182async fn shop_project_catalog_suite_serves_openapi_test(env: TestEnv) {
183183 let response = env
184184 .project("shop")
185185 .service("api")?
186186 .get("/openapi.json")
187187 .send()
shop > catalog > serves_openapi · scope shop_checkout · target shop.apiok · 0ms
162162 assert_eq!(order["lines"][0]["unit_price_cents"], 1620);
163163 assert_eq!(order["total_cents"], 3240);
164164}
165165
166166#[cherry_test(scope = "shop_checkout")]
167167async fn shop_project_catalog_suite_rejects_invalid_sku_test(env: TestEnv) {
168168 let response = env
169169 .project("shop")
170170 .service("api")?
171171 .get("/v1/products/no")
172172 .send()
173173 .await;
174174 response.assert_status(422);
175175 assert_eq!(
176176 response.response_body_json()?["error"]["code"],
177177 "invalid_sku"
178178 );
179179}
180180
181181#[cherry_test(scope = "shop_checkout")]
182182async fn shop_project_catalog_suite_serves_openapi_test(env: TestEnv) {
183183 let response = env
184184 .project("shop")
185185 .service("api")?
186186 .get("/openapi.json")
187187 .send()
188188 .await;
189189 response.assert_status(200);
190190 assert!(
191191 response
192192 .response_body_text()
193193 .contains("\"title\":\"shop-rs-api\"")
194194 || response.response_body_text().contains("shop-rs-api")
195195 );
196196}
197197
198198#[cherry_test(scope = "shop_checkout")]
199199async fn shop_project_cart_suite_unknown_cart_not_found_test(env: TestEnv) {
200200 let response = env
201201 .project("shop")
202202 .service("api")?
shop > catalog > unknown_sku_not_found · scope shop_checkout · target shop.apiok · 1ms
4646
4747#[cherry_test(scope = "shop_checkout")]
4848async fn shop_project_catalog_suite_lists_seeded_products_test(env: TestEnv) {
4949 let response = env
5050 .project("shop")
5151 .service("api")?
5252 .get("/v1/products")
5353 .send()
5454 .await;
5555 response.assert_status(200);
5656 let body = response.response_body_json()?;
5757 assert_eq!(body["total"], 4);
5858 assert_eq!(body["items"][0]["sku"], "LAMP-DESK");
5959 assert_eq!(body["items"][0]["price_cents"], 12900);
6060 assert_eq!(body["items"][0]["discount_percent"], 0);
6161 assert_eq!(body["items"][1]["sku"], "MUG-STONE");
6262 assert_eq!(body["items"][1]["discount_percent"], 10);
6363}
6464
6565#[cherry_test(scope = "shop_checkout")]
6666async fn shop_project_catalog_suite_unknown_sku_not_found_test(env: TestEnv) {
6767 let response = env
6868 .project("shop")
6969 .service("api")?
7070 .get("/v1/products/NOPE-0000")
7171 .send()
7272 .await;
7373 response.assert_status(404);
7474 assert_eq!(
7575 response.response_body_json()?["error"]["code"],
7676 "product_not_found"
7777 );
7878}
7979
8080#[cherry_test(scope = "shop_checkout")]
8181async fn shop_project_cart_suite_rejects_zero_quantity_test(env: TestEnv) {
8282 let cart_id = open_cart(&env).await?;
8383 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8484 response.assert_status(422);
8585 assert_eq!(
8686 response.response_body_json()?["error"]["code"],
shop > checkout > applies_the_mug_discount · scope shop_checkout · target shop.apiok · 9ms
129129 .service("api")?
130130 .post(&format!("/v1/carts/{cart_id}/checkout"))
131131 .send()
132132 .await;
133133 response.assert_status(201);
134134 let order = response.response_body_json()?;
135135 assert_eq!(order["total_cents"], 4200);
136136 assert_eq!(order["cart_id"], cart_id.as_str());
137137 assert_eq!(stock_of(&env, "NB-DOT-A5").await?, stock_before - 3);
138138 let cart = env
139139 .project("shop")
140140 .service("api")?
141141 .get(&format!("/v1/carts/{cart_id}"))
142142 .send()
143143 .await;
144144 cart.assert_status(200);
145145 assert_eq!(cart.response_body_json()?["status"], "checked_out");
146146}
147147
148148#[cherry_test(scope = "shop_checkout")]
149149async fn shop_project_checkout_suite_applies_the_mug_discount_test(env: TestEnv) {
150150 let cart_id = open_cart(&env).await?;
151151 add_item(&env, &cart_id, "MUG-STONE", 2)
152152 .await?
153153 .assert_status(200);
154154 let response = env
155155 .project("shop")
156156 .service("api")?
157157 .post(&format!("/v1/carts/{cart_id}/checkout"))
158158 .send()
159159 .await;
160160 response.assert_status(201);
161161 let order = response.response_body_json()?;
162162 assert_eq!(order["lines"][0]["unit_price_cents"], 1620);
163163 assert_eq!(order["total_cents"], 3240);
164164}
165165
166166#[cherry_test(scope = "shop_checkout")]
167167async fn shop_project_catalog_suite_rejects_invalid_sku_test(env: TestEnv) {
168168 let response = env
169169 .project("shop")
shop > checkout > places_order_and_decrements_stock · scope shop_checkout · target shop.apiok · 4ms
101101
102102#[cherry_test(scope = "shop_checkout")]
103103async fn shop_project_cart_suite_adds_line_to_cart_test(env: TestEnv) {
104104 let cart_id = open_cart(&env).await?;
105105 let response = add_item(&env, &cart_id, "MUG-STONE", 2).await?;
106106 response.assert_status(200);
107107 let cart = env
108108 .project("shop")
109109 .service("api")?
110110 .get(&format!("/v1/carts/{cart_id}"))
111111 .send()
112112 .await;
113113 cart.assert_status(200);
114114 let body = cart.response_body_json()?;
115115 assert_eq!(body["status"], "open");
116116 assert_eq!(body["lines"][0]["sku"], "MUG-STONE");
117117 assert_eq!(body["lines"][0]["quantity"], 2);
118118}
119119
120120#[cherry_test(scope = "shop_checkout")]
121121async fn shop_project_checkout_suite_places_order_and_decrements_stock_test(env: TestEnv) {
122122 let stock_before = stock_of(&env, "NB-DOT-A5").await?;
123123 let cart_id = open_cart(&env).await?;
124124 add_item(&env, &cart_id, "NB-DOT-A5", 3)
125125 .await?
126126 .assert_status(200);
127127 let response = env
128128 .project("shop")
129129 .service("api")?
130130 .post(&format!("/v1/carts/{cart_id}/checkout"))
131131 .send()
132132 .await;
133133 response.assert_status(201);
134134 let order = response.response_body_json()?;
135135 assert_eq!(order["total_cents"], 4200);
136136 assert_eq!(order["cart_id"], cart_id.as_str());
137137 assert_eq!(stock_of(&env, "NB-DOT-A5").await?, stock_before - 3);
138138 let cart = env
139139 .project("shop")
140140 .service("api")?
141141 .get(&format!("/v1/carts/{cart_id}"))
shop > checkout > rejects_empty_cart · scope shop_checkout · target shop.apiok · 2ms
194194 || response.response_body_text().contains("shop-rs-api")
195195 );
196196}
197197
198198#[cherry_test(scope = "shop_checkout")]
199199async fn shop_project_cart_suite_unknown_cart_not_found_test(env: TestEnv) {
200200 let response = env
201201 .project("shop")
202202 .service("api")?
203203 .get("/v1/carts/0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0d")
204204 .send()
205205 .await;
206206 response.assert_status(404);
207207 assert_eq!(
208208 response.response_body_json()?["error"]["code"],
209209 "cart_not_found"
210210 );
211211}
212212
213213#[cherry_test(scope = "shop_checkout")]
214214async fn shop_project_checkout_suite_rejects_empty_cart_test(env: TestEnv) {
215215 let cart_id = open_cart(&env).await?;
216216 let response = env
217217 .project("shop")
218218 .service("api")?
219219 .post(&format!("/v1/carts/{cart_id}/checkout"))
220220 .send()
221221 .await;
222222 response.assert_status(422);
223223 assert_eq!(
224224 response.response_body_json()?["error"]["code"],
225225 "empty_cart"
226226 );
227227}
228228
229229#[cherry_test(scope = "shop_checkout")]
230230async fn shop_project_checkout_suite_rejects_second_checkout_test(env: TestEnv) {
231231 let cart_id = open_cart(&env).await?;
232232 add_item(&env, &cart_id, "TEE-CREW-M", 1)
233233 .await?
234234 .assert_status(200);
shop > checkout > rejects_second_checkout · scope shop_checkout · target shop.apiok · 6ms
210210 );
211211}
212212
213213#[cherry_test(scope = "shop_checkout")]
214214async fn shop_project_checkout_suite_rejects_empty_cart_test(env: TestEnv) {
215215 let cart_id = open_cart(&env).await?;
216216 let response = env
217217 .project("shop")
218218 .service("api")?
219219 .post(&format!("/v1/carts/{cart_id}/checkout"))
220220 .send()
221221 .await;
222222 response.assert_status(422);
223223 assert_eq!(
224224 response.response_body_json()?["error"]["code"],
225225 "empty_cart"
226226 );
227227}
228228
229229#[cherry_test(scope = "shop_checkout")]
230230async fn shop_project_checkout_suite_rejects_second_checkout_test(env: TestEnv) {
231231 let cart_id = open_cart(&env).await?;
232232 add_item(&env, &cart_id, "TEE-CREW-M", 1)
233233 .await?
234234 .assert_status(200);
235235 let checkout_path = format!("/v1/carts/{cart_id}/checkout");
236236 env.project("shop")
237237 .service("api")?
238238 .post(&checkout_path)
239239 .send()
240240 .await
241241 .assert_status(201);
242242 let response = env
243243 .project("shop")
244244 .service("api")?
245245 .post(&checkout_path)
246246 .send()
247247 .await;
248248 response.assert_status(409);
249249 assert_eq!(
250250 response.response_body_json()?["error"]["code"],
shop > unit > shop-rs-api: application::tests::catalog_service::reports_a_price_mismatch · scope none · target shop.shop-rs-apiassertion `left == right` failed: listed price did not match the expected 1500 left: 1800 right: 1500 FAIL · 0ms
reason: assertion `left == right` failed: listed price did not match the expected 1500 left: 1800 right: 1500
213213 }));
214214 let products = service.list_products().await.unwrap();
215215 let mug = products
216216 .iter()
217217 .find(|product| product.sku == sku("MUG-STONE"))
218218 .unwrap();
219219 assert_eq!(
220220 mug.price.cents(),
221221 1500,
222222 "listed price did not match the expected 1500"
223223 );
224224 }
225225}

changes

shop19 files · 721/776 · 721 cov · 55 unc · 92.9% · FAIL
shop-rs-api9 files · 76% covered / 24% uncovered
shop-rs-api/migrations/0002_product_discount.sql+4 -0 · - covered / - uncovered
runoldnew±codecov
1++ALTER TABLE product
2++ ADD COLUMN DISCOUNT_PERCENT INTEGER NOT NULL DEFAULT 0 CHECK (DISCOUNT_PERCENT BETWEEN 0 AND 100);
3++
4++UPDATE product SET DISCOUNT_PERCENT = 10 WHERE SKU = 'MUG-STONE';
shop-rs-api/src/application/service.rs+0 -0 · 92% covered / 8% uncovered
shop-rs-api/src/application/tests.rs+4 -2 · - covered / - uncovered
runoldnew±codecov
33
44use async_trait::async_trait;
55
66use super::service::{CartService, CatalogService, ShopError};
77use crate::domain::model::{
8-- Cart, CartId, CartLine, CartStatus, DomainError, Money, Order, OrderDraft, Product, Quantity,
9-- Sku,
8++ Cart, CartId, CartLine, CartStatus, DiscountPercent, DomainError, Money, Order, OrderDraft,
9++ Product, Quantity, Sku,
1010};
1111use crate::domain::port::{CartRepository, OrderRepository, ProductRepository, RepositoryError};
1212
1313const CART_ID: &str = "0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0c";
1414
…
127127 vec![
128128 Product {
129129 sku: Sku::parse("MUG-STONE").unwrap(),
130130 name: "Stoneware mug".to_owned(),
131131 price: Money::from_cents(1800).unwrap(),
132++ discount_percent: DiscountPercent::none(),
132133 stock: 10,
133134 },
134135 Product {
135136 sku: Sku::parse("LAMP-DESK").unwrap(),
136137 name: "Brass desk lamp".to_owned(),
137138 price: Money::from_cents(12900).unwrap(),
139++ discount_percent: DiscountPercent::none(),
138140 stock: 2,
139141 },
140142 ]
141143}
142144
shop-rs-api/src/domain/model.rs+51 -1 · 82% covered / 18% uncovered
runoldnew±codecov
11use std::fmt;
22
33const MINIMUM_SKU_LENGTH: usize = 3;
44const MAXIMUM_SKU_LENGTH: usize = 32;
55const MAXIMUM_LINE_QUANTITY: u32 = 99;
6++const MAXIMUM_DISCOUNT_PERCENT: u8 = 100;
67const CART_ID_LENGTH: usize = 36;
78const CART_ID_DASH_POSITIONS: [usize; 4] = [8, 13, 18, 23];
89
910#[derive(Debug, Clone, PartialEq, Eq)]
1011pub enum DomainError {
1112 InvalidSku,
1213 InvalidQuantity,
1314 InvalidMoney,
15++ InvalidDiscount,
1416 InvalidCartId,
1517 AmountOverflow,
1618 ProductNotFound(Sku),
1719 CartNotFound(CartId),
1820 CartClosed,
…
2931 ),
3032 Self::InvalidQuantity => {
3133 write!(formatter, "quantity must be between 1 and {MAXIMUM_LINE_QUANTITY}")
3234 }
3335 Self::InvalidMoney => write!(formatter, "amount must not be negative"),
36++ Self::InvalidDiscount => {
37++ write!(formatter, "discount percent must be an integer from 0 through 100")
38++ }
3439 Self::InvalidCartId => write!(formatter, "cart id must be a UUID"),
3540 Self::AmountOverflow => write!(formatter, "order total exceeds the supported range"),
3641 Self::ProductNotFound(sku) => write!(formatter, "product {sku} does not exist"),
3742 Self::CartNotFound(cart_id) => write!(formatter, "cart {cart_id} does not exist"),
3843 Self::CartClosed => write!(formatter, "cart is already checked out"),
…
7075 self.0
7176 .checked_add(other.0)
7277 .map(Self)
7378 .ok_or(DomainError::AmountOverflow)
7479 }
80++ pub fn discounted(self, percent: DiscountPercent) -> Self {
81++ let reduction =
82++ i128::from(self.0) * i128::from(percent.value()) / i128::from(MAXIMUM_DISCOUNT_PERCENT);
83++ Self(self.0 - reduction as i64)
84++ }
7585}
7686
7787#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
7888pub struct Sku(String);
7989
…
91101 pub fn as_str(&self) -> &str {
92102 &self.0
93103 }
94104}
95105
106++#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107++pub struct DiscountPercent(u8);
108++
109++impl DiscountPercent {
110++ pub const fn none() -> Self {
111++ Self(0)
112++ }
113++ pub fn parse(percent: i64) -> Result<Self, DomainError> {
114++ match u8::try_from(percent) {
115++ Ok(value) if value <= MAXIMUM_DISCOUNT_PERCENT => Ok(Self(value)),
116++ _ => Err(DomainError::InvalidDiscount),
117++ }
118++ }
119++ pub fn value(self) -> u8 {
120++ self.0
121++ }
122++}
123++
96124impl fmt::Display for Sku {
97125 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
98126 formatter.write_str(&self.0)
99127 }
100128}
…
147175#[derive(Debug, Clone, PartialEq, Eq)]
148176pub struct Product {
149177 pub sku: Sku,
150178 pub name: String,
151179 pub price: Money,
180++ pub discount_percent: DiscountPercent,
152181 pub stock: u32,
153182}
154183
155184impl Product {
156185 pub fn ensure_available(&self, quantity: Quantity) -> Result<(), DomainError> {
…
249278 .ok_or_else(|| DomainError::ProductNotFound(line.sku.clone()))?;
250279 product.ensure_available(line.quantity)?;
251280 Ok(OrderLine {
252281 sku: line.sku.clone(),
253282 quantity: line.quantity,
254-- unit_price: product.price,
283++ unit_price: product.price.discounted(product.discount_percent),
255284 })
256285 }
257286}
258287
259288#[cfg(test)]
…
263292 fn sku(raw: &str) -> Sku {
264293 Sku::parse(raw).unwrap()
265294 }
266295
267296 fn product(raw_sku: &str, price_cents: i64, stock: u32) -> Product {
297++ product_with_discount(raw_sku, price_cents, 0, stock)
298++ }
299++
300++ fn product_with_discount(
301++ raw_sku: &str,
302++ price_cents: i64,
303++ discount_percent: i64,
304++ stock: u32,
305++ ) -> Product {
268306 Product {
269307 sku: sku(raw_sku),
270308 name: raw_sku.to_owned(),
271309 price: Money::from_cents(price_cents).unwrap(),
310++ discount_percent: DiscountPercent::parse(discount_percent).unwrap(),
272311 stock,
273312 }
274313 }
275314
276315 fn cart(status: CartStatus, lines: Vec<CartLine>) -> Cart {
…
386425 .checkout(&products)
387426 .unwrap();
388427 assert_eq!(draft.total.cents(), 6500);
389428 }
390429
430++ #[test]
431++ fn applies_a_discount_percent_before_the_quantity() {
432++ let products = [product_with_discount("MUG-001", 1800, 10, 10)];
433++ let draft = cart(CartStatus::Open, vec![line("MUG-001", 2)])
434++ .checkout(&products)
435++ .unwrap();
436++ assert_eq!(draft.lines[0].unit_price.cents(), 1620);
437++ assert_eq!(draft.total.cents(), 3240);
438++ }
439++
391440 #[test]
392441 fn rejects_empty_cart() {
393442 let result = cart(CartStatus::Open, vec![]).checkout(&[]);
394443 assert_eq!(result, Err(DomainError::EmptyCart));
395444 }
…
458507 let cart_id = CartId::parse("0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0c").unwrap();
459508 let messages = [
460509 DomainError::InvalidSku.to_string(),
461510 DomainError::InvalidQuantity.to_string(),
462511 DomainError::InvalidMoney.to_string(),
512++ DomainError::InvalidDiscount.to_string(),
463513 DomainError::InvalidCartId.to_string(),
464514 DomainError::AmountOverflow.to_string(),
465515 DomainError::ProductNotFound(sku.clone()).to_string(),
466516 DomainError::CartNotFound(cart_id.clone()).to_string(),
467517 DomainError::CartClosed.to_string(),
shop-rs-api/src/domain/port.rs+0 -0 · 0% covered / 100% uncovered
shop-rs-api/src/infrastructure/postgres.rs+20 -10 · 100% covered / 0% uncovered
runoldnew±codecov
11use async_trait::async_trait;
22use sqlx::postgres::{PgPool, PgPoolOptions, PgRow};
33use sqlx::Row;
44
55use crate::domain::model::{
6-- Cart, CartId, CartLine, CartStatus, DomainError, Money, Order, OrderDraft, Product, Quantity,
7-- Sku,
6++ Cart, CartId, CartLine, CartStatus, DiscountPercent, DomainError, Money, Order, OrderDraft,
7++ Product, Quantity, Sku,
88};
99use crate::domain::port::{CartRepository, OrderRepository, ProductRepository, RepositoryError};
1010
1111const MAXIMUM_POOL_CONNECTIONS: u32 = 5;
1212const CART_STATUS_OPEN: &str = "open";
…
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")?;
43++ let discount_percent: i32 = checked(row.try_get("discount_percent"), "decode_product")?;
4344 let stock: i32 = checked(row.try_get("stock"), "decode_product")?;
44-- product_from_parts(&sku, name, price_cents, stock)
45++ product_from_parts(&sku, name, price_cents, i64::from(discount_percent), stock)
4546}
4647
4748fn product_from_parts(
4849 sku: &str,
4950 name: String,
5051 price_cents: i64,
52++ discount_percent: i64,
5153 stock: i32,
5254) -> Result<Product, RepositoryError> {
5355 Ok(Product {
5456 sku: Sku::parse(sku).map_err(RepositoryError::Rejected)?,
5557 name,
5658 price: Money::from_cents(price_cents).map_err(RepositoryError::Rejected)?,
59++ discount_percent: DiscountPercent::parse(discount_percent)
60++ .map_err(RepositoryError::Rejected)?,
5761 stock: u32::try_from(stock)
5862 .map_err(|_| RepositoryError::Rejected(DomainError::InvalidQuantity))?,
5963 })
6064}
6165
…
7175
7276#[async_trait]
7377impl ProductRepository for PostgresProductRepository {
7478 async fn list(&self) -> Result<Vec<Product>, RepositoryError> {
7579 let rows = checked(
76-- sqlx::query(r#"SELECT SKU, NAME, PRICE_CENTS, STOCK FROM product ORDER BY SKU"#)
80++ sqlx::query(
81++ r#"SELECT SKU, NAME, PRICE_CENTS, DISCOUNT_PERCENT, STOCK FROM product ORDER BY SKU"#,
82++ )
7783 .fetch_all(&self.pool)
7884 .await,
7985 "list_products",
8086 )?;
8187 rows.iter().map(product_from_row).collect()
8288 }
8389 async fn find_by_sku(&self, sku: &Sku) -> Result<Option<Product>, RepositoryError> {
8490 let row = checked(
85-- sqlx::query(r#"SELECT SKU, NAME, PRICE_CENTS, STOCK FROM product WHERE SKU = $1"#)
91++ sqlx::query(
92++ r#"SELECT SKU, NAME, PRICE_CENTS, DISCOUNT_PERCENT, STOCK FROM product WHERE SKU = $1"#,
93++ )
8694 .bind(sku.as_str())
8795 .fetch_optional(&self.pool)
8896 .await,
8997 "find_product",
9098 )?;
9199 row.as_ref().map(product_from_row).transpose()
92100 }
93101 async fn find_by_skus(&self, skus: &[Sku]) -> Result<Vec<Product>, RepositoryError> {
94102 let values: Vec<String> = skus.iter().map(|sku| sku.as_str().to_owned()).collect();
95103 let rows = checked(
96-- sqlx::query(r#"SELECT SKU, NAME, PRICE_CENTS, STOCK FROM product WHERE SKU = ANY($1)"#)
104++ sqlx::query(
105++ r#"SELECT SKU, NAME, PRICE_CENTS, DISCOUNT_PERCENT, STOCK FROM product WHERE SKU = ANY($1)"#,
106++ )
97107 .bind(&values)
98108 .fetch_all(&self.pool)
99109 .await,
100110 "find_products",
101111 )?;
…
322332 );
323333 }
324334
325335 #[test]
326336 fn product_parts_reject_values_the_schema_also_forbids() {
327-- assert!(product_from_parts("MUG-STONE", "Mug".to_owned(), 1800, 4).is_ok());
337++ assert!(product_from_parts("MUG-STONE", "Mug".to_owned(), 1800, 10, 4).is_ok());
328338 assert!(matches!(
329-- product_from_parts("no", "Mug".to_owned(), 1800, 4),
339++ product_from_parts("no", "Mug".to_owned(), 1800, 0, 4),
330340 Err(RepositoryError::Rejected(DomainError::InvalidSku))
331341 ));
332342 assert!(matches!(
333-- product_from_parts("MUG-STONE", "Mug".to_owned(), -1, 4),
343++ product_from_parts("MUG-STONE", "Mug".to_owned(), -1, 0, 4),
334344 Err(RepositoryError::Rejected(DomainError::InvalidMoney))
335345 ));
336346 assert!(matches!(
337-- product_from_parts("MUG-STONE", "Mug".to_owned(), 1800, -1),
347++ product_from_parts("MUG-STONE", "Mug".to_owned(), 1800, 0, -1),
338348 Err(RepositoryError::Rejected(DomainError::InvalidQuantity))
339349 ));
340350 }
341351
342352 #[test]
shop-rs-api/src/infrastructure/web.rs+11 -1 · 80% covered / 20% uncovered
runoldnew±codecov
99use serde::{Deserialize, Serialize};
1010use utoipa::{OpenApi, ToSchema};
1111
1212use crate::application::service::{CartService, CatalogService, ShopError};
1313use crate::domain::model::{
14-- Cart, CartId, CartLine, CartStatus, DomainError, Order, OrderLine, Product, Quantity, Sku,
14++ Cart, CartId, CartLine, CartStatus, DiscountPercent, DomainError, Order, OrderLine, Product,
15++ Quantity, Sku,
1516};
1617
1718#[derive(Clone)]
1819pub struct AppState {
1920 pub catalog: Arc<CatalogService>,
…
2324#[derive(Debug, Serialize, ToSchema)]
2425pub struct ProductResponse {
2526 sku: String,
2627 name: String,
2728 price_cents: i64,
29++ discount_percent: u8,
2830 stock: u32,
2931}
3032
3133impl From<Product> for ProductResponse {
3234 fn from(product: Product) -> Self {
3335 Self {
3436 sku: product.sku.as_str().to_owned(),
3537 name: product.name,
3638 price_cents: product.price.cents(),
39++ discount_percent: product.discount_percent.value(),
3740 stock: product.stock,
3841 }
3942 }
4043}
4144
…
161164 fn from(error: DomainError) -> Self {
162165 let (status, code) = match &error {
163166 DomainError::InvalidSku => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_sku"),
164167 DomainError::InvalidQuantity => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_quantity"),
165168 DomainError::InvalidMoney => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_money"),
169++ DomainError::InvalidDiscount => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_discount"),
166170 DomainError::InvalidCartId => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_cart_id"),
167171 DomainError::AmountOverflow => (StatusCode::UNPROCESSABLE_ENTITY, "amount_overflow"),
168172 DomainError::EmptyCart => (StatusCode::UNPROCESSABLE_ENTITY, "empty_cart"),
169173 DomainError::ProductNotFound(_) => (StatusCode::NOT_FOUND, "product_not_found"),
170174 DomainError::CartNotFound(_) => (StatusCode::NOT_FOUND, "cart_not_found"),
…
485489 fn product(raw: &str, price: i64, stock: u32) -> Product {
486490 Product {
487491 sku: sku(raw),
488492 name: raw.to_owned(),
489493 price: Money::from_cents(price).unwrap(),
494++ discount_percent: DiscountPercent::none(),
490495 stock,
491496 }
492497 }
493498
494499 fn state(
…
560565 (
561566 DomainError::InvalidMoney,
562567 StatusCode::UNPROCESSABLE_ENTITY,
563568 "invalid_money",
564569 ),
570++ (
571++ DomainError::InvalidDiscount,
572++ StatusCode::UNPROCESSABLE_ENTITY,
573++ "invalid_discount",
574++ ),
565575 (
566576 DomainError::InvalidCartId,
567577 StatusCode::UNPROCESSABLE_ENTITY,
568578 "invalid_cart_id",
569579 ),
shop-rs-api/src/main.rs+0 -0 · 100% covered / 0% uncovered
shop-rs-api/tests/integration.rs+21 -0 · - covered / - uncovered
runoldnew±codecov
5555 response.assert_status(200);
5656 let body = response.response_body_json()?;
5757 assert_eq!(body["total"], 4);
5858 assert_eq!(body["items"][0]["sku"], "LAMP-DESK");
5959 assert_eq!(body["items"][0]["price_cents"], 12900);
60++ assert_eq!(body["items"][0]["discount_percent"], 0);
61++ assert_eq!(body["items"][1]["sku"], "MUG-STONE");
62++ assert_eq!(body["items"][1]["discount_percent"], 10);
6063}
6164
6265#[cherry_test(scope = "shop_checkout")]
6366async fn shop_project_catalog_suite_unknown_sku_not_found_test(env: TestEnv) {
6467 let response = env
…
140143 .await;
141144 cart.assert_status(200);
142145 assert_eq!(cart.response_body_json()?["status"], "checked_out");
143146}
144147
148++#[cherry_test(scope = "shop_checkout")]
149++async fn shop_project_checkout_suite_applies_the_mug_discount_test(env: TestEnv) {
150++ let cart_id = open_cart(&env).await?;
151++ add_item(&env, &cart_id, "MUG-STONE", 2)
152++ .await?
153++ .assert_status(200);
154++ let response = env
155++ .project("shop")
156++ .service("api")?
157++ .post(&format!("/v1/carts/{cart_id}/checkout"))
158++ .send()
159++ .await;
160++ response.assert_status(201);
161++ let order = response.response_body_json()?;
162++ assert_eq!(order["lines"][0]["unit_price_cents"], 1620);
163++ assert_eq!(order["total_cents"], 3240);
164++}
165++
145166#[cherry_test(scope = "shop_checkout")]
146167async fn shop_project_catalog_suite_rejects_invalid_sku_test(env: TestEnv) {
147168 let response = env
148169 .project("shop")
149170 .service("api")?
shop-ts-lib-cart2 files · 84% covered / 16% uncovered
shop-ts-lib-cart/src/cart.test.ts+12 -1 · - covered / - uncovered
runoldnew±codecov
11import { describe, expect, it } from "vitest";
2--import { cartTotal, cents, formatCents, InvalidAmountError, InvalidQuantityError, lineTotal } from "./cart";
2++import { cartTotal, cents, discountedUnitCents, formatCents, InvalidAmountError, InvalidQuantityError, lineTotal } from "./cart";
33
44describe("cents", () => {
55 it("accepts zero", () => {
66 expect(cents(0)).toBe(0);
77 });
…
1111 it("rejects fractional amounts", () => {
1212 expect(() => cents(10.5)).toThrow(InvalidAmountError);
1313 });
1414});
1515
16++describe("discountedUnitCents", () => {
17++ it("leaves a list price unchanged at zero percent", () => {
18++ expect(discountedUnitCents(cents(1800), 0)).toBe(1800);
19++ });
20++ it("takes ten percent off before the quantity is applied", () => {
21++ const unit = discountedUnitCents(cents(1800), 10);
22++ expect(unit).toBe(1620);
23++ expect(lineTotal({ sku: "MUG-STONE", quantity: 2, unitPriceCents: unit })).toBe(3240);
24++ });
25++});
26++
1627describe("lineTotal", () => {
1728 it("multiplies unit price by quantity", () => {
1829 expect(lineTotal({ sku: "MUG-STONE", quantity: 2, unitPriceCents: cents(1800) })).toBe(3600);
1930 });
2031 it("rejects a zero quantity", () => {
shop-ts-lib-cart/src/cart.ts+16 -0 · 84% covered / 16% uncovered
runoldnew±codecov
1818 super(`quantity for ${sku} must be an integer between 1 and 99, got ${quantity}`);
1919 this.name = "InvalidQuantityError";
2020 }
2121}
2222
23++export class InvalidDiscountError extends Error {
24++ constructor(readonly percent: number) {
25++ super(`discount percent must be an integer from 0 through 100, got ${percent}`);
26++ this.name = "InvalidDiscountError";
27++ }
28++}
29++
2330const MINIMUM_QUANTITY = 1;
2431const MAXIMUM_QUANTITY = 99;
2532const CENTS_PER_UNIT = 100;
33++const FULL_DISCOUNT_PERCENT = 100;
2634
2735export function cents(amount: number): Cents {
2836 if (!Number.isSafeInteger(amount) || amount < 0) {
2937 throw new InvalidAmountError(amount);
3038 }
3139 return amount as Cents;
3240}
3341
42++export function discountedUnitCents(listPriceCents: Cents, discountPercent: number): Cents {
43++ if (!Number.isInteger(discountPercent) || discountPercent < 0 || discountPercent > FULL_DISCOUNT_PERCENT) {
44++ throw new InvalidDiscountError(discountPercent);
45++ }
46++ const reduction = Math.floor((listPriceCents * discountPercent) / FULL_DISCOUNT_PERCENT);
47++ return cents(listPriceCents - reduction);
48++}
49++
3450export function lineTotal(line: CartLine): Cents {
3551 if (!Number.isInteger(line.quantity) || line.quantity < MINIMUM_QUANTITY || line.quantity > MAXIMUM_QUANTITY) {
3652 throw new InvalidQuantityError(line.sku, line.quantity);
3753 }
3854 return cents(line.unitPriceCents * line.quantity);
shop-ts-web-react8 files · 100% covered / 0% uncovered
shop-ts-web-react/src/Shop.tsx+4 -2 · 100% covered / 0% uncovered
runoldnew±codecov
11import { useEffect, useState } from "react";
2--import { cartTotal, cents, formatCents, lineTotal, type CartLine as PricedLine } from "shop-ts-lib-cart";
2++import { 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";
…
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);
15-- return { sku: line.sku, quantity: line.quantity, unitPriceCents: cents(product?.priceCents ?? 0) };
15++ const listPriceCents = cents(product?.priceCents ?? 0);
16++ const discountPercent = product?.discountPercent ?? 0;
17++ return { sku: line.sku, quantity: line.quantity, unitPriceCents: discountedUnitCents(listPriceCents, discountPercent) };
1618}
1719
1820function messageOf(error: unknown): string {
1921 if (error instanceof ShopRequestError) return error.message;
2022 return "something went wrong";
shop-ts-web-react/src/main.tsx+0 -0 · 100% covered / 0% uncovered
shop-ts-web-react/src/mount.tsx+0 -0 · 100% covered / 0% uncovered
shop-ts-web-react/src/shop.test.tsx+7 -6 · - covered / - uncovered
runoldnew±codecov
22import userEvent from "@testing-library/user-event";
33import { afterEach, describe, expect, it, vi } from "vitest";
44
55import { Shop } from "./Shop";
66
7--const mug = { sku: "MUG-STONE", name: "Stoneware mug", price_cents: 1800, stock: 998 };
8--const lamp = { sku: "LAMP-DESK", name: "Brass desk lamp", price_cents: 12900, stock: 2 };
7++const mug = { sku: "MUG-STONE", name: "Stoneware mug", price_cents: 1800, discount_percent: 10, stock: 998 };
8++const lamp = { sku: "LAMP-DESK", name: "Brass desk lamp", price_cents: 12900, discount_percent: 0, stock: 2 };
99const catalog = { items: [mug, lamp] };
1010const cartId = "0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0c";
1111
1212function json(body: unknown, status = 200, statusText = "OK"): Response {
1313 return new Response(JSON.stringify(body), { status, statusText, headers: { "content-type": "application/json" } });
…
9393 render(<Shop apiBase="" />);
9494 expect(await screen.findByText("Stoneware mug")).toBeInTheDocument();
9595 await user.clear(screen.getByLabelText("Quantity for Stoneware mug"));
9696 await user.type(screen.getByLabelText("Quantity for Stoneware mug"), "2");
9797 await user.click(screen.getByRole("button", { name: "Add Stoneware mug" }));
98-- expect(await screen.findByText("Total $36.00")).toBeInTheDocument();
98++ expect(await screen.findByText("Total $32.40")).toBeInTheDocument();
9999 expect(screen.getByLabelText("Cart")).toHaveTextContent("Stoneware mug");
100-- expect(screen.getByLabelText("Cart")).toHaveTextContent("$36.00");
100++ expect(screen.getByLabelText("Cart")).toHaveTextContent("$32.40");
101++ expect(screen.getByText("$18.00")).toBeInTheDocument();
101102 });
102103
103104 it("disables actions while an add is in flight", async () => {
104105 const user = userEvent.setup();
105106 let release: (response: Response) => void = () => undefined;
…
111112 render(<Shop apiBase="" />);
112113 const add = await screen.findByRole("button", { name: "Add Stoneware mug" });
113114 await user.click(add);
114115 expect(add).toBeDisabled();
115116 release(json(cart([{ sku: "MUG-STONE", quantity: 1 }])));
116-- expect(await screen.findByText("Total $18.00")).toBeInTheDocument();
117++ expect(await screen.findByText("Total $16.20")).toBeInTheDocument();
117118 expect(add).toBeEnabled();
118119 });
119120
120121 it("shows the sku when a cart line is no longer in the catalog", async () => {
121122 const user = userEvent.setup();
…
147148 if (url.endsWith("/v1/carts")) return json(cart([]));
148149 return json(catalog);
149150 });
150151 render(<Shop apiBase="" />);
151152 await user.click(await screen.findByRole("button", { name: "Add Stoneware mug" }));
152-- await screen.findByText("Total $18.00");
153++ await screen.findByText("Total $16.20");
153154 await user.click(screen.getByRole("button", { name: "Checkout" }));
154155 expect(await screen.findByRole("status")).toHaveTextContent("Order 4 placed for $18.00");
155156 expect(screen.getByLabelText("Cart")).toHaveTextContent("The cart is empty.");
156157 await user.click(screen.getByRole("button", { name: "Checkout" }));
157158 expect(await screen.findByRole("alert")).toHaveTextContent("cart has no items");
shop-ts-web-react/src/shopApi.test.ts+2 -2 · - covered / - uncovered
runoldnew±codecov
2323 vi.unstubAllGlobals();
2424});
2525
2626describe("shop API", () => {
2727 it("maps catalog rows into products", async () => {
28-- install(json({ items: [{ sku: "MUG-STONE", name: "Stoneware mug", price_cents: 1800, stock: 1000 }] }));
29-- await expect(listProducts(base)).resolves.toEqual([{ sku: "MUG-STONE", name: "Stoneware mug", priceCents: 1800, stock: 1000 }]);
28++ install(json({ items: [{ sku: "MUG-STONE", name: "Stoneware mug", price_cents: 1800, discount_percent: 10, stock: 1000 }] }));
29++ await expect(listProducts(base)).resolves.toEqual([{ sku: "MUG-STONE", name: "Stoneware mug", priceCents: 1800, discountPercent: 10, stock: 1000 }]);
3030 });
3131
3232 it("opens a cart", async () => {
3333 const seen = install(json({ id: "0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0c", status: "open", lines: [] }));
3434 await expect(openCart(base)).resolves.toEqual({ id: "0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0c", status: "open", lines: [] });
shop-ts-web-react/src/shopApi.ts+9 -1 · 100% covered / 0% uncovered
runoldnew±codecov
11export interface Product {
22 readonly sku: string;
33 readonly name: string;
44 readonly priceCents: number;
5++ readonly discountPercent: number;
56 readonly stock: number;
67}
78
89export interface CartLine {
910 readonly sku: string;
…
3536
3637interface ProductBody {
3738 readonly sku: string;
3839 readonly name: string;
3940 readonly price_cents: number;
41++ readonly discount_percent: number;
4042 readonly stock: number;
4143}
4244
4345interface CartBody {
4446 readonly id: string;
…
5153 readonly cart_id: string;
5254 readonly total_cents: number;
5355}
5456
5557function productFromBody(body: ProductBody): Product {
56-- return { sku: body.sku, name: body.name, priceCents: body.price_cents, stock: body.stock };
58++ return {
59++ sku: body.sku,
60++ name: body.name,
61++ priceCents: body.price_cents,
62++ discountPercent: body.discount_percent,
63++ stock: body.stock,
64++ };
5765}
5866
5967function cartFromBody(body: CartBody): Cart {
6068 return {
6169 id: body.id,
shop-ts-web-react/tests/functional.spec.ts+2 -2 · - covered / - uncovered
runoldnew±codecov
3737test("checks out two mugs and a second empty cart", async ({ page }) => {
3838 await page.goto("/");
3939 await expect(page.getByText("Stoneware mug", { exact: true })).toBeVisible();
4040 await page.getByLabel("Quantity for Stoneware mug").fill("2");
4141 await page.getByRole("button", { name: "Add Stoneware mug" }).click();
42-- await expect(page.getByLabel("Cart")).toContainText("$36.00");
42++ await expect(page.getByLabel("Cart")).toContainText("$32.40");
4343 await page.getByRole("button", { name: "Checkout" }).click();
44-- await expect(page.getByRole("status")).toContainText("placed for $36.00");
44++ await expect(page.getByRole("status")).toContainText("placed for $32.40");
4545 await expect(page.getByLabel("Cart")).toContainText("The cart is empty.");
4646 await page.getByRole("button", { name: "Checkout" }).click();
4747 await expect(page.getByRole("alert")).toHaveText("cart has no items");
4848});
4949
shop-ts-web-react/tests/visual.spec.ts+3 -3 · - covered / - uncovered
runoldnew±codecov
3535test("cart lines", async ({ page }) => {
3636 await page.goto("/");
3737 await expect(page.getByText("Stoneware mug", { exact: true })).toBeVisible();
3838 await page.getByLabel("Quantity for Stoneware mug").fill("2");
3939 await page.getByRole("button", { name: "Add Stoneware mug" }).click();
40-- await expect(page.getByLabel("Cart")).toContainText("Total $36.00");
40++ await expect(page.getByLabel("Cart")).toContainText("Total $32.40");
4141 await expect(page).toHaveScreenshot("cart.png", { fullPage: true });
4242});
4343
4444test("order placed", async ({ page }) => {
4545 await page.goto("/");
4646 await expect(page.getByText("Stoneware mug", { exact: true })).toBeVisible();
4747 await page.getByLabel("Quantity for Stoneware mug").fill("2");
4848 await page.getByRole("button", { name: "Add Stoneware mug" }).click();
49-- await expect(page.getByLabel("Cart")).toContainText("Total $36.00");
49++ await expect(page.getByLabel("Cart")).toContainText("Total $32.40");
5050 await page.getByRole("button", { name: "Checkout" }).click();
51-- await expect(page.getByRole("status")).toHaveText("Order 1 placed for $36.00");
51++ await expect(page.getByRole("status")).toHaveText("Order 1 placed for $32.40");
5252 await expect(page).toHaveScreenshot("order.png", { fullPage: true });
5353});

CRAP

Cyclomatic complexity and the fraction of measured lines in each function. CRAP = complexity^2 * (1 - coverage)^3 + complexity. This is line coverage, not basis-path coverage. The aggregate LCOV percentage is not a CRAP score.

On this change

24 functions · worst 14.7 · 0 at or above 30 (0.0%) · crap load 0.0

ScoreComplexityCoverageCrap loadFunctionLocation
14.71273%0.0fmtshop-rs-api/src/domain/model.rs:26
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 }
13.31279%0.0fromshop-rs-api/src/infrastructure/web.rs:164
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 }
6.020%0.0every_domain_error_has_an_http_statusshop-rs-api/src/infrastructure/web.rs:553
553553 fn every_domain_error_has_an_http_status() {
554554 let cases = [
555555 (
556556 DomainError::InvalidSku,
557557 StatusCode::UNPROCESSABLE_ENTITY,
558558 "invalid_sku",
559559 ),
560560 (
561561 DomainError::InvalidQuantity,
562562 StatusCode::UNPROCESSABLE_ENTITY,
563563 "invalid_quantity",
564564 ),
565565 (
566566 DomainError::InvalidMoney,
567567 StatusCode::UNPROCESSABLE_ENTITY,
568568 "invalid_money",
569569 ),
570570 (
571571 DomainError::InvalidDiscount,
572572 StatusCode::UNPROCESSABLE_ENTITY,
573573 "invalid_discount",
574574 ),
575575 (
576576 DomainError::InvalidCartId,
577577 StatusCode::UNPROCESSABLE_ENTITY,
578578 "invalid_cart_id",
579579 ),
580580 (
581581 DomainError::AmountOverflow,
582582 StatusCode::UNPROCESSABLE_ENTITY,
583583 "amount_overflow",
584584 ),
585585 (
586586 DomainError::EmptyCart,
587587 StatusCode::UNPROCESSABLE_ENTITY,
588588 "empty_cart",
589589 ),
590590 (
591591 DomainError::ProductNotFound(sku("MUG-STONE")),
592592 StatusCode::NOT_FOUND,
593593 "product_not_found",
594594 ),
595595 (
596596 DomainError::CartNotFound(CartId::parse(CART_ID).unwrap()),
597597 StatusCode::NOT_FOUND,
598598 "cart_not_found",
599599 ),
600600 (DomainError::CartClosed, StatusCode::CONFLICT, "cart_closed"),
601601 (
602602 DomainError::OutOfStock(sku("LAMP-DESK")),
603603 StatusCode::CONFLICT,
604604 "out_of_stock",
605605 ),
606606 ];
607607 for (error, status, code) in cases {
608608 let api = ApiError::from(error);
609609 assert_eq!(api.status, status);
610610 assert_eq!(api.code, code);
611611 let response = api.into_response();
612612 assert_eq!(response.status(), status);
613613 }
614614 let unavailable = ApiError::from(ShopError::Unavailable("connect".to_owned()));
615615 assert_eq!(unavailable.status, StatusCode::INTERNAL_SERVER_ERROR);
616616 assert_eq!(unavailable.code, "storage_unavailable");
617617 }
4.4471%0.0discountedUnitCentsshop-ts-lib-cart/src/cart.ts:42
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}
4.1480%0.0parseshop-rs-api/src/domain/model.rs:113
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 }
2.010%0.0noneshop-rs-api/src/domain/model.rs:110
110110 pub const fn none() -> Self {
111111 Self(0)
112112 }
2.02100%0.0price_lineshop-rs-api/src/domain/model.rs:274
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 }
2.010%0.0productshop-rs-api/src/domain/model.rs:296
296296 fn product(raw_sku: &str, price_cents: i64, stock: u32) -> Product {
297297 product_with_discount(raw_sku, price_cents, 0, stock)
298298 }
2.010%0.0product_with_discountshop-rs-api/src/domain/model.rs:300
300300 fn product_with_discount(
301301 raw_sku: &str,
302302 price_cents: i64,
303303 discount_percent: i64,
304304 stock: u32,
305305 ) -> Product {
306306 Product {
307307 sku: sku(raw_sku),
308308 name: raw_sku.to_owned(),
309309 price: Money::from_cents(price_cents).unwrap(),
310310 discount_percent: DiscountPercent::parse(discount_percent).unwrap(),
311311 stock,
312312 }
313313 }
2.010%0.0applies_a_discount_percent_before_the_quantityshop-rs-api/src/domain/model.rs:431
431431 fn applies_a_discount_percent_before_the_quantity() {
432432 let products = [product_with_discount("MUG-001", 1800, 10, 10)];
433433 let draft = cart(CartStatus::Open, vec![line("MUG-001", 2)])
434434 .checkout(&products)
435435 .unwrap();
436436 assert_eq!(draft.lines[0].unit_price.cents(), 1620);
437437 assert_eq!(draft.total.cents(), 3240);
438438 }
2.010%0.0every_domain_error_names_the_problemshop-rs-api/src/domain/model.rs:505
505505 fn every_domain_error_names_the_problem() {
506506 let sku = sku("MUG-001");
507507 let cart_id = CartId::parse("0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0c").unwrap();
508508 let messages = [
509509 DomainError::InvalidSku.to_string(),
510510 DomainError::InvalidQuantity.to_string(),
511511 DomainError::InvalidMoney.to_string(),
512512 DomainError::InvalidDiscount.to_string(),
513513 DomainError::InvalidCartId.to_string(),
514514 DomainError::AmountOverflow.to_string(),
515515 DomainError::ProductNotFound(sku.clone()).to_string(),
516516 DomainError::CartNotFound(cart_id.clone()).to_string(),
517517 DomainError::CartClosed.to_string(),
518518 DomainError::EmptyCart.to_string(),
519519 DomainError::OutOfStock(sku).to_string(),
520520 cart_id.to_string(),
521521 ];
522522 assert!(messages.iter().all(|message| !message.is_empty()));
523523 let as_error: &dyn std::error::Error = &DomainError::InvalidSku;
524524 assert_eq!(as_error.to_string(), DomainError::InvalidSku.to_string());
525525 }
2.010%0.0product_parts_reject_values_the_schema_also_forbidsshop-rs-api/src/infrastructure/postgres.rs:336
336336 fn product_parts_reject_values_the_schema_also_forbids() {
337337 assert!(product_from_parts("MUG-STONE", "Mug".to_owned(), 1800, 10, 4).is_ok());
338338 assert!(matches!(
339339 product_from_parts("no", "Mug".to_owned(), 1800, 0, 4),
340340 Err(RepositoryError::Rejected(DomainError::InvalidSku))
341341 ));
342342 assert!(matches!(
343343 product_from_parts("MUG-STONE", "Mug".to_owned(), -1, 0, 4),
344344 Err(RepositoryError::Rejected(DomainError::InvalidMoney))
345345 ));
346346 assert!(matches!(
347347 product_from_parts("MUG-STONE", "Mug".to_owned(), 1800, 0, -1),
348348 Err(RepositoryError::Rejected(DomainError::InvalidQuantity))
349349 ));
350350 }
2.010%0.0productshop-rs-api/src/infrastructure/web.rs:489
489489 fn product(raw: &str, price: i64, stock: u32) -> Product {
490490 Product {
491491 sku: sku(raw),
492492 name: raw.to_owned(),
493493 price: Money::from_cents(price).unwrap(),
494494 discount_percent: DiscountPercent::none(),
495495 stock,
496496 }
497497 }
1.4125%0.0constructorshop-ts-lib-cart/src/cart.ts:24
2424 constructor(readonly percent: number) {
2525 super(`discount percent must be an integer from 0 through 100, got ${percent}`);
2626 this.name = "InvalidDiscountError";
2727 }
1.01100%0.0discountedshop-rs-api/src/domain/model.rs:80
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 }
1.01100%0.0valueshop-rs-api/src/domain/model.rs:119
119119 pub fn value(self) -> u8 {
120120 self.0
121121 }
1.01100%0.0product_from_rowshop-rs-api/src/infrastructure/postgres.rs:39
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}
1.01100%0.0product_from_partsshop-rs-api/src/infrastructure/postgres.rs:48
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}
1.01100%0.0listshop-rs-api/src/infrastructure/postgres.rs:78
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"#,
8282 )
8383 .fetch_all(&self.pool)
8484 .await,
8585 "list_products",
8686 )?;
8787 rows.iter().map(product_from_row).collect()
8888 }
1.01100%0.0find_by_skushop-rs-api/src/infrastructure/postgres.rs:89
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"#,
9393 )
9494 .bind(sku.as_str())
9595 .fetch_optional(&self.pool)
9696 .await,
9797 "find_product",
9898 )?;
9999 row.as_ref().map(product_from_row).transpose()
100100 }
1.01100%0.0find_by_skusshop-rs-api/src/infrastructure/postgres.rs:101
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)"#,
106106 )
107107 .bind(&values)
108108 .fetch_all(&self.pool)
109109 .await,
110110 "find_products",
111111 )?;
112112 rows.iter().map(product_from_row).collect()
113113 }
1.01100%0.0fromshop-rs-api/src/infrastructure/web.rs:34
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 }
1.01100%0.0pricedLineshop-ts-web-react/src/Shop.tsx:13
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}
1.01100%0.0productFromBodyshop-ts-web-react/src/shopApi.ts:57
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}

167 functions · worst 14.7 · 0 at or above 30 (0.0%) · crap load 0.0

complexity 269 · total CRAP 395.5 · mean 2.4 · median 2.0

ProjectFunctionsComplexityTotal CRAPMeanMedianWorstAt or above 30Crap load
shop167269395.52.42.014.70 (0.0%)0.0
ScoreComplexityCoverageCrap loadFunctionLocation
14.71273%0.0fmtshop-rs-api/src/domain/model.rs:26
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 }
13.31279%0.0fromshop-rs-api/src/infrastructure/web.rs:164
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 }
12.030%0.0fromshop-rs-api/src/application/service.rs:15
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 }
12.030%0.0fmtshop-rs-api/src/domain/port.rs:14
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 }
12.030%0.0listshop-rs-api/src/infrastructure/web.rs:390
390390 async fn list(&self) -> Result<Vec<Product>, RepositoryError> {
391391 match &self.fail {
392392 Some(error) => Err(error.clone()),
393393 None => Ok(self.products.clone()),
394394 }
395395 }
12.030%0.0find_by_skushop-rs-api/src/infrastructure/web.rs:396
396396 async fn find_by_sku(&self, sku: &Sku) -> Result<Option<Product>, RepositoryError> {
397397 match &self.fail {
398398 Some(error) => Err(error.clone()),
399399 None => Ok(self
400400 .products
401401 .iter()
402402 .find(|product| &product.sku == sku)
403403 .cloned()),
404404 }
405405 }
12.030%0.0find_by_skusshop-rs-api/src/infrastructure/web.rs:406
406406 async fn find_by_skus(&self, skus: &[Sku]) -> Result<Vec<Product>, RepositoryError> {
407407 match &self.fail {
408408 Some(error) => Err(error.clone()),
409409 None => Ok(self
410410 .products
411411 .iter()
412412 .filter(|product| skus.contains(&product.sku))
413413 .cloned()
414414 .collect()),
415415 }
416416 }
8.08100%0.0Shopshop-ts-web-react/src/Shop.tsx:25
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>
9696 Quantity
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}
7.07100%0.0readErrorshop-ts-web-react/src/shopApi.ts:75
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}
6.020%0.0createshop-rs-api/src/infrastructure/web.rs:426
426426 async fn create(&self) -> Result<Cart, RepositoryError> {
427427 if let Some(error) = &self.fail {
428428 return Err(error.clone());
429429 }
430430 let cart = Cart {
431431 id: CartId::parse(CART_ID).unwrap(),
432432 status: CartStatus::Open,
433433 lines: Vec::new(),
434434 };
435435 self.carts.lock().unwrap().push(cart.clone());
436436 Ok(cart)
437437 }
6.020%0.0findshop-rs-api/src/infrastructure/web.rs:438
438438 async fn find(&self, cart_id: &CartId) -> Result<Option<Cart>, RepositoryError> {
439439 if let Some(error) = &self.fail {
440440 return Err(error.clone());
441441 }
442442 Ok(self
443443 .carts
444444 .lock()
445445 .unwrap()
446446 .iter()
447447 .find(|cart| &cart.id == cart_id)
448448 .cloned())
449449 }
6.020%0.0save_lineshop-rs-api/src/infrastructure/web.rs:450
450450 async fn save_line(
451451 &self,
452452 cart_id: &CartId,
453453 line: &CartLine,
454454 ) -> Result<(), RepositoryError> {
455455 if let Some(error) = &self.fail {
456456 return Err(error.clone());
457457 }
458458 let mut carts = self.carts.lock().unwrap();
459459 let cart = carts.iter_mut().find(|cart| &cart.id == cart_id).unwrap();
460460 cart.lines.retain(|existing| existing.sku != line.sku);
461461 cart.lines.push(line.clone());
462462 Ok(())
463463 }
6.020%0.0placeshop-rs-api/src/infrastructure/web.rs:472
472472 async fn place(&self, draft: &OrderDraft) -> Result<Order, RepositoryError> {
473473 if let Some(error) = &self.fail {
474474 return Err(error.clone());
475475 }
476476 Ok(Order {
477477 id: 1,
478478 cart_id: draft.cart_id.clone(),
479479 lines: draft.lines.clone(),
480480 total: draft.total,
481481 })
482482 }
6.020%0.0sendshop-rs-api/src/infrastructure/web.rs:533
533533 async fn send(
534534 state: AppState,
535535 method: &str,
536536 path: &str,
537537 body: Option<&str>,
538538 ) -> (StatusCode, String) {
539539 let mut builder = Request::builder().method(method).uri(path);
540540 if body.is_some() {
541541 builder = builder.header("content-type", "application/json");
542542 }
543543 let request = builder
544544 .body(Body::from(body.unwrap_or("").to_owned()))
545545 .unwrap();
546546 let response = router(state).oneshot(request).await.unwrap();
547547 let status = response.status();
548548 let bytes = response.into_body().collect().await.unwrap().to_bytes();
549549 (status, String::from_utf8(bytes.to_vec()).unwrap())
550550 }
6.020%0.0every_domain_error_has_an_http_statusshop-rs-api/src/infrastructure/web.rs:553
553553 fn every_domain_error_has_an_http_status() {
554554 let cases = [
555555 (
556556 DomainError::InvalidSku,
557557 StatusCode::UNPROCESSABLE_ENTITY,
558558 "invalid_sku",
559559 ),
560560 (
561561 DomainError::InvalidQuantity,
562562 StatusCode::UNPROCESSABLE_ENTITY,
563563 "invalid_quantity",
564564 ),
565565 (
566566 DomainError::InvalidMoney,
567567 StatusCode::UNPROCESSABLE_ENTITY,
568568 "invalid_money",
569569 ),
570570 (
571571 DomainError::InvalidDiscount,
572572 StatusCode::UNPROCESSABLE_ENTITY,
573573 "invalid_discount",
574574 ),
575575 (
576576 DomainError::InvalidCartId,
577577 StatusCode::UNPROCESSABLE_ENTITY,
578578 "invalid_cart_id",
579579 ),
580580 (
581581 DomainError::AmountOverflow,
582582 StatusCode::UNPROCESSABLE_ENTITY,
583583 "amount_overflow",
584584 ),
585585 (
586586 DomainError::EmptyCart,
587587 StatusCode::UNPROCESSABLE_ENTITY,
588588 "empty_cart",
589589 ),
590590 (
591591 DomainError::ProductNotFound(sku("MUG-STONE")),
592592 StatusCode::NOT_FOUND,
593593 "product_not_found",
594594 ),
595595 (
596596 DomainError::CartNotFound(CartId::parse(CART_ID).unwrap()),
597597 StatusCode::NOT_FOUND,
598598 "cart_not_found",
599599 ),
600600 (DomainError::CartClosed, StatusCode::CONFLICT, "cart_closed"),
601601 (
602602 DomainError::OutOfStock(sku("LAMP-DESK")),
603603 StatusCode::CONFLICT,
604604 "out_of_stock",
605605 ),
606606 ];
607607 for (error, status, code) in cases {
608608 let api = ApiError::from(error);
609609 assert_eq!(api.status, status);
610610 assert_eq!(api.code, code);
611611 let response = api.into_response();
612612 assert_eq!(response.status(), status);
613613 }
614614 let unavailable = ApiError::from(ShopError::Unavailable("connect".to_owned()));
615615 assert_eq!(unavailable.status, StatusCode::INTERNAL_SERVER_ERROR);
616616 assert_eq!(unavailable.code, "storage_unavailable");
617617 }
5.3336%0.0fromshop-rs-api/src/infrastructure/web.rs:187
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 }
5.05100%0.0parseshop-rs-api/src/domain/model.rs:91
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 }
4.8462%0.0cart_statusshop-rs-api/src/infrastructure/postgres.rs:126
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}
4.4471%0.0discountedUnitCentsshop-ts-lib-cart/src/cart.ts:42
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}
4.1480%0.0parseshop-rs-api/src/domain/model.rs:113
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 }
4.0492%0.0parseshop-rs-api/src/domain/model.rs:150
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 }
4.04100%0.0lineTotalshop-ts-lib-cart/src/cart.ts:50
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}
3.03100%0.0ensure_openshop-rs-api/src/domain/model.rs:268
268268 fn ensure_open(&self) -> Result<(), DomainError> {
269269 match self.status {
270270 CartStatus::Open => Ok(()),
271271 CartStatus::CheckedOut => Err(DomainError::CartClosed),
272272 }
273273 }
3.03100%0.0placeshop-rs-api/src/infrastructure/postgres.rs:249
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"#)
253253 .bind(draft.cart_id.as_str())
254254 .bind(CART_STATUS_CHECKED_OUT)
255255 .bind(CART_STATUS_OPEN)
256256 .execute(&mut *transaction)
257257 .await,
258258 "close_cart",
259259 )?;
260260 close_outcome(closed.rows_affected())?;
261261 for line in &draft.lines {
262262 let quantity = quantity_column(line.quantity.value())?;
263263 let reserved = checked(
264264 sqlx::query(
265265 r#"UPDATE product SET STOCK = STOCK - $2 WHERE SKU = $1 AND STOCK >= $2"#,
266266 )
267267 .bind(line.sku.as_str())
268268 .bind(quantity)
269269 .execute(&mut *transaction)
270270 .await,
271271 "reserve_stock",
272272 )?;
273273 reserve_outcome(reserved.rows_affected(), &line.sku)?;
274274 }
275275 let row = checked(
276276 sqlx::query(r#"INSERT INTO customer_order (CART_ID, TOTAL_CENTS) VALUES ($1::uuid, $2) RETURNING ID"#)
277277 .bind(draft.cart_id.as_str())
278278 .bind(draft.total.cents())
279279 .fetch_one(&mut *transaction)
280280 .await,
281281 "insert_order",
282282 )?;
283283 let order_id: i64 = checked(row.try_get("id"), "decode_order")?;
284284 for line in &draft.lines {
285285 let quantity = quantity_column(line.quantity.value())?;
286286 checked(
287287 sqlx::query(
288288 r#"INSERT INTO customer_order_line (ORDER_ID, SKU, QUANTITY, UNIT_PRICE_CENTS) VALUES ($1, $2, $3, $4)"#,
289289 )
290290 .bind(order_id)
291291 .bind(line.sku.as_str())
292292 .bind(quantity)
293293 .bind(line.unit_price.cents())
294294 .execute(&mut *transaction)
295295 .await,
296296 "insert_order_line",
297297 )?;
298298 }
299299 checked(transaction.commit().await, "commit_order")?;
300300 Ok(Order {
301301 id: order_id,
302302 cart_id: draft.cart_id.clone(),
303303 lines: draft.lines.clone(),
304304 total: draft.total,
305305 })
306306 }
3.03100%0.0fromshop-rs-api/src/infrastructure/web.rs:74
7474 fn from(status: CartStatus) -> Self {
7575 match status {
7676 CartStatus::Open => Self::Open,
7777 CartStatus::CheckedOut => Self::CheckedOut,
7878 }
7979 }
3.03100%0.0centsshop-ts-lib-cart/src/cart.ts:35
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}
3.03100%0.0<arrow>shop-ts-web-react/src/Shop.tsx:125
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}
3.03100%0.0requestJsonshop-ts-web-react/src/shopApi.ts:88
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}
2.3257%0.0reserve_outcomeshop-rs-api/src/infrastructure/postgres.rs:162
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}
2.0280%0.0close_outcomeshop-rs-api/src/infrastructure/postgres.rs:154
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}
137 more functions
2.0283%0.0from_centsshop-rs-api/src/domain/model.rs:56
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 }
2.02100%0.0find_productshop-rs-api/src/application/service.rs:34
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 }
2.02100%0.0find_cartshop-rs-api/src/application/service.rs:63
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 }
2.02100%0.0add_to_cartshop-rs-api/src/application/service.rs:69
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 }
2.010%0.0noneshop-rs-api/src/domain/model.rs:110
110110 pub const fn none() -> Self {
111111 Self(0)
112112 }
2.02100%0.0ensure_availableshop-rs-api/src/domain/model.rs:185
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 }
2.02100%0.0checkoutshop-rs-api/src/domain/model.rs:249
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 }
2.02100%0.0price_lineshop-rs-api/src/domain/model.rs:274
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 }
2.010%0.0skushop-rs-api/src/domain/model.rs:292
292292 fn sku(raw: &str) -> Sku {
293293 Sku::parse(raw).unwrap()
294294 }
2.010%0.0productshop-rs-api/src/domain/model.rs:296
296296 fn product(raw_sku: &str, price_cents: i64, stock: u32) -> Product {
297297 product_with_discount(raw_sku, price_cents, 0, stock)
298298 }
2.010%0.0product_with_discountshop-rs-api/src/domain/model.rs:300
300300 fn product_with_discount(
301301 raw_sku: &str,
302302 price_cents: i64,
303303 discount_percent: i64,
304304 stock: u32,
305305 ) -> Product {
306306 Product {
307307 sku: sku(raw_sku),
308308 name: raw_sku.to_owned(),
309309 price: Money::from_cents(price_cents).unwrap(),
310310 discount_percent: DiscountPercent::parse(discount_percent).unwrap(),
311311 stock,
312312 }
313313 }
2.010%0.0cartshop-rs-api/src/domain/model.rs:315
315315 fn cart(status: CartStatus, lines: Vec<CartLine>) -> Cart {
316316 Cart {
317317 id: CartId::parse("0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0c").unwrap(),
318318 status,
319319 lines,
320320 }
321321 }
2.010%0.0lineshop-rs-api/src/domain/model.rs:323
323323 fn line(raw_sku: &str, quantity: i64) -> CartLine {
324324 CartLine {
325325 sku: sku(raw_sku),
326326 quantity: Quantity::new(quantity).unwrap(),
327327 }
328328 }
2.010%0.0accepts_uppercase_digits_and_dashesshop-rs-api/src/domain/model.rs:334
334334 fn accepts_uppercase_digits_and_dashes() {
335335 assert_eq!(
336336 Sku::parse("MUG-001").map(|parsed| parsed.to_string()),
337337 Ok("MUG-001".to_owned())
338338 );
339339 }
2.010%0.0rejects_lowercaseshop-rs-api/src/domain/model.rs:342
342342 fn rejects_lowercase() {
343343 assert_eq!(Sku::parse("mug-001"), Err(DomainError::InvalidSku));
344344 }
2.010%0.0rejects_too_shortshop-rs-api/src/domain/model.rs:347
347347 fn rejects_too_short() {
348348 assert_eq!(Sku::parse("AB"), Err(DomainError::InvalidSku));
349349 }
2.010%0.0rejects_zeroshop-rs-api/src/domain/model.rs:356
356356 fn rejects_zero() {
357357 assert_eq!(Quantity::new(0), Err(DomainError::InvalidQuantity));
358358 }
2.010%0.0rejects_above_line_maximumshop-rs-api/src/domain/model.rs:361
361361 fn rejects_above_line_maximum() {
362362 assert_eq!(Quantity::new(100), Err(DomainError::InvalidQuantity));
363363 }
2.010%0.0accepts_every_value_in_rangeshop-rs-api/src/domain/model.rs:366
366366 fn accepts_every_value_in_range() {
367367 assert!((1..=99).all(|value| Quantity::new(value).is_ok()));
368368 }
2.010%0.0normalizes_to_lowercaseshop-rs-api/src/domain/model.rs:375
375375 fn normalizes_to_lowercase() {
376376 let parsed = CartId::parse("0B8F6C1E-4A2D-4E1F-9C3B-2D5E7F8A9B0C").unwrap();
377377 assert_eq!(parsed.as_str(), "0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0c");
378378 }
2.010%0.0rejects_non_uuidshop-rs-api/src/domain/model.rs:381
381381 fn rejects_non_uuid() {
382382 assert_eq!(CartId::parse("42"), Err(DomainError::InvalidCartId));
383383 }
2.010%0.0merges_quantity_with_existing_lineshop-rs-api/src/domain/model.rs:390
390390 fn merges_quantity_with_existing_line() {
391391 let result = cart(CartStatus::Open, vec![line("MUG-001", 2)])
392392 .add(&product("MUG-001", 1800, 10), Quantity::new(3).unwrap());
393393 assert_eq!(result, Ok(line("MUG-001", 5)));
394394 }
2.010%0.0rejects_quantity_beyond_stockshop-rs-api/src/domain/model.rs:397
397397 fn rejects_quantity_beyond_stock() {
398398 let result = cart(CartStatus::Open, vec![])
399399 .add(&product("LAMP-1", 12900, 2), Quantity::new(3).unwrap());
400400 assert_eq!(result, Err(DomainError::OutOfStock(sku("LAMP-1"))));
401401 }
2.010%0.0rejects_merged_quantity_beyond_line_maximumshop-rs-api/src/domain/model.rs:404
404404 fn rejects_merged_quantity_beyond_line_maximum() {
405405 let result = cart(CartStatus::Open, vec![line("MUG-001", 98)])
406406 .add(&product("MUG-001", 1800, 1000), Quantity::new(2).unwrap());
407407 assert_eq!(result, Err(DomainError::InvalidQuantity));
408408 }
2.010%0.0rejects_checked_out_cartshop-rs-api/src/domain/model.rs:411
411411 fn rejects_checked_out_cart() {
412412 let result = cart(CartStatus::CheckedOut, vec![])
413413 .add(&product("MUG-001", 1800, 10), Quantity::new(1).unwrap());
414414 assert_eq!(result, Err(DomainError::CartClosed));
415415 }
2.010%0.0totals_every_line_in_centsshop-rs-api/src/domain/model.rs:422
422422 fn totals_every_line_in_cents() {
423423 let products = [product("MUG-001", 1800, 10), product("TEE-M", 2900, 10)];
424424 let draft = cart(CartStatus::Open, vec![line("MUG-001", 2), line("TEE-M", 1)])
425425 .checkout(&products)
426426 .unwrap();
427427 assert_eq!(draft.total.cents(), 6500);
428428 }
2.010%0.0applies_a_discount_percent_before_the_quantityshop-rs-api/src/domain/model.rs:431
431431 fn applies_a_discount_percent_before_the_quantity() {
432432 let products = [product_with_discount("MUG-001", 1800, 10, 10)];
433433 let draft = cart(CartStatus::Open, vec![line("MUG-001", 2)])
434434 .checkout(&products)
435435 .unwrap();
436436 assert_eq!(draft.lines[0].unit_price.cents(), 1620);
437437 assert_eq!(draft.total.cents(), 3240);
438438 }
2.010%0.0rejects_empty_cartshop-rs-api/src/domain/model.rs:441
441441 fn rejects_empty_cart() {
442442 let result = cart(CartStatus::Open, vec![]).checkout(&[]);
443443 assert_eq!(result, Err(DomainError::EmptyCart));
444444 }
2.010%0.0rejects_line_that_exceeds_current_stockshop-rs-api/src/domain/model.rs:447
447447 fn rejects_line_that_exceeds_current_stock() {
448448 let result = cart(CartStatus::Open, vec![line("LAMP-1", 2)])
449449 .checkout(&[product("LAMP-1", 12900, 1)]);
450450 assert_eq!(result, Err(DomainError::OutOfStock(sku("LAMP-1"))));
451451 }
2.010%0.0rejects_checked_out_cartshop-rs-api/src/domain/model.rs:454
454454 fn rejects_checked_out_cart() {
455455 let result = cart(CartStatus::CheckedOut, vec![line("MUG-001", 1)])
456456 .checkout(&[product("MUG-001", 1800, 10)]);
457457 assert_eq!(result, Err(DomainError::CartClosed));
458458 }
2.010%0.0rejects_a_line_whose_product_is_missingshop-rs-api/src/domain/model.rs:461
461461 fn rejects_a_line_whose_product_is_missing() {
462462 let result = cart(CartStatus::Open, vec![line("MUG-001", 1)]).checkout(&[]);
463463 assert_eq!(result, Err(DomainError::ProductNotFound(sku("MUG-001"))));
464464 }
2.010%0.0rejects_a_total_that_overflowsshop-rs-api/src/domain/model.rs:467
467467 fn rejects_a_total_that_overflows() {
468468 let products = [product("MUG-001", i64::MAX, 10)];
469469 let result = cart(CartStatus::Open, vec![line("MUG-001", 2)]).checkout(&products);
470470 assert_eq!(result, Err(DomainError::AmountOverflow));
471471 }
2.010%0.0money_rejects_a_negative_amount_and_an_overflowing_sumshop-rs-api/src/domain/model.rs:475
475475 fn money_rejects_a_negative_amount_and_an_overflowing_sum() {
476476 assert_eq!(Money::from_cents(-1), Err(DomainError::InvalidMoney));
477477 let max = Money::from_cents(i64::MAX).unwrap();
478478 assert_eq!(
479479 max.plus(Money::from_cents(1).unwrap()),
480480 Err(DomainError::AmountOverflow)
481481 );
482482 assert_eq!(
483483 max.times(Quantity::new(2).unwrap()),
484484 Err(DomainError::AmountOverflow)
485485 );
486486 }
2.010%0.0sku_accepts_the_longest_code_and_exposes_itshop-rs-api/src/domain/model.rs:489
489489 fn sku_accepts_the_longest_code_and_exposes_it() {
490490 let longest = "A".repeat(32);
491491 let parsed = Sku::parse(&longest).unwrap();
492492 assert_eq!(parsed.as_str(), longest);
493493 assert_eq!(Sku::parse(&"A".repeat(33)), Err(DomainError::InvalidSku));
494494 }
2.010%0.0cart_id_rejects_a_dash_in_the_wrong_placeshop-rs-api/src/domain/model.rs:497
497497 fn cart_id_rejects_a_dash_in_the_wrong_place() {
498498 assert_eq!(
499499 CartId::parse("0b8f6c1e4a2d-4e1f-9c3b-2d5e7f8a9b0c"),
500500 Err(DomainError::InvalidCartId)
501501 );
502502 }
2.010%0.0every_domain_error_names_the_problemshop-rs-api/src/domain/model.rs:505
505505 fn every_domain_error_names_the_problem() {
506506 let sku = sku("MUG-001");
507507 let cart_id = CartId::parse("0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0c").unwrap();
508508 let messages = [
509509 DomainError::InvalidSku.to_string(),
510510 DomainError::InvalidQuantity.to_string(),
511511 DomainError::InvalidMoney.to_string(),
512512 DomainError::InvalidDiscount.to_string(),
513513 DomainError::InvalidCartId.to_string(),
514514 DomainError::AmountOverflow.to_string(),
515515 DomainError::ProductNotFound(sku.clone()).to_string(),
516516 DomainError::CartNotFound(cart_id.clone()).to_string(),
517517 DomainError::CartClosed.to_string(),
518518 DomainError::EmptyCart.to_string(),
519519 DomainError::OutOfStock(sku).to_string(),
520520 cart_id.to_string(),
521521 ];
522522 assert!(messages.iter().all(|message| !message.is_empty()));
523523 let as_error: &dyn std::error::Error = &DomainError::InvalidSku;
524524 assert_eq!(as_error.to_string(), DomainError::InvalidSku.to_string());
525525 }
2.010%0.0display_includes_the_domain_message_or_the_storage_reasonshop-rs-api/src/domain/port.rs:30
3030 fn display_includes_the_domain_message_or_the_storage_reason() {
3131 let rejected = RepositoryError::Rejected(DomainError::EmptyCart);
3232 let unavailable = RepositoryError::Unavailable("connect".to_owned());
3333 assert_eq!(rejected.to_string(), DomainError::EmptyCart.to_string());
3434 assert_eq!(unavailable.to_string(), "storage unavailable: connect");
3535 let as_error: &dyn std::error::Error = &rejected;
3636 assert!(!as_error.to_string().is_empty());
3737 }
2.010%0.0unavailableshop-rs-api/src/infrastructure/postgres.rs:27
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}
2.010%0.0connect_reports_unavailable_when_the_server_refusesshop-rs-api/src/infrastructure/postgres.rs:314
314314 async fn connect_reports_unavailable_when_the_server_refuses() {
315315 let error = connect("postgres://127.0.0.1:1/shop")
316316 .await
317317 .unwrap_err();
318318 assert_eq!(error, RepositoryError::Unavailable("connect".to_owned()));
319319 assert!(error.to_string().contains("connect"));
320320 }
2.010%0.0cart_status_accepts_both_stored_values_and_rejects_anything_elseshop-rs-api/src/infrastructure/postgres.rs:323
323323 fn cart_status_accepts_both_stored_values_and_rejects_anything_else() {
324324 assert_eq!(cart_status(CART_STATUS_OPEN).unwrap(), CartStatus::Open);
325325 assert_eq!(
326326 cart_status(CART_STATUS_CHECKED_OUT).unwrap(),
327327 CartStatus::CheckedOut
328328 );
329329 assert_eq!(
330330 cart_status("archived").unwrap_err(),
331331 RepositoryError::Unavailable("decode_cart".to_owned())
332332 );
333333 }
2.010%0.0product_parts_reject_values_the_schema_also_forbidsshop-rs-api/src/infrastructure/postgres.rs:336
336336 fn product_parts_reject_values_the_schema_also_forbids() {
337337 assert!(product_from_parts("MUG-STONE", "Mug".to_owned(), 1800, 10, 4).is_ok());
338338 assert!(matches!(
339339 product_from_parts("no", "Mug".to_owned(), 1800, 0, 4),
340340 Err(RepositoryError::Rejected(DomainError::InvalidSku))
341341 ));
342342 assert!(matches!(
343343 product_from_parts("MUG-STONE", "Mug".to_owned(), -1, 0, 4),
344344 Err(RepositoryError::Rejected(DomainError::InvalidMoney))
345345 ));
346346 assert!(matches!(
347347 product_from_parts("MUG-STONE", "Mug".to_owned(), 1800, 0, -1),
348348 Err(RepositoryError::Rejected(DomainError::InvalidQuantity))
349349 ));
350350 }
2.010%0.0cart_line_parts_reject_a_bad_sku_or_quantityshop-rs-api/src/infrastructure/postgres.rs:353
353353 fn cart_line_parts_reject_a_bad_sku_or_quantity() {
354354 assert_eq!(
355355 cart_line_from_parts("MUG-STONE", 2)
356356 .unwrap()
357357 .quantity
358358 .value(),
359359 2
360360 );
361361 assert!(matches!(
362362 cart_line_from_parts("no", 2),
363363 Err(RepositoryError::Rejected(DomainError::InvalidSku))
364364 ));
365365 assert!(matches!(
366366 cart_line_from_parts("MUG-STONE", 0),
367367 Err(RepositoryError::Rejected(DomainError::InvalidQuantity))
368368 ));
369369 }
2.010%0.0quantity_column_rejects_a_value_that_does_not_fit_the_databaseshop-rs-api/src/infrastructure/postgres.rs:372
372372 fn quantity_column_rejects_a_value_that_does_not_fit_the_database() {
373373 assert_eq!(quantity_column(2).unwrap(), 2);
374374 assert!(matches!(
375375 quantity_column(u32::MAX),
376376 Err(RepositoryError::Rejected(DomainError::InvalidQuantity))
377377 ));
378378 }
2.010%0.0order_outcomes_distinguish_a_closed_cart_and_a_missed_reservationshop-rs-api/src/infrastructure/postgres.rs:381
381381 fn order_outcomes_distinguish_a_closed_cart_and_a_missed_reservation() {
382382 let sku = Sku::parse("LAMP-DESK").unwrap();
383383 assert!(close_outcome(1).is_ok());
384384 assert_eq!(
385385 close_outcome(0).unwrap_err(),
386386 RepositoryError::Rejected(DomainError::CartClosed)
387387 );
388388 assert!(reserve_outcome(1, &sku).is_ok());
389389 assert_eq!(
390390 reserve_outcome(0, &sku).unwrap_err(),
391391 RepositoryError::Rejected(DomainError::OutOfStock(sku))
392392 );
393393 }
2.010%0.0fromshop-rs-api/src/infrastructure/web.rs:203
203203 fn from(rejection: JsonRejection) -> Self {
204204 Self {
205205 status: StatusCode::UNPROCESSABLE_ENTITY,
206206 code: "invalid_body",
207207 message: rejection.body_text(),
208208 }
209209 }
2.010%0.0skushop-rs-api/src/infrastructure/web.rs:485
485485 fn sku(raw: &str) -> Sku {
486486 Sku::parse(raw).unwrap()
487487 }
2.010%0.0productshop-rs-api/src/infrastructure/web.rs:489
489489 fn product(raw: &str, price: i64, stock: u32) -> Product {
490490 Product {
491491 sku: sku(raw),
492492 name: raw.to_owned(),
493493 price: Money::from_cents(price).unwrap(),
494494 discount_percent: DiscountPercent::none(),
495495 stock,
496496 }
497497 }
2.010%0.0stateshop-rs-api/src/infrastructure/web.rs:499
499499 fn state(
500500 products: Vec<Product>,
501501 carts: Vec<Cart>,
502502 product_fail: Option<RepositoryError>,
503503 cart_fail: Option<RepositoryError>,
504504 order_fail: Option<RepositoryError>,
505505 ) -> AppState {
506506 AppState {
507507 catalog: Arc::new(CatalogService::new(Arc::new(Products {
508508 fail: product_fail,
509509 products: products.clone(),
510510 }))),
511511 carts: Arc::new(CartService::new(
512512 Arc::new(Products {
513513 fail: None,
514514 products,
515515 }),
516516 Arc::new(Carts {
517517 carts: Mutex::new(carts),
518518 fail: cart_fail,
519519 }),
520520 Arc::new(Orders { fail: order_fail }),
521521 )),
522522 }
523523 }
2.010%0.0open_cart_modelshop-rs-api/src/infrastructure/web.rs:525
525525 fn open_cart_model() -> Cart {
526526 Cart {
527527 id: CartId::parse(CART_ID).unwrap(),
528528 status: CartStatus::Open,
529529 lines: Vec::new(),
530530 }
531531 }
2.010%0.0health_and_openapi_respondshop-rs-api/src/infrastructure/web.rs:620
620620 async fn health_and_openapi_respond() {
621621 let app = state(
622622 vec![product("MUG-STONE", 1800, 10)],
623623 vec![],
624624 None,
625625 None,
626626 None,
627627 );
628628 let (status, _) = send(app.clone(), "GET", "/health", None).await;
629629 assert_eq!(status, StatusCode::OK);
630630 let (status, body) = send(app, "GET", "/openapi.json", None).await;
631631 assert_eq!(status, StatusCode::OK);
632632 assert!(body.contains("shop-rs-api"));
633633 }
2.010%0.0catalog_lists_and_finds_productsshop-rs-api/src/infrastructure/web.rs:636
636636 async fn catalog_lists_and_finds_products() {
637637 let app = state(
638638 vec![product("MUG-STONE", 1800, 10)],
639639 vec![],
640640 None,
641641 None,
642642 None,
643643 );
644644 let (status, body) = send(app.clone(), "GET", "/v1/products", None).await;
645645 assert_eq!(status, StatusCode::OK);
646646 assert!(body.contains("MUG-STONE"));
647647 let (status, body) = send(app.clone(), "GET", "/v1/products/MUG-STONE", None).await;
648648 assert_eq!(status, StatusCode::OK);
649649 assert!(body.contains("1800"));
650650 let (status, body) = send(app.clone(), "GET", "/v1/products/no", None).await;
651651 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
652652 assert!(body.contains("invalid_sku"));
653653 let (status, body) = send(app, "GET", "/v1/products/NOPE-1", None).await;
654654 assert_eq!(status, StatusCode::NOT_FOUND);
655655 assert!(body.contains("product_not_found"));
656656 }
2.010%0.0catalog_reports_storage_failureshop-rs-api/src/infrastructure/web.rs:659
659659 async fn catalog_reports_storage_failure() {
660660 let app = state(
661661 vec![],
662662 vec![],
663663 Some(RepositoryError::Unavailable("connect".to_owned())),
664664 None,
665665 None,
666666 );
667667 let (status, body) = send(app, "GET", "/v1/products", None).await;
668668 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
669669 assert!(body.contains("storage_unavailable"));
670670 }
2.010%0.0cart_opens_adds_and_checks_outshop-rs-api/src/infrastructure/web.rs:673
673673 async fn cart_opens_adds_and_checks_out() {
674674 let app = state(
675675 vec![product("MUG-STONE", 1800, 10)],
676676 vec![],
677677 None,
678678 None,
679679 None,
680680 );
681681 let (status, body) = send(app.clone(), "POST", "/v1/carts", None).await;
682682 assert_eq!(status, StatusCode::CREATED);
683683 assert!(body.contains(CART_ID));
684684 let (status, body) = send(
685685 app.clone(),
686686 "POST",
687687 &format!("/v1/carts/{CART_ID}/items"),
688688 Some(r#"{"sku":"MUG-STONE","quantity":2}"#),
689689 )
690690 .await;
691691 assert_eq!(status, StatusCode::OK);
692692 assert!(body.contains("MUG-STONE"));
693693 let (status, body) = send(
694694 app.clone(),
695695 "POST",
696696 &format!("/v1/carts/{CART_ID}/items"),
697697 Some(r#"{"sku":"MUG-STONE","quantity":1}"#),
698698 )
699699 .await;
700700 assert_eq!(status, StatusCode::OK);
701701 assert!(body.contains("\"quantity\":3"));
702702 let (status, body) = send(app.clone(), "GET", &format!("/v1/carts/{CART_ID}"), None).await;
703703 assert_eq!(status, StatusCode::OK);
704704 assert!(body.contains("open"));
705705 let (status, body) =
706706 send(app, "POST", &format!("/v1/carts/{CART_ID}/checkout"), None).await;
707707 assert_eq!(status, StatusCode::CREATED);
708708 assert!(body.contains("5400"));
709709 }
2.010%0.0cart_rejects_bad_input_and_conflictsshop-rs-api/src/infrastructure/web.rs:712
712712 async fn cart_rejects_bad_input_and_conflicts() {
713713 let closed = Cart {
714714 id: CartId::parse(CART_ID).unwrap(),
715715 status: CartStatus::CheckedOut,
716716 lines: vec![CartLine {
717717 sku: sku("MUG-STONE"),
718718 quantity: Quantity::new(1).unwrap(),
719719 }],
720720 };
721721 let app = state(
722722 vec![
723723 product("MUG-STONE", 1800, 10),
724724 product("LAMP-DESK", 12900, 1),
725725 ],
726726 vec![closed.clone()],
727727 None,
728728 None,
729729 None,
730730 );
731731 let (status, _) = send(app.clone(), "GET", "/v1/carts/not-a-uuid", None).await;
732732 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
733733 let (status, _) = send(
734734 app.clone(),
735735 "GET",
736736 "/v1/carts/0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0d",
737737 None,
738738 )
739739 .await;
740740 assert_eq!(status, StatusCode::NOT_FOUND);
741741 let (status, body) = send(app.clone(), "GET", &format!("/v1/carts/{CART_ID}"), None).await;
742742 assert_eq!(status, StatusCode::OK);
743743 assert!(body.contains("checked_out"));
744744 let (status, _) = send(
745745 app.clone(),
746746 "POST",
747747 &format!("/v1/carts/{CART_ID}/items"),
748748 Some("not-json"),
749749 )
750750 .await;
751751 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
752752 let (status, body) = send(
753753 app.clone(),
754754 "POST",
755755 &format!("/v1/carts/{CART_ID}/items"),
756756 Some(r#"{"sku":"NO","quantity":1}"#),
757757 )
758758 .await;
759759 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
760760 assert!(body.contains("invalid_sku"));
761761 let (status, body) = send(
762762 app.clone(),
763763 "POST",
764764 &format!("/v1/carts/{CART_ID}/items"),
765765 Some(r#"{"sku":"MUG-STONE","quantity":0}"#),
766766 )
767767 .await;
768768 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
769769 assert!(body.contains("invalid_quantity"));
770770 let (status, _) = send(
771771 app.clone(),
772772 "POST",
773773 "/v1/carts/0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0d/checkout",
774774 None,
775775 )
776776 .await;
777777 assert_eq!(status, StatusCode::NOT_FOUND);
778778 let (status, body) =
779779 send(app, "POST", &format!("/v1/carts/{CART_ID}/checkout"), None).await;
780780 assert_eq!(status, StatusCode::CONFLICT);
781781 assert!(body.contains("cart_closed"));
782782 }
2.010%0.0checkout_reports_an_empty_cart_stock_conflict_and_overflowshop-rs-api/src/infrastructure/web.rs:785
785785 async fn checkout_reports_an_empty_cart_stock_conflict_and_overflow() {
786786 let empty = open_cart_model();
787787 let app = state(
788788 vec![
789789 product("MUG-STONE", 1800, 10),
790790 product("LAMP-DESK", i64::MAX, 10),
791791 ],
792792 vec![empty],
793793 None,
794794 None,
795795 None,
796796 );
797797 let (status, body) = send(
798798 app.clone(),
799799 "POST",
800800 &format!("/v1/carts/{CART_ID}/checkout"),
801801 None,
802802 )
803803 .await;
804804 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
805805 assert!(body.contains("empty_cart"));
806806 let (status, _) = send(
807807 app.clone(),
808808 "POST",
809809 &format!("/v1/carts/{CART_ID}/items"),
810810 Some(r#"{"sku":"LAMP-DESK","quantity":2}"#),
811811 )
812812 .await;
813813 assert_eq!(status, StatusCode::OK);
814814 let (status, body) = send(
815815 app.clone(),
816816 "POST",
817817 &format!("/v1/carts/{CART_ID}/checkout"),
818818 None,
819819 )
820820 .await;
821821 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
822822 assert!(body.contains("amount_overflow"));
823823 let stock = state(
824824 vec![product("LAMP-DESK", 12900, 1)],
825825 vec![open_cart_model()],
826826 None,
827827 None,
828828 None,
829829 );
830830 let (status, _) = send(
831831 stock.clone(),
832832 "POST",
833833 &format!("/v1/carts/{CART_ID}/items"),
834834 Some(r#"{"sku":"LAMP-DESK","quantity":3}"#),
835835 )
836836 .await;
837837 assert_eq!(status, StatusCode::CONFLICT);
838838 let missing = state(
839839 vec![product("MUG-STONE", 1800, 10)],
840840 vec![],
841841 None,
842842 None,
843843 None,
844844 );
845845 let (status, _) = send(
846846 missing,
847847 "POST",
848848 &format!("/v1/carts/{CART_ID}/items"),
849849 Some(r#"{"sku":"MUG-STONE","quantity":1}"#),
850850 )
851851 .await;
852852 assert_eq!(status, StatusCode::NOT_FOUND);
853853 }
2.010%0.0cart_storage_failure_is_a_server_errorshop-rs-api/src/infrastructure/web.rs:856
856856 async fn cart_storage_failure_is_a_server_error() {
857857 let app = state(
858858 vec![product("MUG-STONE", 1800, 10)],
859859 vec![],
860860 None,
861861 Some(RepositoryError::Unavailable("connect".to_owned())),
862862 None,
863863 );
864864 let (status, body) = send(app, "POST", "/v1/carts", None).await;
865865 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
866866 assert!(body.contains("storage_unavailable"));
867867 }
2.02100%0.0mainshop-rs-api/src/main.rs:15
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}
2.02100%0.0wait_for_shutdownshop-rs-api/src/main.rs:57
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}
2.02100%0.0messageOfshop-ts-web-react/src/Shop.tsx:20
2020function messageOf(error: unknown): string {
2121 if (error instanceof ShopRequestError) return error.message;
2222 return "something went wrong";
2323}
2.02100%0.0useEffectshop-ts-web-react/src/Shop.tsx:33
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]);
2.02100%0.0thenshop-ts-web-react/src/Shop.tsx:36
3636 .then((loaded) => {
3737 if (active) setProducts(loaded);
3838 })
2.02100%0.0<arrow>shop-ts-web-react/src/Shop.tsx:39
3939 .catch((failure: unknown) => {
4040 if (active) setError(messageOf(failure));
4141 });
2.02100%0.0addshop-ts-web-react/src/Shop.tsx:47
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 }
2.02100%0.0checkoutshop-ts-web-react/src/Shop.tsx:65
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 }
2.02100%0.0mountShopshop-ts-web-react/src/mount.tsx:5
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}
1.4125%0.0constructorshop-ts-lib-cart/src/cart.ts:24
2424 constructor(readonly percent: number) {
2525 super(`discount percent must be an integer from 0 through 100, got ${percent}`);
2626 this.name = "InvalidDiscountError";
2727 }
1.0190%0.0connectshop-rs-api/src/infrastructure/postgres.rs:15
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}
1.01100%0.0<fn>shop-rs-api/src/application/service.rs:28
2828 pub fn new(products: Arc<dyn ProductRepository>) -> Self {
2929 Self { products }
3030 }
1.01100%0.0list_productsshop-rs-api/src/application/service.rs:31
3131 pub async fn list_products(&self) -> Result<Vec<Product>, ShopError> {
3232 Ok(self.products.list().await?)
3333 }
1.01100%0.0<fn>shop-rs-api/src/application/service.rs:49
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 }
1.01100%0.0open_cartshop-rs-api/src/application/service.rs:60
6060 pub async fn open_cart(&self) -> Result<Cart, ShopError> {
6161 Ok(self.carts.create().await?)
6262 }
1.01100%0.0checkoutshop-rs-api/src/application/service.rs:85
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 }
1.01100%0.0zeroshop-rs-api/src/domain/model.rs:62
6262 pub fn zero() -> Self {
6363 Self(0)
6464 }
1.01100%0.0centsshop-rs-api/src/domain/model.rs:65
6565 pub fn cents(self) -> i64 {
6666 self.0
6767 }
1.01100%0.0timesshop-rs-api/src/domain/model.rs:68
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 }
1.01100%0.0plusshop-rs-api/src/domain/model.rs:74
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 }
1.01100%0.0discountedshop-rs-api/src/domain/model.rs:80
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 }
1.01100%0.0as_strshop-rs-api/src/domain/model.rs:101
101101 pub fn as_str(&self) -> &str {
102102 &self.0
103103 }
1.01100%0.0valueshop-rs-api/src/domain/model.rs:119
119119 pub fn value(self) -> u8 {
120120 self.0
121121 }
1.01100%0.0fmtshop-rs-api/src/domain/model.rs:125
125125 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
126126 formatter.write_str(&self.0)
127127 }
1.01100%0.0<fn>shop-rs-api/src/domain/model.rs:134
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 }
1.01100%0.0valueshop-rs-api/src/domain/model.rs:141
141141 pub fn value(self) -> u32 {
142142 self.0
143143 }
1.01100%0.0as_strshop-rs-api/src/domain/model.rs:164
164164 pub fn as_str(&self) -> &str {
165165 &self.0
166166 }
1.01100%0.0fmtshop-rs-api/src/domain/model.rs:170
170170 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
171171 formatter.write_str(&self.0)
172172 }
1.01100%0.0addshop-rs-api/src/domain/model.rs:235
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 }
1.01100%0.0checkedshop-rs-api/src/infrastructure/postgres.rs:32
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}
1.01100%0.0product_from_rowshop-rs-api/src/infrastructure/postgres.rs:39
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}
1.01100%0.0product_from_partsshop-rs-api/src/infrastructure/postgres.rs:48
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}
1.01100%0.0<fn>shop-rs-api/src/infrastructure/postgres.rs:71
7171 pub fn new(pool: PgPool) -> Self {
7272 Self { pool }
7373 }
1.01100%0.0listshop-rs-api/src/infrastructure/postgres.rs:78
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"#,
8282 )
8383 .fetch_all(&self.pool)
8484 .await,
8585 "list_products",
8686 )?;
8787 rows.iter().map(product_from_row).collect()
8888 }
1.01100%0.0find_by_skushop-rs-api/src/infrastructure/postgres.rs:89
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"#,
9393 )
9494 .bind(sku.as_str())
9595 .fetch_optional(&self.pool)
9696 .await,
9797 "find_product",
9898 )?;
9999 row.as_ref().map(product_from_row).transpose()
100100 }
1.01100%0.0find_by_skusshop-rs-api/src/infrastructure/postgres.rs:101
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)"#,
106106 )
107107 .bind(&values)
108108 .fetch_all(&self.pool)
109109 .await,
110110 "find_products",
111111 )?;
112112 rows.iter().map(product_from_row).collect()
113113 }
1.01100%0.0<fn>shop-rs-api/src/infrastructure/postgres.rs:121
121121 pub fn new(pool: PgPool) -> Self {
122122 Self { pool }
123123 }
1.01100%0.0cart_line_from_rowshop-rs-api/src/infrastructure/postgres.rs:137
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}
1.01100%0.0cart_line_from_partsshop-rs-api/src/infrastructure/postgres.rs:143
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}
1.01100%0.0quantity_columnshop-rs-api/src/infrastructure/postgres.rs:150
150150fn quantity_column(quantity: u32) -> Result<i32, RepositoryError> {
151151 i32::try_from(quantity).map_err(|_| RepositoryError::Rejected(DomainError::InvalidQuantity))
152152}
1.01100%0.0createshop-rs-api/src/infrastructure/postgres.rs:174
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)
178178 .fetch_one(&self.pool)
179179 .await,
180180 "create_cart",
181181 )?;
182182 let id: String = checked(row.try_get("id"), "decode_cart")?;
183183 Ok(Cart {
184184 id: CartId::parse(&id).map_err(RepositoryError::Rejected)?,
185185 status: CartStatus::Open,
186186 lines: Vec::new(),
187187 })
188188 }
1.01100%0.0findshop-rs-api/src/infrastructure/postgres.rs:189
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())
193193 .fetch_optional(&self.pool)
194194 .await,
195195 "find_cart",
196196 )?
197197 else {
198198 return Ok(None);
199199 };
200200 let status: String = checked(row.try_get("status"), "decode_cart")?;
201201 let line_rows = checked(
202202 sqlx::query(
203203 r#"SELECT SKU, QUANTITY FROM cart_line WHERE CART_ID = $1::uuid ORDER BY SKU"#,
204204 )
205205 .bind(cart_id.as_str())
206206 .fetch_all(&self.pool)
207207 .await,
208208 "find_cart_lines",
209209 )?;
210210 Ok(Some(Cart {
211211 id: cart_id.clone(),
212212 status: cart_status(&status)?,
213213 lines: line_rows
214214 .iter()
215215 .map(cart_line_from_row)
216216 .collect::<Result<_, _>>()?,
217217 }))
218218 }
1.01100%0.0save_lineshop-rs-api/src/infrastructure/postgres.rs:219
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(
223223 r#"INSERT INTO cart_line (CART_ID, SKU, QUANTITY) VALUES ($1::uuid, $2, $3)
224224 ON CONFLICT (CART_ID, SKU) DO UPDATE SET QUANTITY = EXCLUDED.QUANTITY"#,
225225 )
226226 .bind(cart_id.as_str())
227227 .bind(line.sku.as_str())
228228 .bind(quantity)
229229 .execute(&self.pool)
230230 .await,
231231 "save_cart_line",
232232 )?;
233233 Ok(())
234234 }
1.01100%0.0<fn>shop-rs-api/src/infrastructure/postgres.rs:242
242242 pub fn new(pool: PgPool) -> Self {
243243 Self { pool }
244244 }
1.01100%0.0fromshop-rs-api/src/infrastructure/web.rs:34
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 }
1.01100%0.0fromshop-rs-api/src/infrastructure/web.rs:58
5858 fn from(line: CartLine) -> Self {
5959 Self {
6060 sku: line.sku.as_str().to_owned(),
6161 quantity: line.quantity.value(),
6262 }
6363 }
1.01100%0.0fromshop-rs-api/src/infrastructure/web.rs:90
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 }
1.01100%0.0fromshop-rs-api/src/infrastructure/web.rs:107
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 }
1.01100%0.0fromshop-rs-api/src/infrastructure/web.rs:125
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 }
1.01100%0.0into_responseshop-rs-api/src/infrastructure/web.rs:213
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 }
1.01100%0.0healthshop-rs-api/src/infrastructure/web.rs:225
225225async fn health() -> StatusCode {
226226 StatusCode::OK
227227}
1.01100%0.0list_productsshop-rs-api/src/infrastructure/web.rs:231
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}
1.01100%0.0find_productshop-rs-api/src/infrastructure/web.rs:248
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}
1.01100%0.0open_cartshop-rs-api/src/infrastructure/web.rs:258
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}
1.01100%0.0find_cartshop-rs-api/src/infrastructure/web.rs:268
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}
1.01100%0.0add_cart_itemshop-rs-api/src/infrastructure/web.rs:281
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}
1.01100%0.0checkoutshop-rs-api/src/infrastructure/web.rs:303
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}
1.01100%0.0openapishop-rs-api/src/infrastructure/web.rs:344
344344async fn openapi() -> Json<utoipa::openapi::OpenApi> {
345345 Json(ApiDocument::openapi())
346346}
1.01100%0.0routershop-rs-api/src/infrastructure/web.rs:348
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}
1.01100%0.0runshop-rs-api/src/main.rs:25
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}
1.01100%0.0bind_failureshop-rs-api/src/main.rs:51
5151fn bind_failure(address: &str, error: &std::io::Error) -> String {
5252 error!(error = %error, %address, "bind failed");
5353 format!("bind {address}: {error}")
5454}
1.01100%0.0constructorshop-ts-lib-cart/src/cart.ts:10
1010 constructor(readonly amount: number) {
1111 super(`amount must be a non-negative safe integer of cents, got ${amount}`);
1212 this.name = "InvalidAmountError";
1313 }
1.01100%0.0constructorshop-ts-lib-cart/src/cart.ts:17
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 }
1.01100%0.0cartTotalshop-ts-lib-cart/src/cart.ts:57
5757export function cartTotal(lines: readonly CartLine[]): Cents {
5858 return lines.reduce<Cents>((total, line) => cents(total + lineTotal(line)), cents(0));
5959}
1.01100%0.0formatCentsshop-ts-lib-cart/src/cart.ts:61
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}
1.01100%0.0pricedLineshop-ts-web-react/src/Shop.tsx:13
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}
1.01100%0.0Shopshop-ts-web-react/src/Shop.tsx:25
2525export function Shop({ apiBase }: Props) {
1.01100%0.0<arrow>shop-ts-web-react/src/Shop.tsx:42
4242 return () => {
4343 active = false;
4444 };
1.01100%0.0mapshop-ts-web-react/src/Shop.tsx:90
9090 {(products ?? []).map((product) => (
9191 <li key={product.sku}>
1.01100%0.0<arrow>shop-ts-web-react/src/Shop.tsx:100
100100 onChange={(event) => setQuantities({ ...quantities, [product.sku]: event.target.value })}
1.01100%0.0<arrow>shop-ts-web-react/src/Shop.tsx:103
103103 <button type="button" disabled={busy} onClick={() => void add(product)}>
104104 Add {product.name}
1.01100%0.0mapshop-ts-web-react/src/Shop.tsx:113
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 })}
1.01100%0.0findshop-ts-web-react/src/Shop.tsx:114
114114 const product = products?.find((candidate) => candidate.sku === line.sku);
115115 return (
116116 <li key={line.sku}>
1.01100%0.0constructorshop-ts-web-react/src/shopApi.ts:27
2727 constructor(
2828 readonly status: number,
2929 readonly code: string,
3030 message: string,
3131 ) {
3232 super(message);
3333 this.name = "ShopRequestError";
3434 }
1.01100%0.0productFromBodyshop-ts-web-react/src/shopApi.ts:57
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}
1.01100%0.0cartFromBodyshop-ts-web-react/src/shopApi.ts:67
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}
1.01100%0.0mapshop-ts-web-react/src/shopApi.ts:71
7171 lines: body.lines.map((line) => ({ sku: line.sku, quantity: line.quantity })),
1.01100%0.0listProductsshop-ts-web-react/src/shopApi.ts:99
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}
1.01100%0.0openCartshop-ts-web-react/src/shopApi.ts:104
104104export async function openCart(apiBase: string): Promise<Cart> {
105105 return cartFromBody(await requestJson<CartBody>(apiBase, "/v1/carts", { method: "POST" }));
106106}
1.01100%0.0addCartItemshop-ts-web-react/src/shopApi.ts:108
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}
1.01100%0.0checkoutCartshop-ts-web-react/src/shopApi.ts:118
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}