oxinbox-backend 0.1.11

HTTP/WS API server for the oxinbox GTD productivity system
Documentation
#![allow(clippy::significant_drop_tightening)]

use std::collections::HashMap;
use std::sync::Arc;

use oxinbox_backend::auth::AuthState;
use oxinbox_backend::push::PushService;
use oxinbox_backend::repository::InMemoryTaskRepository;
use oxinbox_backend::routes;
use tokio::sync::RwLock;
use url::Url;
use webauthn_rs::prelude::*;

static TEST_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());

struct TestApp {
    client: reqwest::Client,
    base_url: String,
    pub token: String,
    _shutdown: tokio::sync::oneshot::Sender<()>,
    _guard: tokio::sync::MutexGuard<'static, ()>,
}

impl TestApp {
    async fn new() -> Self {
        let guard = TEST_MUTEX.lock().await;
        InMemoryTaskRepository::shared().clear().await;

        let token = AuthState::generate_token();
        let mut sessions = HashMap::new();
        sessions.insert(token.clone(), 1);
        let mut users = HashMap::new();
        users.insert("test@test.com".to_string(), 1);

        let rp_id = "localhost";
        let rp_origin = Url::parse("http://localhost:3300").expect("invalid URL");
        let webauthn = WebauthnBuilder::new(rp_id, &rp_origin)
            .expect("failed to create webauthn builder")
            .build()
            .expect("failed to build webauthn");

        let state = AuthState {
            webauthn: Arc::new(webauthn),
            reg_states: Arc::new(RwLock::new(HashMap::new())),
            auth_states: Arc::new(RwLock::new(HashMap::new())),
            credentials: Arc::new(RwLock::new(HashMap::new())),
            users: Arc::new(RwLock::new(users)),
            sessions: Arc::new(RwLock::new(sessions)),
            ai_provider: None,
            db: None,
            push: PushService::new(),
        };

        let app = routes::api_routes(&state).with_state(state);

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("failed to bind");
        let addr = listener.local_addr().expect("no local addr");
        let base_url = format!("http://{addr}");

        let (tx, rx) = tokio::sync::oneshot::channel::<()>();
        tokio::spawn(async move {
            axum::serve(listener, app)
                .with_graceful_shutdown(async {
                    rx.await.ok();
                })
                .await
                .expect("server error");
        });

        Self {
            client: reqwest::Client::new(),
            base_url,
            token,
            _shutdown: tx,
            _guard: guard,
        }
    }

    fn url(&self, path: &str) -> String {
        format!("{}{}", self.base_url, path)
    }

    async fn get(&self, path: &str) -> reqwest::Response {
        self.client.get(self.url(path)).send().await.unwrap()
    }

    async fn post<T: serde::Serialize + Sync>(&self, path: &str, body: &T) -> reqwest::Response {
        self.client
            .post(self.url(path))
            .json(body)
            .send()
            .await
            .unwrap()
    }

    async fn auth_get(&self, path: &str) -> reqwest::Response {
        self.client
            .get(self.url(path))
            .header("Authorization", format!("Bearer {}", self.token))
            .send()
            .await
            .unwrap()
    }

    async fn auth_post<T: serde::Serialize + Sync>(
        &self,
        path: &str,
        body: &T,
    ) -> reqwest::Response {
        self.client
            .post(self.url(path))
            .header("Authorization", format!("Bearer {}", self.token))
            .json(body)
            .send()
            .await
            .unwrap()
    }

    async fn auth_put<T: serde::Serialize + Sync>(
        &self,
        path: &str,
        body: &T,
    ) -> reqwest::Response {
        self.client
            .put(self.url(path))
            .header("Authorization", format!("Bearer {}", self.token))
            .json(body)
            .send()
            .await
            .unwrap()
    }

    async fn auth_delete(&self, path: &str) -> reqwest::Response {
        self.client
            .delete(self.url(path))
            .header("Authorization", format!("Bearer {}", self.token))
            .send()
            .await
            .unwrap()
    }
}

#[tokio::test]
async fn health_check() {
    let app = TestApp::new().await;
    let res = app.get("/health").await;
    assert_eq!(res.status(), 200);
    let body: serde_json::Value = res.json().await.unwrap();
    assert_eq!(body["status"], "ok");
}

#[tokio::test]
async fn unauthorized_without_token() {
    let app = TestApp::new().await;
    let res = app
        .post("/api/tasks", &serde_json::json!({"description": "test"}))
        .await;
    assert_eq!(res.status(), 401);
}

