use std::sync::Arc;
use axum::body::Body;
use axum::http::{Method, Request};
use chrono::{TimeZone, Utc};
use serde_json::{Value, json};
use tower::ServiceExt;
use crate::core::hook::{HookEvent, HookEventRecord};
use crate::core::session::{ControlModel, Session, SessionId, SessionStatus};
use crate::daemon::state::DaemonState;
async fn isolated_state() -> (Arc<DaemonState>, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let state = DaemonState::with_root_isolated_managed(dir.path().to_path_buf()).await;
(Arc::new(state), dir)
}
fn register_session(state: &DaemonState) -> SessionId {
let id = SessionId::new();
let mut session = Session::new(id, "/tmp/tm-94", ControlModel::Tmux, None);
session.status = SessionStatus::Active;
state.register_session(session);
id
}
fn push_file_change(
state: &DaemonState,
session: SessionId,
path: &str,
operation: Option<&str>,
at_secs: i64,
) {
let mut payload = json!({ "path": path });
if let Some(op) = operation {
payload["operation"] = json!(op);
}
state.push_hook_event(HookEventRecord {
session,
event: HookEvent::FileChanged,
at: Utc
.timestamp_opt(at_secs, 0)
.single()
.expect("valid timestamp"),
payload,
});
}
async fn get(state: Arc<DaemonState>, uri: &str) -> (u16, Value) {
let request = Request::builder()
.method(Method::GET)
.uri(uri)
.body(Body::empty())
.expect("build request");
let response = crate::daemon::api::router(state)
.oneshot(request)
.await
.expect("route call");
let status = response.status().as_u16();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("read body");
let json = serde_json::from_slice::<Value>(&bytes).unwrap_or(Value::Null);
(status, json)
}
fn paths(body: &Value) -> Vec<String> {
body["files"]
.as_array()
.expect("files array")
.iter()
.map(|e| e["path"].as_str().expect("path string").to_owned())
.collect()
}
#[tokio::test]
async fn files_route_returns_only_that_sessions_records() {
let (state, _dir) = isolated_state().await;
let mine = register_session(&state);
let theirs = register_session(&state);
push_file_change(&state, mine, "/w/src/mine.rs", Some("write"), 1_700_000_100);
push_file_change(
&state,
theirs,
"/w/src/theirs.rs",
Some("write"),
1_700_000_101,
);
state.push_hook_event(HookEventRecord::now(
mine,
HookEvent::PostToolUse,
json!({ "path": "/w/src/not-a-file-change.rs" }),
));
let (status, body) = get(
Arc::clone(&state),
&format!("/api/v1/sessions/managed/{}/files", mine.0),
)
.await;
assert_eq!(status, 200, "body: {body}");
assert_eq!(paths(&body), vec!["/w/src/mine.rs".to_owned()]);
assert_eq!(body["count"], 1);
assert_eq!(body["session"], mine.0.to_string());
assert_eq!(body["since"], Value::Null);
assert_eq!(body["files"][0]["operation"], "write");
assert_eq!(body["files"][0]["timestamp"], 1_700_000_100_i64);
}
#[tokio::test]
async fn files_route_since_excludes_earlier_records() {
let (state, _dir) = isolated_state().await;
let id = register_session(&state);
push_file_change(&state, id, "/w/src/old.rs", Some("write"), 1_700_000_100);
push_file_change(&state, id, "/w/src/new.rs", Some("write"), 1_700_000_200);
let (status, body) = get(
Arc::clone(&state),
&format!("/api/v1/sessions/managed/{}/files?since=1700000200", id.0),
)
.await;
assert_eq!(status, 200, "body: {body}");
assert_eq!(paths(&body), vec!["/w/src/new.rs".to_owned()]);
assert_eq!(body["since"], 1_700_000_200_i64);
let (_, all) = get(state, &format!("/api/v1/sessions/managed/{}/files", id.0)).await;
assert_eq!(all["count"], 2, "body: {all}");
}
#[tokio::test]
async fn files_route_collapses_duplicate_reports() {
let (state, _dir) = isolated_state().await;
let id = register_session(&state);
push_file_change(&state, id, "/w/src/a.rs", Some("write"), 1_700_000_100);
push_file_change(&state, id, "/w/src/a.rs", Some("write"), 1_700_000_100);
push_file_change(&state, id, "/w/src/a.rs", Some("delete"), 1_700_000_100);
let (status, body) = get(state, &format!("/api/v1/sessions/managed/{}/files", id.0)).await;
assert_eq!(status, 200, "body: {body}");
assert_eq!(body["count"], 2, "body: {body}");
let ops: Vec<&str> = body["files"]
.as_array()
.expect("files array")
.iter()
.map(|e| e["operation"].as_str().expect("operation"))
.collect();
assert_eq!(ops, vec!["write", "delete"]);
}
#[tokio::test]
async fn files_route_unknown_session_is_a_typed_404() {
let (state, _dir) = isolated_state().await;
let unknown = uuid::Uuid::new_v4();
let (status, body) = get(state, &format!("/api/v1/sessions/managed/{unknown}/files")).await;
assert_eq!(status, 404, "body: {body}");
assert_eq!(body["session"], unknown.to_string());
let error = body["error"].as_str().expect("typed error field");
assert!(error.contains("not found"), "error was: {error}");
}
#[tokio::test]
async fn files_route_reads_a_managed_store_record() {
let (state, _dir) = isolated_state().await;
let manager = state.session_manager().await;
let record = manager
.create("task".to_owned(), None, None, None, None, None)
.await
.expect("create managed session");
push_file_change(
&state,
SessionId(record.id.0),
"/w/src/managed.rs",
None,
1_700_000_100,
);
let (status, body) = get(
Arc::clone(&state),
&format!("/api/v1/sessions/managed/{}/files", record.id.0),
)
.await;
assert_eq!(status, 200, "body: {body}");
assert_eq!(paths(&body), vec!["/w/src/managed.rs".to_owned()]);
assert_eq!(body["files"][0]["operation"], Value::Null);
}
#[tokio::test]
async fn files_route_rejects_a_malformed_id() {
let (state, _dir) = isolated_state().await;
let (status, _) = get(state, "/api/v1/sessions/managed/not-a-uuid/files").await;
assert_eq!(status, 400);
}