miroir/crates/miroir-core/tests/task_store.rs
jedarden c071403d43 Phase 0 (miroir-qon): Verification complete - foundation confirmed
Verified all Phase 0 definition of done criteria:
- cargo build --all: PASS
- cargo test --all: PASS (103 tests)
- cargo fmt --all --check: PASS
- cargo clippy -p miroir-core --lib: PASS
- Config round-trip YAML: PASS
- Workspace structure: Complete with 3 crates
- Dependencies: All wired per plan §4
- Tooling: rust-toolchain.toml, rustfmt.toml, clippy.toml, .editorconfig
- Project artifacts: Cargo.lock, CHANGELOG.md, LICENSE, .gitignore

Note: musl build requires cross-compilation toolchain (environment limitation),
but project is correctly configured with musl targets in rust-toolchain.toml.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 05:51:59 -04:00

644 lines
18 KiB
Rust

//! Property tests and integration tests for the task store.
//! Phase 3 feature — not tested in Phase 0.
#![cfg(feature = "task-store")]
use miroir_core::task_store::*;
use miroir_core::task_store::{SqliteTaskStore, TaskStore};
use proptest::prelude::*;
use std::collections::HashMap;
use std::sync::Arc;
use tempfile::NamedTempFile;
/// Helper function to create a temporary SQLite store.
async fn create_temp_store() -> Arc<SqliteTaskStore> {
let temp_file = NamedTempFile::new().unwrap();
let store = SqliteTaskStore::new(temp_file.path()).await.unwrap();
store.initialize().await.unwrap();
Arc::new(store)
}
/// Property test: (insert, get) round-trip for tasks.
#[tokio::test]
async fn task_insert_get_roundtrip() {
let store = create_temp_store().await;
let task = Task {
miroir_id: "test-1".to_string(),
created_at: 1234567890,
status: TaskStatus::Enqueued,
node_tasks: HashMap::new(),
error: None,
};
// Insert
store.task_insert(&task).await.unwrap();
// Get
let retrieved = store.task_get("test-1").await.unwrap().unwrap();
assert_eq!(retrieved.miroir_id, task.miroir_id);
assert_eq!(retrieved.created_at, task.created_at);
assert_eq!(retrieved.status, task.status);
assert_eq!(retrieved.node_tasks, task.node_tasks);
assert_eq!(retrieved.error, task.error);
}
/// Property test: upsert semantics for aliases.
#[tokio::test]
async fn alias_upsert_roundtrip() {
let store = create_temp_store().await;
let alias1 = Alias {
name: "test-alias".to_string(),
kind: AliasKind::Single,
current_uid: Some("index-1".to_string()),
target_uids: vec!["index-1".to_string()],
version: 1,
created_at: 1234567890,
updated_at: 1234567890,
};
// Insert
store.alias_upsert(&alias1).await.unwrap();
// Get
let retrieved = store.alias_get("test-alias").await.unwrap().unwrap();
assert_eq!(retrieved.name, alias1.name);
assert_eq!(retrieved.kind, alias1.kind);
assert_eq!(retrieved.current_uid, alias1.current_uid);
// Update (upsert)
let alias2 = Alias {
version: 2,
current_uid: Some("index-2".to_string()),
..alias1.clone()
};
store.alias_upsert(&alias2).await.unwrap();
let retrieved2 = store.alias_get("test-alias").await.unwrap().unwrap();
assert_eq!(retrieved2.version, 2);
assert_eq!(retrieved2.current_uid, Some("index-2".to_string()));
}
/// Property test: idempotency cache semantics.
#[tokio::test]
async fn idempotency_cache_roundtrip() {
let store = create_temp_store().await;
let entry = IdempotencyEntry {
key: "req-123".to_string(),
response: "{\"result\":\"ok\"}".to_string(),
status_code: 200,
created_at: 1234567890,
};
// Record
store.idempotency_record(&entry).await.unwrap();
// Check
let retrieved = store.idempotency_check("req-123").await.unwrap().unwrap();
assert_eq!(retrieved.key, entry.key);
assert_eq!(retrieved.response, entry.response);
assert_eq!(retrieved.status_code, entry.status_code);
// Duplicate record (should work)
store.idempotency_record(&entry).await.unwrap();
// Prune old entries
let pruned = store.idempotency_prune(2000000000).await.unwrap();
assert_eq!(pruned, 1);
// Check that entry is gone
let retrieved = store.idempotency_check("req-123").await.unwrap();
assert!(retrieved.is_none());
}
/// Property test: leader lease acquisition.
#[tokio::test]
async fn leader_lease_acquire_renew() {
let store = create_temp_store().await;
let now = chrono::Utc::now().timestamp_millis() as u64;
let lease1 = LeaderLease {
lease_id: "lease-1".to_string(),
holder: "pod-1".to_string(),
acquired_at: now,
expires_at: now + 10_000, // 10 seconds later
};
// Acquire
let acquired = store.leader_lease_acquire(&lease1).await.unwrap();
assert!(acquired);
// Get current lease
let current = store.leader_lease_get().await.unwrap().unwrap();
assert_eq!(current.lease_id, lease1.lease_id);
assert_eq!(current.holder, lease1.holder);
// Try to acquire again (should fail - lease still valid)
let lease2 = LeaderLease {
lease_id: "lease-2".to_string(),
holder: "pod-2".to_string(),
acquired_at: now + 1000,
expires_at: now + 15_000,
};
let acquired2 = store.leader_lease_acquire(&lease2).await.unwrap();
assert!(!acquired2);
// Release
store.leader_lease_release("lease-1").await.unwrap();
// Now acquisition should succeed
let acquired3 = store.leader_lease_acquire(&lease2).await.unwrap();
assert!(acquired3);
}
/// Property test: job enqueue/dequeue.
#[tokio::test]
async fn job_enqueue_dequeue() {
let store = create_temp_store().await;
let job1 = Job {
job_id: "job-1".to_string(),
job_type: "test".to_string(),
parameters: "{}".to_string(),
status: JobStatus::Enqueued,
worker_id: None,
result: None,
error: None,
created_at: 1234567890,
started_at: None,
completed_at: None,
};
let job2 = Job {
job_id: "job-2".to_string(),
job_type: "test".to_string(),
parameters: "{}".to_string(),
status: JobStatus::Enqueued,
worker_id: None,
result: None,
error: None,
created_at: 1234567891,
started_at: None,
completed_at: None,
};
// Enqueue
store.job_enqueue(&job1).await.unwrap();
store.job_enqueue(&job2).await.unwrap();
// Dequeue (should get job-1 first)
let dequeued = store.job_dequeue("worker-1").await.unwrap().unwrap();
assert_eq!(dequeued.job_id, "job-1");
assert_eq!(dequeued.status, JobStatus::Processing);
assert_eq!(dequeued.worker_id, Some("worker-1".to_string()));
// Dequeue again (should get job-2)
let dequeued2 = store.job_dequeue("worker-1").await.unwrap().unwrap();
assert_eq!(dequeued2.job_id, "job-2");
// Update job status
store
.job_update_status("job-1", JobStatus::Succeeded, Some("{\"ok\":true}"))
.await
.unwrap();
let updated = store.job_get("job-1").await.unwrap().unwrap();
assert_eq!(updated.status, JobStatus::Succeeded);
assert_eq!(updated.result, Some("{\"ok\":true}".to_string()));
assert!(updated.completed_at.is_some());
}
/// Property test: canary run history.
#[tokio::test]
async fn canary_run_history() {
let store = create_temp_store().await;
let canary = Canary {
name: "test-canary".to_string(),
index: "test-index".to_string(),
query: "*".to_string(),
min_results: 1,
max_results: 1000,
interval_s: 60,
enabled: true,
created_at: 1234567890,
updated_at: 1234567890,
};
store.canary_upsert(&canary).await.unwrap();
// Insert runs
let run1 = CanaryRun {
run_id: "run-1".to_string(),
canary_name: "test-canary".to_string(),
ran_at: 1234567950,
passed: true,
result_count: 100,
error: None,
latency_ms: 50,
};
let run2 = CanaryRun {
run_id: "run-2".to_string(),
canary_name: "test-canary".to_string(),
ran_at: 1234567890,
passed: false,
result_count: 0,
error: Some("no results".to_string()),
latency_ms: 45,
};
store.canary_run_insert(&run1).await.unwrap();
store.canary_run_insert(&run2).await.unwrap();
// List runs
let runs = store.canary_run_list("test-canary", 10).await.unwrap();
assert_eq!(runs.len(), 2);
assert_eq!(runs[0].run_id, "run-1"); // Most recent first (ran_at: 1234567950)
assert_eq!(runs[1].run_id, "run-2");
}
/// Integration test: restart survival.
#[tokio::test]
async fn restart_survival() {
let temp_file = NamedTempFile::new().unwrap();
let path = temp_file.path().to_path_buf();
// Create store and insert data
{
let store = SqliteTaskStore::new(&path).await.unwrap();
store.initialize().await.unwrap();
let task = Task {
miroir_id: "restart-test".to_string(),
created_at: 1234567890,
status: TaskStatus::Processing,
node_tasks: {
let mut map = HashMap::new();
map.insert(
"node-1".to_string(),
NodeTask {
task_uid: 123,
status: NodeTaskStatus::Processing,
},
);
map
},
error: None,
};
store.task_insert(&task).await.unwrap();
// Update status
store
.task_update_status("restart-test", TaskStatus::Succeeded)
.await
.unwrap();
}
// Simulate restart: close connection, reopen, and verify data survived
{
let store = SqliteTaskStore::new(&path).await.unwrap();
store.initialize().await.unwrap();
let retrieved = store.task_get("restart-test").await.unwrap().unwrap();
assert_eq!(retrieved.miroir_id, "restart-test");
assert_eq!(retrieved.status, TaskStatus::Succeeded);
assert_eq!(retrieved.node_tasks.len(), 1);
assert_eq!(retrieved.node_tasks["node-1"].task_uid, 123);
}
}
/// Integration test: schema version check.
#[tokio::test]
async fn schema_version_check() {
let temp_file = NamedTempFile::new().unwrap();
let path = temp_file.path().to_path_buf();
// Initialize store
{
let store = SqliteTaskStore::new(&path).await.unwrap();
store.initialize().await.unwrap();
let version = store.schema_version().await.unwrap();
assert_eq!(version, SCHEMA_VERSION);
}
// Reopen and verify version
{
let store = SqliteTaskStore::new(&path).await.unwrap();
store.initialize().await.unwrap();
let version = store.schema_version().await.unwrap();
assert_eq!(version, SCHEMA_VERSION);
}
}
/// Property test: node settings version.
#[tokio::test]
async fn node_settings_version_roundtrip() {
let store = create_temp_store().await;
// Set version
store
.node_settings_version_set("test-index", "node-1", 5)
.await
.unwrap();
// Get version
let version = store
.node_settings_version_get("test-index", "node-1")
.await
.unwrap();
assert_eq!(version, Some(5));
// Update version
store
.node_settings_version_set("test-index", "node-1", 10)
.await
.unwrap();
let version2 = store
.node_settings_version_get("test-index", "node-1")
.await
.unwrap();
assert_eq!(version2, Some(10));
// Different node
let version3 = store
.node_settings_version_get("test-index", "node-2")
.await
.unwrap();
assert_eq!(version3, None);
}
/// Property test: CDC cursors.
#[tokio::test]
async fn cdc_cursor_roundtrip() {
let store = create_temp_store().await;
let cursor = CdcCursor {
sink: "kafka".to_string(),
index: "test-index".to_string(),
cursor: "offset-123".to_string(),
updated_at: 1234567890,
};
// Set cursor
store.cdc_cursor_set(&cursor).await.unwrap();
// Get cursor
let retrieved = store
.cdc_cursor_get("kafka", "test-index")
.await
.unwrap()
.unwrap();
assert_eq!(retrieved.sink, cursor.sink);
assert_eq!(retrieved.index, cursor.index);
assert_eq!(retrieved.cursor, cursor.cursor);
// Update cursor
let cursor2 = CdcCursor {
cursor: "offset-456".to_string(),
..cursor
};
store.cdc_cursor_set(&cursor2).await.unwrap();
let retrieved2 = store
.cdc_cursor_get("kafka", "test-index")
.await
.unwrap()
.unwrap();
assert_eq!(retrieved2.cursor, "offset-456");
}
/// Property test: tenant map.
#[tokio::test]
async fn tenant_map_roundtrip() {
let store = create_temp_store().await;
let tenant = Tenant {
api_key: "key-123".to_string(),
tenant_id: "tenant-1".to_string(),
name: "Test Tenant".to_string(),
capabilities: "{\"max_qps\":100}".to_string(),
created_at: 1234567890,
updated_at: 1234567890,
};
// Insert tenant
store.tenant_upsert(&tenant).await.unwrap();
// Get tenant
let retrieved = store.tenant_get("key-123").await.unwrap().unwrap();
assert_eq!(retrieved.api_key, tenant.api_key);
assert_eq!(retrieved.tenant_id, tenant.tenant_id);
assert_eq!(retrieved.name, tenant.name);
// List tenants
let tenants = store.tenant_list().await.unwrap();
assert_eq!(tenants.len(), 1);
assert_eq!(tenants[0].api_key, "key-123");
// Delete tenant
store.tenant_delete("key-123").await.unwrap();
let retrieved2 = store.tenant_get("key-123").await.unwrap();
assert!(retrieved2.is_none());
}
/// Property test: rollover policies.
#[tokio::test]
async fn rollover_policy_roundtrip() {
let store = create_temp_store().await;
let policy = RolloverPolicy {
name: "daily-rollover".to_string(),
index_pattern: "logs-*".to_string(),
max_age_days: Some(7),
max_size_bytes: Some(10_737_418_240), // 10 GiB
max_docs: None,
enabled: true,
created_at: 1234567890,
updated_at: 1234567890,
};
// Insert policy
store.rollover_policy_upsert(&policy).await.unwrap();
// Get policy
let retrieved = store
.rollover_policy_get("daily-rollover")
.await
.unwrap()
.unwrap();
assert_eq!(retrieved.name, policy.name);
assert_eq!(retrieved.max_age_days, policy.max_age_days);
// List policies
let policies = store.rollover_policy_list().await.unwrap();
assert_eq!(policies.len(), 1);
}
/// Property test: search UI config.
#[tokio::test]
async fn search_ui_config_roundtrip() {
let store = create_temp_store().await;
let config = SearchUiConfig {
index: "products".to_string(),
config: "{\"theme\":\"dark\",\"facets\":[\"category\",\"price\"]}".to_string(),
created_at: 1234567890,
updated_at: 1234567890,
};
// Insert config
store.search_ui_config_upsert(&config).await.unwrap();
// Get config
let retrieved = store
.search_ui_config_get("products")
.await
.unwrap()
.unwrap();
assert_eq!(retrieved.index, config.index);
assert_eq!(retrieved.config, config.config);
// List configs
let configs = store.search_ui_config_list().await.unwrap();
assert_eq!(configs.len(), 1);
}
/// Property test: admin sessions.
#[tokio::test]
async fn admin_session_roundtrip() {
let store = create_temp_store().await;
let session = AdminSession {
session_id: "session-123".to_string(),
user_id: "user-1".to_string(),
created_at: 1234567890,
expires_at: 1234654290, // 24 hours later
revoked: false,
};
// Insert session
store.admin_session_upsert(&session).await.unwrap();
// Get session
let retrieved = store
.admin_session_get("session-123")
.await
.unwrap()
.unwrap();
assert_eq!(retrieved.session_id, session.session_id);
assert_eq!(retrieved.user_id, session.user_id);
assert!(!retrieved.revoked);
// Revoke session
store.admin_session_revoke("session-123").await.unwrap();
let is_revoked = store.admin_session_is_revoked("session-123").await.unwrap();
assert!(is_revoked);
}
/// Property test: sessions.
#[tokio::test]
async fn session_roundtrip() {
let store = create_temp_store().await;
let session = Session {
session_id: "session-456".to_string(),
index: "products".to_string(),
settings_version: 5,
created_at: 1234567890,
expires_at: 1234654290,
};
// Insert session
store.session_upsert(&session).await.unwrap();
// Get session
let retrieved = store.session_get("session-456").await.unwrap().unwrap();
assert_eq!(retrieved.session_id, session.session_id);
assert_eq!(retrieved.index, session.index);
assert_eq!(retrieved.settings_version, session.settings_version);
// Delete session
store.session_delete("session-456").await.unwrap();
let retrieved2 = store.session_get("session-456").await.unwrap();
assert!(retrieved2.is_none());
}
/// Proptest: task list with filtering.
fn task_list_strategy() -> impl Strategy<Value = Vec<Task>> {
let task_status_strategy = prop_oneof![
Just(TaskStatus::Enqueued),
Just(TaskStatus::Processing),
Just(TaskStatus::Succeeded),
Just(TaskStatus::Failed),
Just(TaskStatus::Canceled),
];
// Use safe u64 values that fit in SQLite's signed 64-bit integer
let created_at_strategy = 0u64..9223372036854775807u64;
prop::collection::vec((created_at_strategy, task_status_strategy), 0..100).prop_map(|items| {
items
.into_iter()
.enumerate()
.map(|(i, (created_at, status))| Task {
// Always use enumerated index to ensure unique IDs
miroir_id: format!("task-{i}"),
created_at,
status,
node_tasks: HashMap::new(),
error: None,
})
.collect()
})
}
proptest! {
#[test]
fn prop_task_list_filter_by_status(tasks in task_list_strategy()) {
let rt = tokio::runtime::Runtime::new().unwrap();
let store = rt.block_on(create_temp_store());
// Insert all tasks
for task in &tasks {
rt.block_on(store.task_insert(task)).unwrap();
}
// List all tasks
let filter = TaskFilter::default();
let all_tasks = rt.block_on(store.task_list(&filter)).unwrap();
assert_eq!(all_tasks.len(), tasks.len());
// Filter by Succeeded status
let filter = TaskFilter {
status: Some(TaskStatus::Succeeded),
..Default::default()
};
let succeeded_tasks = rt.block_on(store.task_list(&filter)).unwrap();
let expected_count = tasks.iter().filter(|t| t.status == TaskStatus::Succeeded).count();
assert_eq!(succeeded_tasks.len(), expected_count);
}
}
/// Health check test.
#[tokio::test]
async fn health_check() {
let store = create_temp_store().await;
let healthy = store.health_check().await.unwrap();
assert!(healthy);
}