From 14d9b89e259ac3d00cf68f312ab5e10635d3ecd7 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Fri, 6 Mar 2026 15:49:24 -0800 Subject: [PATCH] Add HTTP integration tests for volume server handlers Tests healthz (200/503), status JSON, write-then-read round-trip, delete-then-404, HEAD headers-only, and invalid path 400 responses. Uses axum's tower::ServiceExt::oneshot for in-process testing. --- seaweed-volume/tests/http_integration.rs | 335 +++++++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 seaweed-volume/tests/http_integration.rs diff --git a/seaweed-volume/tests/http_integration.rs b/seaweed-volume/tests/http_integration.rs new file mode 100644 index 000000000..1d14aa224 --- /dev/null +++ b/seaweed-volume/tests/http_integration.rs @@ -0,0 +1,335 @@ +//! Integration tests for the volume server HTTP handlers. +//! +//! Uses axum's Router with tower::ServiceExt::oneshot to test +//! end-to-end without starting a real TCP server. + +use std::sync::{Arc, RwLock}; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tower::ServiceExt; // for `oneshot` + +use seaweed_volume::security::{Guard, SigningKey}; +use seaweed_volume::server::volume_server::{VolumeServerState, build_admin_router}; +use seaweed_volume::storage::needle_map::NeedleMapKind; +use seaweed_volume::storage::store::Store; +use seaweed_volume::storage::types::{DiskType, VolumeId}; + +use tempfile::TempDir; + +/// Create a test VolumeServerState with a temp directory, a single disk +/// location, and one pre-created volume (VolumeId 1). +fn test_state() -> (Arc, TempDir) { + let tmp = TempDir::new().expect("failed to create temp dir"); + let dir = tmp.path().to_str().unwrap(); + + let mut store = Store::new(NeedleMapKind::InMemory); + store + .add_location(dir, dir, 10, DiskType::HardDrive) + .expect("failed to add location"); + store + .add_volume(VolumeId(1), "", None, None, 0, DiskType::HardDrive) + .expect("failed to create volume"); + + let guard = Guard::new(&[], SigningKey(vec![]), 0, SigningKey(vec![]), 0); + let state = Arc::new(VolumeServerState { + store: RwLock::new(store), + guard, + is_stopping: RwLock::new(false), + }); + (state, tmp) +} + +/// Helper: read the entire response body as bytes. +async fn body_bytes(response: axum::response::Response) -> Vec { + let body = response.into_body(); + axum::body::to_bytes(body, usize::MAX) + .await + .expect("failed to read body") + .to_vec() +} + +// ============================================================================ +// 1. GET /healthz returns 200 when server is running +// ============================================================================ + +#[tokio::test] +async fn healthz_returns_200_when_running() { + let (state, _tmp) = test_state(); + let app = build_admin_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/healthz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); +} + +// ============================================================================ +// 2. GET /healthz returns 503 when is_stopping=true +// ============================================================================ + +#[tokio::test] +async fn healthz_returns_503_when_stopping() { + let (state, _tmp) = test_state(); + *state.is_stopping.write().unwrap() = true; + let app = build_admin_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/healthz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); +} + +// ============================================================================ +// 3. GET /status returns JSON with version and volumes array +// ============================================================================ + +#[tokio::test] +async fn status_returns_json_with_version_and_volumes() { + let (state, _tmp) = test_state(); + let app = build_admin_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/status") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = body_bytes(response).await; + let json: serde_json::Value = + serde_json::from_slice(&body).expect("response is not valid JSON"); + + assert!(json.get("version").is_some(), "missing 'version' field"); + assert!( + json["version"].is_string(), + "'version' should be a string" + ); + + assert!(json.get("volumes").is_some(), "missing 'volumes' field"); + assert!( + json["volumes"].is_array(), + "'volumes' should be an array" + ); + + // We created one volume in test_state, so the array should have one entry + let volumes = json["volumes"].as_array().unwrap(); + assert_eq!(volumes.len(), 1, "expected 1 volume"); + assert_eq!(volumes[0]["id"], 1); +} + +// ============================================================================ +// 4. POST writes data, then GET reads it back +// ============================================================================ + +#[tokio::test] +async fn write_then_read_needle() { + let (state, _tmp) = test_state(); + + // The fid "01637037d6" encodes NeedleId=0x01, Cookie=0x637037d6 + let uri = "/1,01637037d6"; + let payload = b"hello, seaweedfs!"; + + // --- POST (write) --- + let app = build_admin_router(state.clone()); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(uri) + .body(Body::from(payload.to_vec())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + response.status(), + StatusCode::CREATED, + "POST should return 201 Created" + ); + + let body = body_bytes(response).await; + let json: serde_json::Value = + serde_json::from_slice(&body).expect("POST response is not valid JSON"); + assert_eq!(json["size"], payload.len() as u64); + + // --- GET (read back) --- + let app = build_admin_router(state.clone()); + let response = app + .oneshot( + Request::builder() + .uri(uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK, "GET should return 200"); + + let body = body_bytes(response).await; + assert_eq!(body, payload, "GET body should match written data"); +} + +// ============================================================================ +// 5. DELETE deletes a needle, subsequent GET returns 404 +// ============================================================================ + +#[tokio::test] +async fn delete_then_get_returns_404() { + let (state, _tmp) = test_state(); + let uri = "/1,01637037d6"; + let payload = b"to be deleted"; + + // Write the needle first + let app = build_admin_router(state.clone()); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(uri) + .body(Body::from(payload.to_vec())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + + // Delete + let app = build_admin_router(state.clone()); + let response = app + .oneshot( + Request::builder() + .method("DELETE") + .uri(uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::ACCEPTED, + "DELETE should return 202 Accepted" + ); + + // GET should now return 404 + let app = build_admin_router(state.clone()); + let response = app + .oneshot( + Request::builder() + .uri(uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::NOT_FOUND, + "GET after DELETE should return 404" + ); +} + +// ============================================================================ +// 6. HEAD returns headers without body +// ============================================================================ + +#[tokio::test] +async fn head_returns_headers_without_body() { + let (state, _tmp) = test_state(); + let uri = "/1,01637037d6"; + let payload = b"head test data"; + + // Write needle + let app = build_admin_router(state.clone()); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(uri) + .body(Body::from(payload.to_vec())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + + // HEAD + let app = build_admin_router(state.clone()); + let response = app + .oneshot( + Request::builder() + .method("HEAD") + .uri(uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK, "HEAD should return 200"); + + // Content-Length header should be present + let content_length = response + .headers() + .get("content-length") + .expect("HEAD should include Content-Length header"); + let len: usize = content_length + .to_str() + .unwrap() + .parse() + .expect("Content-Length should be a number"); + assert_eq!(len, payload.len(), "Content-Length should match payload size"); + + // Body should be empty for HEAD + let body = body_bytes(response).await; + assert!(body.is_empty(), "HEAD body should be empty"); +} + +// ============================================================================ +// 7. Invalid URL path returns 400 +// ============================================================================ + +#[tokio::test] +async fn invalid_url_path_returns_400() { + let (state, _tmp) = test_state(); + let app = build_admin_router(state); + + // "invalidpath" has no comma or slash separator so parse_url_path returns None + let response = app + .oneshot( + Request::builder() + .uri("/invalidpath") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "invalid URL path should return 400" + ); +}