f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
435 lines
13 KiB
Rust
435 lines
13 KiB
Rust
//! E2E tests for the Requirements Platform HTTP endpoints.
|
|
|
|
mod common;
|
|
|
|
use axum::http::StatusCode;
|
|
use serde_json::json;
|
|
use tower::ServiceExt;
|
|
|
|
use common::{body_json, build_app, delete_with_token, get_request, get_with_token, json_with_token, setup_and_login};
|
|
|
|
#[tokio::test]
|
|
async fn unauthenticated_list_is_rejected() {
|
|
let (app, _services) = build_app().await;
|
|
let resp = app.oneshot(get_request("/api/requirements")).await.unwrap();
|
|
assert!(
|
|
resp.status() == StatusCode::UNAUTHORIZED || resp.status() == StatusCode::FORBIDDEN,
|
|
"expected 401/403, got {}",
|
|
resp.status()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_list_get_update_delete_happy_path() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
// create
|
|
let body = json!({ "title": "E2E", "content": "x", "tag": "e2e", "order_key": "1" });
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token("POST", "/api/requirements", body, &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["status"], "pending");
|
|
let id = json["data"]["id"].as_i64().unwrap();
|
|
|
|
// list (filtered by tag)
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token("/api/requirements?tag=e2e", &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["total"], 1);
|
|
assert_eq!(json["data"]["items"][0]["id"], id);
|
|
|
|
// get
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token(&format!("/api/requirements/{id}"), &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
assert_eq!(body_json(resp).await["data"]["id"], id);
|
|
|
|
// update → done
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token(
|
|
"PUT",
|
|
&format!("/api/requirements/{id}"),
|
|
json!({ "status": "done", "completion_note": "ok" }),
|
|
&token,
|
|
&csrf,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
assert_eq!(body_json(resp).await["data"]["status"], "done");
|
|
|
|
// board
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token("/api/requirements/board?tag=e2e", &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["done"].as_array().unwrap().len(), 1);
|
|
|
|
// tags
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token("/api/requirements/tags", &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let json = body_json(resp).await;
|
|
let e2e = json["data"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|t| t["tag"] == "e2e")
|
|
.expect("e2e tag summary present");
|
|
assert_eq!(e2e["done"], 1);
|
|
|
|
// delete
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(delete_with_token(&format!("/api/requirements/{id}"), &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
// get → 404
|
|
let resp = app
|
|
.oneshot(get_with_token(&format!("/api/requirements/{id}"), &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_missing_title_is_400() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
let resp = app
|
|
.oneshot(json_with_token(
|
|
"POST",
|
|
"/api/requirements",
|
|
json!({ "title": "", "tag": "e2e" }),
|
|
&token,
|
|
&csrf,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(body_json(resp).await["code"], "BAD_REQUEST");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_unknown_is_404() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
let resp = app
|
|
.oneshot(get_with_token("/api/requirements/999999", &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
assert_eq!(body_json(resp).await["code"], "NOT_FOUND");
|
|
}
|
|
|
|
/// Seed a conversation row so `requirements.conversation_id` FK (set by claim) holds.
|
|
async fn seed_conversation(services: &nomifun_app::AppServices, conv_id: i64) {
|
|
sqlx::query(
|
|
"INSERT INTO conversations (id, user_id, name, type, extra, created_at, updated_at) \
|
|
VALUES (?, 'system_default_user', 'Dispatch Conv', 'nomi', '{}', 0, 0)",
|
|
)
|
|
.bind(conv_id)
|
|
.execute(services.database.pool())
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn claim_complete_and_drain() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
let conv = 1;
|
|
seed_conversation(&services, conv).await;
|
|
|
|
// Seed two requirements in tag "disp".
|
|
for (title, order) in [("A", "1"), ("B", "2")] {
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token(
|
|
"POST",
|
|
"/api/requirements",
|
|
json!({ "title": title, "tag": "disp", "order_key": order }),
|
|
&token,
|
|
&csrf,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
}
|
|
|
|
// Claim → lowest order (A) goes in_progress.
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token(
|
|
"POST",
|
|
"/api/requirements/claim",
|
|
json!({ "tag": "disp", "conversation_id": conv }),
|
|
&token,
|
|
&csrf,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["title"], "A");
|
|
assert_eq!(json["data"]["status"], "in_progress");
|
|
let a_id = json["data"]["id"].as_i64().unwrap();
|
|
|
|
// Complete A.
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token(
|
|
"POST",
|
|
&format!("/api/requirements/{a_id}/complete"),
|
|
json!({ "completion_note": "ok" }),
|
|
&token,
|
|
&csrf,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
assert_eq!(body_json(resp).await["data"]["status"], "done");
|
|
|
|
// Claim again → B.
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token(
|
|
"POST",
|
|
"/api/requirements/claim",
|
|
json!({ "tag": "disp", "conversation_id": conv }),
|
|
&token,
|
|
&csrf,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
assert_eq!(body_json(resp).await["data"]["title"], "B");
|
|
|
|
// Claim again → drained (data == null).
|
|
let resp = app
|
|
.oneshot(json_with_token(
|
|
"POST",
|
|
"/api/requirements/claim",
|
|
json!({ "tag": "disp", "conversation_id": conv }),
|
|
&token,
|
|
&csrf,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
assert!(body_json(resp).await["data"].is_null(), "tag drained → null");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn set_autowork_requires_tag_when_enabled() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
let conv = "1";
|
|
seed_conversation(&services, conv.parse().unwrap()).await;
|
|
|
|
// enabled without tag → 400.
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token(
|
|
"POST",
|
|
"/api/requirements/autowork",
|
|
json!({ "target_id": conv, "enabled": true }),
|
|
&token,
|
|
&csrf,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
|
|
// disabled → 200, not running, run_state off.
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token(
|
|
"POST",
|
|
"/api/requirements/autowork",
|
|
json!({ "target_id": conv, "enabled": false }),
|
|
&token,
|
|
&csrf,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["enabled"], false);
|
|
assert_eq!(json["data"]["running"], false);
|
|
assert_eq!(json["data"]["run_state"], "off");
|
|
assert_eq!(json["data"]["kind"], "conversation");
|
|
|
|
// GET reflects disabled (kind/target_id path form).
|
|
let resp = app
|
|
.oneshot(get_with_token(
|
|
&format!("/api/requirements/autowork/conversation/{conv}"),
|
|
&token,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
assert_eq!(body_json(resp).await["data"]["enabled"], false);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn terminal_autowork_unknown_terminal_is_not_found() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
// Enabling AutoWork on a non-existent terminal → ownership check 404.
|
|
let resp = app
|
|
.oneshot(json_with_token(
|
|
"POST",
|
|
"/api/requirements/autowork",
|
|
json!({ "kind": "terminal", "target_id": "term_missing", "enabled": true, "tag": "x" }),
|
|
&token,
|
|
&csrf,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn autowork_unknown_kind_is_bad_request() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let resp = app
|
|
.oneshot(get_with_token("/api/requirements/autowork/bogus/term_1", &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn terminal_autowork_rejects_plain_shell() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
// Create a plain-shell terminal (no agent backend).
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token(
|
|
"POST",
|
|
"/api/terminals",
|
|
json!({ "cwd": std::env::temp_dir().to_string_lossy(), "command": "$SHELL", "cols": 80, "rows": 24 }),
|
|
&token,
|
|
&csrf,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
let term_id = body_json(resp).await["data"]["id"].as_i64().unwrap().to_string();
|
|
|
|
// Enabling AutoWork on a plain shell → eligibility check 400.
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token(
|
|
"POST",
|
|
"/api/requirements/autowork",
|
|
json!({ "kind": "terminal", "target_id": term_id, "enabled": true, "tag": "x" }),
|
|
&token,
|
|
&csrf,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
|
|
// Cleanup: kill/remove the spawned shell.
|
|
let _ = app
|
|
.oneshot(json_with_token(
|
|
"DELETE",
|
|
&format!("/api/terminals/{term_id}"),
|
|
json!({}),
|
|
&token,
|
|
&csrf,
|
|
))
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn batch_delete_removes_selected() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
// Create three requirements; collect their ids.
|
|
let mut ids = Vec::new();
|
|
for (title, order) in [("A", "1"), ("B", "2"), ("C", "3")] {
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token(
|
|
"POST",
|
|
"/api/requirements",
|
|
json!({ "title": title, "tag": "batch", "order_key": order }),
|
|
&token,
|
|
&csrf,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
let id = body_json(resp).await["data"]["id"].as_i64().unwrap();
|
|
ids.push(id);
|
|
}
|
|
|
|
// Batch-delete the first two (plus a non-existent id, which is skipped).
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token(
|
|
"POST",
|
|
"/api/requirements/batch-delete",
|
|
json!({ "ids": [ids[0], ids[1], 999999] }),
|
|
&token,
|
|
&csrf,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
assert_eq!(body_json(resp).await["data"]["deleted"], 2);
|
|
|
|
// Only "C" remains.
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token("/api/requirements?tag=batch", &token))
|
|
.await
|
|
.unwrap();
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["total"], 1);
|
|
assert_eq!(json["data"]["items"][0]["title"], "C");
|
|
|
|
// Empty ids → 400.
|
|
let resp = app
|
|
.oneshot(json_with_token(
|
|
"POST",
|
|
"/api/requirements/batch-delete",
|
|
json!({ "ids": [] }),
|
|
&token,
|
|
&csrf,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|