feat: reviews
Laravel CI-CD / Tests Unitaires (push) Failing after 21s Details
Laravel CI-CD / Deploy via Portainer (push) Has been skipped Details
Laravel CI-CD / Build & Push Docker (push) Has been skipped Details

This commit is contained in:
Leon Morival 2026-05-18 21:46:54 +02:00
parent bb409610a8
commit 67d838fa65
8 changed files with 358 additions and 2 deletions

View File

@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\PostReviewsRequest;
use App\Http\Resources\PostReviewsResource;
use App\Models\MealPosts;
use App\Models\PostReviews;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
class PostReviewsController extends Controller
{
public function index(Request $request, MealPosts $mealPost): AnonymousResourceCollection
{
$perPage = max(1, min($request->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);
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class PostReviewsRequest extends FormRequest
{
public function rules(): array
{
$isCreating = $this->isMethod('post');
return [
'rating' => [$isCreating ? 'required' : 'sometimes', 'integer', 'min:1', 'max:5'],
'comment' => ['sometimes', 'nullable', 'string', 'max:1000'],
];
}
public function authorize(): bool
{
return true;
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Resources;
use App\Models\PostReviews;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
/** @mixin PostReviews */
class PostReviewsResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
];
}),
];
}
}

View File

@ -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')

View File

@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PostReviews extends Model
{
use HasUlids;
protected $fillable = [
'meal_post_id',
'rating',
'comment',
'user_id',
];
public function mealPost(): BelongsTo
{
return $this->belongsTo(MealPosts::class, 'meal_post_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
protected function casts(): array
{
return [
'id' => 'string',
'rating' => 'integer',
];
}
}

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('post_reviews', function (Blueprint $table) {
$table->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');
}
};

View File

@ -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');

View File

@ -0,0 +1,138 @@
<?php
use App\Models\MealPosts;
use App\Models\PostReviews;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
it('creates one review per user for a meal post', function () {
$user = User::factory()->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();
});