Skip to main content

tail_fin_shopee/
parsing.rs

1//! Pure parsers for Shopee browser-mode capture responses.
2//!
3//! Kept separate from `browser.rs` so the JSON-shape logic is unit-
4//! testable without spinning up a `BrowserSession`.
5
6use serde_json::Value;
7use tail_fin_common::TailFinError;
8
9use crate::types::{
10    CartItem, CartPreview, Category, CategoryDetail, Discover, FlashSaleItem, MallShop,
11    ProductDetail, ProductModel, RecommendedItem, RelatedItems, Review, Reviews, SearchItem,
12    SearchResults, ShopInfo, ShopItems, UserMatch, UserSearchResults,
13};
14
15/// Parse a captured `/api/v4/search/search_items` response body.
16///
17/// `keyword` is what we passed to Shopee; we echo it back into the
18/// returned struct so callers don't have to re-thread it. `page` is
19/// the 0-indexed page index that produced this response — Shopee
20/// itself doesn't echo a "page" field, but the caller knows since
21/// it set the `newest=` query param.
22///
23/// Errors when:
24/// - The body's wrapper `error` field is non-zero (anti-bot wall,
25///   auth failure, etc.) — surfaced as `TailFinError::Api`.
26/// - The `items` field is missing or not an array.
27pub fn parse_search_items(
28    body: &Value,
29    keyword: &str,
30    page: u32,
31) -> Result<SearchResults, TailFinError> {
32    // Shopee wraps everything in `{ error, error_msg, data | items, ... }`.
33    // For search the items array is at the top level, NOT inside `data`.
34    let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
35    if err_code != 0 {
36        let msg = body
37            .get("error_msg")
38            .and_then(|v| v.as_str())
39            .unwrap_or("")
40            .to_string();
41        // 90309999 = Shopee's anti-bot CAPTCHA wall. Surface it
42        // distinctly so callers can suggest "warm the profile up
43        // more" rather than "your cookies are bad".
44        if err_code == 90309999 {
45            return Err(TailFinError::Api(format!(
46                "Shopee anti-bot wall (error 90309999): {msg}. \
47                 The attached Chrome tab needs more browsing history / \
48                 a completed order before Shopee will trust it for search."
49            )));
50        }
51        return Err(TailFinError::Api(format!("Shopee error {err_code}: {msg}")));
52    }
53
54    let items_arr = body
55        .get("items")
56        .and_then(|v| v.as_array())
57        .ok_or_else(|| TailFinError::Parse("missing `items` array in search response".into()))?;
58
59    let mut items = Vec::with_capacity(items_arr.len());
60    for raw in items_arr {
61        // Each entry is `{ itemid, shopid, item_basic: {...} }`.
62        // The fields we care about live inside `item_basic`; the
63        // outer `itemid`/`shopid` are duplicates of the inner ones.
64        let basic = raw.get("item_basic").unwrap_or(raw);
65        items.push(parse_one_item(basic));
66    }
67
68    let total_count = body
69        .get("total_count")
70        .and_then(|v| v.as_u64())
71        .unwrap_or(0);
72
73    Ok(SearchResults {
74        keyword: keyword.to_string(),
75        total_count,
76        items,
77        page,
78    })
79}
80
81/// Parse `/api/v4/recommend/product_detail_page` response body —
82/// the personalised "you may also like" feed that fires when a PDP
83/// loads. POST endpoint with `device_sz_fingerprint` in body, but
84/// since we capture off the network layer that's transparent.
85///
86/// Wire shape: `{ data: { sections: [{ key: "you_may_also_like",
87/// units: [<SearchItem-shaped>...] }] } }`. Each unit shares the
88/// search-result wire shape (legacy `itemid`/`shopid`, `name`,
89/// `price`, etc.), so the same per-item parser works.
90pub fn parse_recommend_pdp(body: &Value) -> Vec<RecommendedItem> {
91    body.pointer("/data/sections")
92        .and_then(|v| v.as_array())
93        .map(|sections| {
94            sections
95                .iter()
96                .flat_map(|s| {
97                    s.get("units")
98                        .and_then(|u| u.as_array())
99                        .map(|a| a.iter().map(parse_recommended_item).collect::<Vec<_>>())
100                        .unwrap_or_default()
101                })
102                .collect()
103        })
104        .unwrap_or_default()
105}
106
107/// Parse `/api/v4/pdp/hot_sales/get_item_cards` response body — the
108/// 8-item bestseller card set that fires next to the PDP recommend
109/// feed. Non-base64 wrapper.
110///
111/// Wire shape splits each card across two objects:
112/// - `item_data`: `{ itemid, shopid, item_rating: {...} }` — IDs
113///   and rating only.
114/// - `item_card_displayed_asset`: `{ name, image,
115///   display_price.price }` — the visual fields.
116///
117/// We merge the two into a single `RecommendedItem` so callers see
118/// the same shape as `parse_recommend_pdp`.
119pub fn parse_hot_sales(body: &Value) -> Vec<RecommendedItem> {
120    body.pointer("/data/item_cards")
121        .and_then(|v| v.as_array())
122        .map(|cards| cards.iter().map(parse_one_hot_sale_card).collect())
123        .unwrap_or_default()
124}
125
126fn parse_recommended_item(v: &Value) -> RecommendedItem {
127    let (rating_star, rating_count) = read_rating(v).unwrap_or((0.0, 0));
128    RecommendedItem {
129        itemid: pick_u64(v, &["itemid", "item_id"]),
130        shopid: pick_u64(v, &["shopid", "shop_id"]),
131        name: pick_str(v, &["name", "title"]).unwrap_or_default(),
132        price: pick_u64(v, &["price"]),
133        currency: pick_str(v, &["currency"]),
134        stock: pick_i64(v, &["stock"]),
135        // search/recommend endpoints carry the signal in `stock`
136        // (`-1` hidden, `0` OOS, positive = count). The bool is
137        // shop_items-only.
138        is_sold_out: false,
139        rating_star,
140        rating_count,
141        image: pick_str(v, &["image"]),
142        shop_location: pick_str(v, &["shop_location"]),
143    }
144}
145
146fn parse_one_hot_sale_card(card: &Value) -> RecommendedItem {
147    let item_data = card.get("item_data").unwrap_or(&Value::Null);
148    let asset = card
149        .get("item_card_displayed_asset")
150        .unwrap_or(&Value::Null);
151
152    // Hot-sales' price lives at `displayed_asset.display_price.price`,
153    // not on `item_data` (which only carries IDs + rating).
154    let price = asset
155        .pointer("/display_price/price")
156        .and_then(|v| v.as_u64())
157        .unwrap_or(0);
158    let (rating_star, rating_count) = read_rating(item_data).unwrap_or((0.0, 0));
159
160    RecommendedItem {
161        itemid: pick_u64(item_data, &["itemid", "item_id"]),
162        shopid: pick_u64(item_data, &["shopid", "shop_id"]),
163        name: pick_str(asset, &["name"]).unwrap_or_default(),
164        price,
165        currency: pick_str(item_data, &["currency"]),
166        stock: pick_i64(item_data, &["stock"]),
167        // hot_sales endpoint carries the signal in `stock`; the
168        // bool is shop_items-only.
169        is_sold_out: false,
170        rating_star,
171        rating_count,
172        image: pick_str(asset, &["image"]),
173        shop_location: pick_str(asset, &["shop_location"]),
174    }
175}
176
177/// Combine hot-sales + product-detail-page recommend into a single
178/// `RelatedItems` keyed on the source product. Either body may be
179/// `Value::Null` (capture missed it) — non-fatal; we just return an
180/// empty vec for that side.
181pub fn combine_related(
182    hot_sales_body: Option<&Value>,
183    recommend_body: Option<&Value>,
184    source_shopid: u64,
185    source_itemid: u64,
186) -> RelatedItems {
187    RelatedItems {
188        source_shopid,
189        source_itemid,
190        hot_sales: hot_sales_body.map(parse_hot_sales).unwrap_or_default(),
191        recommended: recommend_body.map(parse_recommend_pdp).unwrap_or_default(),
192    }
193}
194
195fn parse_one_item(b: &Value) -> SearchItem {
196    let item_rating = b.get("item_rating");
197    let rating_star = item_rating
198        .and_then(|r| r.get("rating_star"))
199        .and_then(|v| v.as_f64())
200        .unwrap_or(0.0);
201    // `rating_count` is a 6-element array `[total, 1*, 2*, 3*, 4*, 5*]`.
202    // Index 0 is the total; the rest are per-star bucket counts.
203    let rating_count = item_rating
204        .and_then(|r| r.get("rating_count"))
205        .and_then(|v| v.as_array())
206        .and_then(|a| a.first())
207        .and_then(|v| v.as_u64())
208        .unwrap_or(0);
209
210    SearchItem {
211        itemid: b.get("itemid").and_then(|v| v.as_u64()).unwrap_or(0),
212        shopid: b.get("shopid").and_then(|v| v.as_u64()).unwrap_or(0),
213        name: b
214            .get("name")
215            .and_then(|v| v.as_str())
216            .unwrap_or("")
217            .to_string(),
218        price: b.get("price").and_then(|v| v.as_u64()).unwrap_or(0),
219        price_min: b.get("price_min").and_then(|v| v.as_u64()).unwrap_or(0),
220        price_max: b.get("price_max").and_then(|v| v.as_u64()).unwrap_or(0),
221        currency: b
222            .get("currency")
223            .and_then(|v| v.as_str())
224            .map(|s| s.to_string()),
225        stock: b.get("stock").and_then(|v| v.as_i64()).unwrap_or(0),
226        historical_sold: b
227            .get("historical_sold")
228            .and_then(|v| v.as_i64())
229            .unwrap_or(0),
230        liked_count: b.get("liked_count").and_then(|v| v.as_u64()).unwrap_or(0),
231        rating_star,
232        rating_count,
233        shop_location: b
234            .get("shop_location")
235            .and_then(|v| v.as_str())
236            .map(|s| s.to_string()),
237        image: b
238            .get("image")
239            .and_then(|v| v.as_str())
240            .map(|s| s.to_string()),
241    }
242}
243
244/// Parse a captured product-detail response body.
245///
246/// Shopee has shipped two distinct wire shapes:
247///
248/// 1. **Modern** (`/api/v4/pdp/get_pc`, current): fan-out — `data.item`
249///    has identity + description + attributes; `data.product_price`,
250///    `data.product_review`, `data.product_images`,
251///    `data.product_attributes` carry the rest. Fixed prices live at
252///    `data.product_price.price.single_value` (in micros), variant
253///    ranges at `range_min`/`range_max` (`-1` when not a range).
254/// 2. **Legacy** (`/api/v4/item/get` and older PDPs): everything in
255///    one object — `{ data: { item: { itemid, name, price, … } } }`
256///    or flat under `{ item: {...} }`.
257///
258/// The parser detects which shape it has by probing for
259/// `data.product_price` (modern marker) vs `item.price` (legacy
260/// marker), and reads from the right places. Caller doesn't have to
261/// know which endpoint they got.
262/// Parse `/api/v4/homepage/get_daily_discover` `data.feeds[]`.
263///
264/// Each feed has `type: "centralised_item_card"` and carries an
265/// `item_card_displayed_asset` + `item_data` pair — same shape as
266/// PDP's `pdp/hot_sales` cards, so we reuse `parse_one_hot_sale_card`
267/// to extract a `RecommendedItem`.
268pub fn parse_daily_discover(body: &Value) -> (Vec<RecommendedItem>, u64) {
269    let feeds = body
270        .pointer("/data/feeds")
271        .and_then(|v| v.as_array())
272        .map(|a| {
273            a.iter()
274                // Guard on `item_data` presence — without it we'd
275                // produce a zombie `RecommendedItem { itemid: 0, … }`
276                // for any malformed centralised-card variant Shopee
277                // ever ships (currently consistent, but cheap belt-
278                // and-suspenders against wire rotations).
279                .filter_map(|f| f.get("centralised_item_card"))
280                .filter(|card| card.get("item_data").is_some())
281                .map(parse_one_hot_sale_card)
282                .collect()
283        })
284        .unwrap_or_default();
285    let total = body
286        .pointer("/data/feed_total")
287        .and_then(|v| v.as_u64())
288        .unwrap_or(0);
289    (feeds, total)
290}
291
292/// Parse `/api/v4/flash_sale/flash_sale_get_items` `data.items[]`.
293pub fn parse_flash_sale_items(body: &Value) -> Vec<FlashSaleItem> {
294    body.pointer("/data/items")
295        .and_then(|v| v.as_array())
296        .map(|a| a.iter().map(parse_one_flash_sale_item).collect())
297        .unwrap_or_default()
298}
299
300fn parse_one_flash_sale_item(v: &Value) -> FlashSaleItem {
301    FlashSaleItem {
302        itemid: pick_u64(v, &["itemid", "item_id"]),
303        shopid: pick_u64(v, &["shopid", "shop_id"]),
304        name: pick_str(v, &["name", "title"]).unwrap_or_default(),
305        price: pick_u64(v, &["price"]),
306        raw_discount: v
307            .get("raw_discount")
308            .and_then(|x| x.as_u64())
309            .map(|n| n.min(u64::from(u32::MAX)) as u32)
310            .unwrap_or(0),
311        end_time: pick_i64(v, &["end_time"]),
312        stock: pick_i64(v, &["stock"]),
313        image: pick_str(v, &["image"]),
314        promotionid: pick_u64(v, &["promotionid", "promotion_id"]),
315    }
316}
317
318/// Parse `/api/v4/homepage/mall_shops` `data.shops[]`.
319pub fn parse_mall_shops(body: &Value) -> Vec<MallShop> {
320    body.pointer("/data/shops")
321        .and_then(|v| v.as_array())
322        .map(|a| a.iter().map(parse_one_mall_shop).collect())
323        .unwrap_or_default()
324}
325
326fn parse_one_mall_shop(v: &Value) -> MallShop {
327    MallShop {
328        shopid: pick_u64(v, &["shopid", "shop_id"]),
329        url: pick_str(v, &["url"]).unwrap_or_default(),
330        image: pick_str(v, &["image"]),
331        promo_text: pick_str(v, &["promo_text"]),
332    }
333}
334
335/// Combine the three discover-surface bodies into one `Discover`.
336/// All three are optional — a low-trust profile may legitimately
337/// see fewer than 3 endpoints captured (e.g. flash-sale module
338/// hidden during off-peak hours).
339pub fn combine_discover(
340    discover_body: Option<&Value>,
341    flash_sale_body: Option<&Value>,
342    mall_shops_body: Option<&Value>,
343) -> Discover {
344    let (feeds, feed_total) = discover_body.map(parse_daily_discover).unwrap_or_default();
345    Discover {
346        feeds,
347        feed_total,
348        flash_sale: flash_sale_body
349            .map(parse_flash_sale_items)
350            .unwrap_or_default(),
351        mall_shops: mall_shops_body.map(parse_mall_shops).unwrap_or_default(),
352    }
353}
354
355/// Parse `/api/v4/pages/get_homepage_category_list` response body.
356///
357/// Returns the top-level category list (24 entries on shopee.tw
358/// at the time of writing). Each `Category.children` is an empty
359/// `Vec` — this endpoint doesn't ship nested sub-categories. The
360/// recursive shape is preserved on the type so future drill-down
361/// endpoints can populate it without a breaking change.
362pub fn parse_category_tree(body: &Value) -> Vec<Category> {
363    body.pointer("/data/category_list")
364        .and_then(|v| v.as_array())
365        .map(|a| a.iter().map(parse_one_category).collect())
366        .unwrap_or_default()
367}
368
369fn parse_one_category(v: &Value) -> Category {
370    Category {
371        // Defensive snake-case aliasing: today's endpoint ships
372        // `catid` / `parent_catid` only, but PR #191's PDP endpoint
373        // surfaced empirical `itemid`/`item_id` rotation across
374        // sibling endpoints — cheap insurance against the same churn.
375        catid: pick_u64(v, &["catid", "cat_id"]),
376        parent_catid: pick_u64(v, &["parent_catid", "parent_cat_id"]),
377        name: pick_str(v, &["name"]).unwrap_or_default(),
378        display_name: pick_str(v, &["display_name"]).unwrap_or_default(),
379        image: pick_str(v, &["image"]),
380        level: v
381            .get("level")
382            .and_then(|x| x.as_u64())
383            .map(|n| n.min(u64::from(u32::MAX)) as u32)
384            .unwrap_or(0),
385        // Recursive — current endpoint always sends `null` here, but
386        // a future drill-down endpoint may populate it. Treat null /
387        // missing as empty.
388        children: v
389            .get("children")
390            .and_then(|x| x.as_array())
391            .map(|a| a.iter().map(parse_one_category).collect())
392            .unwrap_or_default(),
393    }
394}
395
396/// Parse `/api/v4/search/get_fe_category_detail` response body.
397///
398/// Wire shape: `{ data: { categories: [{ catid, name, level,
399/// parent_cat_id, display_name, image, ... }] } }`. We pluck the
400/// first entry — for a single-`catids` query Shopee always returns
401/// exactly one record.
402///
403/// Differs from [`parse_category_tree`] in two notable wire-shape
404/// quirks (handled here, not on the type):
405/// 1. `parent_cat_id` is a **JSON array** — `[parent_id]` for
406///    `level >= 2`, `null` for top-level. We flatten to `Option<u64>`.
407/// 2. `display_name` is a **locale array** — `[{lang, value,
408///    is_default}, ...]`. We extract the entry with
409///    `is_default: true` (typically zh-Hant on shopee.tw); falls
410///    back to the first entry if none is flagged default.
411///
412/// Returns `None` when the wrapper is empty / malformed (caller
413/// can choose to surface as missing metadata rather than a hard
414/// fail — items list is the load-bearing field).
415pub fn parse_fe_category_detail(body: &Value) -> Option<CategoryDetail> {
416    let cat = body
417        .pointer("/data/categories")
418        .and_then(|v| v.as_array())
419        .and_then(|a| a.first())?;
420
421    let display_name = cat
422        .get("display_name")
423        .and_then(extract_default_locale_value)
424        .unwrap_or_default();
425
426    let parent_cat_id = cat
427        .get("parent_cat_id")
428        .and_then(|v| v.as_array())
429        .and_then(|a| a.first())
430        .and_then(|x| x.as_u64())
431        // Defensive: if Shopee ever ships a scalar here (matching
432        // `parse_category_tree`'s wire shape), accept that too.
433        .or_else(|| cat.get("parent_cat_id").and_then(|v| v.as_u64()));
434
435    Some(CategoryDetail {
436        catid: pick_u64(cat, &["catid", "cat_id"]),
437        name: pick_str(cat, &["name"]).unwrap_or_default(),
438        display_name,
439        level: cat
440            .get("level")
441            .and_then(|x| x.as_u64())
442            .map(|n| n.min(u64::from(u32::MAX)) as u32)
443            .unwrap_or(0),
444        parent_cat_id,
445        image: pick_str(cat, &["image"]),
446    })
447}
448
449/// Extract the "default" locale value from Shopee's
450/// `display_name` array shape:
451/// `[{lang, value, is_default}, ...]`. Returns the value of the
452/// first `is_default: true` entry, or the first entry's value if
453/// no entry is flagged default. Returns `None` if the input isn't
454/// an array OR is an array of non-objects.
455///
456/// Defensive ordering: when an `is_default: true` entry exists
457/// but its `value` is missing or non-string, return `None` —
458/// don't fall through to a different locale's value (silently
459/// flipping locales is worse than returning empty).
460fn extract_default_locale_value(v: &Value) -> Option<String> {
461    let arr = v.as_array()?;
462    if arr.is_empty() {
463        return None;
464    }
465    if let Some(default) = arr
466        .iter()
467        .find(|e| e.get("is_default").and_then(|d| d.as_bool()) == Some(true))
468    {
469        return default
470            .get("value")
471            .and_then(|x| x.as_str())
472            .map(str::to_string);
473    }
474    // No `is_default: true` entry → fall back to first entry's
475    // value (matches Shopee's most common shape, where the
476    // implicit default is index 0).
477    arr.first()
478        .and_then(|e| e.get("value").and_then(|x| x.as_str()))
479        .map(str::to_string)
480}
481
482/// Parse a shop catalogue response body. Accepts both wire shapes
483/// Shopee ships:
484///
485/// 1. **`/api/v4/shop/search_items`** (popularity-sort across the
486///    full catalogue, fires on collection / category-filtered shop
487///    URLs): flat top level — `centralize_item_card`, `total_count`,
488///    `nomore`.
489/// 2. **`/api/v4/shop/rcmd_items`** (recommended items, fires on
490///    the shop home page): `data` wrapper — `data.centralize_item_card`,
491///    `data.total`, `data.no_more`. POST endpoint with item_id hint.
492///
493/// Each card has `itemid` / `shopid` / `item_rating` at the top
494/// level (NOT nested in `item_data` like PDP hot-sales), plus
495/// `item_card_displayed_asset.{name, image, display_price.price,
496/// sold_count.text}` for the visual fields. We flatten into
497/// `RecommendedItem`.
498///
499/// `shopid` and `page` come from the caller — Shopee doesn't echo
500/// them back.
501pub fn parse_shop_items(body: &Value, shopid: u64, page: u32) -> Result<ShopItems, TailFinError> {
502    let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
503    if err_code != 0 {
504        let msg = body
505            .get("error_msg")
506            .and_then(|v| v.as_str())
507            .unwrap_or("")
508            .to_string();
509        if err_code == 90309999 {
510            return Err(TailFinError::Api(format!(
511                "Shopee anti-bot wall (error 90309999) on shop items: {msg}. \
512                 Profile trust may have reverted."
513            )));
514        }
515        return Err(TailFinError::Api(format!(
516            "Shopee error {err_code} on shop items: {msg}"
517        )));
518    }
519
520    // Items live at `/centralize_item_card/item_cards` (search_items
521    // flat shape) OR `/data/centralize_item_card/item_cards`
522    // (rcmd_items wrapped shape). Try both.
523    let items: Vec<RecommendedItem> = body
524        .pointer("/centralize_item_card/item_cards")
525        .or_else(|| body.pointer("/data/centralize_item_card/item_cards"))
526        .and_then(|v| v.as_array())
527        .map(|a| a.iter().map(parse_one_shop_item_card).collect())
528        .unwrap_or_default();
529
530    // Total: `total_count` (search_items) OR `data.total` (rcmd_items).
531    let total_count = body
532        .get("total_count")
533        .and_then(|v| v.as_u64())
534        .or_else(|| body.pointer("/data/total").and_then(|v| v.as_u64()))
535        .unwrap_or(0);
536
537    // Pagination flag: `nomore` (search_items) OR `data.no_more`
538    // (rcmd_items — note the underscore difference).
539    let nomore = body
540        .get("nomore")
541        .and_then(|v| v.as_bool())
542        .or_else(|| body.pointer("/data/no_more").and_then(|v| v.as_bool()))
543        .unwrap_or(false);
544
545    Ok(ShopItems {
546        shopid,
547        page,
548        total_count,
549        nomore,
550        items,
551    })
552}
553
554/// Parse one shop-item card. Distinct from `parse_one_hot_sale_card`
555/// because the IDs / rating are at the TOP level (not under
556/// `item_data`); only the visual fields live in
557/// `item_card_displayed_asset`.
558fn parse_one_shop_item_card(card: &Value) -> RecommendedItem {
559    let asset = card
560        .get("item_card_displayed_asset")
561        .unwrap_or(&Value::Null);
562
563    let (rating_star, rating_count) = read_rating(card).unwrap_or((0.0, 0));
564    // Price is duplicated at top-level (`item_card_display_price.price`)
565    // and inside the asset (`display_price.price`). They agree on
566    // observed responses; prefer the asset path since it's the
567    // canonical visual surface.
568    let price = asset
569        .pointer("/display_price/price")
570        .and_then(|v| v.as_u64())
571        .or_else(|| {
572            card.pointer("/item_card_display_price/price")
573                .and_then(|v| v.as_u64())
574        })
575        .unwrap_or(0);
576
577    RecommendedItem {
578        itemid: pick_u64(card, &["itemid", "item_id"]),
579        shopid: pick_u64(card, &["shopid", "shop_id"]),
580        name: pick_str(asset, &["name"]).unwrap_or_default(),
581        price,
582        currency: pick_str(card, &["currency"]),
583        // Stock isn't surfaced on shop-listing endpoints — Shopee
584        // only ships a boolean `is_sold_out` flag + textual
585        // `sold_count.text`. Set `stock = -1` (hidden — that's
586        // literally true here) and surface the boolean via the
587        // dedicated `is_sold_out` field on `RecommendedItem`.
588        // Mapping `is_sold_out` into `stock` would conflict with
589        // the type's documented sentinel semantics (-1 = hidden,
590        // 0 = out of stock).
591        stock: -1,
592        is_sold_out: card
593            .get("is_sold_out")
594            .and_then(|v| v.as_bool())
595            .unwrap_or(false),
596        rating_star,
597        rating_count,
598        image: pick_str(asset, &["image"]),
599        shop_location: pick_str(asset, &["shop_location"]),
600    }
601}
602
603/// Parse `/api/v4/promotion/get_shop_info` response body.
604///
605/// Errors only when the wrapper `error` field is non-zero (auth
606/// failure, anti-bot wall, etc.) — non-fatal otherwise; missing
607/// fields default cleanly so a sparse response from a brand-new
608/// shop still parses.
609pub fn parse_shop_info(body: &Value) -> Result<ShopInfo, TailFinError> {
610    let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
611    if err_code != 0 {
612        let msg = body
613            .get("error_msg")
614            .and_then(|v| v.as_str())
615            .unwrap_or("")
616            .to_string();
617        if err_code == 90309999 {
618            return Err(TailFinError::Api(format!(
619                "Shopee anti-bot wall (error 90309999) on get_shop_info: {msg}. \
620                 Profile trust may have reverted."
621            )));
622        }
623        return Err(TailFinError::Api(format!(
624            "Shopee error {err_code} on get_shop_info: {msg}"
625        )));
626    }
627    let data = body
628        .get("data")
629        .ok_or_else(|| TailFinError::Parse("missing `data` in get_shop_info response".into()))?;
630
631    Ok(ShopInfo {
632        shop_id: pick_u64(data, &["shop_id", "shopid"]),
633        user_id: pick_u64(data, &["user_id", "userid"]),
634        name: pick_str(data, &["name"]).unwrap_or_default(),
635        place: pick_str(data, &["place"]),
636        is_official_shop: data
637            .get("is_official_shop")
638            .and_then(|v| v.as_bool())
639            .unwrap_or(false),
640        is_shopee_verified: data
641            .get("is_shopee_verified")
642            .and_then(|v| v.as_bool())
643            .unwrap_or(false),
644        holiday_mode: data
645            .get("holiday_mode")
646            .and_then(|v| v.as_bool())
647            .unwrap_or(false),
648        item_count: pick_u64(data, &["item_count"]),
649        follower_count: pick_u64(data, &["follower_count"]),
650        rating_star: data
651            .get("rating_star")
652            .and_then(|v| v.as_f64())
653            .unwrap_or(0.0),
654        rating_good: pick_u64(data, &["rating_good"]),
655        rating_normal: pick_u64(data, &["rating_normal"]),
656        rating_bad: pick_u64(data, &["rating_bad"]),
657        response_rate: data
658            .get("response_rate")
659            .and_then(|v| v.as_u64())
660            .map(|n| n.min(u64::from(u32::MAX)) as u32)
661            .unwrap_or(0),
662        response_time: pick_u64(data, &["response_time"]),
663        ctime: pick_i64(data, &["ctime"]),
664        last_active_time: pick_i64(data, &["last_active_time"]),
665    })
666}
667
668/// Parse `/api/v2/item/get_ratings` response body.
669///
670/// `shopid` and `itemid` come from the caller (the request
671/// query string echoed them, but Shopee doesn't always echo
672/// them back into the response — we plumb them through so the
673/// returned `Reviews` is self-contained).
674pub fn parse_reviews(body: &Value, shopid: u64, itemid: u64) -> Result<Reviews, TailFinError> {
675    let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
676    if err_code != 0 {
677        let msg = body
678            .get("error_msg")
679            .and_then(|v| v.as_str())
680            .unwrap_or("")
681            .to_string();
682        if err_code == 90309999 {
683            return Err(TailFinError::Api(format!(
684                "Shopee anti-bot wall (error 90309999) on get_ratings: {msg}. \
685                 Profile trust may have reverted."
686            )));
687        }
688        return Err(TailFinError::Api(format!(
689            "Shopee error {err_code} on get_ratings: {msg}"
690        )));
691    }
692
693    let data = body
694        .get("data")
695        .ok_or_else(|| TailFinError::Parse("missing `data` in get_ratings response".into()))?;
696
697    let ratings: Vec<Review> = data
698        .get("ratings")
699        .and_then(|v| v.as_array())
700        .map(|a| a.iter().map(parse_one_review).collect())
701        .unwrap_or_default();
702
703    Ok(Reviews {
704        itemid,
705        shopid,
706        item_rating_star: data
707            .get("item_rating_star")
708            .and_then(|v| v.as_f64())
709            .unwrap_or(0.0),
710        item_rating_count: pick_u64(data, &["item_rating_count"]),
711        has_more: data
712            .get("has_more")
713            .and_then(|v| v.as_bool())
714            .unwrap_or(false),
715        ratings,
716    })
717}
718
719fn parse_one_review(v: &Value) -> Review {
720    Review {
721        cmtid: pick_u64(v, &["cmtid"]),
722        itemid: pick_u64(v, &["itemid", "item_id"]),
723        shopid: pick_u64(v, &["shopid", "shop_id"]),
724        rating_star: v
725            .get("rating_star")
726            .and_then(|x| x.as_u64())
727            .map(|n| n.min(u64::from(u32::MAX)) as u32)
728            .unwrap_or(0),
729        comment: pick_str(v, &["comment"]).unwrap_or_default(),
730        images: v
731            .get("images")
732            .and_then(|x| x.as_array())
733            .map(|a| {
734                a.iter()
735                    .filter_map(|x| x.as_str().map(|s| s.to_string()))
736                    .collect()
737            })
738            .unwrap_or_default(),
739        ctime: pick_i64(v, &["ctime"]),
740        author_username: pick_str(v, &["author_username"]).unwrap_or_default(),
741        anonymous: v
742            .get("anonymous")
743            .and_then(|x| x.as_bool())
744            .unwrap_or(false),
745    }
746}
747
748/// Parse `/api/v4/search/search_user` response body — shops
749/// matching a keyword.
750///
751/// `keyword` comes from the caller — Shopee echoes it in the
752/// query string, but doesn't include it in the response wrapper.
753pub fn parse_search_user(body: &Value, keyword: &str) -> Result<UserSearchResults, TailFinError> {
754    // `error` is `null` on success (matches the recommend-feed
755    // pattern from #193); only treat explicitly non-zero numbers
756    // as failures.
757    let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
758    if err_code != 0 {
759        let msg = body
760            .get("error_msg")
761            .and_then(|v| v.as_str())
762            .unwrap_or("")
763            .to_string();
764        if err_code == 90309999 {
765            return Err(TailFinError::Api(format!(
766                "Shopee anti-bot wall (error 90309999) on search_user: {msg}. \
767                 Profile trust may have reverted."
768            )));
769        }
770        return Err(TailFinError::Api(format!(
771            "Shopee error {err_code} on search_user: {msg}"
772        )));
773    }
774
775    let users: Vec<UserMatch> = body
776        .pointer("/data/users")
777        .and_then(|v| v.as_array())
778        .map(|a| a.iter().map(parse_one_user_match).collect())
779        .unwrap_or_default();
780
781    Ok(UserSearchResults {
782        keyword: keyword.to_string(),
783        users,
784    })
785}
786
787fn parse_one_user_match(v: &Value) -> UserMatch {
788    UserMatch {
789        shopid: pick_u64(v, &["shopid", "shop_id"]),
790        userid: pick_u64(v, &["userid", "user_id"]),
791        username: pick_str(v, &["username"]).unwrap_or_default(),
792        shopname: pick_str(v, &["shopname"]).unwrap_or_default(),
793        nickname: pick_str(v, &["nickname"]).unwrap_or_default(),
794        portrait: pick_str(v, &["portrait"]),
795        shop_rating: v.get("shop_rating").and_then(|x| x.as_f64()).unwrap_or(0.0),
796        follower_count: pick_u64(v, &["follower_count"]),
797        products: pick_u64(v, &["products"]),
798        is_official_shop: v
799            .get("is_official_shop")
800            .and_then(|x| x.as_bool())
801            .unwrap_or(false),
802        shopee_verified_flag: v
803            .get("shopee_verified_flag")
804            .and_then(|x| x.as_u64())
805            .map(|n| n.min(u64::from(u32::MAX)) as u32)
806            .unwrap_or(0),
807        response_rate: v
808            .get("response_rate")
809            .and_then(|x| x.as_u64())
810            .map(|n| n.min(u64::from(u32::MAX)) as u32)
811            .unwrap_or(0),
812        response_time: pick_u64(v, &["response_time"]),
813        country: pick_str(v, &["country"]).unwrap_or_default(),
814    }
815}
816
817/// Parse `/api/v4/cart/mini` response body.
818///
819/// Shape: `{ data: { recent_cart_item_details: [...], total_cart_item_count, unique_cart_item_count } }`.
820/// `recent_cart_item_details` is capped at 5 items by Shopee
821/// regardless of how many distinct items the user has — this is a
822/// preview-only endpoint, not a full cart dump. The full cart
823/// requires `cart/get_items_brief` (POST with shopid list, scoped
824/// to specific shops; more useful from the actual cart page).
825///
826/// Returns an empty preview (rather than erroring) when `error: 0`
827/// but `recent_cart_item_details` is missing or empty — a logged-
828/// in user with an empty cart is a valid state.
829pub fn parse_cart_mini(body: &Value) -> Result<CartPreview, TailFinError> {
830    // Cart-mini's `error` field uses 0/null interchangeably for
831    // success; only treat it as a failure when it's explicitly a
832    // non-zero number.
833    let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
834    if err_code != 0 {
835        let msg = body
836            .get("error_msg")
837            .and_then(|v| v.as_str())
838            .unwrap_or("")
839            .to_string();
840        // Cart endpoints used to be 90309999-walled (memory:
841        // shopee_antibot_signature); surface that distinctly so
842        // callers can interpret "trust score reverted" vs other
843        // failures.
844        if err_code == 90309999 {
845            return Err(TailFinError::Api(format!(
846                "Shopee anti-bot wall (error 90309999) on cart/mini: {msg}. \
847                 The attached Chrome profile's trust may have reverted; \
848                 manually browse + add-to-cart a few times then retry."
849            )));
850        }
851        return Err(TailFinError::Api(format!(
852            "Shopee error {err_code} on cart/mini: {msg}"
853        )));
854    }
855
856    let data = body
857        .get("data")
858        .ok_or_else(|| TailFinError::Parse("missing `data` in cart/mini response".into()))?;
859
860    let recent_items: Vec<CartItem> = data
861        .get("recent_cart_item_details")
862        .and_then(|v| v.as_array())
863        .map(|a| a.iter().map(parse_one_cart_item).collect())
864        .unwrap_or_default();
865
866    Ok(CartPreview {
867        total_count: data
868            .get("total_cart_item_count")
869            .and_then(|v| v.as_u64())
870            .unwrap_or(0),
871        unique_count: data
872            .get("unique_cart_item_count")
873            .and_then(|v| v.as_u64())
874            .unwrap_or(0),
875        recent_items,
876    })
877}
878
879fn parse_one_cart_item(v: &Value) -> CartItem {
880    CartItem {
881        itemid: pick_u64(v, &["itemid", "item_id"]),
882        shopid: pick_u64(v, &["shopid", "shop_id"]),
883        modelid: pick_u64(v, &["modelid", "model_id"]),
884        name: pick_str(v, &["name", "title"]).unwrap_or_default(),
885        price: pick_u64(v, &["price"]),
886        image: pick_str(v, &["image"]),
887        status: pick_i64(v, &["status"]),
888        is_add_on_sub_item: v
889            .get("is_add_on_sub_item")
890            .and_then(|x| x.as_bool())
891            .unwrap_or(false),
892    }
893}
894
895pub fn parse_product_detail(body: &Value) -> Result<ProductDetail, TailFinError> {
896    let err_code = body.get("error").and_then(|v| v.as_i64()).unwrap_or(0);
897    if err_code != 0 {
898        let msg = body
899            .get("error_msg")
900            .and_then(|v| v.as_str())
901            .unwrap_or("")
902            .to_string();
903        if err_code == 90309999 {
904            return Err(TailFinError::Api(format!(
905                "Shopee anti-bot wall (error 90309999): {msg}. \
906                 The attached Chrome tab needs more browsing history / \
907                 a completed order before Shopee will trust it for \
908                 product detail."
909            )));
910        }
911        // 4 = "item not found" / removed listing — surface distinctly
912        // since callers (e.g. CLI users with a stale itemid) will hit
913        // this most often.
914        if err_code == 4 {
915            return Err(TailFinError::Api(format!(
916                "Shopee item not found (error 4): {msg}. \
917                 The product may have been removed or the itemid/shopid \
918                 pair is wrong."
919            )));
920        }
921        return Err(TailFinError::Api(format!("Shopee error {err_code}: {msg}")));
922    }
923
924    let item = unwrap_item(body).ok_or_else(|| {
925        TailFinError::Parse("missing item object in product-detail response".into())
926    })?;
927
928    // Most fields live directly on `data.item` in BOTH modern and
929    // legacy shapes — only the field names + ID casing changed:
930    //   modern  legacy
931    //   item_id  itemid
932    //   shop_id  shopid
933    //   title    name
934    // For images / models / rating / shop_location, modern PDP also
935    // exposes them on `data.product_*` siblings (a fan-out
936    // convenience layer); we read from `item` first, then fall back
937    // to those siblings when item is sparse.
938
939    let pr = body.pointer("/data/product_review");
940    let pi = body.pointer("/data/product_images");
941    let pa = body.pointer("/data/product_attributes");
942    let sd = body.pointer("/data/shop_detailed");
943
944    // Rating: prefer `item.item_rating` (legacy), fall back to
945    // `data.product_review` (modern when item.item_rating absent).
946    let (rating_star, rating_count) = read_rating(item).unwrap_or_else(|| {
947        pr.map(|r| {
948            let s = r.get("rating_star").and_then(|v| v.as_f64()).unwrap_or(0.0);
949            let c = r
950                .get("rating_count")
951                .and_then(|v| v.as_array())
952                .and_then(|a| a.first())
953                .and_then(|v| v.as_u64())
954                .unwrap_or(0);
955            (s, c)
956        })
957        .unwrap_or((0.0, 0))
958    });
959
960    // Images: legacy is array of strings on `item.images`; modern
961    // PDP returns either array of strings on `item.images` or array
962    // of `{image_id, ...}` objects on `data.product_images.images`.
963    let images = read_images(item)
964        .or_else(|| {
965            pi.and_then(|v| v.get("images"))
966                .and_then(read_images_from_value)
967        })
968        .unwrap_or_default();
969    let cover_image = item
970        .get("image")
971        .and_then(|v| v.as_str())
972        .map(|s| s.to_string())
973        .or_else(|| images.first().cloned());
974
975    // Models / variants: same dual location as images.
976    let models: Vec<ProductModel> = item
977        .get("models")
978        .and_then(|v| v.as_array())
979        .map(|a| a.iter().map(parse_one_model).collect())
980        .or_else(|| {
981            pa.and_then(|v| v.get("models"))
982                .and_then(|v| v.as_array())
983                .map(|a| a.iter().map(parse_one_model).collect())
984        })
985        .unwrap_or_default();
986
987    Ok(ProductDetail {
988        // Modern uses snake_case `item_id` / `shop_id`; legacy used
989        // `itemid` / `shopid`. Try modern first since it's current.
990        itemid: pick_u64(item, &["item_id", "itemid"]),
991        shopid: pick_u64(item, &["shop_id", "shopid"]),
992        // Modern uses `title`; legacy used `name`.
993        name: pick_str(item, &["title", "name"]).unwrap_or_default(),
994        description: pick_str(item, &["description"]),
995        price: pick_u64(item, &["price"]),
996        price_min: pick_u64(item, &["price_min"]),
997        price_max: pick_u64(item, &["price_max"]),
998        currency: pick_str(item, &["currency"]),
999        stock: pick_i64(item, &["stock"]),
1000        historical_sold: pick_i64(item, &["historical_sold"]),
1001        liked_count: pick_u64(item, &["liked_count"]),
1002        rating_star,
1003        rating_count,
1004        shop_location: pick_str(item, &["shop_location"]).or_else(|| {
1005            sd.and_then(|v| v.get("shop_location"))
1006                .and_then(|v| v.as_str())
1007                .map(|s| s.to_string())
1008        }),
1009        image: cover_image,
1010        images,
1011        models,
1012    })
1013}
1014
1015/// Walk known wrapper paths to find the item object.
1016///
1017/// Looks for `item_id` (modern snake-case) OR `itemid` (legacy) on
1018/// each candidate so the same walker handles both shapes.
1019fn unwrap_item(body: &Value) -> Option<&Value> {
1020    let candidates: [Option<&Value>; 4] = [
1021        body.pointer("/data/item"),
1022        body.pointer("/data"),
1023        body.pointer("/item"),
1024        Some(body),
1025    ];
1026    for c in candidates.iter().flatten() {
1027        let has_id = c.get("item_id").and_then(|v| v.as_u64()).is_some()
1028            || c.get("itemid").and_then(|v| v.as_u64()).is_some();
1029        if has_id {
1030            return Some(c);
1031        }
1032    }
1033    None
1034}
1035
1036fn pick_u64(v: &Value, keys: &[&str]) -> u64 {
1037    for k in keys {
1038        if let Some(n) = v.get(k).and_then(|x| x.as_u64()) {
1039            return n;
1040        }
1041    }
1042    0
1043}
1044
1045fn pick_i64(v: &Value, keys: &[&str]) -> i64 {
1046    for k in keys {
1047        if let Some(n) = v.get(k).and_then(|x| x.as_i64()) {
1048            return n;
1049        }
1050    }
1051    0
1052}
1053
1054fn pick_str(v: &Value, keys: &[&str]) -> Option<String> {
1055    for k in keys {
1056        if let Some(s) = v.get(k).and_then(|x| x.as_str()) {
1057            return Some(s.to_string());
1058        }
1059    }
1060    None
1061}
1062
1063fn read_rating(item: &Value) -> Option<(f64, u64)> {
1064    let r = item.get("item_rating")?;
1065    let s = r.get("rating_star").and_then(|v| v.as_f64())?;
1066    let c = r
1067        .get("rating_count")
1068        .and_then(|v| v.as_array())
1069        .and_then(|a| a.first())
1070        .and_then(|v| v.as_u64())
1071        .unwrap_or(0);
1072    Some((s, c))
1073}
1074
1075fn read_images(item: &Value) -> Option<Vec<String>> {
1076    item.get("images").and_then(|v| v.as_array()).and_then(|a| {
1077        // Only succeed if at least one element is parseable —
1078        // otherwise fall through to the `data.product_images.*`
1079        // path.
1080        let v: Vec<String> = a
1081            .iter()
1082            .filter_map(|v| v.as_str().map(|s| s.to_string()))
1083            .collect();
1084        if v.is_empty() {
1085            None
1086        } else {
1087            Some(v)
1088        }
1089    })
1090}
1091
1092/// Read an array of image hashes that may be either bare strings
1093/// (legacy / `data.item.images`) or objects (modern PDP fan-out).
1094/// Modern PDP wraps each entry as `{ image_id: "…" }`; some wires
1095/// shipped a transition spelling `image_hash` instead — try
1096/// `image_id` first since that's the current shape on shopee.tw,
1097/// fall back to `image_hash` for any shape that ships the older
1098/// key.
1099fn read_images_from_value(v: &Value) -> Option<Vec<String>> {
1100    v.as_array().map(|a| {
1101        a.iter()
1102            .filter_map(|v| {
1103                v.as_str().map(|s| s.to_string()).or_else(|| {
1104                    v.get("image_id")
1105                        .or_else(|| v.get("image_hash"))
1106                        .and_then(|x| x.as_str())
1107                        .map(|s| s.to_string())
1108                })
1109            })
1110            .collect()
1111    })
1112}
1113
1114/// Parse one variant model. Handles both shapes:
1115///   modern: `{ model_id, name, price: { single_value }, stock }`
1116///   legacy: `{ modelid, name, price, stock }`
1117fn parse_one_model(v: &Value) -> ProductModel {
1118    let price = v
1119        .pointer("/price/single_value")
1120        .and_then(|x| x.as_u64())
1121        .or_else(|| v.get("price").and_then(|x| x.as_u64()))
1122        .unwrap_or(0);
1123    ProductModel {
1124        modelid: pick_u64(v, &["model_id", "modelid"]),
1125        name: v
1126            .get("name")
1127            .and_then(|v| v.as_str())
1128            .unwrap_or("")
1129            .to_string(),
1130        price,
1131        stock: v.get("stock").and_then(|v| v.as_i64()).unwrap_or(0),
1132    }
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137    use super::*;
1138    use serde_json::json;
1139
1140    /// Inline fixture mirroring Shopee's real `/api/v4/search/search_items`
1141    /// shape. Trimmed to two items and the fields the parser reads.
1142    fn fixture() -> Value {
1143        json!({
1144            "error": 0,
1145            "error_msg": null,
1146            "total_count": 8000,
1147            "items": [
1148                {
1149                    "itemid": 12345,
1150                    "shopid": 67890,
1151                    "item_basic": {
1152                        "itemid": 12345,
1153                        "shopid": 67890,
1154                        "name": "iPhone 15 Pro 256GB 黑色",
1155                        "price": 3999000000_u64,
1156                        "price_min": 3999000000_u64,
1157                        "price_max": 5499000000_u64,
1158                        "currency": "TWD",
1159                        "stock": 99,
1160                        "historical_sold": 100,
1161                        "liked_count": 250,
1162                        "shop_location": "新北市",
1163                        "image": "abc123def456",
1164                        "item_rating": {
1165                            "rating_star": 4.85,
1166                            "rating_count": [123, 5, 10, 20, 30, 58]
1167                        }
1168                    }
1169                },
1170                {
1171                    "itemid": 99999,
1172                    "shopid": 11111,
1173                    "item_basic": {
1174                        "itemid": 99999,
1175                        "shopid": 11111,
1176                        "name": "Stock-hidden item",
1177                        "price": 50000000,
1178                        "price_min": 50000000,
1179                        "price_max": 50000000,
1180                        "currency": "TWD",
1181                        "stock": -1,
1182                        "historical_sold": -1,
1183                        "liked_count": 0,
1184                        "shop_location": "Singapore",
1185                        "image": null,
1186                        "item_rating": null
1187                    }
1188                }
1189            ]
1190        })
1191    }
1192
1193    #[test]
1194    fn parses_two_items_and_echoes_keyword() {
1195        let r = parse_search_items(&fixture(), "iPhone", 0).expect("parse");
1196        assert_eq!(r.keyword, "iPhone");
1197        assert_eq!(r.total_count, 8000);
1198        assert_eq!(r.items.len(), 2);
1199    }
1200
1201    #[test]
1202    fn first_item_has_full_fields_populated() {
1203        let r = parse_search_items(&fixture(), "iPhone", 0).unwrap();
1204        let it = &r.items[0];
1205        assert_eq!(it.itemid, 12345);
1206        assert_eq!(it.shopid, 67890);
1207        assert_eq!(it.name, "iPhone 15 Pro 256GB 黑色");
1208        assert_eq!(it.price, 3_999_000_000);
1209        assert_eq!(it.price_min, 3_999_000_000);
1210        assert_eq!(it.price_max, 5_499_000_000);
1211        assert_eq!(it.currency.as_deref(), Some("TWD"));
1212        assert_eq!(it.stock, 99);
1213        assert_eq!(it.historical_sold, 100);
1214        assert_eq!(it.liked_count, 250);
1215        assert!((it.rating_star - 4.85).abs() < 1e-6);
1216        assert_eq!(it.rating_count, 123);
1217        assert_eq!(it.shop_location.as_deref(), Some("新北市"));
1218        assert_eq!(it.image.as_deref(), Some("abc123def456"));
1219    }
1220
1221    #[test]
1222    fn second_item_handles_hidden_stock_and_null_rating() {
1223        let r = parse_search_items(&fixture(), "iPhone", 0).unwrap();
1224        let it = &r.items[1];
1225        assert_eq!(it.stock, -1);
1226        assert_eq!(it.historical_sold, -1);
1227        assert_eq!(it.rating_star, 0.0);
1228        assert_eq!(it.rating_count, 0);
1229        assert!(it.image.is_none());
1230    }
1231
1232    #[test]
1233    fn parse_falls_back_to_outer_when_item_basic_missing() {
1234        // Older Shopee responses (and some endpoints) put fields at
1235        // the outer level instead of nesting under item_basic. The
1236        // parser should still work.
1237        let body = json!({
1238            "error": 0,
1239            "items": [{
1240                "itemid": 1,
1241                "shopid": 2,
1242                "name": "flat-shape item",
1243                "price": 1000000,
1244                "price_min": 1000000,
1245                "price_max": 1000000
1246            }]
1247        });
1248        let r = parse_search_items(&body, "x", 0).unwrap();
1249        assert_eq!(r.items.len(), 1);
1250        assert_eq!(r.items[0].name, "flat-shape item");
1251        assert_eq!(r.items[0].price, 1_000_000);
1252    }
1253
1254    #[test]
1255    fn captcha_wall_surfaces_distinct_error() {
1256        let body = json!({
1257            "error": 90309999,
1258            "error_msg": "anti-bot triggered"
1259        });
1260        let err = parse_search_items(&body, "x", 0).unwrap_err();
1261        let msg = err.to_string();
1262        assert!(
1263            msg.contains("90309999"),
1264            "expected captcha-wall hint, got: {msg}"
1265        );
1266        assert!(msg.contains("trust") || msg.contains("history"));
1267    }
1268
1269    #[test]
1270    fn other_error_codes_surface_generically() {
1271        let body = json!({
1272            "error": 44,
1273            "error_msg": "must login"
1274        });
1275        let err = parse_search_items(&body, "x", 0).unwrap_err();
1276        let msg = err.to_string();
1277        assert!(msg.contains("44"));
1278        assert!(msg.contains("must login"));
1279    }
1280
1281    #[test]
1282    fn missing_items_field_is_parse_error() {
1283        let body = json!({ "error": 0 });
1284        let err = parse_search_items(&body, "x", 0).unwrap_err();
1285        assert!(err.to_string().contains("missing"));
1286    }
1287
1288    // ── product detail ────────────────────────────────────────────────
1289
1290    /// `/api/v4/pdp/get_pc` shape — `{ data: { item: {...} } }`.
1291    fn detail_fixture_pdp() -> Value {
1292        json!({
1293            "error": 0,
1294            "data": {
1295                "item": {
1296                    "itemid": 12345,
1297                    "shopid": 67890,
1298                    "name": "iPhone 15 Pro 256GB 黑色",
1299                    "description": "All-new iPhone 15 Pro with titanium design.",
1300                    "price": 3999000000_u64,
1301                    "price_min": 3999000000_u64,
1302                    "price_max": 5499000000_u64,
1303                    "currency": "TWD",
1304                    "stock": 99,
1305                    "historical_sold": 100,
1306                    "liked_count": 250,
1307                    "shop_location": "新北市",
1308                    "image": "abc123",
1309                    "images": ["abc123", "def456", "ghi789"],
1310                    "item_rating": {
1311                        "rating_star": 4.85,
1312                        "rating_count": [123, 5, 10, 20, 30, 58]
1313                    },
1314                    "models": [
1315                        {
1316                            "modelid": 11111,
1317                            "name": "黑色,256GB",
1318                            "price": 3999000000_u64,
1319                            "stock": 50
1320                        },
1321                        {
1322                            "modelid": 22222,
1323                            "name": "白色,512GB",
1324                            "price": 4799000000_u64,
1325                            "stock": -1
1326                        }
1327                    ]
1328                }
1329            }
1330        })
1331    }
1332
1333    /// `/api/v4/item/get` legacy shape — flat `{ item: {...} }`.
1334    fn detail_fixture_legacy() -> Value {
1335        json!({
1336            "error": 0,
1337            "item": {
1338                "itemid": 999,
1339                "shopid": 888,
1340                "name": "Old item",
1341                "price": 1000000,
1342                "price_min": 1000000,
1343                "price_max": 1000000
1344            }
1345        })
1346    }
1347
1348    #[test]
1349    fn parses_pdp_wrapper_shape() {
1350        let r = parse_product_detail(&detail_fixture_pdp()).expect("parse");
1351        assert_eq!(r.itemid, 12345);
1352        assert_eq!(r.shopid, 67890);
1353        assert_eq!(r.name, "iPhone 15 Pro 256GB 黑色");
1354        assert_eq!(
1355            r.description.as_deref(),
1356            Some("All-new iPhone 15 Pro with titanium design.")
1357        );
1358        assert_eq!(r.price, 3_999_000_000);
1359        assert_eq!(r.currency.as_deref(), Some("TWD"));
1360        assert_eq!(r.stock, 99);
1361        assert!((r.rating_star - 4.85).abs() < 1e-6);
1362        assert_eq!(r.rating_count, 123);
1363    }
1364
1365    #[test]
1366    fn parses_image_gallery() {
1367        let r = parse_product_detail(&detail_fixture_pdp()).unwrap();
1368        assert_eq!(r.images, vec!["abc123", "def456", "ghi789"]);
1369        assert_eq!(r.image.as_deref(), Some("abc123"));
1370    }
1371
1372    #[test]
1373    fn item_images_with_only_null_elements_falls_through_to_fan_out() {
1374        // `data.item.images` exists but every element is `null` —
1375        // `read_images` should return None so the parser falls
1376        // through to `data.product_images.images`. (Without the
1377        // is_empty() check after filter_map, we'd return Some(vec![])
1378        // and never look at the fan-out.)
1379        let body = json!({
1380            "error": 0,
1381            "data": {
1382                "item": {
1383                    "itemid": 1,
1384                    "shopid": 2,
1385                    "name": "x",
1386                    "price": 100,
1387                    "price_min": 100,
1388                    "price_max": 100,
1389                    "images": [null, null]
1390                },
1391                "product_images": {
1392                    "images": [{ "image_id": "fallback_aaa" }]
1393                }
1394            }
1395        });
1396        let r = parse_product_detail(&body).unwrap();
1397        assert_eq!(r.images, vec!["fallback_aaa"]);
1398    }
1399
1400    #[test]
1401    fn parses_variant_models() {
1402        let r = parse_product_detail(&detail_fixture_pdp()).unwrap();
1403        assert_eq!(r.models.len(), 2);
1404        assert_eq!(r.models[0].modelid, 11111);
1405        assert_eq!(r.models[0].name, "黑色,256GB");
1406        assert_eq!(r.models[0].stock, 50);
1407        // Hidden-stock variant.
1408        assert_eq!(r.models[1].stock, -1);
1409        assert_eq!(r.models[1].price, 4_799_000_000);
1410    }
1411
1412    #[test]
1413    fn parses_legacy_wrapper_shape() {
1414        let r = parse_product_detail(&detail_fixture_legacy()).expect("parse legacy");
1415        assert_eq!(r.itemid, 999);
1416        assert_eq!(r.shopid, 888);
1417        assert_eq!(r.name, "Old item");
1418        // Detail-only fields default cleanly when missing.
1419        assert!(r.description.is_none());
1420        assert!(r.images.is_empty());
1421        assert!(r.models.is_empty());
1422    }
1423
1424    #[test]
1425    fn detail_captcha_wall_surfaces_distinct_error() {
1426        let body = json!({ "error": 90309999, "error_msg": "anti-bot" });
1427        let err = parse_product_detail(&body).unwrap_err();
1428        let msg = err.to_string();
1429        assert!(msg.contains("90309999"));
1430        assert!(msg.contains("trust") || msg.contains("history"));
1431    }
1432
1433    #[test]
1434    fn detail_item_not_found_surfaces_distinct_error() {
1435        let body = json!({ "error": 4, "error_msg": "item not found" });
1436        let err = parse_product_detail(&body).unwrap_err();
1437        let msg = err.to_string();
1438        assert!(msg.contains("not found"));
1439        assert!(msg.contains("removed") || msg.contains("itemid"));
1440    }
1441
1442    #[test]
1443    fn detail_missing_item_object_is_parse_error() {
1444        // Wrapper exists but no item-shaped object inside it.
1445        let body = json!({ "error": 0, "data": { "version": "x" } });
1446        let err = parse_product_detail(&body).unwrap_err();
1447        assert!(err.to_string().contains("missing item"));
1448    }
1449
1450    #[test]
1451    fn detail_falls_back_to_root_when_unwrapped() {
1452        // Some endpoints return the item directly at the root with
1453        // an `error` sibling — covered by the `body` candidate in
1454        // `unwrap_item`.
1455        let body = json!({
1456            "error": 0,
1457            "itemid": 7,
1458            "shopid": 8,
1459            "name": "root-shape",
1460            "price": 500000,
1461            "price_min": 500000,
1462            "price_max": 500000
1463        });
1464        let r = parse_product_detail(&body).unwrap();
1465        assert_eq!(r.itemid, 7);
1466        assert_eq!(r.name, "root-shape");
1467    }
1468
1469    /// Modern PDP shape — fields live directly on `data.item` with
1470    /// snake-case IDs (`item_id` / `shop_id`) and `title` instead of
1471    /// `name`. Real Shopee also fan-outs to `data.product_*` sibling
1472    /// objects (we pin the `product_review` / `shop_detailed` fall-
1473    /// backs in a separate test). Captured live from shopee.tw on
1474    /// 2026-04-30.
1475    fn detail_fixture_modern_pdp() -> Value {
1476        json!({
1477            "error": 0,
1478            "data": {
1479                "item": {
1480                    "item_id": 43968123553_u64,
1481                    "shop_id": 188277742,
1482                    "title": "Apple iPhone 17 Pro 256GB 手機",
1483                    "description": "Apple iPhone 17 Pro 256GB...",
1484                    "currency": "TWD",
1485                    "price": 3990000000_u64,
1486                    "price_min": 3990000000_u64,
1487                    "price_max": 3990000000_u64,
1488                    "stock": 50,
1489                    "historical_sold": 25,
1490                    "liked_count": 100,
1491                    "image": "img_hash_aaa",
1492                    "images": ["img_hash_aaa", "img_hash_bbb"],
1493                    "shop_location": "新北市",
1494                    "item_rating": {
1495                        "rating_star": 4.92,
1496                        "rating_count": [50, 1, 0, 2, 5, 42]
1497                    },
1498                    "models": [
1499                        {
1500                            "model_id": 296491431379_u64,
1501                            "name": "黑色,256GB",
1502                            "price": 3990000000_u64,
1503                            "stock": 50
1504                        }
1505                    ]
1506                }
1507            }
1508        })
1509    }
1510
1511    #[test]
1512    fn parses_modern_pdp_shape() {
1513        let r = parse_product_detail(&detail_fixture_modern_pdp()).expect("parse modern");
1514        // Snake-case IDs round-trip through the alias picker.
1515        assert_eq!(r.itemid, 43_968_123_553);
1516        assert_eq!(r.shopid, 188_277_742);
1517        // `title` field, not `name`.
1518        assert_eq!(r.name, "Apple iPhone 17 Pro 256GB 手機");
1519        assert_eq!(r.price, 3_990_000_000);
1520        assert_eq!(r.currency.as_deref(), Some("TWD"));
1521        assert_eq!(r.stock, 50);
1522        assert_eq!(r.historical_sold, 25);
1523        assert!((r.rating_star - 4.92).abs() < 1e-6);
1524        assert_eq!(r.rating_count, 50);
1525    }
1526
1527    #[test]
1528    fn modern_pdp_image_and_models_round_trip() {
1529        let r = parse_product_detail(&detail_fixture_modern_pdp()).unwrap();
1530        assert_eq!(r.images, vec!["img_hash_aaa", "img_hash_bbb"]);
1531        assert_eq!(r.image.as_deref(), Some("img_hash_aaa"));
1532        assert_eq!(r.models.len(), 1);
1533        assert_eq!(r.models[0].modelid, 296_491_431_379);
1534        assert_eq!(r.models[0].name, "黑色,256GB");
1535        assert_eq!(r.models[0].price, 3_990_000_000);
1536    }
1537
1538    #[test]
1539    fn modern_pdp_falls_back_to_product_review_when_item_rating_absent() {
1540        // Real Shopee fan-outs rating to data.product_review even
1541        // though data.item.item_rating exists; some sparse responses
1542        // (e.g. items with very few reviews) only populate the
1543        // sibling. Pin the fallback path.
1544        let mut body = detail_fixture_modern_pdp();
1545        body["data"]["item"]
1546            .as_object_mut()
1547            .unwrap()
1548            .remove("item_rating");
1549        body["data"]["product_review"] = json!({
1550            "rating_star": 4.5,
1551            "rating_count": [10, 0, 0, 1, 2, 7]
1552        });
1553        let r = parse_product_detail(&body).unwrap();
1554        assert!((r.rating_star - 4.5).abs() < 1e-6);
1555        assert_eq!(r.rating_count, 10);
1556    }
1557
1558    #[test]
1559    fn modern_pdp_falls_back_to_product_images_with_image_id() {
1560        // Modern fan-out: `data.product_images.images` carries
1561        // `[{image_id: "..."}]` objects (not bare strings) when
1562        // `data.item.images` is absent or empty.
1563        let mut body = detail_fixture_modern_pdp();
1564        body["data"]["item"]
1565            .as_object_mut()
1566            .unwrap()
1567            .remove("images");
1568        body["data"]["product_images"] = json!({
1569            "images": [
1570                { "image_id": "fan_aaa" },
1571                { "image_id": "fan_bbb" }
1572            ]
1573        });
1574        let r = parse_product_detail(&body).unwrap();
1575        assert_eq!(r.images, vec!["fan_aaa", "fan_bbb"]);
1576    }
1577
1578    // ── recommend / pdp_hot_sales ─────────────────────────────────
1579
1580    /// Fixture mirroring `/api/v4/recommend/product_detail_page` —
1581    /// `data.sections[0].units[]` with each unit shaped like a
1582    /// SearchItem (legacy `itemid`/`shopid`/`name`/`price`/...).
1583    /// Captured live from shopee.tw 2026-04-30.
1584    fn recommend_pdp_fixture() -> Value {
1585        json!({
1586            "error": null,
1587            "data": {
1588                "sections": [{
1589                    "key": "you_may_also_like",
1590                    "total": 600,
1591                    "units": [
1592                        {
1593                            "itemid": 28991283813_u64,
1594                            "shopid": 188277742,
1595                            "name": "iPhone 17 Pro 512GB",
1596                            "price": 4690000000_u64,
1597                            "currency": "TWD",
1598                            "stock": 1,
1599                            "image": "img_aaa",
1600                            "shop_location": "桃園市",
1601                            "item_rating": {
1602                                "rating_star": 4.989,
1603                                "rating_count": [553, 1, 0, 0, 2, 550]
1604                            }
1605                        },
1606                        {
1607                            "itemid": 99,
1608                            "shopid": 88,
1609                            "name": "iPhone 16",
1610                            "price": 2990000000_u64,
1611                            "stock": -1,
1612                            "image": "img_bbb"
1613                        }
1614                    ]
1615                }]
1616            }
1617        })
1618    }
1619
1620    #[test]
1621    fn parses_recommend_pdp_units() {
1622        let r = parse_recommend_pdp(&recommend_pdp_fixture());
1623        assert_eq!(r.len(), 2);
1624        assert_eq!(r[0].itemid, 28_991_283_813);
1625        assert_eq!(r[0].shopid, 188_277_742);
1626        assert_eq!(r[0].name, "iPhone 17 Pro 512GB");
1627        assert_eq!(r[0].price, 4_690_000_000);
1628        assert_eq!(r[0].rating_count, 553);
1629        assert!((r[0].rating_star - 4.989).abs() < 1e-3);
1630        // Hidden-stock variant.
1631        assert_eq!(r[1].stock, -1);
1632    }
1633
1634    #[test]
1635    fn empty_sections_yield_empty_recommendations() {
1636        let body = json!({ "data": { "sections": [] } });
1637        assert!(parse_recommend_pdp(&body).is_empty());
1638        // Also: missing `data.sections` is non-fatal — return empty.
1639        let body = json!({ "data": {} });
1640        assert!(parse_recommend_pdp(&body).is_empty());
1641    }
1642
1643    /// Fixture mirroring `/api/v4/pdp/hot_sales/get_item_cards` —
1644    /// fields split between `item_data` (IDs + rating) and
1645    /// `item_card_displayed_asset` (visual: name/image/price).
1646    /// Captured live from shopee.tw 2026-04-30.
1647    fn hot_sales_fixture() -> Value {
1648        json!({
1649            "error": null,
1650            "data": {
1651                "card_set": { "card_set_name": "FTSS TPFS card" },
1652                "item_cards": [
1653                    {
1654                        "item_data": {
1655                            "itemid": 40518550283_u64,
1656                            "shopid": 109729156,
1657                            "item_rating": {
1658                                "rating_star": 5.0,
1659                                "rating_count": [82, 0, 0, 0, 0, 82]
1660                            }
1661                        },
1662                        "item_card_displayed_asset": {
1663                            "name": "iPhone 17 Pro Max 256G 全新",
1664                            "image": "tw-hot-aaa",
1665                            "display_price": {
1666                                "price": 4697900000_u64,
1667                                "strikethrough_price": null
1668                            }
1669                        }
1670                    }
1671                ]
1672            }
1673        })
1674    }
1675
1676    #[test]
1677    fn parses_hot_sales_split_fields() {
1678        let r = parse_hot_sales(&hot_sales_fixture());
1679        assert_eq!(r.len(), 1);
1680        let c = &r[0];
1681        // IDs come from item_data; price from displayed_asset.
1682        assert_eq!(c.itemid, 40_518_550_283);
1683        assert_eq!(c.shopid, 109_729_156);
1684        assert_eq!(c.name, "iPhone 17 Pro Max 256G 全新");
1685        assert_eq!(c.image.as_deref(), Some("tw-hot-aaa"));
1686        assert_eq!(c.price, 4_697_900_000);
1687        // Rating from item_data.item_rating, total at index 0.
1688        assert_eq!(c.rating_count, 82);
1689        assert!((c.rating_star - 5.0).abs() < 1e-6);
1690    }
1691
1692    #[test]
1693    fn missing_item_cards_yields_empty_hot_sales() {
1694        let body = json!({ "data": {} });
1695        assert!(parse_hot_sales(&body).is_empty());
1696    }
1697
1698    #[test]
1699    fn combine_related_pairs_both_endpoints() {
1700        let r = combine_related(
1701            Some(&hot_sales_fixture()),
1702            Some(&recommend_pdp_fixture()),
1703            999,
1704            888,
1705        );
1706        assert_eq!(r.source_shopid, 999);
1707        assert_eq!(r.source_itemid, 888);
1708        assert_eq!(r.hot_sales.len(), 1);
1709        assert_eq!(r.recommended.len(), 2);
1710    }
1711
1712    #[test]
1713    fn combine_related_handles_one_side_missing() {
1714        // Real-world: a brand-new account often gets the hot_sales
1715        // bestsellers but not the personalised recommend feed.
1716        let r = combine_related(Some(&hot_sales_fixture()), None, 1, 2);
1717        assert_eq!(r.hot_sales.len(), 1);
1718        assert!(r.recommended.is_empty());
1719    }
1720
1721    // ── pagination ────────────────────────────────────────────────
1722
1723    #[test]
1724    fn search_results_carry_page_index() {
1725        let r = parse_search_items(&fixture(), "iPhone", 3).unwrap();
1726        // Page index round-trips so callers can verify they got the
1727        // page they asked for.
1728        assert_eq!(r.page, 3);
1729    }
1730
1731    // ── cart/mini ─────────────────────────────────────────────────
1732
1733    /// Fixture mirrors Shopee's `cart/mini` shape captured live
1734    /// 2026-04-30 (HAR `Downloads/shopee.tw.har`).
1735    fn cart_fixture() -> Value {
1736        json!({
1737            "error": 0,
1738            "error_msg": null,
1739            "data": {
1740                "total_cart_item_count": 22,
1741                "unique_cart_item_count": 20,
1742                "translation_status": 0,
1743                "recent_cart_item_details": [
1744                    {
1745                        "itemid": 44302564613_u64,
1746                        "shopid": 983412696,
1747                        "modelid": 227845841315_u64,
1748                        "bundle_deal_id": 0,
1749                        "is_add_on_sub_item": false,
1750                        "name": "COSTCO 好市多 Webber Naturals 甘胺酸鋅膠囊 240粒",
1751                        "price": 81900000,
1752                        "is_wholesale_price": false,
1753                        "status": 1,
1754                        "image": "tw-img-aaa",
1755                        "promotion_type": 0
1756                    },
1757                    {
1758                        "itemid": 12345,
1759                        "shopid": 678,
1760                        "modelid": 0,
1761                        "is_add_on_sub_item": true,
1762                        "name": "Add-on freebie",
1763                        "price": 0,
1764                        "status": 1,
1765                        "image": null
1766                    }
1767                ]
1768            }
1769        })
1770    }
1771
1772    #[test]
1773    fn parses_cart_mini_counts_and_items() {
1774        let r = parse_cart_mini(&cart_fixture()).expect("parse");
1775        assert_eq!(r.total_count, 22);
1776        assert_eq!(r.unique_count, 20);
1777        assert_eq!(r.recent_items.len(), 2);
1778        let it = &r.recent_items[0];
1779        assert_eq!(it.itemid, 44_302_564_613);
1780        assert_eq!(it.shopid, 983_412_696);
1781        assert_eq!(it.modelid, 227_845_841_315);
1782        assert_eq!(it.name, "COSTCO 好市多 Webber Naturals 甘胺酸鋅膠囊 240粒");
1783        assert_eq!(it.price, 81_900_000);
1784        assert_eq!(it.image.as_deref(), Some("tw-img-aaa"));
1785        assert_eq!(it.status, 1);
1786        assert!(!it.is_add_on_sub_item);
1787    }
1788
1789    #[test]
1790    fn parses_cart_add_on_sub_item_flag() {
1791        let r = parse_cart_mini(&cart_fixture()).unwrap();
1792        // Second fixture entry is an add-on freebie with price=0,
1793        // modelid=0, no image.
1794        let add_on = &r.recent_items[1];
1795        assert!(add_on.is_add_on_sub_item);
1796        assert_eq!(add_on.price, 0);
1797        assert_eq!(add_on.modelid, 0);
1798        assert!(add_on.image.is_none());
1799    }
1800
1801    #[test]
1802    fn empty_cart_yields_zero_counts_and_no_items() {
1803        // Logged-in user with empty cart — Shopee returns
1804        // `error: 0` + `data` with zero counts and an empty (or
1805        // missing) `recent_cart_item_details`.
1806        let body = json!({
1807            "error": 0,
1808            "data": {
1809                "total_cart_item_count": 0,
1810                "unique_cart_item_count": 0
1811            }
1812        });
1813        let r = parse_cart_mini(&body).unwrap();
1814        assert_eq!(r.total_count, 0);
1815        assert_eq!(r.unique_count, 0);
1816        assert!(r.recent_items.is_empty());
1817    }
1818
1819    #[test]
1820    fn cart_captcha_wall_surfaces_distinct_error() {
1821        let body = json!({
1822            "error": 90309999,
1823            "error_msg": "anti-bot triggered"
1824        });
1825        let err = parse_cart_mini(&body).unwrap_err();
1826        let msg = err.to_string();
1827        assert!(msg.contains("90309999"));
1828        assert!(msg.contains("trust") || msg.contains("warm"));
1829    }
1830
1831    #[test]
1832    fn cart_handles_null_error_field_as_success() {
1833        // Shopee sometimes serializes `error` as JSON null on
1834        // success (matches the recommend_v2 pattern). Should be
1835        // treated as `0`.
1836        let body = json!({
1837            "error": null,
1838            "data": {
1839                "total_cart_item_count": 1,
1840                "unique_cart_item_count": 1,
1841                "recent_cart_item_details": []
1842            }
1843        });
1844        let r = parse_cart_mini(&body).unwrap();
1845        assert_eq!(r.total_count, 1);
1846    }
1847
1848    #[test]
1849    fn cart_missing_data_is_parse_error() {
1850        let body = json!({ "error": 0 });
1851        let err = parse_cart_mini(&body).unwrap_err();
1852        assert!(err.to_string().contains("missing `data`"));
1853    }
1854
1855    // ── discover (daily_discover / flash_sale / mall_shops) ───────
1856
1857    /// `daily_discover` fixture mirrors the homepage feed shape
1858    /// captured live 2026-04-30. Each feed wraps a
1859    /// `centralised_item_card` that reuses the same split
1860    /// `item_data` + `item_card_displayed_asset` layout as
1861    /// `pdp/hot_sales` (so `parse_one_hot_sale_card` does the work).
1862    fn daily_discover_fixture() -> Value {
1863        json!({
1864            "error": null,
1865            "data": {
1866                "feed_total": 500,
1867                "feeds": [
1868                    {
1869                        "type": "centralised_item_card",
1870                        "centralised_item_card": {
1871                            "item_data": {
1872                                "itemid": 1001_u64,
1873                                "shopid": 2001_u64,
1874                                "item_rating": {
1875                                    "rating_star": 4.7,
1876                                    "rating_count": [42, 0, 0, 1, 5, 36]
1877                                }
1878                            },
1879                            "item_card_displayed_asset": {
1880                                "name": "discover-feed-aaa",
1881                                "image": "tw-feed-aaa",
1882                                "display_price": { "price": 199000000_u64 }
1883                            }
1884                        }
1885                    },
1886                    {
1887                        "type": "centralised_item_card",
1888                        "centralised_item_card": {
1889                            "item_data": { "itemid": 1002, "shopid": 2002 },
1890                            "item_card_displayed_asset": {
1891                                "name": "discover-feed-bbb",
1892                                "image": "tw-feed-bbb",
1893                                "display_price": { "price": 99000000_u64 }
1894                            }
1895                        }
1896                    }
1897                ]
1898            }
1899        })
1900    }
1901
1902    #[test]
1903    fn parses_daily_discover_feeds_via_hot_sale_layout() {
1904        let (feeds, total) = parse_daily_discover(&daily_discover_fixture());
1905        assert_eq!(total, 500);
1906        assert_eq!(feeds.len(), 2);
1907        assert_eq!(feeds[0].itemid, 1001);
1908        assert_eq!(feeds[0].name, "discover-feed-aaa");
1909        assert_eq!(feeds[0].price, 199_000_000);
1910        assert!((feeds[0].rating_star - 4.7).abs() < 1e-3);
1911        assert_eq!(feeds[0].rating_count, 42);
1912    }
1913
1914    #[test]
1915    fn daily_discover_skips_non_centralised_card_feeds() {
1916        // If a future Shopee response slips a different feed type
1917        // into the array (e.g. an ad card without item_data), the
1918        // parser should silently skip it rather than panic.
1919        let body = json!({
1920            "data": {
1921                "feed_total": 100,
1922                "feeds": [
1923                    { "type": "ads_item_card", "ads_item_card": { "id": 999 } },
1924                    {
1925                        "type": "centralised_item_card",
1926                        "centralised_item_card": {
1927                            "item_data": { "itemid": 7, "shopid": 8 },
1928                            "item_card_displayed_asset": {
1929                                "name": "real-item",
1930                                "display_price": { "price": 50000000_u64 }
1931                            }
1932                        }
1933                    }
1934                ]
1935            }
1936        });
1937        let (feeds, _total) = parse_daily_discover(&body);
1938        assert_eq!(feeds.len(), 1);
1939        assert_eq!(feeds[0].name, "real-item");
1940    }
1941
1942    #[test]
1943    fn daily_discover_skips_centralised_card_without_item_data() {
1944        // Defensive guard: a `centralised_item_card` whose `item_data`
1945        // is missing (Shopee wire rotation, partial response, …)
1946        // would otherwise produce a zombie `RecommendedItem` with
1947        // itemid=0/name=""/price=0. The parser must filter these out
1948        // before they reach typed callers.
1949        let body = json!({
1950            "data": {
1951                "feed_total": 50,
1952                "feeds": [
1953                    {
1954                        "type": "centralised_item_card",
1955                        "centralised_item_card": {
1956                            // No `item_data` — only the visual asset.
1957                            "item_card_displayed_asset": {
1958                                "name": "orphan-no-item-data",
1959                                "display_price": { "price": 1000000_u64 }
1960                            }
1961                        }
1962                    },
1963                    {
1964                        "type": "centralised_item_card",
1965                        "centralised_item_card": {
1966                            "item_data": { "itemid": 9, "shopid": 10 },
1967                            "item_card_displayed_asset": {
1968                                "name": "real-item",
1969                                "display_price": { "price": 50000000_u64 }
1970                            }
1971                        }
1972                    }
1973                ]
1974            }
1975        });
1976        let (feeds, _) = parse_daily_discover(&body);
1977        // Zombie filtered out — only the well-formed entry remains.
1978        assert_eq!(feeds.len(), 1);
1979        assert_eq!(feeds[0].itemid, 9);
1980        assert_eq!(feeds[0].name, "real-item");
1981    }
1982
1983    fn flash_sale_fixture() -> Value {
1984        json!({
1985            "error": 0,
1986            "data": {
1987                "items": [
1988                    {
1989                        "itemid": 9001_u64,
1990                        "shopid": 8001_u64,
1991                        "name": "P&G ARIEL 4D炭酸洗衣膠球",
1992                        "price": 18300000_u64,
1993                        "raw_discount": 69,
1994                        "end_time": 1777521600,
1995                        "stock": 692,
1996                        "image": "https://mms.img.susercontent.com/full-url-aaa",
1997                        "promotionid": 245737892360192_u64
1998                    }
1999                ]
2000            }
2001        })
2002    }
2003
2004    #[test]
2005    fn parses_flash_sale_items() {
2006        let r = parse_flash_sale_items(&flash_sale_fixture());
2007        assert_eq!(r.len(), 1);
2008        let it = &r[0];
2009        assert_eq!(it.itemid, 9001);
2010        assert_eq!(it.shopid, 8001);
2011        assert_eq!(it.price, 18_300_000);
2012        assert_eq!(it.raw_discount, 69);
2013        assert_eq!(it.end_time, 1_777_521_600);
2014        assert_eq!(it.stock, 692);
2015        // Flash-sale image is a full URL, not a hash.
2016        assert!(it.image.as_deref().unwrap().starts_with("https://"));
2017        assert_eq!(it.promotionid, 245_737_892_360_192);
2018    }
2019
2020    fn mall_shops_fixture() -> Value {
2021        json!({
2022            "error": null,
2023            "data": {
2024                "shops": [
2025                    {
2026                        "shopid": 23047686,
2027                        "url": "https://shopee.tw/cookingstar",
2028                        "image": "tw-shop-aaa",
2029                        "promo_text": "無門檻8折券"
2030                    },
2031                    {
2032                        "shopid": 26221748,
2033                        "url": "https://shopee.tw/tokuyo",
2034                        "image": null,
2035                        "promo_text": null
2036                    }
2037                ]
2038            }
2039        })
2040    }
2041
2042    #[test]
2043    fn parses_mall_shops() {
2044        let r = parse_mall_shops(&mall_shops_fixture());
2045        assert_eq!(r.len(), 2);
2046        assert_eq!(r[0].shopid, 23_047_686);
2047        assert_eq!(r[0].url, "https://shopee.tw/cookingstar");
2048        assert_eq!(r[0].image.as_deref(), Some("tw-shop-aaa"));
2049        assert_eq!(r[0].promo_text.as_deref(), Some("無門檻8折券"));
2050        // Second shop has no image / promo — both default to None.
2051        assert!(r[1].image.is_none());
2052        assert!(r[1].promo_text.is_none());
2053    }
2054
2055    #[test]
2056    fn combine_discover_pairs_all_three() {
2057        let d = combine_discover(
2058            Some(&daily_discover_fixture()),
2059            Some(&flash_sale_fixture()),
2060            Some(&mall_shops_fixture()),
2061        );
2062        assert_eq!(d.feeds.len(), 2);
2063        assert_eq!(d.feed_total, 500);
2064        assert_eq!(d.flash_sale.len(), 1);
2065        assert_eq!(d.mall_shops.len(), 2);
2066    }
2067
2068    #[test]
2069    fn combine_discover_handles_missing_endpoints() {
2070        // Real-world: a low-trust profile or off-peak hours can
2071        // legitimately see flash-sale module hidden entirely while
2072        // the personalised feed still loads.
2073        let d = combine_discover(Some(&daily_discover_fixture()), None, None);
2074        assert_eq!(d.feeds.len(), 2);
2075        assert!(d.flash_sale.is_empty());
2076        assert!(d.mall_shops.is_empty());
2077    }
2078
2079    #[test]
2080    fn missing_top_level_keys_yield_empty_collections() {
2081        // Each parser is non-fatal on missing wrappers — a
2082        // misshapen response shouldn't bring down the whole call.
2083        let empty = json!({});
2084        let (feeds, total) = parse_daily_discover(&empty);
2085        assert!(feeds.is_empty());
2086        assert_eq!(total, 0);
2087        assert!(parse_flash_sale_items(&empty).is_empty());
2088        assert!(parse_mall_shops(&empty).is_empty());
2089        assert!(parse_category_tree(&empty).is_empty());
2090    }
2091
2092    // ── category_tree ─────────────────────────────────────────────
2093
2094    /// Fixture mirroring `/api/v4/pages/get_homepage_category_list`
2095    /// — captured live from shopee.tw 2026-04-30 (24 top-level
2096    /// categories, each `level: 1`, `parent_catid: 0`, all
2097    /// `children: null`).
2098    fn category_tree_fixture() -> Value {
2099        json!({
2100            "data": {
2101                "category_list": [
2102                    {
2103                        "catid": 11040766,
2104                        "parent_catid": 0,
2105                        "name": "Women's Apparel",
2106                        "display_name": "女生衣著",
2107                        "image": "17f3879a1872099681d7b85101e187db",
2108                        "level": 1,
2109                        "children": null
2110                    },
2111                    {
2112                        "catid": 11041120,
2113                        "parent_catid": 0,
2114                        "name": "Books & Magazines",
2115                        "display_name": "書籍及雜誌",
2116                        "image": "abc123",
2117                        "level": 1,
2118                        "children": null
2119                    }
2120                ]
2121            }
2122        })
2123    }
2124
2125    #[test]
2126    fn parses_category_tree_top_level() {
2127        let r = parse_category_tree(&category_tree_fixture());
2128        assert_eq!(r.len(), 2);
2129        assert_eq!(r[0].catid, 11_040_766);
2130        assert_eq!(r[0].parent_catid, 0);
2131        assert_eq!(r[0].name, "Women's Apparel");
2132        assert_eq!(r[0].display_name, "女生衣著");
2133        assert_eq!(
2134            r[0].image.as_deref(),
2135            Some("17f3879a1872099681d7b85101e187db")
2136        );
2137        assert_eq!(r[0].level, 1);
2138        // `children: null` → empty Vec, not panic.
2139        assert!(r[0].children.is_empty());
2140    }
2141
2142    #[test]
2143    fn category_tree_recursive_children_are_parsed() {
2144        // Future-proof: if a future drill-down endpoint populates
2145        // `children`, the recursive parser should walk them. Pin
2146        // the recursive shape here even though no current Shopee
2147        // endpoint sends nested children.
2148        let body = json!({
2149            "data": {
2150                "category_list": [{
2151                    "catid": 1,
2152                    "parent_catid": 0,
2153                    "name": "parent",
2154                    "display_name": "父",
2155                    "level": 1,
2156                    "children": [
2157                        {
2158                            "catid": 11,
2159                            "parent_catid": 1,
2160                            "name": "child-a",
2161                            "display_name": "子A",
2162                            "level": 2,
2163                            "children": null
2164                        },
2165                        {
2166                            "catid": 12,
2167                            "parent_catid": 1,
2168                            "name": "child-b",
2169                            "display_name": "子B",
2170                            "level": 2,
2171                            "children": [{
2172                                "catid": 121,
2173                                "parent_catid": 12,
2174                                "name": "grandchild",
2175                                "display_name": "孫",
2176                                "level": 3,
2177                                "children": null
2178                            }]
2179                        }
2180                    ]
2181                }]
2182            }
2183        });
2184        let r = parse_category_tree(&body);
2185        assert_eq!(r.len(), 1);
2186        assert_eq!(r[0].children.len(), 2);
2187        assert_eq!(r[0].children[0].catid, 11);
2188        assert_eq!(r[0].children[0].level, 2);
2189        assert_eq!(r[0].children[1].children.len(), 1);
2190        assert_eq!(r[0].children[1].children[0].catid, 121);
2191        assert_eq!(r[0].children[1].children[0].level, 3);
2192    }
2193
2194    #[test]
2195    fn missing_category_list_yields_empty() {
2196        let body = json!({ "data": {} });
2197        assert!(parse_category_tree(&body).is_empty());
2198    }
2199
2200    #[test]
2201    fn category_list_with_non_array_value_degrades_gracefully() {
2202        // Shopee occasionally serialises endpoints' top-level
2203        // arrays as `null` (or rarely as a string) during partial-
2204        // outage A/B tests. Pin that the parser degrades to an
2205        // empty Vec in those cases instead of panicking.
2206        let null_body = json!({ "data": { "category_list": null } });
2207        assert!(parse_category_tree(&null_body).is_empty());
2208
2209        let string_body = json!({ "data": { "category_list": "oops" } });
2210        assert!(parse_category_tree(&string_body).is_empty());
2211
2212        let object_body = json!({ "data": { "category_list": { "wrong": "shape" } } });
2213        assert!(parse_category_tree(&object_body).is_empty());
2214    }
2215
2216    // ── fe_category_detail ────────────────────────────────────────
2217
2218    /// Fixture mirroring `/api/v4/search/get_fe_category_detail` for
2219    /// a sub-category (`level: 2`, parent_cat_id as array,
2220    /// display_name as locale array). Captured live from shopee.tw
2221    /// 2026-04-30 (catid 11042305 = "長褲" / "Pants" under
2222    /// "Women's Apparel" 11040766).
2223    fn fe_category_detail_subcategory_fixture() -> Value {
2224        json!({
2225            "data": {
2226                "categories": [
2227                    {
2228                        "catid": 11042305,
2229                        "parent_cat_id": [11040766],
2230                        "name": "Pants",
2231                        "display_name": [
2232                            { "lang": "zh-Hant", "value": "長褲", "is_default": true },
2233                            { "lang": "zh-Hans", "value": "Pants", "is_default": false },
2234                            { "lang": "en", "value": "Pants", "is_default": false }
2235                        ],
2236                        "image": "tw-11134258-7rbkc-m8ej6wv3w0yvd7",
2237                        "level": 2,
2238                        "block_buyer_platform": null
2239                    }
2240                ]
2241            }
2242        })
2243    }
2244
2245    #[test]
2246    fn fe_category_detail_subcategory_parses() {
2247        let cat = parse_fe_category_detail(&fe_category_detail_subcategory_fixture())
2248            .expect("subcategory fixture parses to Some");
2249        assert_eq!(cat.catid, 11_042_305);
2250        assert_eq!(cat.name, "Pants");
2251        assert_eq!(cat.display_name, "長褲");
2252        assert_eq!(cat.level, 2);
2253        assert_eq!(cat.parent_cat_id, Some(11_040_766));
2254        assert_eq!(
2255            cat.image.as_deref(),
2256            Some("tw-11134258-7rbkc-m8ej6wv3w0yvd7")
2257        );
2258    }
2259
2260    #[test]
2261    fn fe_category_detail_top_level_has_no_parent() {
2262        // Top-level category: `parent_cat_id: null`, `level: 1`.
2263        // Captured live from shopee.tw 2026-04-30 (catid 11040766
2264        // = "Women's Apparel").
2265        let body = json!({
2266            "data": {
2267                "categories": [{
2268                    "catid": 11040766,
2269                    "parent_cat_id": null,
2270                    "name": "Women's Apparel",
2271                    "display_name": [
2272                        { "lang": "zh-Hant", "value": "女生衣著", "is_default": true }
2273                    ],
2274                    "image": "17f3879a1872099681d7b85101e187db",
2275                    "level": 1,
2276                    "block_buyer_platform": null
2277                }]
2278            }
2279        });
2280        let cat = parse_fe_category_detail(&body).expect("top-level fixture parses");
2281        assert_eq!(cat.catid, 11_040_766);
2282        assert_eq!(cat.name, "Women's Apparel");
2283        assert_eq!(cat.display_name, "女生衣著");
2284        assert_eq!(cat.level, 1);
2285        assert!(cat.parent_cat_id.is_none(), "top-level has no parent");
2286    }
2287
2288    #[test]
2289    fn fe_category_detail_accepts_scalar_parent_cat_id() {
2290        // Defensive: if Shopee ever ships scalar `parent_cat_id`
2291        // (matching `parse_category_tree`'s wire shape), the
2292        // parser still flattens correctly. Pin the contract.
2293        let body = json!({
2294            "data": {
2295                "categories": [{
2296                    "catid": 999,
2297                    "parent_cat_id": 100,
2298                    "name": "scalar",
2299                    "display_name": [{ "lang": "zh-Hant", "value": "X", "is_default": true }],
2300                    "level": 2
2301                }]
2302            }
2303        });
2304        let cat = parse_fe_category_detail(&body).expect("scalar parent fixture parses");
2305        assert_eq!(cat.parent_cat_id, Some(100));
2306    }
2307
2308    #[test]
2309    fn fe_category_detail_picks_first_when_no_default_locale() {
2310        // If no locale entry has `is_default: true`, fall back to
2311        // the first entry's value rather than empty-string.
2312        let body = json!({
2313            "data": {
2314                "categories": [{
2315                    "catid": 1,
2316                    "parent_cat_id": null,
2317                    "name": "n",
2318                    "display_name": [
2319                        { "lang": "en", "value": "fallback", "is_default": false }
2320                    ],
2321                    "level": 1
2322                }]
2323            }
2324        });
2325        let cat = parse_fe_category_detail(&body).expect("fallback fixture parses");
2326        assert_eq!(cat.display_name, "fallback");
2327    }
2328
2329    #[test]
2330    fn fe_category_detail_malformed_default_returns_none_not_other_locale() {
2331        // Defensive: if the `is_default: true` entry has a missing
2332        // or non-string `value`, return None rather than silently
2333        // falling through to a different locale's value (locale
2334        // flip would be worse than empty).
2335        let body = json!({
2336            "data": {
2337                "categories": [{
2338                    "catid": 1,
2339                    "parent_cat_id": null,
2340                    "name": "n",
2341                    "display_name": [
2342                        // Default flag set, value missing
2343                        { "lang": "zh-Hant", "is_default": true },
2344                        { "lang": "en", "value": "WRONG_LOCALE", "is_default": false }
2345                    ],
2346                    "level": 1
2347                }]
2348            }
2349        });
2350        let cat = parse_fe_category_detail(&body).expect("malformed-default fixture parses");
2351        // Critically: NOT "WRONG_LOCALE" — defensive ordering
2352        // returns empty string for missing-value-on-default.
2353        assert_eq!(cat.display_name, "");
2354    }
2355
2356    #[test]
2357    fn fe_category_detail_missing_data_yields_none() {
2358        // Empty wrapper / missing categories → None, not a panic.
2359        // Caller is expected to degrade `CategoryPage::category` to
2360        // None rather than hard-fail (items list still load-bearing).
2361        assert!(parse_fe_category_detail(&json!({})).is_none());
2362        assert!(parse_fe_category_detail(&json!({ "data": {} })).is_none());
2363        assert!(parse_fe_category_detail(&json!({ "data": { "categories": [] } })).is_none());
2364        assert!(parse_fe_category_detail(&json!({ "data": { "categories": null } })).is_none());
2365    }
2366
2367    // ── shop_info ─────────────────────────────────────────────────
2368
2369    /// Fixture mirroring `/api/v4/promotion/get_shop_info` captured
2370    /// live 2026-04-30 (miko 米可 — a real shopee.tw shop).
2371    fn shop_info_fixture() -> Value {
2372        json!({
2373            "error": 0,
2374            "error_msg": null,
2375            "data": {
2376                "shop_id": 1530245671_u64,
2377                "user_id": 1531065367_u64,
2378                "last_active_time": 1777515787,
2379                "holiday_mode": false,
2380                "place": "700 臺南市中西區和意路68號",
2381                "is_shopee_verified": false,
2382                "is_official_shop": true,
2383                "item_count": 400,
2384                "rating_star": 4.987321002386635,
2385                "response_rate": 50,
2386                "name": "miko 米可|手機門號配件專賣",
2387                "ctime": 1745843453,
2388                "response_time": 0,
2389                "follower_count": 10539,
2390                "rating_bad": 2,
2391                "rating_good": 6964,
2392                "rating_normal": 14
2393            }
2394        })
2395    }
2396
2397    #[test]
2398    fn parses_shop_info_full_fields() {
2399        let r = parse_shop_info(&shop_info_fixture()).expect("parse");
2400        assert_eq!(r.shop_id, 1_530_245_671);
2401        assert_eq!(r.user_id, 1_531_065_367);
2402        assert_eq!(r.name, "miko 米可|手機門號配件專賣");
2403        assert_eq!(r.place.as_deref(), Some("700 臺南市中西區和意路68號"));
2404        assert!(r.is_official_shop);
2405        assert!(!r.is_shopee_verified);
2406        assert!(!r.holiday_mode);
2407        assert_eq!(r.item_count, 400);
2408        assert_eq!(r.follower_count, 10_539);
2409        assert!((r.rating_star - 4.987_321).abs() < 1e-3);
2410        assert_eq!(r.rating_good, 6964);
2411        assert_eq!(r.rating_normal, 14);
2412        assert_eq!(r.rating_bad, 2);
2413        assert_eq!(r.response_rate, 50);
2414        assert_eq!(r.ctime, 1_745_843_453);
2415    }
2416
2417    #[test]
2418    fn shop_info_captcha_wall_surfaces_distinct_error() {
2419        let body = json!({ "error": 90309999, "error_msg": "anti-bot" });
2420        let err = parse_shop_info(&body).unwrap_err();
2421        let msg = err.to_string();
2422        assert!(msg.contains("90309999"));
2423        assert!(msg.contains("trust") || msg.contains("Profile"));
2424    }
2425
2426    #[test]
2427    fn shop_info_missing_data_is_parse_error() {
2428        let body = json!({ "error": 0 });
2429        let err = parse_shop_info(&body).unwrap_err();
2430        assert!(err.to_string().contains("missing `data`"));
2431    }
2432
2433    #[test]
2434    fn shop_info_sparse_response_defaults_cleanly() {
2435        // Brand-new shop with zero activity — most fields default
2436        // to 0/false/empty rather than fail to parse.
2437        let body = json!({
2438            "error": 0,
2439            "data": {
2440                "shop_id": 999,
2441                "name": "new-shop",
2442                "rating_star": 0.0
2443            }
2444        });
2445        let r = parse_shop_info(&body).unwrap();
2446        assert_eq!(r.shop_id, 999);
2447        assert_eq!(r.name, "new-shop");
2448        assert_eq!(r.item_count, 0);
2449        assert_eq!(r.follower_count, 0);
2450        assert!(!r.is_official_shop);
2451        assert!(r.place.is_none());
2452    }
2453
2454    // ── reviews ───────────────────────────────────────────────────
2455
2456    fn reviews_fixture() -> Value {
2457        json!({
2458            "error": 0,
2459            "data": {
2460                "ratings": [
2461                    {
2462                        "cmtid": 96399040702_u64,
2463                        "itemid": 42818537019_u64,
2464                        "shopid": 109729156,
2465                        "rating_star": 5,
2466                        "comment": "",
2467                        "ctime": 1776650189,
2468                        "author_username": "k*****s",
2469                        "anonymous": true
2470                    },
2471                    {
2472                        "cmtid": 96399040703_u64,
2473                        "itemid": 42818537019_u64,
2474                        "shopid": 109729156,
2475                        "rating_star": 4,
2476                        "comment": "包裝完整,送達快速",
2477                        "ctime": 1776650200,
2478                        "author_username": "alice123",
2479                        "anonymous": false,
2480                        "images": ["tw-img-rev-aaa", "tw-img-rev-bbb"]
2481                    }
2482                ],
2483                "item_rating_star": 4.5,
2484                "item_rating_count": 6,
2485                "has_more": true
2486            }
2487        })
2488    }
2489
2490    #[test]
2491    fn parses_reviews_with_summary_and_pagination_flag() {
2492        let r = parse_reviews(&reviews_fixture(), 109_729_156, 42_818_537_019).expect("parse");
2493        // shopid / itemid plumbed from caller (Shopee doesn't
2494        // always echo them in the wrapper).
2495        assert_eq!(r.shopid, 109_729_156);
2496        assert_eq!(r.itemid, 42_818_537_019);
2497        assert!((r.item_rating_star - 4.5).abs() < 1e-6);
2498        assert_eq!(r.item_rating_count, 6);
2499        assert!(r.has_more);
2500        assert_eq!(r.ratings.len(), 2);
2501    }
2502
2503    #[test]
2504    fn reviews_first_entry_anonymous_no_text_no_images() {
2505        let r = parse_reviews(&reviews_fixture(), 109_729_156, 42_818_537_019).unwrap();
2506        let rev = &r.ratings[0];
2507        assert_eq!(rev.cmtid, 96_399_040_702);
2508        assert_eq!(rev.rating_star, 5);
2509        assert!(rev.comment.is_empty());
2510        assert!(rev.anonymous);
2511        assert!(rev.images.is_empty());
2512        assert_eq!(rev.author_username, "k*****s");
2513    }
2514
2515    #[test]
2516    fn reviews_second_entry_with_text_and_images() {
2517        let r = parse_reviews(&reviews_fixture(), 109_729_156, 42_818_537_019).unwrap();
2518        let rev = &r.ratings[1];
2519        assert_eq!(rev.rating_star, 4);
2520        assert_eq!(rev.comment, "包裝完整,送達快速");
2521        assert!(!rev.anonymous);
2522        assert_eq!(rev.images, vec!["tw-img-rev-aaa", "tw-img-rev-bbb"]);
2523    }
2524
2525    #[test]
2526    fn reviews_captcha_wall_surfaces_distinct_error() {
2527        let body = json!({ "error": 90309999, "error_msg": "anti-bot" });
2528        let err = parse_reviews(&body, 1, 2).unwrap_err();
2529        let msg = err.to_string();
2530        assert!(msg.contains("90309999"));
2531    }
2532
2533    #[test]
2534    fn reviews_empty_ratings_is_valid() {
2535        let body = json!({
2536            "error": 0,
2537            "data": {
2538                "ratings": [],
2539                "item_rating_star": 0.0,
2540                "item_rating_count": 0,
2541                "has_more": false
2542            }
2543        });
2544        let r = parse_reviews(&body, 1, 2).unwrap();
2545        assert!(r.ratings.is_empty());
2546        assert_eq!(r.item_rating_count, 0);
2547        assert!(!r.has_more);
2548    }
2549
2550    // ── shop_items ────────────────────────────────────────────────
2551
2552    /// Fixture mirroring `/api/v4/shop/search_items` captured live
2553    /// 2026-04-30 (chifonglin shopid=355141, basketball listing).
2554    fn shop_items_fixture() -> Value {
2555        json!({
2556            "error": 0,
2557            "error_msg": "",
2558            "total_count": 28,
2559            "nomore": true,
2560            "centralize_item_card": {
2561                "item_cards": [
2562                    {
2563                        "itemid": 1449269277_u64,
2564                        "shopid": 355141,
2565                        "is_sold_out": false,
2566                        "item_rating": {
2567                            "rating_star": 4.934240362811791,
2568                            "rating_count": [882, 2, 2, 12, 20, 846]
2569                        },
2570                        "liked_count": 999,
2571                        "item_card_display_price": {
2572                            "price": 103100000_u64
2573                        },
2574                        "item_card_displayed_asset": {
2575                            "name": "Wilson 籃球 R68",
2576                            "image": "tw-img-shop-aaa",
2577                            "display_price": { "price": 103100000_u64 },
2578                            "sold_count": { "text": "已售出 1000+" }
2579                        }
2580                    },
2581                    {
2582                        "itemid": 9999_u64,
2583                        "shopid": 355141,
2584                        "is_sold_out": true,
2585                        "item_card_display_price": { "price": 50000000_u64 },
2586                        "item_card_displayed_asset": {
2587                            "name": "OOS item",
2588                            "image": "tw-img-shop-bbb",
2589                            "display_price": { "price": 50000000_u64 }
2590                        }
2591                    }
2592                ]
2593            }
2594        })
2595    }
2596
2597    #[test]
2598    fn parses_shop_items_with_pagination_metadata() {
2599        let r = parse_shop_items(&shop_items_fixture(), 355141, 0).expect("parse");
2600        assert_eq!(r.shopid, 355141);
2601        assert_eq!(r.page, 0);
2602        assert_eq!(r.total_count, 28);
2603        assert!(r.nomore);
2604        assert_eq!(r.items.len(), 2);
2605    }
2606
2607    #[test]
2608    fn shop_items_first_card_has_full_fields() {
2609        let r = parse_shop_items(&shop_items_fixture(), 355141, 0).unwrap();
2610        let it = &r.items[0];
2611        // IDs come from the top-level (NOT `item_data` like
2612        // pdp/hot_sales).
2613        assert_eq!(it.itemid, 1_449_269_277);
2614        assert_eq!(it.shopid, 355141);
2615        // Visual fields come from item_card_displayed_asset.
2616        assert_eq!(it.name, "Wilson 籃球 R68");
2617        assert_eq!(it.image.as_deref(), Some("tw-img-shop-aaa"));
2618        // Price from `display_price.price` inside asset.
2619        assert_eq!(it.price, 103_100_000);
2620        // Rating from top-level `item_rating`.
2621        assert!((it.rating_star - 4.934_240).abs() < 1e-3);
2622        assert_eq!(it.rating_count, 882);
2623        // shop-listing endpoints don't ship stock counts — every
2624        // item gets stock=-1 (hidden, per RecommendedItem.stock's
2625        // documented contract). Availability lives on `is_sold_out`.
2626        assert_eq!(it.stock, -1);
2627        assert!(!it.is_sold_out);
2628    }
2629
2630    #[test]
2631    fn shop_items_sold_out_card_sets_is_sold_out_flag() {
2632        let r = parse_shop_items(&shop_items_fixture(), 355141, 0).unwrap();
2633        let oos = &r.items[1];
2634        assert!(oos.name.contains("OOS"));
2635        // Sold-out items still get stock=-1 (hidden — we don't
2636        // know the count); the boolean flag is the load-bearing
2637        // signal.
2638        assert_eq!(oos.stock, -1);
2639        assert!(oos.is_sold_out);
2640    }
2641
2642    #[test]
2643    fn shop_items_page_index_round_trips() {
2644        let r = parse_shop_items(&shop_items_fixture(), 355141, 3).unwrap();
2645        assert_eq!(r.page, 3);
2646    }
2647
2648    #[test]
2649    fn shop_items_captcha_wall_surfaces_distinct_error() {
2650        let body = json!({ "error": 90309999, "error_msg": "anti-bot" });
2651        let err = parse_shop_items(&body, 1, 0).unwrap_err();
2652        assert!(err.to_string().contains("90309999"));
2653    }
2654
2655    #[test]
2656    fn shop_items_missing_centralize_item_card_yields_empty() {
2657        // Defensive — a sparse response still parses cleanly.
2658        let body = json!({ "error": 0, "total_count": 0, "nomore": true });
2659        let r = parse_shop_items(&body, 1, 0).unwrap();
2660        assert!(r.items.is_empty());
2661        assert!(r.nomore);
2662    }
2663
2664    /// Pin the `rcmd_items` wrapped-shape parsing path. The other
2665    /// shop_items tests use the flat `search_items` shape; this
2666    /// one exercises the `data`-wrapped path that fires on
2667    /// `/shop/{shopid}` (which is what the live test always hits).
2668    fn shop_items_rcmd_fixture() -> Value {
2669        json!({
2670            "error": 0,
2671            "data": {
2672                "total": 1553,
2673                "no_more": false,
2674                "centralize_item_card": {
2675                    "item_cards": [
2676                        {
2677                            "itemid": 71204627_u64,
2678                            "shopid": 355141,
2679                            "is_sold_out": false,
2680                            "item_rating": {
2681                                "rating_star": 4.8,
2682                                "rating_count": [50, 0, 1, 2, 5, 42]
2683                            },
2684                            "item_card_displayed_asset": {
2685                                "name": "rcmd-item-aaa",
2686                                "image": "tw-img-rcmd-aaa",
2687                                "display_price": { "price": 75000000_u64 }
2688                            }
2689                        }
2690                    ]
2691                }
2692            }
2693        })
2694    }
2695
2696    #[test]
2697    fn parses_rcmd_items_wrapped_shape() {
2698        // `data.total` and `data.no_more` (note underscore vs
2699        // `nomore`) need to be picked from the wrapped path — if
2700        // the parser regressed to only checking the flat shape
2701        // these would default to 0 / false.
2702        let r = parse_shop_items(&shop_items_rcmd_fixture(), 355141, 0).expect("parse rcmd");
2703        assert_eq!(r.shopid, 355141);
2704        assert_eq!(r.total_count, 1553);
2705        assert!(!r.nomore); // no_more=false → nomore=false
2706        assert_eq!(r.items.len(), 1);
2707        let it = &r.items[0];
2708        assert_eq!(it.itemid, 71_204_627);
2709        assert_eq!(it.name, "rcmd-item-aaa");
2710        assert_eq!(it.price, 75_000_000);
2711        assert_eq!(it.stock, -1);
2712        assert!(!it.is_sold_out);
2713    }
2714
2715    // ── search_user ───────────────────────────────────────────────
2716
2717    /// Fixture mirroring `/api/v4/search/search_user` captured live
2718    /// 2026-04-30 (keyword "籃球", 1 user shown in product-search
2719    /// sidebar).
2720    fn search_user_fixture() -> Value {
2721        json!({
2722            "error": null,
2723            "data": {
2724                "users": [
2725                    {
2726                        "shopid": 1276618414_u64,
2727                        "userid": 1277122037_u64,
2728                        "username": "orz_orz_orz",
2729                        "shopname": " 銘鈺標識",
2730                        "nickname": " 銘鈺標識",
2731                        "portrait": "tw-11134216-81ztn-mgj5ztv0q51k66",
2732                        "shop_rating": 4.981132075471698,
2733                        "follower_count": 137,
2734                        "products": 277,
2735                        "is_official_shop": false,
2736                        "shopee_verified_flag": 1,
2737                        "response_rate": 100,
2738                        "response_time": 3855,
2739                        "country": "tw"
2740                    }
2741                ]
2742            }
2743        })
2744    }
2745
2746    #[test]
2747    fn parses_search_user_shop_match() {
2748        let r = parse_search_user(&search_user_fixture(), "籃球").expect("parse");
2749        assert_eq!(r.keyword, "籃球");
2750        assert_eq!(r.users.len(), 1);
2751        let u = &r.users[0];
2752        assert_eq!(u.shopid, 1_276_618_414);
2753        assert_eq!(u.userid, 1_277_122_037);
2754        assert_eq!(u.username, "orz_orz_orz");
2755        assert_eq!(u.shopname, " 銘鈺標識");
2756        assert_eq!(
2757            u.portrait.as_deref(),
2758            Some("tw-11134216-81ztn-mgj5ztv0q51k66")
2759        );
2760        assert!((u.shop_rating - 4.981).abs() < 1e-3);
2761        assert_eq!(u.follower_count, 137);
2762        assert_eq!(u.products, 277);
2763        assert!(!u.is_official_shop);
2764        assert_eq!(u.shopee_verified_flag, 1);
2765        assert_eq!(u.response_rate, 100);
2766        assert_eq!(u.country, "tw");
2767    }
2768
2769    #[test]
2770    fn search_user_handles_null_error_field_as_success() {
2771        // Shopee's recommend / search_user endpoints serialise
2772        // `error: null` on success (matches recommend_v2's
2773        // pattern from #193).
2774        let r = parse_search_user(&search_user_fixture(), "x").unwrap();
2775        assert!(!r.users.is_empty());
2776    }
2777
2778    #[test]
2779    fn search_user_captcha_wall_surfaces_distinct_error() {
2780        let body = json!({ "error": 90309999, "error_msg": "anti-bot" });
2781        let err = parse_search_user(&body, "x").unwrap_err();
2782        assert!(err.to_string().contains("90309999"));
2783    }
2784
2785    #[test]
2786    fn search_user_empty_users_is_valid() {
2787        // Keyword with no shop matches — Shopee returns empty
2788        // array, not an error.
2789        let body = json!({ "error": 0, "data": { "users": [] } });
2790        let r = parse_search_user(&body, "non-existent-keyword").unwrap();
2791        assert!(r.users.is_empty());
2792    }
2793
2794    #[test]
2795    fn search_user_missing_data_yields_empty_users() {
2796        // Defensive — sparse response (no `data.users`) still
2797        // parses cleanly with empty users instead of panic.
2798        let body = json!({ "error": 0 });
2799        let r = parse_search_user(&body, "x").unwrap();
2800        assert!(r.users.is_empty());
2801    }
2802}