diff options
| -rw-r--r-- | Cargo.lock | 2 | ||||
| -rw-r--r-- | Cargo.toml | 4 | ||||
| -rw-r--r-- | src/main.rs | 21 | ||||
| -rw-r--r-- | src/tests.rs | 341 |
4 files changed, 361 insertions, 7 deletions
@@ -594,11 +594,13 @@ dependencies = [ "axum", "chrono", "dotenvy", + "http-body-util", "serde", "serde_json", "sqlx", "strum", "tokio", + "tower", "tracing", "tracing-subscriber", "uuid", @@ -16,3 +16,7 @@ serde_json = "1.0" uuid = { version = "1.26.0", features = ["atomic", "v7", "serde"] } chrono = { version = "0.4", features = ["serde"] } strum = { version = "0.28.0", features = ["derive"] } + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" diff --git a/src/main.rs b/src/main.rs index 04f4c99..01a315a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,19 +2,18 @@ mod db; mod error; mod handlers; mod models; +#[cfg(test)] +mod tests; use axum::{ Router, routing::{delete, get, post, put}, }; use dotenvy::dotenv; +use sqlx::PgPool; -#[tokio::main] -async fn main() -> anyhow::Result<()> { - tracing_subscriber::fmt::init(); - dotenv().ok(); - let pool = db::connect().await?; - let app = Router::new() +fn router(pool: PgPool) -> Router { + Router::new() .route("/", get(handlers::index)) .route("/all", get(handlers::all)) .route("/add", post(handlers::add)) @@ -24,7 +23,15 @@ async fn main() -> anyhow::Result<()> { .route("/measurement/{id}", get(handlers::measurement_by_id)) .route("/delete/{id}", delete(handlers::delete)) .route("/update/{id}", put(handlers::update)) - .with_state(pool); + .with_state(pool) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt::init(); + dotenv().ok(); + let pool = db::connect().await?; + let app = router(pool); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; tracing::info!("listening at 0.0.0.0:3000"); axum::serve(listener, app).await?; diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..b10c1ea --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,341 @@ +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<String>, +) -> (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<f64> { + rows.as_array() + .unwrap() + .iter() + .map(|row| row["value"].as_f64().unwrap()) + .collect() +} + +fn sorted_values(rows: &Value) -> Vec<f64> { + 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"])); +} |
