use std::{ops::Range, time::Duration}; use axum::{ extract::{Path, Query, State}, http::StatusCode, Json, }; use axum_extra::routing::Resource; use serde::{Deserialize, Serialize}; use sqlx::{prelude::FromRow, PgPool}; use time::OffsetDateTime; use uuid::Uuid; use crate::{auth::AccessClaims, state::AppState}; use super::error::Error; #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct Task { pub id: Uuid, pub user_id: Option, pub title: String, pub description: Option, pub remote: bool, pub location: Option, pub start: OffsetDateTime, pub duration: Duration, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TaskQuery { pub user_id: Option, pub remote: Option, pub title: Option, pub description: Option, pub time_range: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateTaskSchema { pub user_id: Uuid, pub title: String, pub description: Option, pub remote: bool, pub location: Option, pub start: OffsetDateTime, pub duration: Duration, } pub fn router() -> Resource { Resource::named("tasks") .index(index) .create(create) .show(show) .destroy(destroy) } pub async fn index( Query(task_query): Query, State(pool): State, _: AccessClaims, ) -> Result>, Error> { sqlx::query_as!( Task, "SELECT * FROM task WHERE ($1::UUID IS NULL OR user_id = $1) AND ($2::BOOLEAN IS NULL OR remote = $2) AND ($3::TEXT IS NULL OR title LIKE '%$3%') AND ($4::TEXT IS NULL OR title LIKE '%$4%') LIMIT 100", task_query.user_id, task_query.remote, task_query.title, task_query.description, ) .fetch_all(&pool) .await .map(Json) .map_err(Into::into) } pub async fn create( State(pool): State, AccessClaims { sub, .. }: AccessClaims, Json(CreateTaskSchema { user_id, title, description, remote, location, start, duration, }): Json, ) -> Result<(StatusCode, Json), Error> { if sub != user_id { return Err(Error::InvalidToken); } let task = sqlx::query_as!(Task, "INSERT INTO task (user_id, title, description, remote, location, start, duration) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *", user_id, title, description, remote, location, start, duration, ) .fetch_one(&pool) .await?; Ok((StatusCode::CREATED, Json(task))) } pub async fn show( State(pool): State, Path(uuid): Path, _: AccessClaims, ) -> Result, Error> { sqlx::query_as!(Task, "SELECT * FROM task WHERE id = $1 LIMIT 1", uuid) .fetch_optional(&pool) .await? .ok_or_else(|| Error::TaskNotFound) .map(Json) } pub async fn destroy( State(pool): State, Path(uuid): Path, AccessClaims { sub, .. }: AccessClaims, ) -> Result, Error> { sqlx::query_as!( Task, "SELECT * FROM task WHERE id = $1 AND user_id = $2 LIMIT 1", uuid, sub ) .fetch_optional(&pool) .await? .ok_or_else(|| Error::TaskNotFound) .map(Json) } #[cfg(test)] mod tests { use super::*; use axum::{ body::Body, http::{ header::{CONTENT_TYPE, COOKIE}, Request, StatusCode, }, Router, }; use http_body_util::BodyExt; use time::macros::datetime; use tower::ServiceExt; use crate::{ auth::AccessClaims, tests::{setup_test_env, TestResult}, }; const TASK_ID: Uuid = uuid::uuid!("d5d31b54-0fc4-432c-9212-25175749c7f4"); const TASK_USER_ID: Uuid = uuid::uuid!("4c14f795-86f0-4361-a02f-0edb966fb145"); const TASK_TITLE: &str = "Unload Cargo"; const TASK_DESCRIPTION: &str = r#"I've got some cargo I need unloading."#; const TASK_REMOTE: bool = false; const TASK_LOCATION: &str = "Astroid Gamma 2b, Madranite Mining Belt, 42d5b4, Orion Beta"; const TASK_START_AT: OffsetDateTime = datetime!(2042-05-13 12:00:00 -5); const TASK_END_AT: OffsetDateTime = datetime!(2042-05-13 15:00:00 -5); #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))] async fn test_tasks_post_created(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let task = serde_json::json!( { "user_id": TASK_USER_ID, "title": TASK_TITLE, "description": TASK_DESCRIPTION, "remote": TASK_REMOTE, "location": TASK_LOCATION, "start_at": TASK_START_AT, "end_at": TASK_END_AT, }); let request = Request::builder() .method("POST") .uri("/tasks") .header( COOKIE, AccessClaims::issue(TASK_USER_ID).as_cookie()?.to_string(), ) .header(CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) .body(Body::from(serde_json::to_vec(&task)?))?; let response = router.oneshot(request).await?; assert_eq!( StatusCode::CREATED, response.status(), "{}", std::str::from_utf8(&response.into_body().collect().await?.to_bytes())? ); let body_bytes = response.into_body().collect().await?.to_bytes(); let Task { user_id, title, description, remote, location, start_at, end_at, .. } = serde_json::from_slice(&body_bytes)?; assert_eq!(Some(TASK_USER_ID), user_id); assert_eq!(TASK_TITLE, title); assert_eq!(Some(TASK_DESCRIPTION), description.as_deref()); assert_eq!(TASK_REMOTE, remote); assert_eq!(Some(TASK_LOCATION), location.as_deref()); assert_eq!(Some(TASK_START_AT), start_at); assert_eq!(Some(TASK_END_AT), end_at); Ok(()) } #[sqlx::test(fixtures(path = "../../fixtures", scripts("users", "tasks")))] async fn test_tasks_get_ok(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let request = Request::builder() .uri(format!("/tasks/{}", TASK_ID)) .header( COOKIE, AccessClaims::issue(TASK_USER_ID).as_cookie()?.to_string(), ) .body(Body::empty())?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::OK, response.status()); let body_bytes = response.into_body().collect().await?.to_bytes(); let Task { id, user_id, title, description, remote, location, start_at, end_at, .. } = serde_json::from_slice(&body_bytes)?; assert_eq!(TASK_ID, id); assert_eq!(Some(TASK_USER_ID), user_id); assert_eq!(TASK_TITLE, title); assert_eq!(Some(TASK_DESCRIPTION), description.as_deref()); assert_eq!(TASK_REMOTE, remote); assert_eq!(Some(TASK_LOCATION), location.as_deref()); assert_eq!(Some(TASK_START_AT), start_at); assert_eq!(Some(TASK_END_AT), end_at); Ok(()) } #[sqlx::test] async fn test_tasks_get_not_found(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let request = Request::builder() .uri(format!("/tasks/{}", TASK_ID)) .header( COOKIE, AccessClaims::issue(TASK_USER_ID).as_cookie()?.to_string(), ) .body(Body::empty())?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::NOT_FOUND, response.status()); Ok(()) } }