use axum::{ routing::{delete, get, patch, post}, Router, }; use axum_test::TestServer; use serde_json::{json, Value}; use sqlx::SqlitePool; use std::sync::Arc; use taskpile_backend::{db, ml::MLConfig, state::AppState}; use tokio::sync::Notify; /// Build a TestServer against an in-memory SQLite. We deliberately do NOT /// spawn the ML worker — tests must not depend on Ollama being reachable. /// Feature rows will sit in `pending` forever, which is exactly what we want /// to assert the pure-read graph endpoint behaves correctly in that state. async fn setup_server() -> TestServer { let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); db::run_migrations(&pool).await.unwrap(); let cfg = Arc::new(MLConfig::default()); let notify = Arc::new(Notify::new()); let state = AppState::new(pool, cfg, notify); let app = Router::new() .route("/api/tasks", get(taskpile_backend::routes::tasks::list_tasks)) .route("/api/tasks", post(taskpile_backend::routes::tasks::create_task)) .route("/api/tasks/:id", patch(taskpile_backend::routes::tasks::update_task)) .route("/api/tasks/:id", delete(taskpile_backend::routes::tasks::delete_task)) .route("/api/graph", get(taskpile_backend::routes::graph::get_graph)) .route("/api/ml/status", get(taskpile_backend::routes::ml::get_ml_status)) .with_state(state); TestServer::new(app).unwrap() } #[tokio::test] async fn test_list_tasks_empty() { let server = setup_server().await; let resp = server.get("/api/tasks").await; resp.assert_status_ok(); let body: Value = resp.json(); assert_eq!(body, json!([])); } #[tokio::test] async fn test_create_task() { let server = setup_server().await; let resp = server .post("/api/tasks") .json(&json!({"title": "Buy milk"})) .await; resp.assert_status(axum::http::StatusCode::CREATED); let body: Value = resp.json(); assert_eq!(body["title"], "Buy milk"); assert_eq!(body["completed"], false); assert!(body["id"].is_string()); } #[tokio::test] async fn test_create_task_seeds_feature_row() { // Creating a task should immediately insert a `pending` feature row so // the worker picks it up — the write path shouldn't block on inference. let server = setup_server().await; server .post("/api/tasks") .json(&json!({"title": "Write docs"})) .await; let resp = server.get("/api/ml/status").await; resp.assert_status_ok(); let status: Value = resp.json(); assert_eq!(status["pending"], 1); assert_eq!(status["ready"], 0); assert_eq!(status["failed"], 0); } #[tokio::test] async fn test_crud_flow() { let server = setup_server().await; // Create let resp = server .post("/api/tasks") .json(&json!({"title": "Write tests", "description": "Important"})) .await; resp.assert_status(axum::http::StatusCode::CREATED); let created: Value = resp.json(); let id = created["id"].as_str().unwrap().to_string(); // List let resp = server.get("/api/tasks").await; resp.assert_status_ok(); let tasks: Value = resp.json(); assert_eq!(tasks.as_array().unwrap().len(), 1); // Update let resp = server .patch(&format!("/api/tasks/{id}")) .json(&json!({"completed": true})) .await; resp.assert_status_ok(); let updated: Value = resp.json(); assert_eq!(updated["completed"], true); assert_eq!(updated["title"], "Write tests"); // Graph — nodes present, edges empty (worker not running in tests). let resp = server.get("/api/graph").await; resp.assert_status_ok(); let graph: Value = resp.json(); assert_eq!(graph["nodes"].as_array().unwrap().len(), 1); assert!(graph["edges"].is_array()); assert_eq!(graph["edges"].as_array().unwrap().len(), 0); assert_eq!(graph["pending_count"], 1); // Delete let resp = server.delete(&format!("/api/tasks/{id}")).await; resp.assert_status(axum::http::StatusCode::NO_CONTENT); // List again - empty let resp = server.get("/api/tasks").await; resp.assert_status_ok(); let tasks: Value = resp.json(); assert_eq!(tasks.as_array().unwrap().len(), 0); } #[tokio::test] async fn test_delete_cascades_to_features() { // Deleting a task should cascade to task_features via FK ON DELETE. let server = setup_server().await; let resp = server .post("/api/tasks") .json(&json!({"title": "Temp"})) .await; let id = resp.json::()["id"].as_str().unwrap().to_string(); server.delete(&format!("/api/tasks/{id}")).await; let resp = server.get("/api/ml/status").await; let status: Value = resp.json(); assert_eq!(status["pending"], 0); assert_eq!(status["ready"], 0); assert_eq!(status["failed"], 0); } #[tokio::test] async fn test_delete_nonexistent_returns_404() { let server = setup_server().await; let resp = server.delete("/api/tasks/does-not-exist").await; resp.assert_status(axum::http::StatusCode::NOT_FOUND); } #[tokio::test] async fn test_graph_endpoint_structure() { let server = setup_server().await; let resp = server.get("/api/graph").await; resp.assert_status_ok(); let graph: Value = resp.json(); assert!(graph["nodes"].is_array()); assert!(graph["edges"].is_array()); assert_eq!(graph["pending_count"], 0); } #[tokio::test] async fn test_create_task_with_description() { let server = setup_server().await; let resp = server .post("/api/tasks") .json(&json!({"title": "Task with desc", "description": "Some details"})) .await; resp.assert_status(axum::http::StatusCode::CREATED); let body: Value = resp.json(); assert_eq!(body["description"], "Some details"); } #[tokio::test] async fn test_graph_nodes_present_without_worker() { // Without a worker running, features never become ready, so the graph // contains all nodes but no edges. This is the intended degraded mode: // users still see their tasks even if Ollama is down. let server = setup_server().await; for i in 0..10 { server .post("/api/tasks") .json(&json!({"title": format!("Task {i}")})) .await; } let resp = server.get("/api/graph").await; resp.assert_status_ok(); let graph: Value = resp.json(); assert_eq!(graph["nodes"].as_array().unwrap().len(), 10); assert_eq!(graph["edges"].as_array().unwrap().len(), 0); assert_eq!(graph["pending_count"], 10); } #[tokio::test] async fn test_ml_status_reports_config() { let server = setup_server().await; let resp = server.get("/api/ml/status").await; resp.assert_status_ok(); let status: Value = resp.json(); assert!(status["desc_model"].is_string()); assert!(status["embed_model"].is_string()); assert!(status["prompt_version"].is_string()); assert!(status["min_similarity"].is_number()); }