diff --git a/app/Models/Exercise.php b/app/Models/Exercise.php index 08f989d..77a214c 100644 --- a/app/Models/Exercise.php +++ b/app/Models/Exercise.php @@ -2,12 +2,15 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Storage; class Exercise extends Model { + use HasFactory; + protected $fillable = [ 'name', 'slug', diff --git a/app/Models/Post.php b/app/Models/Post.php index 8c83000..d8d3d45 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -3,12 +3,15 @@ namespace App\Models; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Facades\Storage; class Post extends Model { + use HasFactory; + protected $fillable = [ 'title', 'slug', diff --git a/database/factories/ExerciseFactory.php b/database/factories/ExerciseFactory.php new file mode 100644 index 0000000..0d5bb8f --- /dev/null +++ b/database/factories/ExerciseFactory.php @@ -0,0 +1,25 @@ + + */ +class ExerciseFactory extends Factory +{ + public function definition(): array + { + $name = ucfirst(fake()->unique()->words(3, true)); + + return [ + 'name' => $name, + 'slug' => Str::slug($name), + 'exercise_db_id' => (string) fake()->unique()->numberBetween(1, 999999), + 'gif_url' => 'https://example.com/exercise.gif', + 'instructions' => implode("\n", fake()->sentences(3)), + ]; + } +} diff --git a/database/factories/WorkoutExerciseFactory.php b/database/factories/WorkoutExerciseFactory.php new file mode 100644 index 0000000..7a71420 --- /dev/null +++ b/database/factories/WorkoutExerciseFactory.php @@ -0,0 +1,27 @@ + + */ +class WorkoutExerciseFactory extends Factory +{ + public function definition(): array + { + return [ + 'workout_id' => Workout::factory(), + 'exercise_id' => Exercise::factory(), + 'position' => 1, + 'sets' => 3, + 'reps' => 10, + 'weight' => null, + 'rest_seconds' => 60, + 'notes' => null, + ]; + } +} diff --git a/database/factories/WorkoutFactory.php b/database/factories/WorkoutFactory.php new file mode 100644 index 0000000..00c2796 --- /dev/null +++ b/database/factories/WorkoutFactory.php @@ -0,0 +1,22 @@ + + */ +class WorkoutFactory extends Factory +{ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'title' => ucfirst(fake()->words(3, true)) . ' Workout', + 'notes' => fake()->sentence(), + 'estimated_minutes' => fake()->numberBetween(15, 90), + ]; + } +} diff --git a/database/factories/WorkoutSessionFactory.php b/database/factories/WorkoutSessionFactory.php new file mode 100644 index 0000000..e3ee8f1 --- /dev/null +++ b/database/factories/WorkoutSessionFactory.php @@ -0,0 +1,23 @@ + + */ +class WorkoutSessionFactory extends Factory +{ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'workout_id' => Workout::factory(), + 'started_at' => now(), + 'completed_at' => null, + ]; + } +} diff --git a/database/factories/YogaCategoryFactory.php b/database/factories/YogaCategoryFactory.php new file mode 100644 index 0000000..a0cba5b --- /dev/null +++ b/database/factories/YogaCategoryFactory.php @@ -0,0 +1,20 @@ + + */ +class YogaCategoryFactory extends Factory +{ + public function definition(): array + { + return [ + 'external_id' => fake()->unique()->numberBetween(1, 999999), + 'name' => ucfirst(fake()->unique()->word()) . ' Yoga', + 'description' => fake()->sentence(), + ]; + } +} diff --git a/database/factories/YogaPoseFactory.php b/database/factories/YogaPoseFactory.php new file mode 100644 index 0000000..f371561 --- /dev/null +++ b/database/factories/YogaPoseFactory.php @@ -0,0 +1,33 @@ + + */ +class YogaPoseFactory extends Factory +{ + public function definition(): array + { + $name = ucfirst(fake()->unique()->words(2, true)) . ' Pose'; + + return [ + 'external_id' => fake()->unique()->numberBetween(1, 999999), + 'category_id' => YogaCategory::factory(), + 'slug' => Str::slug($name), + 'english_name' => $name, + 'sanskrit_name_adapted' => fake()->word(), + 'sanskrit_name' => fake()->word(), + 'translation_name' => fake()->word(), + 'description' => fake()->sentence(), + 'benefits' => fake()->sentence(), + 'url_svg' => null, + 'url_png' => null, + 'url_svg_alt' => null, + ]; + } +} diff --git a/tests/Feature/Admin/AdminAccessTest.php b/tests/Feature/Admin/AdminAccessTest.php new file mode 100644 index 0000000..fdfdea7 --- /dev/null +++ b/tests/Feature/Admin/AdminAccessTest.php @@ -0,0 +1,39 @@ +get(route('admin.dashboard')); + + $response->assertRedirect(route('login')); + } + + public function test_regular_user_is_forbidden(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get(route('admin.dashboard')); + + $response->assertForbidden(); + } + + public function test_admin_can_view_dashboard(): void + { + $admin = User::factory()->create(); + $admin->is_admin = true; + $admin->save(); + + $response = $this->actingAs($admin)->get(route('admin.dashboard')); + + $response->assertOk(); + } +} diff --git a/tests/Feature/Admin/ExerciseCrudTest.php b/tests/Feature/Admin/ExerciseCrudTest.php new file mode 100644 index 0000000..fa77d61 --- /dev/null +++ b/tests/Feature/Admin/ExerciseCrudTest.php @@ -0,0 +1,73 @@ +create(); + $admin->is_admin = true; + $admin->save(); + + return $admin; + } + + public function test_regular_user_cannot_access_exercise_admin(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get(route('admin.exercises.index')); + + $response->assertForbidden(); + } + + public function test_admin_can_create_an_exercise(): void + { + $response = $this->actingAs($this->admin())->post(route('admin.exercises.store'), [ + 'name' => 'Barbell Deadlift', + 'gif_url' => 'https://example.com/deadlift.gif', + 'instructions' => 'Lift the bar.', + ]); + + $response->assertRedirect(route('admin.exercises.index')); + $this->assertDatabaseHas('exercises', [ + 'name' => 'Barbell Deadlift', + 'slug' => 'barbell-deadlift', + ]); + } + + public function test_admin_can_update_an_exercise(): void + { + $exercise = Exercise::factory()->create(['name' => 'Old Name']); + + $response = $this->actingAs($this->admin())->patch(route('admin.exercises.update', $exercise), [ + 'name' => 'New Name', + 'gif_url' => $exercise->gif_url, + 'instructions' => $exercise->instructions, + ]); + + $response->assertRedirect(route('admin.exercises.index')); + $this->assertDatabaseHas('exercises', [ + 'id' => $exercise->id, + 'name' => 'New Name', + ]); + } + + public function test_admin_can_delete_an_exercise(): void + { + $exercise = Exercise::factory()->create(); + + $response = $this->actingAs($this->admin())->delete(route('admin.exercises.destroy', $exercise)); + + $response->assertRedirect(route('admin.exercises.index')); + $this->assertDatabaseMissing('exercises', ['id' => $exercise->id]); + } +} diff --git a/tests/Feature/Admin/PostCrudTest.php b/tests/Feature/Admin/PostCrudTest.php new file mode 100644 index 0000000..47f2644 --- /dev/null +++ b/tests/Feature/Admin/PostCrudTest.php @@ -0,0 +1,89 @@ +create(); + $admin->is_admin = true; + $admin->save(); + + return $admin; + } + + public function test_regular_user_cannot_access_post_admin(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get(route('admin.posts.index')); + + $response->assertForbidden(); + } + + public function test_admin_can_create_a_published_post(): void + { + $admin = $this->admin(); + + $response = $this->actingAs($admin)->post(route('admin.posts.store'), [ + 'title' => 'New Training Tips', + 'excerpt' => 'Short summary', + 'body' => 'First paragraph.', + 'published' => '1', + ]); + + $response->assertRedirect(route('admin.posts.index')); + $this->assertDatabaseHas('posts', [ + 'title' => 'New Training Tips', + 'slug' => 'new-training-tips', + 'author_id' => $admin->id, + ]); + $post = Post::where('slug', 'new-training-tips')->firstOrFail(); + $this->assertTrue($post->is_published); + } + + public function test_admin_can_create_a_draft_post(): void + { + $this->actingAs($this->admin())->post(route('admin.posts.store'), [ + 'title' => 'Unfinished Draft', + 'body' => 'Work in progress.', + ]); + + $post = Post::where('slug', 'unfinished-draft')->firstOrFail(); + $this->assertFalse($post->is_published); + } + + public function test_admin_can_update_a_post(): void + { + $post = Post::factory()->create(['title' => 'Old Title', 'published_at' => null]); + + $response = $this->actingAs($this->admin())->patch(route('admin.posts.update', $post), [ + 'title' => 'Updated Title', + 'body' => $post->body, + 'published' => '1', + ]); + + $response->assertRedirect(route('admin.posts.index')); + $post->refresh(); + $this->assertEquals('Updated Title', $post->title); + $this->assertTrue($post->is_published); + } + + public function test_admin_can_delete_a_post(): void + { + $post = Post::factory()->create(); + + $response = $this->actingAs($this->admin())->delete(route('admin.posts.destroy', $post)); + + $response->assertRedirect(route('admin.posts.index')); + $this->assertDatabaseMissing('posts', ['id' => $post->id]); + } +} diff --git a/tests/Feature/BlogTest.php b/tests/Feature/BlogTest.php new file mode 100644 index 0000000..2b10149 --- /dev/null +++ b/tests/Feature/BlogTest.php @@ -0,0 +1,67 @@ +create(['published_at' => now()->subDay()]); + Post::factory()->draft()->create(); + + $response = $this->get(route('blog.index')); + + $response->assertOk(); + $response->assertSee($published->title); + $response->assertViewHas('posts', function ($posts) { + return $posts->total() === 1; + }); + } + + public function test_index_excludes_future_scheduled_posts(): void + { + Post::factory()->create(['published_at' => now()->addDay()]); + + $response = $this->get(route('blog.index')); + + $response->assertOk(); + $response->assertViewHas('posts', function ($posts) { + return $posts->total() === 0; + }); + } + + public function test_show_displays_a_published_post(): void + { + $post = Post::factory()->create([ + 'title' => 'How to Train Consistently', + 'published_at' => now()->subDay(), + ]); + + $response = $this->get(route('blog.show', $post->slug)); + + $response->assertOk(); + $response->assertSee('How to Train Consistently'); + } + + public function test_show_returns_404_for_a_draft_post(): void + { + $post = Post::factory()->draft()->create(); + + $response = $this->get(route('blog.show', $post->slug)); + + $response->assertNotFound(); + } + + public function test_show_returns_404_for_unknown_slug(): void + { + $response = $this->get('/blog/does-not-exist'); + + $response->assertNotFound(); + } +} diff --git a/tests/Feature/ExerciseTest.php b/tests/Feature/ExerciseTest.php new file mode 100644 index 0000000..55c38c8 --- /dev/null +++ b/tests/Feature/ExerciseTest.php @@ -0,0 +1,76 @@ +count(3)->create(); + + $response = $this->get(route('exercises.index')); + + $response->assertOk(); + $response->assertViewHas('exercises', function ($exercises) { + return $exercises->total() === 3; + }); + } + + public function test_index_can_filter_by_search_query(): void + { + Exercise::factory()->create(['name' => 'Barbell Bench Press']); + Exercise::factory()->create(['name' => 'Dumbbell Curl']); + + $response = $this->get(route('exercises.index', ['q' => 'Bench'])); + + $response->assertOk(); + $response->assertViewHas('exercises', function ($exercises) { + return $exercises->total() === 1 + && $exercises->first()->name === 'Barbell Bench Press'; + }); + } + + public function test_index_can_filter_by_body_part(): void + { + $chest = BodyPart::create(['name' => 'Chest', 'slug' => 'chest']); + $back = BodyPart::create(['name' => 'Back', 'slug' => 'back']); + + $chestExercise = Exercise::factory()->create(); + $chestExercise->bodyParts()->attach($chest->id); + + $backExercise = Exercise::factory()->create(); + $backExercise->bodyParts()->attach($back->id); + + $response = $this->get(route('exercises.index', ['bodypart' => 'chest'])); + + $response->assertOk(); + $response->assertViewHas('exercises', function ($exercises) use ($chestExercise) { + return $exercises->total() === 1 + && $exercises->first()->id === $chestExercise->id; + }); + } + + public function test_show_displays_exercise(): void + { + $exercise = Exercise::factory()->create(['name' => 'Barbell Squat']); + + $response = $this->get(route('exercises.show', $exercise->slug)); + + $response->assertOk(); + $response->assertSee('Barbell Squat'); + } + + public function test_show_returns_404_for_unknown_slug(): void + { + $response = $this->get('/exercises/does-not-exist'); + + $response->assertNotFound(); + } +} diff --git a/tests/Feature/FavoriteTest.php b/tests/Feature/FavoriteTest.php new file mode 100644 index 0000000..368be0c --- /dev/null +++ b/tests/Feature/FavoriteTest.php @@ -0,0 +1,47 @@ +create(); + + $response = $this->post(route('exercises.favorite', $exercise)); + + $response->assertRedirect(route('login')); + } + + public function test_user_can_toggle_exercise_favorite(): void + { + $user = User::factory()->create(); + $exercise = Exercise::factory()->create(); + + $this->actingAs($user)->post(route('exercises.favorite', $exercise)); + $this->assertTrue($user->favoriteExercises()->where('exercise_id', $exercise->id)->exists()); + + $this->actingAs($user)->post(route('exercises.favorite', $exercise)); + $this->assertFalse($user->favoriteExercises()->where('exercise_id', $exercise->id)->exists()); + } + + public function test_user_can_toggle_workout_favorite(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(); + + $this->actingAs($user)->post(route('workouts.favorite', $workout)); + $this->assertTrue($user->favoriteWorkouts()->where('workout_id', $workout->id)->exists()); + + $this->actingAs($user)->post(route('workouts.favorite', $workout)); + $this->assertFalse($user->favoriteWorkouts()->where('workout_id', $workout->id)->exists()); + } +} diff --git a/tests/Feature/WorkoutSessionTest.php b/tests/Feature/WorkoutSessionTest.php new file mode 100644 index 0000000..370246f --- /dev/null +++ b/tests/Feature/WorkoutSessionTest.php @@ -0,0 +1,107 @@ +create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + $item1 = WorkoutExercise::factory()->create(['workout_id' => $workout->id, 'position' => 1]); + $item2 = WorkoutExercise::factory()->create(['workout_id' => $workout->id, 'position' => 2]); + + $response = $this->actingAs($user)->post(route('sessions.store', $workout)); + + $session = WorkoutSession::where('workout_id', $workout->id)->firstOrFail(); + $response->assertRedirect(route('sessions.show', $session)); + $this->assertDatabaseHas('workout_session_items', [ + 'session_id' => $session->id, + 'workout_exercise_id' => $item1->id, + ]); + $this->assertDatabaseHas('workout_session_items', [ + 'session_id' => $session->id, + 'workout_exercise_id' => $item2->id, + ]); + } + + public function test_other_user_cannot_start_a_session_for_someone_elses_workout(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $owner->id]); + + $response = $this->actingAs($other)->post(route('sessions.store', $workout)); + + $response->assertForbidden(); + } + + public function test_owner_can_view_their_session(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + $session = WorkoutSession::factory()->create(['user_id' => $user->id, 'workout_id' => $workout->id]); + + $response = $this->actingAs($user)->get(route('sessions.show', $session)); + + $response->assertOk(); + } + + public function test_other_user_cannot_view_session(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $owner->id]); + $session = WorkoutSession::factory()->create(['user_id' => $owner->id, 'workout_id' => $workout->id]); + + $response = $this->actingAs($other)->get(route('sessions.show', $session)); + + $response->assertForbidden(); + } + + public function test_owner_can_toggle_session_item(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + $item = WorkoutExercise::factory()->create(['workout_id' => $workout->id]); + $session = WorkoutSession::factory()->create(['user_id' => $user->id, 'workout_id' => $workout->id]); + + $response = $this->actingAs($user)->patch(route('sessions.items.toggle', [$session, $item])); + + $response->assertRedirect(); + $this->assertDatabaseHas('workout_session_items', [ + 'session_id' => $session->id, + 'workout_exercise_id' => $item->id, + 'done' => true, + ]); + + // Toggling again flips it back off + $this->actingAs($user)->patch(route('sessions.items.toggle', [$session, $item])); + $this->assertDatabaseHas('workout_session_items', [ + 'session_id' => $session->id, + 'workout_exercise_id' => $item->id, + 'done' => false, + ]); + } + + public function test_owner_can_complete_a_session(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + $session = WorkoutSession::factory()->create(['user_id' => $user->id, 'workout_id' => $workout->id]); + + $response = $this->actingAs($user)->post(route('sessions.complete', $session)); + + $response->assertRedirect(route('workouts.show', $workout)); + $this->assertNotNull($session->fresh()->completed_at); + } +} diff --git a/tests/Feature/WorkoutTest.php b/tests/Feature/WorkoutTest.php new file mode 100644 index 0000000..771b473 --- /dev/null +++ b/tests/Feature/WorkoutTest.php @@ -0,0 +1,149 @@ +get(route('workouts.index')); + + $response->assertRedirect(route('login')); + } + + public function test_user_can_create_a_workout(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('workouts.store'), [ + 'title' => 'Leg Day', + 'notes' => 'Focus on form', + 'estimated_minutes' => 45, + ]); + + $this->assertDatabaseHas('workouts', [ + 'user_id' => $user->id, + 'title' => 'Leg Day', + ]); + $workout = Workout::where('title', 'Leg Day')->firstOrFail(); + $response->assertRedirect(route('workouts.show', $workout)); + } + + public function test_owner_can_view_their_workout(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user)->get(route('workouts.show', $workout)); + + $response->assertOk(); + $response->assertSee($workout->title); + } + + public function test_other_user_cannot_view_workout(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $owner->id]); + + $response = $this->actingAs($other)->get(route('workouts.show', $workout)); + + $response->assertForbidden(); + } + + public function test_owner_can_add_exercise_to_workout(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + $exercise = Exercise::factory()->create(); + + $response = $this->actingAs($user)->post(route('workouts.exercises.add', $workout), [ + 'exercise_id' => $exercise->id, + 'sets' => 4, + 'reps' => 8, + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('workout_exercises', [ + 'workout_id' => $workout->id, + 'exercise_id' => $exercise->id, + 'sets' => 4, + 'reps' => 8, + ]); + } + + public function test_other_user_cannot_add_exercise_to_workout(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $owner->id]); + $exercise = Exercise::factory()->create(); + + $response = $this->actingAs($other)->post(route('workouts.exercises.add', $workout), [ + 'exercise_id' => $exercise->id, + ]); + + $response->assertForbidden(); + } + + public function test_owner_can_update_workout_exercise(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + $item = WorkoutExercise::factory()->create(['workout_id' => $workout->id, 'sets' => 3]); + + $response = $this->actingAs($user)->patch(route('workouts.exercises.update', [$workout, $item]), [ + 'sets' => 5, + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('workout_exercises', [ + 'id' => $item->id, + 'sets' => 5, + ]); + } + + public function test_owner_can_remove_workout_exercise(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + $item = WorkoutExercise::factory()->create(['workout_id' => $workout->id]); + + $response = $this->actingAs($user)->delete(route('workouts.exercises.remove', [$workout, $item])); + + $response->assertRedirect(); + $this->assertDatabaseMissing('workout_exercises', ['id' => $item->id]); + } + + public function test_owner_can_delete_workout(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user)->delete(route('workouts.destroy', $workout)); + + $response->assertRedirect(route('workouts.index')); + $this->assertDatabaseMissing('workouts', ['id' => $workout->id]); + } + + public function test_other_user_cannot_delete_workout(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $owner->id]); + + $response = $this->actingAs($other)->delete(route('workouts.destroy', $workout)); + + $response->assertForbidden(); + $this->assertDatabaseHas('workouts', ['id' => $workout->id]); + } +} diff --git a/tests/Feature/YogaPoseTest.php b/tests/Feature/YogaPoseTest.php new file mode 100644 index 0000000..ca31c4f --- /dev/null +++ b/tests/Feature/YogaPoseTest.php @@ -0,0 +1,58 @@ +count(3)->create(); + + $response = $this->get(route('yoga.index')); + + $response->assertOk(); + $response->assertViewHas('poses', function ($poses) { + return $poses->total() === 3; + }); + } + + public function test_index_can_filter_by_category(): void + { + $core = YogaCategory::factory()->create(['name' => 'Core Yoga']); + $balance = YogaCategory::factory()->create(['name' => 'Balance Yoga']); + + $corePose = YogaPose::factory()->create(['category_id' => $core->id]); + YogaPose::factory()->create(['category_id' => $balance->id]); + + $response = $this->get(route('yoga.index', ['category' => $core->id])); + + $response->assertOk(); + $response->assertViewHas('poses', function ($poses) use ($corePose) { + return $poses->total() === 1 && $poses->first()->id === $corePose->id; + }); + } + + public function test_show_displays_pose(): void + { + $pose = YogaPose::factory()->create(['english_name' => 'Downward Dog']); + + $response = $this->get(route('yoga.show', $pose->slug)); + + $response->assertOk(); + $response->assertSee('Downward Dog'); + } + + public function test_show_returns_404_for_unknown_slug(): void + { + $response = $this->get('/yoga/does-not-exist'); + + $response->assertNotFound(); + } +}