Files
chanora/poc/sqlite-storage-spike/tests/storage.rs
T
EdisonJwa 52e8d43f69 feat(poc/storage): add sqlite-storage spike
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.
2026-05-14 12:26:36 +08:00

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");
}