#![allow(clippy::expect_used, clippy::unwrap_used)]
use std::sync::{Arc, Mutex};
use std::time::Duration;
use grabcli_service::fake::{FAKE_CODE, Fake};
use grabcli_service::{
CartOp, GrabBackend, GrabError, LoggedIn, LoginDriver, LoginError, Logins, MemorySlot, OnDone,
Secret, SessionSlot, Step, Timing, View, money, text,
};
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("rt")
}
#[test]
fn money_groups_and_signs() {
assert_eq!(money(123_450, "PHP"), "₱1,234.50");
assert_eq!(money(-5, ""), "-₱0.05");
assert_eq!(money(100_000_000, "USD"), "USD 1,000,000.00");
}
#[test]
fn the_fake_orders_once_against_its_quote() {
let fake = Fake::default();
let slot = Arc::new(MemorySlot::new("acct-1", Some(Fake::session("Sample"))));
let c = fake.client(Arc::clone(&slot) as Arc<dyn SessionSlot>);
rt().block_on(async {
let ms = c.search("chicken", 5).await.expect("search");
assert_eq!(ms.len(), 1);
assert!(text::merchants(&ms).contains("`fake-chicken`"));
let m = c.menu("fake-chicken").await.expect("menu");
assert!(text::menu(&m, Some("spag")).contains("Sweet Spaghetti"));
assert!(!text::menu(&m, Some("spag")).contains("Fried Chicken"));
for _ in 0..2 {
c.cart(&CartOp::Add {
merchant_id: "fake-chicken".into(),
item_id: "fake-chicken:c1".into(),
quantity: 1,
note: Some("extra gravy".into()),
})
.await
.expect("add");
}
let cart = c.cart(&CartOp::Show).await.expect("show");
assert_eq!(cart.subtotal, 19_800);
assert!(text::cart(&cart).contains("(extra gravy)"));
let other = c
.cart(&CartOp::Add {
merchant_id: "fake-ramen".into(),
item_id: "fake-ramen:og".into(),
quantity: 1,
note: None,
})
.await;
assert!(matches!(other, Err(GrabError::Failed(_))));
let q = c.quote().await.expect("quote");
assert_eq!(q.total, 19_800 + 4_900 + 500);
let t = text::quote(&q);
assert!(t.contains(&q.quote_id) && t.contains("Total ₱252.00"));
let p = c.place_order(&q.quote_id).await.expect("order");
assert_eq!(p.order_id, "fake-order-1");
assert_eq!(*fake.orders.lock().unwrap(), vec![q.quote_id.clone()]);
assert!(c.cart(&CartOp::Show).await.expect("show").lines.is_empty());
let tr = c.track(Some("fake-order-1")).await.expect("track");
assert!(text::tracked(&tr).contains("`fake-order-1`"));
let h = c.refresh(true).await.expect("refresh");
assert_eq!(h.display_name.as_deref(), Some("Sample"));
assert_eq!(slot.session().expect("session")["n"], 1);
});
}
#[test]
fn a_dead_or_missing_session_needs_a_relogin() {
let fake = Fake::default();
let dead = fake.client(Arc::new(MemorySlot::new(
"a",
Some(Fake::dead(Fake::session("Sample"))),
)));
let none = fake.client(Arc::new(MemorySlot::new("b", None)));
rt().block_on(async {
assert!(matches!(
dead.search("", 1).await,
Err(GrabError::NeedsRelogin(_))
));
assert!(matches!(
none.quote().await,
Err(GrabError::NeedsRelogin(_))
));
});
}
fn fast() -> Timing {
Timing {
approval_wait: Duration::from_millis(10),
send_cooldown_s: 0,
..Timing::default()
}
}
fn sink() -> (Arc<Mutex<Vec<LoggedIn>>>, OnDone) {
let got: Arc<Mutex<Vec<LoggedIn>>> = Arc::default();
let g = Arc::clone(&got);
let done: OnDone = Arc::new(move |l| {
g.lock().unwrap().push(l);
Ok(())
});
(got, done)
}
fn until(logins: &Logins, person: &str, org: &str, id: &str, f: impl Fn(&View) -> bool) -> View {
for _ in 0..400 {
let s = logins.get(person, org, id).expect("get");
if f(&s.view) {
return s.view;
}
std::thread::sleep(Duration::from_millis(5));
}
panic!("the login never got there");
}
#[test]
fn a_code_login_connects_and_is_bound_to_its_person() {
let logins = Arc::new(Logins::new(
fast(),
Arc::new(|| {
i64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis(),
)
.unwrap()
}),
));
let fake = Fake::default();
let (got, done) = sink();
let s = logins
.start(
"p1",
"acct-1",
Secret::new("+10000000000"),
fake.login("", None).expect("driver"),
done,
)
.expect("start");
let v = until(&logins, "p1", "acct-1", &s.id, |v| {
matches!(v, View::Code { .. })
});
assert_eq!(v.state(), "code");
assert!(matches!(
logins.get("p2", "acct-1", &s.id),
Err(LoginError::NotFound(_))
));
assert!(matches!(
logins.submit("p1", "acct-1", &s.id, Secret::new("12 34")),
Err(LoginError::Invalid(_))
));
logins
.submit("p1", "acct-1", &s.id, Secret::new(FAKE_CODE))
.expect("submit");
let v = until(&logins, "p1", "acct-1", &s.id, View::finished);
assert_eq!(
v,
View::Done {
display_name: "Fake Grab".into()
}
);
let got = got.lock().unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].org, "acct-1");
assert_eq!(got[0].session["fake"], true);
}
#[test]
fn approval_step_ups_bad_phones_and_the_send_budget() {
let logins = Arc::new(Logins::new(
Timing {
sends_per_hour: 2,
..fast()
},
Arc::new(|| 1_000_000),
));
let fake = Fake::default();
let start = |org: &str, phone: &str| {
let (_, done) = sink();
logins.start(
"p1",
org,
Secret::new(phone),
fake.login("", None).expect("driver"),
done,
)
};
assert!(matches!(start("a", "12345"), Err(LoginError::Invalid(_))));
let s = start("a", "+10000000001").expect("start");
let v = until(&logins, "p1", "a", &s.id, View::finished);
assert_eq!(v.state(), "done");
let s = start("a", "+10000000009").expect("start");
let v = until(&logins, "p1", "a", &s.id, View::finished);
assert_eq!(v.state(), "failed");
assert!(matches!(
start("a", "+10000000000"),
Err(LoginError::Budget(_))
));
let s = start("b", "+10000000000").expect("start");
until(&logins, "p1", "b", &s.id, |v| {
matches!(v, View::Code { .. })
});
assert!(matches!(
start("b", "+10000000000"),
Err(LoginError::State(_))
));
logins.cancel("p1", "b", &s.id).expect("cancel");
let v = until(&logins, "p1", "b", &s.id, View::finished);
assert_eq!(v, View::Cancelled);
}
const TOKEN: &str = "tok-0123456789abcdef";
fn tokened() -> serde_json::Value {
let mut s = Fake::session("Sample");
s["sso_token"] = serde_json::json!(TOKEN);
s["refresh_token"] = serde_json::json!(format!("{TOKEN}-refresh"));
s
}
fn no_token(debug: &str) {
assert!(!debug.contains(TOKEN), "a token in {debug}");
assert!(!debug.contains("sso_token"), "the session in {debug}");
assert!(debug.contains("<redacted>"), "{debug}");
}
#[test]
fn sessions_never_debug() {
let step = Step::Done {
session: tokened(),
display_name: "+1••••••••00".into(),
sso_expires_at: Some(1),
};
no_token(&format!("{step:?}"));
no_token(&format!("{step:#?}"));
let slot = MemorySlot::new("acct-1", Some(tokened()));
rt().block_on(slot.save_cart(Some(&serde_json::json!({ "t": TOKEN }))))
.expect("save");
no_token(&format!("{slot:?}"));
no_token(&format!("{slot:#?}"));
assert!(format!("{:?}", MemorySlot::new("a", None)).contains("session: None"));
let l = LoggedIn {
org: "acct-1".into(),
person: "p1".into(),
session: tokened(),
display_name: "x".into(),
sso_expires_at: None,
};
no_token(&format!("{l:?}"));
}
struct Scripted(Option<String>);
impl LoginDriver for Scripted {
fn start(&mut self, _: &Secret) -> Step {
match self.0.take() {
Some(m) => Step::Failed(m),
None => Step::Done {
session: tokened(),
display_name: "+1••••••••00".into(),
sso_expires_at: None,
},
}
}
fn submit_code(&mut self, _: &Secret) -> Step {
Step::Failed("no".into())
}
fn wait(&mut self, _: Duration) -> Step {
Step::Failed("no".into())
}
fn cancel(&mut self) {}
}
#[test]
fn a_failure_never_shows_the_phone() {
let lines: Arc<Mutex<Vec<String>>> = Arc::default();
let l2 = Arc::clone(&lines);
let logins = Arc::new(
Logins::new(fast(), Arc::new(|| 1_000_000))
.with_log(Arc::new(move |s| l2.lock().unwrap().push(s.to_owned()))),
);
let (_, done) = sink();
let msg = "page evaluation failed: +63 9171234567 / +639171234567 / 639171234567 / 09171234567";
let s = logins
.start(
"p1",
"a",
Secret::new(" +639171234567 "),
Box::new(Scripted(Some(msg.into()))),
done,
)
.expect("start");
let View::Failed { message } = until(&logins, "p1", "a", &s.id, View::finished) else {
panic!("not failed");
};
for shown in [message.clone()]
.into_iter()
.chain(lines.lock().unwrap().iter().cloned())
{
assert!(!shown.contains("9171234567"), "the phone in {shown}");
}
assert!(message.starts_with("page evaluation failed: "), "{message}");
}
#[test]
fn concurrent_starts_of_one_org_let_one_through() {
for _ in 0..20 {
let logins = Arc::new(Logins::new(
Timing {
sends_per_hour: 100,
..fast()
},
Arc::new(|| 1_000_000),
));
let n = 8;
let gate = Arc::new(std::sync::Barrier::new(n));
let handles: Vec<_> = (0..n)
.map(|i| {
let (logins, gate) = (Arc::clone(&logins), Arc::clone(&gate));
std::thread::spawn(move || {
let (_, done) = sink();
gate.wait();
logins.start(
&format!("p{i}"),
"org",
Secret::new("+10000000000"),
Fake::default().login("", None).expect("driver"),
done,
)
})
})
.collect();
let results: Vec<_> = handles
.into_iter()
.map(|h| h.join().expect("join"))
.collect();
let ok = results.iter().filter(|r| r.is_ok()).count();
assert_eq!(ok, 1, "{results:?}");
assert!(
results
.iter()
.filter_map(|r| r.as_ref().err())
.all(|e| matches!(e, LoginError::State(_)))
);
}
}