#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::collections::BTreeMap;
use sqlite_core::Database;
use sqlite_forensic::{
attribute_records, carve_all_deleted_records, table_instance_risks_with_sidecar,
TableInstanceRisk,
};
const B_ALTERED: &[u8] = include_bytes!("../../tests/data/drop_recreate/b_journal_altered.db");
const B_ALTERED_JRNL: &[u8] =
include_bytes!("../../tests/data/drop_recreate/b_journal_altered.db-journal");
const B_DML: &[u8] = include_bytes!("../../tests/data/drop_recreate/b_journal_dml.db");
const B_DML_JRNL: &[u8] = include_bytes!("../../tests/data/drop_recreate/b_journal_dml.db-journal");
const B_AUTOINC: &[u8] = include_bytes!("../../tests/data/drop_recreate/b_autoinc.db");
const B_PLAINPK: &[u8] = include_bytes!("../../tests/data/drop_recreate/b_plainpk.db");
fn b_fired_tables(risks: &[TableInstanceRisk]) -> std::collections::BTreeSet<String> {
risks
.iter()
.filter_map(|r| match r {
TableInstanceRisk::SidecarSchemaChanged { table } => Some(table.clone()),
_ => None,
})
.collect()
}
fn risks_with_journal(
main: &[u8],
journal: &[u8],
) -> (
Vec<sqlite_forensic::CarvedRecord>,
Vec<TableInstanceRisk>,
Database,
) {
let db = Database::open(main.to_vec()).expect("open fixture");
let recs = carve_all_deleted_records(&db);
let attrs = attribute_records(&db, &recs);
let prior = db
.rollback_prior(journal)
.expect("rollback_prior parses the PERSIST journal");
let prior_schema = prior.schema_sql();
let risks = table_instance_risks_with_sidecar(&db, &recs, &attrs, &prior_schema);
assert_eq!(risks.len(), recs.len(), "one risk per record");
(recs, risks, db)
}
#[test]
fn altered_schema_fires_detector_b() {
let (recs, risks, _db) = risks_with_journal(B_ALTERED, B_ALTERED_JRNL);
assert!(!recs.is_empty(), "the altered fixture yields residue");
let fired = b_fired_tables(&risks);
assert!(
fired.contains("students"),
"Detector B fires for students on the altered-schema sidecar; got {fired:?}"
);
}
#[test]
fn dml_only_does_not_fire_detector_b() {
let (recs, risks, _db) = risks_with_journal(B_DML, B_DML_JRNL);
assert!(!recs.is_empty(), "the dml fixture yields residue");
assert!(
b_fired_tables(&risks).is_empty(),
"DML-only ⟹ no schema change ⟹ Detector B must NOT fire"
);
}
#[test]
fn no_sidecar_never_fires_detector_b() {
let db = Database::open(B_ALTERED.to_vec()).expect("open fixture");
let recs = carve_all_deleted_records(&db);
let attrs = attribute_records(&db, &recs);
let empty: BTreeMap<String, String> = BTreeMap::new();
let risks = table_instance_risks_with_sidecar(&db, &recs, &attrs, &empty);
assert!(
b_fired_tables(&risks).is_empty(),
"no prior schema ⟹ Detector B silent"
);
}
#[test]
fn bare_detector_a_fixtures_never_trip_detector_b() {
let empty: BTreeMap<String, String> = BTreeMap::new();
for bytes in [B_AUTOINC, B_PLAINPK] {
let db = Database::open(bytes.to_vec()).expect("open fixture");
let recs = carve_all_deleted_records(&db);
let attrs = attribute_records(&db, &recs);
let risks = table_instance_risks_with_sidecar(&db, &recs, &attrs, &empty);
assert!(
b_fired_tables(&risks).is_empty(),
"a bare A fixture must never trip Detector B"
);
}
let db = Database::open(B_AUTOINC.to_vec()).expect("open b_autoinc.db");
let recs = carve_all_deleted_records(&db);
let attrs = attribute_records(&db, &recs);
let risks = table_instance_risks_with_sidecar(&db, &recs, &attrs, &empty);
assert!(
risks
.iter()
.any(|r| matches!(r, TableInstanceRisk::RowidExceedsAutoincHighwater { .. })),
"Detector A still surfaces under the sidecar-aware pass"
);
}
#[test]
fn nemetz_databases_never_spuriously_flag() {
let empty: BTreeMap<String, String> = BTreeMap::new();
for rel in ["nemetz/0C/0C-01.db", "nemetz/0D/0D-01.db"] {
let path = format!("{}/../tests/data/{rel}", env!("CARGO_MANIFEST_DIR"));
let Ok(bytes) = std::fs::read(&path) else {
continue; };
let db = Database::open(bytes).expect("open nemetz db");
let recs = carve_all_deleted_records(&db);
let attrs = attribute_records(&db, &recs);
let risks = table_instance_risks_with_sidecar(&db, &recs, &attrs, &empty);
assert!(
risks.iter().all(|r| *r == TableInstanceRisk::None),
"{rel}: ordinary Nemetz deleted rows must NEVER trip the flag"
);
}
}
#[test]
fn detector_b_note_and_token_are_evidence_bearing_and_non_overclaiming() {
let risk = TableInstanceRisk::SidecarSchemaChanged {
table: "students".to_string(),
};
let note = risk.note();
assert!(note.contains("students"), "note shows the table");
assert!(
note.contains("sidecar") || note.contains("-wal") || note.contains("-journal"),
"note shows the signal is sidecar-derived"
);
assert!(
note.contains("consistent with"),
"note uses consistent-with framing"
);
let lower = note.to_lowercase();
assert!(
!lower.contains("predecessor"),
"note must never assert 'predecessor'"
);
assert_eq!(
risk.token(),
"sidecar_schema_changed(students)",
"token names the table"
);
}