hevy 0.1.0

An async Rust client library for the Hevy public API (https://api.hevyapp.com/docs/)
Documentation
mod common;

use common::{TEST_API_KEY, client_for, mock_server};
use hevy::{CreateWorkoutRequest, HevyError, NewWorkout, WorkoutEvent, WorkoutExerciseInput, WorkoutSetInput};
use serde_json::json;
use wiremock::matchers::{body_json, header, method, path, query_param};
use wiremock::{Mock, ResponseTemplate};

fn sample_workout_json() -> serde_json::Value {
    json!({
        "id": "b459cba5-cd6d-463c-abd6-54f8eafcadcb",
        "title": "Morning Workout 💪",
        "routine_id": "b459cba5-cd6d-463c-abd6-54f8eafcadcb",
        "description": "Pushed myself to the limit today!",
        "start_time": "2021-09-14T12:00:00Z",
        "end_time": "2021-09-14T13:00:00Z",
        "updated_at": "2021-09-14T13:00:00Z",
        "created_at": "2021-09-14T12:00:00Z",
        "exercises": [
            {
                "index": 0,
                "title": "Bench Press (Barbell)",
                "notes": "Felt great!",
                "exercise_template_id": "05293BCA",
                "supersets_id": null,
                "sets": [
                    {
                        "index": 0,
                        "type": "normal",
                        "weight_kg": 100,
                        "reps": 10,
                        "distance_meters": null,
                        "duration_seconds": null,
                        "rpe": 9.5,
                        "custom_metric": null
                    }
                ]
            }
        ]
    })
}

#[tokio::test]
async fn list_workouts_sends_api_key_and_pagination_params() {
    let server = mock_server().await;
    let client = client_for(&server);

    Mock::given(method("GET"))
        .and(path("/v1/workouts"))
        .and(header("api-key", TEST_API_KEY))
        .and(query_param("page", "2"))
        .and(query_param("pageSize", "3"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "page": 2,
            "page_count": 5,
            "workouts": [sample_workout_json()],
        })))
        .mount(&server)
        .await;

    let page = client.list_workouts(Some(2), Some(3)).await.unwrap();

    assert_eq!(page.page, 2);
    assert_eq!(page.page_count, 5);
    assert_eq!(page.workouts.len(), 1);
    let workout = &page.workouts[0];
    assert_eq!(workout.id, "b459cba5-cd6d-463c-abd6-54f8eafcadcb");
    assert_eq!(workout.title, "Morning Workout 💪");
    assert_eq!(workout.exercises.len(), 1);
    assert_eq!(workout.exercises[0].sets.len(), 1);
    assert_eq!(workout.exercises[0].sets[0].weight_kg, Some(100.0));
    assert_eq!(workout.exercises[0].sets[0].reps, Some(10.0));
}

#[tokio::test]
async fn get_workout_returns_deserialized_workout() {
    let server = mock_server().await;
    let client = client_for(&server);
    let id = "b459cba5-cd6d-463c-abd6-54f8eafcadcb";

    Mock::given(method("GET"))
        .and(path(format!("/v1/workouts/{id}")))
        .and(header("api-key", TEST_API_KEY))
        .respond_with(ResponseTemplate::new(200).set_body_json(sample_workout_json()))
        .mount(&server)
        .await;

    let workout = client.get_workout(id).await.unwrap();
    assert_eq!(workout.id, id);
    assert_eq!(workout.start_time, "2021-09-14T12:00:00Z");
}

#[tokio::test]
async fn get_workout_not_found_returns_api_error() {
    let server = mock_server().await;
    let client = client_for(&server);

    Mock::given(method("GET"))
        .and(path("/v1/workouts/missing"))
        .respond_with(ResponseTemplate::new(404))
        .mount(&server)
        .await;

    let err = client.get_workout("missing").await.unwrap_err();
    match err {
        HevyError::Api { status, .. } => assert_eq!(status, 404),
        other => panic!("expected HevyError::Api, got {other:?}"),
    }
}