#[tokio::test]
async fn unauthorized_with_bad_token() {
    let app = TestApp::new().await;
    let res = app
        .client
        .get(app.url("/api/tasks"))
        .header("Authorization", "Bearer invalid_token")
        .send()
        .await
        .unwrap();
    assert_eq!(res.status(), 401);
}

#[tokio::test]
async fn task_crud_full_cycle() {
    let app = TestApp::new().await;

    let create_body = serde_json::json!({
        "description": "Comprar leche",
        "priority": "A",
        "projects": ["casa"],
        "contexts": ["supermercado"],
        "due_date": "2026-07-06"
    });

    let res = app.auth_post("/api/tasks", &create_body).await;
    assert_eq!(res.status(), 200, "create failed");

    let res = app.auth_post("/api/tasks", &create_body).await;
    assert_eq!(res.status(), 200);

    let res = app.auth_get("/api/tasks").await;
    assert_eq!(res.status(), 200);
    let tasks: Vec<serde_json::Value> = res.json().await.unwrap();
    assert_eq!(tasks.len(), 2, "expected 2 tasks");

    let task_id = tasks[0]["id"].as_str().unwrap().to_string();

    let res = app.auth_get(&format!("/api/tasks/{task_id}")).await;
    assert_eq!(res.status(), 200);
    let task: serde_json::Value = res.json().await.unwrap();
    assert_eq!(task["description"], "Comprar leche");
    assert_eq!(task["priority"], "A");

    let update_body = serde_json::json!({
        "description": "Comprar pan",
        "priority": "B",
        "projects": [],
        "contexts": []
    });
    let res = app
        .auth_put(&format!("/api/tasks/{task_id}"), &update_body)
        .await;
    assert_eq!(res.status(), 200);
    let updated: serde_json::Value = res.json().await.unwrap();
    assert_eq!(updated["description"], "Comprar pan");
    assert_eq!(updated["priority"], "B");

    let res = app.auth_delete(&format!("/api/tasks/{task_id}")).await;
    assert_eq!(res.status(), 204);

    let res = app.auth_get(&format!("/api/tasks/{task_id}")).await;
    assert_eq!(res.status(), 404);
}

#[tokio::test]
async fn create_task_with_inbox_status() {
    let app = TestApp::new().await;

    let body = serde_json::json!({
        "description": "Tarea desde inbox",
        "projects": [],
        "contexts": []
    });
    let res = app.auth_post("/api/tasks", &body).await;
    assert_eq!(res.status(), 200);
    let task: serde_json::Value = res.json().await.unwrap();
    assert_eq!(task["description"], "Tarea desde inbox");
    assert_eq!(task["status"], "inbox");
}

#[tokio::test]
async fn update_task_status() {
    let app = TestApp::new().await;

    let body = serde_json::json!({
        "description": "Mover a Doing",
        "projects": [],
        "contexts": []
    });
    let res = app.auth_post("/api/tasks", &body).await;
    assert_eq!(res.status(), 200);
    let task: serde_json::Value = res.json().await.unwrap();
    let task_id = task["id"].as_str().unwrap();

    let update_body = serde_json::json!({
        "description": "Mover a Doing",
        "status": "doing",
        "projects": [],
        "contexts": []
    });
    let res = app
        .auth_put(&format!("/api/tasks/{task_id}"), &update_body)
        .await;
    let status = res.status();
    assert_eq!(status, 200, "PUT returned {status}");
    let updated: serde_json::Value = res.json().await.unwrap();
    assert_eq!(updated["status"], "doing");
}

#[tokio::test]
async fn delete_nonexistent_task_returns_404() {
    let app = TestApp::new().await;
    let res = app
        .auth_delete("/api/tasks/00000000-0000-0000-0000-000000000000")
        .await;
    assert_eq!(res.status(), 404);
}

#[tokio::test]
async fn get_nonexistent_task_returns_404() {
    let app = TestApp::new().await;
    let res = app
        .auth_get("/api/tasks/00000000-0000-0000-0000-000000000000")
        .await;
    assert_eq!(res.status(), 404);
}

#[tokio::test]
async fn list_tasks_empty() {
    let app = TestApp::new().await;
    let res = app.auth_get("/api/tasks").await;
    assert_eq!(res.status(), 200);
    let tasks: Vec<serde_json::Value> = res.json().await.unwrap();
    assert!(tasks.is_empty());
}