projects
| project | package | unit | unit_fn | it | it_fn | merged | status |
|---|---|---|---|---|---|---|---|
| shop | shop-rs-api | 973/1011 · 973 cov · 38 unc · 96.2% | 157/173 · 90.8% | 459/503 · 459 cov · 44 unc · 91.3% | 110/133 · 82.7% | 1052/1052 · 1052 cov · 0 unc · 100.0% | ok |
| shop | shop-ts-lib-cart | 33/33 · 33 cov · 0 unc · 100.0% | 6/6 · 100.0% | 23/33 · 23 cov · 10 unc · 69.7% | 4/6 · 66.7% | 33/33 · 33 cov · 0 unc · 100.0% | ok |
| shop | shop-ts-web-react | 181/181 · 181 cov · 0 unc · 100.0% | 17/17 · 100.0% | - | - | 191/191 · 191 cov · 0 unc · 100.0% | ok |
| ALL | (changed) | 1187/1225 · 1187 cov · 38 unc · 96.9% | 180/196 · 91.8% | 482/536 · 482 cov · 54 unc · 89.9% | 114/139 · 82.0% | 1276/1276 · 1276 cov · 0 unc · 100.0% | ALL |
Test phases, screenshots, logs and source coverage
tests
shop102 run · 101 ok · 1 fail · 0 skip · 3247ms total · 87ms avg · 1ms fastest · 486ms slowest
cart4 run · 4 ok · 0 fail · 0 skip · 101ms total · 33ms avg · 1ms fastest · 96ms slowest
adds_line_to_cart ok · 96ms
runoldnew±codecov
8080 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8181 response.assert_status(422);
8282 assert_eq!(
8383 response.response_body_json()?["error"]["code"],
8484 "invalid_quantity"
8585 );
8686 }
8787
8888 #[test_it(scope = "shop_checkout")]
8989 async fn shop_project_cart_suite_rejects_quantity_beyond_stock_test(env: TestEnv) {
9090 let cart_id = open_cart(&env).await?;
9191 let response = add_item(&env, &cart_id, "LAMP-DESK", 3).await?;
9292 response.assert_status(409);
9393 assert_eq!(
9494 response.response_body_json()?["error"]["code"],
9595 "out_of_stock"
9696 );
9797 }
9898
9999 #[test_it(scope = "shop_checkout")]
100100 async fn shop_project_cart_suite_adds_line_to_cart_test(env: TestEnv) {
101101 let cart_id = open_cart(&env).await?;
102102 let response = add_item(&env, &cart_id, "MUG-STONE", 2).await?;
103103 response.assert_status(200);
105105 .project("shop")
106106 .service("api")?
107107 .get(&format!("/v1/carts/{cart_id}"))
109109 .await;
112112 assert_eq!(body["status"], "open");
113113 assert_eq!(body["lines"][0]["sku"], "MUG-STONE");
114114 assert_eq!(body["lines"][0]["quantity"], 2);
115115 }
116116
117117 #[test_it(scope = "shop_checkout")]
118118 async fn shop_project_checkout_suite_places_order_and_decrements_stock_test(env: TestEnv) {
119119 let stock_before = stock_of(&env, "NB-DOT-A5").await?;
120120 let cart_id = open_cart(&env).await?;
rejects_quantity_beyond_stock ok · 4ms
runoldnew±codecov
6969 .await;
7070 response.assert_status(404);
7171 assert_eq!(
7272 response.response_body_json()?["error"]["code"],
7373 "product_not_found"
7474 );
7575 }
7676
7777 #[test_it(scope = "shop_checkout")]
7878 async fn shop_project_cart_suite_rejects_zero_quantity_test(env: TestEnv) {
7979 let cart_id = open_cart(&env).await?;
8080 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8181 response.assert_status(422);
8282 assert_eq!(
8383 response.response_body_json()?["error"]["code"],
8484 "invalid_quantity"
8585 );
8686 }
8787
8888 #[test_it(scope = "shop_checkout")]
8989 async fn shop_project_cart_suite_rejects_quantity_beyond_stock_test(env: TestEnv) {
9090 let cart_id = open_cart(&env).await?;
9191 let response = add_item(&env, &cart_id, "LAMP-DESK", 3).await?;
9292 response.assert_status(409);
9393 assert_eq!(
9494 response.response_body_json()?["error"]["code"],
9595 "out_of_stock"
9696 );
9797 }
9898
9999 #[test_it(scope = "shop_checkout")]
100100 async fn shop_project_cart_suite_adds_line_to_cart_test(env: TestEnv) {
101101 let cart_id = open_cart(&env).await?;
102102 let response = add_item(&env, &cart_id, "MUG-STONE", 2).await?;
103103 response.assert_status(200);
105105 .project("shop")
106106 .service("api")?
107107 .get(&format!("/v1/carts/{cart_id}"))
109109 .await;
rejects_zero_quantity ok · 1ms
runoldnew±codecov
5858 assert_eq!(body["items"][0]["sku"], "LAMP-DESK");
5959 assert_eq!(body["items"][0]["price_cents"], 12900);
6060 }
6161
6262 #[test_it(scope = "shop_checkout")]
6363 async fn shop_project_catalog_suite_unknown_sku_not_found_test(env: TestEnv) {
6464 let response = env
6565 .project("shop")
6666 .service("api")?
6767 .get("/v1/products/NOPE-0000")
6969 .await;
7070 response.assert_status(404);
7171 assert_eq!(
7272 response.response_body_json()?["error"]["code"],
7373 "product_not_found"
7474 );
7575 }
7676
7777 #[test_it(scope = "shop_checkout")]
7878 async fn shop_project_cart_suite_rejects_zero_quantity_test(env: TestEnv) {
7979 let cart_id = open_cart(&env).await?;
8080 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8181 response.assert_status(422);
8282 assert_eq!(
8383 response.response_body_json()?["error"]["code"],
8484 "invalid_quantity"
8585 );
8686 }
8787
8888 #[test_it(scope = "shop_checkout")]
8989 async fn shop_project_cart_suite_rejects_quantity_beyond_stock_test(env: TestEnv) {
9090 let cart_id = open_cart(&env).await?;
9191 let response = add_item(&env, &cart_id, "LAMP-DESK", 3).await?;
9292 response.assert_status(409);
9393 assert_eq!(
9494 response.response_body_json()?["error"]["code"],
9595 "out_of_stock"
9696 );
9797 }
9898
unknown_cart_not_found ok · 0ms
runoldnew±codecov
158158 }
159159
160160 #[test_it(scope = "shop_checkout")]
161161 async fn shop_project_catalog_suite_serves_openapi_test(env: TestEnv) {
162162 let response = env
163163 .project("shop")
164164 .service("api")?
165165 .get("/openapi.json")
167167 .await;
168168 response.assert_status(200);
169169 assert!(
170170 response
171171 .response_body_text()
172172 .contains("\"title\":\"shop-rs-api\"")
173173 || response.response_body_text().contains("shop-rs-api")
174174 );
175175 }
176176
177177 #[test_it(scope = "shop_checkout")]
178178 async fn shop_project_cart_suite_unknown_cart_not_found_test(env: TestEnv) {
179179 let response = env
180180 .project("shop")
181181 .service("api")?
182182 .get("/v1/carts/0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0d")
184184 .await;
185185 response.assert_status(404);
186186 assert_eq!(
187187 response.response_body_json()?["error"]["code"],
188188 "cart_not_found"
189189 );
190190 }
191191
192192 #[test_it(scope = "shop_checkout")]
193193 async fn shop_project_checkout_suite_rejects_empty_cart_test(env: TestEnv) {
194194 let cart_id = open_cart(&env).await?;
195195 let response = env
196196 .project("shop")
197197 .service("api")?
198198 .post(&format!("/v1/carts/{cart_id}/checkout"))
catalog4 run · 4 ok · 0 fail · 0 skip · 3ms total · 1ms avg · 1ms fastest · 1ms slowest
lists_seeded_products ok · 1ms
runoldnew±codecov
2828 .post(&format!("/v1/carts/{cart_id}/items"))
2929 .json_body(&body)?
3131 .await)
3232 }
3333
3434 async 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}"))
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 #[test_it(scope = "shop_checkout")]
4848 async 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")
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 }
6161
6262 #[test_it(scope = "shop_checkout")]
6363 async fn shop_project_catalog_suite_unknown_sku_not_found_test(env: TestEnv) {
6464 let response = env
6565 .project("shop")
6666 .service("api")?
6767 .get("/v1/products/NOPE-0000")
rejects_invalid_sku ok · 0ms
runoldnew±codecov
126126 .service("api")?
127127 .post(&format!("/v1/carts/{cart_id}/checkout"))
129129 .await;
130130 response.assert_status(201);
131131 let order = response.response_body_json()?;
132132 assert_eq!(order["total_cents"], 4200);
133133 assert_eq!(order["cart_id"], cart_id.as_str());
134134 assert_eq!(stock_of(&env, "NB-DOT-A5").await?, stock_before - 3);
136136 .project("shop")
137137 .service("api")?
138138 .get(&format!("/v1/carts/{cart_id}"))
140140 .await;
143143 }
144144
145145 #[test_it(scope = "shop_checkout")]
146146 async fn shop_project_catalog_suite_rejects_invalid_sku_test(env: TestEnv) {
147147 let response = env
148148 .project("shop")
149149 .service("api")?
150150 .get("/v1/products/no")
152152 .await;
153153 response.assert_status(422);
154154 assert_eq!(
155155 response.response_body_json()?["error"]["code"],
156156 "invalid_sku"
157157 );
158158 }
159159
160160 #[test_it(scope = "shop_checkout")]
161161 async fn shop_project_catalog_suite_serves_openapi_test(env: TestEnv) {
162162 let response = env
163163 .project("shop")
164164 .service("api")?
165165 .get("/openapi.json")
serves_openapi ok · 1ms
runoldnew±codecov
143143 }
144144
145145 #[test_it(scope = "shop_checkout")]
146146 async fn shop_project_catalog_suite_rejects_invalid_sku_test(env: TestEnv) {
147147 let response = env
148148 .project("shop")
149149 .service("api")?
150150 .get("/v1/products/no")
152152 .await;
153153 response.assert_status(422);
154154 assert_eq!(
155155 response.response_body_json()?["error"]["code"],
156156 "invalid_sku"
157157 );
158158 }
159159
160160 #[test_it(scope = "shop_checkout")]
161161 async fn shop_project_catalog_suite_serves_openapi_test(env: TestEnv) {
162162 let response = env
163163 .project("shop")
164164 .service("api")?
165165 .get("/openapi.json")
167167 .await;
168168 response.assert_status(200);
169169 assert!(
170170 response
171171 .response_body_text()
172172 .contains("\"title\":\"shop-rs-api\"")
173173 || response.response_body_text().contains("shop-rs-api")
174174 );
175175 }
176176
177177 #[test_it(scope = "shop_checkout")]
178178 async fn shop_project_cart_suite_unknown_cart_not_found_test(env: TestEnv) {
179179 let response = env
180180 .project("shop")
181181 .service("api")?
unknown_sku_not_found ok · 1ms
runoldnew±codecov
4343 .as_i64()
4444 .ok_or_else(|| anyhow::anyhow!("stock missing for {sku}"))
4545 }
4646
4747 #[test_it(scope = "shop_checkout")]
4848 async 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")
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 }
6161
6262 #[test_it(scope = "shop_checkout")]
6363 async fn shop_project_catalog_suite_unknown_sku_not_found_test(env: TestEnv) {
6464 let response = env
6565 .project("shop")
6666 .service("api")?
6767 .get("/v1/products/NOPE-0000")
6969 .await;
7070 response.assert_status(404);
7171 assert_eq!(
7272 response.response_body_json()?["error"]["code"],
7373 "product_not_found"
7474 );
7575 }
7676
7777 #[test_it(scope = "shop_checkout")]
7878 async fn shop_project_cart_suite_rejects_zero_quantity_test(env: TestEnv) {
7979 let cart_id = open_cart(&env).await?;
8080 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8181 response.assert_status(422);
8282 assert_eq!(
8383 response.response_body_json()?["error"]["code"],
checkout3 run · 3 ok · 0 fail · 0 skip · 19ms total · 6ms avg · 2ms fastest · 11ms slowest
places_order_and_decrements_stock ok · 11ms
runoldnew±codecov
9898
9999 #[test_it(scope = "shop_checkout")]
100100 async fn shop_project_cart_suite_adds_line_to_cart_test(env: TestEnv) {
101101 let cart_id = open_cart(&env).await?;
102102 let response = add_item(&env, &cart_id, "MUG-STONE", 2).await?;
103103 response.assert_status(200);
105105 .project("shop")
106106 .service("api")?
107107 .get(&format!("/v1/carts/{cart_id}"))
109109 .await;
112112 assert_eq!(body["status"], "open");
113113 assert_eq!(body["lines"][0]["sku"], "MUG-STONE");
114114 assert_eq!(body["lines"][0]["quantity"], 2);
115115 }
116116
117117 #[test_it(scope = "shop_checkout")]
118118 async fn shop_project_checkout_suite_places_order_and_decrements_stock_test(env: TestEnv) {
119119 let stock_before = stock_of(&env, "NB-DOT-A5").await?;
120120 let cart_id = open_cart(&env).await?;
121121 add_item(&env, &cart_id, "NB-DOT-A5", 3)
122122 .await?
123123 .assert_status(200);
124124 let response = env
125125 .project("shop")
126126 .service("api")?
127127 .post(&format!("/v1/carts/{cart_id}/checkout"))
129129 .await;
130130 response.assert_status(201);
131131 let order = response.response_body_json()?;
132132 assert_eq!(order["total_cents"], 4200);
133133 assert_eq!(order["cart_id"], cart_id.as_str());
134134 assert_eq!(stock_of(&env, "NB-DOT-A5").await?, stock_before - 3);
136136 .project("shop")
137137 .service("api")?
138138 .get(&format!("/v1/carts/{cart_id}"))
rejects_empty_cart ok · 2ms
runoldnew±codecov
173173 || response.response_body_text().contains("shop-rs-api")
174174 );
175175 }
176176
177177 #[test_it(scope = "shop_checkout")]
178178 async fn shop_project_cart_suite_unknown_cart_not_found_test(env: TestEnv) {
179179 let response = env
180180 .project("shop")
181181 .service("api")?
182182 .get("/v1/carts/0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0d")
184184 .await;
185185 response.assert_status(404);
186186 assert_eq!(
187187 response.response_body_json()?["error"]["code"],
188188 "cart_not_found"
189189 );
190190 }
191191
192192 #[test_it(scope = "shop_checkout")]
193193 async fn shop_project_checkout_suite_rejects_empty_cart_test(env: TestEnv) {
194194 let cart_id = open_cart(&env).await?;
195195 let response = env
196196 .project("shop")
197197 .service("api")?
198198 .post(&format!("/v1/carts/{cart_id}/checkout"))
200200 .await;
201201 response.assert_status(422);
202202 assert_eq!(
203203 response.response_body_json()?["error"]["code"],
204204 "empty_cart"
205205 );
206206 }
207207
208208 #[test_it(scope = "shop_checkout")]
209209 async fn shop_project_checkout_suite_rejects_second_checkout_test(env: TestEnv) {
210210 let cart_id = open_cart(&env).await?;
211211 add_item(&env, &cart_id, "TEE-CREW-M", 1)
212212 .await?
213213 .assert_status(200);
rejects_second_checkout ok · 6ms
runoldnew±codecov
189189 );
190190 }
191191
192192 #[test_it(scope = "shop_checkout")]
193193 async fn shop_project_checkout_suite_rejects_empty_cart_test(env: TestEnv) {
194194 let cart_id = open_cart(&env).await?;
195195 let response = env
196196 .project("shop")
197197 .service("api")?
198198 .post(&format!("/v1/carts/{cart_id}/checkout"))
200200 .await;
201201 response.assert_status(422);
202202 assert_eq!(
203203 response.response_body_json()?["error"]["code"],
204204 "empty_cart"
205205 );
206206 }
207207
208208 #[test_it(scope = "shop_checkout")]
209209 async fn shop_project_checkout_suite_rejects_second_checkout_test(env: TestEnv) {
210210 let cart_id = open_cart(&env).await?;
211211 add_item(&env, &cart_id, "TEE-CREW-M", 1)
212212 .await?
213213 .assert_status(200);
214214 let checkout_path = format!("/v1/carts/{cart_id}/checkout");
215215 env.project("shop")
216216 .service("api")?
217217 .post(&checkout_path)
219219 .await
220220 .assert_status(201);
221221 let response = env
222222 .project("shop")
223223 .service("api")?
224224 .post(&checkout_path)
226226 .await;
227227 response.assert_status(409);
228228 assert_eq!(
229229 response.response_body_json()?["error"]["code"],
functional6 run · 6 ok · 0 fail · 0 skip · 1312ms total · 218ms avg · 155ms fastest · 309ms slowest
shop-ts-web-react: checks out two mugs and a second empty cart ok · 309ms
shop-ts-web-react: rejects a lamp quantity above the stock on hand ok · 155ms
shop-ts-web-react: rejects a zero quantity ok · 182ms
shop-ts-web-react: rejects checkout of an empty cart ok · 185ms
shop-ts-web-react: shows an unknown product when the catalog row disappears ok · 219ms
shop-ts-web-react: shows the seeded catalog ok · 262ms
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 ok · 7ms
unit79 run · 79 ok · 0 fail · 0 skip · 317ms total · 18ms avg · 1ms fastest · 40ms slowest
shop-rs-api: application::tests::add_to_cart::persists_merged_line ok · 0ms
shop-rs-api: application::tests::add_to_cart::rejects_quantity_beyond_stock ok · 0ms
shop-rs-api: application::tests::add_to_cart::rejects_unknown_cart ok · 0ms
shop-rs-api: application::tests::catalog_service::keeps_a_rejected_domain_error ok · 0ms
shop-rs-api: application::tests::catalog_service::reports_a_price_mismatch listed price did not match the expected 1500 FAIL · 1ms
reason: listed price did not match the expected 1500
shop-rs-api: application::tests::catalog_service::returns_not_found_for_unknown_sku ok · 0ms
shop-rs-api: application::tests::catalog_service::surfaces_unavailable_storage ok · 0ms
shop-rs-api: application::tests::checkout::does_not_place_order_for_empty_cart ok · 0ms
shop-rs-api: application::tests::checkout::places_order_with_priced_total ok · 0ms
shop-rs-api: domain::model::tests::add_to_cart::merges_quantity_with_existing_line ok · 0ms
shop-rs-api: domain::model::tests::add_to_cart::rejects_checked_out_cart ok · 0ms
shop-rs-api: domain::model::tests::add_to_cart::rejects_merged_quantity_beyond_line_maximum ok · 0ms
shop-rs-api: domain::model::tests::add_to_cart::rejects_quantity_beyond_stock ok · 0ms
shop-rs-api: domain::model::tests::cart_id_parsing::normalizes_to_lowercase ok · 0ms
shop-rs-api: domain::model::tests::cart_id_parsing::rejects_non_uuid ok · 0ms
shop-rs-api: domain::model::tests::cart_id_rejects_a_dash_in_the_wrong_place ok · 0ms
shop-rs-api: domain::model::tests::checkout::rejects_a_line_whose_product_is_missing ok · 0ms
shop-rs-api: domain::model::tests::checkout::rejects_a_total_that_overflows ok · 0ms
shop-rs-api: domain::model::tests::checkout::rejects_checked_out_cart ok · 0ms
shop-rs-api: domain::model::tests::checkout::rejects_empty_cart ok · 0ms
shop-rs-api: domain::model::tests::checkout::rejects_line_that_exceeds_current_stock ok · 0ms
shop-rs-api: domain::model::tests::checkout::totals_every_line_in_cents ok · 0ms
shop-rs-api: domain::model::tests::every_domain_error_names_the_problem ok · 0ms
shop-rs-api: domain::model::tests::money_rejects_a_negative_amount_and_an_overflowing_sum ok · 0ms
shop-rs-api: domain::model::tests::quantity_bounds::accepts_every_value_in_range ok · 0ms
shop-rs-api: domain::model::tests::quantity_bounds::rejects_above_line_maximum ok · 0ms
shop-rs-api: domain::model::tests::quantity_bounds::rejects_zero ok · 0ms
shop-rs-api: domain::model::tests::sku_accepts_the_longest_code_and_exposes_it ok · 0ms
shop-rs-api: domain::model::tests::sku_parsing::accepts_uppercase_digits_and_dashes ok · 0ms
shop-rs-api: domain::model::tests::sku_parsing::rejects_lowercase ok · 0ms
shop-rs-api: domain::model::tests::sku_parsing::rejects_too_short ok · 0ms
shop-rs-api: domain::port::tests::display_includes_the_domain_message_or_the_storage_reason ok · 0ms
shop-rs-api: infrastructure::postgres::tests::cart_line_parts_reject_a_bad_sku_or_quantity ok · 0ms
shop-rs-api: infrastructure::postgres::tests::cart_status_accepts_both_stored_values_and_rejects_anything_else ok · 0ms
shop-rs-api: infrastructure::postgres::tests::connect_reports_unavailable_when_the_server_refuses ok · 0ms
shop-rs-api: infrastructure::postgres::tests::order_outcomes_distinguish_a_closed_cart_and_a_missed_reservation ok · 0ms
shop-rs-api: infrastructure::postgres::tests::product_parts_reject_values_the_schema_also_forbids ok · 0ms
shop-rs-api: infrastructure::postgres::tests::quantity_column_rejects_a_value_that_does_not_fit_the_database ok · 0ms
shop-rs-api: infrastructure::web::tests::cart_opens_adds_and_checks_out ok · 0ms
shop-rs-api: infrastructure::web::tests::cart_rejects_bad_input_and_conflicts ok · 0ms
shop-rs-api: infrastructure::web::tests::cart_storage_failure_is_a_server_error ok · 0ms
shop-rs-api: infrastructure::web::tests::catalog_lists_and_finds_products ok · 0ms
shop-rs-api: infrastructure::web::tests::catalog_reports_storage_failure ok · 0ms
shop-rs-api: infrastructure::web::tests::checkout_reports_an_empty_cart_stock_conflict_and_overflow ok · 0ms
shop-rs-api: infrastructure::web::tests::every_domain_error_has_an_http_status ok · 0ms
shop-rs-api: infrastructure::web::tests::health_and_openapi_respond ok · 0ms
shop-ts-lib-cart: cartTotal is zero for an empty cart ok · 0ms
shop-ts-lib-cart: cartTotal sums every line ok · 0ms
shop-ts-lib-cart: cents accepts zero ok · 0ms
shop-ts-lib-cart: cents rejects fractional amounts ok · 0ms
shop-ts-lib-cart: cents rejects negative amounts ok · 0ms
shop-ts-lib-cart: formatCents formats dollars for a US locale ok · 0ms
shop-ts-lib-cart: formatCents formats euros for an Italian locale ok · 30ms
shop-ts-lib-cart: lineTotal multiplies unit price by quantity ok · 0ms
shop-ts-lib-cart: lineTotal rejects a quantity above the cart limit ok · 0ms
shop-ts-lib-cart: lineTotal rejects a zero quantity ok · 0ms
shop-ts-web-react: Shop adds two mugs and shows the line total ok · 38ms
shop-ts-web-react: Shop disables actions while an add is in flight ok · 18ms
shop-ts-web-react: Shop ignores a catalog response that arrives after unmount ok · 1ms
shop-ts-web-react: Shop places an order and rejects the next empty checkout ok · 33ms
shop-ts-web-react: Shop shows a generic message when the catalog body is not a list ok · 4ms
shop-ts-web-react: Shop shows a loading line until the catalog arrives ok · 4ms
shop-ts-web-react: Shop shows a network failure ok · 3ms
shop-ts-web-react: Shop shows a storage failure from the catalog ok · 18ms
shop-ts-web-react: Shop shows a zero quantity error ok · 23ms
shop-ts-web-react: Shop shows an empty-cart error and a second checkout error ok · 22ms
shop-ts-web-react: Shop shows an out of stock error ok · 25ms
shop-ts-web-react: Shop shows an unreadable add failure ok · 14ms
shop-ts-web-react: Shop shows the catalog and an empty cart ok · 28ms
shop-ts-web-react: Shop shows the sku when a cart line is no longer in the catalog ok · 13ms
shop-ts-web-react: mountShop rejects a missing root ok · 0ms
shop-ts-web-react: mountShop renders the shop into the root element ok · 40ms
shop-ts-web-react: shop API adds a line and returns the cart ok · 0ms
shop-ts-web-react: shop API keeps the status text when the error body has no code ok · 0ms
shop-ts-web-react: shop API maps a placed order ok · 0ms
shop-ts-web-react: shop API maps catalog rows into products ok · 3ms
shop-ts-web-react: shop API opens a cart ok · 0ms
shop-ts-web-react: shop API reads an API error ok · 0ms
shop-ts-web-react: shop API reports a network failure ok · 0ms
shop-ts-web-react: shop API reports an unreadable error body ok · 0ms
visual4 run · 4 ok · 0 fail · 0 skip · 1488ms total · 372ms avg · 304ms fastest · 486ms slowest
shop-ts-web-react: cart lines ok · 304ms
shop-ts-web-react: catalog ok · 486ms
shop-ts-web-react: empty checkout ok · 373ms
shop-ts-web-react: order placed ok · 325ms
shop > cart > adds_line_to_cart ok · 96ms
runoldnew±codecov
8080 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8181 response.assert_status(422);
8282 assert_eq!(
8383 response.response_body_json()?["error"]["code"],
8484 "invalid_quantity"
8585 );
8686 }
8787
8888 #[test_it(scope = "shop_checkout")]
8989 async fn shop_project_cart_suite_rejects_quantity_beyond_stock_test(env: TestEnv) {
9090 let cart_id = open_cart(&env).await?;
9191 let response = add_item(&env, &cart_id, "LAMP-DESK", 3).await?;
9292 response.assert_status(409);
9393 assert_eq!(
9494 response.response_body_json()?["error"]["code"],
9595 "out_of_stock"
9696 );
9797 }
9898
9999 #[test_it(scope = "shop_checkout")]
100100 async fn shop_project_cart_suite_adds_line_to_cart_test(env: TestEnv) {
101101 let cart_id = open_cart(&env).await?;
102102 let response = add_item(&env, &cart_id, "MUG-STONE", 2).await?;
103103 response.assert_status(200);
105105 .project("shop")
106106 .service("api")?
107107 .get(&format!("/v1/carts/{cart_id}"))
109109 .await;
112112 assert_eq!(body["status"], "open");
113113 assert_eq!(body["lines"][0]["sku"], "MUG-STONE");
114114 assert_eq!(body["lines"][0]["quantity"], 2);
115115 }
116116
117117 #[test_it(scope = "shop_checkout")]
118118 async fn shop_project_checkout_suite_places_order_and_decrements_stock_test(env: TestEnv) {
119119 let stock_before = stock_of(&env, "NB-DOT-A5").await?;
120120 let cart_id = open_cart(&env).await?;
shop > cart > rejects_quantity_beyond_stock ok · 4ms
runoldnew±codecov
6969 .await;
7070 response.assert_status(404);
7171 assert_eq!(
7272 response.response_body_json()?["error"]["code"],
7373 "product_not_found"
7474 );
7575 }
7676
7777 #[test_it(scope = "shop_checkout")]
7878 async fn shop_project_cart_suite_rejects_zero_quantity_test(env: TestEnv) {
7979 let cart_id = open_cart(&env).await?;
8080 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8181 response.assert_status(422);
8282 assert_eq!(
8383 response.response_body_json()?["error"]["code"],
8484 "invalid_quantity"
8585 );
8686 }
8787
8888 #[test_it(scope = "shop_checkout")]
8989 async fn shop_project_cart_suite_rejects_quantity_beyond_stock_test(env: TestEnv) {
9090 let cart_id = open_cart(&env).await?;
9191 let response = add_item(&env, &cart_id, "LAMP-DESK", 3).await?;
9292 response.assert_status(409);
9393 assert_eq!(
9494 response.response_body_json()?["error"]["code"],
9595 "out_of_stock"
9696 );
9797 }
9898
9999 #[test_it(scope = "shop_checkout")]
100100 async fn shop_project_cart_suite_adds_line_to_cart_test(env: TestEnv) {
101101 let cart_id = open_cart(&env).await?;
102102 let response = add_item(&env, &cart_id, "MUG-STONE", 2).await?;
103103 response.assert_status(200);
105105 .project("shop")
106106 .service("api")?
107107 .get(&format!("/v1/carts/{cart_id}"))
109109 .await;
shop > cart > rejects_zero_quantity ok · 1ms
runoldnew±codecov
5858 assert_eq!(body["items"][0]["sku"], "LAMP-DESK");
5959 assert_eq!(body["items"][0]["price_cents"], 12900);
6060 }
6161
6262 #[test_it(scope = "shop_checkout")]
6363 async fn shop_project_catalog_suite_unknown_sku_not_found_test(env: TestEnv) {
6464 let response = env
6565 .project("shop")
6666 .service("api")?
6767 .get("/v1/products/NOPE-0000")
6969 .await;
7070 response.assert_status(404);
7171 assert_eq!(
7272 response.response_body_json()?["error"]["code"],
7373 "product_not_found"
7474 );
7575 }
7676
7777 #[test_it(scope = "shop_checkout")]
7878 async fn shop_project_cart_suite_rejects_zero_quantity_test(env: TestEnv) {
7979 let cart_id = open_cart(&env).await?;
8080 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8181 response.assert_status(422);
8282 assert_eq!(
8383 response.response_body_json()?["error"]["code"],
8484 "invalid_quantity"
8585 );
8686 }
8787
8888 #[test_it(scope = "shop_checkout")]
8989 async fn shop_project_cart_suite_rejects_quantity_beyond_stock_test(env: TestEnv) {
9090 let cart_id = open_cart(&env).await?;
9191 let response = add_item(&env, &cart_id, "LAMP-DESK", 3).await?;
9292 response.assert_status(409);
9393 assert_eq!(
9494 response.response_body_json()?["error"]["code"],
9595 "out_of_stock"
9696 );
9797 }
9898
shop > cart > unknown_cart_not_found ok · 0ms
runoldnew±codecov
158158 }
159159
160160 #[test_it(scope = "shop_checkout")]
161161 async fn shop_project_catalog_suite_serves_openapi_test(env: TestEnv) {
162162 let response = env
163163 .project("shop")
164164 .service("api")?
165165 .get("/openapi.json")
167167 .await;
168168 response.assert_status(200);
169169 assert!(
170170 response
171171 .response_body_text()
172172 .contains("\"title\":\"shop-rs-api\"")
173173 || response.response_body_text().contains("shop-rs-api")
174174 );
175175 }
176176
177177 #[test_it(scope = "shop_checkout")]
178178 async fn shop_project_cart_suite_unknown_cart_not_found_test(env: TestEnv) {
179179 let response = env
180180 .project("shop")
181181 .service("api")?
182182 .get("/v1/carts/0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0d")
184184 .await;
185185 response.assert_status(404);
186186 assert_eq!(
187187 response.response_body_json()?["error"]["code"],
188188 "cart_not_found"
189189 );
190190 }
191191
192192 #[test_it(scope = "shop_checkout")]
193193 async fn shop_project_checkout_suite_rejects_empty_cart_test(env: TestEnv) {
194194 let cart_id = open_cart(&env).await?;
195195 let response = env
196196 .project("shop")
197197 .service("api")?
198198 .post(&format!("/v1/carts/{cart_id}/checkout"))
shop > catalog > lists_seeded_products ok · 1ms
runoldnew±codecov
2828 .post(&format!("/v1/carts/{cart_id}/items"))
2929 .json_body(&body)?
3131 .await)
3232 }
3333
3434 async 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}"))
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 #[test_it(scope = "shop_checkout")]
4848 async 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")
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 }
6161
6262 #[test_it(scope = "shop_checkout")]
6363 async fn shop_project_catalog_suite_unknown_sku_not_found_test(env: TestEnv) {
6464 let response = env
6565 .project("shop")
6666 .service("api")?
6767 .get("/v1/products/NOPE-0000")
shop > catalog > rejects_invalid_sku ok · 0ms
runoldnew±codecov
126126 .service("api")?
127127 .post(&format!("/v1/carts/{cart_id}/checkout"))
129129 .await;
130130 response.assert_status(201);
131131 let order = response.response_body_json()?;
132132 assert_eq!(order["total_cents"], 4200);
133133 assert_eq!(order["cart_id"], cart_id.as_str());
134134 assert_eq!(stock_of(&env, "NB-DOT-A5").await?, stock_before - 3);
136136 .project("shop")
137137 .service("api")?
138138 .get(&format!("/v1/carts/{cart_id}"))
140140 .await;
143143 }
144144
145145 #[test_it(scope = "shop_checkout")]
146146 async fn shop_project_catalog_suite_rejects_invalid_sku_test(env: TestEnv) {
147147 let response = env
148148 .project("shop")
149149 .service("api")?
150150 .get("/v1/products/no")
152152 .await;
153153 response.assert_status(422);
154154 assert_eq!(
155155 response.response_body_json()?["error"]["code"],
156156 "invalid_sku"
157157 );
158158 }
159159
160160 #[test_it(scope = "shop_checkout")]
161161 async fn shop_project_catalog_suite_serves_openapi_test(env: TestEnv) {
162162 let response = env
163163 .project("shop")
164164 .service("api")?
165165 .get("/openapi.json")
shop > catalog > serves_openapi ok · 1ms
runoldnew±codecov
143143 }
144144
145145 #[test_it(scope = "shop_checkout")]
146146 async fn shop_project_catalog_suite_rejects_invalid_sku_test(env: TestEnv) {
147147 let response = env
148148 .project("shop")
149149 .service("api")?
150150 .get("/v1/products/no")
152152 .await;
153153 response.assert_status(422);
154154 assert_eq!(
155155 response.response_body_json()?["error"]["code"],
156156 "invalid_sku"
157157 );
158158 }
159159
160160 #[test_it(scope = "shop_checkout")]
161161 async fn shop_project_catalog_suite_serves_openapi_test(env: TestEnv) {
162162 let response = env
163163 .project("shop")
164164 .service("api")?
165165 .get("/openapi.json")
167167 .await;
168168 response.assert_status(200);
169169 assert!(
170170 response
171171 .response_body_text()
172172 .contains("\"title\":\"shop-rs-api\"")
173173 || response.response_body_text().contains("shop-rs-api")
174174 );
175175 }
176176
177177 #[test_it(scope = "shop_checkout")]
178178 async fn shop_project_cart_suite_unknown_cart_not_found_test(env: TestEnv) {
179179 let response = env
180180 .project("shop")
181181 .service("api")?
shop > catalog > unknown_sku_not_found ok · 1ms
runoldnew±codecov
4343 .as_i64()
4444 .ok_or_else(|| anyhow::anyhow!("stock missing for {sku}"))
4545 }
4646
4747 #[test_it(scope = "shop_checkout")]
4848 async 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")
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 }
6161
6262 #[test_it(scope = "shop_checkout")]
6363 async fn shop_project_catalog_suite_unknown_sku_not_found_test(env: TestEnv) {
6464 let response = env
6565 .project("shop")
6666 .service("api")?
6767 .get("/v1/products/NOPE-0000")
6969 .await;
7070 response.assert_status(404);
7171 assert_eq!(
7272 response.response_body_json()?["error"]["code"],
7373 "product_not_found"
7474 );
7575 }
7676
7777 #[test_it(scope = "shop_checkout")]
7878 async fn shop_project_cart_suite_rejects_zero_quantity_test(env: TestEnv) {
7979 let cart_id = open_cart(&env).await?;
8080 let response = add_item(&env, &cart_id, "MUG-STONE", 0).await?;
8181 response.assert_status(422);
8282 assert_eq!(
8383 response.response_body_json()?["error"]["code"],
shop > checkout > places_order_and_decrements_stock ok · 11ms
runoldnew±codecov
9898
9999 #[test_it(scope = "shop_checkout")]
100100 async fn shop_project_cart_suite_adds_line_to_cart_test(env: TestEnv) {
101101 let cart_id = open_cart(&env).await?;
102102 let response = add_item(&env, &cart_id, "MUG-STONE", 2).await?;
103103 response.assert_status(200);
105105 .project("shop")
106106 .service("api")?
107107 .get(&format!("/v1/carts/{cart_id}"))
109109 .await;
112112 assert_eq!(body["status"], "open");
113113 assert_eq!(body["lines"][0]["sku"], "MUG-STONE");
114114 assert_eq!(body["lines"][0]["quantity"], 2);
115115 }
116116
117117 #[test_it(scope = "shop_checkout")]
118118 async fn shop_project_checkout_suite_places_order_and_decrements_stock_test(env: TestEnv) {
119119 let stock_before = stock_of(&env, "NB-DOT-A5").await?;
120120 let cart_id = open_cart(&env).await?;
121121 add_item(&env, &cart_id, "NB-DOT-A5", 3)
122122 .await?
123123 .assert_status(200);
124124 let response = env
125125 .project("shop")
126126 .service("api")?
127127 .post(&format!("/v1/carts/{cart_id}/checkout"))
129129 .await;
130130 response.assert_status(201);
131131 let order = response.response_body_json()?;
132132 assert_eq!(order["total_cents"], 4200);
133133 assert_eq!(order["cart_id"], cart_id.as_str());
134134 assert_eq!(stock_of(&env, "NB-DOT-A5").await?, stock_before - 3);
136136 .project("shop")
137137 .service("api")?
138138 .get(&format!("/v1/carts/{cart_id}"))
shop > checkout > rejects_empty_cart ok · 2ms
runoldnew±codecov
173173 || response.response_body_text().contains("shop-rs-api")
174174 );
175175 }
176176
177177 #[test_it(scope = "shop_checkout")]
178178 async fn shop_project_cart_suite_unknown_cart_not_found_test(env: TestEnv) {
179179 let response = env
180180 .project("shop")
181181 .service("api")?
182182 .get("/v1/carts/0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0d")
184184 .await;
185185 response.assert_status(404);
186186 assert_eq!(
187187 response.response_body_json()?["error"]["code"],
188188 "cart_not_found"
189189 );
190190 }
191191
192192 #[test_it(scope = "shop_checkout")]
193193 async fn shop_project_checkout_suite_rejects_empty_cart_test(env: TestEnv) {
194194 let cart_id = open_cart(&env).await?;
195195 let response = env
196196 .project("shop")
197197 .service("api")?
198198 .post(&format!("/v1/carts/{cart_id}/checkout"))
200200 .await;
201201 response.assert_status(422);
202202 assert_eq!(
203203 response.response_body_json()?["error"]["code"],
204204 "empty_cart"
205205 );
206206 }
207207
208208 #[test_it(scope = "shop_checkout")]
209209 async fn shop_project_checkout_suite_rejects_second_checkout_test(env: TestEnv) {
210210 let cart_id = open_cart(&env).await?;
211211 add_item(&env, &cart_id, "TEE-CREW-M", 1)
212212 .await?
213213 .assert_status(200);
shop > checkout > rejects_second_checkout ok · 6ms
runoldnew±codecov
189189 );
190190 }
191191
192192 #[test_it(scope = "shop_checkout")]
193193 async fn shop_project_checkout_suite_rejects_empty_cart_test(env: TestEnv) {
194194 let cart_id = open_cart(&env).await?;
195195 let response = env
196196 .project("shop")
197197 .service("api")?
198198 .post(&format!("/v1/carts/{cart_id}/checkout"))
200200 .await;
201201 response.assert_status(422);
202202 assert_eq!(
203203 response.response_body_json()?["error"]["code"],
204204 "empty_cart"
205205 );
206206 }
207207
208208 #[test_it(scope = "shop_checkout")]
209209 async fn shop_project_checkout_suite_rejects_second_checkout_test(env: TestEnv) {
210210 let cart_id = open_cart(&env).await?;
211211 add_item(&env, &cart_id, "TEE-CREW-M", 1)
212212 .await?
213213 .assert_status(200);
214214 let checkout_path = format!("/v1/carts/{cart_id}/checkout");
215215 env.project("shop")
216216 .service("api")?
217217 .post(&checkout_path)
219219 .await
220220 .assert_status(201);
221221 let response = env
222222 .project("shop")
223223 .service("api")?
224224 .post(&checkout_path)
226226 .await;
227227 response.assert_status(409);
228228 assert_eq!(
229229 response.response_body_json()?["error"]["code"],
shop > functional > shop-ts-web-react: checks out two mugs and a second empty cart ok · 309ms
shop > functional > shop-ts-web-react: rejects a lamp quantity above the stock on hand ok · 155ms
shop > functional > shop-ts-web-react: rejects a zero quantity ok · 182ms
shop > functional > shop-ts-web-react: rejects checkout of an empty cart ok · 185ms
shop > functional > shop-ts-web-react: shows an unknown product when the catalog row disappears ok · 219ms
shop > functional > shop-ts-web-react: shows the seeded catalog ok · 262ms
shop > integration > shop-ts-lib-cart: published cart entry prices two mugs through the package export ok · 7ms
shop > unit > shop-rs-api: application::tests::add_to_cart::persists_merged_line ok · 0ms
shop > unit > shop-rs-api: application::tests::add_to_cart::rejects_quantity_beyond_stock ok · 0ms
shop > unit > shop-rs-api: application::tests::add_to_cart::rejects_unknown_cart ok · 0ms
shop > unit > shop-rs-api: application::tests::catalog_service::keeps_a_rejected_domain_error ok · 0ms
shop > unit > shop-rs-api: application::tests::catalog_service::reports_a_price_mismatch listed price did not match the expected 1500 FAIL · 1ms
reason: listed price did not match the expected 1500
shop > unit > shop-rs-api: application::tests::catalog_service::returns_not_found_for_unknown_sku ok · 0ms
shop > unit > shop-rs-api: application::tests::catalog_service::surfaces_unavailable_storage ok · 0ms
shop > unit > shop-rs-api: application::tests::checkout::does_not_place_order_for_empty_cart ok · 0ms
shop > unit > shop-rs-api: application::tests::checkout::places_order_with_priced_total ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::add_to_cart::merges_quantity_with_existing_line ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::add_to_cart::rejects_checked_out_cart ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::add_to_cart::rejects_merged_quantity_beyond_line_maximum ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::add_to_cart::rejects_quantity_beyond_stock ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::cart_id_parsing::normalizes_to_lowercase ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::cart_id_parsing::rejects_non_uuid ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::cart_id_rejects_a_dash_in_the_wrong_place ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::checkout::rejects_a_line_whose_product_is_missing ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::checkout::rejects_a_total_that_overflows ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::checkout::rejects_checked_out_cart ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::checkout::rejects_empty_cart ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::checkout::rejects_line_that_exceeds_current_stock ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::checkout::totals_every_line_in_cents ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::every_domain_error_names_the_problem ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::money_rejects_a_negative_amount_and_an_overflowing_sum ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::quantity_bounds::accepts_every_value_in_range ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::quantity_bounds::rejects_above_line_maximum ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::quantity_bounds::rejects_zero ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::sku_accepts_the_longest_code_and_exposes_it ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::sku_parsing::accepts_uppercase_digits_and_dashes ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::sku_parsing::rejects_lowercase ok · 0ms
shop > unit > shop-rs-api: domain::model::tests::sku_parsing::rejects_too_short ok · 0ms
shop > unit > shop-rs-api: domain::port::tests::display_includes_the_domain_message_or_the_storage_reason ok · 0ms
shop > unit > shop-rs-api: infrastructure::postgres::tests::cart_line_parts_reject_a_bad_sku_or_quantity ok · 0ms
shop > unit > shop-rs-api: infrastructure::postgres::tests::cart_status_accepts_both_stored_values_and_rejects_anything_else ok · 0ms
shop > unit > shop-rs-api: infrastructure::postgres::tests::connect_reports_unavailable_when_the_server_refuses ok · 0ms
shop > unit > shop-rs-api: infrastructure::postgres::tests::order_outcomes_distinguish_a_closed_cart_and_a_missed_reservation ok · 0ms
shop > unit > shop-rs-api: infrastructure::postgres::tests::product_parts_reject_values_the_schema_also_forbids ok · 0ms
shop > unit > shop-rs-api: infrastructure::postgres::tests::quantity_column_rejects_a_value_that_does_not_fit_the_database ok · 0ms
shop > unit > shop-rs-api: infrastructure::web::tests::cart_opens_adds_and_checks_out ok · 0ms
shop > unit > shop-rs-api: infrastructure::web::tests::cart_rejects_bad_input_and_conflicts ok · 0ms
shop > unit > shop-rs-api: infrastructure::web::tests::cart_storage_failure_is_a_server_error ok · 0ms
shop > unit > shop-rs-api: infrastructure::web::tests::catalog_lists_and_finds_products ok · 0ms
shop > unit > shop-rs-api: infrastructure::web::tests::catalog_reports_storage_failure ok · 0ms
shop > unit > shop-rs-api: infrastructure::web::tests::checkout_reports_an_empty_cart_stock_conflict_and_overflow ok · 0ms
shop > unit > shop-rs-api: infrastructure::web::tests::every_domain_error_has_an_http_status ok · 0ms
shop > unit > shop-rs-api: infrastructure::web::tests::health_and_openapi_respond ok · 0ms
shop > unit > shop-ts-lib-cart: cartTotal is zero for an empty cart ok · 0ms
shop > unit > shop-ts-lib-cart: cartTotal sums every line ok · 0ms
shop > unit > shop-ts-lib-cart: cents accepts zero ok · 0ms
shop > unit > shop-ts-lib-cart: cents rejects fractional amounts ok · 0ms
shop > unit > shop-ts-lib-cart: cents rejects negative amounts ok · 0ms
shop > unit > shop-ts-lib-cart: formatCents formats dollars for a US locale ok · 0ms
shop > unit > shop-ts-lib-cart: formatCents formats euros for an Italian locale ok · 30ms
shop > unit > shop-ts-lib-cart: lineTotal multiplies unit price by quantity ok · 0ms
shop > unit > shop-ts-lib-cart: lineTotal rejects a quantity above the cart limit ok · 0ms
shop > unit > shop-ts-lib-cart: lineTotal rejects a zero quantity ok · 0ms
shop > unit > shop-ts-web-react: Shop adds two mugs and shows the line total ok · 38ms
shop > unit > shop-ts-web-react: Shop disables actions while an add is in flight ok · 18ms
shop > unit > shop-ts-web-react: Shop ignores a catalog response that arrives after unmount ok · 1ms
shop > unit > shop-ts-web-react: Shop places an order and rejects the next empty checkout ok · 33ms
shop > unit > shop-ts-web-react: Shop shows a generic message when the catalog body is not a list ok · 4ms
shop > unit > shop-ts-web-react: Shop shows a loading line until the catalog arrives ok · 4ms
shop > unit > shop-ts-web-react: Shop shows a network failure ok · 3ms
shop > unit > shop-ts-web-react: Shop shows a storage failure from the catalog ok · 18ms
shop > unit > shop-ts-web-react: Shop shows a zero quantity error ok · 23ms
shop > unit > shop-ts-web-react: Shop shows an empty-cart error and a second checkout error ok · 22ms
shop > unit > shop-ts-web-react: Shop shows an out of stock error ok · 25ms
shop > unit > shop-ts-web-react: Shop shows an unreadable add failure ok · 14ms
shop > unit > shop-ts-web-react: Shop shows the catalog and an empty cart ok · 28ms
shop > unit > shop-ts-web-react: Shop shows the sku when a cart line is no longer in the catalog ok · 13ms
shop > unit > shop-ts-web-react: mountShop rejects a missing root ok · 0ms
shop > unit > shop-ts-web-react: mountShop renders the shop into the root element ok · 40ms
shop > unit > shop-ts-web-react: shop API adds a line and returns the cart ok · 0ms
shop > unit > shop-ts-web-react: shop API keeps the status text when the error body has no code ok · 0ms
shop > unit > shop-ts-web-react: shop API maps a placed order ok · 0ms
shop > unit > shop-ts-web-react: shop API maps catalog rows into products ok · 3ms
shop > unit > shop-ts-web-react: shop API opens a cart ok · 0ms
shop > unit > shop-ts-web-react: shop API reads an API error ok · 0ms
shop > unit > shop-ts-web-react: shop API reports a network failure ok · 0ms
shop > unit > shop-ts-web-react: shop API reports an unreadable error body ok · 0ms
shop > visual > shop-ts-web-react: cart lines ok · 304ms
shop > visual > shop-ts-web-react: catalog ok · 486ms
shop > visual > shop-ts-web-react: empty checkout ok · 373ms
shop > visual > shop-ts-web-react: order placed ok · 325ms
changes
shop11 files · 1276/1276 · 1276 cov · 0 unc · 100.0% · ok
shop-rs-api6 files · 100% covered / 0% uncovered
shop-rs-api/src/application/service.rs+94 -1 · 96% covered / 4% uncovered
runoldnew±codecov
8--pub const LEGACY_PRICE_CENTS: i64 = 1500;
93++fn price_note() -> i64 { 1500 }
94++assert_eq!(listed, 1500, "listed price did not match the expected 1500");
1++use std::sync::Arc;
2++
3++use crate::domain::model::{Cart, CartId, DomainError, Order, Product, Quantity, Sku};
4++use crate::domain::port::{CartRepository, OrderRepository, ProductRepository, RepositoryError};
5++
6++#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
7++pub enum ShopError {
8++ #[error(transparent)]
9++ Domain(#[from] DomainError),
10++ #[error("storage unavailable: {0}")]
11++ Unavailable(String),
12++}
13++
14++impl From<RepositoryError> for ShopError {
15++ fn from(error: RepositoryError) -> Self {
16++ match error {
17++ RepositoryError::Rejected(domain) => Self::Domain(domain),
18++ RepositoryError::Unavailable(reason) => Self::Unavailable(reason),
19++ }
20++ }
21++}
22++
23++pub struct CatalogService {
24++ products: Arc<dyn ProductRepository>,
25++}
26++
27++impl CatalogService {
28++ pub fn new(products: Arc<dyn ProductRepository>) -> Self {
29++ Self { products }
30++ }
31++ pub async fn list_products(&self) -> Result<Vec<Product>, ShopError> {
32++ Ok(self.products.list().await?)
33++ }
34++ pub async fn find_product(&self, sku: &Sku) -> Result<Product, ShopError> {
35++ self.products
36++ .find_by_sku(sku)
37++ .await?
38++ .ok_or_else(|| ShopError::Domain(DomainError::ProductNotFound(sku.clone())))
39++ }
40++}
41++
42++pub struct CartService {
43++ products: Arc<dyn ProductRepository>,
44++ carts: Arc<dyn CartRepository>,
45++ orders: Arc<dyn OrderRepository>,
46++}
47++
48++impl CartService {
49++ pub fn new(
50++ products: Arc<dyn ProductRepository>,
51++ carts: Arc<dyn CartRepository>,
52++ orders: Arc<dyn OrderRepository>,
53++ ) -> Self {
54++ Self {
55++ products,
56++ carts,
57++ orders,
58++ }
59++ }
60++ pub async fn open_cart(&self) -> Result<Cart, ShopError> {
61++ Ok(self.carts.create().await?)
62++ }
63++ pub async fn find_cart(&self, cart_id: &CartId) -> Result<Cart, ShopError> {
64++ self.carts
65++ .find(cart_id)
66++ .await?
67++ .ok_or_else(|| ShopError::Domain(DomainError::CartNotFound(cart_id.clone())))
68++ }
69++ pub async fn add_to_cart(
70++ &self,
71++ cart_id: &CartId,
72++ sku: &Sku,
73++ quantity: Quantity,
74++ ) -> Result<Cart, ShopError> {
76++ let product = self
77++ .products
78++ .find_by_sku(sku)
79++ .await?
80++ .ok_or_else(|| ShopError::Domain(DomainError::ProductNotFound(sku.clone())))?;
83++ self.find_cart(cart_id).await
84++ }
85++ pub async fn checkout(&self, cart_id: &CartId) -> Result<Order, ShopError> {
88++ let products = self.products.find_by_skus(&skus).await?;
90++ Ok(self.orders.place(&draft).await?)
91++ }
92++}
shop-rs-api/src/domain/model.rs+476 -0 · 100% covered / 0% uncovered
runoldnew±codecov
1++use std::fmt;
2++
3++const MINIMUM_SKU_LENGTH: usize = 3;
4++const MAXIMUM_SKU_LENGTH: usize = 32;
5++const MAXIMUM_LINE_QUANTITY: u32 = 99;
6++const CART_ID_LENGTH: usize = 36;
7++const CART_ID_DASH_POSITIONS: [usize; 4] = [8, 13, 18, 23];
8++
9++#[derive(Debug, Clone, PartialEq, Eq)]
10++pub enum DomainError {
11++ InvalidSku,
12++ InvalidQuantity,
13++ InvalidMoney,
14++ InvalidCartId,
15++ AmountOverflow,
16++ ProductNotFound(Sku),
17++ CartNotFound(CartId),
18++ CartClosed,
19++ EmptyCart,
20++ OutOfStock(Sku),
21++}
22++
23++impl fmt::Display for DomainError {
24++ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
25++ match self {
26++ Self::InvalidSku => write!(
27++ formatter,
28++ "sku must be {MINIMUM_SKU_LENGTH}-{MAXIMUM_SKU_LENGTH} uppercase letters, digits or dashes"
29++ ),
30++ Self::InvalidQuantity => {
31++ write!(formatter, "quantity must be between 1 and {MAXIMUM_LINE_QUANTITY}")
32++ }
33++ Self::InvalidMoney => write!(formatter, "amount must not be negative"),
34++ Self::InvalidCartId => write!(formatter, "cart id must be a UUID"),
35++ Self::AmountOverflow => write!(formatter, "order total exceeds the supported range"),
36++ Self::ProductNotFound(sku) => write!(formatter, "product {sku} does not exist"),
37++ Self::CartNotFound(cart_id) => write!(formatter, "cart {cart_id} does not exist"),
38++ Self::CartClosed => write!(formatter, "cart is already checked out"),
39++ Self::EmptyCart => write!(formatter, "cart has no items"),
40++ Self::OutOfStock(sku) => write!(formatter, "not enough stock for {sku}"),
41++ }
42++ }
43++}
44++
45++impl std::error::Error for DomainError {}
46++
47++#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
48++pub struct Money(i64);
49++
50++impl Money {
51++ pub fn from_cents(cents: i64) -> Result<Self, DomainError> {
52++ if cents < 0 {
53++ return Err(DomainError::InvalidMoney);
54++ }
55++ Ok(Self(cents))
56++ }
57++ pub fn zero() -> Self {
58++ Self(0)
59++ }
60++ pub fn cents(self) -> i64 {
61++ self.0
62++ }
63++ pub fn times(self, quantity: Quantity) -> Result<Self, DomainError> {
64++ self.0
65++ .checked_mul(i64::from(quantity.value()))
66++ .map(Self)
67++ .ok_or(DomainError::AmountOverflow)
68++ }
69++ pub fn plus(self, other: Money) -> Result<Self, DomainError> {
70++ self.0
71++ .checked_add(other.0)
72++ .map(Self)
73++ .ok_or(DomainError::AmountOverflow)
74++ }
75++}
76++
77++#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
78++pub struct Sku(String);
79++
80++impl Sku {
81++ pub fn parse(raw: &str) -> Result<Self, DomainError> {
82++ let length_valid = (MINIMUM_SKU_LENGTH..=MAXIMUM_SKU_LENGTH).contains(&raw.len());
83++ let characters_valid = raw.chars().all(|character| {
84++ character.is_ascii_uppercase() || character.is_ascii_digit() || character == '-'
85++ });
86++ if !length_valid || !characters_valid {
87++ return Err(DomainError::InvalidSku);
88++ }
89++ Ok(Self(raw.to_owned()))
90++ }
91++ pub fn as_str(&self) -> &str {
92++ &self.0
93++ }
94++}
95++
96++impl fmt::Display for Sku {
97++ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
98++ formatter.write_str(&self.0)
99++ }
100++}
101++
102++#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103++pub struct Quantity(u32);
104++
105++impl Quantity {
106++ pub fn new(value: i64) -> Result<Self, DomainError> {
107++ u32::try_from(value)
108++ .ok()
109++ .filter(|candidate| (1..=MAXIMUM_LINE_QUANTITY).contains(candidate))
110++ .map(Self)
111++ .ok_or(DomainError::InvalidQuantity)
112++ }
113++ pub fn value(self) -> u32 {
114++ self.0
115++ }
116++}
117++
118++#[derive(Debug, Clone, PartialEq, Eq)]
119++pub struct CartId(String);
120++
121++impl CartId {
122++ pub fn parse(raw: &str) -> Result<Self, DomainError> {
123++ let shape_valid = raw.len() == CART_ID_LENGTH
124++ && raw.char_indices().all(|(position, character)| {
125++ if CART_ID_DASH_POSITIONS.contains(&position) {
126++ character == '-'
127++ } else {
128++ character.is_ascii_hexdigit()
129++ }
130++ });
131++ if !shape_valid {
132++ return Err(DomainError::InvalidCartId);
133++ }
134++ Ok(Self(raw.to_ascii_lowercase()))
135++ }
136++ pub fn as_str(&self) -> &str {
137++ &self.0
138++ }
139++}
140++
141++impl fmt::Display for CartId {
142++ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
143++ formatter.write_str(&self.0)
144++ }
145++}
146++
147++#[derive(Debug, Clone, PartialEq, Eq)]
148++pub struct Product {
149++ pub sku: Sku,
150++ pub name: String,
151++ pub price: Money,
152++ pub stock: u32,
153++}
154++
155++impl Product {
156++ pub fn ensure_available(&self, quantity: Quantity) -> Result<(), DomainError> {
157++ if quantity.value() > self.stock {
158++ return Err(DomainError::OutOfStock(self.sku.clone()));
159++ }
160++ Ok(())
161++ }
162++}
163++
164++#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165++pub enum CartStatus {
166++ Open,
167++ CheckedOut,
168++}
169++
170++#[derive(Debug, Clone, PartialEq, Eq)]
172++ pub sku: Sku,
173++ pub quantity: Quantity,
174++}
175++
176++#[derive(Debug, Clone, PartialEq, Eq)]
177++pub struct Cart {
178++ pub id: CartId,
179++ pub status: CartStatus,
181++}
182++
183++#[derive(Debug, Clone, PartialEq, Eq)]
184++pub struct OrderLine {
185++ pub sku: Sku,
186++ pub quantity: Quantity,
187++ pub unit_price: Money,
188++}
189++
190++#[derive(Debug, Clone, PartialEq, Eq)]
191++pub struct OrderDraft {
192++ pub cart_id: CartId,
193++ pub lines: Vec<OrderLine>,
194++ pub total: Money,
195++}
196++
197++#[derive(Debug, Clone, PartialEq, Eq)]
198++pub struct Order {
199++ pub id: i64,
200++ pub cart_id: CartId,
201++ pub lines: Vec<OrderLine>,
202++ pub total: Money,
203++}
204++
205++impl Cart {
207++ self.ensure_open()?;
208++ let existing = self
209++ .lines
210++ .iter()
213++ let merged = Quantity::new(i64::from(existing) + i64::from(quantity.value()))?;
214++ product.ensure_available(merged)?;
216++ sku: product.sku.clone(),
217++ quantity: merged,
218++ })
219++ }
220++ pub fn checkout(&self, products: &[Product]) -> Result<OrderDraft, DomainError> {
221++ self.ensure_open()?;
222++ if self.lines.is_empty() {
223++ return Err(DomainError::EmptyCart);
224++ }
225++ let lines = self
226++ .lines
227++ .iter()
229++ .collect::<Result<Vec<_>, _>>()?;
232++ })?;
233++ Ok(OrderDraft {
234++ cart_id: self.id.clone(),
235++ lines,
236++ total,
237++ })
238++ }
239++ fn ensure_open(&self) -> Result<(), DomainError> {
240++ match self.status {
241++ CartStatus::Open => Ok(()),
242++ CartStatus::CheckedOut => Err(DomainError::CartClosed),
243++ }
244++ }
246++ let product = products
247++ .iter()
251++ Ok(OrderLine {
254++ unit_price: product.price,
255++ })
256++ }
257++}
258++
259++#[cfg(test)]
260++mod tests {
261++ use super::*;
262++
263++ fn sku(raw: &str) -> Sku {
264++ Sku::parse(raw).unwrap()
265++ }
266++
267++ fn product(raw_sku: &str, price_cents: i64, stock: u32) -> Product {
268++ Product {
269++ sku: sku(raw_sku),
270++ name: raw_sku.to_owned(),
271++ price: Money::from_cents(price_cents).unwrap(),
272++ stock,
273++ }
274++ }
275++
277++ Cart {
278++ id: CartId::parse("0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0c").unwrap(),
279++ status,
280++ lines,
281++ }
282++ }
283++
286++ sku: sku(raw_sku),
287++ quantity: Quantity::new(quantity).unwrap(),
288++ }
289++ }
290++
291++ mod sku_parsing {
292++ use super::*;
293++
294++ #[test]
295++ fn accepts_uppercase_digits_and_dashes() {
296++ assert_eq!(
297++ Sku::parse("MUG-001").map(|parsed| parsed.to_string()),
298++ Ok("MUG-001".to_owned())
299++ );
300++ }
301++
302++ #[test]
303++ fn rejects_lowercase() {
304++ assert_eq!(Sku::parse("mug-001"), Err(DomainError::InvalidSku));
305++ }
306++
307++ #[test]
308++ fn rejects_too_short() {
309++ assert_eq!(Sku::parse("AB"), Err(DomainError::InvalidSku));
310++ }
311++ }
312++
313++ mod quantity_bounds {
314++ use super::*;
315++
316++ #[test]
317++ fn rejects_zero() {
318++ assert_eq!(Quantity::new(0), Err(DomainError::InvalidQuantity));
319++ }
320++
321++ #[test]
322++ fn rejects_above_line_maximum() {
323++ assert_eq!(Quantity::new(100), Err(DomainError::InvalidQuantity));
324++ }
325++
326++ #[test]
327++ fn accepts_every_value_in_range() {
328++ assert!((1..=99).all(|value| Quantity::new(value).is_ok()));
329++ }
330++ }
331++
332++ mod cart_id_parsing {
333++ use super::*;
334++
335++ #[test]
336++ fn normalizes_to_lowercase() {
337++ let parsed = CartId::parse("0B8F6C1E-4A2D-4E1F-9C3B-2D5E7F8A9B0C").unwrap();
338++ assert_eq!(parsed.as_str(), "0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0c");
339++ }
340++
341++ #[test]
342++ fn rejects_non_uuid() {
343++ assert_eq!(CartId::parse("42"), Err(DomainError::InvalidCartId));
344++ }
345++ }
346++
347++ mod add_to_cart {
348++ use super::*;
349++
350++ #[test]
351++ fn merges_quantity_with_existing_line() {
353++ .add(&product("MUG-001", 1800, 10), Quantity::new(3).unwrap());
355++ }
356++
357++ #[test]
358++ fn rejects_quantity_beyond_stock() {
360++ .add(&product("LAMP-1", 12900, 2), Quantity::new(3).unwrap());
361++ assert_eq!(result, Err(DomainError::OutOfStock(sku("LAMP-1"))));
362++ }
363++
364++ #[test]
365++ fn rejects_merged_quantity_beyond_line_maximum() {
367++ .add(&product("MUG-001", 1800, 1000), Quantity::new(2).unwrap());
368++ assert_eq!(result, Err(DomainError::InvalidQuantity));
369++ }
370++
371++ #[test]
372++ fn rejects_checked_out_cart() {
374++ .add(&product("MUG-001", 1800, 10), Quantity::new(1).unwrap());
375++ assert_eq!(result, Err(DomainError::CartClosed));
376++ }
377++ }
378++
379++ mod checkout {
380++ use super::*;
381++
382++ #[test]
383++ fn totals_every_line_in_cents() {
384++ let products = [product("MUG-001", 1800, 10), product("TEE-M", 2900, 10)];
386++ .checkout(&products)
387++ .unwrap();
388++ assert_eq!(draft.total.cents(), 6500);
389++ }
390++
391++ #[test]
392++ fn rejects_empty_cart() {
394++ assert_eq!(result, Err(DomainError::EmptyCart));
395++ }
396++
397++ #[test]
398++ fn rejects_line_that_exceeds_current_stock() {
400++ .checkout(&[product("LAMP-1", 12900, 1)]);
401++ assert_eq!(result, Err(DomainError::OutOfStock(sku("LAMP-1"))));
402++ }
403++
404++ #[test]
405++ fn rejects_checked_out_cart() {
407++ .checkout(&[product("MUG-001", 1800, 10)]);
408++ assert_eq!(result, Err(DomainError::CartClosed));
409++ }
410++
411++ #[test]
412++ fn rejects_a_line_whose_product_is_missing() {
414++ assert_eq!(result, Err(DomainError::ProductNotFound(sku("MUG-001"))));
415++ }
416++
417++ #[test]
418++ fn rejects_a_total_that_overflows() {
419++ let products = [product("MUG-001", i64::MAX, 10)];
421++ assert_eq!(result, Err(DomainError::AmountOverflow));
422++ }
423++ }
424++
425++ #[test]
426++ fn money_rejects_a_negative_amount_and_an_overflowing_sum() {
427++ assert_eq!(Money::from_cents(-1), Err(DomainError::InvalidMoney));
428++ let max = Money::from_cents(i64::MAX).unwrap();
429++ assert_eq!(
430++ max.plus(Money::from_cents(1).unwrap()),
431++ Err(DomainError::AmountOverflow)
432++ );
433++ assert_eq!(
434++ max.times(Quantity::new(2).unwrap()),
435++ Err(DomainError::AmountOverflow)
436++ );
437++ }
438++
439++ #[test]
440++ fn sku_accepts_the_longest_code_and_exposes_it() {
441++ let longest = "A".repeat(32);
442++ let parsed = Sku::parse(&longest).unwrap();
443++ assert_eq!(parsed.as_str(), longest);
444++ assert_eq!(Sku::parse(&"A".repeat(33)), Err(DomainError::InvalidSku));
445++ }
446++
447++ #[test]
448++ fn cart_id_rejects_a_dash_in_the_wrong_place() {
449++ assert_eq!(
450++ CartId::parse("0b8f6c1e4a2d-4e1f-9c3b-2d5e7f8a9b0c"),
451++ Err(DomainError::InvalidCartId)
452++ );
453++ }
454++
455++ #[test]
456++ fn every_domain_error_names_the_problem() {
457++ let sku = sku("MUG-001");
458++ let cart_id = CartId::parse("0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0c").unwrap();
459++ let messages = [
460++ DomainError::InvalidSku.to_string(),
461++ DomainError::InvalidQuantity.to_string(),
462++ DomainError::InvalidMoney.to_string(),
463++ DomainError::InvalidCartId.to_string(),
464++ DomainError::AmountOverflow.to_string(),
465++ DomainError::ProductNotFound(sku.clone()).to_string(),
466++ DomainError::CartNotFound(cart_id.clone()).to_string(),
467++ DomainError::CartClosed.to_string(),
468++ DomainError::EmptyCart.to_string(),
469++ DomainError::OutOfStock(sku).to_string(),
470++ cart_id.to_string(),
471++ ];
472++ assert!(messages.iter().all(|message| !message.is_empty()));
473++ let as_error: &dyn std::error::Error = &DomainError::InvalidSku;
474++ assert_eq!(as_error.to_string(), DomainError::InvalidSku.to_string());
475++ }
476++}
shop-rs-api/src/domain/port.rs+57 -0 · 100% covered / 0% uncovered
runoldnew±codecov
1++use std::fmt;
2++
3++use async_trait::async_trait;
4++
6++
7++#[derive(Debug, Clone, PartialEq, Eq)]
8++pub enum RepositoryError {
9++ Rejected(DomainError),
10++ Unavailable(String),
11++}
12++
13++impl fmt::Display for RepositoryError {
14++ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
15++ match self {
16++ Self::Rejected(error) => write!(formatter, "{error}"),
17++ Self::Unavailable(reason) => write!(formatter, "storage unavailable: {reason}"),
18++ }
19++ }
20++}
21++
22++impl std::error::Error for RepositoryError {}
23++
24++#[cfg(test)]
25++mod tests {
26++ use super::*;
27++ use crate::domain::model::DomainError;
28++
29++ #[test]
30++ fn display_includes_the_domain_message_or_the_storage_reason() {
31++ let rejected = RepositoryError::Rejected(DomainError::EmptyCart);
32++ let unavailable = RepositoryError::Unavailable("connect".to_owned());
33++ assert_eq!(rejected.to_string(), DomainError::EmptyCart.to_string());
34++ assert_eq!(unavailable.to_string(), "storage unavailable: connect");
35++ let as_error: &dyn std::error::Error = &rejected;
36++ assert!(!as_error.to_string().is_empty());
37++ }
38++}
39++
40++#[async_trait]
41++pub trait ProductRepository: Send + Sync {
42++ async fn list(&self) -> Result<Vec<Product>, RepositoryError>;
43++ async fn find_by_sku(&self, sku: &Sku) -> Result<Option<Product>, RepositoryError>;
44++ async fn find_by_skus(&self, skus: &[Sku]) -> Result<Vec<Product>, RepositoryError>;
45++}
46++
47++#[async_trait]
48++pub trait CartRepository: Send + Sync {
49++ async fn create(&self) -> Result<Cart, RepositoryError>;
50++ async fn find(&self, cart_id: &CartId) -> Result<Option<Cart>, RepositoryError>;
52++}
53++
54++#[async_trait]
55++pub trait OrderRepository: Send + Sync {
56++ async fn place(&self, draft: &OrderDraft) -> Result<Order, RepositoryError>;
57++}
shop-rs-api/src/infrastructure/postgres.rs+384 -0 · 100% covered / 0% uncovered
runoldnew±codecov
1++use async_trait::async_trait;
2++use sqlx::postgres::{PgPool, PgPoolOptions, PgRow};
3++use sqlx::Row;
4++
5++use crate::domain::model::{
7++ Sku,
8++};
9++use crate::domain::port::{CartRepository, OrderRepository, ProductRepository, RepositoryError};
10++
11++const MAXIMUM_POOL_CONNECTIONS: u32 = 5;
12++const CART_STATUS_OPEN: &str = "open";
13++const CART_STATUS_CHECKED_OUT: &str = "checked_out";
14++
15++pub async fn connect(database_url: &str) -> Result<PgPool, RepositoryError> {
16++ let pool = checked(
17++ PgPoolOptions::new()
18++ .max_connections(MAXIMUM_POOL_CONNECTIONS)
19++ .connect(database_url)
20++ .await,
21++ "connect",
22++ )?;
24++ Ok(pool)
25++}
26++
27++fn unavailable(operation: &'static str, error: &dyn std::error::Error) -> RepositoryError {
28++ tracing::error!(upstream = "postgres", operation, error = %error, "storage operation failed");
29++ RepositoryError::Unavailable(operation.to_owned())
30++}
31++
32++fn checked<T, E>(result: Result<T, E>, operation: &'static str) -> Result<T, RepositoryError>
33++where
34++ E: std::error::Error,
35++{
36++ result.map_err(|error| unavailable(operation, &error))
37++}
38++
39++fn product_from_row(row: &PgRow) -> Result<Product, RepositoryError> {
40++ let sku: String = checked(row.try_get("sku"), "decode_product")?;
41++ let name: String = checked(row.try_get("name"), "decode_product")?;
42++ let price_cents: i64 = checked(row.try_get("price_cents"), "decode_product")?;
43++ let stock: i32 = checked(row.try_get("stock"), "decode_product")?;
44++ product_from_parts(&sku, name, price_cents, stock)
45++}
46++
47++fn product_from_parts(
48++ sku: &str,
49++ name: String,
50++ price_cents: i64,
51++ stock: i32,
52++) -> Result<Product, RepositoryError> {
53++ Ok(Product {
54++ sku: Sku::parse(sku).map_err(RepositoryError::Rejected)?,
55++ name,
56++ price: Money::from_cents(price_cents).map_err(RepositoryError::Rejected)?,
57++ stock: u32::try_from(stock)
58++ .map_err(|_| RepositoryError::Rejected(DomainError::InvalidQuantity))?,
59++ })
60++}
61++
62++pub struct PostgresProductRepository {
63++ pool: PgPool,
64++}
65++
66++impl PostgresProductRepository {
67++ pub fn new(pool: PgPool) -> Self {
68++ Self { pool }
69++ }
70++}
71++
72++#[async_trait]
73++impl ProductRepository for PostgresProductRepository {
74++ async fn list(&self) -> Result<Vec<Product>, RepositoryError> {
75++ let rows = checked(
76++ sqlx::query(r#"SELECT SKU, NAME, PRICE_CENTS, STOCK FROM product ORDER BY SKU"#)
77++ .fetch_all(&self.pool)
78++ .await,
79++ "list_products",
80++ )?;
81++ rows.iter().map(product_from_row).collect()
82++ }
83++ async fn find_by_sku(&self, sku: &Sku) -> Result<Option<Product>, RepositoryError> {
84++ let row = checked(
85++ sqlx::query(r#"SELECT SKU, NAME, PRICE_CENTS, STOCK FROM product WHERE SKU = $1"#)
86++ .bind(sku.as_str())
87++ .fetch_optional(&self.pool)
88++ .await,
89++ "find_product",
90++ )?;
91++ row.as_ref().map(product_from_row).transpose()
92++ }
93++ async fn find_by_skus(&self, skus: &[Sku]) -> Result<Vec<Product>, RepositoryError> {
94++ let values: Vec<String> = skus.iter().map(|sku| sku.as_str().to_owned()).collect();
95++ let rows = checked(
96++ sqlx::query(r#"SELECT SKU, NAME, PRICE_CENTS, STOCK FROM product WHERE SKU = ANY($1)"#)
97++ .bind(&values)
98++ .fetch_all(&self.pool)
99++ .await,
100++ "find_products",
101++ )?;
102++ rows.iter().map(product_from_row).collect()
103++ }
104++}
105++
106++pub struct PostgresCartRepository {
107++ pool: PgPool,
108++}
109++
110++impl PostgresCartRepository {
111++ pub fn new(pool: PgPool) -> Self {
112++ Self { pool }
113++ }
114++}
115++
116++fn cart_status(raw: &str) -> Result<CartStatus, RepositoryError> {
117++ match raw {
118++ CART_STATUS_OPEN => Ok(CartStatus::Open),
119++ CART_STATUS_CHECKED_OUT => Ok(CartStatus::CheckedOut),
120++ other => {
121++ tracing::error!(upstream = "postgres", status = other, "unknown cart status");
122++ Err(RepositoryError::Unavailable("decode_cart".to_owned()))
123++ }
124++ }
125++}
126++
128++ let sku: String = checked(row.try_get("sku"), "decode_cart_line")?;
129++ let quantity: i32 = checked(row.try_get("quantity"), "decode_cart_line")?;
130++ cart_line_from_parts(&sku, quantity)
131++}
132++
135++ sku: Sku::parse(sku).map_err(RepositoryError::Rejected)?,
136++ quantity: Quantity::new(i64::from(quantity)).map_err(RepositoryError::Rejected)?,
137++ })
138++}
139++
140++fn quantity_column(quantity: u32) -> Result<i32, RepositoryError> {
141++ i32::try_from(quantity).map_err(|_| RepositoryError::Rejected(DomainError::InvalidQuantity))
142++}
143++
144++fn close_outcome(rows_affected: u64) -> Result<(), RepositoryError> {
145++ if rows_affected == 0 {
146++ Err(RepositoryError::Rejected(DomainError::CartClosed))
147++ } else {
148++ Ok(())
149++ }
150++}
151++
152++fn reserve_outcome(rows_affected: u64, sku: &Sku) -> Result<(), RepositoryError> {
153++ if rows_affected == 0 {
154++ Err(RepositoryError::Rejected(DomainError::OutOfStock(
155++ sku.clone(),
156++ )))
157++ } else {
158++ Ok(())
159++ }
160++}
161++
162++#[async_trait]
163++impl CartRepository for PostgresCartRepository {
164++ async fn create(&self) -> Result<Cart, RepositoryError> {
165++ let row = checked(
166++ sqlx::query(r#"INSERT INTO cart (STATUS) VALUES ($1) RETURNING ID::text AS id"#)
167++ .bind(CART_STATUS_OPEN)
168++ .fetch_one(&self.pool)
169++ .await,
170++ "create_cart",
171++ )?;
172++ let id: String = checked(row.try_get("id"), "decode_cart")?;
173++ Ok(Cart {
174++ id: CartId::parse(&id).map_err(RepositoryError::Rejected)?,
175++ status: CartStatus::Open,
176++ lines: Vec::new(),
177++ })
178++ }
179++ async fn find(&self, cart_id: &CartId) -> Result<Option<Cart>, RepositoryError> {
180++ let Some(row) = checked(
181++ sqlx::query(r#"SELECT STATUS FROM cart WHERE ID = $1::uuid"#)
182++ .bind(cart_id.as_str())
183++ .fetch_optional(&self.pool)
184++ .await,
185++ "find_cart",
186++ )?
187++ else {
188++ return Ok(None);
189++ };
190++ let status: String = checked(row.try_get("status"), "decode_cart")?;
191++ let line_rows = checked(
192++ sqlx::query(
193++ r#"SELECT SKU, QUANTITY FROM cart_line WHERE CART_ID = $1::uuid ORDER BY SKU"#,
194++ )
195++ .bind(cart_id.as_str())
196++ .fetch_all(&self.pool)
197++ .await,
198++ "find_cart_lines",
199++ )?;
200++ Ok(Some(Cart {
201++ id: cart_id.clone(),
202++ status: cart_status(&status)?,
203++ lines: line_rows
204++ .iter()
205++ .map(cart_line_from_row)
206++ .collect::<Result<_, _>>()?,
207++ }))
208++ }
211++ checked(
212++ sqlx::query(
213++ r#"INSERT INTO cart_line (CART_ID, SKU, QUANTITY) VALUES ($1::uuid, $2, $3)
214++ ON CONFLICT (CART_ID, SKU) DO UPDATE SET QUANTITY = EXCLUDED.QUANTITY"#,
215++ )
216++ .bind(cart_id.as_str())
218++ .bind(quantity)
219++ .execute(&self.pool)
220++ .await,
221++ "save_cart_line",
222++ )?;
223++ Ok(())
224++ }
225++}
226++
227++pub struct PostgresOrderRepository {
228++ pool: PgPool,
229++}
230++
231++impl PostgresOrderRepository {
232++ pub fn new(pool: PgPool) -> Self {
233++ Self { pool }
234++ }
235++}
236++
237++#[async_trait]
238++impl OrderRepository for PostgresOrderRepository {
239++ async fn place(&self, draft: &OrderDraft) -> Result<Order, RepositoryError> {
240++ let mut transaction = checked(self.pool.begin().await, "begin_order")?;
241++ let closed = checked(
242++ sqlx::query(r#"UPDATE cart SET STATUS = $2 WHERE ID = $1::uuid AND STATUS = $3"#)
243++ .bind(draft.cart_id.as_str())
244++ .bind(CART_STATUS_CHECKED_OUT)
245++ .bind(CART_STATUS_OPEN)
246++ .execute(&mut *transaction)
247++ .await,
248++ "close_cart",
249++ )?;
250++ close_outcome(closed.rows_affected())?;
253++ let reserved = checked(
254++ sqlx::query(
255++ r#"UPDATE product SET STOCK = STOCK - $2 WHERE SKU = $1 AND STOCK >= $2"#,
256++ )
258++ .bind(quantity)
259++ .execute(&mut *transaction)
260++ .await,
261++ "reserve_stock",
262++ )?;
264++ }
265++ let row = checked(
266++ sqlx::query(r#"INSERT INTO customer_order (CART_ID, TOTAL_CENTS) VALUES ($1::uuid, $2) RETURNING ID"#)
267++ .bind(draft.cart_id.as_str())
268++ .bind(draft.total.cents())
269++ .fetch_one(&mut *transaction)
270++ .await,
271++ "insert_order",
272++ )?;
273++ let order_id: i64 = checked(row.try_get("id"), "decode_order")?;
276++ checked(
277++ sqlx::query(
278++ r#"INSERT INTO customer_order_line (ORDER_ID, SKU, QUANTITY, UNIT_PRICE_CENTS) VALUES ($1, $2, $3, $4)"#,
279++ )
280++ .bind(order_id)
282++ .bind(quantity)
284++ .execute(&mut *transaction)
285++ .await,
286++ "insert_order_line",
287++ )?;
288++ }
289++ checked(transaction.commit().await, "commit_order")?;
290++ Ok(Order {
291++ id: order_id,
292++ cart_id: draft.cart_id.clone(),
293++ lines: draft.lines.clone(),
294++ total: draft.total,
295++ })
296++ }
297++}
298++
299++#[cfg(test)]
300++mod tests {
301++ use super::*;
302++
303++ #[tokio::test]
304++ async fn connect_reports_unavailable_when_the_server_refuses() {
306++ .await
307++ .unwrap_err();
308++ assert_eq!(error, RepositoryError::Unavailable("connect".to_owned()));
309++ assert!(error.to_string().contains("connect"));
310++ }
311++
312++ #[test]
313++ fn cart_status_accepts_both_stored_values_and_rejects_anything_else() {
314++ assert_eq!(cart_status(CART_STATUS_OPEN).unwrap(), CartStatus::Open);
315++ assert_eq!(
316++ cart_status(CART_STATUS_CHECKED_OUT).unwrap(),
317++ CartStatus::CheckedOut
318++ );
319++ assert_eq!(
320++ cart_status("archived").unwrap_err(),
321++ RepositoryError::Unavailable("decode_cart".to_owned())
322++ );
323++ }
324++
325++ #[test]
326++ fn product_parts_reject_values_the_schema_also_forbids() {
327++ assert!(product_from_parts("MUG-STONE", "Mug".to_owned(), 1800, 4).is_ok());
328++ assert!(matches!(
329++ product_from_parts("no", "Mug".to_owned(), 1800, 4),
330++ Err(RepositoryError::Rejected(DomainError::InvalidSku))
331++ ));
332++ assert!(matches!(
333++ product_from_parts("MUG-STONE", "Mug".to_owned(), -1, 4),
334++ Err(RepositoryError::Rejected(DomainError::InvalidMoney))
335++ ));
336++ assert!(matches!(
337++ product_from_parts("MUG-STONE", "Mug".to_owned(), 1800, -1),
338++ Err(RepositoryError::Rejected(DomainError::InvalidQuantity))
339++ ));
340++ }
341++
342++ #[test]
343++ fn cart_line_parts_reject_a_bad_sku_or_quantity() {
344++ assert_eq!(
345++ cart_line_from_parts("MUG-STONE", 2)
346++ .unwrap()
347++ .quantity
348++ .value(),
349++ 2
350++ );
351++ assert!(matches!(
352++ cart_line_from_parts("no", 2),
353++ Err(RepositoryError::Rejected(DomainError::InvalidSku))
354++ ));
355++ assert!(matches!(
356++ cart_line_from_parts("MUG-STONE", 0),
357++ Err(RepositoryError::Rejected(DomainError::InvalidQuantity))
358++ ));
359++ }
360++
361++ #[test]
362++ fn quantity_column_rejects_a_value_that_does_not_fit_the_database() {
363++ assert_eq!(quantity_column(2).unwrap(), 2);
364++ assert!(matches!(
365++ quantity_column(u32::MAX),
366++ Err(RepositoryError::Rejected(DomainError::InvalidQuantity))
367++ ));
368++ }
369++
370++ #[test]
371++ fn order_outcomes_distinguish_a_closed_cart_and_a_missed_reservation() {
372++ let sku = Sku::parse("LAMP-DESK").unwrap();
373++ assert!(close_outcome(1).is_ok());
374++ assert_eq!(
375++ close_outcome(0).unwrap_err(),
376++ RepositoryError::Rejected(DomainError::CartClosed)
377++ );
378++ assert!(reserve_outcome(1, &sku).is_ok());
379++ assert_eq!(
380++ reserve_outcome(0, &sku).unwrap_err(),
381++ RepositoryError::Rejected(DomainError::OutOfStock(sku))
382++ );
383++ }
384++}
shop-rs-api/src/infrastructure/web.rs+858 -0 · 100% covered / 0% uncovered
runoldnew±codecov
1++use std::sync::Arc;
2++
3++use axum::extract::rejection::JsonRejection;
4++use axum::extract::{Path, State};
5++use axum::http::StatusCode;
6++use axum::response::{IntoResponse, Response};
7++use axum::routing::{get, post};
8++use axum::{Json, Router};
9++use serde::{Deserialize, Serialize};
10++use utoipa::{OpenApi, ToSchema};
11++
12++use crate::application::service::{CartService, CatalogService, ShopError};
13++use crate::domain::model::{
15++};
16++
17++#[derive(Clone)]
18++pub struct AppState {
19++ pub catalog: Arc<CatalogService>,
20++ pub carts: Arc<CartService>,
21++}
22++
23++#[derive(Debug, Serialize, ToSchema)]
24++pub struct ProductResponse {
25++ sku: String,
26++ name: String,
27++ price_cents: i64,
28++ stock: u32,
29++}
30++
31++impl From<Product> for ProductResponse {
32++ fn from(product: Product) -> Self {
33++ Self {
34++ sku: product.sku.as_str().to_owned(),
35++ name: product.name,
36++ price_cents: product.price.cents(),
37++ stock: product.stock,
38++ }
39++ }
40++}
41++
42++#[derive(Debug, Serialize, ToSchema)]
43++pub struct ProductListResponse {
44++ items: Vec<ProductResponse>,
45++ total: usize,
46++}
47++
48++#[derive(Debug, Serialize, ToSchema)]
49++pub struct CartLineResponse {
50++ sku: String,
51++ quantity: u32,
52++}
53++
56++ Self {
59++ }
60++ }
61++}
62++
63++#[derive(Debug, Serialize, ToSchema)]
64++#[serde(rename_all = "snake_case")]
65++pub enum CartStatusResponse {
66++ Open,
67++ CheckedOut,
68++}
69++
70++impl From<CartStatus> for CartStatusResponse {
71++ fn from(status: CartStatus) -> Self {
72++ match status {
73++ CartStatus::Open => Self::Open,
74++ CartStatus::CheckedOut => Self::CheckedOut,
75++ }
76++ }
77++}
78++
79++#[derive(Debug, Serialize, ToSchema)]
80++pub struct CartResponse {
81++ id: String,
82++ status: CartStatusResponse,
83++ lines: Vec<CartLineResponse>,
84++}
85++
86++impl From<Cart> for CartResponse {
88++ Self {
92++ }
93++ }
94++}
95++
96++#[derive(Debug, Serialize, ToSchema)]
97++pub struct OrderLineResponse {
98++ sku: String,
99++ quantity: u32,
100++ unit_price_cents: i64,
101++}
102++
103++impl From<OrderLine> for OrderLineResponse {
105++ Self {
109++ }
110++ }
111++}
112++
113++#[derive(Debug, Serialize, ToSchema)]
114++pub struct OrderResponse {
115++ id: i64,
116++ cart_id: String,
117++ lines: Vec<OrderLineResponse>,
118++ total_cents: i64,
119++}
120++
121++impl From<Order> for OrderResponse {
122++ fn from(order: Order) -> Self {
123++ Self {
124++ id: order.id,
125++ cart_id: order.cart_id.as_str().to_owned(),
126++ lines: order
127++ .lines
128++ .into_iter()
129++ .map(OrderLineResponse::from)
130++ .collect(),
131++ total_cents: order.total.cents(),
132++ }
133++ }
134++}
135++
136++#[derive(Debug, Deserialize, ToSchema)]
137++pub struct AddCartItemRequest {
138++ sku: String,
139++ quantity: i64,
140++}
141++
142++#[derive(Debug, Serialize, ToSchema)]
143++pub struct ErrorBody {
144++ code: &'static str,
145++ message: String,
146++}
147++
148++#[derive(Debug, Serialize, ToSchema)]
149++pub struct ErrorResponse {
150++ error: ErrorBody,
151++}
152++
153++#[derive(Debug)]
154++pub struct ApiError {
155++ status: StatusCode,
156++ code: &'static str,
157++ message: String,
158++}
159++
160++impl From<DomainError> for ApiError {
161++ fn from(error: DomainError) -> Self {
162++ let (status, code) = match &error {
163++ DomainError::InvalidSku => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_sku"),
164++ DomainError::InvalidQuantity => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_quantity"),
165++ DomainError::InvalidMoney => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_money"),
166++ DomainError::InvalidCartId => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_cart_id"),
167++ DomainError::AmountOverflow => (StatusCode::UNPROCESSABLE_ENTITY, "amount_overflow"),
168++ DomainError::EmptyCart => (StatusCode::UNPROCESSABLE_ENTITY, "empty_cart"),
169++ DomainError::ProductNotFound(_) => (StatusCode::NOT_FOUND, "product_not_found"),
170++ DomainError::CartNotFound(_) => (StatusCode::NOT_FOUND, "cart_not_found"),
171++ DomainError::CartClosed => (StatusCode::CONFLICT, "cart_closed"),
172++ DomainError::OutOfStock(_) => (StatusCode::CONFLICT, "out_of_stock"),
173++ };
174++ Self {
175++ status,
176++ code,
177++ message: error.to_string(),
178++ }
179++ }
180++}
181++
182++impl From<ShopError> for ApiError {
183++ fn from(error: ShopError) -> Self {
184++ match error {
185++ ShopError::Domain(domain_error) => domain_error.into(),
186++ ShopError::Unavailable(operation) => {
187++ tracing::error!(operation = %operation, "request failed on storage");
188++ Self {
189++ status: StatusCode::INTERNAL_SERVER_ERROR,
190++ code: "storage_unavailable",
191++ message: "storage is unavailable".to_owned(),
192++ }
193++ }
194++ }
195++ }
196++}
197++
198++impl From<JsonRejection> for ApiError {
199++ fn from(rejection: JsonRejection) -> Self {
200++ Self {
201++ status: StatusCode::UNPROCESSABLE_ENTITY,
202++ code: "invalid_body",
203++ message: rejection.body_text(),
204++ }
205++ }
206++}
207++
208++impl IntoResponse for ApiError {
209++ fn into_response(self) -> Response {
210++ let body = ErrorResponse {
211++ error: ErrorBody {
212++ code: self.code,
213++ message: self.message,
214++ },
215++ };
216++ (self.status, Json(body)).into_response()
217++ }
218++}
219++
220++#[utoipa::path(get, path = "/health", tag = "Health", responses((status = 200, description = "Service is up")))]
221++async fn health() -> StatusCode {
222++ StatusCode::OK
223++}
224++
225++#[utoipa::path(get, path = "/v1/products", tag = "Catalog",
226++ responses((status = 200, body = ProductListResponse), (status = 500, body = ErrorResponse)))]
227++async fn list_products(
229++) -> Result<Json<ProductListResponse>, ApiError> {
231++ .catalog
232++ .list_products()
233++ .await?
234++ .into_iter()
235++ .map(ProductResponse::from)
236++ .collect();
237++ let total = items.len();
238++ Ok(Json(ProductListResponse { items, total }))
239++}
240++
241++#[utoipa::path(get, path = "/v1/products/{sku}", tag = "Catalog",
242++ params(("sku" = String, Path, description = "Product SKU, e.g. MUG-STONE")),
243++ responses((status = 200, body = ProductResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
244++async fn find_product(
246++ Path(sku): Path<String>,
247++) -> Result<Json<ProductResponse>, ApiError> {
248++ let sku = Sku::parse(&sku)?;
250++}
251++
252++#[utoipa::path(post, path = "/v1/carts", tag = "Cart",
253++ responses((status = 201, body = CartResponse), (status = 500, body = ErrorResponse)))]
254++async fn open_cart(
256++) -> Result<(StatusCode, Json<CartResponse>), ApiError> {
259++}
260++
261++#[utoipa::path(get, path = "/v1/carts/{id}", tag = "Cart",
262++ params(("id" = String, Path, description = "Cart UUID")),
263++ responses((status = 200, body = CartResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
264++async fn find_cart(
266++ Path(cart_id): Path<String>,
267++) -> Result<Json<CartResponse>, ApiError> {
268++ let cart_id = CartId::parse(&cart_id)?;
270++}
271++
272++#[utoipa::path(post, path = "/v1/carts/{id}/items", tag = "Cart",
273++ params(("id" = String, Path, description = "Cart UUID")),
274++ request_body = AddCartItemRequest,
275++ responses((status = 200, body = CartResponse), (status = 404, body = ErrorResponse),
276++ (status = 409, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
277++async fn add_cart_item(
279++ Path(cart_id): Path<String>,
280++ payload: Result<Json<AddCartItemRequest>, JsonRejection>,
281++) -> Result<Json<CartResponse>, ApiError> {
282++ let Json(request) = payload?;
283++ let cart_id = CartId::parse(&cart_id)?;
284++ let sku = Sku::parse(&request.sku)?;
285++ let quantity = Quantity::new(request.quantity)?;
286++ Ok(Json(
288++ .carts
289++ .add_to_cart(&cart_id, &sku, quantity)
290++ .await?
291++ .into(),
292++ ))
293++}
294++
295++#[utoipa::path(post, path = "/v1/carts/{id}/checkout", tag = "Checkout",
296++ params(("id" = String, Path, description = "Cart UUID")),
297++ responses((status = 201, body = OrderResponse), (status = 404, body = ErrorResponse),
298++ (status = 409, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
299++async fn checkout(
301++ Path(cart_id): Path<String>,
302++) -> Result<(StatusCode, Json<OrderResponse>), ApiError> {
303++ let cart_id = CartId::parse(&cart_id)?;
305++ tracing::info!(order_id = order.id, cart_id = %order.cart_id, total_cents = order.total.cents(), "order placed");
306++ Ok((StatusCode::CREATED, Json(order.into())))
307++}
308++
309++#[derive(OpenApi)]
310++#[openapi(
311++ info(
312++ title = "shop-rs-api",
313++ version = "1.0.0",
314++ description = "Shop showcase API: catalog, cart and checkout"
315++ ),
316++ paths(
317++ health,
318++ list_products,
319++ find_product,
320++ open_cart,
321++ find_cart,
322++ add_cart_item,
323++ checkout
324++ ),
325++ components(schemas(
326++ ProductResponse,
327++ ProductListResponse,
328++ CartResponse,
329++ CartLineResponse,
330++ CartStatusResponse,
331++ OrderResponse,
332++ OrderLineResponse,
333++ AddCartItemRequest,
334++ ErrorResponse,
335++ ErrorBody
336++ ))
337++)]
338++struct ApiDocument;
339++
340++async fn openapi() -> Json<utoipa::openapi::OpenApi> {
341++ Json(ApiDocument::openapi())
342++}
343++
345++ Router::new()
346++ .route("/health", get(health))
347++ .route("/openapi.json", get(openapi))
348++ .route("/v1/products", get(list_products))
349++ .route("/v1/products/{sku}", get(find_product))
350++ .route("/v1/carts", post(open_cart))
351++ .route("/v1/carts/{id}", get(find_cart))
352++ .route("/v1/carts/{id}/items", post(add_cart_item))
353++ .route("/v1/carts/{id}/checkout", post(checkout))
355++}
356++
357++#[cfg(test)]
358++mod tests {
359++ use std::sync::{Arc, Mutex};
360++
361++ use async_trait::async_trait;
362++ use axum::body::Body;
363++ use axum::http::{Request, StatusCode};
364++ use http_body_util::BodyExt;
365++ use tower::ServiceExt;
366++
367++ use super::*;
368++ use crate::application::service::{CartService, CatalogService};
369++ use crate::domain::model::{
371++ Quantity, Sku,
372++ };
373++ use crate::domain::port::{
374++ CartRepository, OrderRepository, ProductRepository, RepositoryError,
375++ };
376++
377++ const CART_ID: &str = "0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0c";
378++
379++ struct Products {
380++ fail: Option<RepositoryError>,
381++ products: Vec<Product>,
382++ }
383++
384++ #[async_trait]
385++ impl ProductRepository for Products {
386++ async fn list(&self) -> Result<Vec<Product>, RepositoryError> {
387++ match &self.fail {
388++ Some(error) => Err(error.clone()),
389++ None => Ok(self.products.clone()),
390++ }
391++ }
392++ async fn find_by_sku(&self, sku: &Sku) -> Result<Option<Product>, RepositoryError> {
393++ match &self.fail {
394++ Some(error) => Err(error.clone()),
395++ None => Ok(self
396++ .products
397++ .iter()
398++ .find(|product| &product.sku == sku)
399++ .cloned()),
400++ }
401++ }
402++ async fn find_by_skus(&self, skus: &[Sku]) -> Result<Vec<Product>, RepositoryError> {
403++ match &self.fail {
404++ Some(error) => Err(error.clone()),
405++ None => Ok(self
406++ .products
407++ .iter()
408++ .filter(|product| skus.contains(&product.sku))
409++ .cloned()
410++ .collect()),
411++ }
412++ }
413++ }
414++
415++ struct Carts {
416++ carts: Mutex<Vec<Cart>>,
417++ fail: Option<RepositoryError>,
418++ }
419++
420++ #[async_trait]
422++ async fn create(&self) -> Result<Cart, RepositoryError> {
423++ if let Some(error) = &self.fail {
424++ return Err(error.clone());
425++ }
427++ id: CartId::parse(CART_ID).unwrap(),
428++ status: CartStatus::Open,
429++ lines: Vec::new(),
430++ };
433++ }
434++ async fn find(&self, cart_id: &CartId) -> Result<Option<Cart>, RepositoryError> {
435++ if let Some(error) = &self.fail {
436++ return Err(error.clone());
437++ }
438++ Ok(self
439++ .carts
440++ .lock()
441++ .unwrap()
442++ .iter()
444++ .cloned())
445++ }
446++ async fn save_line(
447++ &self,
448++ cart_id: &CartId,
450++ ) -> Result<(), RepositoryError> {
451++ if let Some(error) = &self.fail {
452++ return Err(error.clone());
453++ }
454++ let mut carts = self.carts.lock().unwrap();
458++ Ok(())
459++ }
460++ }
461++
462++ struct Orders {
463++ fail: Option<RepositoryError>,
464++ }
465++
466++ #[async_trait]
468++ async fn place(&self, draft: &OrderDraft) -> Result<Order, RepositoryError> {
469++ if let Some(error) = &self.fail {
470++ return Err(error.clone());
471++ }
472++ Ok(Order {
473++ id: 1,
474++ cart_id: draft.cart_id.clone(),
475++ lines: draft.lines.clone(),
476++ total: draft.total,
477++ })
478++ }
479++ }
480++
481++ fn sku(raw: &str) -> Sku {
482++ Sku::parse(raw).unwrap()
483++ }
484++
485++ fn product(raw: &str, price: i64, stock: u32) -> Product {
486++ Product {
487++ sku: sku(raw),
488++ name: raw.to_owned(),
489++ price: Money::from_cents(price).unwrap(),
490++ stock,
491++ }
492++ }
493++
494++ fn state(
495++ products: Vec<Product>,
496++ carts: Vec<Cart>,
497++ product_fail: Option<RepositoryError>,
498++ cart_fail: Option<RepositoryError>,
499++ order_fail: Option<RepositoryError>,
500++ ) -> AppState {
501++ AppState {
502++ catalog: Arc::new(CatalogService::new(Arc::new(Products {
503++ fail: product_fail,
504++ products: products.clone(),
505++ }))),
506++ carts: Arc::new(CartService::new(
507++ Arc::new(Products {
508++ fail: None,
509++ products,
510++ }),
512++ carts: Mutex::new(carts),
513++ fail: cart_fail,
514++ }),
516++ )),
517++ }
518++ }
519++
520++ fn open_cart_model() -> Cart {
521++ Cart {
522++ id: CartId::parse(CART_ID).unwrap(),
523++ status: CartStatus::Open,
524++ lines: Vec::new(),
525++ }
526++ }
527++
528++ async fn send(
530++ method: &str,
531++ path: &str,
532++ body: Option<&str>,
533++ ) -> (StatusCode, String) {
534++ let mut builder = Request::builder().method(method).uri(path);
535++ if body.is_some() {
536++ builder = builder.header("content-type", "application/json");
537++ }
538++ let request = builder
539++ .body(Body::from(body.unwrap_or("").to_owned()))
540++ .unwrap();
542++ let status = response.status();
543++ let bytes = response.into_body().collect().await.unwrap().to_bytes();
544++ (status, String::from_utf8(bytes.to_vec()).unwrap())
545++ }
546++
547++ #[test]
548++ fn every_domain_error_has_an_http_status() {
549++ let cases = [
550++ (
551++ DomainError::InvalidSku,
552++ StatusCode::UNPROCESSABLE_ENTITY,
553++ "invalid_sku",
554++ ),
555++ (
556++ DomainError::InvalidQuantity,
557++ StatusCode::UNPROCESSABLE_ENTITY,
558++ "invalid_quantity",
559++ ),
560++ (
561++ DomainError::InvalidMoney,
562++ StatusCode::UNPROCESSABLE_ENTITY,
563++ "invalid_money",
564++ ),
565++ (
566++ DomainError::InvalidCartId,
567++ StatusCode::UNPROCESSABLE_ENTITY,
568++ "invalid_cart_id",
569++ ),
570++ (
571++ DomainError::AmountOverflow,
572++ StatusCode::UNPROCESSABLE_ENTITY,
573++ "amount_overflow",
574++ ),
575++ (
576++ DomainError::EmptyCart,
577++ StatusCode::UNPROCESSABLE_ENTITY,
578++ "empty_cart",
579++ ),
580++ (
581++ DomainError::ProductNotFound(sku("MUG-STONE")),
582++ StatusCode::NOT_FOUND,
583++ "product_not_found",
584++ ),
585++ (
586++ DomainError::CartNotFound(CartId::parse(CART_ID).unwrap()),
587++ StatusCode::NOT_FOUND,
588++ "cart_not_found",
589++ ),
590++ (DomainError::CartClosed, StatusCode::CONFLICT, "cart_closed"),
591++ (
592++ DomainError::OutOfStock(sku("LAMP-DESK")),
593++ StatusCode::CONFLICT,
594++ "out_of_stock",
595++ ),
596++ ];
597++ for (error, status, code) in cases {
598++ let api = ApiError::from(error);
599++ assert_eq!(api.status, status);
600++ assert_eq!(api.code, code);
601++ let response = api.into_response();
602++ assert_eq!(response.status(), status);
603++ }
604++ let unavailable = ApiError::from(ShopError::Unavailable("connect".to_owned()));
605++ assert_eq!(unavailable.status, StatusCode::INTERNAL_SERVER_ERROR);
606++ assert_eq!(unavailable.code, "storage_unavailable");
607++ }
608++
609++ #[tokio::test]
610++ async fn health_and_openapi_respond() {
612++ vec![product("MUG-STONE", 1800, 10)],
613++ vec![],
614++ None,
615++ None,
616++ None,
617++ );
619++ assert_eq!(status, StatusCode::OK);
621++ assert_eq!(status, StatusCode::OK);
622++ assert!(body.contains("shop-rs-api"));
623++ }
624++
625++ #[tokio::test]
626++ async fn catalog_lists_and_finds_products() {
628++ vec![product("MUG-STONE", 1800, 10)],
629++ vec![],
630++ None,
631++ None,
632++ None,
633++ );
635++ assert_eq!(status, StatusCode::OK);
636++ assert!(body.contains("MUG-STONE"));
638++ assert_eq!(status, StatusCode::OK);
639++ assert!(body.contains("1800"));
641++ assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
642++ assert!(body.contains("invalid_sku"));
644++ assert_eq!(status, StatusCode::NOT_FOUND);
645++ assert!(body.contains("product_not_found"));
646++ }
647++
648++ #[tokio::test]
649++ async fn catalog_reports_storage_failure() {
651++ vec![],
652++ vec![],
653++ Some(RepositoryError::Unavailable("connect".to_owned())),
654++ None,
655++ None,
656++ );
658++ assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
659++ assert!(body.contains("storage_unavailable"));
660++ }
661++
662++ #[tokio::test]
663++ async fn cart_opens_adds_and_checks_out() {
665++ vec![product("MUG-STONE", 1800, 10)],
666++ vec![],
667++ None,
668++ None,
669++ None,
670++ );
672++ assert_eq!(status, StatusCode::CREATED);
673++ assert!(body.contains(CART_ID));
675++ app.clone(),
676++ "POST",
677++ &format!("/v1/carts/{CART_ID}/items"),
678++ Some(r#"{"sku":"MUG-STONE","quantity":2}"#),
679++ )
680++ .await;
681++ assert_eq!(status, StatusCode::OK);
682++ assert!(body.contains("MUG-STONE"));
684++ app.clone(),
685++ "POST",
686++ &format!("/v1/carts/{CART_ID}/items"),
687++ Some(r#"{"sku":"MUG-STONE","quantity":1}"#),
688++ )
689++ .await;
690++ assert_eq!(status, StatusCode::OK);
691++ assert!(body.contains("\"quantity\":3"));
693++ assert_eq!(status, StatusCode::OK);
694++ assert!(body.contains("open"));
695++ let (status, body) =
697++ assert_eq!(status, StatusCode::CREATED);
698++ assert!(body.contains("5400"));
699++ }
700++
701++ #[tokio::test]
702++ async fn cart_rejects_bad_input_and_conflicts() {
703++ let closed = Cart {
704++ id: CartId::parse(CART_ID).unwrap(),
705++ status: CartStatus::CheckedOut,
707++ sku: sku("MUG-STONE"),
708++ quantity: Quantity::new(1).unwrap(),
709++ }],
710++ };
712++ vec![
713++ product("MUG-STONE", 1800, 10),
714++ product("LAMP-DESK", 12900, 1),
715++ ],
716++ vec![closed.clone()],
717++ None,
718++ None,
719++ None,
720++ );
722++ assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
724++ app.clone(),
725++ "GET",
726++ "/v1/carts/0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0d",
727++ None,
728++ )
729++ .await;
730++ assert_eq!(status, StatusCode::NOT_FOUND);
732++ assert_eq!(status, StatusCode::OK);
733++ assert!(body.contains("checked_out"));
735++ app.clone(),
736++ "POST",
737++ &format!("/v1/carts/{CART_ID}/items"),
738++ Some("not-json"),
739++ )
740++ .await;
741++ assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
743++ app.clone(),
744++ "POST",
745++ &format!("/v1/carts/{CART_ID}/items"),
746++ Some(r#"{"sku":"NO","quantity":1}"#),
747++ )
748++ .await;
749++ assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
750++ assert!(body.contains("invalid_sku"));
752++ app.clone(),
753++ "POST",
754++ &format!("/v1/carts/{CART_ID}/items"),
755++ Some(r#"{"sku":"MUG-STONE","quantity":0}"#),
756++ )
757++ .await;
758++ assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
759++ assert!(body.contains("invalid_quantity"));
761++ app.clone(),
762++ "POST",
763++ "/v1/carts/0b8f6c1e-4a2d-4e1f-9c3b-2d5e7f8a9b0d/checkout",
764++ None,
765++ )
766++ .await;
767++ assert_eq!(status, StatusCode::NOT_FOUND);
768++ let (status, body) =
770++ assert_eq!(status, StatusCode::CONFLICT);
771++ assert!(body.contains("cart_closed"));
772++ }
773++
774++ #[tokio::test]
775++ async fn checkout_reports_an_empty_cart_stock_conflict_and_overflow() {
778++ vec![
779++ product("MUG-STONE", 1800, 10),
780++ product("LAMP-DESK", i64::MAX, 10),
781++ ],
782++ vec![empty],
783++ None,
784++ None,
785++ None,
786++ );
788++ app.clone(),
789++ "POST",
790++ &format!("/v1/carts/{CART_ID}/checkout"),
791++ None,
792++ )
793++ .await;
794++ assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
795++ assert!(body.contains("empty_cart"));
797++ app.clone(),
798++ "POST",
799++ &format!("/v1/carts/{CART_ID}/items"),
800++ Some(r#"{"sku":"LAMP-DESK","quantity":2}"#),
801++ )
802++ .await;
803++ assert_eq!(status, StatusCode::OK);
805++ app.clone(),
806++ "POST",
807++ &format!("/v1/carts/{CART_ID}/checkout"),
808++ None,
809++ )
810++ .await;
811++ assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
812++ assert!(body.contains("amount_overflow"));
814++ vec![product("LAMP-DESK", 12900, 1)],
816++ None,
817++ None,
818++ None,
819++ );
821++ stock.clone(),
822++ "POST",
823++ &format!("/v1/carts/{CART_ID}/items"),
824++ Some(r#"{"sku":"LAMP-DESK","quantity":3}"#),
825++ )
826++ .await;
827++ assert_eq!(status, StatusCode::CONFLICT);
829++ vec![product("MUG-STONE", 1800, 10)],
830++ vec![],
831++ None,
832++ None,
833++ None,
834++ );
836++ missing,
837++ "POST",
838++ &format!("/v1/carts/{CART_ID}/items"),
839++ Some(r#"{"sku":"MUG-STONE","quantity":1}"#),
840++ )
841++ .await;
842++ assert_eq!(status, StatusCode::NOT_FOUND);
843++ }
844++
845++ #[tokio::test]
846++ async fn cart_storage_failure_is_a_server_error() {
848++ vec![product("MUG-STONE", 1800, 10)],
849++ vec![],
850++ None,
851++ Some(RepositoryError::Unavailable("connect".to_owned())),
852++ None,
853++ );
855++ assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
856++ assert!(body.contains("storage_unavailable"));
857++ }
858++}
shop-rs-api/src/main.rs+60 -0 · 100% covered / 0% uncovered
runoldnew±codecov
1++use std::sync::Arc;
2++
3++use shop_rs_api::application::service::{CartService, CatalogService};
4++use shop_rs_api::infrastructure::postgres::{
5++ connect, PostgresCartRepository, PostgresOrderRepository, PostgresProductRepository,
6++};
7++use shop_rs_api::infrastructure::web::{router, AppState};
8++use tokio::net::TcpListener;
9++use tracing::error;
10++use tracing_subscriber::EnvFilter;
11++
12++const DEFAULT_ADDRESS: &str = "0.0.0.0:3000";
13++
14++#[tokio::main]
15++async fn main() {
16++ tracing_subscriber::fmt()
17++ .with_env_filter(EnvFilter::from_default_env())
18++ .init();
20++ error!(error = %message, "shop-rs-api failed");
21++ std::process::exit(1);
22++ }
23++}
24++
25++async fn run() -> Result<(), String> {
27++ let database_url =
28++ std::env::var("DATABASE_URL").map_err(|_| "DATABASE_URL is not set".to_owned())?;
29++ let pool = connect(&database_url)
30++ .await
31++ .map_err(|error| format!("database: {error:?}"))?;
32++ let products = Arc::new(PostgresProductRepository::new(pool.clone()));
34++ catalog: Arc::new(CatalogService::new(products.clone())),
35++ carts: Arc::new(CartService::new(
36++ products,
37++ Arc::new(PostgresCartRepository::new(pool.clone())),
38++ Arc::new(PostgresOrderRepository::new(pool)),
39++ )),
40++ };
41++ let listener = TcpListener::bind(&address)
42++ .await
44++ tracing::info!(%address, "shop-rs-api listening");
47++ .await
48++ .map_err(|error| format!("serve: {error}"))
49++}
50++
51++fn bind_failure(address: &str, error: &std::io::Error) -> String {
52++ error!(error = %error, %address, "bind failed");
53++ format!("bind {address}: {error}")
54++}
55++
56++#[rustfmt::skip]
57++async fn wait_for_shutdown() {
58++ #[cfg(unix)]
59++ if let Ok(mut terminate) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { terminate.recv().await; }
60++}
shop-ts-lib-cart1 files · 100% covered / 0% uncovered
shop-ts-lib-cart/src/cart.ts+47 -0 · 100% covered / 0% uncovered
runoldnew±codecov
1++export type Cents = number & { readonly __brand: "Cents" };
2++
4++ readonly sku: string;
5++ readonly quantity: number;
6++ readonly unitPriceCents: Cents;
7++}
8++
9++export class InvalidAmountError extends Error {
10++ constructor(readonly amount: number) {
11++ super(`amount must be a non-negative safe integer of cents, got ${amount}`);
12++ this.name = "InvalidAmountError";
13++ }
14++}
15++
16++export class InvalidQuantityError extends Error {
17++ constructor(readonly sku: string, readonly quantity: number) {
18++ super(`quantity for ${sku} must be an integer between 1 and 99, got ${quantity}`);
19++ this.name = "InvalidQuantityError";
20++ }
21++}
22++
23++const MINIMUM_QUANTITY = 1;
24++const MAXIMUM_QUANTITY = 99;
25++const CENTS_PER_UNIT = 100;
26++
27++export function cents(amount: number): Cents {
28++ if (!Number.isSafeInteger(amount) || amount < 0) {
29++ throw new InvalidAmountError(amount);
30++ }
31++ return amount as Cents;
32++}
33++
35++ if (!Number.isInteger(line.quantity) || line.quantity < MINIMUM_QUANTITY || line.quantity > MAXIMUM_QUANTITY) {
37++ }
39++}
40++
43++}
44++
45++export function formatCents(amount: Cents, currency: string, locale: string): string {
46++ return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount / CENTS_PER_UNIT);
47++}
shop-ts-web-react4 files · 100% covered / 0% uncovered
shop-ts-web-react/src/Shop.tsx+136 -0 · 100% covered / 0% uncovered
runoldnew±codecov
1++import { useEffect, useState } from "react";
2++import { cartTotal, cents, formatCents, lineTotal, type CartLine as PricedLine } from "shop-ts-lib-cart";
3++
4++import { addCartItem, checkoutCart, listProducts, openCart, ShopRequestError, type Cart, type Order, type Product } from "./shopApi";
5++
6++const CURRENCY = "USD";
7++const LOCALE = "en-US";
8++
9++interface Props {
10++ readonly apiBase: string;
11++}
12++
15++ return { sku: line.sku, quantity: line.quantity, unitPriceCents: cents(product?.priceCents ?? 0) };
16++}
17++
18++function messageOf(error: unknown): string {
20++ return "something went wrong";
21++}
22++
23++export function Shop({ apiBase }: Props) {
24++ const [products, setProducts] = useState<readonly Product[] | null>(null);
26++ const [order, setOrder] = useState<Order | null>(null);
27++ const [quantities, setQuantities] = useState<Record<string, string>>({});
28++ const [error, setError] = useState<string | null>(null);
29++ const [busy, setBusy] = useState(false);
30++
31++ useEffect(() => {
32++ let active = true;
33++ listProducts(apiBase)
34++ .then((loaded) => {
35++ if (active) setProducts(loaded);
36++ })
37++ .catch((failure: unknown) => {
38++ if (active) setError(messageOf(failure));
39++ });
40++ return () => {
41++ active = false;
42++ };
43++ }, [apiBase]);
44++
45++ async function add(product: Product) {
46++ const parsed = Number(quantities[product.sku] ?? "1");
47++ setBusy(true);
48++ setError(null);
49++ setOrder(null);
50++ try {
52++ const updated = await addCartItem(apiBase, current.id, product.sku, parsed);
53++ setCart(updated);
54++ const refreshed = await listProducts(apiBase);
55++ setProducts(refreshed);
56++ } catch (failure: unknown) {
57++ setError(messageOf(failure));
58++ } finally {
59++ setBusy(false);
60++ }
61++ }
62++
63++ async function checkout() {
64++ setBusy(true);
65++ setError(null);
66++ try {
68++ const placed = await checkoutCart(apiBase, current.id);
69++ setOrder(placed);
70++ setCart(null);
71++ setProducts(await listProducts(apiBase));
72++ } catch (failure: unknown) {
73++ setError(messageOf(failure));
74++ } finally {
75++ setBusy(false);
76++ }
77++ }
78++
80++ const total = lines.length > 0 ? cartTotal(lines) : cents(0);
81++
82++ return (
84++ <h1>Shop</h1>
85++ {products === null && error === null ? <p>Loading the catalog.</p> : null}
86++ {error ? <p role="alert">{error}</p> : null}
87++ <ul>
88++ {(products ?? []).map((product) => (
89++ <li key={product.sku}>
90++ <span>{product.name}</span>
91++ <span>{formatCents(cents(product.priceCents), CURRENCY, LOCALE)}</span>
92++ <span>{product.stock} in stock</span>
93++ <label>
94++ Quantity
95++ <input
96++ aria-label={`Quantity for ${product.name}`}
97++ value={quantities[product.sku] ?? "1"}
98++ onChange={(event) => setQuantities({ ...quantities, [product.sku]: event.target.value })}
99++ />
100++ </label>
101++ <button type="button" disabled={busy} onClick={() => void add(product)}>
102++ Add {product.name}
103++ </button>
104++ </li>
105++ ))}
106++ </ul>
107++ <section aria-label="Cart">
108++ <h2>Cart</h2>
110++ <ul>
113++ return (
118++ </li>
119++ );
120++ })}
121++ </ul>
122++ <p>Total {formatCents(total, CURRENCY, LOCALE)}</p>
123++ <button type="button" disabled={busy} onClick={() => void checkout()}>
124++ Checkout
125++ </button>
126++ </section>
127++ {order ? (
128++ <p role="status">
129++ Order {order.id} placed for {formatCents(cents(order.totalCents), CURRENCY, LOCALE)}
130++ </p>
131++ ) : null}
133++ );
134++}
135++
136++
shop-ts-web-react/src/main.tsx+4 -0 · 100% covered / 0% uncovered
runoldnew±codecov
1++import { mountShop } from "./mount";
2++import "./style.css";
3++
4++mountShop(document.getElementById("root"), "");
shop-ts-web-react/src/mount.tsx+8 -0 · 100% covered / 0% uncovered
runoldnew±codecov
1++import { createRoot } from "react-dom/client";
2++
3++import { Shop } from "./Shop";
4++
5++export function mountShop(root: HTMLElement | null, apiBase: string) {
6++ if (!root) throw new Error("root element is missing");
7++ createRoot(root).render(<Shop apiBase={apiBase} />);
8++}
shop-ts-web-react/src/shopApi.ts+113 -0 · 100% covered / 0% uncovered
runoldnew±codecov
1++export interface Product {
2++ readonly sku: string;
3++ readonly name: string;
4++ readonly priceCents: number;
5++ readonly stock: number;
6++}
7++
9++ readonly sku: string;
10++ readonly quantity: number;
11++}
12++
13++export interface Cart {
14++ readonly id: string;
15++ readonly status: "open" | "checked_out";
17++}
18++
19++export interface Order {
20++ readonly id: number;
21++ readonly cartId: string;
22++ readonly totalCents: number;
23++}
24++
25++export class ShopRequestError extends Error {
26++ constructor(
27++ readonly status: number,
28++ readonly code: string,
29++ message: string,
30++ ) {
31++ super(message);
32++ this.name = "ShopRequestError";
33++ }
34++}
35++
36++interface ProductBody {
37++ readonly sku: string;
38++ readonly name: string;
39++ readonly price_cents: number;
40++ readonly stock: number;
41++}
42++
43++interface CartBody {
44++ readonly id: string;
45++ readonly status: "open" | "checked_out";
46++ readonly lines: readonly { readonly sku: string; readonly quantity: number }[];
47++}
48++
49++interface OrderBody {
50++ readonly id: number;
51++ readonly cart_id: string;
52++ readonly total_cents: number;
53++}
54++
56++ return { sku: body.sku, name: body.name, priceCents: body.price_cents, stock: body.stock };
57++}
58++
59++function cartFromBody(body: CartBody): Cart {
60++ return {
61++ id: body.id,
62++ status: body.status,
64++ };
65++}
66++
68++ let code = "request_failed";
69++ let message = response.statusText;
70++ try {
71++ const body = (await response.json()) as { error?: { code?: string; message?: string } };
72++ if (body.error?.code) code = body.error.code;
73++ if (body.error?.message) message = body.error.message;
74++ } catch {
75++ message = "the shop could not read the response";
76++ }
78++}
79++
80++async function requestJson<T>(apiBase: string, path: string, init?: RequestInit): Promise<T> {
81++ let response: Response;
82++ try {
83++ response = await fetch(`${apiBase}${path}`, init);
84++ } catch {
86++ }
87++ if (!response.ok) throw await readError(response);
88++ return (await response.json()) as T;
89++}
90++
91++export async function listProducts(apiBase: string): Promise<readonly Product[]> {
93++ return body.items.map(productFromBody);
94++}
95++
96++export async function openCart(apiBase: string): Promise<Cart> {
97++ return cartFromBody(await requestJson<CartBody>(apiBase, "/v1/carts", { method: "POST" }));
98++}
99++
100++export async function addCartItem(apiBase: string, cartId: string, sku: string, quantity: number): Promise<Cart> {
101++ return cartFromBody(
102++ await requestJson<CartBody>(apiBase, `/v1/carts/${cartId}/items`, {
103++ method: "POST",
104++ headers: { "content-type": "application/json" },
105++ body: JSON.stringify({ sku, quantity }),
106++ }),
107++ );
108++}
109++
110++export async function checkoutCart(apiBase: string, cartId: string): Promise<Order> {
111++ const body = await requestJson<OrderBody>(apiBase, `/v1/carts/${cartId}/checkout`, { method: "POST" });
112++ return { id: body.id, cartId: body.cart_id, totalCents: body.total_cents };
113++}