Skip to main content

grabcli_service/
fake.rs

1//! A Grab with no network, for tests and local development. Nothing here
2//! reaches Grab; every merchant, item and order is made up.
3//!
4//! - Login: a phone ending in `1` is approved in the app (after two
5//!   waits); one ending in `9` hits a reCAPTCHA step-up (failed); any other
6//!   gets a code, `123456`.
7//! - The session is `{"fake": true, "account": "<name>", "n": <refreshes>}`;
8//!   `"dead": true` in it makes every call need a re-login.
9//! - Two merchants, a cart in memory per session account, a quote id that
10//!   is a hash of the cart, orders that only count up.
11//!
12//! [`Fake::session`] makes a connected session without a login.
13
14use alloc::collections::BTreeMap;
15use alloc::sync::Arc;
16use core::time::Duration;
17use std::sync::Mutex;
18
19use serde_json::{Value, json};
20use sha2::{Digest as _, Sha256};
21
22use super::api::{
23    Cart, CartOp, Fee, GrabBackend, GrabClient, GrabError, GrabResult, Health, Line, LoginDriver,
24    Menu, MenuItem, Merchant, Placed, Quote, SessionSlot, Step, Tracked,
25};
26use crate::Secret;
27
28/// The fake's code.
29pub const FAKE_CODE: &str = "123456";
30
31/// The fake backend; its orders are counted (the tests check that an
32/// order went through once, or never).
33#[derive(Default)]
34pub struct Fake {
35    carts: Arc<Mutex<BTreeMap<String, Cart>>>,
36    /// The quote ids of the orders placed, in order.
37    pub orders: Arc<Mutex<Vec<String>>>,
38    /// Quote ids of the next quotes, instead of the cart's hash (a test's
39    /// price change).
40    pub next_quotes: Arc<Mutex<Vec<String>>>,
41}
42
43fn merchants() -> Vec<Merchant> {
44    vec![
45        Merchant {
46            id: "fake-chicken".into(),
47            name: "Chicken House (Fake)".into(),
48            cuisine: "Fast food".into(),
49            eta_min: Some(25),
50            distance_km: Some(1.2),
51            rating: Some(4.7),
52            promo: "₱50 off".into(),
53            open: true,
54        },
55        Merchant {
56            id: "fake-ramen".into(),
57            name: "Ramen Bar (Fake)".into(),
58            cuisine: "Japanese".into(),
59            eta_min: Some(35),
60            distance_km: Some(2.8),
61            rating: Some(4.8),
62            promo: String::new(),
63            open: true,
64        },
65    ]
66}
67
68fn items(merchant: &str) -> Vec<MenuItem> {
69    let it = |id: &str, name: &str, price: i64| MenuItem {
70        id: format!("{merchant}:{id}"),
71        name: name.into(),
72        category: "Mains".into(),
73        price,
74        description: String::new(),
75        available: true,
76    };
77    if merchant == "fake-ramen" {
78        vec![
79            it("og", "Tonkotsu Ramen", 46_000),
80            it("black", "Black Garlic Ramen", 48_000),
81        ]
82    } else {
83        vec![
84            it("c1", "Fried Chicken 1pc", 9_900),
85            it("spag", "Sweet Spaghetti", 7_500),
86        ]
87    }
88}
89
90struct FakeClient {
91    fake: Arc<FakeShared>,
92    slot: Arc<dyn SessionSlot>,
93}
94
95struct FakeShared {
96    carts: Arc<Mutex<BTreeMap<String, Cart>>>,
97    orders: Arc<Mutex<Vec<String>>>,
98    next_quotes: Arc<Mutex<Vec<String>>>,
99}
100
101impl FakeClient {
102    /// The session's account, or why not.
103    async fn account(&self) -> GrabResult<(String, Value)> {
104        let s = self
105            .slot
106            .load()
107            .await?
108            .ok_or_else(|| GrabError::NeedsRelogin("not connected".into()))?;
109        if s["dead"].as_bool() == Some(true) {
110            return Err(GrabError::NeedsRelogin("the SSO login was refused".into()));
111        }
112        Ok((s["account"].as_str().unwrap_or("fake").to_owned(), s))
113    }
114
115    fn cart_of(&self, account: &str) -> Cart {
116        crate::util::lock(&self.fake.carts)
117            .get(account)
118            .cloned()
119            .unwrap_or_else(|| Cart {
120                currency: "PHP".into(),
121                ..Cart::default()
122            })
123    }
124
125    fn quote_of(&self, cart: &Cart) -> Quote {
126        let mut h = Sha256::new();
127        h.update(serde_json::to_vec(cart).unwrap_or_default());
128        let mut id = format!("fq-{}", &crate::util::hex(&h.finalize())[..12]);
129        if let Some(next) = crate::util::lock(&self.fake.next_quotes).pop() {
130            id = next;
131        }
132        let fees = vec![
133            Fee {
134                label: "Delivery fee".into(),
135                amount: 4_900,
136            },
137            Fee {
138                label: "Platform fee".into(),
139                amount: 500,
140            },
141        ];
142        let discount = if cart.subtotal >= 20_000 { 5_000 } else { 0 };
143        let total = cart.subtotal + fees.iter().map(|f| f.amount).sum::<i64>() - discount;
144        Quote {
145            quote_id: id,
146            merchant_name: cart.merchant_name.clone(),
147            currency: "PHP".into(),
148            lines: cart.lines.clone(),
149            subtotal: cart.subtotal,
150            fees,
151            discount,
152            voucher: if discount > 0 {
153                "FAKE50".into()
154            } else {
155                String::new()
156            },
157            total,
158            eta_min: Some(30),
159            payment_method: "GrabPay (fake)".into(),
160            address_label: "Home".into(),
161        }
162    }
163}
164
165#[async_trait::async_trait]
166impl GrabClient for FakeClient {
167    async fn search(&self, query: &str, limit: usize) -> GrabResult<Vec<Merchant>> {
168        self.account().await?;
169        let q = query.to_lowercase();
170        Ok(merchants()
171            .into_iter()
172            .filter(|m| {
173                q.is_empty()
174                    || m.name.to_lowercase().contains(&q)
175                    || m.cuisine.to_lowercase().contains(&q)
176            })
177            .take(limit)
178            .collect())
179    }
180
181    async fn menu(&self, merchant_id: &str) -> GrabResult<Menu> {
182        self.account().await?;
183        let merchant = merchants()
184            .into_iter()
185            .find(|m| m.id == merchant_id)
186            .ok_or_else(|| GrabError::Failed(format!("no merchant {merchant_id}")))?;
187        Ok(Menu {
188            items: items(&merchant.id),
189            merchant,
190            currency: "PHP".into(),
191        })
192    }
193
194    async fn cart(&self, op: &CartOp) -> GrabResult<Cart> {
195        let (account, _) = self.account().await?;
196        let mut cart = self.cart_of(&account);
197        match op {
198            CartOp::Show => {}
199            CartOp::Clear => {
200                cart = Cart {
201                    currency: "PHP".into(),
202                    ..Cart::default()
203                };
204            }
205            CartOp::Add {
206                merchant_id,
207                item_id,
208                quantity,
209                note,
210            } => {
211                let m = merchants()
212                    .into_iter()
213                    .find(|m| m.id == *merchant_id)
214                    .ok_or_else(|| GrabError::Failed(format!("no merchant {merchant_id}")))?;
215                if !cart.merchant_id.is_empty() && cart.merchant_id != m.id {
216                    return Err(GrabError::Failed(
217                        "the cart has another merchant's items: clear it first".into(),
218                    ));
219                }
220                let it = items(&m.id)
221                    .into_iter()
222                    .find(|i| i.id == *item_id)
223                    .ok_or_else(|| GrabError::Failed(format!("no item {item_id}")))?;
224                cart.merchant_id = m.id;
225                cart.merchant_name = m.name;
226                cart.lines.push(Line {
227                    item_id: it.id,
228                    name: it.name,
229                    quantity: *quantity,
230                    amount: it.price * i64::from(*quantity),
231                    note: note.clone().unwrap_or_default(),
232                });
233            }
234            CartOp::Remove { item_id } => {
235                let before = cart.lines.len();
236                cart.lines.retain(|l| l.item_id != *item_id);
237                if cart.lines.len() == before {
238                    return Err(GrabError::Failed(format!("{item_id} isn't in the cart")));
239                }
240                if cart.lines.is_empty() {
241                    cart.merchant_id.clear();
242                    cart.merchant_name.clear();
243                }
244            }
245        }
246        cart.subtotal = cart.lines.iter().map(|l| l.amount).sum();
247        crate::util::lock(&self.fake.carts).insert(account, cart.clone());
248        Ok(cart)
249    }
250
251    async fn quote(&self) -> GrabResult<Quote> {
252        let (account, _) = self.account().await?;
253        let cart = self.cart_of(&account);
254        if cart.lines.is_empty() {
255            return Err(GrabError::Failed("the cart is empty".into()));
256        }
257        Ok(self.quote_of(&cart))
258    }
259
260    async fn place_order(&self, quote_id: &str) -> GrabResult<Placed> {
261        let (account, _) = self.account().await?;
262        let mut orders = crate::util::lock(&self.fake.orders);
263        orders.push(quote_id.to_owned());
264        crate::util::lock(&self.fake.carts).remove(&account);
265        Ok(Placed {
266            order_id: format!("fake-order-{}", orders.len()),
267            eta_min: Some(30),
268        })
269    }
270
271    async fn track(&self, order_id: Option<&str>) -> GrabResult<Vec<Tracked>> {
272        self.account().await?;
273        let n = crate::util::lock(&self.fake.orders).len();
274        Ok((1..=n)
275            .map(|i| Tracked {
276                order_id: format!("fake-order-{i}"),
277                merchant_name: "Chicken House (Fake)".into(),
278                status: "Driver on the way".into(),
279                eta_min: Some(12),
280                driver: "Sample Driver (fake)".into(),
281            })
282            .filter(|t| order_id.is_none_or(|o| o == t.order_id))
283            .collect())
284    }
285
286    async fn refresh(&self, liveness: bool) -> GrabResult<Health> {
287        let (account, mut s) = self.account().await?;
288        s["n"] = json!(s["n"].as_i64().unwrap_or(0) + 1);
289        self.slot.save(&s).await?;
290        Ok(Health {
291            display_name: liveness.then_some(account),
292            sso_expires_at: s["sso_expires_at"].as_i64(),
293        })
294    }
295}
296
297impl GrabBackend for Fake {
298    fn client(&self, session: Arc<dyn SessionSlot>) -> Arc<dyn GrabClient> {
299        Arc::new(FakeClient {
300            fake: Arc::new(FakeShared {
301                carts: Arc::clone(&self.carts),
302                orders: Arc::clone(&self.orders),
303                next_quotes: Arc::clone(&self.next_quotes),
304            }),
305            slot: session,
306        })
307    }
308
309    fn login(
310        &self,
311        _cdp_url: &str,
312        _previous: Option<Value>,
313    ) -> Result<Box<dyn LoginDriver>, String> {
314        Ok(Box::new(FakeLogin::default()))
315    }
316}
317
318/// The fake's login (see the module's docs for which phones do what).
319#[derive(Default)]
320pub struct FakeLogin {
321    waits: u32,
322}
323
324fn done() -> Step {
325    Step::Done {
326        session: Fake::session("Fake Grab"),
327        display_name: "Fake Grab".into(),
328        sso_expires_at: Some(crate::util::now_s() + 60 * 86400),
329    }
330}
331
332impl Fake {
333    /// A connected fake session of `account` (what a fake login yields), its
334    /// SSO good for 60 days.
335    pub fn session(account: &str) -> Value {
336        json!({
337            "fake": true,
338            "account": account,
339            "n": 0,
340            "sso_expires_at": crate::util::now_s() + 60 * 86400,
341        })
342    }
343
344    /// `session`, refused by Grab: every call needs a re-login.
345    pub fn dead(mut session: Value) -> Value {
346        session["dead"] = json!(true);
347        session
348    }
349}
350
351impl LoginDriver for FakeLogin {
352    fn start(&mut self, phone: &Secret) -> Step {
353        let p = phone.expose();
354        if p.ends_with('9') {
355            return Step::Failed("Grab asked for a reCAPTCHA check (a step-up): try later".into());
356        }
357        if p.ends_with('1') {
358            return Step::AwaitingApproval;
359        }
360        Step::AwaitingCode {
361            channel: "sms".into(),
362        }
363    }
364
365    fn submit_code(&mut self, code: &Secret) -> Step {
366        if code.expose() == FAKE_CODE {
367            done()
368        } else {
369            Step::Failed("Grab refused the code".into())
370        }
371    }
372
373    fn wait(&mut self, timeout: Duration) -> Step {
374        self.waits += 1;
375        if self.waits >= 2 {
376            return done();
377        }
378        std::thread::sleep(timeout.min(Duration::from_millis(50)));
379        Step::AwaitingApproval
380    }
381
382    fn cancel(&mut self) {
383        self.waits = 0;
384    }
385}