task-track 0.6.1

A JJ workspace-based task and TODO management CLI tool
Documentation
use axum::body::Body;
use axum::http::{Request, StatusCode};
use std::sync::Arc;
use tower::ServiceExt;
use track::db::Database;
use track::services::{TaskService, TodoService};
use track::webui::{build_router, AppState, Templates, WebState};

fn test_router(db: Database) -> axum::Router {
    let app_state = AppState::from_database(db);
    let web_state = WebState {
        app: app_state,
        templates: Arc::new(Templates::embedded()),
    };
    build_router(web_state)
}

#[tokio::test]
async fn api_status_without_active_task_returns_empty_payload() {
    let db = Database::new_in_memory().unwrap();
    let app = test_router(db);

    let response = app
        .oneshot(
            Request::builder()
                .uri("/api/status")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);

    let body = http_body_util::BodyExt::collect(response.into_body())
        .await
        .unwrap()
        .to_bytes();
    let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert!(json.get("task").unwrap().is_null());
    assert_eq!(json.get("todos").unwrap().as_array().unwrap().len(), 0);
}

#[tokio::test]
async fn api_status_returns_current_task_todos() {
    let db = Database::new_in_memory().unwrap();
    let task_service = TaskService::new(&db);
    let task = task_service
        .create_task("Web task", None, None, None)
        .unwrap();
    db.set_current_task_id(task.id).unwrap();

    let todo_service = TodoService::new(&db);
    todo_service
        .add_todo(task.id, "From browser", false)
        .unwrap();

    let app = test_router(db);

    let response = app
        .oneshot(
            Request::builder()
                .uri("/api/status")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);

    let body = http_body_util::BodyExt::collect(response.into_body())
        .await
        .unwrap()
        .to_bytes();
    let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert_eq!(json.get("task").unwrap()["name"], "Web task");
    assert_eq!(json.get("todos").unwrap().as_array().unwrap().len(), 1);
}

#[tokio::test]
async fn api_status_includes_workflow_fields() {
    let db = Database::new_in_memory().unwrap();
    let task_service = TaskService::new(&db);
    let task = task_service
        .create_task("Web task", None, None, None)
        .unwrap();
    db.set_current_task_id(task.id).unwrap();

    let todo_service = TodoService::new(&db);
    todo_service
        .add_todo(task.id, "From browser", false)
        .unwrap();

    let app = test_router(db);

    let response = app
        .oneshot(
            Request::builder()
                .uri("/api/status")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);

    let body = http_body_util::BodyExt::collect(response.into_body())
        .await
        .unwrap()
        .to_bytes();
    let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert_eq!(json["workflow"]["phase"], "setup");
    assert_eq!(json["vcs_mode"], "jj");
    assert!(json["guardrails"]["reopen_forbidden"].as_bool().unwrap());
    assert!(json["guardrails"]["must_use_jj_skill"].as_bool().unwrap());
    assert_eq!(json["jj"]["skill"], "jj");
    assert!(json.get("git").is_none());
    assert_eq!(json["todos_agent"].as_array().unwrap().len(), 1);
}

#[tokio::test]
async fn add_todo_rejects_empty_content() {
    let db = Database::new_in_memory().unwrap();
    let task_service = TaskService::new(&db);
    let task = task_service
        .create_task("Web task", None, None, None)
        .unwrap();
    db.set_current_task_id(task.id).unwrap();

    let app = test_router(db);

    let response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/todo")
                .header("content-type", "application/x-www-form-urlencoded")
                .body(Body::from("content=%20%20%20"))
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn add_scrap_rejects_empty_content() {
    let db = Database::new_in_memory().unwrap();
    let task_service = TaskService::new(&db);
    let task = task_service
        .create_task("Web task", None, None, None)
        .unwrap();
    db.set_current_task_id(task.id).unwrap();

    let app = test_router(db);

    let response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/scrap")
                .header("content-type", "application/x-www-form-urlencoded")
                .body(Body::from("content=%20%20"))
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn update_todo_rejects_reopen_from_done() {
    let db = Database::new_in_memory().unwrap();
    let task_service = TaskService::new(&db);
    let task = task_service
        .create_task("Web task", None, None, None)
        .unwrap();
    db.set_current_task_id(task.id).unwrap();

    let todo_service = TodoService::new(&db);
    let todo = todo_service.add_todo(task.id, "Done item", false).unwrap();
    todo_service.update_status(todo.id, "done").unwrap();

    let app = test_router(db);

    let response = app
        .oneshot(
            Request::builder()
                .method("PATCH")
                .uri("/api/todo/1/pending")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::BAD_REQUEST);

    let body = http_body_util::BodyExt::collect(response.into_body())
        .await
        .unwrap()
        .to_bytes();
    let text = String::from_utf8(body.to_vec()).unwrap();
    assert!(text.contains("cannot be reopened"));
}

#[tokio::test]
async fn index_renders_without_active_task() {
    let db = Database::new_in_memory().unwrap();
    let app = test_router(db);

    let response = app
        .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);

    let body = http_body_util::BodyExt::collect(response.into_body())
        .await
        .unwrap()
        .to_bytes();
    let html = String::from_utf8(body.to_vec()).unwrap();
    assert!(html.contains("<html") || html.contains("track"));
}