use crate::commands::doctor_fts::handle_doctor_fts;
use crate::errors::Error;
use crate::memory::crud::test_fake_embedder;
use crate::output::DoctorFtsResponse;
use crate::sqlite::Database;
use crate::sqlite::fts::FtsDesyncReport;
use rusqlite::Connection;
use std::path::PathBuf;
use std::process::ExitCode;
fn create_test_db() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("test.db");
Database::open(&path).unwrap();
(dir, path)
}
fn data_version(conn: &Connection) -> i64 {
conn.query_row("PRAGMA data_version", [], |r| r.get(0))
.unwrap()
}
fn fts_count(conn: &Connection) -> i64 {
conn.query_row("SELECT COUNT(*) FROM memories_fts", [], |r| r.get(0))
.unwrap()
}
fn memory_count(conn: &Connection) -> i64 {
conn.query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
.unwrap()
}
#[test]
fn f1_healthy_database_reports_in_sync() {
let (_dir, db_path) = create_test_db();
let db = Database::open(&db_path).unwrap();
let emb = test_fake_embedder("hello world").unwrap();
db.insert("proj-a", "hello world", &emb, None, "fact", "active")
.unwrap();
let version_before = data_version(db.conn());
let result = handle_doctor_fts(&db_path, None, false, false);
assert!(result.is_ok());
assert_eq!(result.unwrap(), ExitCode::SUCCESS, "in-sync must exit 0");
let version_after = data_version(db.conn());
assert_eq!(
version_before, version_after,
"detection must perform zero database writes"
);
}
#[test]
fn f1_fresh_zero_row_db_is_healthy_not_desync() {
let (_dir, db_path) = create_test_db();
let result = handle_doctor_fts(&db_path, None, false, false);
assert!(result.is_ok());
assert_eq!(result.unwrap(), ExitCode::SUCCESS, "zero/zero must exit 0");
let db = Database::from_conn(
Connection::open_with_flags(&db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap(),
);
let report = db.detect_fts_desync().unwrap();
assert!(!report.is_desynced(), "0/0 is NOT a desync");
assert_eq!(report.total_desynced(), 0);
}
#[test]
fn f2_missing_fts_table_reports_in_sync() {
let (_dir, db_path) = create_test_db();
let db = Database::open(&db_path).unwrap();
let emb = test_fake_embedder("hello").unwrap();
db.insert("proj-a", "hello", &emb, None, "fact", "active")
.unwrap();
db.conn().execute("DROP TABLE memories_fts", []).unwrap();
let report = db.detect_fts_desync().unwrap();
assert!(
!report.is_desynced(),
"missing FTS table is treated as in-sync"
);
assert_eq!(report.total_desynced(), 0);
}
#[test]
fn f3_healthy_db_with_multiple_rows_is_in_sync() {
let (_dir, db_path) = create_test_db();
let db = Database::open(&db_path).unwrap();
for i in 0..5 {
let emb = test_fake_embedder(&format!("memory {i}")).unwrap();
let proj = if i < 3 { "proj-a" } else { "proj-b" };
db.insert(proj, &format!("memory {i}"), &emb, None, "fact", "active")
.unwrap();
}
let report = db.detect_fts_desync().unwrap();
assert!(!report.is_desynced(), "trigger-synced DB must be in-sync");
assert_eq!(report.underpopulated_global, 0);
assert_eq!(report.orphans, 0);
assert_eq!(memory_count(db.conn()), fts_count(db.conn()));
}
#[test]
fn f5_p_scoping_in_sync_projects() {
let (_dir, db_path) = create_test_db();
let db = Database::open(&db_path).unwrap();
for i in 0..2 {
let emb = test_fake_embedder(&format!("x {i}")).unwrap();
db.insert("X", &format!("x {i}"), &emb, None, "fact", "active")
.unwrap();
}
let emb = test_fake_embedder("y only").unwrap();
db.insert("Y", "y only", &emb, None, "fact", "active")
.unwrap();
let all_report = db.detect_fts_desync().unwrap();
assert!(!all_report.is_desynced());
assert!(
handle_doctor_fts(&db_path, Some("X"), false, true).is_ok(),
"scoped -p X must not error"
);
assert!(
handle_doctor_fts(&db_path, Some("Y"), false, false).is_ok(),
"a project with zero desynced rows must report in-sync, not error"
);
}
#[test]
fn f6_repair_on_healthy_db_skips_rebuild_and_leaves_version_unchanged() {
let (_dir, db_path) = create_test_db();
let db = Database::open(&db_path).unwrap();
let emb = test_fake_embedder("hello").unwrap();
db.insert("proj-a", "hello", &emb, None, "fact", "active")
.unwrap();
let version_before = data_version(db.conn());
let result = handle_doctor_fts(&db_path, None, true, false);
assert!(result.is_ok());
assert_eq!(result.unwrap(), ExitCode::SUCCESS);
let version_after = data_version(db.conn());
assert_eq!(
version_before, version_after,
"repair with zero desync must SKIP the rebuild (data_version unchanged)"
);
}
#[test]
fn f7_rebuild_on_in_sync_db_preserves_memories() {
let (_dir, db_path) = create_test_db();
let db = Database::open(&db_path).unwrap();
for i in 0..3 {
let emb = test_fake_embedder(&format!("row {i}")).unwrap();
db.insert("proj-a", &format!("row {i}"), &emb, None, "fact", "active")
.unwrap();
}
let snapshot_before: Vec<(String, String)> = {
let mut stmt = db
.conn()
.prepare("SELECT id, content FROM memories ORDER BY id")
.unwrap();
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap();
rows.map(|r| r.unwrap()).collect()
};
assert_eq!(snapshot_before.len(), 3, "sanity: 3 seeded rows");
let result = handle_doctor_fts(&db_path, None, true, false);
assert!(result.is_ok());
assert_eq!(result.unwrap(), ExitCode::SUCCESS);
let db2 = Database::open(&db_path).unwrap();
for name in [
"memories_fts_insert",
"memories_fts_delete",
"memories_fts_update",
] {
let present: i64 = db2
.conn()
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='trigger' AND name=?1",
rusqlite::params![name],
|r| r.get(0),
)
.unwrap();
assert_eq!(present, 1, "trigger {name} must still exist");
}
let snapshot_after: Vec<(String, String)> = {
let mut stmt = db2
.conn()
.prepare("SELECT id, content FROM memories ORDER BY id")
.unwrap();
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap();
rows.map(|r| r.unwrap()).collect()
};
assert_eq!(
snapshot_before, snapshot_after,
"memories rows must be byte-unchanged after repair skip"
);
}
#[test]
fn repair_is_idempotent_second_run_skips() {
let (_dir, db_path) = create_test_db();
let db = Database::open(&db_path).unwrap();
let emb = test_fake_embedder("a").unwrap();
db.insert("proj-a", "a", &emb, None, "fact", "active")
.unwrap();
assert!(handle_doctor_fts(&db_path, None, true, false).is_ok());
let db2 = Database::open(&db_path).unwrap();
let version_before = data_version(db2.conn());
assert!(handle_doctor_fts(&db_path, None, true, false).is_ok());
let version_after = data_version(db2.conn());
assert_eq!(
version_before, version_after,
"second repair run must skip (data_version unchanged)"
);
}
#[test]
fn f9_locked_db_fast_fails_on_detect_and_repair() {
let (_dir, db_path) = create_test_db();
let lock_conn = Connection::open(&db_path).unwrap();
lock_conn.execute("BEGIN EXCLUSIVE", []).unwrap();
match handle_doctor_fts(&db_path, None, false, false) {
Err(Error::Config(msg)) => {
assert!(msg.contains("locked"), "got: {msg}");
assert!(msg.contains("MCP server"), "got: {msg}");
}
other => panic!("expected Config locked error, got {other:?}"),
}
match handle_doctor_fts(&db_path, None, true, false) {
Err(Error::Config(msg)) => {
assert!(msg.contains("locked"), "got: {msg}");
assert!(msg.contains("MCP server"), "got: {msg}");
}
other => panic!("expected Config locked error, got {other:?}"),
}
lock_conn.execute("ROLLBACK", []).unwrap();
}
#[test]
fn fts_desync_report_helpers() {
let healthy = FtsDesyncReport::default();
assert!(!healthy.is_desynced());
assert_eq!(healthy.total_desynced(), 0);
let desynced = FtsDesyncReport {
underpopulated_by_project: [("p".to_string(), 2)].into_iter().collect(),
underpopulated_global: 2,
orphans: 1,
};
assert!(desynced.is_desynced());
assert_eq!(desynced.total_desynced(), 3);
}
#[test]
fn doctor_fts_response_serializes_expected_fields() {
let response = DoctorFtsResponse {
in_sync: false,
underpopulated_by_project: vec![crate::output::DoctorFtsProject {
project_id: "proj".to_string(),
memory_rows: 5,
missing_from_fts: 2,
}],
orphan_rows: 1,
total_desynced: 3,
repaired: true,
actions: 3,
};
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("\"in_sync\":false"));
assert!(json.contains("\"orphan_rows\":1"));
assert!(json.contains("\"total_desynced\":3"));
assert!(json.contains("\"repaired\":true"));
assert!(json.contains("\"actions\":3"));
assert!(json.contains("\"missing_from_fts\":2"));
}