From 8427a62d2b4e08659aacf3bfc311b5bd0864146d Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sat, 11 Jul 2026 13:27:15 -0700 Subject: [PATCH] test: replace obsolete model integration suites --- tests/integration.rs | 511 +++++++--------------------------------- tests/write_pipeline.rs | 243 ++++++++----------- 2 files changed, 194 insertions(+), 560 deletions(-) diff --git a/tests/integration.rs b/tests/integration.rs index 049e5c9..a0ea6ca 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,456 +1,127 @@ -//! Integration tests for engraph. -//! -//! All tests are `#[ignore]` because they require the ONNX model download (~23MB). -//! Run with: `cargo test --test integration -- --ignored` +//! Model-free integration tests for indexing and search. use std::path::{Path, PathBuf}; -use engraph::chunker::chunk_markdown; use engraph::config::Config; -use engraph::docid::generate_docid; -use engraph::embedder::Embedder; -use engraph::hnsw::HnswIndex; -use engraph::indexer::{compute_file_hash, diff_vault, walk_vault}; +use engraph::indexer::run_index_shared; +use engraph::llm::MockLlm; +use engraph::search::search_internal; use engraph::store::Store; - use tempfile::TempDir; -/// Fixture directory relative to the project root. fn fixtures_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") } -/// Copy the fixture vault into a temp directory so tests can mutate it. fn copy_fixtures_to(dest: &Path) { - copy_dir_recursive(&fixtures_dir(), dest); -} - -fn copy_dir_recursive(src: &Path, dst: &Path) { - std::fs::create_dir_all(dst).unwrap(); - for entry in std::fs::read_dir(src).unwrap() { + std::fs::create_dir_all(dest).unwrap(); + for entry in std::fs::read_dir(fixtures_dir()).unwrap() { let entry = entry.unwrap(); - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - if src_path.is_dir() { - copy_dir_recursive(&src_path, &dst_path); - } else { - std::fs::copy(&src_path, &dst_path).unwrap(); - } - } -} - -/// Write a file at the given relative path inside a directory. -fn write_file(dir: &Path, rel_path: &str, content: &str) { - let full = dir.join(rel_path); - if let Some(parent) = full.parent() { - std::fs::create_dir_all(parent).unwrap(); + copy_entry(&entry.path(), &dest.join(entry.file_name())); } - std::fs::write(&full, content).unwrap(); } -/// Index a vault directory into the given data_dir using lower-level APIs. -/// Returns the number of files indexed. -fn index_vault(vault_path: &Path, data_dir: &Path, config: &Config, rebuild: bool) -> usize { - let db_path = data_dir.join("engraph.db"); - let store = Store::open(&db_path).unwrap(); - let hnsw_dir = data_dir.join("hnsw"); - - let files = walk_vault(vault_path, &config.exclude).unwrap(); - - let (new_files, changed_files, deleted_files) = if rebuild { - (files.clone(), Vec::new(), Vec::new()) - } else { - diff_vault(&files, vault_path, &store).unwrap() - }; - - // Handle deletes. - for record in &deleted_files { - store.delete_file(record.id).unwrap(); - } - - // Handle updates (delete old record, then treat as new). - let mut files_to_index: Vec = new_files.clone(); - for file_path in &changed_files { - let rel = file_path.strip_prefix(vault_path).unwrap_or(file_path); - let rel_str = rel.to_string_lossy().to_string(); - if let Some(record) = store.get_file(&rel_str).unwrap() { - store.delete_file(record.id).unwrap(); - } - files_to_index.push(file_path.clone()); - } - - // Embed and store with vectors. - let models_dir = data_dir.join("models"); - let mut embedder = Embedder::new(&models_dir).unwrap(); - - let mut next_vid: u64 = store - .get_all_vectors() - .unwrap_or_default() - .iter() - .map(|(id, _)| *id) - .max() - .map_or(0, |m| m + 1); - - for file_path in &files_to_index { - let content = std::fs::read_to_string(file_path).unwrap(); - let rel = file_path.strip_prefix(vault_path).unwrap_or(file_path); - let rel_str = rel.to_string_lossy().to_string(); - let hash = compute_file_hash(file_path).unwrap(); - - let parsed = chunk_markdown(&content); - let tags = parsed.tags; - let chunks = parsed.chunks; - - let docid = generate_docid(&rel_str); - let file_id = store - .insert_file(&rel_str, &hash, 0, &tags, &docid, None, None) - .unwrap(); - - for chunk in &chunks { - let heading = chunk.heading.clone().unwrap_or_default(); - let vec = embedder.embed_one(&chunk.text).unwrap(); - let token_count = embedder.token_count(&chunk.text) as i64; - let vector_id = next_vid; - next_vid += 1; - store - .insert_chunk_with_vector( - file_id, - &heading, - &chunk.snippet, - vector_id, - token_count, - &vec, - ) - .unwrap(); +fn copy_entry(source: &Path, destination: &Path) { + if source.is_dir() { + std::fs::create_dir_all(destination).unwrap(); + for entry in std::fs::read_dir(source).unwrap() { + let entry = entry.unwrap(); + copy_entry(&entry.path(), &destination.join(entry.file_name())); } + } else { + std::fs::copy(source, destination).unwrap(); } - - store - .set_meta("vault_path", &vault_path.to_string_lossy()) - .unwrap(); - store - .set_meta( - "last_indexed_at", - &format!( - "{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - ), - ) - .unwrap(); - - // Rebuild HNSW from all vectors in SQLite (hnsw_rs doesn't support append after load). - let all_vectors = store.get_all_vectors().unwrap(); - let mut hnsw = HnswIndex::new(all_vectors.len().max(1000)); - for (vid, vector) in &all_vectors { - hnsw.insert_with_id(vector, *vid); - } - hnsw.save(&hnsw_dir).unwrap(); - - files_to_index.len() } -/// Search using lower-level APIs and return results as (file_path, score) pairs. -fn search_vault(query: &str, top_n: usize, data_dir: &Path) -> Vec<(String, f32)> { - let models_dir = data_dir.join("models"); - let mut embedder = Embedder::new(&models_dir).unwrap(); - - let hnsw_dir = data_dir.join("hnsw"); - let index = HnswIndex::load(&hnsw_dir).unwrap(); - - let db_path = data_dir.join("engraph.db"); - let store = Store::open(&db_path).unwrap(); - - let query_vec = embedder.embed_one(query).unwrap(); - let tombstones = store.get_tombstones().unwrap(); - let raw_results = index.search(&query_vec, top_n, &tombstones); +struct Harness { + _data_dir: TempDir, + vault_dir: TempDir, + store: Store, + embedder: MockLlm, + config: Config, +} - let mut results = Vec::new(); - for (vector_id, distance) in raw_results { - if let Some(chunk) = store.get_chunk_by_vector_id(vector_id).unwrap() { - let file_path = store - .get_file_path_by_id(chunk.file_id) - .unwrap() - .unwrap_or_else(|| "".to_string()); - let score = 1.0 - distance; - results.push((file_path, score)); +impl Harness { + fn new() -> Self { + let data_dir = TempDir::new().unwrap(); + let vault_dir = TempDir::new().unwrap(); + copy_fixtures_to(vault_dir.path()); + + Self { + store: Store::open(&data_dir.path().join("engraph.db")).unwrap(), + embedder: MockLlm::new(256), + config: Config::default(), + _data_dir: data_dir, + vault_dir, } } - results -} - -// ── Tests ──────────────────────────────────────────────────────── - -#[test] -#[ignore] -fn test_full_index_and_search() { - let vault_dir = TempDir::new().unwrap(); - let data_dir = TempDir::new().unwrap(); - copy_fixtures_to(vault_dir.path()); - - let config = Config::default(); - let indexed = index_vault(vault_dir.path(), data_dir.path(), &config, false); - assert!(indexed > 0, "should have indexed some files"); - - let results = search_vault("error handling", 5, data_dir.path()); - assert!(!results.is_empty(), "search should return results"); - - let file_paths: Vec<&str> = results.iter().map(|(p, _)| p.as_str()).collect(); - assert!( - file_paths.iter().any(|p| p.contains("note1")), - "results should contain note1.md (Rust Error Handling), got: {:?}", - file_paths - ); -} - -#[test] -#[ignore] -fn test_incremental_add() { - let vault_dir = TempDir::new().unwrap(); - let data_dir = TempDir::new().unwrap(); - copy_fixtures_to(vault_dir.path()); - - let config = Config::default(); - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - // Check initial file count. - let db_path = data_dir.path().join("engraph.db"); - let store = Store::open(&db_path).unwrap(); - let initial_count = store.stats().unwrap().file_count; - drop(store); - - // Add a new file. - write_file( - vault_dir.path(), - "note5.md", - "---\ntags: [kubernetes]\n---\n\n# Kubernetes Pods\n\nPods are the smallest deployable units.", - ); - - // Re-index incrementally. - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - let store = Store::open(&db_path).unwrap(); - let new_count = store.stats().unwrap().file_count; - assert_eq!( - new_count, - initial_count + 1, - "file count should increase by 1 after adding a file" - ); - - // Search for the new content. - let results = search_vault("kubernetes pods", 5, data_dir.path()); - let file_paths: Vec<&str> = results.iter().map(|(p, _)| p.as_str()).collect(); - assert!( - file_paths.iter().any(|p| p.contains("note5")), - "results should contain newly added note5.md, got: {:?}", - file_paths - ); -} - -#[test] -#[ignore] -fn test_incremental_delete() { - let vault_dir = TempDir::new().unwrap(); - let data_dir = TempDir::new().unwrap(); - copy_fixtures_to(vault_dir.path()); - let config = Config::default(); - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - let db_path = data_dir.path().join("engraph.db"); - let store = Store::open(&db_path).unwrap(); - let initial_count = store.stats().unwrap().file_count; - drop(store); - - // Delete note2.md from the vault. - std::fs::remove_file(vault_dir.path().join("note2.md")).unwrap(); - - // Re-index incrementally. - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - let store = Store::open(&db_path).unwrap(); - let new_count = store.stats().unwrap().file_count; - assert_eq!( - new_count, - initial_count - 1, - "file count should decrease by 1 after deleting a file" - ); - - // Verify the deleted file is gone from the store. - assert!( - store.get_file("note2.md").unwrap().is_none(), - "deleted file should not be in the store" - ); -} - -#[test] -#[ignore] -fn test_incremental_update() { - let vault_dir = TempDir::new().unwrap(); - let data_dir = TempDir::new().unwrap(); - copy_fixtures_to(vault_dir.path()); - - let config = Config::default(); - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - // Modify note3.md to contain completely different content. - write_file( - vault_dir.path(), - "note3.md", - "# Quantum Computing\n\nQuantum computers use qubits for parallel computation.", - ); - - // Re-index incrementally. - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - // Search for the updated content. - let results = search_vault("quantum computing qubits", 5, data_dir.path()); - let file_paths: Vec<&str> = results.iter().map(|(p, _)| p.as_str()).collect(); - assert!( - file_paths.iter().any(|p| p.contains("note3")), - "results should contain updated note3.md with quantum computing content, got: {:?}", - file_paths - ); -} - -#[test] -#[ignore] -fn test_obsidian_dir_excluded() { - let vault_dir = TempDir::new().unwrap(); - let data_dir = TempDir::new().unwrap(); - copy_fixtures_to(vault_dir.path()); - - let config = Config::default(); // default excludes .obsidian/ - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - let db_path = data_dir.path().join("engraph.db"); - let store = Store::open(&db_path).unwrap(); - let stats = store.stats().unwrap(); - - // Fixtures have 4 .md files (note1-4) plus .obsidian/config.md. - // .obsidian should be excluded, so we expect exactly 4 files. - assert_eq!( - stats.file_count, 4, - "should index exactly 4 files (excluding .obsidian/), got {}", - stats.file_count - ); - - // Verify .obsidian/config.md is not in the store. - assert!( - store.get_file(".obsidian/config.md").unwrap().is_none(), - ".obsidian/config.md should not be indexed" - ); -} - -#[test] -#[ignore] -fn test_rebuild_flag() { - let vault_dir = TempDir::new().unwrap(); - let data_dir = TempDir::new().unwrap(); - copy_fixtures_to(vault_dir.path()); - - let config = Config::default(); - - // Initial index. - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - let db_path = data_dir.path().join("engraph.db"); - let store = Store::open(&db_path).unwrap(); - let initial_stats = store.stats().unwrap(); - drop(store); - - // Rebuild from scratch. - // First clear the store to simulate rebuild behavior. - std::fs::remove_file(&db_path).unwrap(); - let hnsw_dir = data_dir.path().join("hnsw"); - if hnsw_dir.exists() { - std::fs::remove_dir_all(&hnsw_dir).unwrap(); + fn index(&mut self) -> engraph::indexer::IndexResult { + run_index_shared( + self.vault_dir.path(), + &self.config, + &self.store, + &mut self.embedder, + false, + None, + ) + .unwrap() } - - index_vault(vault_dir.path(), data_dir.path(), &config, true); - - let store = Store::open(&db_path).unwrap(); - let rebuild_stats = store.stats().unwrap(); - - assert_eq!( - rebuild_stats.file_count, initial_stats.file_count, - "rebuild should index the same number of files" - ); - assert_eq!( - rebuild_stats.tombstone_count, 0, - "rebuild should have no tombstones" - ); } #[test] -#[ignore] -fn test_clear_preserves_model() { - let vault_dir = TempDir::new().unwrap(); - let data_dir = TempDir::new().unwrap(); - copy_fixtures_to(vault_dir.path()); - - let config = Config::default(); - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - // Verify model files exist. - let models_dir = data_dir.path().join("models"); - assert!( - models_dir.join("model.onnx").exists(), - "model.onnx should exist before clear" - ); - - // Simulate `engraph clear` (without --all): remove db and hnsw, keep models. - let db_path = data_dir.path().join("engraph.db"); - let hnsw_dir = data_dir.path().join("hnsw"); - let _ = std::fs::remove_file(&db_path); - if hnsw_dir.exists() { - let _ = std::fs::remove_dir_all(&hnsw_dir); - } - - // Model directory should still exist. - assert!( - models_dir.join("model.onnx").exists(), - "model.onnx should survive clear (without --all)" - ); +fn full_index_is_searchable() { + let mut harness = Harness::new(); + let indexed = harness.index(); + + assert_eq!(indexed.new_files, 4); + assert_eq!(harness.store.stats().unwrap().file_count, 4); + + let results = search_internal( + "Rust error handling", + 5, + &harness.store, + &mut harness.embedder, + ) + .unwrap(); assert!( - models_dir.join("tokenizer.json").exists(), - "tokenizer.json should survive clear (without --all)" + results + .results + .iter() + .any(|result| result.file_path == "note1.md") ); } #[test] -#[ignore] -fn test_status_output() { - let vault_dir = TempDir::new().unwrap(); - let data_dir = TempDir::new().unwrap(); - copy_fixtures_to(vault_dir.path()); - - let config = Config::default(); - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - let db_path = data_dir.path().join("engraph.db"); - let store = Store::open(&db_path).unwrap(); - let stats = store.stats().unwrap(); - - assert_eq!(stats.file_count, 4, "expected 4 indexed files"); - assert!(stats.chunk_count > 0, "expected some chunks"); - assert_eq!( - stats.tombstone_count, 0, - "expected no tombstones on fresh index" - ); - assert!( - stats.last_indexed_at.is_some(), - "last_indexed_at should be set" - ); - assert!(stats.vault_path.is_some(), "vault_path should be set"); +fn incremental_index_tracks_add_change_and_delete() { + let mut harness = Harness::new(); + harness.index(); + + std::fs::write( + harness.vault_dir.path().join("note4.md"), + "# Kubernetes Pods\n\nPods are deployable units.\n", + ) + .unwrap(); + std::fs::write( + harness.vault_dir.path().join("note2.md"), + "# Python Basics\n\nPython has dynamic typing and pattern matching.\n", + ) + .unwrap(); + std::fs::remove_file(harness.vault_dir.path().join("note3.md")).unwrap(); + + let indexed = harness.index(); + assert_eq!(indexed.new_files, 1); + assert_eq!(indexed.updated_files, 1); + assert_eq!(harness.store.stats().unwrap().file_count, 4); + assert!(harness.store.get_file("note4.md").unwrap().is_some()); + assert!(harness.store.get_file("note3.md").unwrap().is_none()); + + let results = + search_internal("Kubernetes Pods", 5, &harness.store, &mut harness.embedder).unwrap(); assert!( - stats - .vault_path - .as_ref() - .unwrap() - .contains(vault_dir.path().to_str().unwrap()), - "vault_path should point to the test vault" + results + .results + .iter() + .any(|result| result.file_path == "note4.md") ); } diff --git a/tests/write_pipeline.rs b/tests/write_pipeline.rs index 7e84a78..521ddfb 100644 --- a/tests/write_pipeline.rs +++ b/tests/write_pipeline.rs @@ -1,165 +1,128 @@ -//! Integration tests for the write pipeline. -//! Run with: cargo test --test write_pipeline -- --ignored +//! Model-free integration tests for the write pipeline. -use std::path::Path; - -use engraph::embedder::Embedder; +use engraph::llm::MockLlm; +use engraph::search::search_internal; use engraph::store::Store; -use engraph::vecstore; use engraph::writer::{AppendInput, CreateNoteInput, append_to_note, create_note}; -fn setup(vault_dir: &Path) -> (Store, Embedder) { - // Register sqlite-vec extension - vecstore::init_sqlite_vec(); - - // Create minimal vault structure - std::fs::create_dir_all(vault_dir.join("00-Inbox")).unwrap(); - std::fs::create_dir_all(vault_dir.join("03-Resources/People")).unwrap(); - std::fs::write( - vault_dir.join("03-Resources/People/Steve Barbera.md"), - "# Steve Barbera\n\nRole: VP Engineering\n", - ) - .unwrap(); - - // Open store and set vault path - let data_dir = tempfile::TempDir::new().unwrap(); - let db_path = data_dir.path().join("engraph.db"); - let store = Store::open(&db_path).unwrap(); - store - .set_meta("vault_path", &vault_dir.to_string_lossy()) - .unwrap(); +struct Harness { + vault_dir: tempfile::TempDir, + store: Store, + embedder: MockLlm, +} - // Index the existing file so it's in the store - let docid = engraph::docid::generate_docid("03-Resources/People/Steve Barbera.md"); - store - .insert_file( - "03-Resources/People/Steve Barbera.md", - "hash1", - 0, - &[], - &docid, - None, +impl Harness { + fn new() -> Self { + let vault_dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(vault_dir.path().join("00-Inbox")).unwrap(); + + Self { + store: Store::open_memory().unwrap(), + embedder: MockLlm::new(256), + vault_dir, + } + } + + fn create_note(&mut self, title: &str, body: &str) -> engraph::writer::WriteResult { + create_note( + CreateNoteInput { + content: format!("# {title}\n\n{body}"), + filename: Some(title.to_string()), + type_hint: None, + tags: vec!["engraph".to_string()], + folder: Some("00-Inbox".to_string()), + created_by: "integration-test".to_string(), + auto_link: Some(false), + }, + &self.store, + &mut self.embedder, + self.vault_dir.path(), None, ) - .unwrap(); - - // Load embedder - let models_dir = engraph::config::Config::data_dir().unwrap().join("models"); - let embedder = Embedder::new(&models_dir).unwrap(); - - (store, embedder) + .unwrap() + } } #[test] -#[ignore] // requires model download -fn test_create_note_is_immediately_searchable() { - let vault_dir = tempfile::TempDir::new().unwrap(); - let (store, mut embedder) = setup(vault_dir.path()); - - let input = CreateNoteInput { - content: - "# RRF Tuning Notes\n\nWe tested reciprocal rank fusion with k=60 and got good results." - .into(), - filename: Some("RRF Tuning".into()), - type_hint: None, - tags: vec!["engraph".into(), "search".into()], - folder: Some("00-Inbox".into()), - created_by: "test".into(), - auto_link: None, - }; - - let result = create_note(input, &store, &mut embedder, vault_dir.path(), None).unwrap(); - assert!(result.path.starts_with("00-Inbox/")); - assert!(result.path.ends_with(".md")); - assert!(!result.docid.is_empty()); - - // Verify the file exists on disk - assert!(vault_dir.path().join(&result.path).exists()); - - // Verify it's immediately searchable via sqlite-vec - let search = - engraph::search::search_internal("reciprocal rank fusion", 5, &store, &mut embedder) - .unwrap(); - assert!( - !search.results.is_empty(), - "created note should be searchable immediately" +fn create_note_is_immediately_searchable() { + let mut harness = Harness::new(); + let created = harness.create_note( + "RRF Tuning", + "Reciprocal rank fusion uses a stable rank constant.", ); + + assert!(harness.vault_dir.path().join(&created.path).is_file()); + let results = search_internal( + "reciprocal rank fusion", + 5, + &harness.store, + &mut harness.embedder, + ) + .unwrap(); assert!( - search.results.iter().any(|r| r.file_path == result.path), - "created note should appear in search results" + results + .results + .iter() + .any(|result| result.file_path == created.path) ); } #[test] -#[ignore] -fn test_append_updates_index() { - let vault_dir = tempfile::TempDir::new().unwrap(); - let (store, mut embedder) = setup(vault_dir.path()); - - // Create a note first - let input = CreateNoteInput { - content: "# Meeting Notes\n\nDiscussed the roadmap for Q2.".into(), - filename: Some("Meeting 2026-03-25".into()), - type_hint: None, - tags: vec![], - folder: Some("00-Inbox".into()), - created_by: "test".into(), - auto_link: None, - }; - let created = create_note(input, &store, &mut embedder, vault_dir.path(), None).unwrap(); - - // Append new content - let append_input = AppendInput { - file: created.path.clone(), - content: "## Action Items\n\n- Ship sqlite-vec migration by Friday\n- Review PR #42".into(), - modified_by: "test".into(), - }; - let _appended = append_to_note(append_input, &store, &mut embedder, vault_dir.path()).unwrap(); +fn append_updates_the_search_index() { + let mut harness = Harness::new(); + let created = harness.create_note("Meeting Notes", "Discussed the roadmap."); + + append_to_note( + AppendInput { + file: created.path.clone(), + content: "## Action Items\n\nShip sqlite vector migration by Friday.".to_string(), + modified_by: "integration-test".to_string(), + }, + &harness.store, + &mut harness.embedder, + harness.vault_dir.path(), + ) + .unwrap(); - // Verify appended content is searchable - let search = - engraph::search::search_internal("sqlite-vec migration", 5, &store, &mut embedder).unwrap(); + let results = search_internal( + "sqlite vector migration", + 5, + &harness.store, + &mut harness.embedder, + ) + .unwrap(); assert!( - search.results.iter().any(|r| r.file_path == created.path), - "appended content should be searchable" + results + .results + .iter() + .any(|result| result.file_path == created.path) ); } #[test] -#[ignore] -fn test_conflict_detection() { - let vault_dir = tempfile::TempDir::new().unwrap(); - let (store, mut embedder) = setup(vault_dir.path()); - - let input = CreateNoteInput { - content: "# Test Note\n\nOriginal content.".into(), - filename: Some("conflict-test".into()), - type_hint: None, - tags: vec![], - folder: Some("00-Inbox".into()), - created_by: "test".into(), - auto_link: None, - }; - let created = create_note(input, &store, &mut embedder, vault_dir.path(), None).unwrap(); - - // Modify file externally (simulates Obsidian edit) - let abs_path = vault_dir.path().join(&created.path); - // Wait a moment so mtime changes - std::thread::sleep(std::time::Duration::from_millis(1100)); - std::fs::write(&abs_path, "# Modified externally\n\nNew content.").unwrap(); +fn append_rejects_an_mtime_conflict() { + let mut harness = Harness::new(); + let created = harness.create_note("Conflict Test", "Original content."); + let record = harness.store.get_file(&created.path).unwrap().unwrap(); + harness + .store + .conn() + .execute( + "UPDATE files SET mtime = ?1 WHERE id = ?2", + rusqlite::params![record.mtime - 1, record.id], + ) + .unwrap(); - // Attempt append — should fail with conflict - let append_input = AppendInput { - file: created.path, - content: "appended content".into(), - modified_by: "test".into(), - }; - let result = append_to_note(append_input, &store, &mut embedder, vault_dir.path()); - assert!(result.is_err(), "should detect mtime conflict"); - let err_msg = result.unwrap_err().to_string(); - assert!( - err_msg.contains("mtime conflict") || err_msg.contains("CONFLICT"), - "error should mention conflict, got: {}", - err_msg + let result = append_to_note( + AppendInput { + file: created.path, + content: "Unexpected append".to_string(), + modified_by: "integration-test".to_string(), + }, + &harness.store, + &mut harness.embedder, + harness.vault_dir.path(), ); + + assert!(result.unwrap_err().to_string().contains("mtime conflict")); }