Files
taskpile/backend/tests/integration_test.rs
Alvis 9b77d6ea67 Add MLOps feature store, fix UI layout, add docs and Gitea remote
Backend:
- Replace on-the-fly Ollama calls with versioned feature store (task_features, task_edges)
- Background Tokio worker drains pending rows; write path returns immediately
- MLConfig versioning: changing model IDs triggers automatic backfill via next_stale()
- AppState with FromRef; new GET /api/ml/status observability endpoint
- Idempotent mark_pending (content hash guards), retry failed rows after 30s
- Remove tracked build artifacts (backend/target/, frontend/.next/, node_modules/)

Frontend:
- TaskItem: items-center alignment (fixes checkbox/text offset), break-words for overflow
- TaskDetailPanel: fix invisible AI context (text-gray-700→text-gray-400), show all fields
- TaskDetailPanel: pending placeholder when latent_desc not yet computed, show task ID
- GraphView: surface pending_count as amber pulsing "analyzing N tasks…" hint in legend
- Fix Task.created_at type (number/Unix seconds, not string)
- Auth gate: LoginPage + sessionStorage; fix e2e tests to bypass gate in jsdom
- Fix deleteTask test assertion and '1 remaining'→'1 left' stale text

Docs:
- VitePress docs in docs/ with guide, MLOps pipeline, and API reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:16:28 +00:00

210 lines
6.9 KiB
Rust

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::<Value>()["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());
}