fabro/lib/apps/fabro-server/tests/it/api/run_files.rs
Bryan Helmkamp 60b503322c
Delete the legacy run event log, its reducer and its types
Step 4 of the legacy executor deletion, fourth commit: with no writer
and no reader left, the legacy event log goes.

- `fabro-types`: `run_event` (`EventBody`, `RunEvent` and every props
  struct), `EventEnvelope` and the `RunEventDetail*` types are deleted.
  What the projection and the API still use moves out of the event
  vocabulary: `AgentEventProps`, `AgentSessionActivatedProps`,
  `AgentToolsAvailableProps`, `StagePromptProps`, `SessionCapability`
  and the coding event names to `agent_props`; `RunNoticeLevel` and
  `RunNoticeCode` to `notice`; `InterviewOption` beside the question
  types; `RunRunnableSource` beside the run status. `Checkpoint` is
  what Fabro records for a Petri run: `timestamp`, `current_node`,
  `git_commit_sha`; the conclusion's stage summaries derive from the
  projection's stages instead of the checkpoint's node maps.
- `fabro-store`: the Slate bridge (`RunDatabase`, the Slate `Database`,
  `keys`, `record`, `EventPayload`) and the reducer (`run_state`) are
  deleted. `Database` is the blob table and the run summary store over
  one pool; the blob store is SQLite only; the run summary store keeps
  the `runs` row a projector writes and lists, and finds the pull
  request creation candidates over `platform_records`; `build_summary`
  and `projected_usage` live in `run_summary`. The SlateDB dependency
  is gone. Test fixtures build the store from its two SQLite stores.
- `fabro-workflow`: the `event` module (the `Event` enum, its
  conversion, sink, emitter, redaction, stored fields and names),
  `runtime_store`, `StageScope` and the legacy seeding test helpers are
  deleted; the tests that seeded legacy runs read platform records or
  a projection instead.
- `fabro-sandbox` owns `GitRetryReason`.
- The server builds the store without an object store; the legacy
  `POST /runs/{id}/events` tests go, an interrupt answers
  `interrupt_unsupported` in the tests as it does in the handler, and
  the tests that read a run back through the Slate handle read its
  projection or its platform records. The projection folds a block
  that lands while the run is paused as the pause's prior block, and a
  pause or unpause clears the pending control it answers; a control
  request's check-and-append holds a per-run lock so two concurrent
  cancels record one request.
- The CLI's final output is the response of the last stage that
  produced one; the workflow tests read completed nodes from the
  succeeded stages.
- The spec's `RunCheckpoint` carries the three fields the type keeps.

Still failing until the next commits: the CLI tests that seed runs
through `POST /runs/{id}/events` or wait for legacy event names, and
the two Ask Fabro resume tests (the sandbox instance gap).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 14:09:17 -04:00

286 lines
10 KiB
Rust

