//! Integration tests for the StemeDB API. #![allow(clippy::expect_used)] use axum::{ body::Body, http::{Request, StatusCode}, }; use ed25519_dalek::{Signer, SigningKey}; use rand::rngs::OsRng; use serde_json::json; use std::sync::Arc; use tokio::sync::Mutex; use tower::ServiceExt; // for `oneshot` and `ready` use stemedb_api::{create_router, AppState}; use stemedb_ingest::Ingestor; use stemedb_storage::SledStore; use stemedb_wal::Journal; /// Test environment that keeps temp directories alive for the test duration. /// /// The `_temp_dir` field must be kept alive to prevent the directory from /// being deleted while the Journal and SledStore are still using it. struct TestEnvironment { _temp_dir: tempfile::TempDir, state: AppState, } /// Test environment with full ingestor for roundtrip tests. struct TestEnvironmentWithIngestor { _temp_dir: tempfile::TempDir, state: AppState, ingestor: Ingestor, } /// Helper to create a test environment with temporary directories. async fn create_test_env() -> TestEnvironment { let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); let wal_dir = temp_dir.path().join("wal"); let db_dir = temp_dir.path().join("db"); std::fs::create_dir_all(&wal_dir).expect("failed to create wal dir"); std::fs::create_dir_all(&db_dir).expect("failed to create db dir"); let journal = Journal::open(&wal_dir).expect("failed to open journal"); let store = SledStore::open(&db_dir).expect("failed to open store"); let state = AppState::new(journal, store); TestEnvironment { _temp_dir: temp_dir, state } } /// Helper to create a test environment with a running ingestor for roundtrip tests. /// /// Note: We need to share the same store between AppState and Ingestor. /// AppState::new() takes ownership, so we need a different approach: /// we'll create the ingestor first, then construct AppState manually. async fn create_test_env_with_ingestor() -> TestEnvironmentWithIngestor { use stemedb_storage::GenericQuotaStore; let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); let wal_dir = temp_dir.path().join("wal"); let db_dir = temp_dir.path().join("db"); std::fs::create_dir_all(&wal_dir).expect("failed to create wal dir"); std::fs::create_dir_all(&db_dir).expect("failed to create db dir"); // Create shared store let store = Arc::new(SledStore::open(&db_dir).expect("failed to open store")); // Journal for API (writing) let journal_for_api = Arc::new(Mutex::new(Journal::open(&wal_dir).expect("failed to open journal for API"))); // Journal for ingestor (reading) - WAL allows multiple readers let journal_for_ingestor = Arc::new(Mutex::new(Journal::open(&wal_dir).expect("failed to open journal for ingestor"))); // Create ingestor with shared store let ingestor = Ingestor::new(journal_for_ingestor, store.clone()) .await .expect("failed to create ingestor"); // Create quota store for AppState let quota_store = Arc::new(GenericQuotaStore::new(store.clone())); // Construct AppState manually to share the store let state = AppState { journal: journal_for_api, store, quota_store }; TestEnvironmentWithIngestor { _temp_dir: temp_dir, state, ingestor } } #[tokio::test] async fn test_health_check() { let env = create_test_env().await; let app = create_router(env.state); let request = Request::builder().uri("/v1/health").method("GET").body(Body::empty()).unwrap(); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["status"], "healthy"); assert!(json["version"].is_string()); assert_eq!(json["assertions_count"], 0); } #[tokio::test] async fn test_dto_confidence_validation() { let env = create_test_env().await; let app = create_router(env.state); // Test confidence > 1.0 let invalid_assertion = json!({ "subject": "Tesla", "predicate": "has_revenue", "object": {"type": "Number", "value": 100.0}, "confidence": 1.5, "signatures": [{ "agent_id": "a".repeat(64), "signature": "b".repeat(128), "timestamp": 1234567890 }], "source_hash": "c".repeat(64) }); let request = Request::builder() .uri("/v1/assert") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&invalid_assertion).unwrap())) .unwrap(); let response = app.clone().oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!(json["error"].as_str().unwrap().contains("Confidence must be between 0.0 and 1.0")); // Test confidence < 0.0 let invalid_assertion = json!({ "subject": "Tesla", "predicate": "has_revenue", "object": {"type": "Number", "value": 100.0}, "confidence": -0.5, "signatures": [{ "agent_id": "a".repeat(64), "signature": "b".repeat(128), "timestamp": 1234567890 }], "source_hash": "c".repeat(64) }); let request = Request::builder() .uri("/v1/assert") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&invalid_assertion).unwrap())) .unwrap(); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!(json["error"].as_str().unwrap().contains("Confidence must be between 0.0 and 1.0")); } #[tokio::test] async fn test_dto_weight_validation() { let env = create_test_env().await; let app = create_router(env.state); // Test weight > 1.0 let invalid_vote = json!({ "assertion_hash": "a".repeat(64), "agent_id": "b".repeat(64), "weight": 1.5, "signature": "c".repeat(128) }); let request = Request::builder() .uri("/v1/vote") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&invalid_vote).unwrap())) .unwrap(); let response = app.clone().oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!(json["error"].as_str().unwrap().contains("Weight must be between 0.0 and 1.0")); // Test weight < 0.0 let invalid_vote = json!({ "assertion_hash": "a".repeat(64), "agent_id": "b".repeat(64), "weight": -0.5, "signature": "c".repeat(128) }); let request = Request::builder() .uri("/v1/vote") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&invalid_vote).unwrap())) .unwrap(); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!(json["error"].as_str().unwrap().contains("Weight must be between 0.0 and 1.0")); } #[tokio::test] async fn test_hex_decode_wrong_length() { let env = create_test_env().await; let app = create_router(env.state); // Test assertion with wrong-length source_hash let invalid_assertion = json!({ "subject": "Tesla", "predicate": "has_revenue", "object": {"type": "Number", "value": 100.0}, "confidence": 0.9, "signatures": [{ "agent_id": "a".repeat(64), "signature": "b".repeat(128), "timestamp": 1234567890 }], "source_hash": "abc" // Too short }); let request = Request::builder() .uri("/v1/assert") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&invalid_assertion).unwrap())) .unwrap(); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); let error_msg = json["error"].as_str().unwrap(); assert!(error_msg.contains("Expected 64 hex characters")); assert!(error_msg.contains("got 3")); } #[tokio::test] async fn test_hex_decode_valid() { let env = create_test_env().await; let app = create_router(env.state); // Test assertion with valid hex lengths // Note: This will succeed in parsing/validation but may fail in the ingest worker // We're primarily testing that hex validation accepts correct-length strings let valid_assertion = json!({ "subject": "Tesla", "predicate": "has_revenue", "object": {"type": "Number", "value": 100.0}, "confidence": 0.9, "signatures": [{ "agent_id": "a".repeat(64), "signature": "b".repeat(128), "timestamp": 1234567890 }], "source_hash": "c".repeat(64) }); let request = Request::builder() .uri("/v1/assert") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&valid_assertion).unwrap())) .unwrap(); let response = app.oneshot(request).await.unwrap(); // Accept either 201 (success) or 500 (ingest worker not running in test) // We're primarily testing that the hex validation doesn't reject valid lengths assert!( response.status() == StatusCode::CREATED || response.status() == StatusCode::INTERNAL_SERVER_ERROR, "Expected 201 or 500, got {}", response.status() ); } #[tokio::test] async fn test_dto_to_assertion_invalid_hex() { let env = create_test_env().await; let app = create_router(env.state); // Test assertion with invalid hex characters let invalid_assertion = json!({ "subject": "Tesla", "predicate": "has_revenue", "object": {"type": "Number", "value": 100.0}, "confidence": 0.9, "signatures": [{ "agent_id": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", // 'z' is not a valid hex character (64 z's) "signature": "b".repeat(128), "timestamp": 1234567890 }], "source_hash": "c".repeat(64) }); let request = Request::builder() .uri("/v1/assert") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&invalid_assertion).unwrap())) .unwrap(); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); let error = json["error"].as_str().unwrap(); // The error could be "Invalid hex encoding" or similar - just check it's a bad request error assert!(!error.is_empty(), "Error message should not be empty"); } #[tokio::test] async fn test_query_empty_results() { let env = create_test_env().await; let app = create_router(env.state); let request = Request::builder() .uri("/v1/query?subject=nonexistent") .method("GET") .body(Body::empty()) .unwrap(); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["assertions"], json!([])); assert_eq!(json["total_count"], 0); assert_eq!(json["has_more"], false); } // ============================================================================ // Epoch Endpoint Integration Tests // ============================================================================ #[tokio::test] async fn test_create_epoch_success() { let env = create_test_env().await; let app = create_router(env.state); let epoch = json!({ "name": "GAAP-2024", "start_timestamp": 1704067200 }); let request = Request::builder() .uri("/v1/epoch") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&epoch).unwrap())) .unwrap(); let response = app.oneshot(request).await.unwrap(); let status = response.status(); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(status, StatusCode::CREATED, "Body: {:?}", json); assert_eq!(json["status"], "created"); // BLAKE3 hash = 32 bytes = 64 hex characters assert_eq!(json["hash"].as_str().unwrap().len(), 64); } #[tokio::test] async fn test_create_epoch_empty_name_fails() { let env = create_test_env().await; let app = create_router(env.state); let epoch = json!({ "name": "", "start_timestamp": 1704067200 }); let request = Request::builder() .uri("/v1/epoch") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&epoch).unwrap())) .unwrap(); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!(json["error"].as_str().unwrap().contains("cannot be empty")); } #[tokio::test] async fn test_create_epoch_whitespace_name_fails() { let env = create_test_env().await; let app = create_router(env.state); let epoch = json!({ "name": " ", "start_timestamp": 1704067200 }); let request = Request::builder() .uri("/v1/epoch") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&epoch).unwrap())) .unwrap(); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!(json["error"].as_str().unwrap().contains("cannot be empty")); } #[tokio::test] async fn test_create_epoch_with_supersedes() { let env = create_test_env().await; let app = create_router(env.state); // Supersession requires both supersedes hash AND supersession_type let epoch = json!({ "name": "GAAP-2025", "supersedes": "a".repeat(64), "supersession_type": "Temporal", "start_timestamp": 1735689600 }); let request = Request::builder() .uri("/v1/epoch") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&epoch).unwrap())) .unwrap(); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::CREATED); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["status"], "created"); } #[tokio::test] async fn test_create_epoch_supersedes_without_type_fails() { let env = create_test_env().await; let app = create_router(env.state); // Providing supersedes but NOT supersession_type should fail let epoch = json!({ "name": "GAAP-2025", "supersedes": "a".repeat(64), // Missing supersession_type! "start_timestamp": 1735689600 }); let request = Request::builder() .uri("/v1/epoch") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&epoch).unwrap())) .unwrap(); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!(json["error"].as_str().unwrap().contains("supersession_type is required")); } #[tokio::test] async fn test_create_epoch_invalid_supersedes_hex() { let env = create_test_env().await; let app = create_router(env.state); let epoch = json!({ "name": "GAAP-2025", "supersedes": "invalid", // Too short, should be 64 hex chars "supersession_type": "Invalidate", "start_timestamp": 1735689600 }); let request = Request::builder() .uri("/v1/epoch") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&epoch).unwrap())) .unwrap(); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); // Should complain about hex length assert!(json["error"].as_str().unwrap().contains("Expected 64 hex characters")); } #[tokio::test] async fn test_create_epoch_deterministic_id() { let env = create_test_env().await; let app = create_router(env.state); // Create the same epoch twice - should get the same ID let epoch = json!({ "name": "Deterministic-Test", "start_timestamp": 1000 }); let request1 = Request::builder() .uri("/v1/epoch") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&epoch).unwrap())) .unwrap(); let response1 = app.clone().oneshot(request1).await.unwrap(); assert_eq!(response1.status(), StatusCode::CREATED); let body1 = axum::body::to_bytes(response1.into_body(), usize::MAX).await.unwrap(); let json1: serde_json::Value = serde_json::from_slice(&body1).unwrap(); let hash1 = json1["hash"].as_str().unwrap(); let request2 = Request::builder() .uri("/v1/epoch") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&epoch).unwrap())) .unwrap(); let response2 = app.oneshot(request2).await.unwrap(); assert_eq!(response2.status(), StatusCode::CREATED); let body2 = axum::body::to_bytes(response2.into_body(), usize::MAX).await.unwrap(); let json2: serde_json::Value = serde_json::from_slice(&body2).unwrap(); let hash2 = json2["hash"].as_str().unwrap(); // Same input should produce same ID assert_eq!(hash1, hash2, "Same epoch input should produce deterministic ID"); } #[tokio::test] async fn test_create_epoch_defaults_timestamp() { let env = create_test_env().await; let app = create_router(env.state); // Omit start_timestamp - should default to current time let epoch = json!({ "name": "Default-Timestamp-Test" }); let request = Request::builder() .uri("/v1/epoch") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&epoch).unwrap())) .unwrap(); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::CREATED); // If we got 201, the timestamp defaulted successfully let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["status"], "created"); } // ============================================================================ // Full Pipeline Integration Tests (POST → Ingest → GET roundtrip) // ============================================================================ /// Create a properly signed assertion for testing. fn create_signed_assertion_json(subject: &str, predicate: &str, value: f64) -> serde_json::Value { let mut csprng = OsRng; let signing_key = SigningKey::generate(&mut csprng); let verifying_key = signing_key.verifying_key(); let message = format!("{}:{}", subject, predicate); let signature = signing_key.sign(message.as_bytes()); let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); json!({ "subject": subject, "predicate": predicate, "object": {"type": "Number", "value": value}, "confidence": 0.95, "source_class": "Expert", "lifecycle": "Proposed", "signatures": [{ "agent_id": hex::encode(verifying_key.to_bytes()), "signature": hex::encode(signature.to_bytes()), "timestamp": timestamp }], "source_hash": "0".repeat(64), "timestamp": timestamp }) } /// Test full assertion roundtrip: POST → Ingest → GET /// /// This test validates the complete pipeline: /// 1. POST an assertion to /v1/assert (writes to WAL) /// 2. Run the ingestor to process the WAL entry /// 3. GET /v1/query to verify the assertion is queryable #[tokio::test] async fn test_assertion_roundtrip_with_ingestor() { let env = create_test_env_with_ingestor().await; let app = create_router(env.state.clone()); let subject = "RoundtripTest_Entity"; let predicate = "test_property"; let value = 42.0; // 1. POST the assertion let assertion = create_signed_assertion_json(subject, predicate, value); let request = Request::builder() .uri("/v1/assert") .method("POST") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&assertion).unwrap())) .unwrap(); let response = app.clone().oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::CREATED, "POST should succeed with 201"); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let post_result: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(post_result["status"], "created"); let assertion_hash = post_result["hash"].as_str().expect("hash should be present"); assert_eq!(assertion_hash.len(), 64, "hash should be 64 hex chars"); // 2. Run the ingestor to process the pending record (synchronous for testing) let bytes_processed = env.ingestor.process_pending().await.expect("ingestor should process"); assert!(bytes_processed > 0, "Should have processed WAL bytes"); // 3. GET the assertion via query let request = Request::builder() .uri(format!("/v1/query?subject={}&predicate={}", subject, predicate)) .method("GET") .body(Body::empty()) .unwrap(); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let query_result: serde_json::Value = serde_json::from_slice(&body).unwrap(); // Verify the assertion is returned correctly assert_eq!(query_result["total_count"], 1, "Should find exactly 1 assertion"); let assertions = query_result["assertions"].as_array().expect("assertions array"); assert_eq!(assertions.len(), 1); let found = &assertions[0]; assert_eq!(found["subject"], subject); assert_eq!(found["predicate"], predicate); assert_eq!(found["object"]["type"], "Number"); assert!((found["object"]["value"].as_f64().unwrap() - value).abs() < 0.001); assert_eq!(found["confidence"], 0.95); }