diff --git a/crates/vestige-core/src/storage/migrations.rs b/crates/vestige-core/src/storage/migrations.rs index f5e4857..566561f 100644 --- a/crates/vestige-core/src/storage/migrations.rs +++ b/crates/vestige-core/src/storage/migrations.rs @@ -721,3 +721,71 @@ pub fn apply_migrations(conn: &rusqlite::Connection) -> rusqlite::Result { Ok(applied) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh in-memory DB must end up at schema_version = highest migration + /// version after `apply_migrations` runs all migrations end-to-end, and + /// neither of the dead tables V11 drops must exist afterwards. + #[test] + fn test_apply_migrations_advances_to_v11_and_drops_dead_tables() { + let conn = rusqlite::Connection::open_in_memory().expect("open in-memory"); + + // Pre-requisite: schema_version must be bootstrapped by V1. + apply_migrations(&conn).expect("apply_migrations succeeds"); + + // 1. schema_version advanced to V11 + let version = get_current_version(&conn).expect("read schema_version"); + assert_eq!(version, 11, "schema_version must be 11 after all migrations"); + + // 2. knowledge_edges is gone (V11 drops it) + let knowledge_edges_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='knowledge_edges'", + [], + |row| row.get(0), + ) + .expect("query sqlite_master"); + assert_eq!( + knowledge_edges_rows, 0, + "knowledge_edges table must be dropped by V11" + ); + + // 3. compressed_memories is gone (V11 drops it) + let compressed_memories_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='compressed_memories'", + [], + |row| row.get(0), + ) + .expect("query sqlite_master"); + assert_eq!( + compressed_memories_rows, 0, + "compressed_memories table must be dropped by V11" + ); + } + + /// V11 must be idempotent on replay — if the tables were already dropped + /// (e.g. a user ran v2.0.7, downgraded, then upgraded again), re-running + /// the migration must not error. `DROP TABLE IF EXISTS` handles this but + /// we enforce it with an explicit test so a future refactor to plain + /// `DROP TABLE` would be caught. + #[test] + fn test_v11_is_idempotent_on_replay() { + let conn = rusqlite::Connection::open_in_memory().expect("open in-memory"); + apply_migrations(&conn).expect("first apply_migrations succeeds"); + + // Force schema_version back to 10 so V11 runs again even though its + // changes are already applied. + conn.execute("UPDATE schema_version SET version = 10", []) + .expect("rewind schema_version"); + + // Replay must not error. + apply_migrations(&conn).expect("V11 replay must be idempotent"); + + let version = get_current_version(&conn).expect("read schema_version"); + assert_eq!(version, 11, "schema_version back at 11 after replay"); + } +} diff --git a/crates/vestige-mcp/src/tools/changelog.rs b/crates/vestige-mcp/src/tools/changelog.rs index 59d6612..ef4811e 100644 --- a/crates/vestige-mcp/src/tools/changelog.rs +++ b/crates/vestige-mcp/src/tools/changelog.rs @@ -375,4 +375,39 @@ mod tests { assert_eq!(value["totalTransitions"], 0); assert!(value["transitions"].as_array().unwrap().is_empty()); } + + /// v2.0.7 hygiene: malformed `start` must return a helpful error instead + /// of silently dropping the filter (the pre-v2.0.7 behavior was to + /// `#[allow(dead_code)]` the field entirely). Guards against a regression + /// where someone unwraps the parse and triggers a panic on bad input. + #[tokio::test] + async fn test_changelog_malformed_start_returns_error() { + let (storage, _dir) = test_storage().await; + let args = serde_json::json!({ "start": "not-a-date" }); + let result = execute(&storage, Some(args)).await; + assert!(result.is_err(), "malformed start should error"); + let err = result.unwrap_err(); + assert!( + err.contains("Invalid start"), + "error should name the offending field; got: {}", + err + ); + assert!( + err.contains("ISO-8601") || err.contains("RFC-3339"), + "error should hint at the expected format; got: {}", + err + ); + } + + /// The response must echo the applied `start` bound so callers can confirm + /// the window was honored. Empty store so filter narrows to 0 events. + #[tokio::test] + async fn test_changelog_filter_field_echoes_start() { + let (storage, _dir) = test_storage().await; + let args = serde_json::json!({ "start": "2026-04-19T00:00:00Z" }); + let result = execute(&storage, Some(args)).await; + let value = result.unwrap(); + assert_eq!(value["filter"]["start"], "2026-04-19T00:00:00+00:00"); + assert!(value["filter"]["end"].is_null()); + } } diff --git a/crates/vestige-mcp/src/tools/predict.rs b/crates/vestige-mcp/src/tools/predict.rs index 1499979..32a52b2 100644 --- a/crates/vestige-mcp/src/tools/predict.rs +++ b/crates/vestige-mcp/src/tools/predict.rs @@ -260,4 +260,20 @@ mod tests { let accuracy = value["prediction_accuracy"].as_f64().unwrap(); assert!(accuracy >= 0.0); } + + /// The happy path must surface `predict_degraded: false`. A regression + /// that accidentally hard-coded `true` or dropped the field entirely + /// would break downstream callers that rely on the flag to distinguish + /// "no predictions because cold start" from "no predictions because + /// the system is broken." + #[tokio::test] + async fn test_predict_degraded_false_on_happy_path() { + let (storage, _dir) = test_storage().await; + let result = execute(&storage, &test_cognitive(), None).await; + let value = result.unwrap(); + assert_eq!( + value["predict_degraded"], false, + "fresh cognitive engine should not be degraded" + ); + } }