feat: moderation
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ModerationCaseStatus;
|
||||
use App\Enums\ReportReason;
|
||||
use App\Enums\UserRole;
|
||||
use App\Filament\Resources\ModerationCases\Pages\ListModerationCases;
|
||||
use App\Models\MealPosts;
|
||||
use App\Models\ModerationCase;
|
||||
use App\Models\Report;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
app()->setLocale('fr');
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('dashboard'));
|
||||
});
|
||||
|
||||
it('exposes moderation cases in filament and hides a reported meal post', function () {
|
||||
$moderator = User::factory()->create(['role' => UserRole::MODERATOR]);
|
||||
$mealPost = MealPosts::factory()->create();
|
||||
$case = ModerationCase::factory()->create([
|
||||
'caseable_type' => MealPosts::class,
|
||||
'caseable_id' => $mealPost->id,
|
||||
'reports_count' => 3,
|
||||
'threshold' => 3,
|
||||
]);
|
||||
|
||||
Report::factory()->create([
|
||||
'moderation_case_id' => $case->id,
|
||||
'reportable_type' => MealPosts::class,
|
||||
'reportable_id' => $mealPost->id,
|
||||
'reason' => ReportReason::INAPPROPRIATE,
|
||||
]);
|
||||
|
||||
$this->actingAs($moderator);
|
||||
|
||||
Livewire::test(ListModerationCases::class)
|
||||
->assertTableColumnExists(
|
||||
'status',
|
||||
fn (TextColumn $column): bool => $column->getLabel() === 'Statut',
|
||||
)
|
||||
->callTableAction('hideMeal', $case);
|
||||
|
||||
expect($mealPost->fresh()->hidden_at)->not->toBeNull()
|
||||
->and($mealPost->fresh()->hidden_by)->toBe($moderator->id)
|
||||
->and($case->fresh()->status)->toBe(ModerationCaseStatus::REVIEWING);
|
||||
|
||||
Sanctum::actingAs(User::factory()->create());
|
||||
|
||||
$this->getJson("/api/meal-posts/{$mealPost->id}")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('suspends a reported user from the moderation table', function () {
|
||||
$moderator = User::factory()->create(['role' => UserRole::MODERATOR]);
|
||||
$target = User::factory()->create();
|
||||
$case = ModerationCase::factory()->create([
|
||||
'caseable_type' => User::class,
|
||||
'caseable_id' => $target->id,
|
||||
'reports_count' => 5,
|
||||
'threshold' => 5,
|
||||
]);
|
||||
|
||||
$this->actingAs($moderator);
|
||||
|
||||
Livewire::test(ListModerationCases::class)
|
||||
->callTableAction('suspendUser', $case);
|
||||
|
||||
expect($target->fresh()->suspended_at)->not->toBeNull()
|
||||
->and($target->fresh()->suspended_by)->toBe($moderator->id);
|
||||
|
||||
Sanctum::actingAs($target->fresh());
|
||||
|
||||
$this->postJson('/api/meal-posts', [
|
||||
'image_url' => 'https://example.com/meal.jpg',
|
||||
'title' => 'Repas suspendu',
|
||||
'eaten_at' => '2026-05-22T12:00:00.000Z',
|
||||
'type' => \App\Enums\MealPostType::BREAKFAST->value,
|
||||
])->assertForbidden();
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ReportReason;
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\MealPosts;
|
||||
use App\Models\ModerationCase;
|
||||
use App\Models\Report;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ModerationThresholdReached;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('creates a report and a moderation case for a meal post', function () {
|
||||
$reporter = User::factory()->create();
|
||||
$mealPost = MealPosts::factory()->create();
|
||||
|
||||
Sanctum::actingAs($reporter);
|
||||
|
||||
$this->postJson("/api/meal-posts/{$mealPost->id}/reports", [
|
||||
'reason' => ReportReason::SPAM->value,
|
||||
'details' => 'Même image publiée en boucle.',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('message', __('api.reports.created'))
|
||||
->assertJsonPath('data.reason', ReportReason::SPAM->value)
|
||||
->assertJsonPath('data.reportableType', 'meal_post')
|
||||
->assertJsonPath('data.reportableId', $mealPost->id);
|
||||
|
||||
$this->assertDatabaseHas('reports', [
|
||||
'reporter_id' => $reporter->id,
|
||||
'reportable_type' => MealPosts::class,
|
||||
'reportable_id' => $mealPost->id,
|
||||
'reason' => ReportReason::SPAM->value,
|
||||
'details' => 'Même image publiée en boucle.',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('moderation_cases', [
|
||||
'caseable_type' => MealPosts::class,
|
||||
'caseable_id' => $mealPost->id,
|
||||
'reports_count' => 1,
|
||||
'threshold' => 3,
|
||||
]);
|
||||
});
|
||||
|
||||
it('prevents duplicate reports and reports on own content', function () {
|
||||
$owner = User::factory()->create();
|
||||
$mealPost = MealPosts::factory()->for($owner, 'user')->create();
|
||||
$reporter = User::factory()->create();
|
||||
|
||||
Sanctum::actingAs($reporter);
|
||||
|
||||
$payload = ['reason' => ReportReason::OTHER->value];
|
||||
|
||||
$this->postJson("/api/meal-posts/{$mealPost->id}/reports", $payload)
|
||||
->assertCreated();
|
||||
|
||||
$this->postJson("/api/meal-posts/{$mealPost->id}/reports", $payload)
|
||||
->assertConflict()
|
||||
->assertJsonPath('message', __('api.reports.already_exists'));
|
||||
|
||||
expect(Report::query()->count())->toBe(1);
|
||||
|
||||
Sanctum::actingAs($owner);
|
||||
|
||||
$this->postJson("/api/meal-posts/{$mealPost->id}/reports", $payload)
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('message', __('api.reports.cannot_report_own_meal'));
|
||||
});
|
||||
|
||||
it('reports users and prevents reporting own profile', function () {
|
||||
$target = User::factory()->create();
|
||||
$reporter = User::factory()->create();
|
||||
|
||||
Sanctum::actingAs($reporter);
|
||||
|
||||
$this->postJson("/api/users/{$target->id}/reports", [
|
||||
'reason' => ReportReason::HARASSMENT->value,
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.reportableType', 'user')
|
||||
->assertJsonPath('data.reportableId', $target->id);
|
||||
|
||||
$this->assertDatabaseHas('moderation_cases', [
|
||||
'caseable_type' => User::class,
|
||||
'caseable_id' => $target->id,
|
||||
'threshold' => 5,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($target);
|
||||
|
||||
$this->postJson("/api/users/{$target->id}/reports", [
|
||||
'reason' => ReportReason::OTHER->value,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('message', __('api.reports.cannot_report_self'));
|
||||
});
|
||||
|
||||
it('notifies moderators once when a report threshold is reached', function () {
|
||||
Notification::fake();
|
||||
|
||||
$admin = User::factory()->create(['role' => UserRole::ADMIN]);
|
||||
$moderator = User::factory()->create(['role' => UserRole::MODERATOR]);
|
||||
$regularUser = User::factory()->create();
|
||||
$mealPost = MealPosts::factory()->create();
|
||||
|
||||
foreach (User::factory()->count(3)->create() as $reporter) {
|
||||
Sanctum::actingAs($reporter);
|
||||
|
||||
$this->postJson("/api/meal-posts/{$mealPost->id}/reports", [
|
||||
'reason' => ReportReason::INAPPROPRIATE->value,
|
||||
])->assertCreated();
|
||||
}
|
||||
|
||||
$moderationCase = ModerationCase::query()->where('caseable_id', $mealPost->id)->firstOrFail();
|
||||
|
||||
expect($moderationCase->reports_count)->toBe(3)
|
||||
->and($moderationCase->threshold_notified_at)->not->toBeNull();
|
||||
|
||||
Notification::assertSentTo($admin, ModerationThresholdReached::class);
|
||||
Notification::assertSentTo($moderator, ModerationThresholdReached::class);
|
||||
Notification::assertNotSentTo($regularUser, ModerationThresholdReached::class);
|
||||
Notification::assertSentTimes(ModerationThresholdReached::class, 2);
|
||||
expect((new ModerationThresholdReached($moderationCase))->via($admin))->toBe(['database']);
|
||||
|
||||
Sanctum::actingAs(User::factory()->create());
|
||||
|
||||
$this->postJson("/api/meal-posts/{$mealPost->id}/reports", [
|
||||
'reason' => ReportReason::MISLEADING->value,
|
||||
])->assertCreated();
|
||||
|
||||
Notification::assertSentTimes(ModerationThresholdReached::class, 2);
|
||||
});
|
||||
Reference in New Issue
Block a user