Skip to main content

k_ruoka_mcp/
types.rs

1//! Serde shapes for the basket API.
2//!
3//! Every field here was observed on a live response, not read
4//! from documentation -- there isn't any. Structs are therefore deliberately
5//! permissive: unknown fields are ignored and almost everything is `default`ed,
6//! so a K-Ruoka deploy that adds or drops a field degrades the output rather
7//! than failing the call.
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12/// The raw basket, as returned by `/kr-api/basket/active` and by every
13/// successful `PATCH /kr-api/basket/by-id/{id}`.
14#[derive(Debug, Clone, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct Basket {
17    pub id: String,
18    #[serde(default)]
19    pub items: Vec<BasketItem>,
20    #[serde(default)]
21    pub price_summary: PriceSummary,
22    pub store: Option<Store>,
23    #[serde(default)]
24    pub user_info: UserInfo,
25}
26
27#[derive(Debug, Clone, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct BasketItem {
30    /// The basket's own item id, and what `SET-ITEM-AMOUNT` / `REMOVE-ITEM` take.
31    ///
32    /// For ordinary products this is observably equal to the EAN, but the
33    /// frontend keys items by `(localStoreId, ean)`, so that equality is not
34    /// something to depend on for store-local products. Always resolve it from a
35    /// `get_cart` read.
36    pub id: String,
37    #[serde(default)]
38    pub ean: String,
39    #[serde(default)]
40    pub name: LocalizedName,
41    #[serde(default)]
42    pub amount_info: AmountInfo,
43    #[serde(default)]
44    pub pricing: Option<Pricing>,
45    #[serde(default)]
46    pub allow_substitutes: bool,
47    #[serde(default)]
48    pub product_details: ProductDetails,
49}
50
51impl BasketItem {
52    /// Whether K-Ruoka actually has a product record for this EAN.
53    ///
54    /// `ADD-ITEM` accepts *any* EAN and cheerfully puts an item named "Tuntematon
55    /// tuote" / "Unknown product" in the basket, so a typo'd barcode silently
56    /// pollutes the cart while reporting success.
57    ///
58    /// The discriminator is the presence of `productDetails.attributes`, not
59    /// `pricing`: `attributes` means "we have a record of this product", while
60    /// `availability` means "you can get it here". A real product that is simply
61    /// out of stock still has `attributes`, so keying off `pricing == null` would
62    /// risk rejecting valid adds. Observed live -- two real EANs carried
63    /// `[attributes, availability, category, soldBy]`, the phantom only
64    /// `[availability]`.
65    pub fn is_known_product(&self) -> bool {
66        self.product_details.attributes.is_some()
67    }
68}
69
70/// Only the one key we need. The full blob is several KB of nutrition data,
71/// images and category trees.
72#[derive(Debug, Clone, Default, Deserialize)]
73pub struct ProductDetails {
74    #[serde(default)]
75    pub attributes: Option<serde_json::Value>,
76}
77
78#[derive(Debug, Clone, Default, Deserialize)]
79pub struct LocalizedName {
80    #[serde(default)]
81    pub finnish: Option<String>,
82    #[serde(default)]
83    pub english: Option<String>,
84    #[serde(default)]
85    pub swedish: Option<String>,
86}
87
88impl LocalizedName {
89    pub fn best(&self) -> String {
90        self.finnish
91            .clone()
92            .or_else(|| self.english.clone())
93            .or_else(|| self.swedish.clone())
94            .unwrap_or_default()
95    }
96}
97
98/// K-Ruoka amounts always carry a unit. `kpl` ("pieces") is overwhelmingly the
99/// common one, including for goods that are *priced* by weight.
100pub const DEFAULT_UNIT: &str = "kpl";
101
102#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
103pub struct AmountInfo {
104    pub amount: f64,
105    pub unit: String,
106}
107
108impl Default for AmountInfo {
109    fn default() -> Self {
110        Self {
111            amount: 0.0,
112            unit: DEFAULT_UNIT.into(),
113        }
114    }
115}
116
117#[derive(Debug, Clone, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct Pricing {
120    #[serde(default)]
121    pub price: Option<f64>,
122    #[serde(default)]
123    pub unit: Option<String>,
124    /// True for weight-priced goods, where the charge is settled at picking.
125    #[serde(default)]
126    pub is_approximate: bool,
127}
128
129#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
130#[serde(rename_all = "camelCase")]
131pub struct PriceSummary {
132    #[serde(default)]
133    pub items_sub_total: f64,
134    #[serde(default)]
135    pub grand_total: f64,
136}
137
138#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
139pub struct Store {
140    #[serde(default)]
141    pub id: String,
142    #[serde(default)]
143    pub name: String,
144}
145
146/// Empty strings throughout on an anonymous session; populated once logged in.
147/// This is the only reliable signal of whether the basket belongs to an account,
148/// because an anonymous caller gets a perfectly valid basket rather than a 401.
149#[derive(Debug, Clone, Default, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct UserInfo {
152    #[serde(default)]
153    pub first_name: String,
154    #[serde(default)]
155    pub last_name: String,
156    #[serde(default)]
157    pub email: String,
158}
159
160impl UserInfo {
161    /// The signed-in account, or `None` when the session is anonymous.
162    ///
163    /// The only reliable signal: an anonymous caller gets a perfectly valid basket
164    /// rather than a 401, so a successful call proves nothing.
165    pub fn display(&self) -> Option<String> {
166        if self.email.is_empty() && self.first_name.is_empty() && self.last_name.is_empty() {
167            return None;
168        }
169        let name = format!("{} {}", self.first_name, self.last_name)
170            .trim()
171            .to_string();
172        Some(match (name.is_empty(), self.email.is_empty()) {
173            (false, false) => format!("{name} <{}>", self.email),
174            (false, true) => name,
175            (true, _) => self.email.clone(),
176        })
177    }
178}
179
180/// A cart mutation. `PATCH /kr-api/basket/by-id/{id}` always takes an array of
181/// these, even for a single change.
182///
183/// Names match the literals in K-Ruoka's own bundle (`pendingEvents.push(...)`).
184/// Two further events exist there -- `SET-ITEM-ALLOW-SUBSTITUTES` and
185/// `SET-ITEM-MESSAGE-TO-STORE` -- deliberately not modelled, as they are outside
186/// the tool surface.
187#[derive(Debug, Clone, Serialize)]
188#[serde(tag = "type", rename_all = "SCREAMING-KEBAB-CASE")]
189pub enum BasketEvent {
190    AddItem {
191        item: NewItem,
192    },
193    #[serde(rename_all = "camelCase")]
194    SetItemAmount {
195        item_id: String,
196        value: AmountInfo,
197    },
198    #[serde(rename_all = "camelCase")]
199    RemoveItem {
200        item_id: String,
201    },
202    ClearItems,
203}
204
205#[derive(Debug, Clone, Serialize)]
206#[serde(rename_all = "camelCase")]
207pub struct NewItem {
208    pub ean: String,
209    /// Only set for store-local products; omitted entirely otherwise.
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub local_store_id: Option<String>,
212    pub allow_substitutes: bool,
213    pub amount_info: AmountInfo,
214}
215
216// ---------------------------------------------------------------------------
217// What the MCP tools actually return.
218//
219// The raw basket carries a large `productDetails` blob per item (nutrition,
220// images, category trees). Returning that verbatim would bury the few fields a
221// caller needs in several KB of noise per item, so tools return this view.
222// ---------------------------------------------------------------------------
223
224#[derive(Debug, Clone, Serialize, JsonSchema)]
225#[serde(rename_all = "camelCase")]
226pub struct CartView {
227    pub basket_id: String,
228    pub store: Store,
229    /// `null` when the session is anonymous -- the cart is then a throwaway
230    /// basket, not the account's.
231    pub account: Option<String>,
232    pub items: Vec<CartItemView>,
233    pub totals: PriceSummary,
234}
235
236#[derive(Debug, Clone, Serialize, JsonSchema)]
237#[serde(rename_all = "camelCase")]
238pub struct CartItemView {
239    /// Pass this as `item_id` to `update_cart_item` / `remove_from_cart`.
240    pub item_id: String,
241    pub ean: String,
242    pub name: String,
243    pub amount: f64,
244    pub unit: String,
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub price: Option<f64>,
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub price_unit: Option<String>,
249    /// Weight-priced item: the final charge is settled when the order is picked.
250    pub price_is_approximate: bool,
251}
252
253impl From<Basket> for CartView {
254    fn from(b: Basket) -> Self {
255        Self {
256            basket_id: b.id,
257            store: b.store.unwrap_or_default(),
258            account: b.user_info.display(),
259            items: b
260                .items
261                .into_iter()
262                .map(|i| CartItemView {
263                    item_id: i.id,
264                    ean: i.ean,
265                    name: i.name.best(),
266                    amount: i.amount_info.amount,
267                    unit: i.amount_info.unit,
268                    price: i.pricing.as_ref().and_then(|p| p.price),
269                    price_unit: i.pricing.as_ref().and_then(|p| p.unit.clone()),
270                    price_is_approximate: i.pricing.as_ref().is_some_and(|p| p.is_approximate),
271                })
272                .collect(),
273            totals: b.price_summary,
274        }
275    }
276}
277
278// --- Product search -----------------------------------------------------------------
279
280/// `POST /kr-api/v2/product-search/{term}`. Only the fields the tool surfaces are
281/// modelled; the response also carries aggregations, brands and suggestions.
282#[derive(Debug, Clone, Deserialize)]
283#[serde(rename_all = "camelCase")]
284pub struct ProductSearchResponse {
285    #[serde(default)]
286    pub result: Vec<ProductHit>,
287    #[serde(default)]
288    pub total_hits: u64,
289}
290
291#[derive(Debug, Clone, Deserialize)]
292pub struct ProductHit {
293    pub product: SearchProduct,
294}
295
296#[derive(Debug, Clone, Deserialize)]
297#[serde(rename_all = "camelCase")]
298pub struct SearchProduct {
299    #[serde(default)]
300    pub ean: String,
301    #[serde(default)]
302    pub localized_name: LocalizedName,
303    #[serde(default)]
304    pub brand: Option<Brand>,
305    /// Whether it can be bought at this store right now, as opposed to merely existing
306    /// in the catalogue. Surfaced because an unavailable hit is still a valid EAN and
307    /// `add_to_cart` would accept it.
308    #[serde(default)]
309    pub is_available: bool,
310    /// Price lives under `mobilescan`, not at the top level, and is absent often enough
311    /// that it has to be optional.
312    #[serde(default)]
313    pub mobilescan: Option<MobileScan>,
314}
315
316#[derive(Debug, Clone, Deserialize)]
317pub struct Brand {
318    #[serde(default)]
319    pub name: Option<String>,
320}
321
322#[derive(Debug, Clone, Deserialize)]
323pub struct MobileScan {
324    #[serde(default)]
325    pub pricing: Option<MobileScanPricing>,
326}
327
328#[derive(Debug, Clone, Deserialize)]
329pub struct MobileScanPricing {
330    #[serde(default)]
331    pub normal: Option<NormalPrice>,
332}
333
334#[derive(Debug, Clone, Deserialize)]
335#[serde(rename_all = "camelCase")]
336pub struct NormalPrice {
337    #[serde(default)]
338    pub price: Option<f64>,
339    #[serde(default)]
340    pub unit: Option<String>,
341    /// Comparison price, e.g. per kg, which is what makes weight-priced items
342    /// comparable at all.
343    #[serde(default)]
344    pub unit_price: Option<UnitPrice>,
345    #[serde(default)]
346    pub is_approximate: bool,
347}
348
349#[derive(Debug, Clone, Deserialize)]
350pub struct UnitPrice {
351    #[serde(default)]
352    pub value: Option<f64>,
353    #[serde(default)]
354    pub unit: Option<String>,
355}
356
357#[derive(Debug, Clone, Serialize, JsonSchema)]
358#[serde(rename_all = "camelCase")]
359pub struct ProductSearchView {
360    /// How many the catalogue holds in total, which is usually far more than `results`.
361    pub total_hits: u64,
362    pub results: Vec<ProductView>,
363}
364
365#[derive(Debug, Clone, Serialize, JsonSchema)]
366#[serde(rename_all = "camelCase")]
367pub struct ProductView {
368    /// Pass this as `ean` to `add_to_cart`.
369    pub ean: String,
370    pub name: String,
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub brand: Option<String>,
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub price: Option<f64>,
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub price_unit: Option<String>,
377    /// e.g. "1.69 EUR/kg". Absent when K-Ruoka gives no comparison price.
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub comparison_price: Option<String>,
380    /// Weight-priced: the final charge is settled when the order is picked.
381    pub price_is_approximate: bool,
382    /// `false` means the EAN is real but not buyable at this store right now.
383    pub is_available: bool,
384}
385
386impl From<ProductSearchResponse> for ProductSearchView {
387    fn from(r: ProductSearchResponse) -> Self {
388        Self {
389            total_hits: r.total_hits,
390            results: r.result.into_iter().map(|h| h.product.into()).collect(),
391        }
392    }
393}
394
395impl From<SearchProduct> for ProductView {
396    fn from(p: SearchProduct) -> Self {
397        let normal = p
398            .mobilescan
399            .as_ref()
400            .and_then(|m| m.pricing.as_ref())
401            .and_then(|p| p.normal.as_ref());
402        Self {
403            ean: p.ean,
404            name: p.localized_name.best(),
405            brand: p.brand.and_then(|b| b.name),
406            price: normal.and_then(|n| n.price),
407            price_unit: normal.and_then(|n| n.unit.clone()),
408            comparison_price: normal.and_then(|n| n.unit_price.as_ref()).and_then(|u| {
409                match (u.value, u.unit.as_deref()) {
410                    (Some(value), Some(unit)) => Some(format!("{value} EUR/{unit}")),
411                    _ => None,
412                }
413            }),
414            price_is_approximate: normal.is_some_and(|n| n.is_approximate),
415            is_available: p.is_available,
416        }
417    }
418}
419
420// --- Store search -------------------------------------------------------------------
421
422/// `POST /kr-api/stores/search`, which takes its query in a JSON body rather than the
423/// path or query string like product search does.
424#[derive(Debug, Clone, Deserialize)]
425#[serde(rename_all = "camelCase")]
426pub struct StoreSearchResponse {
427    #[serde(default)]
428    pub results: Vec<StoreHit>,
429    #[serde(default)]
430    pub total_hits: u64,
431}
432
433#[derive(Debug, Clone, Deserialize)]
434#[serde(rename_all = "camelCase")]
435pub struct StoreHit {
436    #[serde(default)]
437    pub id: String,
438    #[serde(default)]
439    pub name: String,
440    #[serde(default)]
441    pub location: Option<String>,
442    #[serde(default)]
443    pub chain_name: Option<String>,
444    /// A store that is not a web store has no cart to operate on, so this decides
445    /// whether an id is usable here at all.
446    #[serde(default)]
447    pub is_web_store: bool,
448    #[serde(default)]
449    pub has_pickup: bool,
450    #[serde(default)]
451    pub has_home_delivery: bool,
452}
453
454#[derive(Debug, Clone, Serialize, JsonSchema)]
455#[serde(rename_all = "camelCase")]
456pub struct StoreSearchView {
457    pub total_hits: u64,
458    pub results: Vec<StoreView>,
459}
460
461#[derive(Debug, Clone, Serialize, JsonSchema)]
462#[serde(rename_all = "camelCase")]
463pub struct StoreView {
464    /// Pass this as `store_id` to every other tool.
465    pub store_id: String,
466    pub name: String,
467    #[serde(skip_serializing_if = "Option::is_none")]
468    pub location: Option<String>,
469    #[serde(skip_serializing_if = "Option::is_none")]
470    pub chain: Option<String>,
471    /// `false` means this store has no online cart, so the other tools cannot use it.
472    pub is_web_store: bool,
473    pub has_pickup: bool,
474    pub has_home_delivery: bool,
475}
476
477impl From<StoreSearchResponse> for StoreSearchView {
478    fn from(r: StoreSearchResponse) -> Self {
479        Self {
480            total_hits: r.total_hits,
481            results: r
482                .results
483                .into_iter()
484                .map(|s| StoreView {
485                    store_id: s.id,
486                    name: s.name,
487                    location: s.location,
488                    chain: s.chain_name,
489                    is_web_store: s.is_web_store,
490                    has_pickup: s.has_pickup,
491                    has_home_delivery: s.has_home_delivery,
492                })
493                .collect(),
494        }
495    }
496}
497
498// --- Personal offers ------------------------------------------------------------------
499
500/// `POST /kr-api/tos-offers` -- OmaPlussa-edut, the account's personalised offers.
501/// "tos" is K-Ruoka's own term, seen throughout the frontend (`tosOffers`,
502/// `isTargetingOffersAllowed`); not modelled here since nothing surfaces it.
503#[derive(Debug, Clone, Deserialize)]
504#[serde(rename_all = "camelCase")]
505pub struct PersonalOffersResponse {
506    #[serde(default)]
507    pub offers: Vec<PersonalOffer>,
508}
509
510#[derive(Debug, Clone, Deserialize)]
511#[serde(rename_all = "camelCase")]
512pub struct PersonalOffer {
513    #[serde(default)]
514    pub localized_title: LocalizedName,
515    #[serde(default)]
516    pub pricing: Option<PersonalOfferPricing>,
517    #[serde(default)]
518    pub normal_pricing: Option<PersonalOfferNormalPricing>,
519    #[serde(default)]
520    pub products: Vec<PersonalOfferProduct>,
521    /// How many redemptions are left on the Plussa card. Distinct from `valid_until`:
522    /// an offer can still have time left but no redemptions, or the reverse.
523    #[serde(default)]
524    pub remaining_quantity: Option<u32>,
525    #[serde(default)]
526    pub valid_until: Option<String>,
527}
528
529#[derive(Debug, Clone, Deserialize)]
530#[serde(rename_all = "camelCase")]
531pub struct PersonalOfferPricing {
532    #[serde(default)]
533    pub price: Option<f64>,
534    #[serde(default)]
535    pub discount_percentage: Option<String>,
536    /// What `price` buys -- often more than one item. A live "-57-65 %" coffee offer
537    /// priced at 10.00 EUR turned out to mean 10.00 EUR for three (`unit.fi` was
538    /// "3 kpl"), not one; every offer observed carried this field.
539    #[serde(default)]
540    pub unit: Option<OfferUnit>,
541}
542
543#[derive(Debug, Clone, Default, Deserialize)]
544pub struct OfferUnit {
545    #[serde(default)]
546    pub fi: Option<String>,
547}
548
549#[derive(Debug, Clone, Deserialize)]
550#[serde(rename_all = "camelCase")]
551pub struct PersonalOfferNormalPricing {
552    #[serde(default)]
553    pub price: Option<f64>,
554}
555
556#[derive(Debug, Clone, Deserialize)]
557pub struct PersonalOfferProduct {
558    pub product: OfferProduct,
559}
560
561#[derive(Debug, Clone, Default, Deserialize)]
562#[serde(rename_all = "camelCase")]
563pub struct OfferProduct {
564    #[serde(default)]
565    pub ean: String,
566    #[serde(default)]
567    pub localized_name: LocalizedName,
568    #[serde(default)]
569    pub is_available: bool,
570}
571
572#[derive(Debug, Clone, Serialize, JsonSchema)]
573#[serde(rename_all = "camelCase")]
574pub struct PersonalOffersView {
575    /// The store the offers are scoped to. Measured against two real stores: the sets
576    /// differ (one was a strict superset of the other) rather than being identical
577    /// everywhere, so this is worth echoing back rather than assuming the caller
578    /// remembers which store a cached-looking result came from.
579    pub store_id: String,
580    pub offers: Vec<PersonalOfferView>,
581}
582
583#[derive(Debug, Clone, Serialize, JsonSchema)]
584#[serde(rename_all = "camelCase")]
585pub struct PersonalOfferView {
586    pub title: String,
587    #[serde(skip_serializing_if = "Option::is_none")]
588    pub price: Option<f64>,
589    /// What `price` buys, e.g. "3 kpl" for a buy-three bundle. Read this before
590    /// treating `price` as a per-item price -- it usually is not for a bundle offer.
591    #[serde(skip_serializing_if = "Option::is_none")]
592    pub price_unit: Option<String>,
593    #[serde(skip_serializing_if = "Option::is_none")]
594    pub normal_price: Option<f64>,
595    #[serde(skip_serializing_if = "Option::is_none")]
596    pub discount_percentage: Option<String>,
597    #[serde(skip_serializing_if = "Option::is_none")]
598    pub remaining_quantity: Option<u32>,
599    #[serde(skip_serializing_if = "Option::is_none")]
600    pub valid_until: Option<String>,
601    /// Any one of these that is available redeems the offer -- pass an available EAN
602    /// to `add_to_cart`. `is_available` still has to be checked here, same as
603    /// `search_products`: an offer can list a product this store does not stock.
604    /// Every offer observed so far was already loaded onto the account's Plussa card
605    /// with no separate activation call; not verified as a universal K-Ruoka guarantee.
606    pub products: Vec<PersonalOfferProductView>,
607}
608
609#[derive(Debug, Clone, Serialize, JsonSchema)]
610#[serde(rename_all = "camelCase")]
611pub struct PersonalOfferProductView {
612    /// Pass this as `ean` to `add_to_cart`.
613    pub ean: String,
614    pub name: String,
615    /// `false` means the EAN is real but not buyable at this store right now.
616    pub is_available: bool,
617}
618
619impl From<PersonalOffer> for PersonalOfferView {
620    fn from(o: PersonalOffer) -> Self {
621        Self {
622            title: o.localized_title.best(),
623            price: o.pricing.as_ref().and_then(|p| p.price),
624            price_unit: o
625                .pricing
626                .as_ref()
627                .and_then(|p| p.unit.as_ref())
628                .and_then(|u| u.fi.clone()),
629            normal_price: o.normal_pricing.as_ref().and_then(|p| p.price),
630            discount_percentage: o.pricing.and_then(|p| p.discount_percentage),
631            remaining_quantity: o.remaining_quantity,
632            valid_until: o.valid_until,
633            products: o.products.into_iter().map(Into::into).collect(),
634        }
635    }
636}
637
638impl From<PersonalOfferProduct> for PersonalOfferProductView {
639    fn from(p: PersonalOfferProduct) -> Self {
640        Self {
641            ean: p.product.ean,
642            name: p.product.localized_name.best(),
643            is_available: p.product.is_available,
644        }
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    /// Verbatim from a live `PATCH /kr-api/basket/by-id/...`, trimmed of the
653    /// `productDetails` blob.
654    const LIVE: &str = r#"{
655      "schemaVersion": 5,
656      "id": "c0fa67a6-4b56-4dc6-9a7e-506c2b29b7cf",
657      "name": "Ostoskori",
658      "userInfo": {"firstName":"","lastName":"","email":"","phoneNumber":""},
659      "items": [{
660        "allowSubstitutes": true,
661        "amountInfo": {"amount": 1, "unit": "kpl"},
662        "ean": "2000818700008",
663        "id": "2000818700008",
664        "name": {"english":"Pirkka banana","finnish":"Pirkka banaani","swedish":"Pirkka banan"},
665        "pricing": {"isApproximate": true, "price": 0.3, "unit": "kg"},
666        "productDetails": {"attributes": {"ean": "2000818700008"}}
667      }],
668      "priceSummary": {"grandTotal": 0.3, "itemsSubTotal": 0.3,
669                       "plussaSavings": {"total": 0, "type": "POTENTIAL"}},
670      "store": {"id": "N137", "name": "K-Citymarket Helsinki Ruoholahti"}
671    }"#;
672
673    #[test]
674    fn parses_a_live_basket_into_a_view() {
675        let view: CartView = serde_json::from_str::<Basket>(LIVE).unwrap().into();
676        assert_eq!(view.store.id, "N137");
677        assert_eq!(view.totals.grand_total, 0.3);
678        assert_eq!(view.items.len(), 1);
679
680        let item = &view.items[0];
681        assert_eq!(item.item_id, "2000818700008");
682        assert_eq!(item.name, "Pirkka banaani");
683        assert_eq!(item.amount, 1.0);
684        assert_eq!(item.unit, "kpl");
685        assert!(item.price_is_approximate);
686    }
687
688    /// An anonymous session returns a valid basket with a blank `userInfo`, so
689    /// "the call worked" must not be read as "we are logged in".
690    #[test]
691    fn anonymous_basket_reports_no_account() {
692        let view: CartView = serde_json::from_str::<Basket>(LIVE).unwrap().into();
693        assert_eq!(view.account, None);
694    }
695
696    #[test]
697    fn logged_in_basket_reports_the_account() {
698        let json = LIVE.replace(
699            r#""firstName":"","lastName":"","email":""#,
700            r#""firstName":"Niko","lastName":"Savola","email":"n@example.com"#,
701        );
702        let view: CartView = serde_json::from_str::<Basket>(&json).unwrap().into();
703        assert_eq!(view.account.as_deref(), Some("Niko Savola <n@example.com>"));
704    }
705
706    /// The wire format is dictated by K-Ruoka's bundle, so pin it exactly.
707    #[test]
708    fn events_serialise_to_the_shapes_the_bundle_expects() {
709        let add = BasketEvent::AddItem {
710            item: NewItem {
711                ean: "2000818700008".into(),
712                local_store_id: None,
713                allow_substitutes: true,
714                amount_info: AmountInfo {
715                    amount: 2.0,
716                    unit: "kpl".into(),
717                },
718            },
719        };
720        assert_eq!(
721            serde_json::to_value(&add).unwrap(),
722            serde_json::json!({
723                "type": "ADD-ITEM",
724                "item": {
725                    "ean": "2000818700008",
726                    "allowSubstitutes": true,
727                    "amountInfo": {"amount": 2.0, "unit": "kpl"}
728                }
729            })
730        );
731
732        let set = BasketEvent::SetItemAmount {
733            item_id: "x".into(),
734            value: AmountInfo {
735                amount: 3.0,
736                unit: "kpl".into(),
737            },
738        };
739        assert_eq!(
740            serde_json::to_value(&set).unwrap(),
741            serde_json::json!({
742                "type": "SET-ITEM-AMOUNT",
743                "itemId": "x",
744                "value": {"amount": 3.0, "unit": "kpl"}
745            })
746        );
747
748        assert_eq!(
749            serde_json::to_value(BasketEvent::RemoveItem {
750                item_id: "x".into()
751            })
752            .unwrap(),
753            serde_json::json!({"type": "REMOVE-ITEM", "itemId": "x"})
754        );
755        assert_eq!(
756            serde_json::to_value(BasketEvent::ClearItems).unwrap(),
757            serde_json::json!({"type": "CLEAR-ITEMS"})
758        );
759    }
760
761    /// `localStoreId` is conditional in the bundle -- present only for
762    /// store-local products, and it must be absent rather than null otherwise.
763    #[test]
764    fn local_store_id_is_omitted_when_absent() {
765        let item = NewItem {
766            ean: "1".into(),
767            local_store_id: None,
768            allow_substitutes: false,
769            amount_info: AmountInfo::default(),
770        };
771        let v = serde_json::to_value(&item).unwrap();
772        assert!(!v.as_object().unwrap().contains_key("localStoreId"));
773
774        let item = NewItem {
775            local_store_id: Some("N137".into()),
776            ..item
777        };
778        assert_eq!(serde_json::to_value(&item).unwrap()["localStoreId"], "N137");
779    }
780}