Add feature test coverage for the core domain

Only Breeze's default auth/profile tests existed - nothing covered
Workouts, Sessions, Exercises, Yoga, Favorites, Blog, or the Admin
panel, which is the actual product. Adds factories for the domain
models and 45 new feature tests covering the main flows plus
authorization boundaries (owner-only access on workouts/sessions,
admin-only access on /admin, drafts hidden from the public blog).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 11:47:51 +03:00
co-authored by Claude Sonnet 5
parent 221493e56f
commit 0a36afbfdc
17 changed files with 861 additions and 0 deletions
+3
View File
@@ -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',
+3
View File
@@ -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',
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Exercise>
*/
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)),
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace Database\Factories;
use App\Models\Exercise;
use App\Models\Workout;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WorkoutExercise>
*/
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,
];
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Workout>
*/
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),
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace Database\Factories;
use App\Models\User;
use App\Models\Workout;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WorkoutSession>
*/
class WorkoutSessionFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(),
'workout_id' => Workout::factory(),
'started_at' => now(),
'completed_at' => null,
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\YogaCategory>
*/
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(),
];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Database\Factories;
use App\Models\YogaCategory;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\YogaPose>
*/
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,
];
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AdminAccessTest extends TestCase
{
use RefreshDatabase;
public function test_guest_is_redirected_to_login(): void
{
$response = $this->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();
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\Exercise;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExerciseCrudTest extends TestCase
{
use RefreshDatabase;
protected function admin(): User
{
$admin = User::factory()->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]);
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostCrudTest extends TestCase
{
use RefreshDatabase;
protected function admin(): User
{
$admin = User::factory()->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]);
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace Tests\Feature;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BlogTest extends TestCase
{
use RefreshDatabase;
public function test_index_only_lists_published_posts(): void
{
$published = Post::factory()->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();
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace Tests\Feature;
use App\Models\BodyPart;
use App\Models\Exercise;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExerciseTest extends TestCase
{
use RefreshDatabase;
public function test_index_lists_exercises(): void
{
Exercise::factory()->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();
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace Tests\Feature;
use App\Models\Exercise;
use App\Models\User;
use App\Models\Workout;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class FavoriteTest extends TestCase
{
use RefreshDatabase;
public function test_guest_cannot_favorite_an_exercise(): void
{
$exercise = Exercise::factory()->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());
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\Workout;
use App\Models\WorkoutExercise;
use App\Models\WorkoutSession;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WorkoutSessionTest extends TestCase
{
use RefreshDatabase;
public function test_owner_can_start_a_session_with_items_precreated(): void
{
$user = User::factory()->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);
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
namespace Tests\Feature;
use App\Models\Exercise;
use App\Models\User;
use App\Models\Workout;
use App\Models\WorkoutExercise;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WorkoutTest extends TestCase
{
use RefreshDatabase;
public function test_guest_is_redirected_to_login(): void
{
$response = $this->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]);
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace Tests\Feature;
use App\Models\YogaCategory;
use App\Models\YogaPose;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class YogaPoseTest extends TestCase
{
use RefreshDatabase;
public function test_index_lists_poses(): void
{
YogaPose::factory()->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();
}
}