1use crate::browser::session::{ApiError, KrApi};
4use crate::types::{AmountInfo, Basket, BasketEvent, BasketItem, NewItem};
5
6pub struct Cart<'a> {
7 api: &'a dyn KrApi,
8}
9
10impl<'a> Cart<'a> {
11 pub fn new(api: &'a dyn KrApi) -> Self {
12 Self { api }
13 }
14
15 pub async fn active(&self, store_id: &str) -> Result<Basket, ApiError> {
21 let body = serde_json::json!({
22 "storeId": store_id,
23 "substitutionDefault": true,
24 });
25 let value = self
26 .api
27 .call("POST", "/kr-api/basket/active", Some(&body))
28 .await
29 .map_err(|e| clarify_store_error(e, store_id))?;
30 parse(value)
31 }
32
33 pub async fn apply(&self, basket_id: &str, events: &[BasketEvent]) -> Result<Basket, ApiError> {
36 let body = serde_json::to_value(events).map_err(|e| ApiError::Other(e.into()))?;
37 let path = format!("/kr-api/basket/by-id/{basket_id}");
38 let value = self.api.call("PATCH", &path, Some(&body)).await?;
39 parse(value)
40 }
41
42 pub async fn mutate(&self, store_id: &str, events: &[BasketEvent]) -> Result<Basket, ApiError> {
45 let basket = self.active(store_id).await?;
46 self.apply(&basket.id, events).await
47 }
48
49 pub async fn add(
57 &self,
58 store_id: &str,
59 ean: &str,
60 amount: f64,
61 unit: &str,
62 local_store_id: Option<String>,
63 allow_substitutes: bool,
64 ) -> Result<Basket, ApiError> {
65 if amount.is_nan() || amount <= 0.0 {
69 return Err(ApiError::InvalidRequest(format!(
70 "quantity must be greater than 0 (got {amount}). K-Ruoka would accept this \
71 and add nothing while reporting success."
72 )));
73 }
74 if ean.trim().is_empty() {
78 return Err(ApiError::InvalidRequest(
79 "ean must not be empty. Use search_products to find a product's EAN \
80 barcode."
81 .to_string(),
82 ));
83 }
84 let event = BasketEvent::AddItem {
85 item: NewItem {
86 ean: ean.to_string(),
87 local_store_id,
88 allow_substitutes,
89 amount_info: AmountInfo {
90 amount,
91 unit: unit.to_string(),
92 },
93 },
94 };
95 let basket = self.mutate(store_id, &[event]).await?;
96
97 let Some(added) = basket.items.iter().find(|i| i.ean == ean) else {
104 return Err(ApiError::Other(anyhow::anyhow!(
105 "K-Ruoka accepted the add for EAN {ean} but the item is not in the cart it \
106 returned. Nothing was changed as far as can be told; check the cart."
107 )));
108 };
109 if !added.is_known_product() {
110 let item_id = added.id.clone();
111 let undo = BasketEvent::RemoveItem {
115 item_id: item_id.clone(),
116 };
117 let rolled_back = self
124 .apply(&basket.id, &[undo])
125 .await
126 .is_ok_and(|after| !after.items.iter().any(|i| i.id == item_id));
127 return Err(ApiError::InvalidRequest(if rolled_back {
128 format!("K-Ruoka has no product with EAN {ean}; nothing was added.")
129 } else {
130 format!(
133 "K-Ruoka has no product with EAN {ean}. It was added to the cart as \
134 \"Unknown product\" and could not be removed again -- call \
135 remove_from_cart with item_id={item_id}."
136 )
137 }));
138 }
139 Ok(basket)
140 }
141
142 pub async fn set_amount(
149 &self,
150 store_id: &str,
151 item_id: &str,
152 amount: f64,
153 unit: Option<&str>,
154 ) -> Result<Basket, ApiError> {
155 if amount < 0.0 || amount.is_nan() {
159 return Err(ApiError::InvalidRequest(format!(
160 "quantity cannot be negative (got {amount}). Use 0 to remove the item."
161 )));
162 }
163 let basket = self.active(store_id).await?;
164 let item = find_item(&basket, item_id)?;
165 let unit = unit.unwrap_or(&item.amount_info.unit).to_string();
166 let event = BasketEvent::SetItemAmount {
167 item_id: item_id.to_string(),
168 value: AmountInfo { amount, unit },
169 };
170 let after = self.apply(&basket.id, &[event]).await?;
171 confirm_amount(&after, item_id, amount)?;
172 Ok(after)
173 }
174
175 pub async fn remove(&self, store_id: &str, item_id: &str) -> Result<Basket, ApiError> {
176 let basket = self.active(store_id).await?;
177 find_item(&basket, item_id)?;
178 let event = BasketEvent::RemoveItem {
179 item_id: item_id.to_string(),
180 };
181 let after = self.apply(&basket.id, &[event]).await?;
182 confirm_absent(&after, item_id)?;
183 Ok(after)
184 }
185
186 pub async fn clear(&self, store_id: &str) -> Result<Basket, ApiError> {
192 self.mutate(store_id, &[BasketEvent::ClearItems]).await
193 }
194}
195
196fn parse(value: serde_json::Value) -> Result<Basket, ApiError> {
197 serde_json::from_value(value)
198 .map_err(|e| ApiError::Other(anyhow::anyhow!("unexpected basket shape: {e}")))
199}
200
201fn clarify_store_error(e: ApiError, store_id: &str) -> ApiError {
206 match &e {
207 ApiError::Api {
208 status: 422,
209 message,
210 } if message.contains("InvalidStoreIdError") => ApiError::Api {
211 status: 422,
212 message: format!(
213 "K-Ruoka rejected store id {store_id:?} as invalid. Store ids look like \
214 \"N137\"; use search_stores to find one."
215 ),
216 },
217 _ => e,
218 }
219}
220
221fn find_item<'b>(basket: &'b Basket, item_id: &str) -> Result<&'b BasketItem, ApiError> {
231 if let Some(item) = basket.items.iter().find(|i| i.id == item_id) {
232 return Ok(item);
233 }
234 let available: Vec<&str> = basket.items.iter().map(|i| i.id.as_str()).collect();
235 Err(ApiError::InvalidRequest(if available.is_empty() {
236 format!("no item {item_id:?} in the cart -- the cart is empty")
237 } else {
238 format!(
239 "no item {item_id:?} in the cart. Item ids currently in it: {}. \
240 Note these are basket item ids from get_cart, not EANs.",
241 available.join(", ")
242 )
243 }))
244}
245
246const MAX_AMOUNT: f64 = 999.0;
248
249fn confirm_amount(after: &Basket, item_id: &str, wanted: f64) -> Result<(), ApiError> {
257 let Some(item) = after.items.iter().find(|i| i.id == item_id) else {
258 if wanted == 0.0 {
260 return Ok(());
261 }
262 return Err(ApiError::Other(anyhow::anyhow!(
263 "K-Ruoka accepted setting item {item_id} to {wanted} but the item is not in \
264 the cart it returned. Something else probably removed it at the same time; \
265 call get_cart to see the current state."
266 )));
267 };
268 if wanted == 0.0 {
269 return Err(ApiError::Other(anyhow::anyhow!(
270 "asked K-Ruoka to remove item {item_id} (amount 0) but it is still in the \
271 cart it returned, with amount {}.",
272 item.amount_info.amount
273 )));
274 }
275 let got = item.amount_info.amount;
276 if got != wanted && !(wanted > MAX_AMOUNT && got == MAX_AMOUNT) {
277 return Err(ApiError::Other(anyhow::anyhow!(
278 "asked K-Ruoka to set item {item_id} to {wanted} but the cart it returned \
279 shows {got}."
280 )));
281 }
282 Ok(())
283}
284
285fn confirm_absent(after: &Basket, item_id: &str) -> Result<(), ApiError> {
288 if after.items.iter().any(|i| i.id == item_id) {
289 return Err(ApiError::Other(anyhow::anyhow!(
290 "K-Ruoka accepted removing item {item_id} but it is still in the cart it \
291 returned. Call get_cart to see the current state."
292 )));
293 }
294 Ok(())
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 fn basket_with(ids: &[&str]) -> Basket {
302 let items: Vec<_> = ids
303 .iter()
304 .map(|id| serde_json::json!({"id": id, "ean": id}))
305 .collect();
306 serde_json::from_value(serde_json::json!({"id": "b1", "items": items})).unwrap()
307 }
308
309 #[test]
310 fn existing_item_is_found() {
311 assert_eq!(find_item(&basket_with(&["abc"]), "abc").unwrap().id, "abc");
312 }
313
314 #[test]
317 fn missing_item_reports_the_valid_ids() {
318 match find_item(&basket_with(&["abc", "def"]), "xyz") {
319 Err(ApiError::InvalidRequest(message)) => {
320 assert!(message.contains("abc, def"), "{message}");
321 assert!(message.contains("not EANs"), "{message}");
322 }
323 other => panic!("expected InvalidRequest, got {:?}", other.map(|i| &i.id)),
324 }
325 }
326
327 #[test]
328 fn empty_cart_says_so() {
329 match find_item(&basket_with(&[]), "xyz") {
330 Err(ApiError::InvalidRequest(message)) => {
331 assert!(message.contains("empty"), "{message}")
332 }
333 other => panic!("expected InvalidRequest, got {:?}", other.map(|i| &i.id)),
334 }
335 }
336
337 fn basket_with_amounts(items: &[(&str, f64)]) -> Basket {
338 let items: Vec<_> = items
339 .iter()
340 .map(|(id, amount)| {
341 serde_json::json!({
342 "id": id, "ean": id,
343 "amountInfo": {"amount": amount, "unit": "kpl"},
344 })
345 })
346 .collect();
347 serde_json::from_value(serde_json::json!({"id": "b1", "items": items})).unwrap()
348 }
349
350 #[test]
354 fn a_set_amount_that_changed_nothing_is_not_a_success() {
355 let unchanged = basket_with_amounts(&[("abc", 2.0)]);
356 assert!(confirm_amount(&unchanged, "abc", 5.0).is_err());
357 assert!(confirm_amount(&unchanged, "abc", 2.0).is_ok());
358 }
359
360 #[test]
363 fn the_999_clamp_is_not_treated_as_a_discrepancy() {
364 let clamped = basket_with_amounts(&[("abc", 999.0)]);
365 assert!(confirm_amount(&clamped, "abc", 1e9).is_ok());
366 assert!(confirm_amount(&clamped, "abc", 998.0).is_err());
367 }
368
369 #[test]
372 fn amount_zero_succeeds_only_when_the_item_is_gone() {
373 assert!(confirm_amount(&basket_with_amounts(&[]), "abc", 0.0).is_ok());
374 assert!(confirm_amount(&basket_with_amounts(&[("abc", 1.0)]), "abc", 0.0).is_err());
375 }
376
377 #[test]
379 fn a_vanished_item_fails_a_non_zero_set() {
380 assert!(confirm_amount(&basket_with_amounts(&[]), "abc", 3.0).is_err());
381 }
382
383 #[test]
384 fn a_remove_that_left_the_item_behind_is_not_a_success() {
385 assert!(confirm_absent(&basket_with(&["abc"]), "abc").is_err());
386 assert!(confirm_absent(&basket_with(&["def"]), "abc").is_ok());
387 }
388
389 #[test]
392 fn omitted_unit_is_inherited_from_the_item() {
393 let basket: Basket = serde_json::from_value(serde_json::json!({
394 "id": "b1",
395 "items": [{"id": "x", "ean": "x", "amountInfo": {"amount": 1.5, "unit": "kg"}}],
396 }))
397 .unwrap();
398 let item = find_item(&basket, "x").unwrap();
399 assert_eq!(item.amount_info.unit, "kg");
400 }
401
402 #[test]
404 fn phantom_products_are_recognisable() {
405 let basket: Basket = serde_json::from_value(serde_json::json!({
406 "id": "b1",
407 "items": [
408 {"id": "real", "ean": "real",
409 "productDetails": {"attributes": {"ean": "real"}, "availability": {}}},
410 {"id": "phantom", "ean": "phantom", "pricing": null,
412 "productDetails": {"availability": {}}},
413 ],
414 }))
415 .unwrap();
416 assert!(find_item(&basket, "real").unwrap().is_known_product());
417 assert!(!find_item(&basket, "phantom").unwrap().is_known_product());
418 }
419
420 #[test]
423 fn a_real_product_without_pricing_is_still_known() {
424 let basket: Basket = serde_json::from_value(serde_json::json!({
425 "id": "b1",
426 "items": [{"id": "x", "ean": "x", "pricing": null,
427 "productDetails": {"attributes": {}, "availability": {}}}],
428 }))
429 .unwrap();
430 assert!(find_item(&basket, "x").unwrap().is_known_product());
431 }
432
433 #[test]
434 fn invalid_store_error_names_the_store_the_caller_passed() {
435 let raw = ApiError::Api {
436 status: 422,
437 message: r#"{"name":"InvalidStoreIdError","message":"Invalid store ID undefined"}"#
438 .into(),
439 };
440 match clarify_store_error(raw, "ZZZZ9") {
441 ApiError::Api { message, .. } => {
442 assert!(message.contains("ZZZZ9"), "{message}");
443 assert!(!message.contains("undefined"), "{message}");
444 }
445 other => panic!("expected an Api error, got {other:?}"),
446 }
447 }
448
449 #[test]
452 fn clarify_store_error_leaves_other_errors_alone() {
453 assert!(matches!(
454 clarify_store_error(ApiError::AuthExpired, "N137"),
455 ApiError::AuthExpired
456 ));
457 }
458}