circus/crates/server/tests/e2e_test.rs
NotAShelf a127f3f62c
treewide: address all clippy lints
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I5cf55cc4cb558c3f9f764c71224e87176a6a6964
2026-02-28 17:37:53 +03:00

337 lines
9 KiB
Rust

//! End-to-end integration test.
//! Requires `TEST_DATABASE_URL` to be set.
//! Tests the full flow: create project -> jobset -> evaluation -> builds.
//!
//! Nix-dependent steps are skipped if nix is not available.
use axum::{
body::Body,
http::{Request, StatusCode},
};
use fc_common::models::*;
use tower::ServiceExt;
async fn get_pool() -> Option<sqlx::PgPool> {
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
println!("Skipping E2E test: TEST_DATABASE_URL not set");
return None;
};
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&url)
.await
.ok()?;
sqlx::migrate!("../common/migrations")
.run(&pool)
.await
.ok()?;
Some(pool)
}
#[tokio::test]
async fn test_e2e_project_eval_build_flow() {
let Some(pool) = get_pool().await else {
return;
};
// 1. Create a project
let project_name = format!("e2e-test-{}", uuid::Uuid::new_v4());
let project = fc_common::repo::projects::create(&pool, CreateProject {
name: project_name.clone(),
description: Some("E2E test project".to_string()),
repository_url: "https://github.com/test/e2e".to_string(),
})
.await
.expect("create project");
assert_eq!(project.name, project_name);
// 2. Create a jobset
let jobset = fc_common::repo::jobsets::create(&pool, CreateJobset {
project_id: project.id,
name: "default".to_string(),
nix_expression: "packages".to_string(),
enabled: Some(true),
flake_mode: Some(true),
check_interval: Some(300),
branch: None,
scheduling_shares: None,
state: None,
keep_nr: None,
})
.await
.expect("create jobset");
assert_eq!(jobset.project_id, project.id);
assert!(jobset.enabled);
// 3. Verify active jobsets include our new one
let active = fc_common::repo::jobsets::list_active(&pool)
.await
.expect("list active");
assert!(
active.iter().any(|j| j.id == jobset.id),
"new jobset should be in active list"
);
// 4. Create an evaluation
let eval = fc_common::repo::evaluations::create(&pool, CreateEvaluation {
jobset_id: jobset.id,
commit_hash: "e2e0000000000000000000000000000000000000".to_string(),
pr_number: None,
pr_head_branch: None,
pr_base_branch: None,
pr_action: None,
})
.await
.expect("create evaluation");
assert_eq!(eval.jobset_id, jobset.id);
assert_eq!(eval.status, EvaluationStatus::Pending);
// 5. Mark evaluation as running
fc_common::repo::evaluations::update_status(
&pool,
eval.id,
EvaluationStatus::Running,
None,
)
.await
.expect("update eval status");
// 6. Create builds as if nix evaluation found jobs
let build1 = fc_common::repo::builds::create(&pool, CreateBuild {
evaluation_id: eval.id,
job_name: "hello".to_string(),
drv_path: "/nix/store/e2e000-hello.drv".to_string(),
system: Some("x86_64-linux".to_string()),
outputs: Some(serde_json::json!({"out": "/nix/store/e2e000-hello"})),
is_aggregate: Some(false),
constituents: None,
})
.await
.expect("create build 1");
let build2 = fc_common::repo::builds::create(&pool, CreateBuild {
evaluation_id: eval.id,
job_name: "world".to_string(),
drv_path: "/nix/store/e2e000-world.drv".to_string(),
system: Some("x86_64-linux".to_string()),
outputs: Some(serde_json::json!({"out": "/nix/store/e2e000-world"})),
is_aggregate: Some(false),
constituents: None,
})
.await
.expect("create build 2");
assert_eq!(build1.status, BuildStatus::Pending);
assert_eq!(build2.status, BuildStatus::Pending);
// 7. Create build dependency (hello depends on world)
fc_common::repo::build_dependencies::create(&pool, build1.id, build2.id)
.await
.expect("create dependency");
// 8. Verify dependency check: build1 deps NOT complete (world is still
// pending)
let deps_complete =
fc_common::repo::build_dependencies::all_deps_completed(&pool, build1.id)
.await
.expect("check deps");
assert!(!deps_complete, "deps should NOT be complete yet");
// 9. Complete build2 (world)
fc_common::repo::builds::start(&pool, build2.id)
.await
.expect("start build2");
fc_common::repo::builds::complete(
&pool,
build2.id,
BuildStatus::Succeeded,
None,
Some("/nix/store/e2e000-world"),
None,
)
.await
.expect("complete build2");
// 10. Now build1 deps should be complete
let deps_complete =
fc_common::repo::build_dependencies::all_deps_completed(&pool, build1.id)
.await
.expect("check deps again");
assert!(deps_complete, "deps should be complete after build2 done");
// 11. Complete build1 (hello)
fc_common::repo::builds::start(&pool, build1.id)
.await
.expect("start build1");
let step = fc_common::repo::build_steps::create(&pool, CreateBuildStep {
build_id: build1.id,
step_number: 1,
command: "nix build /nix/store/e2e000-hello.drv".to_string(),
})
.await
.expect("create step");
fc_common::repo::build_steps::complete(
&pool,
step.id,
0,
Some("built!"),
None,
)
.await
.expect("complete step");
fc_common::repo::build_products::create(&pool, CreateBuildProduct {
build_id: build1.id,
name: "out".to_string(),
path: "/nix/store/e2e000-hello".to_string(),
sha256_hash: Some("abcdef1234567890".to_string()),
file_size: Some(12345),
content_type: None,
is_directory: true,
})
.await
.expect("create product");
fc_common::repo::builds::complete(
&pool,
build1.id,
BuildStatus::Succeeded,
None,
Some("/nix/store/e2e000-hello"),
None,
)
.await
.expect("complete build1");
// 12. Mark evaluation as completed
fc_common::repo::evaluations::update_status(
&pool,
eval.id,
EvaluationStatus::Completed,
None,
)
.await
.expect("complete eval");
// 13. Verify everything is in the expected state
let final_eval = fc_common::repo::evaluations::get(&pool, eval.id)
.await
.expect("get eval");
assert_eq!(final_eval.status, EvaluationStatus::Completed);
let final_build1 = fc_common::repo::builds::get(&pool, build1.id)
.await
.expect("get build1");
assert_eq!(final_build1.status, BuildStatus::Succeeded);
assert_eq!(
final_build1.build_output_path.as_deref(),
Some("/nix/store/e2e000-hello")
);
let products =
fc_common::repo::build_products::list_for_build(&pool, build1.id)
.await
.expect("list products");
assert_eq!(products.len(), 1);
assert_eq!(products[0].name, "out");
let steps = fc_common::repo::build_steps::list_for_build(&pool, build1.id)
.await
.expect("list steps");
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].exit_code, Some(0));
// 14. Verify build stats reflect our changes
let build_stats = fc_common::repo::builds::get_stats(&pool)
.await
.expect("get stats");
assert!(build_stats.completed_builds.unwrap_or(0) >= 2);
// 15. Create a channel and verify it works
let channel = fc_common::repo::channels::create(&pool, CreateChannel {
project_id: project.id,
name: "stable".to_string(),
jobset_id: jobset.id,
})
.await
.expect("create channel");
let channels = fc_common::repo::channels::list_all(&pool)
.await
.expect("list channels");
assert!(channels.iter().any(|c| c.id == channel.id));
// 16. Test the HTTP API layer
let config = fc_common::config::Config::default();
let server_config = config.server.clone();
let state = fc_server::state::AppState {
pool: pool.clone(),
config,
sessions: std::sync::Arc::new(dashmap::DashMap::new()),
http_client: reqwest::Client::new(),
};
let app = fc_server::routes::router(state, &server_config);
// GET /health
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// GET /api/v1/projects/{id}
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/v1/projects/{}", project.id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// GET /api/v1/builds/{id}
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/v1/builds/{}", build1.id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// GET / (dashboard)
let resp = app
.clone()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("Dashboard"));
// Clean up
let _ = fc_common::repo::projects::delete(&pool, project.id).await;
}