فهرست منبع

web tests: split API round-trips into api_tests.rs (500-line cap)

P2/P3/P4 endpoint tests moved out of tests.rs (527 lines); helpers
stay put as pub(crate). Same repo pattern as cluster_state/power tests.
Alexander Korolev 2 ماه پیش
والد
کامیت
887bcac7ba
3فایلهای تغییر یافته به همراه244 افزوده شده و 233 حذف شده
  1. 238 0
      src/web/api_tests.rs
  2. 2 0
      src/web/mod.rs
  3. 4 233
      src/web/tests.rs

+ 238 - 0
src/web/api_tests.rs

@@ -0,0 +1,238 @@
+//! Web API round-trip tests (P2 actions, P3 log windows, P4 raw content) —
+//! split from `tests.rs` at the 500-line cap. Shared helpers live there.
+
+use super::tests::{app_state, boot, call, fake_config};
+use crate::web::events::Event;
+use axum::http::StatusCode;
+
+// --- P2 -----------------------------------------------------------------------
+
+#[tokio::test]
+async fn single_flight_rejects_duplicate_then_allows_after_finish() {
+    let app = app_state(None);
+    let id = crate::web::jobs::new_job(&app, "serve", "c1", Some("r1")).unwrap();
+    let dup = crate::web::jobs::new_job(&app, "serve", "c1", Some("r1"));
+    assert!(matches!(dup, Err((StatusCode::CONFLICT, _))));
+    // a different recipe on the same cluster is fine
+    let id2 = crate::web::jobs::new_job(&app, "serve", "c1", Some("r2")).unwrap();
+    assert_ne!(id, id2);
+    // stop is a different kind: allowed even while serve r1 runs
+    let _id3 = crate::web::jobs::new_job(&app, "stop", "c1", Some("r1")).unwrap();
+    crate::web::jobs::finish_job(&app, &id, true, "done".into());
+    // after r1's serve finished, a fresh serve r1 is allowed
+    let id4 = crate::web::jobs::new_job(&app, "serve", "c1", Some("r1")).unwrap();
+    assert_ne!(id, id4);
+}
+
+#[tokio::test]
+async fn recipes_endpoint_lists_repo_recipes() {
+    let (addr, _h) = boot().await;
+    let res = call(addr, "/api/recipes").await;
+    assert_eq!(res.status(), StatusCode::OK);
+    let v: serde_json::Value = res.json().await.unwrap();
+    let arr = v["recipes"].as_array().unwrap();
+    assert!(!arr.is_empty(), "repo recipes/ should be discoverable");
+    let names: Vec<&str> = arr.iter().map(|r| r["name"].as_str().unwrap()).collect();
+    assert!(
+        names.contains(&"qwen38-27b-nvfp4-unsloth"),
+        "got: {names:?}"
+    );
+}
+
+#[tokio::test]
+async fn serve_endpoint_registers_job_and_rejects_concurrent() {
+    let (addr, _h) = boot().await;
+    let body = serde_json::json!({ "cluster": "c1", "recipe": "r1", "dry_run": true });
+    let res = reqwest::Client::new()
+        .post(format!("http://{addr}/api/serve"))
+        .json(&body)
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), StatusCode::OK);
+    let v: serde_json::Value = res.json().await.unwrap();
+    let id = v["job"].as_str().unwrap().to_string();
+    // the dry-run job targets the fake config's clusters? No — "c1" doesn't
+    // exist in the real config; the job will fail asynchronously (cluster
+    // resolution error). Single-flight still holds while it runs.
+    let res2 = reqwest::Client::new()
+        .post(format!("http://{addr}/api/serve"))
+        .json(&body)
+        .send()
+        .await
+        .unwrap();
+    // Either 409 (still running) or 200 (already finished and released).
+    assert!(
+        matches!(res2.status(), StatusCode::CONFLICT | StatusCode::OK),
+        "unexpected status {}",
+        res2.status()
+    );
+    let _ = id;
+}
+
+
+// --- P3: log windows -----------------------------------------------------------
+
+#[tokio::test]
+async fn log_attach_get_detach_roundtrip() {
+    let (addr, _h) = boot().await;
+    let base = format!("http://{addr}");
+    let client = reqwest::Client::new();
+
+    // Attach on the fake cluster (node n0 at a refused port: the stream task
+    // dies immediately, but the registry wiring is what's under test).
+    let res = client
+        .post(format!("{base}/api/logs"))
+        .json(
+            &serde_json::json!({ "cluster": "fake", "node": "n0", "recipe": "deepseek-v4-flash" }),
+        )
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), 200, "attach ok");
+    let body: serde_json::Value = res.json().await.unwrap();
+    let attach_id = body["attach_id"].as_str().unwrap().to_string();
+    assert_eq!(attach_id, "l1");
+    assert!(body["history"].is_array());
+
+    // Idempotent: same key -> same attach.
+    let res = client
+        .post(format!("{base}/api/logs"))
+        .json(
+            &serde_json::json!({ "cluster": "fake", "node": "n0", "recipe": "deepseek-v4-flash" }),
+        )
+        .send()
+        .await
+        .unwrap();
+    let body: serde_json::Value = res.json().await.unwrap();
+    assert_eq!(body["attach_id"], attach_id);
+
+    // History endpoint (empty ring — the SSH stream failed on the refused port).
+    let res = client
+        .get(format!(
+            "{base}/api/logs?cluster=fake&node=n0&recipe=deepseek-v4-flash"
+        ))
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), 200);
+    let body: serde_json::Value = res.json().await.unwrap();
+    assert_eq!(body["attach_id"], attach_id);
+    assert!(body["history"].as_array().unwrap().is_empty());
+
+    // Unknown cluster -> 400.
+    let res = client
+        .post(format!("{base}/api/logs"))
+        .json(&serde_json::json!({ "cluster": "nope", "node": "n0", "recipe": "r" }))
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), 400);
+
+    // Detach -> 204, then 404.
+    let res = client
+        .delete(format!("{base}/api/logs"))
+        .json(&serde_json::json!({ "attach_id": attach_id }))
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), 204);
+    let res = client
+        .delete(format!("{base}/api/logs"))
+        .json(&serde_json::json!({ "attach_id": attach_id }))
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), 404);
+}
+
+#[tokio::test]
+async fn log_event_serializes_with_type_tag() {
+    let ev = Event::Log {
+        attach: "l1".into(),
+        cluster: "fake".into(),
+        node: "n0".into(),
+        recipe: "r".into(),
+        rank: 0,
+        line: "hello".into(),
+    };
+    let json = serde_json::to_value(&ev).unwrap();
+    assert_eq!(json["type"], "log");
+    assert_eq!(json["attach"], "l1");
+    assert_eq!(json["line"], "hello");
+}
+
+// --- P4: raw content -------------------------------------------------------------
+
+#[tokio::test]
+async fn raw_endpoints_serve_config_recipe_effective() {
+    let (addr, _h) = boot().await;
+    let base = format!("http://{addr}");
+    let client = reqwest::Client::new();
+
+    // The active config file verbatim (cwd `config.toml` in tests).
+    let res = client
+        .get(format!("{base}/api/raw/config"))
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), 200, "repo config.toml readable");
+    assert!(
+        res.text().await.unwrap().contains("ssh_user"),
+        "config verbatim"
+    );
+
+    // Effective: merged config as JSON; the fake cluster is present and the
+    // (absent) HA token never leaks.
+    let res = client
+        .get(format!("{base}/api/raw/effective"))
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), 200);
+    let body: serde_json::Value = res.json().await.unwrap();
+    let names: Vec<&str> = body["clusters"]
+        .as_array()
+        .unwrap()
+        .iter()
+        .map(|c| c["name"].as_str().unwrap())
+        .collect();
+    assert!(names.contains(&"fake"), "{names:?}");
+    assert_eq!(body["ssh_user"], "u");
+    assert!(
+        body.get("home_assistant")
+            .map(|v| v.is_null())
+            .unwrap_or(true)
+    );
+
+    // Recipe: a real recipe from the repo (tests run from the crate root).
+    let res = client
+        .get(format!("{base}/api/raw/recipe/deepseek-v4-flash"))
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), 200, "repo recipe readable");
+    let text = res.text().await.unwrap();
+    assert!(text.contains("engine"), "recipe.toml verbatim");
+
+    // Unknown recipe 404s; path traversal is refused before any file access.
+    assert_eq!(
+        client
+            .get(format!("{base}/api/raw/recipe/no-such-recipe"))
+            .send()
+            .await
+            .unwrap()
+            .status(),
+        404
+    );
+    assert_eq!(
+        client
+            .get(format!("{base}/api/raw/recipe/..%2F..%2Fetc"))
+            .send()
+            .await
+            .unwrap()
+            .status(),
+        400,
+        "traversal name refused"
+    );
+}

