Files
blocker/storage/src/codec/tests.rs
T
SellPL 610d6b4190 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-02 13:10:25 +00:00

202 lines
7.0 KiB
Rust

//! Tests for the storage encoding layer.
//!
//! These need no database: the codec is where a silently-dropped field would
//! live, and a round-trip test here is far cheaper to run and to read than the
//! same assertion through SQLite.
#![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};
/// Every condition shape must survive encode -> decode unchanged.
///
/// A field that is written but not read back is invisible in production and
/// catastrophic here: the schedule quietly stops working the next time the app
/// restarts.
#[test]
fn every_condition_round_trips() {
let cases = vec![
Condition::Time(TimeWindow { days: vec![0, 1, 2, 3, 4], start: 9 * 60, end: 17 * 60 }),
// Overnight: start > end must not be normalised or dropped on the way
// through the database. This is the representation the whole overnight
// rule depends on.
Condition::Time(TimeWindow { days: vec![6], start: 22 * 60, end: 6 * 60 }),
Condition::UsageLimit { limit_minutes: 90, window: UsageWindow::Daily },
Condition::LaunchCount { limit: 5, window: UsageWindow::Hourly },
Condition::WifiSsid("CoffeeShop".into()),
Condition::Location {
latitude: 52.52,
longitude: 13.405,
radius_meters: 500,
block_when_outside: true,
},
];
for original in cases {
let rows = encode_conditions(std::slice::from_ref(&original));
let decoded = decode_conditions(&rows).expect("decode must succeed");
assert_eq!(decoded, vec![original], "round trip changed the condition");
}
}
/// A schedule's ordinal order must survive, since the user arranges targets
/// deliberately and the block screen shows them in that order.
#[test]
fn target_order_is_preserved() {
let targets = vec![
Target::App("b.app".into()),
Target::Website("example.com".into()),
Target::Keyword("casino".into()),
Target::App("a.app".into()),
];
let mut rows = encode_targets(&targets);
// Simulate the database returning rows in an arbitrary order.
rows.reverse();
assert_eq!(decode_targets(&rows).unwrap(), targets);
}
/// Unknown discriminators must error, not default.
///
/// Defaulting here would turn a typo or a newer-version row into a schedule
/// that silently blocks the wrong thing.
#[test]
fn unknown_kinds_are_errors_not_defaults() {
let bad_target =
vec![TargetRow { ordinal: 0, kind: "applicaton".into(), value: "com.example".into() }];
assert_eq!(decode_targets(&bad_target), Err(DecodeError::UnknownKind("applicaton".into())));
let bad_condition = vec![ConditionRow {
ordinal: 0,
kind: "Timey".into(),
days: None,
start_min: None,
end_min: None,
limit_value: None,
window: None,
ssid: None,
latitude: None,
longitude: None,
radius_m: None,
outside: None,
}];
assert_eq!(decode_conditions(&bad_condition), Err(DecodeError::UnknownKind("Timey".into())));
}
/// A time of day outside 0..1440 must be rejected rather than wrapping.
///
/// A wrapped minute would produce a window that never matches, or one that
/// always matches — both look like "the app ignores my schedule".
#[test]
fn out_of_range_minutes_are_rejected() {
let bad = ConditionRow {
ordinal: 0,
kind: "Time".into(),
days: Some("0".into()),
start_min: Some(-60),
end_min: Some(1020),
limit_value: None,
window: None,
ssid: None,
latitude: None,
longitude: None,
radius_m: None,
outside: None,
};
assert_eq!(decode_conditions(&[bad]), Err(DecodeError::MinuteOutOfRange(-60)));
}
/// Weekday values outside 0..=6 must be rejected.
#[test]
fn out_of_range_weekdays_are_rejected() {
assert_eq!(decode_days("0,1,9"), Err(DecodeError::BadDays("0,1,9".into())));
assert_eq!(decode_days("monday"), Err(DecodeError::BadDays("monday".into())));
assert_eq!(decode_days(""), Ok(vec![]));
assert_eq!(decode_days("0,6"), Ok(vec![0, 6]));
}
/// A flag column holding something other than 0 or 1 must be rejected.
#[test]
fn bad_flags_are_rejected() {
assert_eq!(decode_flag(0), Ok(false));
assert_eq!(decode_flag(1), Ok(true));
assert_eq!(decode_flag(2), Err(DecodeError::BadFlag(2)));
assert_eq!(decode_flag(-1), Err(DecodeError::BadFlag(-1)));
}
/// Negative counts must be rejected — a negative usage limit would mean
/// "block immediately", which is not what a corrupted row should do.
#[test]
fn negative_counts_are_rejected() {
assert_eq!(require_count(Some(-5)), Err(DecodeError::NegativeCount(-5)));
assert_eq!(require_count(Some(0)), Ok(0));
assert_eq!(require_count(None), Err(DecodeError::NegativeCount(-1)));
}
/// `HH:MM` parsing, as the golden tables use it.
#[test]
fn hhmm_parsing() {
assert_eq!(parse_hhmm("09:00"), Ok(540));
assert_eq!(parse_hhmm("22:30"), Ok(1350));
assert_eq!(parse_hhmm("00:00"), Ok(0));
assert_eq!(parse_hhmm("9:5"), Ok(545));
assert_eq!(parse_hhmm("24:00"), Err(DecodeError::BadTime("24:00".into())));
assert_eq!(parse_hhmm("12:60"), Err(DecodeError::BadTime("12:60".into())));
assert_eq!(parse_hhmm("nine"), Err(DecodeError::BadTime("nine".into())));
assert_eq!(parse_hhmm("0900"), Err(DecodeError::BadTime("0900".into())));
}
/// Lock level and mode round-trip through their string forms.
#[test]
fn level_and_mode_round_trip() {
for level in [
blocker_core::lock::LockLevel::Normal,
blocker_core::lock::LockLevel::Strict,
blocker_core::lock::LockLevel::Stubborn,
] {
assert_eq!(level_from_str(level_to_str(level)).unwrap(), level);
}
assert!(level_from_str("Paranoid").is_err());
for mode in [ScheduleMode::Blocklist, ScheduleMode::Allowlist] {
assert_eq!(mode_from_str(mode_to_str(mode)).unwrap(), mode);
}
assert!(mode_from_str("Whitelist").is_err());
}
/// A full schedule round-trips through its parts.
#[test]
fn schedule_round_trips_through_parts() {
let original = 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())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4],
start: 22 * 60,
end: 6 * 60,
})],
paused: false,
};
let decoded = decode_schedule(
&original.id,
&original.name,
original.icon.as_deref(),
mode_to_str(original.mode),
&encode_targets(&original.targets),
&encode_conditions(&original.conditions),
original.paused,
)
.unwrap();
assert_eq!(decoded, original);
}