diff --git a/app/Http/Controllers/PostReviewsController.php b/app/Http/Controllers/PostReviewsController.php new file mode 100644 index 0000000..782f14d --- /dev/null +++ b/app/Http/Controllers/PostReviewsController.php @@ -0,0 +1,85 @@ +integer('per_page', 15), 100)); + + $reviews = $mealPost->reviews() + ->with('user:id,name,avatar_url') + ->latest() + ->paginate($perPage) + ->withQueryString(); + + return PostReviewsResource::collection($reviews); + } + + public function store(PostReviewsRequest $request, MealPosts $mealPost): JsonResponse + { + $userId = $request->user()->getKey(); + + abort_if( + PostReviews::query() + ->where('meal_post_id', $mealPost->getKey()) + ->where('user_id', $userId) + ->exists(), + 409, + 'Vous avez déjà donné un avis pour ce repas.' + ); + + $postReview = PostReviews::create([ + ...$request->validated(), + 'meal_post_id' => $mealPost->getKey(), + 'user_id' => $userId, + ]); + + return (new PostReviewsResource($postReview->load('user:id,name,avatar_url'))) + ->response() + ->setStatusCode(201); + } + + public function show(PostReviews $postReview): PostReviewsResource + { + return new PostReviewsResource($postReview->loadMissing( + 'mealPost.ingredients', + 'mealPost.user:id,name,avatar_url', + 'user:id,name,avatar_url', + )); + } + + public function update(PostReviewsRequest $request, PostReviews $postReview): PostReviewsResource + { + $this->abortIfNotOwner($request, $postReview); + + $postReview->update($request->validated()); + + return new PostReviewsResource($postReview->refresh()->loadMissing('user:id,name,avatar_url')); + } + + public function destroy(Request $request, PostReviews $postReview): Response + { + $this->abortIfNotOwner($request, $postReview); + + $postReview->delete(); + + return response()->noContent(); + } + + private function abortIfNotOwner(Request $request, PostReviews $postReview): void + { + abort_unless($request->user(), 401); + abort_unless($postReview->user_id === $request->user()->getKey(), 404); + } +} diff --git a/app/Http/Requests/PostReviewsRequest.php b/app/Http/Requests/PostReviewsRequest.php new file mode 100644 index 0000000..723a505 --- /dev/null +++ b/app/Http/Requests/PostReviewsRequest.php @@ -0,0 +1,23 @@ +isMethod('post'); + + return [ + 'rating' => [$isCreating ? 'required' : 'sometimes', 'integer', 'min:1', 'max:5'], + 'comment' => ['sometimes', 'nullable', 'string', 'max:1000'], + ]; + } + + public function authorize(): bool + { + return true; + } +} diff --git a/app/Http/Resources/PostReviewsResource.php b/app/Http/Resources/PostReviewsResource.php new file mode 100644 index 0000000..764c35b --- /dev/null +++ b/app/Http/Resources/PostReviewsResource.php @@ -0,0 +1,34 @@ + $this->id, + 'rating' => $this->rating, + 'comment' => $this->comment, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + 'user' => $this->whenLoaded('user', function (): ?array { + if (! $this->user) { + return null; + } + + return [ + 'id' => $this->user->id, + 'name' => $this->user->name, + 'avatarUrl' => $this->user->avatar_url ? asset(Storage::url($this->user->avatar_url)) : null, + ]; + }), + ]; + } +} diff --git a/app/Models/MealPosts.php b/app/Models/MealPosts.php index a69de4e..7d16a5f 100644 --- a/app/Models/MealPosts.php +++ b/app/Models/MealPosts.php @@ -49,6 +49,11 @@ class MealPosts extends Model return $this->hasMany(MealPostIngredients::class)->orderBy('position'); } + public function reviews(): HasMany + { + return $this->hasMany(PostReviews::class, 'meal_post_id'); + } + public function likedByUsers(): BelongsToMany { return $this->belongsToMany(User::class, 'meal_post_likes', 'meal_posts_id', 'user_id') diff --git a/app/Models/PostReviews.php b/app/Models/PostReviews.php new file mode 100644 index 0000000..8d16b06 --- /dev/null +++ b/app/Models/PostReviews.php @@ -0,0 +1,37 @@ +belongsTo(MealPosts::class, 'meal_post_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + protected function casts(): array + { + return [ + 'id' => 'string', + 'rating' => 'integer', + ]; + } +} diff --git a/database/migrations/2026_05_18_193442_create_post_reviews_table.php b/database/migrations/2026_05_18_193442_create_post_reviews_table.php new file mode 100644 index 0000000..552a4b4 --- /dev/null +++ b/database/migrations/2026_05_18_193442_create_post_reviews_table.php @@ -0,0 +1,27 @@ +ulid('id')->primary(); + $table->foreignUlid('meal_post_id')->constrained('meal_posts')->cascadeOnDelete(); + $table->unsignedTinyInteger('rating'); + $table->text('comment')->nullable(); + $table->foreignUlid('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->unique(['meal_post_id', 'user_id']); + + }); + } + + public function down(): void + { + Schema::dropIfExists('post_reviews'); + } +}; diff --git a/routes/api.php b/routes/api.php index c031fe7..37e6787 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,9 +4,10 @@ use App\Http\Controllers\AuthController; use App\Http\Controllers\DeviceTokenController; use App\Http\Controllers\MealImageAnalysisController; use App\Http\Controllers\MealPostController; +use App\Http\Controllers\PostReviewsController; use Illuminate\Support\Facades\Route; -//Auth +// Auth Route::prefix('auth')->group(function (): void { Route::post('register', [AuthController::class, 'register']); Route::post('login', [AuthController::class, 'login']); @@ -25,13 +26,19 @@ Route::middleware('auth:sanctum')->group(function (): void { Route::get('test-notifs', [DeviceTokenController::class, 'notifs']); }); -//Meals +// Meals Route::middleware('auth:sanctum')->group(function (): void { Route::get('meal-posts/stats', [MealPostController::class, 'stats']); Route::get('meal-posts/calendar', [MealPostController::class, 'calendar']); Route::post('meal-posts/analyze-image', [MealImageAnalysisController::class, 'store']); Route::post('meal-posts/{mealPost}/like', [MealPostController::class, 'like'])->name('meal-posts.like'); Route::delete('meal-posts/{mealPost}/like', [MealPostController::class, 'unlike'])->name('meal-posts.unlike'); + Route::get('meal-posts/{mealPost}/reviews', [PostReviewsController::class, 'index'])->name('meal-posts.reviews.index'); + Route::post('meal-posts/{mealPost}/reviews', [PostReviewsController::class, 'store'])->name('meal-posts.reviews.store'); + Route::get('post-reviews/{postReview}', [PostReviewsController::class, 'show'])->name('post-reviews.show'); + Route::put('post-reviews/{postReview}', [PostReviewsController::class, 'update'])->name('post-reviews.update'); + Route::patch('post-reviews/{postReview}', [PostReviewsController::class, 'update'])->name('post-reviews.patch'); + Route::delete('post-reviews/{postReview}', [PostReviewsController::class, 'destroy'])->name('post-reviews.destroy'); Route::apiResource('meal-posts', MealPostController::class)->except(['update']); Route::put('meal-posts/{mealPost}', [MealPostController::class, 'update'])->name('meal-posts.update'); Route::patch('meal-posts/{mealPost}', [MealPostController::class, 'update'])->name('meal-posts.patch'); diff --git a/tests/Feature/PostReviewsControllerTest.php b/tests/Feature/PostReviewsControllerTest.php new file mode 100644 index 0000000..f4d887b --- /dev/null +++ b/tests/Feature/PostReviewsControllerTest.php @@ -0,0 +1,138 @@ +create(); + $mealPost = MealPosts::factory()->create(); + + Sanctum::actingAs($user); + + $this->postJson("/api/meal-posts/{$mealPost->id}/reviews", [ + 'rating' => 5, + 'comment' => 'Très bon repas.', + ]) + ->assertCreated() + ->assertJsonPath('data.rating', 5) + ->assertJsonPath('data.comment', 'Très bon repas.') + ->assertJsonPath('data.meal_post_id', $mealPost->id) + ->assertJsonPath('data.user_id', $user->id); + + $this->assertDatabaseHas('post_reviews', [ + 'meal_post_id' => $mealPost->id, + 'user_id' => $user->id, + 'rating' => 5, + 'comment' => 'Très bon repas.', + ]); + + $this->postJson("/api/meal-posts/{$mealPost->id}/reviews", [ + 'rating' => 4, + ]) + ->assertConflict() + ->assertJsonPath('message', 'Vous avez déjà donné un avis pour ce repas.'); + + expect(PostReviews::query()->count())->toBe(1); +}); + +it('lists reviews for a single meal post', function () { + $user = User::factory()->create(); + $mealPost = MealPosts::factory()->create(); + $otherMealPost = MealPosts::factory()->create(); + $firstReview = PostReviews::query()->create([ + 'meal_post_id' => $mealPost->id, + 'user_id' => User::factory()->create()->id, + 'rating' => 4, + 'comment' => 'Solide.', + ]); + $secondReview = PostReviews::query()->create([ + 'meal_post_id' => $mealPost->id, + 'user_id' => User::factory()->create()->id, + 'rating' => 5, + 'comment' => null, + ]); + $otherReview = PostReviews::query()->create([ + 'meal_post_id' => $otherMealPost->id, + 'user_id' => User::factory()->create()->id, + 'rating' => 1, + 'comment' => 'Pas celui-ci.', + ]); + + Sanctum::actingAs($user); + + $this->getJson("/api/meal-posts/{$mealPost->id}/reviews") + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonFragment(['id' => $firstReview->id]) + ->assertJsonFragment(['id' => $secondReview->id]) + ->assertJsonMissing(['id' => $otherReview->id]); +}); + +it('lets the review author update and delete their review', function () { + $user = User::factory()->create(); + $review = PostReviews::query()->create([ + 'meal_post_id' => MealPosts::factory()->create()->id, + 'user_id' => $user->id, + 'rating' => 3, + 'comment' => 'Initial.', + ]); + + Sanctum::actingAs($user); + + $this->patchJson("/api/post-reviews/{$review->id}", [ + 'rating' => 4, + 'comment' => null, + ]) + ->assertOk() + ->assertJsonPath('data.rating', 4) + ->assertJsonPath('data.comment', null); + + $this->assertDatabaseHas('post_reviews', [ + 'id' => $review->id, + 'rating' => 4, + 'comment' => null, + ]); + + $this->deleteJson("/api/post-reviews/{$review->id}") + ->assertNoContent(); + + $this->assertDatabaseMissing('post_reviews', [ + 'id' => $review->id, + ]); +}); + +it('does not let another user mutate a review', function () { + $review = PostReviews::query()->create([ + 'meal_post_id' => MealPosts::factory()->create()->id, + 'user_id' => User::factory()->create()->id, + 'rating' => 3, + 'comment' => 'Initial.', + ]); + + Sanctum::actingAs(User::factory()->create()); + + $this->patchJson("/api/post-reviews/{$review->id}", [ + 'rating' => 4, + ])->assertNotFound(); + + $this->deleteJson("/api/post-reviews/{$review->id}") + ->assertNotFound(); + + $this->assertDatabaseHas('post_reviews', [ + 'id' => $review->id, + 'rating' => 3, + ]); +}); + +it('requires authentication to create a review', function () { + $mealPost = MealPosts::factory()->create(); + + $this->postJson("/api/meal-posts/{$mealPost->id}/reviews", [ + 'rating' => 5, + ])->assertUnauthorized(); +});