963 lines
29 KiB
Rust
963 lines
29 KiB
Rust
//! Integration tests for M7P4 Operational Visibility.
|
|
//!
|
|
//! Covers all 9 tasks:
|
|
//! - Task 01: QueryStats — per-query execution statistics attached to every result
|
|
//! - Task 02: Signal + WAL metrics
|
|
//! - Task 03: Index health metrics
|
|
//! - Task 04: Session + cohort + degradation metrics
|
|
//! - Task 05: Prometheus exposition format correctness
|
|
//! - Task 06: Structured error context
|
|
//! - Task 07: Metrics feature flag (zero-overhead base types)
|
|
//! - Task 08: RLHF signal export
|
|
//! - Task 09: Cross-session aggregation
|
|
#![allow(clippy::unwrap_used)]
|
|
|
|
use std::collections::HashMap;
|
|
use std::time::Duration;
|
|
|
|
use tidaldb::schema::{DecaySpec, EntityId, EntityKind, Timestamp, Window};
|
|
use tidaldb::{ExportFormat, ExportRequest, ExportedSignal, TidalDb};
|
|
|
|
// ── Prometheus output helpers ────────────────────────────────────────────────
|
|
|
|
/// Extract the numeric value for a metric from Prometheus text output.
|
|
///
|
|
/// Looks for a line that starts with `name` (not a comment), then parses
|
|
/// the trailing float. Returns `None` if the metric is absent.
|
|
#[cfg(feature = "metrics")]
|
|
fn prometheus_value(prom: &str, name: &str) -> Option<f64> {
|
|
prom.lines()
|
|
.filter(|l| !l.starts_with('#'))
|
|
.find(|l| l.starts_with(name) && l[name.len()..].starts_with(' '))
|
|
.and_then(|l| l.split_whitespace().last())
|
|
.and_then(|v| v.parse().ok())
|
|
}
|
|
|
|
// ── Shared test schema ───────────────────────────────────────────────────────
|
|
|
|
fn build_test_schema() -> tidaldb::schema::Schema {
|
|
use tidaldb::AgentPolicy;
|
|
use tidaldb::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 02: Signal + WAL Metrics ───────────────────────────────────────────
|
|
|
|
#[cfg(feature = "metrics")]
|
|
#[test]
|
|
fn signal_write_increments_counter() {
|
|
let schema = build_test_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
let before = prometheus_value(
|
|
&db.metrics().render_prometheus(),
|
|
"tidaldb_signal_writes_total",
|
|
)
|
|
.unwrap_or(0.0);
|
|
db.signal("view", EntityId::new(1), 1.0, Timestamp::now())
|
|
.unwrap();
|
|
db.signal("like", EntityId::new(2), 1.0, Timestamp::now())
|
|
.unwrap();
|
|
db.signal("view", EntityId::new(3), 0.5, Timestamp::now())
|
|
.unwrap();
|
|
let after = prometheus_value(
|
|
&db.metrics().render_prometheus(),
|
|
"tidaldb_signal_writes_total",
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
(after - before) as u64,
|
|
3,
|
|
"signal_writes_total must increment once per signal write"
|
|
);
|
|
}
|
|
|
|
#[cfg(feature = "metrics")]
|
|
#[test]
|
|
fn signal_write_latency_appears_in_prometheus() {
|
|
let schema = build_test_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
for i in 1u64..=3 {
|
|
db.signal("view", EntityId::new(i), 1.0, Timestamp::now())
|
|
.unwrap();
|
|
}
|
|
|
|
let prom = db.metrics().render_prometheus();
|
|
assert!(
|
|
prom.contains("tidaldb_signal_write_latency_us"),
|
|
"Prometheus output must include signal write latency histogram"
|
|
);
|
|
assert!(
|
|
prom.contains("tidaldb_signal_writes_total"),
|
|
"Prometheus output must include signal_writes_total counter"
|
|
);
|
|
assert!(
|
|
prom.contains("tidaldb_wal_lag_bytes"),
|
|
"Prometheus output must include wal_lag_bytes gauge"
|
|
);
|
|
assert!(
|
|
prom.contains("tidaldb_checkpoint_age_seconds"),
|
|
"Prometheus output must include checkpoint_age_seconds gauge"
|
|
);
|
|
}
|
|
|
|
// ── Task 03: Index Health Metrics ────────────────────────────────────────────
|
|
|
|
#[cfg(feature = "metrics")]
|
|
#[test]
|
|
fn prometheus_output_contains_all_index_gauge_names() {
|
|
let schema = build_test_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
let prom = db.metrics().render_prometheus();
|
|
|
|
assert!(
|
|
prom.contains("tidaldb_tantivy_segment_count"),
|
|
"Prometheus output must include tantivy_segment_count gauge"
|
|
);
|
|
assert!(
|
|
prom.contains("tidaldb_tantivy_indexed_docs"),
|
|
"Prometheus output must include tantivy_indexed_docs gauge"
|
|
);
|
|
assert!(
|
|
prom.contains("tidaldb_usearch_vector_count"),
|
|
"Prometheus output must include usearch_vector_count gauge"
|
|
);
|
|
assert!(
|
|
prom.contains("tidaldb_usearch_index_size_bytes"),
|
|
"Prometheus output must include usearch_index_size_bytes gauge"
|
|
);
|
|
assert!(
|
|
prom.contains("tidaldb_bitmap_index_cardinality"),
|
|
"Prometheus output must include bitmap_index_cardinality gauge"
|
|
);
|
|
}
|
|
|
|
// ── Task 04: Session + Cohort + Degradation Metrics ─────────────────────────
|
|
|
|
#[cfg(feature = "metrics")]
|
|
#[test]
|
|
fn session_start_increments_active_sessions() {
|
|
let schema = build_test_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
let before = prometheus_value(&db.metrics().render_prometheus(), "tidaldb_active_sessions")
|
|
.unwrap_or(0.0);
|
|
let meta = HashMap::new();
|
|
let _handle = db.start_session(1u64, "agent1", "default", meta).unwrap();
|
|
let after =
|
|
prometheus_value(&db.metrics().render_prometheus(), "tidaldb_active_sessions").unwrap();
|
|
|
|
assert_eq!(
|
|
(after - before) as u64,
|
|
1,
|
|
"active_sessions must increment when a session starts"
|
|
);
|
|
}
|
|
|
|
#[cfg(feature = "metrics")]
|
|
#[test]
|
|
fn session_close_updates_active_and_closed_counters() {
|
|
let schema = build_test_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
let before_active =
|
|
prometheus_value(&db.metrics().render_prometheus(), "tidaldb_active_sessions")
|
|
.unwrap_or(0.0);
|
|
let before_closed = prometheus_value(
|
|
&db.metrics().render_prometheus(),
|
|
"tidaldb_closed_sessions_total",
|
|
)
|
|
.unwrap_or(0.0);
|
|
|
|
let meta = HashMap::new();
|
|
let handle = db
|
|
.start_session(42u64, "agent-close-test", "default", meta)
|
|
.unwrap();
|
|
db.close_session(handle).unwrap();
|
|
|
|
let after_active =
|
|
prometheus_value(&db.metrics().render_prometheus(), "tidaldb_active_sessions").unwrap();
|
|
let after_closed = prometheus_value(
|
|
&db.metrics().render_prometheus(),
|
|
"tidaldb_closed_sessions_total",
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
after_active as u64, before_active as u64,
|
|
"active_sessions must return to its previous value after close_session"
|
|
);
|
|
assert_eq!(
|
|
(after_closed - before_closed) as u64,
|
|
1,
|
|
"closed_sessions_total must increment on close_session"
|
|
);
|
|
}
|
|
|
|
#[cfg(feature = "metrics")]
|
|
#[test]
|
|
fn degradation_level_appears_in_prometheus_output() {
|
|
let schema = build_test_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
let prom = db.metrics().render_prometheus();
|
|
|
|
// Default: Full fidelity (level 0).
|
|
let level = prometheus_value(&prom, "tidaldb_degradation_level").unwrap();
|
|
assert_eq!(
|
|
level as u64, 0,
|
|
"initial degradation level must be 0 (Full fidelity)"
|
|
);
|
|
|
|
assert!(
|
|
prom.contains("tidaldb_degradation_level"),
|
|
"Prometheus output must include degradation_level gauge"
|
|
);
|
|
assert!(
|
|
prom.contains("tidaldb_rate_limited_total"),
|
|
"Prometheus output must include rate_limited_total counter"
|
|
);
|
|
}
|
|
|
|
// ── 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"
|
|
);
|
|
}
|
|
|
|
// ── Task 06: Structured Error Context ────────────────────────────────────────
|
|
|
|
/// `TidalError::Internal` wraps `ErrorContext` with operation + detail fields.
|
|
#[test]
|
|
fn tidal_error_internal_wraps_error_context() {
|
|
use tidaldb::{ErrorContext, TidalError};
|
|
|
|
let err = TidalError::internal("test_operation", "something went wrong");
|
|
|
|
if let TidalError::Internal(ctx) = &err {
|
|
assert_eq!(ctx.operation, "test_operation");
|
|
assert_eq!(ctx.detail, "something went wrong");
|
|
assert!(ctx.entity_id.is_none(), "entity_id should default to None");
|
|
assert!(
|
|
ctx.entity_kind.is_none(),
|
|
"entity_kind should default to None"
|
|
);
|
|
assert!(
|
|
ctx.signal_type.is_none(),
|
|
"signal_type should default to None"
|
|
);
|
|
} else {
|
|
panic!("expected TidalError::Internal, got {err:?}");
|
|
}
|
|
|
|
// Display must include the operation name.
|
|
let display = format!("{err}");
|
|
assert!(
|
|
display.contains("test_operation"),
|
|
"Display must include the operation name: {display}"
|
|
);
|
|
}
|
|
|
|
/// `ErrorContext` builder pattern sets optional fields correctly.
|
|
#[test]
|
|
fn error_context_builder_sets_optional_fields() {
|
|
use tidaldb::ErrorContext;
|
|
|
|
let ctx = ErrorContext::new("write_signal", "WAL append failed")
|
|
.with_entity(42)
|
|
.with_kind(EntityKind::Item)
|
|
.with_signal("view");
|
|
|
|
assert_eq!(ctx.operation, "write_signal");
|
|
assert_eq!(ctx.detail, "WAL append failed");
|
|
assert_eq!(ctx.entity_id, Some(42));
|
|
assert_eq!(ctx.entity_kind, Some(EntityKind::Item));
|
|
assert_eq!(ctx.signal_type, Some("view".to_string()));
|
|
}
|
|
|
|
// ── Task 07: Feature Flag ────────────────────────────────────────────────────
|
|
|
|
/// `QueryStats` is always available regardless of the `metrics` feature flag.
|
|
/// This test intentionally has NO `#[cfg(feature = "metrics")]` annotation.
|
|
#[test]
|
|
fn query_stats_available_without_metrics_feature() {
|
|
use tidaldb::query::stats::QueryStats;
|
|
|
|
let stats = QueryStats::new("my_profile".to_owned());
|
|
assert_eq!(stats.profile_name, "my_profile");
|
|
assert_eq!(stats.total_time_us, 0);
|
|
assert_eq!(stats.candidates_considered, 0);
|
|
assert_eq!(stats.filters_applied, 0);
|
|
assert_eq!(stats.degradation_level, 0);
|
|
}
|
|
|
|
/// `MetricsState` base fields (`uptime_seconds`, `health_ok_value`) work
|
|
/// unconditionally — they are NOT gated behind the `metrics` feature.
|
|
#[test]
|
|
fn metrics_state_base_fields_always_available() {
|
|
let schema = build_test_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
// These methods exist and compile even with --no-default-features.
|
|
assert!(db.metrics().uptime_seconds() >= 0.0);
|
|
assert!((db.metrics().health_ok_value() - 1.0).abs() < f64::EPSILON);
|
|
}
|
|
|
|
// ── Task 08: RLHF Signal Export ──────────────────────────────────────────────
|
|
|
|
#[test]
|
|
#[cfg(feature = "test-utils")]
|
|
fn export_signals_ephemeral_returns_empty() {
|
|
use tidaldb::TidalDb;
|
|
use tidaldb::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 schema = builder.build().unwrap();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
// Ephemeral mode: no WAL on disk, always returns empty.
|
|
let req = ExportRequest::time_range(0, u64::MAX);
|
|
let signals = db.export_signals(&req).unwrap();
|
|
assert!(signals.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(feature = "test-utils")]
|
|
fn export_signals_unknown_type_returns_error() {
|
|
use tidaldb::TidalDb;
|
|
use tidaldb::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 schema = builder.build().unwrap();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
let req = ExportRequest::signals_in_range(vec!["nonexistent".into()], 0, u64::MAX);
|
|
let result = db.export_signals(&req);
|
|
assert!(
|
|
result.is_err(),
|
|
"requesting an unknown signal type must return an error"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn export_format_json_lines_value() {
|
|
assert_eq!(ExportFormat::JsonLines, ExportFormat::JsonLines);
|
|
}
|
|
|
|
#[test]
|
|
fn export_request_time_range_fields() {
|
|
let req = ExportRequest::time_range(1000, 2000);
|
|
assert_eq!(req.since, Some(1000));
|
|
assert_eq!(req.until, Some(2000));
|
|
assert!(req.signal_types.is_empty());
|
|
assert_eq!(req.format, ExportFormat::JsonLines);
|
|
assert!(req.limit.is_none());
|
|
assert!(req.user_id.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn exported_signal_to_json_line_valid() {
|
|
let sig = ExportedSignal {
|
|
entity_id: 42,
|
|
signal_type: "view".to_string(),
|
|
weight: 1.0,
|
|
timestamp_ns: 1_700_000_000_000_000_000,
|
|
};
|
|
let line = sig.to_json_line();
|
|
assert!(line.starts_with('{'));
|
|
assert!(line.ends_with('}'));
|
|
assert!(line.contains("\"entity_id\":42"));
|
|
assert!(line.contains("\"signal_type\":\"view\""));
|
|
}
|
|
|
|
/// `export_signals` reads actual WAL events written to a persistent database.
|
|
///
|
|
/// Export reads live, uncompacted WAL segments — those fsynced to disk but
|
|
/// not yet removed by a clean shutdown. This test exercises the WAL scanning
|
|
/// code path (persistent mode) that the ephemeral early-return skips.
|
|
///
|
|
/// Note: a clean `close()` compacts WAL segments, so export is called on the
|
|
/// same open DB instance — not after reopening.
|
|
#[test]
|
|
#[cfg(feature = "test-utils")]
|
|
fn export_signals_reads_wal_events() {
|
|
use tidaldb::TempTidalHome;
|
|
|
|
let home = TempTidalHome::new().unwrap();
|
|
let schema = build_test_schema();
|
|
|
|
let db = TidalDb::builder()
|
|
.with_data_dir(home.path())
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
let since = Timestamp::now().as_nanos();
|
|
db.signal("view", EntityId::new(1), 1.0, Timestamp::now())
|
|
.unwrap();
|
|
db.signal("view", EntityId::new(2), 1.5, Timestamp::now())
|
|
.unwrap();
|
|
db.signal("like", EntityId::new(3), 2.0, Timestamp::now())
|
|
.unwrap();
|
|
|
|
// Export BEFORE close — WAL segments are live on disk.
|
|
// Clean close compacts (deletes) segments, so export must happen first.
|
|
let req = ExportRequest::time_range(since, u64::MAX);
|
|
let signals = db.export_signals(&req).unwrap();
|
|
|
|
assert_eq!(signals.len(), 3, "all 3 WAL events must be exported");
|
|
|
|
let views: Vec<_> = signals.iter().filter(|s| s.signal_type == "view").collect();
|
|
assert_eq!(views.len(), 2, "must export 2 view events");
|
|
|
|
let likes: Vec<_> = signals.iter().filter(|s| s.signal_type == "like").collect();
|
|
assert_eq!(likes.len(), 1, "must export 1 like event");
|
|
|
|
// Entity IDs and weights are preserved end-to-end.
|
|
assert!(
|
|
signals
|
|
.iter()
|
|
.any(|s| s.entity_id == 1 && (s.weight - 1.0).abs() < 0.001),
|
|
"entity 1 view event must be present"
|
|
);
|
|
assert!(
|
|
signals
|
|
.iter()
|
|
.any(|s| s.entity_id == 3 && s.signal_type == "like"),
|
|
"entity 3 like event must be present"
|
|
);
|
|
|
|
db.close().unwrap();
|
|
}
|
|
|
|
/// `export_signals` respects the `limit` field.
|
|
///
|
|
/// Export is called on the same open DB instance (before close) because
|
|
/// clean shutdown compacts WAL segments, leaving nothing to scan.
|
|
#[test]
|
|
#[cfg(feature = "test-utils")]
|
|
fn export_signals_respects_limit() {
|
|
use tidaldb::TempTidalHome;
|
|
|
|
let home = TempTidalHome::new().unwrap();
|
|
let schema = build_test_schema();
|
|
|
|
let db = TidalDb::builder()
|
|
.with_data_dir(home.path())
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
for i in 1u64..=10 {
|
|
db.signal("view", EntityId::new(i), 1.0, Timestamp::now())
|
|
.unwrap();
|
|
}
|
|
|
|
let mut req = ExportRequest::time_range(0, u64::MAX);
|
|
req.limit = Some(3);
|
|
let signals = db.export_signals(&req).unwrap();
|
|
assert_eq!(signals.len(), 3, "limit must cap the result count");
|
|
|
|
db.close().unwrap();
|
|
}
|
|
|
|
/// `user_id` filter returns empty because WAL events do not record the
|
|
/// originating user.
|
|
///
|
|
/// Per task-08 spec: "if user_id filter is set but no user-to-entity
|
|
/// mapping exists, an empty result is returned." This early return fires
|
|
/// before any WAL scanning, so no persistent DB is needed.
|
|
#[test]
|
|
fn export_signals_user_id_filter_returns_empty() {
|
|
let schema = build_test_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
db.signal("view", EntityId::new(1), 1.0, Timestamp::now())
|
|
.unwrap();
|
|
|
|
let mut req = ExportRequest::time_range(0, u64::MAX);
|
|
req.user_id = Some(42);
|
|
let signals = db.export_signals(&req).unwrap();
|
|
assert!(
|
|
signals.is_empty(),
|
|
"user_id filter must return empty: WAL events do not record the originating user"
|
|
);
|
|
}
|
|
|
|
// ── Task 09: Cross-Session Aggregation ──────────────────────────────────────
|
|
|
|
#[test]
|
|
fn user_session_summary_aggregates_correctly() {
|
|
let schema = build_test_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
let user_id = 42u64;
|
|
let meta = HashMap::new();
|
|
|
|
// Run 3 sessions, each with 2 views + 1 like = 3 signals per session.
|
|
for i in 0u64..3 {
|
|
let handle = db
|
|
.start_session(user_id, "test", "default", meta.clone())
|
|
.unwrap();
|
|
db.session_signal(
|
|
&handle,
|
|
"view",
|
|
EntityId::new(i * 10 + 1),
|
|
1.0,
|
|
Timestamp::now(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
db.session_signal(
|
|
&handle,
|
|
"view",
|
|
EntityId::new(i * 10 + 2),
|
|
1.0,
|
|
Timestamp::now(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
db.session_signal(
|
|
&handle,
|
|
"like",
|
|
EntityId::new(i * 10 + 3),
|
|
1.0,
|
|
Timestamp::now(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
db.close_session(handle).unwrap();
|
|
}
|
|
|
|
let summary = db.user_session_summary(user_id, 0).unwrap();
|
|
assert_eq!(summary.sessions_count, 3);
|
|
assert_eq!(summary.total_signals, 9);
|
|
assert_eq!(summary.total_rejections, 0);
|
|
assert_eq!(summary.user_id, user_id);
|
|
assert_eq!(summary.since_ns, 0);
|
|
assert!(summary.earliest_session_ns.is_some());
|
|
assert!(summary.latest_session_ns.is_some());
|
|
assert!(summary.preference_drift.is_none());
|
|
|
|
// Top signal types: "view" first (6 total), "like" second (3 total).
|
|
assert_eq!(summary.top_signal_types.len(), 2);
|
|
assert_eq!(summary.top_signal_types[0].0, "view");
|
|
assert_eq!(summary.top_signal_types[0].1, 6);
|
|
assert_eq!(summary.top_signal_types[1].0, "like");
|
|
assert_eq!(summary.top_signal_types[1].1, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn user_session_summary_no_sessions_returns_not_found() {
|
|
let schema = build_test_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
let result = db.user_session_summary(999, 0);
|
|
assert!(result.is_err());
|
|
assert!(matches!(result, Err(tidaldb::TidalError::NotFound { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn user_session_summary_different_user_excluded() {
|
|
let schema = build_test_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
let meta = HashMap::new();
|
|
|
|
// User 1: one view signal.
|
|
let handle = db
|
|
.start_session(1u64, "test", "default", meta.clone())
|
|
.unwrap();
|
|
db.session_signal(
|
|
&handle,
|
|
"view",
|
|
EntityId::new(1),
|
|
1.0,
|
|
Timestamp::now(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
db.close_session(handle).unwrap();
|
|
|
|
// User 2: one like signal.
|
|
let handle = db.start_session(2u64, "test", "default", meta).unwrap();
|
|
db.session_signal(
|
|
&handle,
|
|
"like",
|
|
EntityId::new(2),
|
|
1.0,
|
|
Timestamp::now(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
db.close_session(handle).unwrap();
|
|
|
|
// Query user 1 only — user 2's session must be excluded.
|
|
let summary = db.user_session_summary(1, 0).unwrap();
|
|
assert_eq!(summary.sessions_count, 1);
|
|
assert_eq!(summary.total_signals, 1);
|
|
assert_eq!(summary.top_signal_types.len(), 1);
|
|
assert_eq!(summary.top_signal_types[0].0, "view");
|
|
assert_eq!(summary.top_signal_types[0].1, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn user_session_summary_since_ns_filter() {
|
|
let schema = build_test_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
let user_id = 100u64;
|
|
let meta = HashMap::new();
|
|
|
|
// Session 1.
|
|
let handle = db
|
|
.start_session(user_id, "test", "default", meta.clone())
|
|
.unwrap();
|
|
db.session_signal(
|
|
&handle,
|
|
"view",
|
|
EntityId::new(1),
|
|
1.0,
|
|
Timestamp::now(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
db.close_session(handle).unwrap();
|
|
|
|
// Record time between sessions.
|
|
let midpoint = Timestamp::now().as_nanos();
|
|
|
|
// Session 2 (after midpoint).
|
|
let handle = db.start_session(user_id, "test", "default", meta).unwrap();
|
|
db.session_signal(
|
|
&handle,
|
|
"like",
|
|
EntityId::new(2),
|
|
1.0,
|
|
Timestamp::now(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
db.close_session(handle).unwrap();
|
|
|
|
// since_ns = midpoint must exclude session 1 and include only session 2.
|
|
let summary = db.user_session_summary(user_id, midpoint).unwrap();
|
|
assert_eq!(summary.sessions_count, 1);
|
|
assert_eq!(summary.total_signals, 1);
|
|
assert_eq!(summary.top_signal_types[0].0, "like");
|
|
}
|
|
|
|
#[test]
|
|
fn session_snapshot_has_timing_fields() {
|
|
let schema = build_test_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
let user_id = 50u64;
|
|
let meta = HashMap::new();
|
|
|
|
let before = Timestamp::now().as_nanos();
|
|
|
|
let handle = db.start_session(user_id, "test", "default", meta).unwrap();
|
|
db.session_signal(
|
|
&handle,
|
|
"view",
|
|
EntityId::new(1),
|
|
1.0,
|
|
Timestamp::now(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
// Active snapshot: started_at_ns populated, closed_at_ns = 0.
|
|
let active_snap = db.session_snapshot(handle.id).unwrap();
|
|
assert!(
|
|
active_snap.started_at_ns >= before,
|
|
"started_at_ns should be after test start"
|
|
);
|
|
assert_eq!(
|
|
active_snap.closed_at_ns, 0,
|
|
"active session should have closed_at_ns = 0"
|
|
);
|
|
|
|
db.close_session(handle).unwrap();
|
|
|
|
let after = Timestamp::now().as_nanos();
|
|
|
|
// Summary timing should bracket the session.
|
|
let summary = db.user_session_summary(user_id, 0).unwrap();
|
|
assert!(summary.earliest_session_ns.unwrap() >= before);
|
|
assert!(summary.latest_session_ns.unwrap() <= after);
|
|
}
|
|
|
|
#[test]
|
|
fn signal_snap_entry_has_count() {
|
|
let schema = build_test_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
let user_id = 60u64;
|
|
let meta = HashMap::new();
|
|
|
|
let handle = db.start_session(user_id, "test", "default", meta).unwrap();
|
|
// Write 3 view signals and 1 like signal.
|
|
for entity in [1u64, 2, 3] {
|
|
db.session_signal(
|
|
&handle,
|
|
"view",
|
|
EntityId::new(entity),
|
|
1.0,
|
|
Timestamp::now(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
}
|
|
db.session_signal(
|
|
&handle,
|
|
"like",
|
|
EntityId::new(4),
|
|
1.0,
|
|
Timestamp::now(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
let snap = db.session_snapshot(handle.id).unwrap();
|
|
let view_entry = snap.signals.get("view").unwrap();
|
|
assert_eq!(view_entry.count, 3);
|
|
let like_entry = snap.signals.get("like").unwrap();
|
|
assert_eq!(like_entry.count, 1);
|
|
|
|
db.close_session(handle).unwrap();
|
|
}
|