Proof-of-concept proving the SQLite-storage exit criterion from
docs/architecture/proof-of-concept-plan.md §2:
"Schema, migration, and repository pattern are demonstrated."
Also satisfies the SRS-089 acceptance criteria explicitly:
"Storage implementation uses an embedded local data store and
migration mechanism."
Implements:
- A forward-only Migrator over a fixed Migration list, tracking
the applied version via PRAGMA user_version. Each migration is
applied inside an IMMEDIATE transaction; rolled back on failure.
- Three canonical migrations (initial schema, add nickname,
add last_connected_at) demonstrating ALTER TABLE flows.
- A LocalDatabaseRepository implementing both BookmarkRepository
and SettingsRepository traits.
- Bookmark.identity_ref is a reference to a secret name, never
a secret value (cross-checked by the secure-storage spike's
SS-AUD-001/002 scans). This is the SAD-067 separation.
Test suite (11/11 PASS on 2026-05-13):
- migrator brings fresh DB to latest version
- migrator is idempotent (no-op when already current)
- migrator applies only pending versions (catch-up upgrade)
- migrator rejects out-of-order versions
- migrator rejects DB newer than known migrations (downgrade guard)
- failed migration rolls back atomically
- bookmark CRUD round-trip
- bookmark list ordered by recency
- bookmark UNIQUE(host, identity_ref) enforcement
- settings upsert + delete
- open creates file and persists across reopen
Surfaced finding for the decision register: DEC-013 does not pin a
SQLite crate. The PoC uses rusqlite with the bundled feature
(no system libsqlite3 dependency); production code needs an
owner ruling on rusqlite vs. sqlx vs. sea-orm.
Authority: PoC plan §2, SRS-089, SDD-077, SAD-067,
SysDes-033/036/049/091.
Not product code; not promoted into chanora_storage.
238 lines
7.6 KiB
Rust
238 lines
7.6 KiB
Rust
//! Verification tests mapped to SRS-089's acceptance criteria:
|
|
//! "Storage implementation uses an embedded local data store and
|
|
//! migration mechanism."
|
|
//!
|
|
//! And to the PoC plan §2 exit criterion:
|
|
//! "Schema, migration, and repository pattern are demonstrated."
|
|
|
|
use rusqlite::Connection;
|
|
use sqlite_storage_spike::{
|
|
migrate::{canonical_migrations, Migration, Migrator},
|
|
repo::NewBookmark,
|
|
BookmarkRepository, LocalDatabaseRepository, SettingsRepository,
|
|
};
|
|
|
|
// ---------- Migration mechanism ----------
|
|
|
|
#[test]
|
|
fn migrator_brings_fresh_db_to_latest_version() {
|
|
let mut conn = Connection::open_in_memory().unwrap();
|
|
let migrator = Migrator::new(canonical_migrations()).unwrap();
|
|
|
|
let final_v = migrator.run(&mut conn).unwrap();
|
|
assert_eq!(final_v, migrator.highest_version());
|
|
}
|
|
|
|
#[test]
|
|
fn migrator_is_idempotent() {
|
|
let mut conn = Connection::open_in_memory().unwrap();
|
|
let migrator = Migrator::new(canonical_migrations()).unwrap();
|
|
let v1 = migrator.run(&mut conn).unwrap();
|
|
let v2 = migrator.run(&mut conn).unwrap();
|
|
let v3 = migrator.run(&mut conn).unwrap();
|
|
assert_eq!(v1, v2);
|
|
assert_eq!(v2, v3);
|
|
}
|
|
|
|
#[test]
|
|
fn migrator_applies_only_pending_versions() {
|
|
let mut conn = Connection::open_in_memory().unwrap();
|
|
|
|
// Apply only v1 first.
|
|
let first = Migrator::new(vec![canonical_migrations()[0]]).unwrap();
|
|
assert_eq!(first.run(&mut conn).unwrap(), 1);
|
|
|
|
// Now apply the full list — must catch up from v1 to v3 cleanly.
|
|
let full = Migrator::new(canonical_migrations()).unwrap();
|
|
assert_eq!(full.run(&mut conn).unwrap(), 3);
|
|
|
|
// Schema reflects v2 + v3 column additions.
|
|
let cols: Vec<String> = conn
|
|
.prepare("SELECT name FROM pragma_table_info('bookmarks')")
|
|
.unwrap()
|
|
.query_map([], |r| r.get::<_, String>(0))
|
|
.unwrap()
|
|
.collect::<Result<_, _>>()
|
|
.unwrap();
|
|
assert!(cols.contains(&"nickname".to_string()));
|
|
assert!(cols.contains(&"last_connected_at".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn migrator_rejects_out_of_order_versions() {
|
|
let bogus = vec![
|
|
Migration { version: 2, name: "a", sql: "" },
|
|
Migration { version: 1, name: "b", sql: "" },
|
|
];
|
|
assert!(Migrator::new(bogus).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn migrator_rejects_db_newer_than_known_migrations() {
|
|
let mut conn = Connection::open_in_memory().unwrap();
|
|
conn.execute_batch("PRAGMA user_version = 99").unwrap();
|
|
let migrator = Migrator::new(canonical_migrations()).unwrap();
|
|
assert!(migrator.run(&mut conn).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn failed_migration_rolls_back_atomically() {
|
|
let mut conn = Connection::open_in_memory().unwrap();
|
|
|
|
// v1 ok; v2 deliberately broken.
|
|
let broken = vec![
|
|
canonical_migrations()[0],
|
|
Migration {
|
|
version: 2,
|
|
name: "broken",
|
|
sql: "ALTER TABLE not_a_table ADD COLUMN x TEXT;",
|
|
},
|
|
];
|
|
let m = Migrator::new(broken).unwrap();
|
|
assert!(m.run(&mut conn).is_err());
|
|
|
|
// We should still be on v1, not partially upgraded.
|
|
let v: i64 = conn
|
|
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(v, 1);
|
|
}
|
|
|
|
// ---------- Repository pattern ----------
|
|
|
|
#[test]
|
|
fn bookmark_repo_crud_round_trip() {
|
|
let repo = LocalDatabaseRepository::open_in_memory().unwrap();
|
|
let id = BookmarkRepository::insert(
|
|
&repo,
|
|
NewBookmark {
|
|
server_name: "Vigorous",
|
|
server_host: "cn.teamspeak.app",
|
|
identity_ref: "identity.primary",
|
|
nickname: Some("ChanoraPoC"),
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let got = BookmarkRepository::get(&repo, id).unwrap();
|
|
assert_eq!(got.server_name, "Vigorous");
|
|
assert_eq!(got.server_host, "cn.teamspeak.app");
|
|
assert_eq!(got.identity_ref, "identity.primary");
|
|
assert_eq!(got.nickname.as_deref(), Some("ChanoraPoC"));
|
|
assert!(got.last_connected_at.is_none());
|
|
|
|
BookmarkRepository::touch_connected(&repo, id, 1_700_000_000).unwrap();
|
|
let again = BookmarkRepository::get(&repo, id).unwrap();
|
|
assert_eq!(again.last_connected_at, Some(1_700_000_000));
|
|
|
|
BookmarkRepository::delete(&repo, id).unwrap();
|
|
assert!(matches!(
|
|
BookmarkRepository::get(&repo, id),
|
|
Err(sqlite_storage_spike::RepoError::NotFound)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn bookmark_repo_list_orders_recent_first() {
|
|
let repo = LocalDatabaseRepository::open_in_memory().unwrap();
|
|
let a = BookmarkRepository::insert(
|
|
&repo,
|
|
NewBookmark { server_name: "A", server_host: "a.example", identity_ref: "i.a", nickname: None },
|
|
)
|
|
.unwrap();
|
|
let b = BookmarkRepository::insert(
|
|
&repo,
|
|
NewBookmark { server_name: "B", server_host: "b.example", identity_ref: "i.b", nickname: None },
|
|
)
|
|
.unwrap();
|
|
let _c = BookmarkRepository::insert(
|
|
&repo,
|
|
NewBookmark { server_name: "C", server_host: "c.example", identity_ref: "i.c", nickname: None },
|
|
)
|
|
.unwrap();
|
|
BookmarkRepository::touch_connected(&repo, b, 200).unwrap();
|
|
BookmarkRepository::touch_connected(&repo, a, 100).unwrap();
|
|
// c never connected.
|
|
|
|
let list = BookmarkRepository::list_ordered(&repo).unwrap();
|
|
let names: Vec<_> = list.iter().map(|x| x.server_name.as_str()).collect();
|
|
// b (200), a (100), then c (NULL last) — c sorts after by name fallback.
|
|
assert_eq!(names, vec!["B", "A", "C"]);
|
|
}
|
|
|
|
#[test]
|
|
fn bookmark_repo_unique_host_identity_is_enforced() {
|
|
let repo = LocalDatabaseRepository::open_in_memory().unwrap();
|
|
BookmarkRepository::insert(
|
|
&repo,
|
|
NewBookmark {
|
|
server_name: "Vigorous",
|
|
server_host: "cn.teamspeak.app",
|
|
identity_ref: "identity.primary",
|
|
nickname: None,
|
|
},
|
|
)
|
|
.unwrap();
|
|
let dup = BookmarkRepository::insert(
|
|
&repo,
|
|
NewBookmark {
|
|
server_name: "Vigorous Again",
|
|
server_host: "cn.teamspeak.app",
|
|
identity_ref: "identity.primary",
|
|
nickname: None,
|
|
},
|
|
);
|
|
assert!(dup.is_err(), "expected UNIQUE violation");
|
|
}
|
|
|
|
#[test]
|
|
fn settings_repo_upsert_and_delete() {
|
|
let repo = LocalDatabaseRepository::open_in_memory().unwrap();
|
|
SettingsRepository::put(&repo, "audio.aec", "true").unwrap();
|
|
assert_eq!(
|
|
SettingsRepository::get(&repo, "audio.aec").unwrap().as_deref(),
|
|
Some("true")
|
|
);
|
|
|
|
// Upsert (same key, new value).
|
|
SettingsRepository::put(&repo, "audio.aec", "false").unwrap();
|
|
assert_eq!(
|
|
SettingsRepository::get(&repo, "audio.aec").unwrap().as_deref(),
|
|
Some("false")
|
|
);
|
|
|
|
assert!(SettingsRepository::delete(&repo, "audio.aec").unwrap());
|
|
assert!(SettingsRepository::get(&repo, "audio.aec").unwrap().is_none());
|
|
assert!(!SettingsRepository::delete(&repo, "audio.aec").unwrap());
|
|
}
|
|
|
|
// ---------- End-to-end on disk ----------
|
|
|
|
#[test]
|
|
fn open_creates_file_and_persists_across_reopen() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let path = tmp.path().join("chanora.sqlite");
|
|
|
|
{
|
|
let repo = LocalDatabaseRepository::open(&path).unwrap();
|
|
assert_eq!(repo.schema_version().unwrap(), 3);
|
|
BookmarkRepository::insert(
|
|
&repo,
|
|
NewBookmark {
|
|
server_name: "Persist",
|
|
server_host: "persist.example",
|
|
identity_ref: "i.persist",
|
|
nickname: None,
|
|
},
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
// Reopen and verify durability + migrator no-ops.
|
|
let repo2 = LocalDatabaseRepository::open(&path).unwrap();
|
|
assert_eq!(repo2.schema_version().unwrap(), 3);
|
|
let list = BookmarkRepository::list_ordered(&repo2).unwrap();
|
|
assert_eq!(list.len(), 1);
|
|
assert_eq!(list[0].server_name, "Persist");
|
|
}
|