This commit adds the read path (Cortex) to complement the write path (Spine): ## Crates - stemedb-api: HTTP API with axum + utoipa OpenAPI - /v1/assert, /v1/query, /v1/epoch, /v1/skeptic, /v1/trace, /v1/audit - Metered endpoints with quota enforcement - Ed25519 signature verification - stemedb-lens: Truth resolution lenses - RecencyLens, ConsensusLens, ConfidenceLens - VoteAwareConsensusLens (Ballot Box pattern) - TrustAwareAuthorityLens (The Hive pattern) - SkepticLens (conflict analysis) - EpochAwareLens (paradigm-safe queries) - stemedb-query: Query engine with materialized views ## Storage Extensions - VoteStore: Vote aggregation with cached counts - TrustRankStore: Agent reputation with decay - AuditStore: Query audit trail - IndexStore: SP/P/S index structures - SupersessionStore: Epoch supersession chains ## SDKs - sdk/go/steme: Go HTTP client with Ed25519 signing - sdk/go/adk: ADK-Go tools for AI agents ## Documentation - Updated CLAUDE.md, architecture.md, roadmap.md - New ai-lookup entries for all services - Use case docs for consumer health intelligence - Arena roadmap for simulation advancement Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
692 lines
24 KiB
Rust
692 lines
24 KiB
Rust
//! 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<SledStore>,
|
|
}
|
|
|
|
/// 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);
|
|
}
|