diff options
| -rw-r--r-- | src/handlers.rs | 157 | ||||
| -rw-r--r-- | src/main.rs | 26 | ||||
| -rw-r--r-- | src/models.rs | 60 | ||||
| -rw-r--r-- | src/tests.rs | 238 |
4 files changed, 252 insertions, 229 deletions
diff --git a/src/handlers.rs b/src/handlers.rs index a49d29a..b048681 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -1,57 +1,85 @@ use axum::{ extract::{Path, Query, State}, - http::StatusCode, + http::{HeaderName, StatusCode, header}, response::Json, }; use chrono::NaiveTime; use sqlx::PgPool; +use strum::IntoEnumIterator; use uuid::Uuid; -use crate::models::{AllQuery, DateQuery, MeasurementData, MeasurementView, Unit}; -use crate::{error::AppError, models::MetricType}; +use crate::error::AppError; +use crate::models::{ + ListQuery, MeasurementData, MeasurementView, MetricType, MetricUnitPair, Order, Unit, +}; -pub async fn index() -> &'static str { - "Hello World!" +pub async fn health() -> StatusCode { + StatusCode::OK } -pub async fn all( +pub async fn list_measurements( State(pool): State<PgPool>, - Query(options): Query<AllQuery>, + Query(query): Query<ListQuery>, ) -> Result<Json<Vec<MeasurementView>>, AppError> { - let limit = match options.limit { + let limit = match query.limit { Some(x) if (x > 0) => Some(x), None => None, _ => return Err(AppError::BadRequest), }; + let (from, to) = match (query.date, query.from, query.to) { + (Some(date), None, None) => (Some(date), Some(date)), + (None, from, to) => (from, to), + _ => return Err(AppError::BadRequest), + }; + if let (Some(from), Some(to)) = (from, to) + && from > to + { + return Err(AppError::BadRequest); + } + let start = from.map(|from| from.and_time(NaiveTime::MIN).and_utc()); + let end = match to { + Some(to) => { + let Some(next_day) = to.succ_opt() else { + return Err(AppError::BadRequest); + }; + Some(next_day.and_time(NaiveTime::MIN).and_utc()) + } + None => None, + }; // SQL can't bind ASC/DESC as a parameter. The CASE yields measured_at only when - // `reverse` is true (sorted DESC) and NULL otherwise, so that key ties for every + // `order` is desc (sorted DESC) and NULL otherwise, so that key ties for every // row and the ASC key decides the order. If this gets slow, use an if/else with a // separate ASC and DESC query and add an index on measured_at. let rows = sqlx::query_as!( MeasurementView, " SELECT id, measured_at, metric_type, value, unit FROM measurement - ORDER BY CASE WHEN $2 THEN measured_at END DESC, + WHERE ($1::timestamptz IS NULL OR measured_at >= $1) + AND ($2::timestamptz IS NULL OR measured_at < $2) + ORDER BY CASE WHEN $4 THEN measured_at END DESC, measured_at ASC - LIMIT $1 + LIMIT $3 ", + start, + end, limit, - options.reverse.unwrap_or(false) + matches!(query.order, Order::Desc) ) .fetch_all(&pool) .await?; Ok(Json(rows)) } -pub async fn add( +pub async fn create_measurement( State(pool): State<PgPool>, Json(data): Json<MeasurementData>, -) -> Result<(StatusCode, Json<Uuid>), AppError> { - let rec = sqlx::query!( +) -> Result<(StatusCode, [(HeaderName, String); 1], Json<MeasurementView>), AppError> { + let measurement = sqlx::query_as!( + MeasurementView, " INSERT INTO measurement (id, measured_at, metric_type, value, unit) VALUES ($1, $2, $3, $4, $5) - RETURNING id + RETURNING id, measured_at, metric_type, value, unit ", Uuid::now_v7(), data.measured_at, @@ -61,66 +89,15 @@ pub async fn add( ) .fetch_one(&pool) .await?; - Ok((StatusCode::CREATED, Json(rec.id))) -} - -pub async fn search( - State(pool): State<PgPool>, - Query(date_query): Query<DateQuery>, -) -> Result<Json<Vec<MeasurementView>>, AppError> { - let (from, to) = match (date_query.date, date_query.from, date_query.to) { - (Some(date), None, None) => (Some(date), Some(date)), - (None, from, to) => (from, to), - _ => return Err(AppError::BadRequest), - }; - if from.is_none() && to.is_none() { - return Err(AppError::BadRequest); - } - if let (Some(from), Some(to)) = (from, to) - && from > to - { - return Err(AppError::BadRequest); - } - let start = from.map(|from| from.and_time(NaiveTime::MIN).and_utc()); - let end = match to { - Some(to) => { - let Some(next_day) = to.succ_opt() else { - return Err(AppError::BadRequest); - }; - Some(next_day.and_time(NaiveTime::MIN).and_utc()) - } - None => None, - }; - let rows = sqlx::query_as!( - MeasurementView, - "SELECT id, measured_at, metric_type, value, unit FROM measurement - WHERE ($1::timestamptz IS NULL OR measured_at >= $1) - AND ($2::timestamptz IS NULL OR measured_at < $2)", - start, - end - ) - .fetch_all(&pool) - .await?; - Ok(Json(rows)) -} - -pub async fn units(State(pool): State<PgPool>) -> Result<Json<Vec<Unit>>, AppError> { - Ok(Json( - sqlx::query_scalar("SELECT enum_range(NULL::unit)") - .fetch_one(&pool) - .await?, + let location = format!("/measurements/{}", measurement.id); + Ok(( + StatusCode::CREATED, + [(header::LOCATION, location)], + Json(measurement), )) } -pub async fn metric_types(State(pool): State<PgPool>) -> Result<Json<Vec<MetricType>>, AppError> { - Ok(Json( - sqlx::query_scalar("SELECT enum_range(NULL::metric_type)") - .fetch_one(&pool) - .await?, - )) -} - -pub async fn measurement_by_id( +pub async fn get_measurement( State(pool): State<PgPool>, Path(id): Path<Uuid>, ) -> Result<Json<MeasurementView>, AppError> { @@ -137,17 +114,7 @@ pub async fn measurement_by_id( Ok(Json(measurement)) } -pub async fn delete( - State(pool): State<PgPool>, - Path(id): Path<Uuid>, -) -> Result<StatusCode, AppError> { - sqlx::query!("DELETE FROM measurement WHERE id = $1", id) - .execute(&pool) - .await?; - Ok(StatusCode::NO_CONTENT) -} - -pub async fn update( +pub async fn update_measurement( State(pool): State<PgPool>, Path(id): Path<Uuid>, Json(data): Json<MeasurementData>, @@ -174,3 +141,25 @@ pub async fn update( } Ok(StatusCode::NO_CONTENT) } + +pub async fn delete_measurement( + State(pool): State<PgPool>, + Path(id): Path<Uuid>, +) -> Result<StatusCode, AppError> { + sqlx::query!("DELETE FROM measurement WHERE id = $1", id) + .execute(&pool) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +pub async fn metric_types() -> Json<Vec<MetricUnitPair>> { + Json( + MetricType::iter() + .flat_map(|metric_type| { + Unit::iter() + .filter(move |unit| metric_type.accepts(unit)) + .map(move |unit| MetricUnitPair { metric_type, unit }) + }) + .collect(), + ) +} diff --git a/src/main.rs b/src/main.rs index 01a315a..3faa650 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,24 +5,24 @@ mod models; #[cfg(test)] mod tests; -use axum::{ - Router, - routing::{delete, get, post, put}, -}; +use axum::{Router, routing::get}; use dotenvy::dotenv; use sqlx::PgPool; fn router(pool: PgPool) -> Router { Router::new() - .route("/", get(handlers::index)) - .route("/all", get(handlers::all)) - .route("/add", post(handlers::add)) - .route("/search", get(handlers::search)) - .route("/units", get(handlers::units)) - .route("/metric_types", get(handlers::metric_types)) - .route("/measurement/{id}", get(handlers::measurement_by_id)) - .route("/delete/{id}", delete(handlers::delete)) - .route("/update/{id}", put(handlers::update)) + .route("/health", get(handlers::health)) + .route( + "/measurements", + get(handlers::list_measurements).post(handlers::create_measurement), + ) + .route( + "/measurements/{id}", + get(handlers::get_measurement) + .put(handlers::update_measurement) + .delete(handlers::delete_measurement), + ) + .route("/metric-types", get(handlers::metric_types)) .with_state(pool) } diff --git a/src/models.rs b/src/models.rs index 9e14b71..6b44040 100644 --- a/src/models.rs +++ b/src/models.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use strum::{EnumIter, IntoStaticStr}; use uuid::Uuid; -#[derive(sqlx::Type, Serialize, Deserialize, EnumIter, Debug, IntoStaticStr)] +#[derive(sqlx::Type, Serialize, Deserialize, EnumIter, Debug, IntoStaticStr, Clone, Copy)] #[sqlx(type_name = "metric_type")] pub enum MetricType { BodyWeight, @@ -12,6 +12,17 @@ pub enum MetricType { Waist, } +impl MetricType { + pub fn accepts(&self, unit: &Unit) -> bool { + matches!( + (self, unit), + (MetricType::BodyWeight, Unit::Kilogram) + | (MetricType::Hip, Unit::Centimeter) + | (MetricType::Waist, Unit::Centimeter) + ) + } +} + #[derive(sqlx::Type, Serialize, Deserialize, EnumIter, Debug, IntoStaticStr)] #[sqlx(type_name = "unit")] pub enum Unit { @@ -52,35 +63,42 @@ impl TryFrom<MeasurementDataUnchecked> for MeasurementData { if value.value < 0.0 || value.value.is_nan() { return Err("Invalid value".to_string()); } - match (&value.metric_type, &value.unit) { - (MetricType::BodyWeight, Unit::Kilogram) - | (MetricType::Hip, Unit::Centimeter) - | (MetricType::Waist, Unit::Centimeter) => Ok(Self { - measured_at: value.measured_at, - metric_type: value.metric_type, - value: value.value, - unit: value.unit, - }), - (metric_type, unit) => { - let metric_type: &'static str = metric_type.into(); - let unit: &'static str = unit.into(); - Err(format!("Invalid pair {}, {}", metric_type, unit)) - } + if !value.metric_type.accepts(&value.unit) { + let metric_type: &'static str = (&value.metric_type).into(); + let unit: &'static str = (&value.unit).into(); + return Err(format!("Invalid pair {}, {}", metric_type, unit)); } + Ok(Self { + measured_at: value.measured_at, + metric_type: value.metric_type, + value: value.value, + unit: value.unit, + }) } } +#[derive(Serialize)] +pub struct MetricUnitPair { + pub metric_type: MetricType, + pub unit: Unit, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Order { + #[default] + Asc, + Desc, +} + #[derive(Deserialize)] -pub struct DateQuery { +pub struct ListQuery { pub date: Option<NaiveDate>, pub from: Option<NaiveDate>, pub to: Option<NaiveDate>, -} - -#[derive(Deserialize)] -pub struct AllQuery { pub limit: Option<i64>, - pub reverse: Option<bool>, + #[serde(default)] + pub order: Order, } #[cfg(test)] diff --git a/src/tests.rs b/src/tests.rs index b10c1ea..586f35a 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,7 +1,10 @@ use axum::{ Router, body::Body, - http::{Method, Request, StatusCode, header::CONTENT_TYPE}, + http::{ + HeaderMap, Method, Request, StatusCode, + header::{CONTENT_TYPE, LOCATION}, + }, }; use http_body_util::BodyExt; use serde_json::{Value, json}; @@ -16,7 +19,7 @@ async fn send( method: Method, uri: &str, body: Option<String>, -) -> (StatusCode, Value) { +) -> (StatusCode, HeaderMap, Value) { let mut request = Request::builder().method(method).uri(uri); let body = match body { Some(body) => { @@ -31,12 +34,18 @@ async fn send( .await .unwrap(); let status = response.status(); + let headers = response.headers().clone(); let bytes = response.into_body().collect().await.unwrap().to_bytes(); - (status, serde_json::from_slice(&bytes).unwrap_or(Value::Null)) + ( + status, + headers, + 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 + 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 { @@ -51,9 +60,9 @@ fn measurement(measured_at: &str, metric_type: &str, value: f64, unit: &str) -> 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; + let (status, _, created) = send(app, Method::POST, "/measurements", Some(body)).await; assert_eq!(status, StatusCode::CREATED); - id.as_str().unwrap().to_owned() + created["id"].as_str().unwrap().to_owned() } async fn seed(app: &Router) { @@ -71,34 +80,23 @@ fn values(rows: &Value) -> Vec<f64> { .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) { +async fn health_is_ok(pool: PgPool) { let app = router(pool); - let (status, _) = get_json(&app, "/").await; + let (status, _) = get_json(&app, "/health").await; assert_eq!(status, StatusCode::OK); } #[sqlx::test] -async fn add_returns_created_with_uuid(pool: PgPool) { +async fn create_returns_the_measurement_and_location(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); + let body = measurement("2026-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!( - row, + created, json!({ "id": id, "measured_at": "2026-10-14T12:00:00Z", @@ -107,10 +105,15 @@ async fn add_then_get_by_id_round_trips(pool: PgPool) { "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 add_rejects_bad_bodies(pool: PgPool) { +async fn create_rejects_bad_bodies(pool: PgPool) { let app = router(pool); let cases = [ (json!({"metric_type": "Hip"}).to_string(), StatusCode::UNPROCESSABLE_ENTITY), @@ -119,129 +122,142 @@ async fn add_rejects_bad_bodies(pool: PgPool) { 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; + let (status, _, _) = send(&app, Method::POST, "/measurements", Some(body.clone())).await; assert_eq!(status, expected, "body: {body}"); } - let (_, rows) = get_json(&app, "/all").await; + let (_, rows) = get_json(&app, "/measurements").await; assert!(values(&rows).is_empty()); } #[sqlx::test] -async fn add_accepts_every_valid_pair(pool: PgPool) { +async fn metric_types_lists_the_valid_pairs(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}"); + 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("2026-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 all_is_empty_array_without_data(pool: PgPool) { +async fn list_is_empty_array_without_data(pool: PgPool) { let app = router(pool); - let (status, rows) = get_json(&app, "/all").await; + let (status, rows) = get_json(&app, "/measurements").await; assert_eq!(status, StatusCode::OK); assert_eq!(rows, json!([])); } #[sqlx::test] -async fn all_orders_by_measured_at(pool: PgPool) { +async fn list_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; + 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 all_limit_keeps_oldest(pool: PgPool) { +async fn list_limit_applies_after_ordering(pool: PgPool) { let app = router(pool); seed(&app).await; - let (_, rows) = get_json(&app, "/all?limit=2").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 all_reverse_is_newest_first(pool: PgPool) { +async fn list_single_date_includes_the_whole_day(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]); + let (status, rows) = get_json(&app, "/measurements?date=2026-10-15").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(values(&rows), [2.0, 3.0]); } #[sqlx::test] -async fn all_reverse_with_limit_keeps_newest(pool: PgPool) { +async fn list_ranges_are_inclusive_of_both_days(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}"); + let cases = [ + ("/measurements?from=2026-10-15", vec![2.0, 3.0, 4.0]), + ("/measurements?to=2026-10-14", vec![1.0]), + ("/measurements?from=2026-10-14&to=2026-10-15", vec![1.0, 2.0, 3.0]), + ("/measurements?from=2026-10-15&to=2026-10-15", vec![2.0, 3.0]), + ("/measurements?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!(values(&rows), expected, "{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) { +async fn list_combines_filters_with_order_and_limit(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![]), + ("/measurements?from=2026-10-15&limit=1", vec![2.0]), + ("/measurements?from=2026-10-15&order=desc&limit=2", vec![4.0, 3.0]), + ("/measurements?date=2026-10-15&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!(sorted_values(&rows), expected, "{uri}"); + assert_eq!(values(&rows), expected, "{uri}"); } } #[sqlx::test] -async fn search_rejects_bad_queries(pool: PgPool) { +async fn list_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", + "/measurements?limit=0", + "/measurements?limit=-1", + "/measurements?order=sideways", + "/measurements?date=bad", + "/measurements?from=bad", + "/measurements?date=2026-10-15&from=2026-10-14", + "/measurements?date=2026-10-15&to=2026-10-16", + "/measurements?from=2026-10-16&to=2026-10-14", ]; for uri in uris { let (status, _) = get_json(&app, uri).await; @@ -250,11 +266,11 @@ async fn search_rejects_bad_queries(pool: PgPool) { } #[sqlx::test] -async fn get_by_id_not_found_and_bad_id(pool: PgPool) { +async fn get_not_found_and_bad_id(pool: PgPool) { let app = router(pool); - let (status, _) = get_json(&app, &format!("/measurement/{}", Uuid::now_v7())).await; + let (status, _) = get_json(&app, &format!("/measurements/{}", Uuid::now_v7())).await; assert_eq!(status, StatusCode::NOT_FOUND); - let (status, _) = get_json(&app, "/measurement/not-a-uuid").await; + let (status, _) = get_json(&app, "/measurements/not-a-uuid").await; assert_eq!(status, StatusCode::BAD_REQUEST); } @@ -262,10 +278,11 @@ async fn get_by_id_not_found_and_bad_id(pool: PgPool) { 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 uri = format!("/measurements/{id}"); let body = measurement("2026-10-20T09:30:00Z", "Hip", 95.0, "Centimeter"); - let (status, _) = send(&app, Method::PUT, &format!("/update/{id}"), Some(body)).await; + let (status, _, _) = send(&app, Method::PUT, &uri, Some(body)).await; assert_eq!(status, StatusCode::NO_CONTENT); - let (_, row) = get_json(&app, &format!("/measurement/{id}")).await; + let (_, row) = get_json(&app, &uri).await; assert_eq!( row, json!({ @@ -282,8 +299,8 @@ async fn update_replaces_the_row(pool: PgPool) { 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; + let uri = format!("/measurements/{}", Uuid::now_v7()); + let (status, _, _) = send(&app, Method::PUT, &uri, Some(body)).await; assert_eq!(status, StatusCode::NOT_FOUND); } @@ -291,17 +308,17 @@ async fn update_missing_id_is_not_found(pool: PgPool) { 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 uri = format!("/measurements/{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; + 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; + let (_, row) = get_json(&app, &uri).await; assert_eq!(row["value"], json!(80.0)); } @@ -309,9 +326,10 @@ async fn update_rejects_invalid_body_and_leaves_row_alone(pool: PgPool) { 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; + 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, &format!("/measurement/{id}")).await; + let (status, _) = get_json(&app, &uri).await; assert_eq!(status, StatusCode::NOT_FOUND); } @@ -319,23 +337,21 @@ async fn delete_removes_the_row(pool: PgPool) { 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}"); + let uri = format!("/measurements/{id}"); for _ in 0..2 { - let (status, _) = send(&app, Method::DELETE, &uri, None).await; + 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; + 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 units_and_metric_types_list_the_enums(pool: PgPool) { +async fn old_paths_are_gone(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"])); + for uri in ["/", "/all", "/search?from=2026-10-14", "/units", "/metric_types"] { + let (status, _) = get_json(&app, uri).await; + assert_eq!(status, StatusCode::NOT_FOUND, "{uri}"); + } } |
