test: production ingest E2E test suite with enhanced logging #55
@@ -34,6 +34,36 @@ impl std::fmt::Display for JobStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Mock JobStatusStore for testing
|
||||||
|
pub struct MockJobStatusStore {
|
||||||
|
updates: std::sync::Arc<std::sync::Mutex<Vec<(String, JobStatus)>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockJobStatusStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
updates: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn updates(&self) -> Vec<(String, JobStatus)> {
|
||||||
|
self.updates.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl JobStatusStore for MockJobStatusStore {
|
||||||
|
async fn update_status(&self, ingest_id: &str, status: JobStatus) -> Result<()> {
|
||||||
|
self.updates.lock().unwrap().push((ingest_id.to_string(), status));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Structured logging context for ingest operations — ensures consistent field names across all logs
|
/// Structured logging context for ingest operations — ensures consistent field names across all logs
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct IngestLogContext {
|
pub struct IngestLogContext {
|
||||||
@@ -54,6 +84,36 @@ impl IngestLogContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Job status store trait — abstracts database persistence of job status (enables mocking)
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait JobStatusStore: Send + Sync {
|
||||||
|
/// Update job status in storage
|
||||||
|
async fn update_status(&self, ingest_id: &str, status: JobStatus) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PostgreSQL implementation of JobStatusStore
|
||||||
|
pub struct PgJobStatusStore {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PgJobStatusStore {
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl JobStatusStore for PgJobStatusStore {
|
||||||
|
async fn update_status(&self, ingest_id: &str, status: JobStatus) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE ingest_jobs SET status=$1, started_at=NOW() WHERE ingest_id=$2")
|
||||||
|
.bind(status.as_str())
|
||||||
|
.bind(ingest_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Ingest worker — processes queued records through entity/fact extraction pipeline
|
/// Ingest worker — processes queued records through entity/fact extraction pipeline
|
||||||
pub struct IngestWorker {
|
pub struct IngestWorker {
|
||||||
@@ -61,6 +121,7 @@ pub struct IngestWorker {
|
|||||||
vector_store: Arc<VectorStore>,
|
vector_store: Arc<VectorStore>,
|
||||||
embeddings: Arc<EmbeddingsClient>,
|
embeddings: Arc<EmbeddingsClient>,
|
||||||
pipeline: Arc<IngestPipeline>,
|
pipeline: Arc<IngestPipeline>,
|
||||||
|
job_status_store: Arc<dyn JobStatusStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IngestWorker {
|
impl IngestWorker {
|
||||||
@@ -68,6 +129,16 @@ impl IngestWorker {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
embeddings: EmbeddingsClient,
|
embeddings: EmbeddingsClient,
|
||||||
|
) -> Self {
|
||||||
|
let job_status_store = Arc::new(PgJobStatusStore::new(pool.clone()));
|
||||||
|
Self::with_job_store(pool, embeddings, job_status_store)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create worker with custom job status store (for testing)
|
||||||
|
pub fn with_job_store(
|
||||||
|
pool: PgPool,
|
||||||
|
embeddings: EmbeddingsClient,
|
||||||
|
job_status_store: Arc<dyn JobStatusStore>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let vector_store = Arc::new(VectorStore::new(pool.clone()));
|
let vector_store = Arc::new(VectorStore::new(pool.clone()));
|
||||||
|
|
||||||
@@ -102,6 +173,7 @@ impl IngestWorker {
|
|||||||
vector_store,
|
vector_store,
|
||||||
embeddings: Arc::new(embeddings),
|
embeddings: Arc::new(embeddings),
|
||||||
pipeline,
|
pipeline,
|
||||||
|
job_status_store,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,13 +200,8 @@ impl IngestWorker {
|
|||||||
"Starting ingest job"
|
"Starting ingest job"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update job status to processing
|
// Update job status to processing (via trait, testable)
|
||||||
if let Err(e) = sqlx::query("UPDATE ingest_jobs SET status=$1, started_at=NOW() WHERE ingest_id=$2")
|
if let Err(e) = self.job_status_store.update_status(ingest_id, JobStatus::Processing).await {
|
||||||
.bind(JobStatus::Processing.as_str())
|
|
||||||
.bind(ingest_id)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
target: "ingest",
|
target: "ingest",
|
||||||
error = %e,
|
error = %e,
|
||||||
@@ -213,14 +280,9 @@ impl IngestWorker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark job complete
|
// Mark job complete (via trait, testable)
|
||||||
let final_status = JobStatus::Done;
|
let final_status = JobStatus::Done;
|
||||||
if let Err(e) = sqlx::query("UPDATE ingest_jobs SET status=$1, completed_at=NOW() WHERE ingest_id=$2")
|
if let Err(e) = self.job_status_store.update_status(ingest_id, final_status).await {
|
||||||
.bind(final_status.as_str())
|
|
||||||
.bind(ingest_id)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
target: "ingest",
|
target: "ingest",
|
||||||
error = %e,
|
error = %e,
|
||||||
@@ -316,6 +378,7 @@ async fn save_entity_with_logging(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Save entity to database via raw SQL (normally would use EntityRepo trait)
|
/// Save entity to database via raw SQL (normally would use EntityRepo trait)
|
||||||
|
/// NOTE: async_trait requires manual implementation for non-trait functions
|
||||||
async fn save_entity_to_db(pool: &PgPool, entity: &mem_core::entity::Entity) -> Result<()> {
|
async fn save_entity_to_db(pool: &PgPool, entity: &mem_core::entity::Entity) -> Result<()> {
|
||||||
// Convert OffsetDateTime to PostgreSQL timestamp format
|
// Convert OffsetDateTime to PostgreSQL timestamp format
|
||||||
let t_created_str = entity.t_created.to_string();
|
let t_created_str = entity.t_created.to_string();
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
//! Integration test: Full ingest + embedding flow with api-gw
|
||||||
|
//!
|
||||||
|
//! Tests:
|
||||||
|
//! 1. POST /memory/ingest with sample records
|
||||||
|
//! 2. Poll /memory/ingest/{id} until done
|
||||||
|
//! 3. Log root causes of errors
|
||||||
|
//!
|
||||||
|
//! Features:
|
||||||
|
//! - RAII guard for port-forward cleanup (fix for resource leak)
|
||||||
|
//! - Enhanced error handling with resource cleanup
|
||||||
|
//!
|
||||||
|
//! Requires:
|
||||||
|
//! - DATABASE_URL set (postgres)
|
||||||
|
//! - LLM_ENDPOINT set (for embeddings)
|
||||||
|
//! - Server running locally or started by test
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! ```
|
||||||
|
//! RUST_LOG=debug cargo test --test integration_ingest_with_gw -- --nocapture
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::process::Child;
|
||||||
|
|
||||||
|
/// RAII guard for port-forward cleanup — ensures process is killed even if test panics
|
||||||
|
struct PortForwardGuard {
|
||||||
|
process: Option<Child>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PortForwardGuard {
|
||||||
|
fn spawn(namespace: &str, service: &str, local_port: u16, remote_port: u16) -> std::io::Result<Self> {
|
||||||
|
let process = std::process::Command::new("kubectl")
|
||||||
|
.args(&["-n", namespace, "port-forward", &format!("svc/{}", service), &format!("{}:{}", local_port, remote_port)])
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
Ok(PortForwardGuard {
|
||||||
|
process: Some(process),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PortForwardGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(mut process) = self.process.take() {
|
||||||
|
let _ = process.kill();
|
||||||
|
let _ = process.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore] // Run manually: cargo test --test integration_ingest_with_gw -- --ignored --nocapture
|
||||||
|
async fn test_ingest_with_embeddings_and_logging() {
|
||||||
|
// Initialize tracing with DEBUG level to see all logs
|
||||||
|
let _ = tracing_subscriber::fmt()
|
||||||
|
.with_max_level(tracing::Level::DEBUG)
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.try_init();
|
||||||
|
|
||||||
|
let base_url = env::var("MEM_API_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||||
|
let api_key = env::var("MEM_API_KEY").unwrap_or_else(|_| "test-key".to_string());
|
||||||
|
let local_port: u16 = 9990;
|
||||||
|
const NAMESPACE: &str = "poimen";
|
||||||
|
const SERVICE: &str = "poimen-memory";
|
||||||
|
|
||||||
|
// Spawn port-forward with RAII guard — guaranteed cleanup
|
||||||
|
let _pf_guard = match PortForwardGuard::spawn(NAMESPACE, SERVICE, local_port, 8080) {
|
||||||
|
Ok(guard) => {
|
||||||
|
sleep(Duration::from_secs(2)).await;
|
||||||
|
println!("[TEST] ✓ Port-forward started");
|
||||||
|
guard
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[ERROR] Failed to spawn port-forward: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
// Sample ingest payload
|
||||||
|
let payload = json!({
|
||||||
|
"project": "test-project",
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"content": "Kubernetes is an open-source container orchestration platform. [[Docker]] [[Go]]",
|
||||||
|
"source": "wiki/kubernetes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "Docker is a containerization platform that makes it easier to build, ship, and run applications. [[Linux]] [[Container]]",
|
||||||
|
"source": "wiki/docker"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "Go is a programming language designed at Google. [[Concurrency]] [[Static Typing]]",
|
||||||
|
"source": "wiki/go"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
println!("[TEST] Sending ingest request...");
|
||||||
|
tracing::info!(
|
||||||
|
target: "integration_test",
|
||||||
|
"Ingest payload: {}",
|
||||||
|
serde_json::to_string_pretty(&payload).unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /memory/ingest
|
||||||
|
let response = match client
|
||||||
|
.post(&format!("{}/memory/ingest", base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", api_key))
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) => resp,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[ERROR] Failed to send ingest request: {}", e);
|
||||||
|
tracing::error!(
|
||||||
|
target: "integration_test",
|
||||||
|
error = %e,
|
||||||
|
"Failed to POST /memory/ingest"
|
||||||
|
);
|
||||||
|
panic!("Request failed: {}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
println!("[TEST] Ingest response status: {}", status);
|
||||||
|
|
||||||
|
let body_text = match response.text().await {
|
||||||
|
Ok(text) => text,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(target: "integration_test", error = %e, "Failed to read response body");
|
||||||
|
panic!("Failed to read response body: {}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("[TEST] Response body:\n{}", body_text);
|
||||||
|
|
||||||
|
// Parse response
|
||||||
|
let resp_json: serde_json::Value = match serde_json::from_str(&body_text) {
|
||||||
|
Ok(j) => j,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
target: "integration_test",
|
||||||
|
error = %e,
|
||||||
|
body = %body_text,
|
||||||
|
"Failed to parse JSON response"
|
||||||
|
);
|
||||||
|
panic!("Failed to parse JSON: {}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ingest_id = match resp_json["id"].as_str() {
|
||||||
|
Some(id) => id.to_string(),
|
||||||
|
None => {
|
||||||
|
tracing::error!(
|
||||||
|
target: "integration_test",
|
||||||
|
response = %serde_json::to_string_pretty(&resp_json).unwrap(),
|
||||||
|
"Missing 'id' in response"
|
||||||
|
);
|
||||||
|
panic!("Missing 'id' in response: {}", resp_json);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("[TEST] Ingest ID: {}", ingest_id);
|
||||||
|
tracing::info!(target: "integration_test", ingest_id = %ingest_id, "Ingest queued");
|
||||||
|
|
||||||
|
// Poll until complete or timeout
|
||||||
|
let max_polls = 60; // 10 minutes with 10s intervals
|
||||||
|
for poll_num in 1..=max_polls {
|
||||||
|
sleep(Duration::from_secs(10)).await;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"[TEST] Poll #{}/{}: Checking status of ingest {}",
|
||||||
|
poll_num, max_polls, ingest_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let status_response = match client
|
||||||
|
.get(&format!("{}/memory/ingest/{}", base_url, ingest_id))
|
||||||
|
.header("Authorization", format!("Bearer {}", api_key))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) => resp,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
target: "integration_test",
|
||||||
|
error = %e,
|
||||||
|
ingest_id = %ingest_id,
|
||||||
|
poll = poll_num,
|
||||||
|
"Failed to fetch status"
|
||||||
|
);
|
||||||
|
eprintln!("[ERROR] Failed to fetch status: {}", e);
|
||||||
|
sleep(Duration::from_secs(5)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_text = match status_response.text().await {
|
||||||
|
Ok(text) => text,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
target: "integration_test",
|
||||||
|
error = %e,
|
||||||
|
ingest_id = %ingest_id,
|
||||||
|
"Failed to read status response"
|
||||||
|
);
|
||||||
|
eprintln!("[ERROR] Failed to read status: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_json: serde_json::Value = match serde_json::from_str(&status_text) {
|
||||||
|
Ok(j) => j,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
target: "integration_test",
|
||||||
|
error = %e,
|
||||||
|
body = %status_text,
|
||||||
|
"Failed to parse status JSON"
|
||||||
|
);
|
||||||
|
eprintln!("[ERROR] Failed to parse status JSON: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = status_json["status"].as_str().unwrap_or("unknown");
|
||||||
|
println!(
|
||||||
|
"[TEST] Poll #{}: status = {}",
|
||||||
|
poll_num, status
|
||||||
|
);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
target: "integration_test",
|
||||||
|
ingest_id = %ingest_id,
|
||||||
|
poll = poll_num,
|
||||||
|
status = %status,
|
||||||
|
full_response = %serde_json::to_string_pretty(&status_json).unwrap(),
|
||||||
|
"Status check"
|
||||||
|
);
|
||||||
|
|
||||||
|
match status {
|
||||||
|
"done" => {
|
||||||
|
println!("[TEST] ✓ Ingest completed successfully!");
|
||||||
|
tracing::info!(target: "integration_test", "Ingest completed");
|
||||||
|
|
||||||
|
// Extract and log results
|
||||||
|
if let Some(results) = status_json.get("results") {
|
||||||
|
println!("[TEST] Results:\n{}", serde_json::to_string_pretty(results).unwrap());
|
||||||
|
tracing::info!(
|
||||||
|
target: "integration_test",
|
||||||
|
results = %serde_json::to_string_pretty(results).unwrap(),
|
||||||
|
"Ingest results"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
"failed" | "error" => {
|
||||||
|
let error_msg = status_json["error"].as_str().unwrap_or("unknown error");
|
||||||
|
println!("[TEST] ✗ Ingest FAILED: {}", error_msg);
|
||||||
|
tracing::error!(
|
||||||
|
target: "integration_test",
|
||||||
|
ingest_id = %ingest_id,
|
||||||
|
error = %error_msg,
|
||||||
|
full_response = %serde_json::to_string_pretty(&status_json).unwrap(),
|
||||||
|
"Ingest failed"
|
||||||
|
);
|
||||||
|
panic!("Ingest failed: {}", error_msg);
|
||||||
|
}
|
||||||
|
"processing" | "queued" => {
|
||||||
|
// Continue polling
|
||||||
|
println!("[TEST] Still processing, poll again...");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
println!("[TEST] Unknown status: {}", status);
|
||||||
|
tracing::warn!(target: "integration_test", status = %status, "Unknown status");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout — _pf_guard will be dropped here, cleaning up port-forward
|
||||||
|
let msg = format!("Ingest did not complete after {} polls (timeout)", max_polls);
|
||||||
|
println!("[TEST] ✗ {}", msg);
|
||||||
|
tracing::error!(target: "integration_test", ingest_id = %ingest_id, "Ingest timeout");
|
||||||
|
panic!("{}", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn test_ingest_endpoint_only() {
|
||||||
|
let _ = tracing_subscriber::fmt()
|
||||||
|
.with_max_level(tracing::Level::DEBUG)
|
||||||
|
.try_init();
|
||||||
|
|
||||||
|
let base_url = env::var("MEM_API_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||||
|
let api_key = env::var("MEM_API_KEY").unwrap_or_else(|_| "test-key".to_string());
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"project": "test-project",
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"content": "Simple test record",
|
||||||
|
"source": "test"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
println!("[TEST] Testing /memory/ingest endpoint only");
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(&format!("{}/memory/ingest", base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", api_key))
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to send request");
|
||||||
|
|
||||||
|
println!("[TEST] Status: {}", response.status());
|
||||||
|
|
||||||
|
let body = response.text().await.expect("Failed to read body");
|
||||||
|
println!("[TEST] Response: {}", body);
|
||||||
|
|
||||||
|
let json: serde_json::Value = serde_json::from_str(&body).expect("Invalid JSON");
|
||||||
|
println!("[TEST] Parsed: {}", serde_json::to_string_pretty(&json).unwrap());
|
||||||
|
|
||||||
|
assert!(json.get("id").is_some(), "Response should contain 'id'");
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
//! Unit test: Ingest pipeline with detailed error logging and enhanced assertions
|
||||||
|
//!
|
||||||
|
//! Tests extraction pipeline in isolation without requiring HTTP server or embeddings.
|
||||||
|
//! Useful for debugging extraction errors.
|
||||||
|
//!
|
||||||
|
//! Features:
|
||||||
|
//! - Verify extracted entity names (not just count)
|
||||||
|
//! - Verify edge connections between entities
|
||||||
|
//! - Detailed error logging
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! ```
|
||||||
|
//! RUST_LOG=debug,mem_ingest=debug cargo test --test unit_ingest_logging -- --nocapture
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use mem_ingest::ingest_pipeline::{IngestPipeline, Episode};
|
||||||
|
use mem_ingest::entity_extractor::WikiLinkFallbackExtractor;
|
||||||
|
use mem_ingest::fact_extractor::SimpleFactExtractor;
|
||||||
|
use mem_ingest::contradiction_detector::ContradictionHandler;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
fn init_logging() {
|
||||||
|
let _ = tracing_subscriber::fmt()
|
||||||
|
.with_max_level(tracing::Level::DEBUG)
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.try_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_wiki_link_extraction_with_entity_verification() {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
println!("\n[TEST] Wiki link extraction with entity name verification\n");
|
||||||
|
|
||||||
|
let entity_extractor = Arc::new(WikiLinkFallbackExtractor);
|
||||||
|
let fact_extractor = Arc::new(SimpleFactExtractor);
|
||||||
|
let contradiction_detector = Arc::new(ContradictionHandler::default());
|
||||||
|
|
||||||
|
let pipeline = IngestPipeline::new(
|
||||||
|
entity_extractor,
|
||||||
|
fact_extractor,
|
||||||
|
contradiction_detector,
|
||||||
|
);
|
||||||
|
|
||||||
|
let episode = Episode {
|
||||||
|
id: "test-1".to_string(),
|
||||||
|
project_id: "test-project".to_string(),
|
||||||
|
text: "Kubernetes [[Docker]] is a [[Container]] orchestration platform. It works with [[Go]] programs."
|
||||||
|
.to_string(),
|
||||||
|
wiki_links: vec!["Docker".to_string(), "Container".to_string(), "Go".to_string()],
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
target: "test",
|
||||||
|
episode_id = %episode.id,
|
||||||
|
wiki_links = ?episode.wiki_links,
|
||||||
|
"Starting pipeline ingest"
|
||||||
|
);
|
||||||
|
|
||||||
|
match pipeline.ingest(&episode).await {
|
||||||
|
Ok(result) => {
|
||||||
|
tracing::info!(
|
||||||
|
target: "test",
|
||||||
|
entities = result.entities.len(),
|
||||||
|
edges = result.edges.len(),
|
||||||
|
reviews = result.reviews.len(),
|
||||||
|
"Pipeline succeeded"
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("✓ Extracted {} entities", result.entities.len());
|
||||||
|
for entity in &result.entities {
|
||||||
|
println!(" - {} ({}): {}", entity.name, entity.entity_type.as_str(), entity.summary.as_deref().unwrap_or(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("✓ Extracted {} edges", result.edges.len());
|
||||||
|
for edge in &result.edges {
|
||||||
|
println!(" - {} --[{}]--> {}", edge.source_entity_id, edge.relation_type, edge.target_entity_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ENHANCED: Verify extracted entity names (not just count)
|
||||||
|
assert!(!result.entities.is_empty(), "Should extract at least one entity");
|
||||||
|
|
||||||
|
let entity_names: Vec<&str> = result.entities.iter().map(|e| e.name.as_str()).collect();
|
||||||
|
println!("\nEntity names extracted: {:?}", entity_names);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
entity_names.iter().any(|&name| name.contains("Docker") || name.contains("docker")),
|
||||||
|
"Should extract Docker entity"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
entity_names.iter().any(|&name| name.contains("Container") || name.contains("container")),
|
||||||
|
"Should extract Container entity"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
entity_names.iter().any(|&name| name.contains("Go") || name.contains("go")),
|
||||||
|
"Should extract Go entity"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ENHANCED: Verify edges connect correct entity pairs
|
||||||
|
if !result.edges.is_empty() {
|
||||||
|
println!("\nEdge connections:");
|
||||||
|
for edge in &result.edges {
|
||||||
|
println!(" {} → {}", edge.source_entity_id, edge.target_entity_id);
|
||||||
|
|
||||||
|
// Verify both endpoints exist in entities
|
||||||
|
let source_exists = result.entities.iter().any(|e| e.id == edge.source_entity_id);
|
||||||
|
let target_exists = result.entities.iter().any(|e| e.id == edge.target_entity_id);
|
||||||
|
|
||||||
|
assert!(source_exists, "Edge source entity {} must exist in extracted entities", edge.source_entity_id);
|
||||||
|
assert!(target_exists, "Edge target entity {} must exist in extracted entities", edge.target_entity_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
target: "test",
|
||||||
|
error = %e,
|
||||||
|
"Pipeline failed"
|
||||||
|
);
|
||||||
|
panic!("Pipeline failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_extraction_error_logging() {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
println!("\n[TEST] Pipeline error handling with logging\n");
|
||||||
|
|
||||||
|
let entity_extractor = Arc::new(WikiLinkFallbackExtractor);
|
||||||
|
let fact_extractor = Arc::new(SimpleFactExtractor);
|
||||||
|
let contradiction_detector = Arc::new(ContradictionHandler::default());
|
||||||
|
|
||||||
|
let pipeline = IngestPipeline::new(
|
||||||
|
entity_extractor,
|
||||||
|
fact_extractor,
|
||||||
|
contradiction_detector,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Episode with problematic content (empty, or only whitespace)
|
||||||
|
let episode = Episode {
|
||||||
|
id: "test-empty".to_string(),
|
||||||
|
project_id: "test-project".to_string(),
|
||||||
|
text: "".to_string(),
|
||||||
|
wiki_links: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
target: "test",
|
||||||
|
episode_id = %episode.id,
|
||||||
|
text_len = episode.text.len(),
|
||||||
|
"Processing empty episode"
|
||||||
|
);
|
||||||
|
|
||||||
|
match pipeline.ingest(&episode).await {
|
||||||
|
Ok(result) => {
|
||||||
|
tracing::info!(
|
||||||
|
target: "test",
|
||||||
|
entities = result.entities.len(),
|
||||||
|
edges = result.edges.len(),
|
||||||
|
"Empty episode processed (no error expected)"
|
||||||
|
);
|
||||||
|
println!("✓ Empty episode handled gracefully");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
target: "test",
|
||||||
|
error = %e,
|
||||||
|
"Empty episode caused error"
|
||||||
|
);
|
||||||
|
// Empty is OK for some extractors
|
||||||
|
println!("⚠ Empty episode error (may be expected): {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multiple_records_with_entity_verification() {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
println!("\n[TEST] Processing multiple records with entity name verification\n");
|
||||||
|
|
||||||
|
let entity_extractor = Arc::new(WikiLinkFallbackExtractor);
|
||||||
|
let fact_extractor = Arc::new(SimpleFactExtractor);
|
||||||
|
let contradiction_detector = Arc::new(ContradictionHandler::default());
|
||||||
|
|
||||||
|
let pipeline = IngestPipeline::new(
|
||||||
|
entity_extractor,
|
||||||
|
fact_extractor,
|
||||||
|
contradiction_detector,
|
||||||
|
);
|
||||||
|
|
||||||
|
let records = vec![
|
||||||
|
("Kubernetes [[Docker]] is a container orchestrator", "wiki/k8s"),
|
||||||
|
("Docker [[Linux]] containers enable microservices", "wiki/docker"),
|
||||||
|
("", "wiki/empty"),
|
||||||
|
("Go [[Concurrency]] is powerful for backend services", "wiki/go"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut success_count = 0;
|
||||||
|
let mut error_count = 0;
|
||||||
|
let mut all_extracted_entities = Vec::new();
|
||||||
|
|
||||||
|
for (idx, (text, source)) in records.iter().enumerate() {
|
||||||
|
let episode = Episode {
|
||||||
|
id: format!("record-{}", idx),
|
||||||
|
project_id: "test-project".to_string(),
|
||||||
|
text: text.to_string(),
|
||||||
|
wiki_links: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
target: "test",
|
||||||
|
record_idx = idx,
|
||||||
|
source = source,
|
||||||
|
text_len = text.len(),
|
||||||
|
"Processing record"
|
||||||
|
);
|
||||||
|
|
||||||
|
match pipeline.ingest(&episode).await {
|
||||||
|
Ok(result) => {
|
||||||
|
tracing::debug!(
|
||||||
|
target: "test",
|
||||||
|
record_idx = idx,
|
||||||
|
entities = result.entities.len(),
|
||||||
|
edges = result.edges.len(),
|
||||||
|
"Record succeeded"
|
||||||
|
);
|
||||||
|
println!(" ✓ Record {}: {} entities, {} edges", idx, result.entities.len(), result.edges.len());
|
||||||
|
|
||||||
|
// Collect entity names for batch verification
|
||||||
|
for entity in &result.entities {
|
||||||
|
all_extracted_entities.push(entity.name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
success_count += 1;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "test",
|
||||||
|
record_idx = idx,
|
||||||
|
error = %e,
|
||||||
|
source = source,
|
||||||
|
"Record failed"
|
||||||
|
);
|
||||||
|
println!(" ✗ Record {}: {}", idx, e);
|
||||||
|
error_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\nSummary: {} success, {} errors", success_count, error_count);
|
||||||
|
println!("All extracted entities: {:?}", all_extracted_entities);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
target: "test",
|
||||||
|
total_records = records.len(),
|
||||||
|
success = success_count,
|
||||||
|
errors = error_count,
|
||||||
|
"Batch processing complete"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ENHANCED: Verify that expected entities were extracted across records
|
||||||
|
assert!(success_count > 0, "At least some records should succeed");
|
||||||
|
assert!(
|
||||||
|
all_extracted_entities.iter().any(|name| name.contains("Docker") || name.contains("docker")),
|
||||||
|
"Docker entity should be extracted from at least one record"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
all_extracted_entities.iter().any(|name| name.contains("Linux") || name.contains("linux")),
|
||||||
|
"Linux entity should be extracted from at least one record"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
all_extracted_entities.iter().any(|name| name.contains("Go") || name.contains("go")),
|
||||||
|
"Go entity should be extracted from at least one record"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_entity_deduplication() {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
println!("\n[TEST] Entity deduplication (same entity from multiple records)\n");
|
||||||
|
|
||||||
|
let entity_extractor = Arc::new(WikiLinkFallbackExtractor);
|
||||||
|
let fact_extractor = Arc::new(SimpleFactExtractor);
|
||||||
|
let contradiction_detector = Arc::new(ContradictionHandler::default());
|
||||||
|
|
||||||
|
let pipeline = IngestPipeline::new(
|
||||||
|
entity_extractor,
|
||||||
|
fact_extractor,
|
||||||
|
contradiction_detector,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Two records with overlapping entity references
|
||||||
|
let episode1 = Episode {
|
||||||
|
id: "record-1".to_string(),
|
||||||
|
project_id: "test-project".to_string(),
|
||||||
|
text: "Kubernetes uses [[Docker]] containers".to_string(),
|
||||||
|
wiki_links: vec!["Docker".to_string()],
|
||||||
|
};
|
||||||
|
|
||||||
|
let episode2 = Episode {
|
||||||
|
id: "record-2".to_string(),
|
||||||
|
project_id: "test-project".to_string(),
|
||||||
|
text: "Docker is used by [[Kubernetes]]".to_string(),
|
||||||
|
wiki_links: vec!["Kubernetes".to_string()],
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut all_entities = Vec::new();
|
||||||
|
|
||||||
|
for episode in &[episode1, episode2] {
|
||||||
|
match pipeline.ingest(episode).await {
|
||||||
|
Ok(result) => {
|
||||||
|
all_entities.extend(result.entities);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(target: "test", error = %e, "Failed to ingest");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Total entities extracted: {}", all_entities.len());
|
||||||
|
for entity in &all_entities {
|
||||||
|
println!(" - {}", entity.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify both Docker and Kubernetes were extracted
|
||||||
|
assert!(
|
||||||
|
all_entities.iter().any(|e| e.name.contains("Docker") || e.name.contains("docker")),
|
||||||
|
"Docker should be extracted"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
all_entities.iter().any(|e| e.name.contains("Kubernetes") || e.name.contains("kubernetes")),
|
||||||
|
"Kubernetes should be extracted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user