//! Integration tests for M7P4 Operational Visibility — `QueryStats` + Prometheus format. //! //! - Task 01: `QueryStats` — per-query execution statistics attached to every result //! - Task 05: Prometheus exposition format correctness #![allow(clippy::too_many_lines, clippy::unwrap_used)] use std::{collections::HashMap, time::Duration}; use tidaldb::{ TidalDb, schema::{DecaySpec, EntityId, EntityKind, Timestamp, Window}, }; // ── Shared test schema ─────────────────────────────────────────────────────── fn build_test_schema() -> tidaldb::schema::Schema { use tidaldb::{AgentPolicy, schema::SchemaBuilder}; let mut builder = SchemaBuilder::new(); let _ = builder .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::OneHour]) .add(); let _ = builder .signal( "like", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(30 * 24 * 3600), }, ) .windows(&[Window::OneHour]) .add(); builder.session_policy( "default", AgentPolicy { allowed_signals: vec!["view".to_string(), "like".to_string()], denied_signals: vec![], max_session_duration: Duration::from_secs(3600), max_signals_per_session: 1000, }, ); builder.build().unwrap() } // ── Task 01: QueryStats ────────────────────────────────────────────────────── /// RETRIEVE results carry `QueryStats` with the profile name and pipeline counts. #[test] fn retrieve_results_include_query_stats() { use tidaldb::query::retrieve::{ProfileRef, RetrieveBuilder}; let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); // Write items so the candidate generation stage has something to score. for i in 1u64..=5 { let mut meta = HashMap::new(); meta.insert("category".to_string(), "news".to_string()); db.write_item_with_metadata(EntityId::new(i), &meta) .unwrap(); db.signal("view", EntityId::new(i), 1.0, Timestamp::now()) .unwrap(); } let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("new")) .limit(5) .build() .unwrap(); let results = db.retrieve(&query).unwrap(); // QueryStats is unconditionally populated (not feature-gated). assert_eq!( results.stats.profile_name, "new", "stats must capture the profile name used for scoring" ); // Pipeline stage fields are accessible; exact values depend on item count. let _ = results.stats.candidates_considered; let _ = results.stats.candidates_after_filter; let _ = results.stats.total_time_us; let _ = results.stats.scoring_time_us; let _ = results.stats.diversity_time_us; } /// SEARCH results carry `QueryStats` with the "search" profile name. #[test] fn search_results_include_query_stats() { use tidaldb::query::search::Search; let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); // No text schema, so BM25 returns empty — but stats are still populated. let query = Search::builder() .query("test article") .limit(5) .build() .unwrap(); let results = db.search(&query).unwrap(); assert_eq!( results.stats.profile_name, "search", "search pipeline must record the 'search' builtin profile in stats" ); let _ = results.stats.candidates_considered; let _ = results.stats.total_time_us; } // ── Task 05: Prometheus Exposition Format ──────────────────────────────────── /// Every `# HELP` line must be immediately followed by a `# TYPE` line. #[test] fn prometheus_format_help_always_followed_by_type() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let prom = db.metrics().render_prometheus(); let lines: Vec<&str> = prom.lines().collect(); for (i, line) in lines.iter().enumerate() { if line.starts_with("# HELP ") { assert!( i + 1 < lines.len() && lines[i + 1].starts_with("# TYPE "), "# HELP line must be immediately followed by # TYPE at index {i}: {line:?}" ); } } } /// /healthz JSON contains the required fields. #[test] fn healthz_json_contains_required_fields() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let healthz = db.metrics().render_healthz(); assert!( healthz.contains("\"status\":"), "healthz must contain status" ); assert!( healthz.contains("\"uptime_seconds\":"), "healthz must contain uptime_seconds" ); assert!( healthz.contains("\"version\":"), "healthz must contain version" ); assert!( healthz.contains("\"build_hash\":"), "healthz must contain build_hash" ); }