use quorum_core::memory::identity::finding_identity_hash;
use quorum_core::memory::{
DismissalReason, FindingIdentityHash, LocalSqliteMemoryStore, MemoryError, MemoryStore,
PromotionState, ShortHashResolution, TransitionTrigger, BODY_SNAPSHOT_MAX_BYTES,
};
use quorum_core::review::{Finding, FindingSource, Severity};
use rusqlite::Connection;
use tempfile::TempDir;
fn init_repo() -> TempDir {
let td = TempDir::new().unwrap();
let _ = git2::Repository::init(td.path()).unwrap();
td
}
fn sample_finding(title: &str, models: &[&str]) -> Finding {
Finding {
severity: Severity::High,
title: title.to_string(),
body: format!("Confidence: 0.90. Supported by: {}.", models.join(", ")),
source: FindingSource::Divergence,
supported_by: models.iter().map(|s| s.to_string()).collect(),
confidence: Some(0.90),
}
}
#[test]
fn opens_and_migrates_to_current_version() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
assert!(store.path().exists(), "sqlite file must exist after new()");
let conn = Connection::open(store.path()).unwrap();
let version: i64 = conn
.query_row("SELECT version FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(version, 2);
let fwd: String = conn
.query_row(
"SELECT value FROM schema_meta WHERE key = 'forward_compat_min_version'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(fwd, "2");
}
#[test]
fn reopen_is_idempotent() {
let td = init_repo();
drop(LocalSqliteMemoryStore::new(td.path()).unwrap());
drop(LocalSqliteMemoryStore::new(td.path()).unwrap());
let conn = Connection::open(td.path().join(".quorum").join("dismissals.sqlite")).unwrap();
let n: i64 = conn
.query_row("SELECT COUNT(*) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1);
}
#[test]
fn pragmas_applied() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let conn = Connection::open(store.path()).unwrap();
let journal_mode: String = conn
.query_row("PRAGMA journal_mode", [], |r| r.get(0))
.unwrap();
assert!(
journal_mode.eq_ignore_ascii_case("wal") || journal_mode.eq_ignore_ascii_case("delete"),
"unexpected journal_mode {journal_mode}"
);
let foreign_keys: i64 = conn
.query_row("PRAGMA foreign_keys", [], |r| r.get(0))
.unwrap();
assert_eq!(foreign_keys, 1, "foreign_keys must be ON");
let busy_timeout: i64 = conn
.query_row("PRAGMA busy_timeout", [], |r| r.get(0))
.unwrap();
assert_eq!(busy_timeout, 5000, "busy_timeout must be 5000ms");
}
#[test]
fn dismiss_then_load_active_returns_row() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let f = sample_finding("Critical bug", &["claude-sonnet", "gpt-4o"]);
let id = store
.dismiss(
&f,
"headsha",
"main",
DismissalReason::WontFix,
None,
Some(time::Duration::days(365)),
)
.unwrap();
assert!(id.0 > 0);
let active = store.load_active_dismissals().unwrap();
let h = finding_identity_hash(&f);
let row = active.get(&h).expect("dismissal must be active");
assert_eq!(row.recurrence_count, 1);
assert!(row.expires_at.is_some());
assert_eq!(row.promotion_state, PromotionState::Candidate);
assert_eq!(row.reason, DismissalReason::WontFix);
}
#[test]
fn dismiss_duplicate_returns_already_dismissed() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let f = sample_finding("dup", &["m"]);
store
.dismiss(
&f,
"headsha",
"main",
DismissalReason::FalsePositive,
None,
Some(time::Duration::days(365)),
)
.unwrap();
let err = store
.dismiss(
&f,
"headsha",
"main",
DismissalReason::FalsePositive,
None,
Some(time::Duration::days(365)),
)
.unwrap_err();
assert!(matches!(err, MemoryError::AlreadyDismissed));
}
#[test]
fn record_seen_idempotent_per_session_and_bumps_per_new_session() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let f = sample_finding("ZZZ", &["m"]);
store
.dismiss(
&f,
"headsha",
"main",
DismissalReason::FalsePositive,
None,
Some(time::Duration::days(365)),
)
.unwrap();
let h = finding_identity_hash(&f);
let now = time::OffsetDateTime::now_utc();
let high = 1_000_000u32;
store.record_seen(&[h], "session-A", now, high).unwrap();
store
.record_seen(&[h], "session-A", now + time::Duration::seconds(1), high)
.unwrap();
let row = store.load_active_dismissals().unwrap()[&h].clone();
assert_eq!(row.recurrence_count, 2, "session-A bumps once total");
store
.record_seen(&[h], "session-B", now + time::Duration::seconds(2), high)
.unwrap();
let row = store.load_active_dismissals().unwrap()[&h].clone();
assert_eq!(row.recurrence_count, 3, "session-B bumps once more");
}
#[test]
fn delete_idempotent_on_unknown_and_known_ids() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
assert!(!store
.delete(quorum_core::memory::DismissalId(99_999))
.unwrap());
let f = sample_finding("D", &["m"]);
let id = store
.dismiss(
&f,
"h",
"main",
DismissalReason::Intentional,
None,
Some(time::Duration::days(365)),
)
.unwrap();
assert!(store.delete(id).unwrap());
assert!(!store.delete(id).unwrap());
}
#[test]
fn permanent_dismissal_persists_indefinitely() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let f = sample_finding("permanent", &["m"]);
store
.dismiss(&f, "h", "main", DismissalReason::Intentional, None, None)
.unwrap();
let active = store.load_active_dismissals().unwrap();
let row = active.get(&finding_identity_hash(&f)).unwrap();
assert!(row.expires_at.is_none());
}
#[test]
fn expired_dismissal_not_returned() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let f = sample_finding("already-expired", &["m"]);
store
.dismiss(
&f,
"h",
"main",
DismissalReason::FalsePositive,
None,
Some(time::Duration::seconds(-1)),
)
.unwrap();
let active = store.load_active_dismissals().unwrap();
assert!(active.is_empty());
}
#[test]
fn check_constraints_fire() {
let td = init_repo();
drop(LocalSqliteMemoryStore::new(td.path()).unwrap());
let conn = Connection::open(td.path().join(".quorum").join("dismissals.sqlite")).unwrap();
let insert_with = |reason: &str, note: Option<&str>, promo: &str, count: i64| {
conn.execute(
"INSERT INTO dismissals (
finding_identity_hash, title_snapshot, source_type_snapshot,
models_snapshot, branch_snapshot, reason, note,
dismissed_at, last_seen_at, recurrence_count,
repo_head_sha_first, promotion_state
) VALUES (?1, 't', 'agreement', '[]', 'main', ?2, ?3, ?4, ?4, ?5, 'sha', ?6)",
rusqlite::params![
format!("{:064}", count), reason,
note,
"2026-01-01T00:00:00Z",
count,
promo,
],
)
};
assert!(
insert_with("false_positive", None, "candidate", 0).is_err(),
"recurrence_count = 0 must fail"
);
assert!(
insert_with("bogus_reason", None, "candidate", 1).is_err(),
"unknown reason must fail"
);
assert!(
insert_with("other", None, "candidate", 1).is_err(),
"reason=other without note must fail"
);
assert!(
insert_with("false_positive", None, "bogus_promo", 1).is_err(),
"unknown promotion_state must fail"
);
assert!(
insert_with("false_positive", None, "candidate", 1).is_ok(),
"valid row should succeed"
);
}
#[test]
fn body_snapshot_bounded_to_2kb_at_codepoint_boundary() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let huge = "ä".repeat(3000); let f = Finding {
body: huge.clone(),
..sample_finding("BIG", &["m"])
};
let id = store
.dismiss(
&f,
"h",
"main",
DismissalReason::FalsePositive,
None,
Some(time::Duration::days(365)),
)
.unwrap();
let row = store.get(id).unwrap().unwrap();
let body = row.body_snapshot.unwrap();
assert!(body.len() <= BODY_SNAPSHOT_MAX_BYTES);
assert!(huge.starts_with(&body));
}
#[test]
fn reopen_after_dropping_with_wal_sidecars() {
let td = init_repo();
let f = sample_finding("preserved", &["m"]);
let key: FindingIdentityHash;
{
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
key = finding_identity_hash(&f);
store
.dismiss(
&f,
"h",
"main",
DismissalReason::WontFix,
None,
Some(time::Duration::days(365)),
)
.unwrap();
}
let store2 = LocalSqliteMemoryStore::new(td.path()).unwrap();
let active = store2.load_active_dismissals().unwrap();
assert!(active.contains_key(&key));
}
#[test]
fn other_without_note_rejected_at_trait_layer() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let f = sample_finding("o", &["m"]);
let err = store
.dismiss(
&f,
"h",
"main",
DismissalReason::Other,
None,
Some(time::Duration::days(365)),
)
.unwrap_err();
assert!(matches!(err, MemoryError::OtherWithoutNote));
}
#[test]
fn note_size_and_format_validated() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let f = sample_finding("n", &["m"]);
let too_big = "x".repeat(quorum_core::memory::NOTE_MAX_BYTES + 1);
let err = store
.dismiss(
&f,
"h",
"main",
DismissalReason::FalsePositive,
Some(too_big),
Some(time::Duration::days(365)),
)
.unwrap_err();
assert!(matches!(err, MemoryError::InvalidNote));
let with_newline = "valid\nnope".to_string();
let f2 = sample_finding("n2", &["m"]);
let err = store
.dismiss(
&f2,
"h",
"main",
DismissalReason::FalsePositive,
Some(with_newline),
Some(time::Duration::days(365)),
)
.unwrap_err();
assert!(matches!(err, MemoryError::InvalidNote));
}
#[test]
fn gitignore_written_on_first_open() {
let td = init_repo();
let _store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let gi = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
assert!(gi.contains(".quorum/dismissals.sqlite*"));
assert!(gi.contains("Quorum dismissals store"));
}
fn build_v1_fixture(path: &std::path::Path) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(
"CREATE TABLE schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
CREATE TABLE dismissals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
finding_identity_hash TEXT NOT NULL UNIQUE,
title_snapshot TEXT NOT NULL,
body_snapshot TEXT,
source_type_snapshot TEXT NOT NULL,
models_snapshot TEXT NOT NULL,
branch_snapshot TEXT NOT NULL,
reason TEXT NOT NULL,
note TEXT,
dismissed_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
last_seen_session_id TEXT,
recurrence_count INTEGER NOT NULL DEFAULT 1,
expires_at TEXT,
repo_head_sha_first TEXT NOT NULL,
promotion_state TEXT NOT NULL DEFAULT 'candidate',
CHECK (recurrence_count >= 1),
CHECK (reason IN ('false_positive','intentional','out_of_scope','wont_fix','other')),
CHECK (reason != 'other' OR note IS NOT NULL),
CHECK (promotion_state IN ('candidate','local_only','promoted_convention'))
);
CREATE INDEX idx_dismissals_expires_at ON dismissals(expires_at);
INSERT INTO schema_version (version, applied_at) VALUES (1, '2026-01-01T00:00:00Z');",
)
.unwrap();
conn.execute(
"INSERT INTO dismissals (
finding_identity_hash, title_snapshot, source_type_snapshot,
models_snapshot, branch_snapshot, reason,
dismissed_at, last_seen_at, recurrence_count, repo_head_sha_first
) VALUES (?1, 'preexisting', 'agreement', '[]', 'main', 'false_positive',
?2, ?2, 2, 'sha')",
rusqlite::params!["e".repeat(64), "2026-01-01T00:00:00Z"],
)
.unwrap();
}
#[test]
fn migrate_upgrades_v1_fixture_to_v2() {
let td = init_repo();
let quorum_dir = td.path().join(".quorum");
std::fs::create_dir_all(&quorum_dir).unwrap();
let db_path = quorum_dir.join("dismissals.sqlite");
build_v1_fixture(&db_path);
let _store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let conn = Connection::open(&db_path).unwrap();
let version: i64 = conn
.query_row("SELECT version FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(version, 2, "schema_version must be bumped to 2");
let n: i64 = conn
.query_row("SELECT COUNT(*) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1, "schema_version must remain a single row");
let fwd: String = conn
.query_row(
"SELECT value FROM schema_meta WHERE key = 'forward_compat_min_version'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(fwd, "2");
let surviving: i64 = conn
.query_row(
"SELECT COUNT(*) FROM dismissals WHERE title_snapshot = 'preexisting'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(surviving, 1);
let st_count: i64 = conn
.query_row("SELECT COUNT(*) FROM state_transitions", [], |r| r.get(0))
.unwrap();
assert_eq!(st_count, 0);
}
#[test]
fn migrate_is_idempotent_on_v2() {
let td = init_repo();
let db_path = {
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
store.path().to_owned()
};
{
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let f = sample_finding("idempotency probe", &["m"]);
store
.dismiss(
&f,
"h",
"main",
DismissalReason::FalsePositive,
None,
Some(time::Duration::days(365)),
)
.unwrap();
}
for _ in 0..3 {
drop(LocalSqliteMemoryStore::new(td.path()).unwrap());
}
let conn = Connection::open(&db_path).unwrap();
let version: i64 = conn
.query_row("SELECT version FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(version, 2);
let n: i64 = conn
.query_row("SELECT COUNT(*) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1, "no extra schema_version rows after re-open");
let meta_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM schema_meta WHERE key = 'forward_compat_min_version'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(meta_count, 1);
let dismissals: i64 = conn
.query_row("SELECT COUNT(*) FROM dismissals", [], |r| r.get(0))
.unwrap();
assert_eq!(dismissals, 1, "existing dismissal preserved across re-open");
}
#[test]
fn state_transitions_fk_cascades_on_dismissal_delete() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let f = sample_finding("for cascade", &["m"]);
let id = store
.dismiss(
&f,
"h",
"main",
DismissalReason::FalsePositive,
None,
Some(time::Duration::days(365)),
)
.unwrap();
let hash_hex = finding_identity_hash(&f).to_hex();
let conn = Connection::open(store.path()).unwrap();
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
conn.execute(
"INSERT INTO state_transitions
(finding_identity_hash, from_state, to_state, trigger, ts,
by_review_session_id, recurrence_at_transition)
VALUES (?1, 'candidate', 'local_only', 'auto_recurrence', ?2, ?3, 3)",
rusqlite::params![hash_hex, 1_700_000_000_000i64, "S-cascade-test"],
)
.unwrap();
let pre: i64 = conn
.query_row(
"SELECT COUNT(*) FROM state_transitions WHERE finding_identity_hash = ?1",
[&hash_hex],
|r| r.get(0),
)
.unwrap();
assert_eq!(pre, 1);
assert!(store.delete(id).unwrap());
let post: i64 = conn
.query_row(
"SELECT COUNT(*) FROM state_transitions WHERE finding_identity_hash = ?1",
[&hash_hex],
|r| r.get(0),
)
.unwrap();
assert_eq!(post, 0, "FK CASCADE must drop the audit log");
}
#[test]
fn forward_compat_rejects_future_schema_version() {
let td = init_repo();
drop(LocalSqliteMemoryStore::new(td.path()).unwrap());
let db_path = td.path().join(".quorum").join("dismissals.sqlite");
{
let conn = Connection::open(&db_path).unwrap();
conn.execute("UPDATE schema_version SET version = 3", [])
.unwrap();
}
let err = match LocalSqliteMemoryStore::new(td.path()) {
Ok(_) => panic!("expected SchemaTooNew error"),
Err(e) => e,
};
match err {
MemoryError::SchemaTooNew {
schema_version,
forward_compat_min,
} => {
assert_eq!(schema_version, 3);
assert_eq!(forward_compat_min, 2);
}
other => panic!("expected SchemaTooNew, got {other:?}"),
}
}
#[test]
fn forward_compat_rejects_future_min_version_marker() {
let td = init_repo();
drop(LocalSqliteMemoryStore::new(td.path()).unwrap());
let db_path = td.path().join(".quorum").join("dismissals.sqlite");
{
let conn = Connection::open(&db_path).unwrap();
conn.execute(
"INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('forward_compat_min_version', '3')",
[],
)
.unwrap();
}
let err = match LocalSqliteMemoryStore::new(td.path()) {
Ok(_) => panic!("expected SchemaTooNew error"),
Err(e) => e,
};
match err {
MemoryError::SchemaTooNew {
schema_version,
forward_compat_min,
} => {
assert_eq!(schema_version, 2);
assert_eq!(forward_compat_min, 3);
}
other => panic!("expected SchemaTooNew, got {other:?}"),
}
}
#[test]
fn conventions_text_byte_count_check_roundtrip() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let f = sample_finding("for-check-roundtrip", &["m"]);
store
.dismiss(
&f,
"h",
"main",
DismissalReason::FalsePositive,
None,
Some(time::Duration::days(365)),
)
.unwrap();
let hash_hex = finding_identity_hash(&f).to_hex();
let conn = Connection::open(store.path()).unwrap();
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
let insert = |hash: &str, text: &str| -> rusqlite::Result<usize> {
conn.execute(
"INSERT INTO conventions
(finding_identity_hash, convention_text, promoted_at, conventions_md_block_id)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![hash, text, 1_700_000_000_000i64, "abcdef012345"],
)
};
let ok_4096 = "a".repeat(4096);
insert(&hash_hex, &ok_4096).expect("4096-byte text must pass");
conn.execute(
"DELETE FROM conventions WHERE finding_identity_hash = ?1",
[&hash_hex],
)
.unwrap();
let too_big = "a".repeat(4097);
assert!(
insert(&hash_hex, &too_big).is_err(),
"4097-byte text must fail byte-count CHECK"
);
insert(&hash_hex, "x").expect("1-byte text must pass");
conn.execute(
"DELETE FROM conventions WHERE finding_identity_hash = ?1",
[&hash_hex],
)
.unwrap();
assert!(
insert(&hash_hex, "").is_err(),
"empty text must fail byte-count CHECK"
);
let mut emoji_overflow = String::with_capacity(4100);
for _ in 0..1025 {
emoji_overflow.push('\u{1F600}'); }
assert_eq!(emoji_overflow.len(), 4100);
assert!(
insert(&hash_hex, &emoji_overflow).is_err(),
"multi-byte text >4096 bytes must fail; CHECK is byte-count, not codepoint-count"
);
let mut emoji_ok = String::with_capacity(4096);
for _ in 0..1024 {
emoji_ok.push('\u{1F600}');
}
assert_eq!(emoji_ok.len(), 4096);
insert(&hash_hex, &emoji_ok).expect("exactly-4096-byte UTF-8 text must pass");
}
#[test]
fn no_secret_material_persisted() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let f = sample_finding("Normal finding", &["claude-sonnet", "gpt-4o", "gemini-pro"]);
store
.dismiss(
&f,
"head-sha-abc",
"main",
DismissalReason::FalsePositive,
Some("safe note about a code pattern".into()),
Some(time::Duration::days(365)),
)
.unwrap();
drop(store);
let bytes = std::fs::read(td.path().join(".quorum").join("dismissals.sqlite")).unwrap();
let dump = String::from_utf8_lossy(&bytes);
for forbidden in [
"QUORUM_LIPPA_SESSION",
"QUORUM_LIPPA_PASSWORD",
"session=",
"@lippa", "Cookie:",
] {
assert!(
!dump.contains(forbidden),
"dismissals.sqlite must not leak {forbidden}"
);
}
}
fn dismiss_with_state(
store: &LocalSqliteMemoryStore,
title: &str,
models: &[&str],
state: PromotionState,
) -> FindingIdentityHash {
let f = sample_finding(title, models);
let h = finding_identity_hash(&f);
store
.dismiss(
&f,
"head-sha",
"main",
DismissalReason::FalsePositive,
None,
Some(time::Duration::days(365)),
)
.unwrap();
if state != PromotionState::Candidate {
let conn = Connection::open(store.path()).unwrap();
conn.execute(
"UPDATE dismissals SET promotion_state = ?1 WHERE finding_identity_hash = ?2",
rusqlite::params![state.as_db_str(), h.to_hex()],
)
.unwrap();
}
h
}
#[test]
fn list_by_state_filters_and_sorts() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let _h_a = dismiss_with_state(&store, "alpha", &["m"], PromotionState::Candidate);
let h_b = dismiss_with_state(&store, "bravo", &["m"], PromotionState::LocalOnly);
let _h_c = dismiss_with_state(
&store,
"charlie",
&["m"],
PromotionState::PromotedConvention,
);
{
let conn = Connection::open(store.path()).unwrap();
conn.execute(
"UPDATE dismissals SET recurrence_count = 7 WHERE finding_identity_hash = ?1",
[h_b.to_hex()],
)
.unwrap();
}
let all = store.list_by_state(None).unwrap();
assert_eq!(all.len(), 3, "None returns every row");
assert_eq!(all[0].title_snapshot, "bravo");
let local = store
.list_by_state(Some(PromotionState::LocalOnly))
.unwrap();
assert_eq!(local.len(), 1);
assert_eq!(local[0].title_snapshot, "bravo");
let cand = store
.list_by_state(Some(PromotionState::Candidate))
.unwrap();
assert_eq!(cand.len(), 1);
assert_eq!(cand[0].title_snapshot, "alpha");
let prom = store
.list_by_state(Some(PromotionState::PromotedConvention))
.unwrap();
assert_eq!(prom.len(), 1);
assert_eq!(prom[0].title_snapshot, "charlie");
}
#[test]
fn find_by_short_hash_exact_ambiguous_notfound_and_full64() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h1 = dismiss_with_state(&store, "row-1", &["m"], PromotionState::Candidate);
let h2 = dismiss_with_state(&store, "row-2", &["m"], PromotionState::Candidate);
let prefix = "deadbeef";
let full1 = format!("{prefix}{}", &h1.to_hex()[8..]);
let full2 = format!("{prefix}{}", &h2.to_hex()[8..]);
{
let conn = Connection::open(store.path()).unwrap();
conn.execute(
"UPDATE dismissals SET finding_identity_hash = ?1 WHERE title_snapshot = 'row-1'",
[&full1],
)
.unwrap();
conn.execute(
"UPDATE dismissals SET finding_identity_hash = ?1 WHERE title_snapshot = 'row-2'",
[&full2],
)
.unwrap();
}
let res = store.find_by_short_hash(prefix).unwrap();
assert!(
matches!(res, ShortHashResolution::Ambiguous(ref v) if v.len() == 2),
"two rows sharing 8-hex prefix → Ambiguous"
);
let exact_prefix = &full1[..16];
let res = store.find_by_short_hash(exact_prefix).unwrap();
assert!(matches!(res, ShortHashResolution::Exact(ref d) if d.title_snapshot == "row-1"));
let res = store.find_by_short_hash(&full1).unwrap();
assert!(matches!(res, ShortHashResolution::Exact(_)));
let res = store.find_by_short_hash("00000000").unwrap();
assert!(matches!(res, ShortHashResolution::NotFound));
}
#[test]
fn find_by_short_hash_rejects_short_and_nonhex() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let err = store.find_by_short_hash("abcd").unwrap_err();
assert!(matches!(err, MemoryError::Backend(_)));
let err = store.find_by_short_hash("nothexpfx").unwrap_err();
assert!(matches!(err, MemoryError::Backend(_)));
}
#[test]
fn load_transitions_for_v2_row_returns_audit_log_oldest_first() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = dismiss_with_state(&store, "audit-row", &["m"], PromotionState::Candidate);
{
let conn = Connection::open(store.path()).unwrap();
conn.execute(
"INSERT INTO state_transitions
(finding_identity_hash, from_state, to_state, trigger, ts,
by_review_session_id, recurrence_at_transition)
VALUES (?1, 'candidate', 'local_only', 'auto_recurrence', 1000, 'sess-1', 3)",
[h.to_hex()],
)
.unwrap();
conn.execute(
"INSERT INTO state_transitions
(finding_identity_hash, from_state, to_state, trigger, ts,
by_review_session_id, recurrence_at_transition)
VALUES (?1, 'local_only', 'promoted_convention', 'explicit_promote', 5000, NULL, NULL)",
[h.to_hex()],
)
.unwrap();
}
let rows = store.load_transitions(&h).unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].ts_ms, 1000, "oldest-first");
assert_eq!(rows[0].trigger, TransitionTrigger::AutoRecurrence);
assert_eq!(rows[0].by_review_session_id.as_deref(), Some("sess-1"));
assert_eq!(rows[0].recurrence_at_transition, Some(3));
assert_eq!(rows[1].ts_ms, 5000);
assert_eq!(rows[1].trigger, TransitionTrigger::ExplicitPromote);
assert!(rows[1].by_review_session_id.is_none());
assert!(rows[1].recurrence_at_transition.is_none());
}
#[test]
fn load_transitions_for_pre_v2_row_returns_empty() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = dismiss_with_state(&store, "no-audit", &["m"], PromotionState::Candidate);
let rows = store.load_transitions(&h).unwrap();
assert!(rows.is_empty());
}
#[test]
fn load_transitions_for_unknown_hash_returns_empty() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let bogus = FindingIdentityHash([0u8; 32]);
let rows = store.load_transitions(&bogus).unwrap();
assert!(rows.is_empty());
}