use axum::{ Router, body::Body, http::{Method, Request, StatusCode, header::CONTENT_TYPE}, }; use http_body_util::BodyExt; use serde_json::{Value, json}; use sqlx::PgPool; use tower::ServiceExt; use uuid::Uuid; use crate::router; async fn send( app: &Router, method: Method, uri: &str, body: Option, ) -> (StatusCode, Value) { let mut request = Request::builder().method(method).uri(uri); let body = match body { Some(body) => { request = request.header(CONTENT_TYPE, "application/json"); Body::from(body) } None => Body::empty(), }; let response = app .clone() .oneshot(request.body(body).unwrap()) .await .unwrap(); let status = response.status(); let bytes = response.into_body().collect().await.unwrap().to_bytes(); (status, serde_json::from_slice(&bytes).unwrap_or(Value::Null)) } async fn get_json(app: &Router, uri: &str) -> (StatusCode, Value) { send(app, Method::GET, uri, None).await } fn measurement(measured_at: &str, metric_type: &str, value: f64, unit: &str) -> String { json!({ "measured_at": measured_at, "metric_type": metric_type, "value": value, "unit": unit, }) .to_string() } async fn add_waist(app: &Router, measured_at: &str, value: f64) -> String { let body = measurement(measured_at, "Waist", value, "Centimeter"); let (status, id) = send(app, Method::POST, "/add", Some(body)).await; assert_eq!(status, StatusCode::CREATED); id.as_str().unwrap().to_owned() } async fn seed(app: &Router) { add_waist(app, "2026-10-14T12:00:00Z", 1.0).await; add_waist(app, "2026-10-15T00:00:00Z", 2.0).await; add_waist(app, "2026-10-15T23:59:59.500Z", 3.0).await; add_waist(app, "2026-10-16T08:00:00Z", 4.0).await; } fn values(rows: &Value) -> Vec { rows.as_array() .unwrap() .iter() .map(|row| row["value"].as_f64().unwrap()) .collect() } fn sorted_values(rows: &Value) -> Vec { let mut values = values(rows); values.sort_by(|a, b| a.partial_cmp(b).unwrap()); values } #[sqlx::test] async fn index_says_hello(pool: PgPool) { let app = router(pool); let (status, _) = get_json(&app, "/").await; assert_eq!(status, StatusCode::OK); } #[sqlx::test] async fn add_returns_created_with_uuid(pool: PgPool) { let app = router(pool); let id = add_waist(&app, "2026-10-14T12:00:00Z", 80.5).await; Uuid::parse_str(&id).unwrap(); } #[sqlx::test] async fn add_then_get_by_id_round_trips(pool: PgPool) { let app = router(pool); let id = add_waist(&app, "2026-10-14T12:00:00Z", 80.5).await; let (status, row) = get_json(&app, &format!("/measurement/{id}")).await; assert_eq!(status, StatusCode::OK); assert_eq!( row, json!({ "id": id, "measured_at": "2026-10-14T12:00:00Z", "metric_type": "Waist", "value": 80.5, "unit": "Centimeter", }) ); } #[sqlx::test] async fn add_rejects_bad_bodies(pool: PgPool) { let app = router(pool); let cases = [ (json!({"metric_type": "Hip"}).to_string(), StatusCode::UNPROCESSABLE_ENTITY), ( measurement("2026-10-14T12:00:00Z", "Nope", 1.0, "Kilogram"), StatusCode::UNPROCESSABLE_ENTITY, ), ( measurement("2026-10-14T12:00:00Z", "Waist", 80.0, "Kilogram"), StatusCode::UNPROCESSABLE_ENTITY, ), ( measurement("2026-10-14T12:00:00Z", "Waist", -1.0, "Centimeter"), StatusCode::UNPROCESSABLE_ENTITY, ), ("not json".to_owned(), StatusCode::BAD_REQUEST), ]; for (body, expected) in cases { let (status, _) = send(&app, Method::POST, "/add", Some(body.clone())).await; assert_eq!(status, expected, "body: {body}"); } let (_, rows) = get_json(&app, "/all").await; assert!(values(&rows).is_empty()); } #[sqlx::test] async fn add_accepts_every_valid_pair(pool: PgPool) { let app = router(pool); let pairs = [ ("BodyWeight", "Kilogram"), ("Hip", "Centimeter"), ("Waist", "Centimeter"), ]; for (metric_type, unit) in pairs { let body = measurement("2026-10-14T12:00:00Z", metric_type, 70.0, unit); let (status, _) = send(&app, Method::POST, "/add", Some(body)).await; assert_eq!(status, StatusCode::CREATED, "{metric_type} {unit}"); } } #[sqlx::test] async fn all_is_empty_array_without_data(pool: PgPool) { let app = router(pool); let (status, rows) = get_json(&app, "/all").await; assert_eq!(status, StatusCode::OK); assert_eq!(rows, json!([])); } #[sqlx::test] async fn all_orders_by_measured_at(pool: PgPool) { let app = router(pool); add_waist(&app, "2026-10-16T08:00:00Z", 4.0).await; add_waist(&app, "2026-10-14T12:00:00Z", 1.0).await; add_waist(&app, "2026-10-15T23:59:59.500Z", 3.0).await; add_waist(&app, "2026-10-15T00:00:00Z", 2.0).await; let (_, rows) = get_json(&app, "/all").await; assert_eq!(values(&rows), [1.0, 2.0, 3.0, 4.0]); } #[sqlx::test] async fn all_limit_keeps_oldest(pool: PgPool) { let app = router(pool); seed(&app).await; let (_, rows) = get_json(&app, "/all?limit=2").await; assert_eq!(values(&rows), [1.0, 2.0]); } #[sqlx::test] async fn all_reverse_is_newest_first(pool: PgPool) { let app = router(pool); seed(&app).await; let (_, rows) = get_json(&app, "/all?reverse=true").await; assert_eq!(values(&rows), [4.0, 3.0, 2.0, 1.0]); } #[sqlx::test] async fn all_reverse_with_limit_keeps_newest(pool: PgPool) { let app = router(pool); seed(&app).await; let (_, rows) = get_json(&app, "/all?limit=2&reverse=true").await; assert_eq!(values(&rows), [4.0, 3.0]); let (_, rows) = get_json(&app, "/all?limit=2&reverse=false").await; assert_eq!(values(&rows), [1.0, 2.0]); } #[sqlx::test] async fn all_rejects_non_positive_limit(pool: PgPool) { let app = router(pool); for uri in ["/all?limit=0", "/all?limit=-1"] { let (status, _) = get_json(&app, uri).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{uri}"); } } #[sqlx::test] async fn search_by_single_date_includes_the_whole_day(pool: PgPool) { let app = router(pool); seed(&app).await; let (status, rows) = get_json(&app, "/search?date=2026-10-15").await; assert_eq!(status, StatusCode::OK); assert_eq!(sorted_values(&rows), [2.0, 3.0]); } #[sqlx::test] async fn search_ranges_are_inclusive_of_both_days(pool: PgPool) { let app = router(pool); seed(&app).await; let cases = [ ("/search?from=2026-10-15", vec![2.0, 3.0, 4.0]), ("/search?to=2026-10-14", vec![1.0]), ("/search?from=2026-10-14&to=2026-10-15", vec![1.0, 2.0, 3.0]), ("/search?from=2026-10-15&to=2026-10-15", vec![2.0, 3.0]), ("/search?from=2030-01-01", vec![]), ]; for (uri, expected) in cases { let (status, rows) = get_json(&app, uri).await; assert_eq!(status, StatusCode::OK, "{uri}"); assert_eq!(sorted_values(&rows), expected, "{uri}"); } } #[sqlx::test] async fn search_rejects_bad_queries(pool: PgPool) { let app = router(pool); let uris = [ "/search", "/search?date=bad", "/search?from=bad", "/search?date=2026-10-15&from=2026-10-14", "/search?date=2026-10-15&to=2026-10-16", "/search?from=2026-10-16&to=2026-10-14", ]; for uri in uris { let (status, _) = get_json(&app, uri).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{uri}"); } } #[sqlx::test] async fn get_by_id_not_found_and_bad_id(pool: PgPool) { let app = router(pool); let (status, _) = get_json(&app, &format!("/measurement/{}", Uuid::now_v7())).await; assert_eq!(status, StatusCode::NOT_FOUND); let (status, _) = get_json(&app, "/measurement/not-a-uuid").await; assert_eq!(status, StatusCode::BAD_REQUEST); } #[sqlx::test] async fn update_replaces_the_row(pool: PgPool) { let app = router(pool); let id = add_waist(&app, "2026-10-14T12:00:00Z", 80.0).await; let body = measurement("2026-10-20T09:30:00Z", "Hip", 95.0, "Centimeter"); let (status, _) = send(&app, Method::PUT, &format!("/update/{id}"), Some(body)).await; assert_eq!(status, StatusCode::NO_CONTENT); let (_, row) = get_json(&app, &format!("/measurement/{id}")).await; assert_eq!( row, json!({ "id": id, "measured_at": "2026-10-20T09:30:00Z", "metric_type": "Hip", "value": 95.0, "unit": "Centimeter", }) ); } #[sqlx::test] async fn update_missing_id_is_not_found(pool: PgPool) { let app = router(pool); let body = measurement("2026-10-20T09:30:00Z", "Hip", 95.0, "Centimeter"); let uri = format!("/update/{}", Uuid::now_v7()); let (status, _) = send(&app, Method::PUT, &uri, Some(body)).await; assert_eq!(status, StatusCode::NOT_FOUND); } #[sqlx::test] async fn update_rejects_invalid_body_and_leaves_row_alone(pool: PgPool) { let app = router(pool); let id = add_waist(&app, "2026-10-14T12:00:00Z", 80.0).await; let uri = format!("/update/{id}"); let bodies = [ json!({"x": 1}).to_string(), measurement("2026-10-20T09:30:00Z", "Waist", 82.0, "Kilogram"), measurement("2026-10-20T09:30:00Z", "Waist", -1.0, "Centimeter"), ]; for body in bodies { let (status, _) = send(&app, Method::PUT, &uri, Some(body.clone())).await; assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "body: {body}"); } let (_, row) = get_json(&app, &format!("/measurement/{id}")).await; assert_eq!(row["value"], json!(80.0)); } #[sqlx::test] async fn delete_removes_the_row(pool: PgPool) { let app = router(pool); let id = add_waist(&app, "2026-10-14T12:00:00Z", 80.0).await; let (status, _) = send(&app, Method::DELETE, &format!("/delete/{id}"), None).await; assert_eq!(status, StatusCode::NO_CONTENT); let (status, _) = get_json(&app, &format!("/measurement/{id}")).await; assert_eq!(status, StatusCode::NOT_FOUND); } #[sqlx::test] async fn delete_is_idempotent(pool: PgPool) { let app = router(pool); let id = add_waist(&app, "2026-10-14T12:00:00Z", 80.0).await; let uri = format!("/delete/{id}"); for _ in 0..2 { let (status, _) = send(&app, Method::DELETE, &uri, None).await; assert_eq!(status, StatusCode::NO_CONTENT); } let missing = format!("/delete/{}", Uuid::now_v7()); let (status, _) = send(&app, Method::DELETE, &missing, None).await; assert_eq!(status, StatusCode::NO_CONTENT); } #[sqlx::test] async fn units_and_metric_types_list_the_enums(pool: PgPool) { let app = router(pool); let (status, units) = get_json(&app, "/units").await; assert_eq!(status, StatusCode::OK); assert_eq!(units, json!(["Kilogram", "Centimeter"])); let (status, metric_types) = get_json(&app, "/metric_types").await; assert_eq!(status, StatusCode::OK); assert_eq!(metric_types, json!(["BodyWeight", "Hip", "Waist"])); }