- Foldable left panel (user profile) and right panel (task details) - Clicking a task in the list or graph node selects it and shows details - Both views (task list + graph) always mounted via absolute inset-0 for correct canvas dimensions; tabs toggle visibility with opacity - Graph node selection animation: other nodes repel outward (charge -600), then selected node smoothly slides to center (500ms cubic ease-out), then charge restores to -120 and graph stabilizes - Graph re-fits on tab switch and panel resize via ResizeObserver - Fix UUID string IDs throughout (backend returns UUIDs, not integers) - Add TaskDetailPanel, UserPanel components - Add CLAUDE.md project documentation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
154 lines
4.7 KiB
Rust
154 lines
4.7 KiB
Rust
use axum::{
|
|
routing::{delete, get, patch, post},
|
|
Router,
|
|
};
|
|
use axum_test::TestServer;
|
|
use serde_json::{json, Value};
|
|
use sqlx::SqlitePool;
|
|
|
|
async fn setup_server() -> TestServer {
|
|
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
|
sqlx::query(
|
|
r#"
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
title TEXT NOT NULL,
|
|
description TEXT,
|
|
completed BOOLEAN NOT NULL DEFAULT 0,
|
|
created_at INTEGER NOT NULL
|
|
)
|
|
"#,
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
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))
|
|
.with_state(pool);
|
|
|
|
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_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
|
|
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());
|
|
|
|
// 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_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());
|
|
}
|
|
|
|
#[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_with_multiple_tasks_has_edges() {
|
|
let server = setup_server().await;
|
|
// Create enough tasks to statistically guarantee edges (~30% of pairs)
|
|
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);
|
|
// 45 possible pairs at 30% => highly likely to have at least 1
|
|
let edge_count = graph["edges"].as_array().unwrap().len();
|
|
assert!(edge_count <= 45);
|
|
}
|