//! HTTP-level integration tests for `GET /api/v1/runs/{id}/files`.
//!
//! These tests exercise the handler's request-plumbing branches —
//! authentication extractor, route matching, query validation, demo-mode
//! branching, and the empty-envelope / not-found responses — without
//! requiring a reconnected sandbox. The sandbox happy path is covered by
//! unit tests on the sandbox-git helpers and by `stitch_file_diff` tests
//! in `run_files.rs`.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use crate::helpers::{
MINIMAL_DOT, api, minimal_intent_json, response_json, response_status, test_app_state,
};
fn files_url(run_id: &str) -> String {
api(&format!("/runs/{run_id}/files"))
}
fn commits_url(run_id: &str) -> String {
api(&format!("/runs/{run_id}/commits"))
}
fn files_url_with_scope(run_id: &str, scope: &str) -> String {
format!("{}?scope={scope}", files_url(run_id))
}
#[tokio::test]
async fn invalid_run_id_returns_400() {
let app = fabro_server::test_support::build_test_router(test_app_state());
let req = Request::builder()
.method("GET")
.uri(files_url("not-a-ulid"))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
response_status(
resp,
StatusCode::BAD_REQUEST,
"GET /api/v1/runs/not-a-ulid/files",
)
.await;
}
#[tokio::test]
async fn unknown_run_returns_404() {
let app = fabro_server::test_support::build_test_router(test_app_state());
// Valid ULID format but not a run we've created.
let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let req = Request::builder()
.method("GET")
.uri(files_url(fake))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
response_status(
resp,
StatusCode::NOT_FOUND,
format!("GET /api/v1/runs/{fake}/files"),
)
.await;
}
#[tokio::test]
async fn malformed_from_sha_query_returns_400() {
let app = fabro_server::test_support::build_test_router(test_app_state());
let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let req = Request::builder()
.method("GET")
.uri(format!("{}?from_sha=not-hex", files_url(fake)))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = crate::helpers::response_json(
resp,
StatusCode::BAD_REQUEST,
format!("{}:{}", file!(), line!()),
)
.await;
assert!(
body["errors"][0]["detail"]
.as_str()
.unwrap_or_default()
.contains("from_sha")
);
}
#[tokio::test]
async fn one_sided_from_sha_returns_400_even_when_hex() {
let app = fabro_server::test_support::build_test_router(test_app_state());
let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let req = Request::builder()
.method("GET")
.uri(format!(
"{}?from_sha=abc1234def56789abc1234def56789abc1234def",
files_url(fake)
))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
response_status(
resp,
StatusCode::BAD_REQUEST,
format!("GET /api/v1/runs/{fake}/files?from_sha=<one-sided>"),
)
.await;
}
#[tokio::test]
async fn malformed_to_sha_returns_400() {
let app = fabro_server::test_support::build_test_router(test_app_state());
let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let req = Request::builder()
.method("GET")
.uri(format!("{}?to_sha=xyz", files_url(fake)))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
response_status(
resp,
StatusCode::BAD_REQUEST,
format!("GET /api/v1/runs/{fake}/files?to_sha=xyz"),
)
.await;
}
#[tokio::test]
async fn invalid_scope_returns_400() {
let app = fabro_server::test_support::build_test_router(test_app_state());
let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let req = Request::builder()
.method("GET")
.uri(files_url_with_scope(fake, "dirty"))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
response_status(
resp,
StatusCode::BAD_REQUEST,
format!("GET /api/v1/runs/{fake}/files?scope=dirty"),
)
.await;
}
#[tokio::test]
async fn submitted_run_without_sandbox_returns_empty_envelope() {
let workspace = tempfile::tempdir().unwrap();
// A run that has been created but not started has no base_sha or
// run sandbox, so the handler returns an empty envelope. The UI
// maps that to R4(a).
let app = fabro_server::test_support::build_test_router(test_app_state());
let intent = minimal_intent_json(&app, MINIMAL_DOT, workspace.path()).await;
let create_req = Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&intent).unwrap()))
.unwrap();
let create_resp = app.clone().oneshot(create_req).await.unwrap();
let create_body = response_json(create_resp, StatusCode::CREATED, "POST /api/v1/runs").await;
let run_id = create_body["id"].as_str().unwrap().to_string();
let req = Request::builder()
.method("GET")
.uri(files_url(&run_id))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = response_json(
resp,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/files"),
)
.await;
assert!(
body["data"].as_array().is_some_and(Vec::is_empty),
"expected empty data: {body}"
);
assert_eq!(body["meta"]["total_changed"], 0);
assert_eq!(body["meta"]["source"].as_str(), Some("final_patch"));
assert_eq!(body["meta"]["scope"].as_str(), Some("committed"));
// Degraded is false because there's no final_patch either — the run
// simply hasn't produced anything to diff.
assert_eq!(body["meta"]["degraded"].as_bool(), Some(false));
}
#[tokio::test]
async fn demo_mode_returns_fixture_without_touching_store() {
// R34: demo handler must return the illustrative fixture with no
// cross-contamination with real run state.
let app = fabro_server::test_support::build_test_router(test_app_state());
let arbitrary = "not-even-a-valid-ulid-for-run";
let req = Request::builder()
.method("GET")
.uri(files_url(arbitrary))
.header("x-fabro-demo", "1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = response_json(resp, StatusCode::OK, "GET /api/v1/runs/whatever/files").await;
// Demo fixture ships three entries (modified + added + renamed).
assert_eq!(body["meta"]["source"].as_str(), Some("sandbox"));
assert_eq!(body["meta"]["scope"].as_str(), Some("committed"));
let data = body["data"].as_array().expect("data array");
assert_eq!(data.len(), 3, "demo fixture should have 3 entries");
// At least one entry must render with populated contents to prove the
// fixture exercises the MultiFileDiff branch.
let has_content = data.iter().any(|entry| {
entry["new_file"]["contents"]
.as_str()
.is_some_and(|s| !s.is_empty())
});
assert!(has_content, "demo fixture should contain populated content");
}
#[tokio::test]
async fn response_envelope_matches_openapi_paginated_run_file_list_shape() {
// Sanity check that the happy-path envelope shape matches what the
// OpenAPI spec + regenerated TS client expect. Uses demo mode so the
// test stays deterministic without running a sandbox.
let app = fabro_server::test_support::build_test_router(test_app_state());
let req = Request::builder()
.method("GET")
.uri(files_url("whatever"))
.header("x-fabro-demo", "1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = response_json(resp, StatusCode::OK, "GET /api/v1/runs/whatever/files").await;
assert!(body["data"].is_array());
assert!(body["meta"].is_object());
assert!(body.get("source").is_none());
assert!(body["meta"]["source"].is_string());
assert!(body["meta"]["scope"].is_string());
assert!(body["meta"]["truncated"].is_boolean());
assert!(body["meta"]["total_changed"].is_number());
for entry in body["data"].as_array().unwrap() {
assert!(entry["old_file"]["name"].is_string());
assert!(entry["old_file"]["contents"].is_string());
assert!(entry["new_file"]["name"].is_string());
assert!(entry["new_file"]["contents"].is_string());
}
}
#[tokio::test]
async fn commit_response_envelope_matches_openapi_paginated_run_commit_list_shape() {
// Sanity check that the commits route is wired and returns the envelope
// shape generated into the TypeScript client. Demo mode keeps this
// route-level test deterministic without requiring a live sandbox.
let app = fabro_server::test_support::build_test_router(test_app_state());
let req = Request::builder()
.method("GET")
.uri(commits_url("whatever"))
.header("x-fabro-demo", "1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = response_json(resp, StatusCode::OK, "GET /api/v1/runs/whatever/commits").await;
assert!(body["data"].is_array());
assert!(body["meta"].is_object());
assert_eq!(body["meta"]["source"].as_str(), Some("sandbox"));
assert!(body["meta"]["base_sha"].is_string());
assert!(body["meta"]["head_sha"].is_string());
assert!(body["meta"]["limit"].is_number());
assert!(body["meta"]["total_returned"].is_number());
assert!(body["meta"]["truncated"].is_boolean());
let data = body["data"].as_array().expect("data array");
assert_eq!(data.len(), 1, "demo commits fixture should have one commit");
let commit = &data[0];
assert!(commit["sha"].is_string());
assert!(commit["short_sha"].is_string());
assert!(commit["parents"].is_array());
assert!(commit["author"].is_object());
assert!(commit["committer"].is_object());
assert!(commit["subject"].is_string());
assert!(commit["message"].is_string());
assert!(commit["trailers"].is_object());
assert!(commit["tree_sha"].is_string());
}