use super::super::router;
use super::test_state;
use crate::{ActivitySource, DaemonEvent};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{json, Value};
use tower::util::ServiceExt;
#[tokio::test]
async fn serves_index_html_fallback() {
let state = test_state();
let app = router().with_state(state);
let resp = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert!(
resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND,
"got {}",
resp.status()
);
}
#[tokio::test]
async fn activity_endpoint_lists_recent_emits() {
let state = test_state();
state.emit(DaemonEvent::PalaceCreated {
id: "alpha".into(),
name: "alpha".into(),
source: ActivitySource::Http,
});
state.emit(DaemonEvent::DrawerAdded {
palace_id: "alpha".into(),
palace_name: "alpha".into(),
drawer_count: 1,
timestamp: chrono::Utc::now(),
content_preview: "hello".into(),
source: ActivitySource::Mcp,
});
state.emit(DaemonEvent::DrawerAdded {
palace_id: "beta".into(),
palace_name: "beta".into(),
drawer_count: 1,
timestamp: chrono::Utc::now(),
content_preview: "hi there".into(),
source: ActivitySource::Http,
});
state.emit(DaemonEvent::DrawerDeleted {
palace_id: "alpha".into(),
drawer_count: 0,
source: ActivitySource::Http,
});
state.flush_activity_writes().await;
let app = router().with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/activity?limit=10")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["limit"], 10);
assert_eq!(v["offset"], 0);
assert_eq!(v["total"], 4);
let entries = v["entries"].as_array().expect("entries array");
assert_eq!(entries.len(), 4);
assert_eq!(entries[0]["event_type"], "drawer_deleted");
assert_eq!(entries[3]["event_type"], "palace_created");
let sources: Vec<&str> = entries
.iter()
.filter_map(|e| e["source"].as_str())
.collect();
assert!(sources.contains(&"http"));
assert!(sources.contains(&"mcp"));
assert!(entries[0]["payload"].is_object());
}
#[tokio::test]
async fn activity_endpoint_clamps_limit() {
let state = test_state();
let app = router().with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/activity?limit=10000")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["limit"], json!(500));
}
#[tokio::test]
async fn activity_endpoint_filters_by_source_and_palace() {
let state = test_state();
state.emit(DaemonEvent::DrawerAdded {
palace_id: "alpha".into(),
palace_name: "alpha".into(),
drawer_count: 1,
timestamp: chrono::Utc::now(),
content_preview: "".into(),
source: ActivitySource::Mcp,
});
state.emit(DaemonEvent::DrawerAdded {
palace_id: "alpha".into(),
palace_name: "alpha".into(),
drawer_count: 2,
timestamp: chrono::Utc::now(),
content_preview: "".into(),
source: ActivitySource::Http,
});
state.emit(DaemonEvent::DrawerAdded {
palace_id: "beta".into(),
palace_name: "beta".into(),
drawer_count: 1,
timestamp: chrono::Utc::now(),
content_preview: "".into(),
source: ActivitySource::Mcp,
});
state.flush_activity_writes().await;
let app = router().with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/activity?palace=alpha&source=mcp&limit=50")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let entries = v["entries"].as_array().unwrap();
assert_eq!(entries.len(), 1, "filter should leave one row, got {v}");
assert_eq!(entries[0]["palace_id"], "alpha");
assert_eq!(entries[0]["source"], "mcp");
}
#[tokio::test]
async fn activity_endpoint_rejects_unknown_source() {
let state = test_state();
let app = router().with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/activity?source=nope")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn mcp_memory_remember_emits_drawer_added_with_mcp_source() {
use crate::tools::dispatch_tool;
let state = test_state();
let mut rx = state.events.subscribe();
let _ = dispatch_tool(&state, "palace_create", json!({"name": "p1"}))
.await
.expect("palace_create");
let first = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
.await
.expect("first event")
.expect("channel open");
assert!(
matches!(first, DaemonEvent::PalaceCreated { ref source, .. } if *source == ActivitySource::Mcp)
);
let _ = dispatch_tool(
&state,
"memory_remember",
json!({
"palace": "p1",
"text": "the quick brown fox jumps over the lazy dog and more"
}),
)
.await
.expect("memory_remember");
let next = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
.await
.expect("drawer_added event")
.expect("channel open");
match next {
DaemonEvent::DrawerAdded {
source, palace_id, ..
} => {
assert_eq!(source, ActivitySource::Mcp);
assert_eq!(palace_id, "p1");
}
other => panic!("expected DrawerAdded, got {other:?}"),
}
state.flush_activity_writes().await;
let app = router().with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/activity?source=mcp&limit=10")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let entries = v["entries"].as_array().unwrap();
let event_types: std::collections::HashSet<&str> = entries
.iter()
.filter_map(|e| e["event_type"].as_str())
.collect();
assert!(event_types.contains("drawer_added"));
assert!(event_types.contains("palace_created"));
}