Add a community workout library
Workouts were private to their owner with no way to browse or copy
another user's - FavoriteController::toggleWorkout even had a comment
anticipating sharing ("users can still favorite others' workouts if
shared later") that was never followed up on.
Adds an is_public flag workouts can be toggled to from their own page,
a /workouts/community browse+search page, and a copy action that
duplicates a public workout (including all its exercises/sets/reps)
into the copier's own library.
Making this work required splitting workout authorization into two
tiers: authorizeView() (owner, the assigning trainer, or anyone if the
workout is public - read-only) and the existing authorizeWorkout()
(owner or assigning trainer - can add/edit/remove exercises). Deletion
stays strictly owner-only as before. The show page now hides the
"Add Exercise" panel and per-exercise edit/remove controls for viewers
who can see but not manage a workout.
Found and fixed a real Blade compiler quirk via live testing: `@if`
directly after a word character with no space (`exercises@if(...)`)
doesn't compile - Blade's directive regex treats it as email-like text
and leaves it literal - while the matching `@endif` still compiles,
producing invalid mismatched PHP. Fixed by adding a space.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,64 @@ class WorkoutController extends Controller
|
||||
return view('workouts.index', compact('workouts'));
|
||||
}
|
||||
|
||||
public function community(Request $request)
|
||||
{
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
|
||||
$workouts = Workout::query()
|
||||
->where('is_public', true)
|
||||
->with(['user:id,name', 'items'])
|
||||
->when($q !== '', fn ($qb) => $qb->where('title', 'like', "%{$q}%"))
|
||||
->latest()
|
||||
->paginate(12)
|
||||
->withQueryString();
|
||||
|
||||
return view('workouts.community', [
|
||||
'workouts' => $workouts,
|
||||
'filters' => ['q' => $q],
|
||||
]);
|
||||
}
|
||||
|
||||
public function togglePublic(Workout $workout)
|
||||
{
|
||||
if ($workout->user_id !== Auth::id()) {
|
||||
abort(403);
|
||||
}
|
||||
$workout->is_public = !$workout->is_public;
|
||||
$workout->save();
|
||||
|
||||
return back()->with('status', $workout->is_public ? 'Workout is now public' : 'Workout is now private');
|
||||
}
|
||||
|
||||
public function copy(Workout $workout)
|
||||
{
|
||||
if (!$workout->is_public && $workout->user_id !== Auth::id()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workout->loadMissing('items');
|
||||
|
||||
$copy = Auth::user()->workouts()->create([
|
||||
'title' => $workout->title . ' (copy)',
|
||||
'notes' => $workout->notes,
|
||||
'estimated_minutes' => $workout->estimated_minutes,
|
||||
]);
|
||||
|
||||
foreach ($workout->items as $item) {
|
||||
$copy->items()->create([
|
||||
'exercise_id' => $item->exercise_id,
|
||||
'position' => $item->position,
|
||||
'sets' => $item->sets,
|
||||
'reps' => $item->reps,
|
||||
'weight' => $item->weight,
|
||||
'rest_seconds' => $item->rest_seconds,
|
||||
'notes' => $item->notes,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('workouts.show', $copy)->with('status', 'Workout copied to your library');
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
// Simple create form (builder-like)
|
||||
@@ -40,8 +98,8 @@ class WorkoutController extends Controller
|
||||
|
||||
public function show(Workout $workout)
|
||||
{
|
||||
$this->authorizeWorkout($workout);
|
||||
$workout->load(['items.exercise', 'assignedByTrainer']);
|
||||
$this->authorizeView($workout);
|
||||
$workout->load(['items.exercise', 'assignedByTrainer', 'user:id,name']);
|
||||
$exercises = Exercise::orderBy('name')->limit(20)->get(['id','name','slug']);
|
||||
return view('workouts.show', compact('workout', 'exercises'));
|
||||
}
|
||||
@@ -133,4 +191,14 @@ class WorkoutController extends Controller
|
||||
}
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Read-only: anyone can view a public workout (to decide whether to copy it),
|
||||
// in addition to everyone authorizeWorkout() already allows.
|
||||
protected function authorizeView(Workout $workout): void
|
||||
{
|
||||
if ($workout->is_public) {
|
||||
return;
|
||||
}
|
||||
$this->authorizeWorkout($workout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,14 @@ class Workout extends Model
|
||||
'week_number',
|
||||
'day_number',
|
||||
'assigned_by_trainer_id',
|
||||
'is_public',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'estimated_minutes' => 'integer',
|
||||
'week_number' => 'integer',
|
||||
'day_number' => 'integer',
|
||||
'is_public' => 'boolean',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('workouts', function (Blueprint $table) {
|
||||
$table->boolean('is_public')->default(false)->after('title');
|
||||
$table->index('is_public');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('workouts', function (Blueprint $table) {
|
||||
$table->dropIndex(['is_public']);
|
||||
$table->dropColumn('is_public');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<x-app-layout>
|
||||
<section class="bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||
<a href="{{ route('workouts.index') }}" class="text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">← My Workouts</a>
|
||||
<h1 class="mt-2 text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">Community Workouts</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-300">Workouts other members have made public. Copy one to your own library to use it.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6">
|
||||
<form action="{{ route('workouts.community') }}" method="GET" class="mb-6 flex items-end gap-3 bg-white dark:bg-gray-800 rounded-2xl p-4 ring-1 ring-gray-200 dark:ring-gray-700">
|
||||
<div class="w-full sm:w-80">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
|
||||
<input type="text" name="q" value="{{ $filters['q'] ?? '' }}" placeholder="Search by title" class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2">
|
||||
</div>
|
||||
<button type="submit" class="inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-fuchsia-500 hover:to-indigo-500">Search</button>
|
||||
</form>
|
||||
|
||||
@if($workouts->count() === 0)
|
||||
<div class="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 p-8 text-center text-gray-600 dark:text-gray-300">No public workouts yet. Make one of yours public from its page to be the first.</div>
|
||||
@else
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@foreach($workouts as $workout)
|
||||
<a href="{{ route('workouts.show', $workout) }}" class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5 hover:shadow-lg transition flex flex-col">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ $workout->title }}</h3>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">by {{ $workout->user->name }} · {{ $workout->items->count() }} exercises @if($workout->estimated_minutes) · ~{{ $workout->estimated_minutes }} min @endif</p>
|
||||
@if($workout->notes)
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300 line-clamp-3">{{ $workout->notes }}</p>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="mt-6">{{ $workouts->links() }}</div>
|
||||
@endif
|
||||
</section>
|
||||
</x-app-layout>
|
||||
@@ -6,7 +6,10 @@
|
||||
<h1 class="text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">{{ __('app.workouts.my_workouts') }}</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-300">{{ __('app.workouts.manage_desc') }}</p>
|
||||
</div>
|
||||
<a href="{{ route('workouts.create') }}" class="inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-fuchsia-500 hover:to-indigo-500">{{ __('app.workouts.new_workout') }}</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ route('workouts.community') }}" class="inline-flex items-center rounded-xl bg-gray-100 dark:bg-gray-800 px-4 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 ring-1 ring-gray-200 dark:ring-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700">Browse Community</a>
|
||||
<a href="{{ route('workouts.create') }}" class="inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-fuchsia-500 hover:to-indigo-500">{{ __('app.workouts.new_workout') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<x-app-layout>
|
||||
@php
|
||||
$isOwner = Auth::id() === $workout->user_id;
|
||||
$canManage = $isOwner || (Auth::check() && $workout->assigned_by_trainer_id === Auth::id());
|
||||
@endphp
|
||||
<section class="bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<a href="{{ route('workouts.index') }}" class="text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">← Back to Workouts</a>
|
||||
<a href="{{ url()->previous() === url()->current() ? route('workouts.index') : url()->previous() }}" class="text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">← Back</a>
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
<h1 class="text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">{{ $workout->title }}</h1>
|
||||
@auth
|
||||
@@ -26,19 +30,33 @@
|
||||
@if($workout->assignedByTrainer)
|
||||
<span class="mt-2 inline-flex items-center rounded-full bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300 px-2.5 py-0.5 text-xs font-medium">Assigned by {{ $workout->assignedByTrainer->name }}</span>
|
||||
@endif
|
||||
@if(!$isOwner && $workout->is_public)
|
||||
<span class="mt-2 inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 px-2.5 py-0.5 text-xs font-medium">Public workout by {{ $workout->user->name }}</span>
|
||||
@endif
|
||||
<p class="mt-1 text-gray-600 dark:text-gray-300">@if($workout->estimated_minutes) ~ {{ $workout->estimated_minutes }} min · @endif {{ $workout->items->count() }} exercises</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@if(Auth::id() === $workout->user_id)
|
||||
@if($isOwner)
|
||||
<form method="POST" action="{{ route('sessions.store', $workout) }}">
|
||||
@csrf
|
||||
<button type="submit" class="inline-flex items-center rounded-xl bg-gradient-to-r from-emerald-600 to-teal-600 px-3 py-2 text-sm font-semibold text-white shadow hover:from-emerald-500 hover:to-teal-500" title="{{ __('app.sessions.start') }}" aria-label="{{ __('app.sessions.start') }}">{{ __('app.sessions.start') }}</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('workouts.toggle-public', $workout) }}">
|
||||
@csrf
|
||||
<button type="submit" class="inline-flex items-center gap-1.5 rounded-full bg-gray-100 dark:bg-gray-800 px-3 py-1 text-xs font-semibold text-gray-900 dark:text-gray-100 ring-1 ring-gray-200 dark:ring-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
{{ $workout->is_public ? 'Public' : 'Private' }}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('workouts.destroy', $workout) }}" onsubmit="return confirm('{{ __('app.workouts.confirm_delete') }}');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-sm text-red-600 hover:text-red-500">{{ __('app.workouts.delete') }}</button>
|
||||
</form>
|
||||
@elseif(Auth::check() && $workout->is_public)
|
||||
<form method="POST" action="{{ route('workouts.copy', $workout) }}">
|
||||
@csrf
|
||||
<button type="submit" class="inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow hover:from-fuchsia-500 hover:to-indigo-500">Copy to My Workouts</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,30 +101,34 @@
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $item->notes }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<form method="POST" action="{{ route('workouts.exercises.remove', [$workout, $item]) }}" onsubmit="return confirm('Remove this exercise?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-sm text-red-600 hover:text-red-500">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<details class="mt-3">
|
||||
<summary class="cursor-pointer text-sm text-indigo-600">Edit</summary>
|
||||
<form method="POST" action="{{ route('workouts.exercises.update', [$workout, $item]) }}" class="mt-3 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<input type="number" name="position" value="{{ $item->position }}" min="1" placeholder="Position" class="rounded-lg border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2" />
|
||||
<input type="number" name="sets" value="{{ $item->sets }}" min="1" placeholder="Sets" class="rounded-lg border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2" />
|
||||
<input type="number" name="reps" value="{{ $item->reps }}" min="1" placeholder="Reps" class="rounded-lg border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2" />
|
||||
<input type="number" step="0.5" name="weight" value="{{ $item->weight }}" min="0" placeholder="Weight (kg)" class="rounded-lg border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2" />
|
||||
<input type="number" name="rest_seconds" value="{{ $item->rest_seconds }}" min="0" placeholder="Rest (s)" class="rounded-lg border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2" />
|
||||
<input type="text" name="notes" value="{{ $item->notes }}" placeholder="Notes" class="lg:col-span-6 rounded-lg border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2" />
|
||||
<div class="lg:col-span-6">
|
||||
<button type="submit" class="inline-flex items-center rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500">Save</button>
|
||||
@if($canManage)
|
||||
<div class="flex items-center gap-3">
|
||||
<form method="POST" action="{{ route('workouts.exercises.remove', [$workout, $item]) }}" onsubmit="return confirm('Remove this exercise?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-sm text-red-600 hover:text-red-500">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
@endif
|
||||
</div>
|
||||
@if($canManage)
|
||||
<details class="mt-3">
|
||||
<summary class="cursor-pointer text-sm text-indigo-600">Edit</summary>
|
||||
<form method="POST" action="{{ route('workouts.exercises.update', [$workout, $item]) }}" class="mt-3 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<input type="number" name="position" value="{{ $item->position }}" min="1" placeholder="Position" class="rounded-lg border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2" />
|
||||
<input type="number" name="sets" value="{{ $item->sets }}" min="1" placeholder="Sets" class="rounded-lg border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2" />
|
||||
<input type="number" name="reps" value="{{ $item->reps }}" min="1" placeholder="Reps" class="rounded-lg border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2" />
|
||||
<input type="number" step="0.5" name="weight" value="{{ $item->weight }}" min="0" placeholder="Weight (kg)" class="rounded-lg border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2" />
|
||||
<input type="number" name="rest_seconds" value="{{ $item->rest_seconds }}" min="0" placeholder="Rest (s)" class="rounded-lg border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2" />
|
||||
<input type="text" name="notes" value="{{ $item->notes }}" placeholder="Notes" class="lg:col-span-6 rounded-lg border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2" />
|
||||
<div class="lg:col-span-6">
|
||||
<button type="submit" class="inline-flex items-center rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@@ -114,6 +136,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:col-span-5">
|
||||
@if($canManage)
|
||||
<div class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Add Exercise</h2>
|
||||
<form method="POST" action="{{ route('workouts.exercises.add', $workout) }}" class="mt-4 space-y-4"
|
||||
@@ -165,6 +188,7 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -44,11 +44,14 @@ Route::prefix('/exercises')->group(function () {
|
||||
Route::middleware('auth')->prefix('/workouts')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\WorkoutController::class, 'index'])->name('workouts.index');
|
||||
Route::get('/builder', [\App\Http\Controllers\WorkoutController::class, 'builder'])->name('workouts.builder');
|
||||
Route::get('/community', [\App\Http\Controllers\WorkoutController::class, 'community'])->name('workouts.community');
|
||||
|
||||
Route::get('/create', [\App\Http\Controllers\WorkoutController::class, 'create'])->name('workouts.create');
|
||||
Route::post('/', [\App\Http\Controllers\WorkoutController::class, 'store'])->name('workouts.store');
|
||||
Route::get('/{workout}', [\App\Http\Controllers\WorkoutController::class, 'show'])->name('workouts.show');
|
||||
Route::delete('/{workout}', [\App\Http\Controllers\WorkoutController::class, 'destroy'])->name('workouts.destroy');
|
||||
Route::post('/{workout}/toggle-public', [\App\Http\Controllers\WorkoutController::class, 'togglePublic'])->name('workouts.toggle-public');
|
||||
Route::post('/{workout}/copy', [\App\Http\Controllers\WorkoutController::class, 'copy'])->name('workouts.copy');
|
||||
|
||||
// Manage exercises within a workout
|
||||
Route::get('/{workout}/exercises/search', [\App\Http\Controllers\WorkoutController::class, 'searchExercises'])->name('workouts.exercises.search');
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<?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 WorkoutCommunityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_community_index_only_lists_public_workouts(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$public = Workout::factory()->create(['user_id' => $user->id, 'is_public' => true, 'title' => 'Public One']);
|
||||
Workout::factory()->create(['user_id' => $user->id, 'is_public' => false, 'title' => 'Private One']);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('workouts.community'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Public One');
|
||||
$response->assertDontSee('Private One');
|
||||
}
|
||||
|
||||
public function test_community_index_can_search_by_title(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
Workout::factory()->create(['user_id' => $user->id, 'is_public' => true, 'title' => 'Leg Day Blast']);
|
||||
Workout::factory()->create(['user_id' => $user->id, 'is_public' => true, 'title' => 'Upper Body Push']);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('workouts.community', ['q' => 'Leg']));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Leg Day Blast');
|
||||
$response->assertDontSee('Upper Body Push');
|
||||
}
|
||||
|
||||
public function test_owner_can_toggle_a_workout_public(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$workout = Workout::factory()->create(['user_id' => $user->id, 'is_public' => false]);
|
||||
|
||||
$this->actingAs($user)->post(route('workouts.toggle-public', $workout));
|
||||
|
||||
$this->assertTrue($workout->fresh()->is_public);
|
||||
|
||||
$this->actingAs($user)->post(route('workouts.toggle-public', $workout));
|
||||
|
||||
$this->assertFalse($workout->fresh()->is_public);
|
||||
}
|
||||
|
||||
public function test_other_user_cannot_toggle_someone_elses_workout_public(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
$workout = Workout::factory()->create(['user_id' => $owner->id, 'is_public' => false]);
|
||||
|
||||
$response = $this->actingAs($other)->post(route('workouts.toggle-public', $workout));
|
||||
|
||||
$response->assertForbidden();
|
||||
$this->assertFalse($workout->fresh()->is_public);
|
||||
}
|
||||
|
||||
public function test_any_user_can_view_a_public_workout(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$viewer = User::factory()->create();
|
||||
$workout = Workout::factory()->create(['user_id' => $owner->id, 'is_public' => true, 'title' => 'Full Body Blast']);
|
||||
|
||||
$response = $this->actingAs($viewer)->get(route('workouts.show', $workout));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Full Body Blast');
|
||||
}
|
||||
|
||||
public function test_viewer_of_a_public_workout_cannot_manage_its_exercises(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$viewer = User::factory()->create();
|
||||
$workout = Workout::factory()->create(['user_id' => $owner->id, 'is_public' => true]);
|
||||
$exercise = Exercise::factory()->create();
|
||||
|
||||
$response = $this->actingAs($viewer)->get(route('workouts.show', $workout));
|
||||
$response->assertOk();
|
||||
$response->assertDontSee('Add Exercise');
|
||||
|
||||
$addResponse = $this->actingAs($viewer)->post(route('workouts.exercises.add', $workout), [
|
||||
'exercise_id' => $exercise->id,
|
||||
]);
|
||||
$addResponse->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_non_owner_cannot_view_a_private_workout(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$viewer = User::factory()->create();
|
||||
$workout = Workout::factory()->create(['user_id' => $owner->id, 'is_public' => false]);
|
||||
|
||||
$response = $this->actingAs($viewer)->get(route('workouts.show', $workout));
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_user_can_copy_a_public_workout_with_its_exercises(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$copier = User::factory()->create();
|
||||
$workout = Workout::factory()->create([
|
||||
'user_id' => $owner->id,
|
||||
'is_public' => true,
|
||||
'title' => 'Original Workout',
|
||||
'estimated_minutes' => 45,
|
||||
]);
|
||||
WorkoutExercise::factory()->create(['workout_id' => $workout->id, 'sets' => 4, 'reps' => 6]);
|
||||
|
||||
$response = $this->actingAs($copier)->post(route('workouts.copy', $workout));
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseHas('workouts', [
|
||||
'user_id' => $copier->id,
|
||||
'title' => 'Original Workout (copy)',
|
||||
'estimated_minutes' => 45,
|
||||
]);
|
||||
$copiedWorkout = Workout::where('user_id', $copier->id)->firstOrFail();
|
||||
$this->assertEquals(1, $copiedWorkout->items()->count());
|
||||
$this->assertEquals(4, $copiedWorkout->items()->first()->sets);
|
||||
|
||||
// The original is untouched
|
||||
$this->assertEquals(1, $workout->fresh()->items()->count());
|
||||
}
|
||||
|
||||
public function test_user_cannot_copy_a_private_workout_they_do_not_own(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
$workout = Workout::factory()->create(['user_id' => $owner->id, 'is_public' => false]);
|
||||
|
||||
$response = $this->actingAs($other)->post(route('workouts.copy', $workout));
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_owner_can_copy_their_own_private_workout(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$workout = Workout::factory()->create(['user_id' => $owner->id, 'is_public' => false]);
|
||||
|
||||
$response = $this->actingAs($owner)->post(route('workouts.copy', $workout));
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertEquals(2, Workout::where('user_id', $owner->id)->count());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user