use reqwest::StatusCode; use std::path::Path; use std::sync::atomic::{AtomicU32, Ordering}; static TEST_COUNTER: AtomicU32 = AtomicU32::new(0); /// Start the mkv server in-process on a random port with its own DB. /// Returns the base URL (e.g. "http://localhost:12345"). async fn start_server() -> String { let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed); let db_path = format!("/tmp/mkv-test/index-{id}.db"); // Clean up any previous test database let _ = std::fs::remove_file(&db_path); let _ = std::fs::remove_file(format!("{db_path}-wal")); let _ = std::fs::remove_file(format!("{db_path}-shm")); let mut config = mkv::config::Config::load(Path::new("tests/test_config.toml")).expect("load test config"); config.database.path = db_path; // Bind to port 0 to get a random available port let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); let app = mkv::build_app(config).await; tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); // Give the server a moment to start tokio::time::sleep(std::time::Duration::from_millis(50)).await; format!("http://127.0.0.1:{port}") } fn client() -> reqwest::Client { reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() .unwrap() } #[tokio::test] async fn test_put_and_head() { let base = start_server().await; let c = client(); // PUT a key let resp = c .put(format!("{base}/hello")) .body("world") .send() .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); // HEAD should return 200 with content-length let resp = c.head(format!("{base}/hello")).send().await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert_eq!( resp.headers() .get("content-length") .unwrap() .to_str() .unwrap(), "5" ); } #[tokio::test] async fn test_put_and_get_redirect() { let base = start_server().await; let c = client(); // PUT let resp = c .put(format!("{base}/redirect-test")) .body("some data") .send() .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); // GET should return 302 with Location header pointing to a volume let resp = c.get(format!("{base}/redirect-test")).send().await.unwrap(); assert_eq!(resp.status(), StatusCode::FOUND); let location = resp.headers().get("location").unwrap().to_str().unwrap(); assert!( location.starts_with("http://localhost:310"), "location should point to a volume, got: {location}" ); // Follow the redirect manually and verify the blob content let blob_resp = reqwest::get(location).await.unwrap(); assert_eq!(blob_resp.status(), StatusCode::OK); assert_eq!(blob_resp.text().await.unwrap(), "some data"); } #[tokio::test] async fn test_get_nonexistent_returns_404() { let base = start_server().await; let c = client(); let resp = c.get(format!("{base}/does-not-exist")).send().await.unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_put_get_delete_get() { let base = start_server().await; let c = client(); // PUT let resp = c .put(format!("{base}/delete-me")) .body("temporary") .send() .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); // GET → 302 let resp = c.get(format!("{base}/delete-me")).send().await.unwrap(); assert_eq!(resp.status(), StatusCode::FOUND); // DELETE → 204 let resp = c.delete(format!("{base}/delete-me")).send().await.unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); // GET after delete → 404 let resp = c.get(format!("{base}/delete-me")).send().await.unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_delete_nonexistent_returns_404() { let base = start_server().await; let c = client(); let resp = c .delete(format!("{base}/never-existed")) .send() .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_list_keys() { let base = start_server().await; let c = client(); // PUT a few keys with a common prefix for name in ["docs/a", "docs/b", "docs/c", "other/x"] { c.put(format!("{base}/{name}")) .body("data") .send() .await .unwrap(); } // List all let resp = c.get(format!("{base}/")).send().await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = resp.text().await.unwrap(); assert!(body.contains("docs/a")); assert!(body.contains("other/x")); // List with prefix filter let resp = c .get(format!("{base}/?prefix=docs/")) .send() .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = resp.text().await.unwrap(); let lines: Vec<&str> = body.lines().collect(); assert_eq!(lines.len(), 3); assert!(!body.contains("other/x")); } #[tokio::test] async fn test_put_overwrite() { let base = start_server().await; let c = client(); // PUT v1 c.put(format!("{base}/overwrite")) .body("version1") .send() .await .unwrap(); // PUT v2 (same key) let resp = c .put(format!("{base}/overwrite")) .body("version2") .send() .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); // HEAD should reflect new size let resp = c.head(format!("{base}/overwrite")).send().await.unwrap(); assert_eq!( resp.headers() .get("content-length") .unwrap() .to_str() .unwrap(), "8" ); // Follow redirect, verify content is v2 let resp = c.get(format!("{base}/overwrite")).send().await.unwrap(); let location = resp.headers().get("location").unwrap().to_str().unwrap(); let body = reqwest::get(location).await.unwrap().text().await.unwrap(); assert_eq!(body, "version2"); } #[tokio::test] async fn test_replication_writes_to_multiple_volumes() { let base = start_server().await; let c = client(); // PUT a key (replication_factor=2 in test config) c.put(format!("{base}/replicated")) .body("replica-data") .send() .await .unwrap(); // HEAD to confirm it exists let resp = c .head(format!("{base}/replicated")) .send() .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); // GET and verify the blob is accessible let resp = c .get(format!("{base}/replicated")) .send() .await .unwrap(); assert_eq!(resp.status(), StatusCode::FOUND); let location = resp.headers().get("location").unwrap().to_str().unwrap(); let body = reqwest::get(location).await.unwrap().text().await.unwrap(); assert_eq!(body, "replica-data"); }