Files
blocker/storage/src/tests.rs
T
SellPL 9a5c529a8e feat(storage): add SQLite persistence in a separate crate
Implements ADR-4 (CLAUDE.md section 3, `rusqlite` bundled). Schedules, lock
state, disable attempts and usage buckets now persist across restarts.

**Why a new crate rather than a module inside `blocker-core`.** The purity
contract says the engine does no I/O, and `tests/purity.rs` enforces that
mechanically by failing if `blocker-core` has any dependency at all. Putting
rusqlite in `blocker-core` would have forced that check to degrade into a
source-text grep — something a careful developer can route around and nobody
reads in six months. `blocker-storage` depends on `blocker-core`; the reverse
edge does not exist, so the zero-dependency proof stays automatic.

Both crates sit in the same layer of the section 2 diagram: storage is shared by
every platform and contains no platform code.

**Schema is relational, not JSON-in-a-text-column.** `schedule_target` and
`schedule_condition` are separate tables because target rows are queried by
target, and an index on a JSON string is not a plan. Serialisation is
hand-written in `storage/src/codec.rs` — `serde` is not a section 3 dependency,
and section 0 rule 3 requires asking before adding one.

**Fail open, verified end to end** (section 1.5.4). Corruption moves the damaged
file aside and starts clean, keeping the original for diagnosis. Confirmed
against the real binary, not only in tests:

```
$ printf 'SQLite format 3\x00 not a database' > .../blocker.db
$ blocker-daemon
blocker-daemon: database was corrupt and has been moved to
  .../blocker.db.corrupt; starting clean, so no schedules are active
  until you re-add them
EXIT=0
```

A truncated file is covered separately — that is what a power loss during a
write actually produces, and it fails differently from garbage bytes.

**Two bugs this PR's own tests caught:**

1. `prune_usage` anchored its cutoff on `MIN(day_index)`, the oldest row. Since
   that row is by definition the one being tested, the cutoff always sat before
   it and the delete matched nothing — the table grew forever, which is the slow
   disk leak the function exists to prevent. Now anchored on `MAX`.
2. An off-by-one in the same function: with a strict `<` against
   `newest - keep_days`, the boundary day was kept too, yielding `keep_days + 1`
   rows. Both are covered by `old_usage_is_pruned`.

**Cost, measured rather than assumed:** the stripped daemon goes from 0.279 MB
to **1.364 MB** with SQLite actually linked in — bundled SQLite is ~1.1 MB of
the 5 MB budget, leaving 3.8 MB for D-Bus, X11 and a UI toolkit. That is a real
margin rather than a comfortable one, and it makes ADR-4's bundled-vs-system
trade-off a genuine open question rather than a settled one.

The M0 figure of 0.279 MB excluded SQLite entirely, because dead-code elimination
dropped a crate the placeholder binary never referenced.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 17:14:16 +03:00

305 lines
10 KiB
Rust

//! 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);
}