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