- 任务类型改为「执行脚本 / 发起 HTTP 请求 / 运行二进制程序」,脚本类型下新增语言选择(Python / Shell / Node / Lua) - 语言联动表单:Python 才显示虚拟环境、解释器和 uv 状态;Shell/Node/Lua 隐藏这些字段并切换脚本标签、占位符和调用预览 - 切换语言时自动设置解释器与默认脚本,Shell 通过 cmd.exe /C 执行批处理,Node/Lua 使用对应解释器 - TaskAction 新增可选 language,旧任务按解释器推断;补 Shell 执行测试、语言切换单测与 Playwright 用例
369 lines
12 KiB
Rust
369 lines
12 KiB
Rust
use axum::{
|
|
Router,
|
|
body::Body,
|
|
http::{Method, Request, StatusCode, header},
|
|
response::Response,
|
|
};
|
|
use http_body_util::BodyExt;
|
|
use serde_json::{Value, json};
|
|
use sqlx::{
|
|
SqlitePool,
|
|
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
|
};
|
|
use task_pilot_backend::database;
|
|
use tempfile::TempDir;
|
|
use tower::ServiceExt;
|
|
|
|
async fn test_app() -> (Router, TempDir) {
|
|
let temp_dir = TempDir::new().expect("temporary directory should be created");
|
|
let pool = database::connect("sqlite::memory:")
|
|
.await
|
|
.expect("test database should initialize");
|
|
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
|
|
.await
|
|
.expect("test app should start");
|
|
(app, temp_dir)
|
|
}
|
|
|
|
async fn connect_file(path: &std::path::Path) -> SqlitePool {
|
|
let options = SqliteConnectOptions::new()
|
|
.filename(path)
|
|
.create_if_missing(true)
|
|
.foreign_keys(true);
|
|
let pool = SqlitePoolOptions::new()
|
|
.max_connections(1)
|
|
.connect_with(options)
|
|
.await
|
|
.expect("file database should open");
|
|
database::migrate(&pool)
|
|
.await
|
|
.expect("migrations should run");
|
|
pool
|
|
}
|
|
|
|
async fn send_json(app: &Router, method: Method, uri: &str, body: Option<Value>) -> Response {
|
|
let token = if matches!(method, Method::GET | Method::HEAD | Method::OPTIONS) {
|
|
None
|
|
} else {
|
|
Some(fetch_api_token(app).await)
|
|
};
|
|
let body = body
|
|
.map(|value| Body::from(serde_json::to_vec(&value).expect("JSON should serialize")))
|
|
.unwrap_or_else(Body::empty);
|
|
let mut builder = Request::builder()
|
|
.method(method)
|
|
.uri(uri)
|
|
.header(header::CONTENT_TYPE, "application/json");
|
|
if let Some(token) = token {
|
|
builder = builder.header("x-taskpilot-token", token);
|
|
}
|
|
let request = builder.body(body).expect("request should build");
|
|
|
|
app.clone()
|
|
.oneshot(request)
|
|
.await
|
|
.expect("router should respond")
|
|
}
|
|
|
|
async fn fetch_api_token(app: &Router) -> String {
|
|
let request = Request::builder()
|
|
.method(Method::GET)
|
|
.uri("/api/v1/session")
|
|
.body(Body::empty())
|
|
.expect("session request should build");
|
|
let response = app
|
|
.clone()
|
|
.oneshot(request)
|
|
.await
|
|
.expect("session endpoint should respond");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
response_json(response).await["token"]
|
|
.as_str()
|
|
.expect("session token should be a string")
|
|
.to_owned()
|
|
}
|
|
|
|
async fn response_json(response: Response) -> Value {
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("response body should be readable")
|
|
.to_bytes();
|
|
serde_json::from_slice(&body).expect("response should be JSON")
|
|
}
|
|
|
|
fn script_task(id: &str, name: &str) -> Value {
|
|
json!({
|
|
"id": id,
|
|
"name": name,
|
|
"cron": "0 0 2 * * *",
|
|
"enabled": true,
|
|
"timeout_secs": 300,
|
|
"action": {
|
|
"type": "script",
|
|
"working_dir": "D:\\Projects\\sync",
|
|
"interpreter": "python.exe",
|
|
"script": "main.py",
|
|
"args": ["--mode", "full"],
|
|
"env": {
|
|
"PYTHONUNBUFFERED": "1"
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_interpreter_defaults_to_python() {
|
|
let (app, _temp_dir) = test_app().await;
|
|
let mut payload = script_task("default_interpreter", "Default interpreter");
|
|
payload["action"]["interpreter"] = json!("");
|
|
|
|
let response = send_json(&app, Method::POST, "/api/v1/tasks", Some(payload)).await;
|
|
|
|
assert_eq!(response.status(), StatusCode::CREATED);
|
|
let created = response_json(response).await;
|
|
assert_eq!(created["action"]["interpreter"], "python.exe");
|
|
assert_eq!(created["action"]["language"], "python");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn task_crud_flow_works_end_to_end() {
|
|
let (app, _temp_dir) = test_app().await;
|
|
|
|
let create_response = send_json(
|
|
&app,
|
|
Method::POST,
|
|
"/api/v1/tasks",
|
|
Some(script_task("sync_data", "Data sync")),
|
|
)
|
|
.await;
|
|
assert_eq!(create_response.status(), StatusCode::CREATED);
|
|
let created = response_json(create_response).await;
|
|
assert_eq!(created["id"], "sync_data");
|
|
assert_eq!(created["timezone"], "Asia/Shanghai");
|
|
assert_eq!(created["action"]["type"], "script");
|
|
|
|
let get_response = send_json(&app, Method::GET, "/api/v1/tasks/sync_data", None).await;
|
|
assert_eq!(get_response.status(), StatusCode::OK);
|
|
assert_eq!(response_json(get_response).await["name"], "Data sync");
|
|
|
|
let list_response = send_json(
|
|
&app,
|
|
Method::GET,
|
|
"/api/v1/tasks?q=sync&enabled=true&action_type=script&page=1&page_size=10",
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(list_response.status(), StatusCode::OK);
|
|
let listed = response_json(list_response).await;
|
|
assert_eq!(listed["total"], 1);
|
|
assert_eq!(listed["items"][0]["id"], "sync_data");
|
|
|
|
let update_response = send_json(
|
|
&app,
|
|
Method::PUT,
|
|
"/api/v1/tasks/sync_data",
|
|
Some(json!({
|
|
"name": "Updated data sync",
|
|
"cron": "*/30 * * * * *",
|
|
"enabled": false,
|
|
"timeout_secs": 120,
|
|
"action": {
|
|
"type": "http",
|
|
"url": "https://example.com/health",
|
|
"method": "GET",
|
|
"headers": {},
|
|
"expected_status": [200]
|
|
}
|
|
})),
|
|
)
|
|
.await;
|
|
assert_eq!(update_response.status(), StatusCode::OK);
|
|
let updated = response_json(update_response).await;
|
|
assert_eq!(updated["name"], "Updated data sync");
|
|
assert_eq!(updated["enabled"], false);
|
|
assert_eq!(updated["action"]["type"], "http");
|
|
|
|
let delete_response = send_json(&app, Method::DELETE, "/api/v1/tasks/sync_data", None).await;
|
|
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
|
|
|
|
let missing_response = send_json(&app, Method::GET, "/api/v1/tasks/sync_data", None).await;
|
|
assert_eq!(missing_response.status(), StatusCode::NOT_FOUND);
|
|
let missing = response_json(missing_response).await;
|
|
assert_eq!(missing["code"], "TASK_NOT_FOUND");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn duplicate_task_id_returns_conflict() {
|
|
let (app, _temp_dir) = test_app().await;
|
|
let first = send_json(
|
|
&app,
|
|
Method::POST,
|
|
"/api/v1/tasks",
|
|
Some(script_task("duplicate", "First")),
|
|
)
|
|
.await;
|
|
assert_eq!(first.status(), StatusCode::CREATED);
|
|
|
|
let second = send_json(
|
|
&app,
|
|
Method::POST,
|
|
"/api/v1/tasks",
|
|
Some(script_task("duplicate", "Second")),
|
|
)
|
|
.await;
|
|
assert_eq!(second.status(), StatusCode::CONFLICT);
|
|
let body = response_json(second).await;
|
|
assert_eq!(body["code"], "TASK_ID_CONFLICT");
|
|
assert_eq!(body["field"], "id");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_cron_returns_structured_validation_error() {
|
|
let (app, _temp_dir) = test_app().await;
|
|
let mut request = script_task("invalid_cron", "Invalid cron");
|
|
request["cron"] = Value::String("*/0 * * * * *".to_owned());
|
|
|
|
let response = send_json(&app, Method::POST, "/api/v1/tasks", Some(request)).await;
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
let body = response_json(response).await;
|
|
assert_eq!(body["code"], "VALIDATION_ERROR");
|
|
assert_eq!(body["field"], "cron");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn malformed_json_and_query_return_structured_errors() {
|
|
let (app, _temp_dir) = test_app().await;
|
|
let token = fetch_api_token(&app).await;
|
|
let malformed_request = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/api/v1/tasks")
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.header("x-taskpilot-token", token)
|
|
.body(Body::from("{not-json"))
|
|
.expect("request should build");
|
|
let malformed_response = app
|
|
.clone()
|
|
.oneshot(malformed_request)
|
|
.await
|
|
.expect("router should respond");
|
|
assert_eq!(malformed_response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
response_json(malformed_response).await["code"],
|
|
"INVALID_JSON"
|
|
);
|
|
|
|
let invalid_query = send_json(&app, Method::GET, "/api/v1/tasks?enabled=maybe", None).await;
|
|
assert_eq!(invalid_query.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(response_json(invalid_query).await["code"], "INVALID_QUERY");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejects_cross_origin_and_missing_management_token() {
|
|
let (app, _temp_dir) = test_app().await;
|
|
let cross_origin = Request::builder()
|
|
.method(Method::GET)
|
|
.uri("/api/v1/tasks")
|
|
.header(header::ORIGIN, "https://evil.example")
|
|
.body(Body::empty())
|
|
.expect("request should build");
|
|
let response = app
|
|
.clone()
|
|
.oneshot(cross_origin)
|
|
.await
|
|
.expect("router should respond");
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
|
|
let missing_token = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/api/v1/tasks")
|
|
.header(header::ORIGIN, "http://127.0.0.1:5173")
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_vec(&script_task("blocked_task", "Blocked"))
|
|
.expect("JSON should serialize"),
|
|
))
|
|
.expect("request should build");
|
|
let response = app
|
|
.oneshot(missing_token)
|
|
.await
|
|
.expect("router should respond");
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
assert_eq!(response_json(response).await["code"], "FORBIDDEN");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_supports_pagination_and_escaped_search() {
|
|
let (app, _temp_dir) = test_app().await;
|
|
for (id, name) in [
|
|
("sync_one", "Sync one"),
|
|
("sync_two", "Sync two"),
|
|
("other", "100% complete"),
|
|
] {
|
|
let response = send_json(
|
|
&app,
|
|
Method::POST,
|
|
"/api/v1/tasks",
|
|
Some(script_task(id, name)),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::CREATED);
|
|
}
|
|
|
|
let first_page = send_json(
|
|
&app,
|
|
Method::GET,
|
|
"/api/v1/tasks?q=sync&page=1&page_size=1",
|
|
None,
|
|
)
|
|
.await;
|
|
let first_page = response_json(first_page).await;
|
|
assert_eq!(first_page["page"], 1);
|
|
assert_eq!(first_page["page_size"], 1);
|
|
assert_eq!(first_page["total"], 2);
|
|
assert_eq!(first_page["items"].as_array().map(Vec::len), Some(1));
|
|
|
|
let escaped = send_json(&app, Method::GET, "/api/v1/tasks?q=%25", None).await;
|
|
let escaped = response_json(escaped).await;
|
|
assert_eq!(escaped["total"], 1);
|
|
assert_eq!(escaped["items"][0]["id"], "other");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tasks_survive_database_reconnect() {
|
|
let temp_dir = TempDir::new().expect("temporary directory should be created");
|
|
let database_path = temp_dir.path().join("task_pilot_test.db");
|
|
|
|
let first_pool = connect_file(&database_path).await;
|
|
let first_app = task_pilot_backend::app(first_pool.clone(), temp_dir.path().join("logs-first"))
|
|
.await
|
|
.expect("first app should start");
|
|
let create_response = send_json(
|
|
&first_app,
|
|
Method::POST,
|
|
"/api/v1/tasks",
|
|
Some(script_task("persistent_task", "Persistent task")),
|
|
)
|
|
.await;
|
|
assert_eq!(create_response.status(), StatusCode::CREATED);
|
|
first_pool.close().await;
|
|
drop(first_app);
|
|
|
|
let second_pool = connect_file(&database_path).await;
|
|
let second_app =
|
|
task_pilot_backend::app(second_pool.clone(), temp_dir.path().join("logs-second"))
|
|
.await
|
|
.expect("second app should start");
|
|
let get_response = send_json(
|
|
&second_app,
|
|
Method::GET,
|
|
"/api/v1/tasks/persistent_task",
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(get_response.status(), StatusCode::OK);
|
|
assert_eq!(response_json(get_response).await["name"], "Persistent task");
|
|
second_pool.close().await;
|
|
}
|