feat: reviews
This commit is contained in:
parent
bb409610a8
commit
67d838fa65
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue