Skip to main content

k_ruoka_mcp/browser/
basket.rs

1//! The `/kr-api/basket/...` calls, on top of [`Session`](crate::browser::session::Session).
2
3use 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    /// Fetch (or implicitly create) the active basket for a store.
16    ///
17    /// On an anonymous session this happily returns a fresh, valid basket rather
18    /// than a 401 -- so a successful call is not evidence of being logged in.
19    /// Check `userInfo` for that.
20    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    /// Apply a batch of events. The endpoint always takes an array, even for one
34    /// change, and returns the whole updated basket.
35    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    /// Read the cart, apply events to it, and return the result. Saves callers
43    /// from threading the basket id around; `basket/active` is cheap.
44    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    /// `amount` is the resulting quantity, not a delta.
50    ///
51    /// Observed live: `ADD-ITEM` for an EAN already in the basket *replaces* that
52    /// item's amount rather than accumulating -- add 1 twice and the cart holds 1.
53    /// K-Ruoka's own frontend never sends `ADD-ITEM` for an item already present
54    /// (it switches to `SET-ITEM-AMOUNT`), so this path is outside what the site
55    /// itself exercises; the behaviour was measured rather than assumed.
56    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        // Observed live: K-Ruoka accepts amount 0 or negative and adds nothing,
66        // returning 200. Reject it here so "add nothing" cannot masquerade as a
67        // successful add.
68        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        // An empty EAN would sail through the phantom-product check below by matching
75        // any returned item that carries no `ean` of its own, turning a request that
76        // added nothing into a reported success on someone else's line item.
77        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        // `ADD-ITEM` accepts any EAN and inserts "Tuntematon tuote" for one it does
98        // not recognise, so an unknown barcode looks like a success. Undo it and say
99        // so, rather than leaving a phantom line in the user's cart.
100        // "The call returned 200" is not evidence the item is in the cart -- that is
101        // the recurring failure mode of this API, so check rather than
102        // assume.
103        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            // Roll back through the basket id already in hand rather than `remove`,
112            // which would issue another read; tool calls run concurrently and there
113            // is no reason to widen that window.
114            let undo = BasketEvent::RemoveItem {
115                item_id: item_id.clone(),
116            };
117            // Check the returned basket, not the status. `REMOVE-ITEM` is one of the
118            // calls known to answer 200 while changing nothing, and a phantom item is
119            // exactly the off-the-tested-path case where that is most plausible. On
120            // `is_ok()` alone this would report "nothing was added" while leaving
121            // "Tuntematon tuote" in a real cart -- the outcome this whole check exists
122            // to prevent.
123            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                // Never fail silently leaving junk behind -- that is the whole
131                // point of this check.
132                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    /// `amount` of 0 removes the item -- verified live, the server handles it
143    /// rather than needing a `REMOVE-ITEM` translation the way the frontend does.
144    ///
145    /// `unit` defaults to the unit the item already carries. Defaulting it to
146    /// `"kpl"` instead would silently convert a `kg` item to pieces, which is
147    /// corruption rather than a no-op.
148    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        // 0 is a documented remove; negative is almost certainly a caller bug, and
156        // K-Ruoka treats it as a remove too, which would make two spellings of the
157        // same operation with only one of them documented.
158        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    /// Empty the basket via `CLEAR-ITEMS`.
187    ///
188    /// `DELETE /kr-api/basket/by-id/{id}` also exists and destroys the basket
189    /// itself. `CLEAR-ITEMS` is preferred: it leaves the basket and its settings
190    /// in place, and returns the emptied basket so the caller can see the result.
191    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
201/// K-Ruoka answers a bad store id with 422 `InvalidStoreIdError` whose message is
202/// literally "Invalid store ID undefined" -- it does not echo the id, and an empty
203/// store id and a nonexistent one produce the identical text. Say which one the
204/// caller actually passed.
205fn 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
221/// Resolve an item id against the cart, refusing one that is not there.
222///
223/// K-Ruoka accepts `REMOVE-ITEM` / `SET-ITEM-AMOUNT` for an item id that is not in
224/// the basket and returns 200 with the basket unchanged, so a typo'd id looks like a
225/// success. Since the caller most likely passed an EAN where an item id was wanted,
226/// fail loudly and list what is valid.
227///
228/// Returns the item, not just `Ok(())`: `set_amount` needs its current unit, and a
229/// second lookup would be a second chance to disagree.
230fn 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
246/// K-Ruoka clamps silently at 999, so an amount at or above it is not a discrepancy.
247const MAX_AMOUNT: f64 = 999.0;
248
249/// Confirm a `SET-ITEM-AMOUNT` actually took effect.
250///
251/// `find_item` checks the id against a *previous* read, and tool calls run
252/// concurrently, so between that read and this write another call can remove the
253/// item. K-Ruoka then answers 200 and changes nothing -- reinstating precisely the
254/// silent no-op `find_item` exists to prevent, just through a two-call interleaving
255/// instead of a typo. The response carries the basket, so checking costs nothing.
256fn 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        // Requesting 0 *is* the documented spelling of remove, so absence is success.
259        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
285/// Confirm a `REMOVE-ITEM` actually removed it, for the same reason as
286/// [`confirm_amount`]: 200 is not evidence.
287fn 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    /// `InvalidRequest`, not `Api`: K-Ruoka was never asked, so an "API error"
315    /// message would claim something untrue.
316    #[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    /// `find_item` validated against an *earlier* read. Tool calls run concurrently,
351    /// so a 200 that changed nothing is still reachable by interleaving even when the
352    /// id was valid when checked.
353    #[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    /// K-Ruoka clamps at 999 and the returned cart shows the truth, so a clamped
361    /// amount is the API working as measured -- not a discrepancy to reject.
362    #[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    /// Amount 0 is the documented spelling of remove, so absence is the success case
370    /// and presence is the failure -- the opposite of every other amount.
371    #[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    /// An item vanishing under a non-zero set is a concurrent removal, not a success.
378    #[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    /// The unit a caller omits must come from the item, not a constant. Defaulting
390    /// to "kpl" would convert a kg item to pieces -- corruption, not a no-op.
391    #[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    /// `productDetails.attributes` is the marker for "K-Ruoka knows this EAN".
403    #[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                // What ADD-ITEM returns for an EAN K-Ruoka has no record of.
411                {"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    /// Deliberately not keyed off `pricing`: a real product that is out of stock
421    /// could plausibly have none, and rejecting a valid add is worse than the bug.
422    #[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    /// Other errors must pass through untouched -- notably AuthExpired, which the
450    /// caller needs to see as itself.
451    #[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}