538 lines
18 KiB
Python
538 lines
18 KiB
Python
import json
|
|
from pathlib import Path
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.main import create_app
|
|
|
|
|
|
def make_client(tmp_path: Path) -> TestClient:
|
|
app = create_app(state_dir=tmp_path)
|
|
return TestClient(app)
|
|
|
|
|
|
def test_health_endpoint_reports_ok(tmp_path: Path) -> None:
|
|
client = make_client(tmp_path)
|
|
|
|
response = client.get('/api/health')
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {'status': 'ok'}
|
|
|
|
|
|
def test_dashboard_summary_seeds_john_and_manndra(tmp_path: Path) -> None:
|
|
client = make_client(tmp_path)
|
|
|
|
response = client.get('/api/dashboard/summary')
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
user_ids = [user['id'] for user in payload['users']]
|
|
|
|
assert user_ids == ['john', 'manndra']
|
|
assert payload['counts'] == {
|
|
'exercise_templates': 31,
|
|
'measurement_entries': 0,
|
|
'routines': 5,
|
|
'weight_entries': 0,
|
|
'workout_sessions': 0,
|
|
}
|
|
john = next(user for user in payload['users'] if user['id'] == 'john')
|
|
assert john['measurement_system'] == 'imperial'
|
|
routine_names = [routine['name'] for routine in payload['routines']]
|
|
assert routine_names == [
|
|
'Monday: Chest',
|
|
'Tuesday: Back',
|
|
'Wednesday: Arms',
|
|
'Thursday: Shoulders',
|
|
'Friday: Legs',
|
|
]
|
|
assert payload['routines'][0]['exercises'][0] == {
|
|
'exercise_name': 'Bench Press',
|
|
'target_sets': 3,
|
|
'target_reps': '10',
|
|
}
|
|
assert '3 sets of 10 reps' in payload['routines'][0]['notes']
|
|
assert 'Weights are still TBD' in payload['routines'][0]['notes']
|
|
template_names = [template['name'] for template in payload['exercise_templates']]
|
|
assert template_names == [
|
|
'Resistance Band Crunches',
|
|
'Calf Raises',
|
|
'Deadlifts',
|
|
'Step Ups',
|
|
'Leg Extensions',
|
|
]
|
|
|
|
|
|
def test_updating_profile_and_logging_weight_computes_bmi(tmp_path: Path) -> None:
|
|
client = make_client(tmp_path)
|
|
|
|
profile_response = client.put(
|
|
'/api/profile/john',
|
|
json={
|
|
'display_name': 'John',
|
|
'height_inches': 68.5,
|
|
},
|
|
)
|
|
assert profile_response.status_code == 200
|
|
|
|
weight_response = client.post(
|
|
'/api/profile/john/weight',
|
|
json={
|
|
'recorded_at': '2026-05-21T08:30:00',
|
|
'weight_lbs': 220,
|
|
'body_fat_percent': 24.5,
|
|
},
|
|
)
|
|
|
|
assert weight_response.status_code == 201
|
|
saved_entry = weight_response.json()
|
|
assert saved_entry['user_id'] == 'john'
|
|
assert saved_entry['weight_lbs'] == 220
|
|
assert saved_entry['body_fat_percent'] == 24.5
|
|
assert round(saved_entry['bmi'], 1) == 33.0
|
|
|
|
summary = client.get('/api/dashboard/summary').json()
|
|
john = next(user for user in summary['users'] if user['id'] == 'john')
|
|
|
|
assert john['current_weight_lbs'] == 220
|
|
assert round(john['current_bmi'], 1) == 33.0
|
|
|
|
|
|
def test_logging_measurements_tracks_latest_imperial_body_metrics(tmp_path: Path) -> None:
|
|
client = make_client(tmp_path)
|
|
|
|
response = client.post(
|
|
'/api/profile/john/measurements',
|
|
json={
|
|
'recorded_at': '2026-05-21T08:45:00',
|
|
'waist_inches': 42,
|
|
'hip_inches': 41,
|
|
'chest_inches': 46,
|
|
'neck_inches': 17,
|
|
'notes': 'Post-op baseline',
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
saved_entry = response.json()
|
|
assert saved_entry['user_id'] == 'john'
|
|
assert saved_entry['waist_inches'] == 42
|
|
assert saved_entry['hip_inches'] == 41
|
|
assert saved_entry['chest_inches'] == 46
|
|
assert saved_entry['neck_inches'] == 17
|
|
|
|
summary = client.get('/api/dashboard/summary').json()
|
|
john = next(user for user in summary['users'] if user['id'] == 'john')
|
|
|
|
assert summary['counts']['measurement_entries'] == 1
|
|
assert john['latest_measurements'] == {
|
|
'recorded_at': '2026-05-21T08:45:00',
|
|
'waist_inches': 42,
|
|
'hip_inches': 41,
|
|
'chest_inches': 46,
|
|
'neck_inches': 17,
|
|
}
|
|
|
|
|
|
def test_can_create_and_list_exercise_templates(tmp_path: Path) -> None:
|
|
client = make_client(tmp_path)
|
|
|
|
create_response = client.post(
|
|
'/api/exercises/templates',
|
|
json={
|
|
'name': 'Bench Press',
|
|
'primary_muscle': 'chest',
|
|
'equipment': 'barbell',
|
|
'notes': 'Flat bench barbell press.',
|
|
},
|
|
)
|
|
|
|
assert create_response.status_code == 201
|
|
saved_template = create_response.json()
|
|
assert saved_template['name'] == 'Bench Press'
|
|
assert saved_template['equipment'] == 'barbell'
|
|
|
|
list_response = client.get('/api/exercises/templates')
|
|
assert list_response.status_code == 200
|
|
payload = list_response.json()
|
|
assert len(payload) == 32
|
|
assert any(
|
|
item['name'] == 'Bench Press' and item['notes'] == 'Flat bench barbell press.'
|
|
for item in payload
|
|
)
|
|
|
|
summary = client.get('/api/dashboard/summary').json()
|
|
assert summary['counts']['exercise_templates'] == 32
|
|
|
|
|
|
def test_can_create_routine_before_real_program_arrives(tmp_path: Path) -> None:
|
|
client = make_client(tmp_path)
|
|
|
|
client.post(
|
|
'/api/exercises/templates',
|
|
json={
|
|
'name': 'Bench Press',
|
|
'primary_muscle': 'chest',
|
|
'equipment': 'barbell',
|
|
},
|
|
)
|
|
client.post(
|
|
'/api/exercises/templates',
|
|
json={
|
|
'name': 'Incline Dumbbell Press',
|
|
'primary_muscle': 'chest',
|
|
'equipment': 'dumbbells',
|
|
},
|
|
)
|
|
|
|
create_response = client.post(
|
|
'/api/routines',
|
|
json={
|
|
'user_id': 'john',
|
|
'name': 'Push Day Placeholder',
|
|
'notes': 'Temporary until John sends the real split.',
|
|
'exercises': [
|
|
{'exercise_name': 'Bench Press', 'target_sets': 3, 'target_reps': '8-10'},
|
|
{'exercise_name': 'Incline Dumbbell Press', 'target_sets': 3, 'target_reps': '10-12'},
|
|
],
|
|
},
|
|
)
|
|
|
|
assert create_response.status_code == 201
|
|
saved_routine = create_response.json()
|
|
assert saved_routine['user_id'] == 'john'
|
|
assert saved_routine['name'] == 'Push Day Placeholder'
|
|
assert len(saved_routine['exercises']) == 2
|
|
|
|
list_response = client.get('/api/routines')
|
|
assert list_response.status_code == 200
|
|
payload = list_response.json()
|
|
assert len(payload) == 6
|
|
assert any(routine['name'] == 'Push Day Placeholder' for routine in payload)
|
|
|
|
summary = client.get('/api/dashboard/summary').json()
|
|
assert summary['counts']['routines'] == 6
|
|
|
|
|
|
def test_history_endpoints_return_newest_first(tmp_path: Path) -> None:
|
|
client = make_client(tmp_path)
|
|
|
|
client.post(
|
|
'/api/profile/john/weight',
|
|
json={'recorded_at': '2026-05-20T08:30:00', 'weight_lbs': 222},
|
|
)
|
|
client.post(
|
|
'/api/profile/john/weight',
|
|
json={'recorded_at': '2026-05-21T08:30:00', 'weight_lbs': 220},
|
|
)
|
|
client.post(
|
|
'/api/profile/john/measurements',
|
|
json={'recorded_at': '2026-05-20T08:45:00', 'waist_inches': 43},
|
|
)
|
|
client.post(
|
|
'/api/profile/john/measurements',
|
|
json={'recorded_at': '2026-05-21T08:45:00', 'waist_inches': 42},
|
|
)
|
|
client.post(
|
|
'/api/workouts',
|
|
json={
|
|
'user_id': 'john',
|
|
'performed_at': '2026-05-20T09:15:00',
|
|
'routine_name': 'Old Workout',
|
|
'exercises': [{'exercise_name': 'Bench Press', 'sets': [{'reps': 8, 'weight_lbs': 135}]}],
|
|
},
|
|
)
|
|
client.post(
|
|
'/api/workouts',
|
|
json={
|
|
'user_id': 'john',
|
|
'performed_at': '2026-05-21T09:15:00',
|
|
'routine_name': 'New Workout',
|
|
'exercises': [{'exercise_name': 'Bench Press', 'sets': [{'reps': 8, 'weight_lbs': 145}]}],
|
|
},
|
|
)
|
|
|
|
weight_history = client.get('/api/history/john/weights')
|
|
measurement_history = client.get('/api/history/john/measurements')
|
|
workout_history = client.get('/api/history/john/workouts')
|
|
|
|
assert weight_history.status_code == 200
|
|
assert measurement_history.status_code == 200
|
|
assert workout_history.status_code == 200
|
|
|
|
assert [entry['weight_lbs'] for entry in weight_history.json()] == [220, 222]
|
|
assert [entry['waist_inches'] for entry in measurement_history.json()] == [42, 43]
|
|
assert [entry['routine_name'] for entry in workout_history.json()] == ['New Workout', 'Old Workout']
|
|
|
|
|
|
def test_logging_structured_workout_updates_recent_activity(tmp_path: Path) -> None:
|
|
client = make_client(tmp_path)
|
|
|
|
response = client.post(
|
|
'/api/workouts',
|
|
json={
|
|
'user_id': 'john',
|
|
'performed_at': '2026-05-21T09:15:00',
|
|
'routine_name': 'Push Day',
|
|
'notes': 'First pass',
|
|
'exercises': [
|
|
{
|
|
'exercise_name': 'Bench Press',
|
|
'sets': [
|
|
{'reps': 8, 'weight_lbs': 135, 'rpe': 7.5, 'rest_seconds': 120},
|
|
{'reps': 8, 'weight_lbs': 145, 'rpe': 8.0, 'rest_seconds': 120},
|
|
],
|
|
}
|
|
],
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
workout = response.json()
|
|
assert workout['user_id'] == 'john'
|
|
assert workout['routine_name'] == 'Push Day'
|
|
assert workout['exercise_count'] == 1
|
|
assert workout['set_count'] == 2
|
|
assert workout['total_volume_lbs'] == 2240
|
|
|
|
summary = client.get('/api/dashboard/summary').json()
|
|
assert summary['counts']['workout_sessions'] == 1
|
|
assert summary['recent_workouts'][0]['routine_name'] == 'Push Day'
|
|
assert summary['recent_workouts'][0]['total_volume_lbs'] == 2240
|
|
|
|
|
|
def test_progression_endpoint_summarizes_exercise_bests(tmp_path: Path) -> None:
|
|
client = make_client(tmp_path)
|
|
|
|
client.post(
|
|
'/api/workouts',
|
|
json={
|
|
'user_id': 'john',
|
|
'performed_at': '2026-05-20T09:15:00',
|
|
'routine_name': 'Push Day',
|
|
'exercises': [
|
|
{
|
|
'exercise_name': 'Bench Press',
|
|
'sets': [
|
|
{'reps': 8, 'weight_lbs': 135},
|
|
{'reps': 5, 'weight_lbs': 155},
|
|
],
|
|
}
|
|
],
|
|
},
|
|
)
|
|
client.post(
|
|
'/api/workouts',
|
|
json={
|
|
'user_id': 'john',
|
|
'performed_at': '2026-05-22T09:15:00',
|
|
'routine_name': 'Push Day',
|
|
'exercises': [
|
|
{
|
|
'exercise_name': 'Bench Press',
|
|
'sets': [
|
|
{'reps': 3, 'weight_lbs': 175},
|
|
],
|
|
},
|
|
{
|
|
'exercise_name': 'Incline Dumbbell Press',
|
|
'sets': [
|
|
{'reps': 10, 'weight_lbs': 55},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
)
|
|
|
|
response = client.get('/api/progression/john/exercises')
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload[0]['exercise_name'] == 'Bench Press'
|
|
assert payload[0]['sessions'] == 2
|
|
assert payload[0]['max_weight_lbs'] == 175
|
|
assert payload[0]['best_set_estimated_1rm'] == 192.5
|
|
assert payload[0]['last_performed_at'] == '2026-05-22T09:15:00'
|
|
assert payload[1]['exercise_name'] == 'Incline Dumbbell Press'
|
|
assert payload[1]['sessions'] == 1
|
|
|
|
|
|
def test_homepage_renders_core_sections_and_forms(tmp_path: Path) -> None:
|
|
client = make_client(tmp_path)
|
|
|
|
response = client.get('/')
|
|
|
|
assert response.status_code == 200
|
|
body = response.text
|
|
assert 'Doris Barbell' in body
|
|
assert 'Recent workouts' in body
|
|
assert 'Current body metrics' in body
|
|
assert 'Log weight' in body
|
|
assert 'Add exercise template' in body
|
|
assert 'Current routine board' in body
|
|
assert 'Monday: Chest' in body
|
|
|
|
|
|
def test_existing_state_gets_seeded_routine_backfill(tmp_path: Path) -> None:
|
|
state_path = tmp_path / 'state.json'
|
|
state_path.write_text(
|
|
json.dumps(
|
|
{
|
|
'users': {
|
|
'john': {
|
|
'id': 'john',
|
|
'display_name': 'John',
|
|
'is_stub': False,
|
|
'measurement_system': 'imperial',
|
|
'height_inches': 68.5,
|
|
'goal_weight_lbs': None,
|
|
'notes': 'Legacy state',
|
|
},
|
|
'manndra': {
|
|
'id': 'manndra',
|
|
'display_name': 'Manndra',
|
|
'is_stub': True,
|
|
'measurement_system': 'imperial',
|
|
'height_inches': None,
|
|
'goal_weight_lbs': None,
|
|
'notes': 'Legacy stub',
|
|
},
|
|
},
|
|
'exercise_templates': [],
|
|
'routines': [],
|
|
'weight_entries': [],
|
|
'measurement_entries': [],
|
|
'workout_sessions': [],
|
|
}
|
|
)
|
|
)
|
|
|
|
client = make_client(tmp_path)
|
|
payload = client.get('/api/dashboard/summary').json()
|
|
|
|
assert payload['counts']['exercise_templates'] == 31
|
|
assert payload['counts']['routines'] == 5
|
|
assert payload['routines'][0]['name'] == 'Monday: Chest'
|
|
assert payload['routines'][0]['exercises'][0]['target_sets'] == 3
|
|
assert payload['routines'][0]['exercises'][0]['target_reps'] == '10'
|
|
|
|
|
|
def test_existing_seeded_routines_upgrade_prescriptions_to_three_by_ten(tmp_path: Path) -> None:
|
|
state_path = tmp_path / 'state.json'
|
|
state_path.write_text(
|
|
json.dumps(
|
|
{
|
|
'users': {
|
|
'john': {
|
|
'id': 'john',
|
|
'display_name': 'John',
|
|
'is_stub': False,
|
|
'measurement_system': 'imperial',
|
|
'height_inches': 68.5,
|
|
'goal_weight_lbs': None,
|
|
'notes': 'Legacy state',
|
|
},
|
|
'manndra': {
|
|
'id': 'manndra',
|
|
'display_name': 'Manndra',
|
|
'is_stub': True,
|
|
'measurement_system': 'imperial',
|
|
'height_inches': None,
|
|
'goal_weight_lbs': None,
|
|
'notes': 'Legacy stub',
|
|
},
|
|
},
|
|
'exercise_templates': [],
|
|
'routines': [
|
|
{
|
|
'id': 'seed-routine-01',
|
|
'user_id': 'john',
|
|
'name': 'Monday: Chest',
|
|
'notes': 'Imported from John\'s current routine. Set and rep targets were not provided yet.',
|
|
'exercises': [
|
|
{'exercise_name': 'Bench Press', 'target_sets': 1, 'target_reps': 'TBD'},
|
|
{'exercise_name': 'Incline Bench Press', 'target_sets': 1, 'target_reps': 'TBD'},
|
|
],
|
|
}
|
|
],
|
|
'weight_entries': [],
|
|
'measurement_entries': [],
|
|
'workout_sessions': [],
|
|
}
|
|
)
|
|
)
|
|
|
|
client = make_client(tmp_path)
|
|
payload = client.get('/api/dashboard/summary').json()
|
|
|
|
monday = next(routine for routine in payload['routines'] if routine['name'] == 'Monday: Chest')
|
|
assert monday['exercises'][0]['target_sets'] == 3
|
|
assert monday['exercises'][0]['target_reps'] == '10'
|
|
assert 'Weights are still TBD' in monday['notes']
|
|
|
|
|
|
def test_weight_form_submission_redirects_and_updates_dashboard(tmp_path: Path) -> None:
|
|
client = make_client(tmp_path)
|
|
|
|
response = client.post(
|
|
'/log/weight',
|
|
data={
|
|
'user_id': 'john',
|
|
'recorded_at': '2026-05-22T06:15:00',
|
|
'weight_lbs': '218.5',
|
|
'body_fat_percent': '23.7',
|
|
'notes': 'Pre-gym weigh-in',
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers['location'] == '/'
|
|
|
|
summary = client.get('/api/dashboard/summary').json()
|
|
john = next(user for user in summary['users'] if user['id'] == 'john')
|
|
assert john['current_weight_lbs'] == 218.5
|
|
|
|
history = client.get('/api/history/john/weights').json()
|
|
assert history[0]['notes'] == 'Pre-gym weigh-in'
|
|
|
|
|
|
def test_template_and_routine_form_submissions_redirect_and_render_entries(tmp_path: Path) -> None:
|
|
client = make_client(tmp_path)
|
|
|
|
template_response = client.post(
|
|
'/templates',
|
|
data={
|
|
'name': 'Leg Press',
|
|
'primary_muscle': 'quads',
|
|
'equipment': 'machine',
|
|
'notes': 'Plate-loaded sled.',
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert template_response.status_code == 303
|
|
assert template_response.headers['location'] == '/'
|
|
|
|
routine_response = client.post(
|
|
'/routines',
|
|
data={
|
|
'user_id': 'john',
|
|
'name': 'Leg Day Draft',
|
|
'notes': 'Temporary until John sends the real split.',
|
|
'exercise_lines': 'Leg Press|4|10-12\nHamstring Curl|3|10-12|Slow negative',
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert routine_response.status_code == 303
|
|
assert routine_response.headers['location'] == '/'
|
|
|
|
page = client.get('/')
|
|
assert page.status_code == 200
|
|
assert 'Leg Press' in page.text
|
|
|
|
routines = client.get('/api/routines').json()
|
|
saved_routine = next(routine for routine in routines if routine['name'] == 'Leg Day Draft')
|
|
assert saved_routine['exercises'][0]['exercise_name'] == 'Leg Press'
|
|
assert saved_routine['exercises'][1]['notes'] == 'Slow negative'
|