nigig-org/crates/robius-sms/tests/sms_pipeline.rs
nigig-ci 25f32f7870
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
nigig-map / test (push) Failing after 1s
sms / gates (push) Successful in 3s
sms / robius-sms (push) Successful in 23s
sms / android (push) Failing after 54s
sms / nigig-sms (push) Successful in 4m12s
sms / supply-chain (push) Successful in 6s
test(sms): build a real test suite (Phase G)
50 tests -> 102, and the two that were there at the start of this work
are deleted.

Where this started: robius-sms had ZERO tests, and nigig-sms had two --
bulk_sub_tab_default_is_contacts and bulk_sub_tab_variants_distinct.
Both asserted a derived Default and a derived PartialEq. Neither
mentioned SMS. Neither could fail short of the compiler breaking. That
is the defect that produced every other defect in this plan: nothing
could prove a change was safe, so nothing was ever deleted and every
bug survived contact with review.

Property tests (proptest, new dev-dependency)

  Seven over truncate_preview, format_timestamp, badge_text, and five
  more over segment_count, the rate limiter and ScheduleRequest.

  These are the ones that matter, because the hand-written cases in this
  repo all encode a bug someone had ALREADY found. proptest searches the
  space instead. I verified that by reinstating the original byte-slicing
  truncate_preview and confirming
  prop_truncate_preview_survives_mixed_scripts and
  prop_truncate_preview_respects_the_char_limit both fail against it --
  they would have caught A3 before it shipped.

  prop_rate_limiter_respects_capacity models the window independently
  and asserts the invariant across random clock sequences, rather than
  re-implementing the limiter's own arithmetic in the assertion.

Integration tests (2 new files, public API only)

  robius-sms/tests/sms_pipeline.rs and nigig-core/tests/sms_store.rs go
  through the public surface the application actually uses. The unit
  tests inside src/ can see private helpers; these cannot, which is the
  point -- they catch a refactor that keeps every unit test green while
  breaking the caller-visible contract.

  Two of them are privacy canaries. e1_message_bodies_are_never_persisted
  and e1_no_body_text_reaches_the_serialised_store fail if anyone removes
  #[serde(skip)] from OfflineSmsMessage.body. Verified by removing it:
  both fail, the other six pass. Nothing else in the tree would have
  noticed the inbox silently going back to plaintext on disk.

Named regression tests

  One per defect, named for it -- c1_*, d1_*, d3_*, e1_*, e7_*, a4_*,
  c3_*, c7_* -- so a future reader goes from a failing test straight to
  the bug it guards rather than to a git archaeology session.

New coverage for logic that had none

  - build_timeline_items / build_filtered_timeline_items: date-divider
    placement and the message indices the draw loop uses to index
    conv_data.messages. An off-by-one there renders the wrong body in
    the wrong bubble; it had no test at all.
  - kind_to_offline / kind_from_offline round-trip: the only thing
    stopping a cached Sent message reappearing as Inbox after a restart,
    which would flip the bubble to the wrong side of the screen.
  - normalize_number: what C1 groups on, across five formatting variants
    plus short codes and alphanumeric senders.

MessageKind::from_android_type / to_android_type were hoisted out of
sys/android/inbox.rs onto the type, the same way ScheduleRequest::validate
was in A4, so the provider mapping is testable off-device. An
unrecognised TYPE value is preserved verbatim in Unknown rather than
defaulted, and there is a property test asserting the round trip is
total over every i32.

CI: a test-count FLOOR at 100. A floor rather than a ratchet -- unlike
the clippy count, there is no reason to ever want this number to fall.

Deliberately NOT faked: the JNI cursor loop, the keystore round-trip and
broadcast delivery still need an emulator. A mock returning what I expect
would test my expectations, not Android. Those remain called out in the
Phase A and E commit messages.

Verified: 11/11 checks. 48 robius-sms + 46 nigig-sms + 8 sms_store = 102.
clippy -D warnings clean on host and aarch64-linux-android; nigig-sms
ratchet holds at 32 (my first draft added an orphaned `use super::*`,
caught by the ratchet and removed rather than baselined).
2026-08-02 08:43:16 +00:00

220 lines
8.4 KiB
Rust

//! 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<i64> = 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<i64> = 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<String> = (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"
);
}