async fn test_app_with_bus() -> (Router, EventBus<AgentEvent>, lopi_memory::MemoryStore) {
let store = lopi_memory::MemoryStore::open_in_memory().await.unwrap();
let bus: EventBus<AgentEvent> = EventBus::new(64);
let queue = TaskQueue::new();
let pool = Arc::new(AgentPool::new(
1,
PathBuf::from("."),
queue.clone(),
bus.clone(),
));
let state = AppState::new(store.clone(), bus.clone(), queue, pool, None);
(build_app(state), bus, store)
}
async fn collect_sse_lines(body: axum::body::Body, want: usize) -> Vec<String> {
use futures::StreamExt as _;
let mut stream = body.into_data_stream();
let mut lines = Vec::new();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
while lines.len() < want {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
assert!(
!remaining.is_zero(),
"timed out waiting for {want} SSE lines, only got {}: {lines:?}",
lines.len()
);
let chunk = tokio::time::timeout(remaining, stream.next())
.await
.expect("timed out reading the next SSE chunk")
.expect("SSE body stream ended before enough lines arrived")
.expect("SSE body stream yielded an error frame");
for line in String::from_utf8_lossy(&chunk).lines() {
if let Some(data) = line.strip_prefix("data: ") {
lines.push(data.to_string());
}
}
}
lines
}
#[tokio::test]
async fn task_stream_isolates_concurrent_tasks_with_zero_cross_talk() {
let (app, bus, store) = test_app_with_bus().await;
let saved_a = Task::new("stack card a");
let saved_b = Task::new("stack card b");
store.save_task(&saved_a, "running").await.unwrap();
store.save_task(&saved_b, "running").await.unwrap();
let task_a = saved_a.id;
let task_b = saved_b.id;
let resp_a = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/tasks/{}/stream", task_a.0))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let resp_b = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/tasks/{}/stream", task_b.0))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp_a.status(), StatusCode::OK);
assert_eq!(resp_b.status(), StatusCode::OK);
let body_a = resp_a.into_body();
let body_b = resp_b.into_body();
const N: usize = 10;
for i in 0..N {
bus.send(AgentEvent::info(task_a, format!("line-a-{i}")));
bus.send(AgentEvent::info(task_b, format!("line-b-{i}")));
}
let (lines_a, lines_b) = tokio::join!(
collect_sse_lines(body_a, N),
collect_sse_lines(body_b, N)
);
let cross_talk_a = lines_a.iter().filter(|l| l.contains("line-b-")).count();
let cross_talk_b = lines_b.iter().filter(|l| l.contains("line-a-")).count();
assert_eq!(
cross_talk_a, 0,
"task A's stream must never surface task B's events"
);
assert_eq!(
cross_talk_b, 0,
"task B's stream must never surface task A's events"
);
assert!(
lines_a.iter().all(|l| l.contains("line-a-")),
"every line on A's stream is actually A's: {lines_a:?}"
);
assert!(
lines_b.iter().all(|l| l.contains("line-b-")),
"every line on B's stream is actually B's: {lines_b:?}"
);
}
#[tokio::test]
async fn f8_id_scoped_reads_status_codes() {
let (app, store) = test_app_with_store().await;
let task = Task::new("known but logless task");
let known = task.id.0.to_string();
store.save_task(&task, "success").await.unwrap();
let bogus = "00000000-0000-0000-0000-000000000000";
let cases: Vec<(String, StatusCode, &str)> = vec![
(
format!("/api/tasks/{known}/logs"),
StatusCode::OK,
"known task, no logs yet -> valid empty 200",
),
(
format!("/api/agents/{known}/dag"),
StatusCode::OK,
"known task, no DAG yet -> valid empty 200",
),
(
format!("/api/tasks/{known}/stream"),
StatusCode::OK,
"known task -> live SSE 200",
),
(
format!("/api/tasks/{bogus}/logs"),
StatusCode::NOT_FOUND,
"unknown id -> 404 (was 200)",
),
(
format!("/api/agents/{bogus}/dag"),
StatusCode::NOT_FOUND,
"unknown id -> 404 (was 200)",
),
(
format!("/api/tasks/{bogus}/stream"),
StatusCode::NOT_FOUND,
"unknown well-formed id -> 404 (was 200)",
),
(
"/api/tasks/not-a-uuid/stream".to_string(),
StatusCode::BAD_REQUEST,
"malformed id -> 400 (distinct from a well-formed-but-unknown id)",
),
];
for (uri, want, why) in cases {
let resp = get_req(app.clone(), &uri).await;
assert_eq!(resp.status(), want, "{why}: GET {uri}");
}
}