#![cfg(test)]
use crate::memory::crud::test_fake_embedder;
use crate::sqlite::Database;
use crate::sqlite::identity;
const CONFIG_ENV_VARS_EXCLUDED: &[&str] = crate::config::tests_utils::CONFIG_ENV_VARS;
const PROJECT_ENV_VARS_EXCLUDED: &[&str] = &["VIPUNE_PROJECT", "VIPUNE_DEBUG"];
fn create_test_db() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("test.db");
Database::open(&path).unwrap();
(dir, path)
}
fn get_embedding(db: &Database, id: &str) -> Vec<f32> {
db.list_all_rows_for_project("proj")
.unwrap()
.iter()
.find(|(i, _, _)| i == id)
.map(|(_, _, e)| e.clone())
.unwrap()
}
fn marker_only(conn: &rusqlite::Connection, target: &identity::ModelIdentity) {
conn.execute(
"INSERT INTO model_identity (id, model_id, model_revision, migration_marker)
VALUES (1, NULL, NULL, ?1)
ON CONFLICT(id) DO UPDATE SET migration_marker = excluded.migration_marker",
[crate::migration::migration_marker_for(target)],
)
.unwrap();
}
fn vipune_bin() -> std::path::PathBuf {
let exe = std::env::current_exe().expect("current_exe");
let mut path = exe;
for _ in 0..4 {
if let Some(parent) = path.parent() {
let candidate = parent.join("vipune");
if candidate.exists() {
return candidate;
}
path = parent.to_path_buf();
}
}
panic!("could not locate the vipune binary for subprocess capture");
}
fn capture_force_output(db_path: &std::path::Path, json: bool) -> (i32, String, String) {
let mut cmd = std::process::Command::new(vipune_bin());
cmd.arg("reindex")
.arg("--force")
.arg("--all-projects")
.arg("--db-path")
.arg(db_path);
if json {
cmd.arg("--json");
}
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
for var in CONFIG_ENV_VARS_EXCLUDED
.iter()
.chain(PROJECT_ENV_VARS_EXCLUDED.iter())
{
cmd.env_remove(var);
}
let child = {
let _lock = crate::config::tests_utils::lock_env_mutex();
cmd.spawn().expect("spawn vipune reindex --force")
};
let output = child
.wait_with_output()
.expect("wait for vipune reindex --force");
let code = output.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
(code, stdout, stderr)
}
#[test]
fn test_force_json_success_output() {
let (_dir, db_path) = create_test_db();
let db = Database::open(&db_path).unwrap();
db.insert(
"proj",
"content",
&test_fake_embedder("c").unwrap(),
None,
"fact",
"active",
)
.unwrap();
drop(db);
let (exit, out, err) = capture_force_output(&db_path, true);
assert_eq!(exit, 0, "clean pass must exit 0");
let parsed: Vec<serde_json::Value> =
serde_json::from_str(out.trim()).expect("stdout must be the JSON summary");
assert_eq!(parsed.len(), 1);
let obj = parsed[0].as_object().expect("summary must be an object");
assert_eq!(obj["project_id"].as_str(), Some("project proj"));
assert_eq!(obj["reindexed"].as_u64(), Some(1));
assert_eq!(obj["skipped"].as_u64(), Some(0));
assert_eq!(obj["failed"].as_array().map(|a| a.len()), Some(0));
assert!(
err.trim().is_empty(),
"clean pass must write nothing to stderr, got: {err:?}"
);
}
#[test]
fn test_force_migration_banner_on_mismatch() {
let (_dir, db_path) = create_test_db();
let db = Database::open(&db_path).unwrap();
db.insert(
"proj",
"content",
&test_fake_embedder("c").unwrap(),
None,
"fact",
"active",
)
.unwrap();
let old = identity::ModelIdentity {
model_id: "intfloat/multilingual-e5-small".to_string(),
revision: "614241f622f53c4eeff9890bdc4f31cfecc418b3".to_string(),
};
db.conn()
.execute(
"INSERT INTO model_identity (id, model_id, model_revision, migration_marker)
VALUES (1, ?1, ?2, NULL)",
(&old.model_id, &old.revision),
)
.unwrap();
drop(db);
let (exit, out, err) = capture_force_output(&db_path, false);
assert_eq!(exit, 0, "clean pass must exit 0");
assert_eq!(
out.lines().next(),
Some(
"Migrating from intfloat/multilingual-e5-small@614241f622f53c4eeff9890bdc4f31cfecc418b3 to BAAI/bge-small-en-v1.5@5c38ec7c405ec4b44b94cc5a9bb96e735b38267a..."
),
"banner must be the first stdout line, got: {out:?}"
);
assert!(
out.contains(
"Model identity updated to BAAI/bge-small-en-v1.5@5c38ec7c405ec4b44b94cc5a9bb96e735b38267a (marker cleared)."
),
"success line missing, got: {out:?}"
);
assert!(out.contains("Total across project proj:"));
assert!(out.contains(" Reindexed: 1"));
assert!(out.contains(" Skipped: 0"));
assert!(out.contains(" Failed: 0"));
assert!(
err.trim().is_empty(),
"clean pass must write nothing to stderr, got: {err:?}"
);
let db = Database::open(&db_path).unwrap();
assert_eq!(
identity::read_identity(db.conn()).unwrap(),
Some(identity::ModelIdentity::default_identity())
);
assert_eq!(identity::read_marker(db.conn()).unwrap(), None);
}
#[test]
fn test_force_resuming_banner_when_marker_present() {
let (_dir, db_path) = create_test_db();
let db = Database::open(&db_path).unwrap();
db.insert(
"proj",
"content",
&test_fake_embedder("c").unwrap(),
None,
"fact",
"active",
)
.unwrap();
marker_only(db.conn(), &identity::ModelIdentity::default_identity());
drop(db);
let (exit, out, _err) = capture_force_output(&db_path, false);
assert_eq!(exit, 0, "clean pass must exit 0");
assert_eq!(
out.lines().next(),
Some(
"Resuming interrupted migration to BAAI/bge-small-en-v1.5@5c38ec7c405ec4b44b94cc5a9bb96e735b38267a..."
),
"resuming banner must be the first stdout line, got: {out:?}"
);
}
#[test]
fn test_force_skipped_corrupted_note_and_success_exit() {
let (_dir, db_path) = create_test_db();
let db = Database::open(&db_path).unwrap();
db.insert(
"proj",
"content",
&test_fake_embedder("c").unwrap(),
None,
"fact",
"active",
)
.unwrap();
db.insert("proj", "bad", &vec![0.0; 384], None, "fact", "active")
.unwrap();
drop(db);
let (exit, _out, err) = capture_force_output(&db_path, false);
assert_eq!(exit, 0, "clean pass must exit 0");
assert_eq!(
err.trim(),
"1 row(s) had corrupted embeddings and were skipped",
"skipped-corrupted note must be byte-identical"
);
}
#[test]
fn test_force_per_row_failure_error_shape_and_post_state() {
let (_dir, db_path) = create_test_db();
let mut db = Database::open(&db_path).unwrap();
db.insert(
"proj",
"ok content",
&test_fake_embedder("c").unwrap(),
None,
"fact",
"active",
)
.unwrap();
let fail_id = db
.insert(
"proj",
"bad content",
&test_fake_embedder("d").unwrap(),
None,
"fact",
"active",
)
.unwrap();
let config = crate::config::Config {
database_path: db_path.clone(),
embedding_model: "BAAI/bge-small-en-v1.5".to_string(),
..Default::default()
};
let mut embed = |content: &str| {
if content == "bad content" {
Err(crate::sqlite::Error::Sqlite(
"simulated embed failure".to_string(),
))
} else {
test_fake_embedder(content).map_err(|e| crate::sqlite::Error::Sqlite(e.to_string()))
}
};
let mut count = |content: &str| -> Result<usize, crate::sqlite::Error> {
Ok(content.split_whitespace().count())
};
let result =
crate::migration::migrate_model_with_embedder(&mut db, &config, &mut embed, &mut count);
let err = result.expect_err("a failing row must refuse the pass");
match err {
crate::sqlite::Error::MigrationIncomplete { report } => {
assert_eq!(report.failures.len(), 1, "one failure in the report");
assert_eq!(report.failures[0].id, fail_id);
}
other => panic!("expected MigrationIncomplete, got {other:?}"),
}
assert!(
identity::is_migrating(db.conn()).unwrap(),
"marker must stay"
);
assert_eq!(
identity::read_identity(db.conn()).unwrap(),
None,
"old identity (unrecorded default) must be kept"
);
assert_eq!(
get_embedding(&db, &fail_id),
test_fake_embedder("d").unwrap(),
"failed row keeps its stale vector"
);
}
#[test]
fn test_force_preflight_refusal_cli_output_and_blob_invariance() {
let (_dir, db_path) = create_test_db();
let db = Database::open(&db_path).unwrap();
let long_content: String = (0..600)
.map(|i| format!("word{i}"))
.collect::<Vec<_>>()
.join(" ");
let long_id = db
.insert(
"proj",
&long_content,
&test_fake_embedder("long").unwrap(),
None,
"fact",
"active",
)
.unwrap();
let short_id = db
.insert(
"proj",
"short content",
&test_fake_embedder("short").unwrap(),
None,
"fact",
"active",
)
.unwrap();
let long_blob_before = get_embedding(&db, &long_id);
let short_blob_before = get_embedding(&db, &short_id);
drop(db);
let (exit, _out, err) = capture_force_output(&db_path, false);
assert_eq!(exit, 1, "pre-flight refusal must exit 1");
assert!(
err.contains(
"Error: reindex --force refused to start: 1 row(s) exceed the 512-token limit once the '' passage prefix is prepended. Fix or remove these memories, then re-run `vipune reindex --force`:"
),
"refusal header missing, got: {err:?}"
);
assert!(
err.contains(&format!(" {long_id}")),
"offending id missing from refusal block"
);
assert!(
!err.contains(&format!(" {short_id}")),
"short row must not be reported"
);
assert!(
err.contains("No migration marker was written and no rows were changed."),
"refusal footer missing"
);
let db = Database::open(&db_path).unwrap();
assert_eq!(
get_embedding(&db, &long_id),
long_blob_before,
"long row BLOB must be byte-identical"
);
assert_eq!(
get_embedding(&db, &short_id),
short_blob_before,
"short row BLOB must be byte-identical"
);
assert_eq!(
identity::read_marker(db.conn()).unwrap(),
None,
"no marker on pre-flight refusal"
);
}