//! Tests for the SQLite store. //! //! These use a temporary file rather than `:memory:` because WAL is enabled on //! the connection and an in-memory database with WAL is a subtly different thing //! than what ships. #![allow( clippy::unwrap_used, clippy::expect_used, clippy::panic, reason = "test fixtures: a failing assertion or a broken test is not a runtime \ condition to handle gracefully" )] use super::*; use blocker_core::model::{Condition, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow}; /// A unique temp path per test, since tests run in parallel and a shared /// database would make them interfere. fn temp_db(name: &str) -> PathBuf { let mut p = std::env::temp_dir(); p.push(format!("blocker-test-{}-{name}.db", std::process::id())); let _ = std::fs::remove_file(&p); p } fn sample_schedule() -> Schedule { Schedule { id: "s1".into(), name: "Evening Focus".into(), icon: Some("/icons/focus.png".into()), mode: ScheduleMode::Blocklist, targets: vec![ Target::App("com.example.social".into()), Target::Website("example.com".into()), Target::Keyword("casino".into()), ], conditions: vec![ Condition::Time(TimeWindow { days: vec![0, 1, 2, 3, 4], start: 22 * 60, end: 6 * 60 }), Condition::UsageLimit { limit_minutes: 90, window: UsageWindow::Daily }, ], paused: false, } } #[test] fn schedules_round_trip_through_the_database() { let path = temp_db("schedules"); let original = sample_schedule(); { let mut store = Store::open(&path).expect("open"); store.save_schedules(std::slice::from_ref(&original)).expect("save"); } { let store = Store::open(&path).expect("reopen"); let loaded = store.load_schedules().expect("load"); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0], original, "a schedule must survive a restart intact"); } let _ = std::fs::remove_file(&path); } #[test] fn overnight_window_survives_a_restart() { // The specific case worth its own test: if the overnight flag were lost in // storage, the schedule would stop blocking at midnight *after a restart*, // while appearing correct in the UI. let path = temp_db("overnight"); let mut original = sample_schedule(); original.conditions = vec![Condition::Time(TimeWindow { days: vec![0], start: 22 * 60, end: 6 * 60 })]; { let mut store = Store::open(&path).expect("open"); store.save_schedules(std::slice::from_ref(&original)).expect("save"); } { let store = Store::open(&path).expect("reopen"); let loaded = store.load_schedules().expect("load"); match &loaded[0].conditions[0] { Condition::Time(w) => { assert!( w.is_overnight(), "start={} end={} must still read as overnight after a restart", w.start, w.end ); assert_eq!(w.start, 22 * 60); assert_eq!(w.end, 6 * 60); } other => panic!("condition type changed across a restart: {other:?}"), } } let _ = std::fs::remove_file(&path); } #[test] fn saving_replaces_rather_than_accumulates() { let path = temp_db("replace"); let mut store = Store::open(&path).expect("open"); store.save_schedules(&[sample_schedule(), sample_schedule_with_id("s2")]).expect("save"); assert_eq!(store.load_schedules().unwrap().len(), 2); store.save_schedules(&[sample_schedule()]).expect("save"); let loaded = store.load_schedules().expect("load"); assert_eq!(loaded.len(), 1, "a second save must replace, not append"); assert_eq!(loaded[0].id, "s1"); let _ = std::fs::remove_file(&path); } fn sample_schedule_with_id(id: &str) -> Schedule { let mut s = sample_schedule(); s.id = id.to_owned(); s.name = format!("Schedule {id}"); s } #[test] fn usage_accumulates_rather_than_overwrites() { let path = temp_db("usage"); let mut store = Store::open(&path).expect("open"); store.add_daily_usage(20_731, "com.example.social", 30).unwrap(); store.add_daily_usage(20_731, "com.example.social", 25).unwrap(); store.add_daily_launch(20_731, "com.example.social").unwrap(); store.add_daily_launch(20_731, "com.example.social").unwrap(); let row = store.load_daily_usage(20_731).expect("load"); assert_eq!(row.minutes.get("com.example.social"), Some(&55)); assert_eq!(row.launches.get("com.example.social"), Some(&2)); // A different day is a different bucket. assert!(store.load_daily_usage(20_732).unwrap().minutes.is_empty()); let _ = std::fs::remove_file(&path); } #[test] fn a_pending_disable_request_survives_a_restart() { // Section 1.5.2.2: filing a request must not lose the request on restart, or // a user who waits out a 12-hour delay and restarts the machine would be // back at square one with no explanation. let path = temp_db("pending"); let state = blocker_core::lock::LockState { level: blocker_core::lock::LockLevel::Stubborn, schedules_locked: true, pending: Some(blocker_core::lock::PendingRequest { ready_at: Monotonic(12 * 3_600_000), window_closes_at: Monotonic(12 * 3_600_000 + 600_000), }), cooldown_remaining: None, attempts_today: 0, keyholder_code: None, }; { let mut store = Store::open(&path).expect("open"); store.save_lock(&state, BootId(7), 1_000).expect("save"); } { let store = Store::open(&path).expect("reopen"); let (ready, boot) = store .load_pending_request() .expect("load") .expect("a pending request must survive a restart"); assert_eq!(ready, Monotonic(12 * 3_600_000)); assert_eq!(boot, BootId(7), "the boot id must survive, so a reboot is detectable"); } let _ = std::fs::remove_file(&path); } #[test] fn no_pending_request_reads_as_none() { let path = temp_db("nopending"); let store = Store::open(&path).expect("open"); assert_eq!(store.load_pending_request().unwrap(), None); let _ = std::fs::remove_file(&path); } #[test] fn disable_attempts_are_counted_per_day() { // Section 1.5.2.5: the block screen shows "you tried 4 times today". let path = temp_db("attempts"); let mut store = Store::open(&path).expect("open"); for _ in 0..4 { store.record_attempt(Monotonic(1_000), BootId(1), 20_731).unwrap(); } store.record_attempt(Monotonic(2_000), BootId(1), 20_732).unwrap(); assert_eq!(store.attempt_count(20_731).unwrap(), 4); assert_eq!(store.attempt_count(20_732).unwrap(), 1); assert_eq!(store.attempt_count(99_999).unwrap(), 0); let _ = std::fs::remove_file(&path); } /// Section 1.5.4: a corrupt database must never leave a device unusable. /// /// This is the single most important test in the crate. The requirement is /// fail *open*: blocking stops, the user keeps a working device, and the damaged /// file is kept for diagnosis. #[test] fn a_corrupt_database_fails_open() { let path = temp_db("corrupt"); // Real bytes in place of the SQLite header. Enough to make the file // unopenable or unreadable, which is what "corrupt" means in the field. std::fs::write(&path, b"SQLite format 3\x00 this is not actually a database").unwrap(); let store = Store::open(&path).expect("open must succeed despite corruption"); assert!(store.recovered_from_corruption(), "the store must report that it recovered"); assert!( store.load_schedules().unwrap().is_empty(), "a recovered database starts empty, which blocks nothing — fail open" ); assert!( path.with_extension("corrupt").exists(), "the damaged file must be kept for diagnosis, not deleted" ); let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(path.with_extension("corrupt")); } #[test] fn a_truncated_database_fails_open() { // A different failure mode from garbage bytes: a valid header with the // content cut off. Corruption in the field is often truncation, from a // power loss during a write. let path = temp_db("truncated"); { let mut store = Store::open(&path).expect("open"); store.save_schedules(&[sample_schedule()]).expect("save"); } // Keep the first 200 bytes — a valid header, an unusable file. let full = std::fs::read(&path).unwrap(); std::fs::write(&path, &full[..200.min(full.len())]).unwrap(); let store = Store::open(&path); assert!( store.is_ok(), "a truncated database must still open, because failing to open would \ leave the device with no enforcement and no UI" ); let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(path.with_extension("corrupt")); } #[test] fn opening_creates_the_directory() { let mut nested = std::env::temp_dir(); nested.push(format!("blocker-test-{}-nested/state", std::process::id())); let _ = std::fs::remove_dir_all(&nested); let path = nested.join("blocker.db"); let store = Store::open(&path); assert!(store.is_ok(), "a missing parent directory must not be fatal"); assert!(path.exists()); let _ = std::fs::remove_dir_all(&nested); } #[test] fn old_usage_is_pruned() { // An unbounded usage table is a slow disk leak, and this machine has 9.6 GB // free. Statistics (section 1.6) need history, not infinite history. let path = temp_db("prune"); let mut store = Store::open(&path).expect("open"); for day in 20_000..20_100 { store.add_daily_usage(day, "com.example.social", 10).unwrap(); } let removed = store.prune_usage(10).expect("prune"); assert_eq!(removed, 90, "90 of 100 days are outside the 10-day window"); let kept = store.load_daily_usage(20_099).unwrap(); assert!(!kept.minutes.is_empty(), "recent history must be kept"); let _ = std::fs::remove_file(&path); } #[test] fn reopening_is_idempotent() { // The migration must not run twice or clobber data. let path = temp_db("idempotent"); let mut store = Store::open(&path).expect("open"); store.save_schedules(&[sample_schedule()]).expect("save"); drop(store); for _ in 0..3 { let store = Store::open(&path).expect("reopen"); assert_eq!(store.load_schedules().unwrap().len(), 1, "data must survive re-migration"); } let _ = std::fs::remove_file(&path); }