Browse Source
Add HTTP integration tests for volume server handlers
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.rust-volume-server
1 changed files with 335 additions and 0 deletions
@ -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<VolumeServerState>, 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<u8> {
|
||||
|
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"
|
||||
|
);
|
||||
|
}
|
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue