use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; use fabro_static::EnvVars; use tower::ServiceExt; use crate::helpers::{ MINIMAL_DOT, api, minimal_manifest_json, response_json, response_status, test_app_state, }; async fn create_run(app: &axum::Router) -> String { let request = Request::builder() .method("POST") .uri(api("/runs")) .header("content-type", "application/json") .body(Body::from( serde_json::to_string(&minimal_manifest_json(MINIMAL_DOT)) .expect("manifest should serialize"), )) .expect("create-run request should build"); let body = response_json( app.clone().oneshot(request).await.unwrap(), StatusCode::CREATED, "POST /api/v1/runs", ) .await; body["id"] .as_str() .expect("create-run response should include an id") .to_string() } async fn create_session(app: &axum::Router, run_id: &str, title: &str) -> serde_json::Value { create_session_with_body(app, run_id, serde_json::json!({ "title": title })).await } async fn create_session_with_model( app: &axum::Router, run_id: &str, title: &str, model: &str, ) -> serde_json::Value { create_session_with_body( app, run_id, serde_json::json!({ "title": title, "model": model }), ) .await } async fn create_session_with_body( app: &axum::Router, run_id: &str, body: serde_json::Value, ) -> serde_json::Value { response_json( create_session_response(app, run_id, body).await, StatusCode::CREATED, format!("POST /api/v1/runs/{run_id}/sessions"), ) .await } async fn create_session_response( app: &axum::Router, run_id: &str, body: serde_json::Value, ) -> axum::response::Response { let request = Request::builder() .method("POST") .uri(api(&format!("/runs/{run_id}/sessions"))) .header("content-type", "application/json") .body(Body::from( serde_json::to_string(&body).expect("session request should serialize"), )) .expect("create-session request should build"); app.clone().oneshot(request).await.unwrap() } #[tokio::test] async fn run_bound_session_is_created_as_run_event_and_resolves_by_flat_id() { let app = fabro_server::test_support::build_test_router(test_app_state()); let run_id = create_run(&app).await; let created = create_session(&app, &run_id, "Ask Fabro").await; let session_id = created["id"] .as_str() .expect("session response should include an id"); assert_eq!(created["run_id"], run_id); assert_eq!(created["title"], "Ask Fabro"); assert_session_metadata_only(&created); assert!(session_id.parse::().is_ok()); let get_request = Request::builder() .method("GET") .uri(api(&format!("/sessions/{session_id}"))) .body(Body::empty()) .expect("get-session request should build"); let fetched = response_json( app.clone().oneshot(get_request).await.unwrap(), StatusCode::OK, format!("GET /api/v1/sessions/{session_id}"), ) .await; assert_eq!(fetched["id"], session_id); assert_eq!(fetched["run_id"], run_id); assert_session_metadata_only(&fetched); assert_eq!(fetched["messages"].as_array().unwrap().len(), 0); assert!(fetched["active_turn"].is_null()); let events_request = Request::builder() .method("GET") .uri(api(&format!("/runs/{run_id}/events"))) .body(Body::empty()) .expect("run-events request should build"); let events = response_json( app.clone().oneshot(events_request).await.unwrap(), StatusCode::OK, format!("GET /api/v1/runs/{run_id}/events"), ) .await; let session_events: Vec<_> = events["data"] .as_array() .expect("events response should include data") .iter() .filter(|event| event["session_id"] == session_id) .collect(); assert_eq!(session_events.len(), 1); assert_eq!(fetched["last_seq"], session_events[0]["seq"]); assert_eq!(session_events[0]["event"], "run.session.created"); assert!( session_events[0]["properties"].get("permissions").is_none(), "run session creation event should not expose permissions" ); } #[tokio::test] async fn sessions_are_listed_only_under_their_owning_run() { let app = fabro_server::test_support::build_test_router(test_app_state()); let first_run_id = create_run(&app).await; let second_run_id = create_run(&app).await; let created = create_session(&app, &first_run_id, "First run chat").await; let first_request = Request::builder() .method("GET") .uri(api(&format!("/runs/{first_run_id}/sessions"))) .body(Body::empty()) .expect("list sessions request should build"); let first = response_json( app.clone().oneshot(first_request).await.unwrap(), StatusCode::OK, format!("GET /api/v1/runs/{first_run_id}/sessions"), ) .await; assert_eq!(first["data"].as_array().unwrap().len(), 1); assert_eq!(first["data"][0]["id"], created["id"]); assert_session_metadata_only(&first["data"][0]); let second_request = Request::builder() .method("GET") .uri(api(&format!("/runs/{second_run_id}/sessions"))) .body(Body::empty()) .expect("list sessions request should build"); let second = response_json( app.clone().oneshot(second_request).await.unwrap(), StatusCode::OK, format!("GET /api/v1/runs/{second_run_id}/sessions"), ) .await; assert!(second["data"].as_array().unwrap().is_empty()); } #[tokio::test] async fn supplied_session_model_alias_is_canonicalized() { let app = fabro_server::test_support::build_test_router(test_app_state()); let run_id = create_run(&app).await; let created = create_session_with_model(&app, &run_id, "Ask Fabro", "gpt54").await; assert_eq!(created["model"], "gpt-5.4"); let events_request = Request::builder() .method("GET") .uri(api(&format!("/runs/{run_id}/events"))) .body(Body::empty()) .expect("run-events request should build"); let events = response_json( app.clone().oneshot(events_request).await.unwrap(), StatusCode::OK, format!("GET /api/v1/runs/{run_id}/events"), ) .await; let created_event = events["data"] .as_array() .expect("events response should include data") .iter() .find(|event| event["event"] == "run.session.created") .expect("session creation event should be recorded"); assert_eq!(created_event["properties"]["model"], "gpt-5.4"); assert_eq!(created_event["properties"]["provider"], "openai"); } #[tokio::test] async fn provider_qualified_session_model_is_canonicalized() { let app = fabro_server::test_support::build_test_router(test_app_state()); let run_id = create_run(&app).await; let created = create_session_with_model(&app, &run_id, "Ask Fabro", "openai/gpt-5.4-mini").await; assert_eq!(created["model"], "gpt-5.4-mini"); assert_eq!(created["provider"], "openai"); } #[tokio::test] async fn unknown_session_models_preserve_passthrough_on_the_selected_provider() { let app = fabro_server::test_support::build_test_router(test_app_state()); let run_id = create_run(&app).await; for model in ["not-a-real-model", "openai/not-a-real-model"] { let created = create_session_with_model(&app, &run_id, "Ask Fabro", model).await; assert_eq!(created["model"], "not-a-real-model"); assert_eq!(created["provider"], "openai"); } } #[tokio::test] async fn invalid_session_model_refs_are_rejected_at_creation() { let app = fabro_server::test_support::build_test_router(test_app_state()); let run_id = create_run(&app).await; for model in ["openai", "openai/", "anthropic/gpt-5.4"] { let response = create_session_response( &app, &run_id, serde_json::json!({ "title": "Ask Fabro", "model": model }), ) .await; response_status( response, StatusCode::BAD_REQUEST, format!("POST /api/v1/runs/{run_id}/sessions with model {model}"), ) .await; } } #[tokio::test] async fn ambiguous_session_model_refs_are_rejected_at_creation() { let mut catalog_settings = fabro_model::catalog::LlmCatalogSettings::default(); catalog_settings.providers.insert( "openai".to_string(), fabro_model::catalog::ProviderCatalogSettings { aliases: Some(vec!["gpt54".to_string()]), ..fabro_model::catalog::ProviderCatalogSettings::default() }, ); let state = fabro_server::test_support::TestAppStateBuilder::new() .llm_catalog_settings(catalog_settings) .vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")]) .build(); let app = fabro_server::test_support::build_test_router(state); let run_id = create_run(&app).await; let response = create_session_response( &app, &run_id, serde_json::json!({ "title": "Ask Fabro", "model": "gpt54" }), ) .await; response_status( response, StatusCode::BAD_REQUEST, format!("POST /api/v1/runs/{run_id}/sessions with ambiguous model gpt54"), ) .await; } #[tokio::test] async fn session_turn_fails_when_selected_model_provider_becomes_unconfigured() { let app = fabro_server::test_support::build_test_router(test_app_state()); let run_id = create_run(&app).await; let created = create_session_with_model(&app, &run_id, "Ask Fabro", "gpt54").await; let session_id = created["id"] .as_str() .expect("session response should include an id"); delete_openai_credential(&app).await; let request = Request::builder() .method("POST") .uri(api(&format!("/sessions/{session_id}/turns"))) .header("content-type", "application/json") .body(Body::from(r#"{"input":"Which provider are you using?"}"#)) .expect("submit-turn request should build"); let response = app.clone().oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); assert!( response.headers().contains_key("x-fabro-turn-id"), "submit turn should return the generated turn id" ); let events = session_sse_events(response).await; let failed = events .iter() .find(|event| event["event"] == "run.session.turn.failed") .expect("selected provider failure should be streamed"); assert!( failed["properties"]["error"] .as_str() .expect("failure event should include an error") .contains("provider 'openai'"), "failure should be for the selected model provider: {failed:?}" ); assert_eq!(failed["properties"]["code"], "model_unavailable"); assert_eq!(failed["properties"]["retryable"], false); } #[tokio::test] async fn session_metadata_patch_route_is_removed() { let app = fabro_server::test_support::build_test_router(test_app_state()); let run_id = create_run(&app).await; let created = create_session(&app, &run_id, "Ask Fabro").await; let session_id = created["id"] .as_str() .expect("session response should include an id"); let request = Request::builder() .method("PATCH") .uri(api(&format!("/sessions/{session_id}"))) .header("content-type", "application/json") .body(Body::from(r#"{"title":"Renamed"}"#)) .expect("patch-session request should build"); response_status( app.clone().oneshot(request).await.unwrap(), StatusCode::NOT_FOUND, format!("PATCH /api/v1/sessions/{session_id}"), ) .await; } #[tokio::test] async fn unsupported_derived_turn_read_routes_are_removed() { let app = fabro_server::test_support::build_test_router(test_app_state()); let run_id = create_run(&app).await; let created = create_session(&app, &run_id, "Ask Fabro").await; let session_id = created["id"] .as_str() .expect("session response should include an id"); let turn_id = fabro_types::TurnId::new(); for path in [format!("/sessions/{session_id}/turns/{turn_id}")] { let request = Request::builder() .method("GET") .uri(api(&path)) .body(Body::empty()) .expect("removed session read request should build"); response_status( app.clone().oneshot(request).await.unwrap(), StatusCode::NOT_FOUND, format!("GET /api/v1{path}"), ) .await; } } #[tokio::test] async fn session_events_are_filtered_by_session_and_paginated_by_run_sequence() { let app = fabro_server::test_support::build_test_router(test_app_state()); let run_id = create_run(&app).await; let first = create_session(&app, &run_id, "First").await; let second = create_session(&app, &run_id, "Second").await; let first_id = first["id"].as_str().unwrap(); let second_id = second["id"].as_str().unwrap(); let get_first = Request::builder() .method("GET") .uri(api(&format!("/sessions/{first_id}"))) .body(Body::empty()) .expect("get-session request should build"); let first_detail = response_json( app.clone().oneshot(get_first).await.unwrap(), StatusCode::OK, format!("GET /api/v1/sessions/{first_id}"), ) .await; let after_first_created_seq = first_detail["last_seq"].as_u64().unwrap() + 1; delete_openai_credential(&app).await; let turn_id = fabro_types::TurnId::new(); let submit = Request::builder() .method("POST") .uri(api(&format!("/sessions/{first_id}/turns"))) .header("content-type", "application/json") .body(Body::from(format!( r#"{{"turn_id":"{turn_id}","input":"Which provider?"}}"# ))) .expect("submit-turn request should build"); let response = app.clone().oneshot(submit).await.unwrap(); assert_eq!( response.headers().get("x-fabro-turn-id").unwrap(), turn_id.to_string().as_str() ); let _ = session_sse_events(response).await; let request = Request::builder() .method("GET") .uri(api(&format!( "/sessions/{first_id}/events?since_seq={after_first_created_seq}&limit=1" ))) .body(Body::empty()) .expect("session events request should build"); let page = response_json( app.clone().oneshot(request).await.unwrap(), StatusCode::OK, format!("GET /api/v1/sessions/{first_id}/events"), ) .await; assert_eq!(page["data"].as_array().unwrap().len(), 1); assert_eq!(page["data"][0]["session_id"], first_id); assert_ne!(page["data"][0]["session_id"], second_id); assert_eq!(page["data"][0]["event"], "run.session.turn.started"); assert_eq!( page["data"][0]["properties"]["turn_id"], turn_id.to_string() ); assert_eq!(page["meta"]["has_more"], true); } #[tokio::test] async fn inactive_turn_interrupt_returns_conflict() { let app = fabro_server::test_support::build_test_router(test_app_state()); let run_id = create_run(&app).await; let created = create_session(&app, &run_id, "Ask Fabro").await; let session_id = created["id"] .as_str() .expect("session response should include an id"); let turn_id = fabro_types::TurnId::new(); let request = Request::builder() .method("POST") .uri(api(&format!( "/sessions/{session_id}/turns/{turn_id}/interrupt" ))) .body(Body::empty()) .expect("interrupt request should build"); response_status( app.clone().oneshot(request).await.unwrap(), StatusCode::CONFLICT, format!("POST /api/v1/sessions/{session_id}/turns/{turn_id}/interrupt"), ) .await; } async fn session_sse_events(response: axum::response::Response) -> Vec { let bytes = to_bytes(response.into_body(), usize::MAX) .await .expect("session event stream body should be readable"); let body = String::from_utf8(bytes.to_vec()).expect("session event stream should be UTF-8"); body.lines() .filter_map(|line| line.strip_prefix("data: ")) .map(|data| serde_json::from_str(data).expect("session event data should be JSON")) .collect() } async fn delete_openai_credential(app: &axum::Router) { let request = Request::builder() .method("DELETE") .uri(api("/secrets")) .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&serde_json::json!({ "name": EnvVars::OPENAI_API_KEY, })) .expect("delete-secret request should serialize"), )) .expect("delete-secret request should build"); response_status( app.clone().oneshot(request).await.unwrap(), StatusCode::NO_CONTENT, "DELETE /api/v1/secrets", ) .await; } fn assert_session_metadata_only(value: &serde_json::Value) { let object = value .as_object() .expect("session response should be a JSON object"); assert_eq!(value["provider"], "openai"); for field in [ "working_dir", "permissions", "deleted_at", "runtime_context", ] { assert!( !object.contains_key(field), "session metadata should not expose {field}" ); } }