summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/models.rs7
-rw-r--r--src/tests.rs144
2 files changed, 100 insertions, 51 deletions
diff --git a/src/models.rs b/src/models.rs
index 15da87b..480ef2f 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -1,8 +1,10 @@
-use chrono::{DateTime, Utc};
+use chrono::{DateTime, TimeDelta, Utc};
use serde::{Deserialize, Serialize};
use strum::{EnumIter, IntoStaticStr};
use uuid::Uuid;
+const FUTURE_TOLERANCE: TimeDelta = TimeDelta::minutes(5);
+
#[derive(sqlx::Type, Serialize, Deserialize, EnumIter, Debug, IntoStaticStr, Clone, Copy)]
#[sqlx(type_name = "metric_type")]
pub enum MetricType {
@@ -62,6 +64,9 @@ impl TryFrom<MeasurementDataUnchecked> for MeasurementData {
if value.value < 0.0 || value.value.is_nan() {
return Err("Invalid value".to_string());
}
+ if value.measured_at > Utc::now() + FUTURE_TOLERANCE {
+ return Err("measured_at is in the future".to_string());
+ }
if !value.metric_type.accepts(&value.unit) {
let metric_type: &'static str = (&value.metric_type).into();
let unit: &'static str = (&value.unit).into();
diff --git a/src/tests.rs b/src/tests.rs
index 1f539bd..e97065a 100644
--- a/src/tests.rs
+++ b/src/tests.rs
@@ -6,6 +6,7 @@ use axum::{
header::{CONTENT_TYPE, LOCATION},
},
};
+use chrono::{TimeDelta, Utc};
use http_body_util::BodyExt;
use serde_json::{Value, json};
use sqlx::PgPool;
@@ -66,10 +67,10 @@ async fn add_waist(app: &Router, measured_at: &str, value: f64) -> String {
}
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;
+ 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<f64> {
@@ -90,7 +91,7 @@ async fn health_is_ok(pool: PgPool) {
#[sqlx::test]
async fn create_returns_the_measurement_and_location(pool: PgPool) {
let app = router(pool);
- let body = measurement("2026-10-14T12:00:00Z", "Waist", 80.5, "Centimeter");
+ 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();
@@ -99,7 +100,7 @@ async fn create_returns_the_measurement_and_location(pool: PgPool) {
created,
json!({
"id": id,
- "measured_at": "2026-10-14T12:00:00Z",
+ "measured_at": "2025-10-14T12:00:00Z",
"metric_type": "Waist",
"value": 80.5,
"unit": "Centimeter",
@@ -121,15 +122,15 @@ async fn create_rejects_bad_bodies(pool: PgPool) {
StatusCode::UNPROCESSABLE_ENTITY,
),
(
- measurement("2026-10-14T12:00:00Z", "Nope", 1.0, "Kilogram"),
+ measurement("2025-10-14T12:00:00Z", "Nope", 1.0, "Kilogram"),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
- measurement("2026-10-14T12:00:00Z", "Waist", -1.0, "Centimeter"),
+ measurement("2025-10-14T12:00:00Z", "Waist", -1.0, "Centimeter"),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
- measurement("2026-10-14T12:00:00", "Waist", 80.0, "Centimeter"),
+ measurement("2025-10-14T12:00:00", "Waist", 80.0, "Centimeter"),
StatusCode::UNPROCESSABLE_ENTITY,
),
("not json".to_owned(), StatusCode::BAD_REQUEST),
@@ -170,7 +171,7 @@ async fn create_accepts_exactly_the_advertised_pairs(pool: PgPool) {
} else {
StatusCode::UNPROCESSABLE_ENTITY
};
- let body = measurement("2026-10-14T12:00:00Z", metric_type, 70.0, unit);
+ 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}");
}
@@ -188,10 +189,10 @@ async fn list_is_empty_array_without_data(pool: PgPool) {
#[sqlx::test]
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;
+ 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;
@@ -214,7 +215,7 @@ async fn list_limit_applies_after_ordering(pool: PgPool) {
async fn list_bounds_are_half_open(pool: PgPool) {
let app = router(pool);
seed(&app).await;
- let uri = "/measurements?from=2026-10-15T00:00:00Z&to=2026-10-16T08:00:00Z";
+ 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]);
@@ -226,20 +227,20 @@ async fn list_filters_by_utc_timestamps(pool: PgPool) {
seed(&app).await;
let cases = [
(
- "/measurements?from=2026-10-15T00:00:00Z",
+ "/measurements?from=2025-10-15T00:00:00Z",
vec![2.0, 3.0, 4.0],
),
- ("/measurements?to=2026-10-15T00:00:00Z", vec![1.0]),
+ ("/measurements?to=2025-10-15T00:00:00Z", vec![1.0]),
(
- "/measurements?from=2026-10-14T15:00:00Z&to=2026-10-15T15:00:00Z",
+ "/measurements?from=2025-10-14T15:00:00Z&to=2025-10-15T15:00:00Z",
vec![2.0],
),
(
- "/measurements?from=2026-10-15T09:00:00%2B09:00",
+ "/measurements?from=2025-10-15T09:00:00%2B09:00",
vec![2.0, 3.0, 4.0],
),
(
- "/measurements?from=2026-10-15T00:00:00Z&to=2026-10-15T00:00:00Z",
+ "/measurements?from=2025-10-15T00:00:00Z&to=2025-10-15T00:00:00Z",
vec![],
),
("/measurements?from=2030-01-01T00:00:00Z", vec![]),
@@ -256,13 +257,13 @@ async fn list_combines_filters_with_order_and_limit(pool: PgPool) {
let app = router(pool);
seed(&app).await;
let cases = [
- ("/measurements?from=2026-10-15T00:00:00Z&limit=1", vec![2.0]),
+ ("/measurements?from=2025-10-15T00:00:00Z&limit=1", vec![2.0]),
(
- "/measurements?from=2026-10-15T00:00:00Z&order=desc&limit=2",
+ "/measurements?from=2025-10-15T00:00:00Z&order=desc&limit=2",
vec![4.0, 3.0],
),
(
- "/measurements?from=2026-10-15T00:00:00Z&to=2026-10-16T00:00:00Z&order=desc",
+ "/measurements?from=2025-10-15T00:00:00Z&to=2025-10-16T00:00:00Z&order=desc",
vec![3.0, 2.0],
),
];
@@ -277,11 +278,11 @@ async fn list_combines_filters_with_order_and_limit(pool: PgPool) {
async fn list_filters_by_metric_type(pool: PgPool) {
let app = router(pool);
let rows = [
- ("2026-10-14T12:00:00Z", "BodyWeight", 70.0, "Kilogram"),
- ("2026-10-15T12:00:00Z", "Waist", 80.0, "Centimeter"),
- ("2026-10-16T12:00:00Z", "BodyWeight", 71.0, "Kilogram"),
- ("2026-10-17T12:00:00Z", "Hip", 95.0, "Centimeter"),
- ("2026-10-18T12:00:00Z", "BodyWeight", 72.0, "Kilogram"),
+ ("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);
@@ -301,11 +302,11 @@ async fn list_filters_by_metric_type(pool: PgPool) {
vec![72.0, 71.0],
),
(
- "/measurements?metric_type=BodyWeight&from=2026-10-15T00:00:00Z&to=2026-10-18T00:00:00Z",
+ "/measurements?metric_type=BodyWeight&from=2025-10-15T00:00:00Z&to=2025-10-18T00:00:00Z",
vec![71.0],
),
(
- "/measurements?metric_type=Hip&to=2026-10-17T00:00:00Z",
+ "/measurements?metric_type=Hip&to=2025-10-17T00:00:00Z",
vec![],
),
];
@@ -326,11 +327,11 @@ async fn list_rejects_bad_queries(pool: PgPool) {
"/measurements?metric_type=Nope",
"/measurements?metric_type=bodyweight",
"/measurements?from=bad",
- "/measurements?from=2026-10-15",
- "/measurements?to=2026-10-15T00:00:00",
- "/measurements?from=2026-10-15T09:00:00+09:00",
- "/measurements?date=2026-10-15",
- "/measurements?from=2026-10-16T00:00:00Z&to=2026-10-14T00:00:00Z",
+ "/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;
@@ -350,9 +351,9 @@ async fn get_not_found_and_bad_id(pool: PgPool) {
#[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 id = add_waist(&app, "2025-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 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;
@@ -360,7 +361,7 @@ async fn update_replaces_the_row(pool: PgPool) {
row,
json!({
"id": id,
- "measured_at": "2026-10-20T09:30:00Z",
+ "measured_at": "2025-10-20T09:30:00Z",
"metric_type": "Hip",
"value": 95.0,
"unit": "Centimeter",
@@ -371,7 +372,7 @@ async fn update_replaces_the_row(pool: PgPool) {
#[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 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);
@@ -380,12 +381,12 @@ async fn update_missing_id_is_not_found(pool: PgPool) {
#[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 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("2026-10-20T09:30:00Z", "Waist", 82.0, "Kilogram"),
- measurement("2026-10-20T09:30:00Z", "Waist", -1.0, "Centimeter"),
+ 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;
@@ -398,7 +399,7 @@ async fn update_rejects_invalid_body_and_leaves_row_alone(pool: PgPool) {
#[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 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);
@@ -409,7 +410,7 @@ async fn delete_removes_the_row(pool: PgPool) {
#[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 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;
@@ -426,7 +427,7 @@ async fn old_paths_are_gone(pool: PgPool) {
for uri in [
"/",
"/all",
- "/search?from=2026-10-14",
+ "/search?from=2025-10-14",
"/units",
"/metric_types",
] {
@@ -439,8 +440,8 @@ async fn old_paths_are_gone(pool: PgPool) {
async fn errors_have_a_json_body(pool: PgPool) {
let app = router(pool);
let missing = format!("/measurements/{}", Uuid::now_v7());
- let valid = measurement("2026-10-14T12:00:00Z", "Waist", 80.0, "Centimeter");
- let invalid_pair = measurement("2026-10-14T12:00:00Z", "Waist", 80.0, "Kilogram");
+ 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,
@@ -501,7 +502,7 @@ async fn error_messages_explain_the_problem(pool: PgPool) {
let cases = [
("/measurements?limit=0", "limit must be positive"),
(
- "/measurements?from=2026-10-16T00:00:00Z&to=2026-10-14T00:00:00Z",
+ "/measurements?from=2025-10-16T00:00:00Z&to=2025-10-14T00:00:00Z",
"from must not be after to",
),
];
@@ -509,10 +510,10 @@ async fn error_messages_explain_the_problem(pool: PgPool) {
let (_, body) = get_json(&app, uri).await;
assert_eq!(body["error"], message, "{uri}");
}
- let (_, body) = get_json(&app, "/measurements?date=2026-10-15").await;
+ 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("2026-10-14T12:00:00Z", "Waist", 80.0, "Kilogram");
+ 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!(
@@ -520,3 +521,46 @@ async fn error_messages_explain_the_problem(pool: PgPool) {
"{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));
+}