//! End-to-end integration tests for aether-ask. //! //! Spawns the actual HTTP server on a real port and verifies the //! /api/stats, /q/, and POST /ask flows. use serde_json::json; use std::io::{Read, Write}; use std::net::TcpStream; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; fn binary_path() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_aether-ask")) } struct Ask { child: Child, port: u16, } impl Ask { fn spawn(data_dir: &PathBuf) -> Self { let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); let port = 31000 + ((stamp % 1000) as u16); let mut cmd = Command::new(binary_path()); cmd.env("AETHER_RECALL_DIR", data_dir) .env("AETHER_ASK_PORT", port.to_string()) .env("OLLAMA_HOST", "127.0.0.1") .env("OLLAMA_PORT", "1") .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); let mut child = cmd.spawn().expect("spawn aether-ask"); // Wait for it to bind let addr = format!("127.0.0.1:{}", port); for _ in 0..50 { if TcpStream::connect_timeout(&addr.parse().unwrap(), Duration::from_millis(50)).is_ok() { return Self { child, port }; } std::thread::sleep(Duration::from_millis(50)); } let _ = child.kill(); let _ = child.wait(); panic!("aether-ask did not bind to {}", addr); } fn http(&self, req: &str) -> String { let mut s = TcpStream::connect(("127.0.0.1", self.port)).unwrap(); s.set_read_timeout(Some(Duration::from_secs(3))).ok(); s.write_all(req.as_bytes()).unwrap(); let mut buf = Vec::new(); s.read_to_end(&mut buf).ok(); String::from_utf8_lossy(&buf).to_string() } } impl Drop for Ask { fn drop(&mut self) { let _ = self.child.kill(); } } #[test] fn stats_endpoint_returns_json() { let dir = tempdir_in("/tmp"); let ask = Ask::spawn(&dir); let r = ask.http("GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n"); assert!(r.contains("\"records\":"), "got: {r}"); assert!(r.contains("\"hit_rate\":")); } #[test] fn get_root_serves_html() { let dir = tempdir_in("/tmp"); let ask = Ask::spawn(&dir); let r = ask.http("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); assert!(r.starts_with("HTTP/1.1 200")); assert!(r.contains("AetherAsk")); } #[test] fn post_ask_returns_json() { let dir = tempdir_in("/tmp"); let ask = Ask::spawn(&dir); let body = json!({"question":"hello-world-test-q"}).to_string(); let r = ask.http(&format!( "POST /ask HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", body.len(), body )); assert!(r.starts_with("HTTP/1.1 200")); let body = r.split("\r\n\r\n").nth(1).unwrap_or(""); let json: serde_json::Value = serde_json::from_str(body).expect("json body"); // No ollama: should report the source as neural with the error message // or, if cached from a prior ask, maybe deterministic. ollama is // unreachable so we expect the [error] fallback body path. let answer = json["answer"].as_str().unwrap(); // Either successfully recorded with deterministic fallback, or stored // the ollama error. We don't enforce the exact text, only that answer field exists. assert!(!answer.is_empty()); // The next call must hit the cache (deterministic) let body2 = json!({"question":"hello-world-test-q"}).to_string(); let r2 = ask.http(&format!( "POST /ask HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", body2.len(), body2 )); let body2 = r2.split("\r\n\r\n").nth(1).unwrap_or(""); let json2: serde_json::Value = serde_json::from_str(body2).unwrap(); assert_eq!(json2["source"], "deterministic", "second identical ask must be a cache hit, got: {json2:?}"); assert_eq!(json2["answer"], json["answer"]); } #[test] fn get_q_route_responds_with_deterministic_after_seeding() { let dir = tempdir_in("/tmp"); let ask = Ask::spawn(&dir); // First, record a value directly via /api/... no — we have only /q/ and /ask. // So we issue the same query twice. First ollama fails -> [error …]; second is cache hit. let q = "second-call-determ-test"; let _ = ask.http(&format!("GET /q/{q} HTTP/1.1\r\nHost: localhost\r\n\r\n")); let r2 = ask.http(&format!("GET /q/{q} HTTP/1.1\r\nHost: localhost\r\n\r\n")); assert!(r2.contains("[deterministic]"), "got: {r2}"); } fn tempdir_in(parent: &str) -> PathBuf { let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); let p = PathBuf::from(parent).join(format!("aether-ask-it-{}", stamp)); std::fs::create_dir_all(&p).unwrap(); p }