summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/handlers.rs157
-rw-r--r--src/main.rs26
-rw-r--r--src/models.rs60
-rw-r--r--src/tests.rs238
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}");
+ }
}