Files

133 lines
4.9 KiB
Rust
Raw Permalink Normal View History

2026-07-09 13:28:28 -04:00
//! End-to-end integration tests for aether-ask.
//!
//! Spawns the actual HTTP server on a real port and verifies the
//! /api/stats, /q/<question>, 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
}