+ 2 - 0
src/web/mod.rs

@@ -209,5 +209,7 @@ pub async fn run(
     Ok(())
 }
 
+#[cfg(test)]
+mod api_tests;
 #[cfg(test)]
 mod tests;

+ 4 - 233
src/web/tests.rs

@@ -8,7 +8,7 @@ use axum::http::StatusCode;
 use std::collections::HashMap;
 use std::net::SocketAddr;
 
-async fn call(addr: SocketAddr, path: &str) -> reqwest::Response {
+pub(crate) async fn call(addr: SocketAddr, path: &str) -> reqwest::Response {
     reqwest::Client::new()
         .get(format!("http://{addr}{path}"))
         .send()
@@ -49,7 +49,7 @@ pub(crate) fn fake_config() -> Config {
     }
 }
 
-fn app_state(token: Option<&str>) -> AppState {
+pub(crate) fn app_state(token: Option<&str>) -> AppState {
     app_state_with(fake_config(), token)
 }
 
@@ -65,7 +65,7 @@ fn app_state_with(config: Config, token: Option<&str>) -> AppState {
     }
 }
 
-async fn boot() -> (SocketAddr, std::sync::Arc<tokio::task::JoinHandle<()>>) {
+pub(crate) async fn boot() -> (SocketAddr, std::sync::Arc<tokio::task::JoinHandle<()>>) {
     boot_with(fake_config()).await
 }
 
@@ -170,70 +170,7 @@ async fn snapshot_serializes_with_type_tag() {
     assert!(json["snapshot"].is_object());
 }
 
-// --- P2 -----------------------------------------------------------------------
-
-#[tokio::test]
-async fn single_flight_rejects_duplicate_then_allows_after_finish() {
-    let app = app_state(None);
-    let id = crate::web::jobs::new_job(&app, "serve", "c1", Some("r1")).unwrap();
-    let dup = crate::web::jobs::new_job(&app, "serve", "c1", Some("r1"));
-    assert!(matches!(dup, Err((StatusCode::CONFLICT, _))));
-    // a different recipe on the same cluster is fine
-    let id2 = crate::web::jobs::new_job(&app, "serve", "c1", Some("r2")).unwrap();
-    assert_ne!(id, id2);
-    // stop is a different kind: allowed even while serve r1 runs
-    let _id3 = crate::web::jobs::new_job(&app, "stop", "c1", Some("r1")).unwrap();
-    crate::web::jobs::finish_job(&app, &id, true, "done".into());
-    // after r1's serve finished, a fresh serve r1 is allowed
-    let id4 = crate::web::jobs::new_job(&app, "serve", "c1", Some("r1")).unwrap();
-    assert_ne!(id, id4);
-}
-
-#[tokio::test]
-async fn recipes_endpoint_lists_repo_recipes() {
-    let (addr, _h) = boot().await;
-    let res = call(addr, "/api/recipes").await;
-    assert_eq!(res.status(), StatusCode::OK);
-    let v: serde_json::Value = res.json().await.unwrap();
-    let arr = v["recipes"].as_array().unwrap();
-    assert!(!arr.is_empty(), "repo recipes/ should be discoverable");
-    let names: Vec<&str> = arr.iter().map(|r| r["name"].as_str().unwrap()).collect();
-    assert!(
-        names.contains(&"qwen38-27b-nvfp4-unsloth"),
-        "got: {names:?}"
-    );
-}
-
-#[tokio::test]
-async fn serve_endpoint_registers_job_and_rejects_concurrent() {
-    let (addr, _h) = boot().await;
-    let body = serde_json::json!({ "cluster": "c1", "recipe": "r1", "dry_run": true });
-    let res = reqwest::Client::new()
-        .post(format!("http://{addr}/api/serve"))
-        .json(&body)
-        .send()
-        .await
-        .unwrap();
-    assert_eq!(res.status(), StatusCode::OK);
-    let v: serde_json::Value = res.json().await.unwrap();
-    let id = v["job"].as_str().unwrap().to_string();
-    // the dry-run job targets the fake config's clusters? No — "c1" doesn't
-    // exist in the real config; the job will fail asynchronously (cluster
-    // resolution error). Single-flight still holds while it runs.
-    let res2 = reqwest::Client::new()
-        .post(format!("http://{addr}/api/serve"))
-        .json(&body)
-        .send()
-        .await
-        .unwrap();
-    // Either 409 (still running) or 200 (already finished and released).
-    assert!(
-        matches!(res2.status(), StatusCode::CONFLICT | StatusCode::OK),
-        "unexpected status {}",
-        res2.status()
-    );
-    let _ = id;
-}
+// (P2/P3/P4 API round-trip tests moved to api_tests.rs — 500-line cap)
 
 // --- UI (ui.html) -------------------------------------------------------------
 // The embedded page is vanilla JS with no build step; these tests run the
@@ -359,169 +296,3 @@ async fn ui_playwright_checks() {
     );
     eprintln!("{stdout}");
 }
-
-// --- P3: log windows -----------------------------------------------------------
-
-#[tokio::test]
-async fn log_attach_get_detach_roundtrip() {
-    let (addr, _h) = boot().await;
-    let base = format!("http://{addr}");
-    let client = reqwest::Client::new();
-
-    // Attach on the fake cluster (node n0 at a refused port: the stream task
-    // dies immediately, but the registry wiring is what's under test).
-    let res = client
-        .post(format!("{base}/api/logs"))
-        .json(
-            &serde_json::json!({ "cluster": "fake", "node": "n0", "recipe": "deepseek-v4-flash" }),
-        )
-        .send()
-        .await
-        .unwrap();
-    assert_eq!(res.status(), 200, "attach ok");
-    let body: serde_json::Value = res.json().await.unwrap();
-    let attach_id = body["attach_id"].as_str().unwrap().to_string();
-    assert_eq!(attach_id, "l1");
-    assert!(body["history"].is_array());
-
-    // Idempotent: same key -> same attach.
-    let res = client
-        .post(format!("{base}/api/logs"))
-        .json(
-            &serde_json::json!({ "cluster": "fake", "node": "n0", "recipe": "deepseek-v4-flash" }),
-        )
-        .send()
-        .await
-        .unwrap();
-    let body: serde_json::Value = res.json().await.unwrap();
-    assert_eq!(body["attach_id"], attach_id);
-
-    // History endpoint (empty ring — the SSH stream failed on the refused port).
-    let res = client
-        .get(format!(
-            "{base}/api/logs?cluster=fake&node=n0&recipe=deepseek-v4-flash"
-        ))
-        .send()
-        .await
-        .unwrap();
-    assert_eq!(res.status(), 200);
-    let body: serde_json::Value = res.json().await.unwrap();
-    assert_eq!(body["attach_id"], attach_id);
-    assert!(body["history"].as_array().unwrap().is_empty());
-
-    // Unknown cluster -> 400.
-    let res = client
-        .post(format!("{base}/api/logs"))
-        .json(&serde_json::json!({ "cluster": "nope", "node": "n0", "recipe": "r" }))
-        .send()
-        .await
-        .unwrap();
-    assert_eq!(res.status(), 400);
-
-    // Detach -> 204, then 404.
-    let res = client
-        .delete(format!("{base}/api/logs"))
-        .json(&serde_json::json!({ "attach_id": attach_id }))
-        .send()
-        .await
-        .unwrap();
-    assert_eq!(res.status(), 204);
-    let res = client
-        .delete(format!("{base}/api/logs"))
-        .json(&serde_json::json!({ "attach_id": attach_id }))
-        .send()
-        .await
-        .unwrap();
-    assert_eq!(res.status(), 404);
-}
-
-#[tokio::test]
-async fn log_event_serializes_with_type_tag() {
-    let ev = Event::Log {
-        attach: "l1".into(),
-        cluster: "fake".into(),
-        node: "n0".into(),
-        recipe: "r".into(),
-        rank: 0,
-        line: "hello".into(),
-    };
-    let json = serde_json::to_value(&ev).unwrap();
-    assert_eq!(json["type"], "log");
-    assert_eq!(json["attach"], "l1");
-    assert_eq!(json["line"], "hello");
-}
-
-// --- P4: raw content -------------------------------------------------------------
-
-#[tokio::test]
-async fn raw_endpoints_serve_config_recipe_effective() {
-    let (addr, _h) = boot().await;
-    let base = format!("http://{addr}");
-    let client = reqwest::Client::new();
-
-    // The active config file verbatim (cwd `config.toml` in tests).
-    let res = client
-        .get(format!("{base}/api/raw/config"))
-        .send()
-        .await
-        .unwrap();
-    assert_eq!(res.status(), 200, "repo config.toml readable");
-    assert!(
-        res.text().await.unwrap().contains("ssh_user"),
-        "config verbatim"
-    );
-
-    // Effective: merged config as JSON; the fake cluster is present and the
-    // (absent) HA token never leaks.
-    let res = client
-        .get(format!("{base}/api/raw/effective"))
-        .send()
-        .await
-        .unwrap();
-    assert_eq!(res.status(), 200);
-    let body: serde_json::Value = res.json().await.unwrap();
-    let names: Vec<&str> = body["clusters"]
-        .as_array()
-        .unwrap()
-        .iter()
-        .map(|c| c["name"].as_str().unwrap())
-        .collect();
-    assert!(names.contains(&"fake"), "{names:?}");
-    assert_eq!(body["ssh_user"], "u");
-    assert!(
-        body.get("home_assistant")
-            .map(|v| v.is_null())
-            .unwrap_or(true)
-    );
-
-    // Recipe: a real recipe from the repo (tests run from the crate root).
-    let res = client
-        .get(format!("{base}/api/raw/recipe/deepseek-v4-flash"))
-        .send()
-        .await
-        .unwrap();
-    assert_eq!(res.status(), 200, "repo recipe readable");
-    let text = res.text().await.unwrap();
-    assert!(text.contains("engine"), "recipe.toml verbatim");
-
-    // Unknown recipe 404s; path traversal is refused before any file access.
-    assert_eq!(
-        client
-            .get(format!("{base}/api/raw/recipe/no-such-recipe"))
-            .send()
-            .await
-            .unwrap()
-            .status(),
-        404
-    );
-    assert_eq!(
-        client
-            .get(format!("{base}/api/raw/recipe/..%2F..%2Fetc"))
-            .send()
-            .await
-            .unwrap()
-            .status(),
-        400,
-        "traversal name refused"
-    );
-}