Files
MyAiDesk/nomifun-tauri/crates/backend/nomifun-app/tests/requirements_e2e.rs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

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);
}