#[tokio::test]
async fn create_workout_sends_expected_body_and_returns_created_workout() {
    let server = mock_server().await;
    let client = client_for(&server);

    let expected_body = json!({
        "workout": {
            "title": "Friday Leg Day 🔥",
            "description": "Medium intensity leg day focusing on quads.",
            "start_time": "2024-08-14T12:00:00Z",
            "end_time": "2024-08-14T12:30:00Z",
            "is_private": false,
            "exercises": [
                {
                    "exercise_template_id": "D04AC939",
                    "sets": [
                        { "type": "normal", "weight_kg": 100.0, "reps": 10 }
                    ]
                }
            ]
        }
    });

    Mock::given(method("POST"))
        .and(path("/v1/workouts"))
        .and(header("api-key", TEST_API_KEY))
        .and(body_json(&expected_body))
        .respond_with(ResponseTemplate::new(201).set_body_json(sample_workout_json()))
        .mount(&server)
        .await;

    let workout = NewWorkout::new(
        "Friday Leg Day 🔥",
        "2024-08-14T12:00:00Z",
        "2024-08-14T12:30:00Z",
    )
    .with_exercise(
        WorkoutExerciseInput::new("D04AC939").with_set(WorkoutSetInput::normal(100.0, 10)),
    );
    let mut workout = workout;
    workout.description = Some("Medium intensity leg day focusing on quads.".to_string());

    let created = client
        .create_workout(&CreateWorkoutRequest::new(workout))
        .await
        .unwrap();

    assert_eq!(created.id, "b459cba5-cd6d-463c-abd6-54f8eafcadcb");
}

#[tokio::test]
async fn create_workout_invalid_body_returns_error_message_from_api() {
    let server = mock_server().await;
    let client = client_for(&server);

    Mock::given(method("POST"))
        .and(path("/v1/workouts"))
        .respond_with(
            ResponseTemplate::new(400).set_body_json(json!({ "error": "Invalid request body" })),
        )
        .mount(&server)
        .await;

    let workout = NewWorkout::new("Bad Workout", "2024-08-14T12:00:00Z", "2024-08-14T12:30:00Z");
    let err = client
        .create_workout(&CreateWorkoutRequest::new(workout))
        .await
        .unwrap_err();

    match err {
        HevyError::Api { status, message } => {
            assert_eq!(status, 400);
            assert_eq!(message, "Invalid request body");
        }
        other => panic!("expected HevyError::Api, got {other:?}"),
    }
}

#[tokio::test]
async fn update_workout_sends_put_request() {
    let server = mock_server().await;
    let client = client_for(&server);
    let id = "b459cba5-cd6d-463c-abd6-54f8eafcadcb";

    Mock::given(method("PUT"))
        .and(path(format!("/v1/workouts/{id}")))
        .respond_with(ResponseTemplate::new(200).set_body_json(sample_workout_json()))
        .mount(&server)
        .await;

    let workout = NewWorkout::new("Updated Title", "2024-08-14T12:00:00Z", "2024-08-14T12:30:00Z");
    let updated = client
        .update_workout(id, &CreateWorkoutRequest::new(workout))
        .await
        .unwrap();

    assert_eq!(updated.id, id);
}

#[tokio::test]
async fn workout_count_returns_integer() {
    let server = mock_server().await;
    let client = client_for(&server);

    Mock::given(method("GET"))
        .and(path("/v1/workouts/count"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "workout_count": 42 })))
        .mount(&server)
        .await;

    let count = client.workout_count().await.unwrap();
    assert_eq!(count, 42);
}

#[tokio::test]
async fn workout_events_deserializes_updated_and_deleted_variants() {
    let server = mock_server().await;
    let client = client_for(&server);

    Mock::given(method("GET"))
        .and(path("/v1/workouts/events"))
        .and(query_param("since", "2024-01-01T00:00:00Z"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "page": 1,
            "page_count": 1,
            "events": [
                { "type": "updated", "workout": sample_workout_json() },
                {
                    "type": "deleted",
                    "id": "efe6801c-4aee-4959-bcdd-fca3f272821b",
                    "deleted_at": "2021-09-13T12:00:00Z"
                }
            ]
        })))
        .mount(&server)
        .await;

    let events = client
        .workout_events(None, None, Some("2024-01-01T00:00:00Z"))
        .await
        .unwrap();

    assert_eq!(events.events.len(), 2);
    match &events.events[0] {
        WorkoutEvent::Updated { workout } => {
            assert_eq!(workout.id, "b459cba5-cd6d-463c-abd6-54f8eafcadcb")
        }
        other => panic!("expected Updated event, got {other:?}"),
    }
    match &events.events[1] {
        WorkoutEvent::Deleted { id, deleted_at } => {
            assert_eq!(id, "efe6801c-4aee-4959-bcdd-fca3f272821b");
            assert_eq!(deleted_at.as_deref(), Some("2021-09-13T12:00:00Z"));
        }
        other => panic!("expected Deleted event, got {other:?}"),
    }
}