use axum::{ Router, body::{Body, Bytes}, http::{ HeaderMap, Method, Request, StatusCode, header::{CONTENT_DISPOSITION, CONTENT_TYPE, LOCATION}, }, }; use chrono::{TimeDelta, Utc}; 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, HeaderMap, Value) { let (status, headers, bytes) = send_raw(app, method, uri, body).await; ( status, headers, serde_json::from_slice(&bytes).unwrap_or(Value::Null), ) } async fn get_text(app: &Router, uri: &str) -> (StatusCode, HeaderMap, String) { let (status, headers, bytes) = send_raw(app, Method::GET, uri, None).await; (status, headers, String::from_utf8(bytes.to_vec()).unwrap()) } async fn send_raw( app: &Router, method: Method, uri: &str, body: Option, ) -> (StatusCode, HeaderMap, Bytes) { 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 headers = response.headers().clone(); let bytes = response.into_body().collect().await.unwrap().to_bytes(); (status, headers, bytes) } async fn get_json(app: &Router, uri: &str) -> (StatusCode, Value) { let (status, _, body) = send(app, Method::GET, uri, None).await; (status, body) } 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, _, created) = send(app, Method::POST, "/measurements", Some(body)).await; assert_eq!(status, StatusCode::CREATED); created["id"].as_str().unwrap().to_owned() } async fn seed(app: &Router) { add_waist(app, "2025-10-14T12:00:00Z", 1.0).await; add_waist(app, "2025-10-15T00:00:00Z", 2.0).await; add_waist(app, "2025-10-15T23:59:59.500Z", 3.0).await; add_waist(app, "2025-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() } #[sqlx::test] async fn health_is_ok(pool: PgPool) { let app = router(pool); let (status, _) = get_json(&app, "/health").await; assert_eq!(status, StatusCode::OK); } #[sqlx::test] async fn create_returns_the_measurement_and_location(pool: PgPool) { let app = router(pool); let body = measurement("2025-10-14T12:00:00Z", "Waist", 80.5, "Centimeter"); let (status, headers, created) = send(&app, Method::POST, "/measurements", Some(body)).await; assert_eq!(status, StatusCode::CREATED); let id = created["id"].as_str().unwrap(); Uuid::parse_str(id).unwrap(); assert_eq!( created, json!({ "id": id, "measured_at": "2025-10-14T12:00:00Z", "metric_type": "Waist", "value": 80.5, "unit": "Centimeter", }) ); let location = headers[LOCATION].to_str().unwrap(); assert_eq!(location, format!("/measurements/{id}")); let (status, fetched) = get_json(&app, location).await; assert_eq!(status, StatusCode::OK); assert_eq!(fetched, created); } #[sqlx::test] async fn create_rejects_bad_bodies(pool: PgPool) { let app = router(pool); let cases = [ ( json!({"metric_type": "Hip"}).to_string(), StatusCode::UNPROCESSABLE_ENTITY, ), ( measurement("2025-10-14T12:00:00Z", "Nope", 1.0, "Kilogram"), StatusCode::UNPROCESSABLE_ENTITY, ), ( measurement("2025-10-14T12:00:00Z", "Waist", -1.0, "Centimeter"), StatusCode::UNPROCESSABLE_ENTITY, ), ( measurement("2025-10-14T12:00:00", "Waist", 80.0, "Centimeter"), StatusCode::UNPROCESSABLE_ENTITY, ), ("not json".to_owned(), StatusCode::BAD_REQUEST), ]; for (body, expected) in cases { let (status, _, _) = send(&app, Method::POST, "/measurements", Some(body.clone())).await; assert_eq!(status, expected, "body: {body}"); } let (_, rows) = get_json(&app, "/measurements").await; assert!(values(&rows).is_empty()); } #[sqlx::test] async fn metric_types_lists_the_valid_pairs(pool: PgPool) { let app = router(pool); let (status, pairs) = get_json(&app, "/metric-types").await; assert_eq!(status, StatusCode::OK); assert_eq!( pairs, json!([ {"metric_type": "BodyWeight", "unit": "Kilogram"}, {"metric_type": "Hip", "unit": "Centimeter"}, {"metric_type": "Waist", "unit": "Centimeter"}, ]) ); } #[sqlx::test] async fn create_accepts_exactly_the_advertised_pairs(pool: PgPool) { let app = router(pool); let (_, pairs) = get_json(&app, "/metric-types").await; let pairs = pairs.as_array().unwrap(); for metric_type in ["BodyWeight", "Hip", "Waist"] { for unit in ["Kilogram", "Centimeter"] { let advertised = pairs.contains(&json!({"metric_type": metric_type, "unit": unit})); let expected = if advertised { StatusCode::CREATED } else { StatusCode::UNPROCESSABLE_ENTITY }; let body = measurement("2025-10-14T12:00:00Z", metric_type, 70.0, unit); let (status, _, _) = send(&app, Method::POST, "/measurements", Some(body)).await; assert_eq!(status, expected, "{metric_type} {unit}"); } } } #[sqlx::test] async fn list_is_empty_array_without_data(pool: PgPool) { let app = router(pool); let (status, rows) = get_json(&app, "/measurements").await; assert_eq!(status, StatusCode::OK); assert_eq!(rows, json!([])); } #[sqlx::test] async fn list_orders_by_measured_at(pool: PgPool) { let app = router(pool); add_waist(&app, "2025-10-16T08:00:00Z", 4.0).await; add_waist(&app, "2025-10-14T12:00:00Z", 1.0).await; add_waist(&app, "2025-10-15T23:59:59.500Z", 3.0).await; add_waist(&app, "2025-10-15T00:00:00Z", 2.0).await; let (_, rows) = get_json(&app, "/measurements").await; assert_eq!(values(&rows), [1.0, 2.0, 3.0, 4.0]); let (_, rows) = get_json(&app, "/measurements?order=asc").await; assert_eq!(values(&rows), [1.0, 2.0, 3.0, 4.0]); let (_, rows) = get_json(&app, "/measurements?order=desc").await; assert_eq!(values(&rows), [4.0, 3.0, 2.0, 1.0]); } #[sqlx::test] async fn list_limit_applies_after_ordering(pool: PgPool) { let app = router(pool); seed(&app).await; let (_, rows) = get_json(&app, "/measurements?limit=2").await; assert_eq!(values(&rows), [1.0, 2.0]); let (_, rows) = get_json(&app, "/measurements?limit=2&order=desc").await; assert_eq!(values(&rows), [4.0, 3.0]); } #[sqlx::test] async fn list_bounds_are_half_open(pool: PgPool) { let app = router(pool); seed(&app).await; let uri = "/measurements?from=2025-10-15T00:00:00Z&to=2025-10-16T08:00:00Z"; let (status, rows) = get_json(&app, uri).await; assert_eq!(status, StatusCode::OK); assert_eq!(values(&rows), [2.0, 3.0]); } #[sqlx::test] async fn list_filters_by_utc_timestamps(pool: PgPool) { let app = router(pool); seed(&app).await; let cases = [ ( "/measurements?from=2025-10-15T00:00:00Z", vec![2.0, 3.0, 4.0], ), ("/measurements?to=2025-10-15T00:00:00Z", vec![1.0]), ( "/measurements?from=2025-10-14T15:00:00Z&to=2025-10-15T15:00:00Z", vec![2.0], ), ( "/measurements?from=2025-10-15T09:00:00%2B09:00", vec![2.0, 3.0, 4.0], ), ( "/measurements?from=2025-10-15T00:00:00Z&to=2025-10-15T00:00:00Z", vec![], ), ("/measurements?from=2030-01-01T00:00:00Z", vec![]), ]; for (uri, expected) in cases { let (status, rows) = get_json(&app, uri).await; assert_eq!(status, StatusCode::OK, "{uri}"); assert_eq!(values(&rows), expected, "{uri}"); } } #[sqlx::test] async fn list_combines_filters_with_order_and_limit(pool: PgPool) { let app = router(pool); seed(&app).await; let cases = [ ("/measurements?from=2025-10-15T00:00:00Z&limit=1", vec![2.0]), ( "/measurements?from=2025-10-15T00:00:00Z&order=desc&limit=2", vec![4.0, 3.0], ), ( "/measurements?from=2025-10-15T00:00:00Z&to=2025-10-16T00:00:00Z&order=desc", vec![3.0, 2.0], ), ]; for (uri, expected) in cases { let (status, rows) = get_json(&app, uri).await; assert_eq!(status, StatusCode::OK, "{uri}"); assert_eq!(values(&rows), expected, "{uri}"); } } #[sqlx::test] async fn list_filters_by_metric_type(pool: PgPool) { let app = router(pool); let rows = [ ("2025-10-14T12:00:00Z", "BodyWeight", 70.0, "Kilogram"), ("2025-10-15T12:00:00Z", "Waist", 80.0, "Centimeter"), ("2025-10-16T12:00:00Z", "BodyWeight", 71.0, "Kilogram"), ("2025-10-17T12:00:00Z", "Hip", 95.0, "Centimeter"), ("2025-10-18T12:00:00Z", "BodyWeight", 72.0, "Kilogram"), ]; for (measured_at, metric_type, value, unit) in rows { let body = measurement(measured_at, metric_type, value, unit); let (status, _, _) = send(&app, Method::POST, "/measurements", Some(body)).await; assert_eq!(status, StatusCode::CREATED); } let cases = [ ("/measurements", vec![70.0, 80.0, 71.0, 95.0, 72.0]), ( "/measurements?metric_type=BodyWeight", vec![70.0, 71.0, 72.0], ), ("/measurements?metric_type=Waist", vec![80.0]), ("/measurements?metric_type=Hip", vec![95.0]), ( "/measurements?metric_type=BodyWeight&order=desc&limit=2", vec![72.0, 71.0], ), ( "/measurements?metric_type=BodyWeight&from=2025-10-15T00:00:00Z&to=2025-10-18T00:00:00Z", vec![71.0], ), ( "/measurements?metric_type=Hip&to=2025-10-17T00:00:00Z", vec![], ), ]; for (uri, expected) in cases { let (status, rows) = get_json(&app, uri).await; assert_eq!(status, StatusCode::OK, "{uri}"); assert_eq!(values(&rows), expected, "{uri}"); } } #[sqlx::test] async fn list_rejects_bad_queries(pool: PgPool) { let app = router(pool); let uris = [ "/measurements?limit=0", "/measurements?limit=-1", "/measurements?order=sideways", "/measurements?metric_type=Nope", "/measurements?metric_type=bodyweight", "/measurements?from=bad", "/measurements?from=2025-10-15", "/measurements?to=2025-10-15T00:00:00", "/measurements?from=2025-10-15T09:00:00+09:00", "/measurements?date=2025-10-15", "/measurements?from=2025-10-16T00:00:00Z&to=2025-10-14T00:00:00Z", ]; for uri in uris { let (status, _) = get_json(&app, uri).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{uri}"); } } #[sqlx::test] async fn get_not_found_and_bad_id(pool: PgPool) { let app = router(pool); let (status, _) = get_json(&app, &format!("/measurements/{}", Uuid::now_v7())).await; assert_eq!(status, StatusCode::NOT_FOUND); let (status, _) = get_json(&app, "/measurements/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, "2025-10-14T12:00:00Z", 80.0).await; let uri = format!("/measurements/{id}"); let body = measurement("2025-10-20T09:30:00Z", "Hip", 95.0, "Centimeter"); let (status, _, _) = send(&app, Method::PUT, &uri, Some(body)).await; assert_eq!(status, StatusCode::NO_CONTENT); let (_, row) = get_json(&app, &uri).await; assert_eq!( row, json!({ "id": id, "measured_at": "2025-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("2025-10-20T09:30:00Z", "Hip", 95.0, "Centimeter"); let uri = format!("/measurements/{}", 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, "2025-10-14T12:00:00Z", 80.0).await; let uri = format!("/measurements/{id}"); let bodies = [ json!({"x": 1}).to_string(), measurement("2025-10-20T09:30:00Z", "Waist", 82.0, "Kilogram"), measurement("2025-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, &uri).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, "2025-10-14T12:00:00Z", 80.0).await; let uri = format!("/measurements/{id}"); let (status, _, _) = send(&app, Method::DELETE, &uri, None).await; assert_eq!(status, StatusCode::NO_CONTENT); let (status, _) = get_json(&app, &uri).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, "2025-10-14T12:00:00Z", 80.0).await; let uri = format!("/measurements/{id}"); for _ in 0..2 { let (status, _, _) = send(&app, Method::DELETE, &uri, None).await; assert_eq!(status, StatusCode::NO_CONTENT); } let missing = format!("/measurements/{}", Uuid::now_v7()); let (status, _, _) = send(&app, Method::DELETE, &missing, None).await; assert_eq!(status, StatusCode::NO_CONTENT); } #[sqlx::test] async fn old_paths_are_gone(pool: PgPool) { let app = router(pool); for uri in [ "/", "/all", "/search?from=2025-10-14", "/units", "/metric_types", ] { let (status, _) = get_json(&app, uri).await; assert_eq!(status, StatusCode::NOT_FOUND, "{uri}"); } } #[sqlx::test] async fn errors_have_a_json_body(pool: PgPool) { let app = router(pool); let missing = format!("/measurements/{}", Uuid::now_v7()); let valid = measurement("2025-10-14T12:00:00Z", "Waist", 80.0, "Centimeter"); let invalid_pair = measurement("2025-10-14T12:00:00Z", "Waist", 80.0, "Kilogram"); let cases = [ ( Method::GET, "/measurements?limit=0".to_owned(), None, StatusCode::BAD_REQUEST, ), ( Method::GET, "/measurements?order=sideways".to_owned(), None, StatusCode::BAD_REQUEST, ), ( Method::GET, "/measurements?from=bad".to_owned(), None, StatusCode::BAD_REQUEST, ), ( Method::GET, "/measurements/not-a-uuid".to_owned(), None, StatusCode::BAD_REQUEST, ), (Method::GET, missing.clone(), None, StatusCode::NOT_FOUND), (Method::PUT, missing, Some(valid), StatusCode::NOT_FOUND), ( Method::POST, "/measurements".to_owned(), Some("not json".to_owned()), StatusCode::BAD_REQUEST, ), ( Method::POST, "/measurements".to_owned(), Some(invalid_pair), StatusCode::UNPROCESSABLE_ENTITY, ), (Method::GET, "/nope".to_owned(), None, StatusCode::NOT_FOUND), ( Method::PATCH, "/measurements".to_owned(), None, StatusCode::METHOD_NOT_ALLOWED, ), ]; for (method, uri, body, expected) in cases { let (status, _, body) = send(&app, method.clone(), &uri, body).await; assert_eq!(status, expected, "{method} {uri}"); assert!(body["error"].is_string(), "{method} {uri}: {body}"); } } #[sqlx::test] async fn error_messages_explain_the_problem(pool: PgPool) { let app = router(pool); let cases = [ ("/measurements?limit=0", "limit must be positive"), ( "/measurements?from=2025-10-16T00:00:00Z&to=2025-10-14T00:00:00Z", "from must not be after to", ), ]; for (uri, message) in cases { let (_, body) = get_json(&app, uri).await; assert_eq!(body["error"], message, "{uri}"); } let (_, body) = get_json(&app, "/measurements?date=2025-10-15").await; let message = body["error"].as_str().unwrap(); assert!(message.contains("unknown field `date`"), "{message}"); let body = measurement("2025-10-14T12:00:00Z", "Waist", 80.0, "Kilogram"); let (_, _, body) = send(&app, Method::POST, "/measurements", Some(body)).await; let message = body["error"].as_str().unwrap(); assert!( message.contains("Invalid pair Waist, Kilogram"), "{message}" ); } #[sqlx::test] async fn create_rejects_future_measured_at_beyond_tolerance(pool: PgPool) { let app = router(pool); let cases = [ (Utc::now() - TimeDelta::days(1), StatusCode::CREATED), (Utc::now() + TimeDelta::minutes(1), StatusCode::CREATED), ( Utc::now() + TimeDelta::hours(1), StatusCode::UNPROCESSABLE_ENTITY, ), ( Utc::now() + TimeDelta::hours(9), StatusCode::UNPROCESSABLE_ENTITY, ), ]; for (measured_at, expected) in cases { let measured_at = measured_at.to_rfc3339(); let body = measurement(&measured_at, "Waist", 80.0, "Centimeter"); let (status, _, body) = send(&app, Method::POST, "/measurements", Some(body)).await; assert_eq!(status, expected, "{measured_at}"); if expected == StatusCode::UNPROCESSABLE_ENTITY { let message = body["error"].as_str().unwrap(); assert!( message.contains("measured_at is in the future"), "{message}" ); } } } #[sqlx::test] async fn update_rejects_future_measured_at(pool: PgPool) { let app = router(pool); let id = add_waist(&app, "2025-10-14T12:00:00Z", 80.0).await; let uri = format!("/measurements/{id}"); let future = (Utc::now() + TimeDelta::hours(1)).to_rfc3339(); let body = measurement(&future, "Waist", 82.0, "Centimeter"); let (status, _, _) = send(&app, Method::PUT, &uri, Some(body)).await; assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); let (_, row) = get_json(&app, &uri).await; assert_eq!(row["value"], json!(80.0)); } #[sqlx::test] async fn export_returns_a_csv_download(pool: PgPool) { let app = router(pool); let id = add_waist(&app, "2025-10-14T12:00:00Z", 80.5).await; let (status, headers, csv) = get_text(&app, "/measurements/export").await; assert_eq!(status, StatusCode::OK); assert_eq!(headers[CONTENT_TYPE], "text/csv; charset=utf-8"); assert_eq!( headers[CONTENT_DISPOSITION], "attachment; filename=\"measurements.csv\"" ); assert_eq!( csv, format!( "id,measured_at,metric_type,value,unit\n{id},2025-10-14T12:00:00Z,Waist,80.5,Centimeter\n" ) ); } #[sqlx::test] async fn export_without_data_is_just_the_header(pool: PgPool) { let app = router(pool); let (status, _, csv) = get_text(&app, "/measurements/export").await; assert_eq!(status, StatusCode::OK); assert_eq!(csv, "id,measured_at,metric_type,value,unit\n"); } #[sqlx::test] async fn export_matches_the_list_for_the_same_query(pool: PgPool) { let app = router(pool); let rows = [ ("2025-10-14T12:00:00Z", "BodyWeight", 70.0, "Kilogram"), ("2025-10-15T12:00:00Z", "Waist", 80.0, "Centimeter"), ("2025-10-16T12:00:00Z", "BodyWeight", 71.0, "Kilogram"), ("2025-10-17T12:00:00Z", "Hip", 95.0, "Centimeter"), ]; for (measured_at, metric_type, value, unit) in rows { let body = measurement(measured_at, metric_type, value, unit); let (status, _, _) = send(&app, Method::POST, "/measurements", Some(body)).await; assert_eq!(status, StatusCode::CREATED); } let queries = [ "", "?metric_type=BodyWeight", "?order=desc&limit=2", "?from=2025-10-15T00:00:00Z&to=2025-10-17T00:00:00Z", "?metric_type=Hip&from=2026-01-01T00:00:00Z", ]; for query in queries { let (_, listed) = get_json(&app, &format!("/measurements{query}")).await; let listed: Vec<&str> = listed .as_array() .unwrap() .iter() .map(|row| row["id"].as_str().unwrap()) .collect(); let (status, _, csv) = get_text(&app, &format!("/measurements/export{query}")).await; assert_eq!(status, StatusCode::OK, "{query}"); let exported: Vec<&str> = csv .lines() .skip(1) .map(|line| line.split(',').next().unwrap()) .collect(); assert_eq!(exported, listed, "{query}"); } } #[sqlx::test] async fn export_rejects_bad_queries_with_json_errors(pool: PgPool) { let app = router(pool); let uris = [ "/measurements/export?limit=0", "/measurements/export?metric_type=Nope", "/measurements/export?date=2025-10-15", "/measurements/export?from=2025-10-16T00:00:00Z&to=2025-10-14T00:00:00Z", ]; for uri in uris { let (status, _, body) = send(&app, Method::GET, uri, None).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{uri}"); assert!(body["error"].is_string(), "{uri}: {body}"); } let (status, _, _) = send(&app, Method::POST, "/measurements/export", None).await; assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED); }