summaryrefslogtreecommitdiff
path: root/src/tests.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/tests.rs')
-rw-r--r--src/tests.rs341
1 files changed, 341 insertions, 0 deletions
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"]));
+}