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:?}"),
}
}