fabro/lib/apps/fabro-server/tests/it/api/sessions.rs
2026-08-31 14:16:58 -04:00

559 lines
20 KiB
Rust

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::<fabro_types::SessionId>().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 generic_session_creation_requires_the_dedicated_operation_without_advancing_history() {
let app = fabro_server::test_support::build_test_router(test_app_state());
let run_id = create_run(&app).await;
let before_request = Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}/events")))
.body(Body::empty())
.expect("run-events request should build");
let before = response_json(
app.clone().oneshot(before_request).await.unwrap(),
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/events before rejected append"),
)
.await;
let session_id = fabro_types::SessionId::new();
let request = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/events")))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"id": ulid::Ulid::new().to_string(),
"ts": "2026-08-31T12:00:00Z",
"run_id": run_id,
"event": "run.session.created",
"session_id": session_id,
"properties": { "title": "Injected session" },
}))
.expect("session creation event should serialize"),
))
.expect("append-event request should build");
let rejected = response_json(
app.clone().oneshot(request).await.unwrap(),
StatusCode::BAD_REQUEST,
format!("POST /api/v1/runs/{run_id}/events with session creation"),
)
.await;
let detail = rejected["errors"][0]["detail"]
.as_str()
.expect("error response should include detail");
assert!(detail.contains("dedicated operation endpoint"));
assert!(detail.contains("run.session.created"));
let after_request = Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}/events")))
.body(Body::empty())
.expect("run-events request should build");
let after = response_json(
app.clone().oneshot(after_request).await.unwrap(),
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/events after rejected append"),
)
.await;
assert_eq!(after, before);
}
#[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<serde_json::Value> {
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}"
);
}
}