//! Integration tests for the SMS pipeline (Phase G). //! //! These exercise the crate through its PUBLIC API only, the way the //! application uses it. The unit tests inside `src/` can see private //! helpers; these cannot, which is the point — they catch the case where //! an internal refactor keeps every unit test green while breaking the //! surface a caller actually touches. //! //! Everything here runs on any host. The device-only paths (JNI, the //! keystore, broadcast delivery) still need an emulator and are called //! out in the Phase A/E commit messages rather than faked here: a mock //! that returns what I expect would test my expectations, not Android. use robius_sms::{ BulkSendRequest, MessageKind, ScheduleRequest, SendOutcome, SendRateLimiter, SendReport, SendRequest, SmsEncoding, segment_count, }; // ─── The fixture inbox the desktop build ships ──────────────────────── #[test] fn dummy_inbox_is_coherent_enough_to_drive_the_ui() { let messages = robius_sms::dummy_messages(); let threads = robius_sms::dummy_threads(); assert!(!messages.is_empty(), "desktop builds render this"); assert!(!threads.is_empty()); // Every message must belong to a thread the thread list also knows // about, or the conversation list shows a row that opens onto // nothing. let thread_ids: Vec = threads.iter().map(|t| t.thread_id).collect(); for m in &messages { let tid = m.thread_id.expect("fixture messages carry a thread id"); assert!( thread_ids.contains(&tid), "message {} references thread {tid}, which is not in dummy_threads()", m.id ); } // Ids must be unique: the UI keys rows on them. let mut ids: Vec = messages.iter().map(|m| m.id).collect(); ids.sort_unstable(); let before = ids.len(); ids.dedup(); assert_eq!(before, ids.len(), "duplicate message ids in the fixture"); } #[test] fn fixture_covers_both_directions_and_a_short_code() { let messages = robius_sms::dummy_messages(); assert!( messages.iter().any(|m| m.kind == MessageKind::Inbox), "no received message: the bubble layout would never be exercised" ); assert!( messages.iter().any(|m| m.kind == MessageKind::Sent), "no sent message" ); // C1 relies on alphanumeric senders NOT normalising to digits. assert!( messages .iter() .any(|m| m.address.as_deref().is_some_and(|a| a.chars().any(|c| c.is_alphabetic()))), "no alphanumeric sender (Safaricom-style) in the fixture" ); } // ─── Cost, end to end ───────────────────────────────────────────────── #[test] fn a_bulk_campaign_reports_its_true_segment_cost() { // The scenario the bulk UI makes two taps away: one body, many // recipients, scraped from a directory. let body = "Dear customer, our new branch opens Monday. \ Visit us for 20% off all items this week only. \ Reply STOP to opt out."; let recipients: Vec = (0..200) .map(|i| format!("+2547{:08}", 10_000_000 + i)) .collect(); let request = BulkSendRequest { recipients: recipients.clone(), body: body.to_string(), }; assert!(request.validate().is_ok()); let per_message = segment_count(body); assert_eq!(per_message.encoding, SmsEncoding::Gsm7); let total = per_message.segments * recipients.len(); assert!( total >= recipients.len(), "a campaign can never cost less than one segment per recipient" ); // E8: the limiter stops this well short of the full 200. let mut rl = SendRateLimiter::default(); let sent = (0..recipients.len() as i64).filter(|i| rl.allow_at(*i).is_ok()).count(); assert_eq!(sent, SendRateLimiter::DEFAULT_CAPACITY as usize); assert!(sent < recipients.len(), "the whole batch reached the carrier"); } #[test] fn one_emoji_changes_what_a_campaign_costs() { // A5/F10. This is the trap the UI has to surface before sending: // the same copy, one emoji added, bills differently. let plain = "a".repeat(100); let with_emoji = format!("{plain}🎉"); let a = segment_count(&plain); let b = segment_count(&with_emoji); assert_eq!(a.encoding, SmsEncoding::Gsm7); assert_eq!(a.segments, 1); assert_eq!(b.encoding, SmsEncoding::Ucs2); assert!(b.segments > a.segments, "UCS-2 must split a 100-char body"); } // ─── Validation at the boundary ─────────────────────────────────────── #[test] fn c7_a_blank_line_in_the_recipient_box_is_rejected() { // The app parses the recipients textarea by lines, so a stray blank // line arrives as an empty recipient. Before C7 the app's own send // loop skipped this check entirely. let r = BulkSendRequest { recipients: vec!["+254712345678".into(), " ".into()], body: "hi".into(), }; assert!(r.validate().is_err()); } #[test] fn a4_the_one_shot_schedule_the_ui_builds_is_accepted() { // The exact shape sms_schedule_page.rs constructs. Under the old // validator every scheduled message on Android failed with // InvalidInput -- an entire feature that could never once have run. let r = ScheduleRequest { id: 1001, recipient: "+254712345678".into(), body: "Reminder: your appointment is tomorrow.".into(), first_trigger_at_ms: 1_719_820_800_000, interval_ms: None, }; assert!(r.validate().is_ok(), "one-shot schedules must be sendable"); } #[test] fn a_repeating_schedule_still_requires_a_positive_interval() { let r = ScheduleRequest { id: 1, recipient: "+254712345678".into(), body: "x".into(), first_trigger_at_ms: 1, interval_ms: Some(0), }; assert!(r.validate().is_err(), "a zero interval would loop the alarm"); } #[test] fn send_requests_reject_empty_input() { // send_sms validates before touching the platform, so these never // reach a radio. for (recipient, body) in [("", "hi"), (" ", "hi"), ("+254712345678", "")] { let req = SendRequest { recipient: recipient.into(), body: body.into(), }; // No platform here, so this is PermanentlyUnavailable at worst -- // the point is that it never panics and never claims success. assert!(robius_sms::send_sms(&req).is_err()); } } // ─── Truthful status (E7) ───────────────────────────────────────────── #[test] fn e7_sent_and_delivered_are_distinguishable_outcomes() { // Before E7 both PendingIntents were null, so nothing could report // back and `Ok` collapsed radio-accepted, delivered and failed into // one value the UI rendered as a tick. let sent = SendReport { token: 1, outcome: SendOutcome::Sent }; let delivered = SendReport { token: 1, outcome: SendOutcome::Delivered }; let failed = SendReport { token: 1, outcome: SendOutcome::SendFailed { code: 4 }, // RESULT_ERROR_NO_SERVICE }; assert!(sent.outcome.is_ok()); assert!(delivered.outcome.is_ok()); assert!(!failed.outcome.is_ok()); assert_ne!(sent.outcome, delivered.outcome); } #[test] fn e7_reports_start_empty_on_a_platform_that_cannot_send() { // No Android here, so nothing can have been sent and nothing can // have reported back. Must be empty rather than panicking. assert!(robius_sms::take_send_reports().is_empty()); } // ─── Unsupported platforms are honest (C3/F2) ───────────────────────── #[test] fn c3_unsupported_platforms_say_unsupported_not_unknown() { // sys/unsupported.rs used to return Error::Unknown for all twelve // functions where every other stub returned PermanentlyUnavailable, // so a caller was told "Unknown error" instead of "not supported // here". F2 then collapsed the four duplicate stub files into one, // which is what keeps them from drifting again. let err = robius_sms::list_messages().unwrap_err(); assert_eq!( err.to_string(), "Permanently unavailable on this platform", "stub backends must be honest about why they failed" ); }