feat: moderation
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\MealPostVisibility;
|
||||
use App\Models\MealPosts;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('moderation.content.enabled', true);
|
||||
config()->set('moderation.content.openai_key', 'test-key');
|
||||
config()->set('moderation.content.openai_url', 'https://api.openai.com/v1');
|
||||
});
|
||||
|
||||
it('rejects a public meal when text moderation flags it', function () {
|
||||
Storage::fake();
|
||||
Http::fake([
|
||||
'https://api.openai.com/v1/moderations' => Http::response([
|
||||
'results' => [['flagged' => true]],
|
||||
]),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$this->postJson('/api/meal-posts', [
|
||||
'title' => 'Contenu interdit',
|
||||
'eaten_at' => now()->toISOString(),
|
||||
'image' => UploadedFile::fake()->image('meal.jpg'),
|
||||
'type' => 'lunch',
|
||||
'visibility' => MealPostVisibility::Public->value,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('content');
|
||||
|
||||
$this->assertDatabaseCount('meal_posts', 0);
|
||||
expect(Storage::allFiles())->toBeEmpty();
|
||||
});
|
||||
|
||||
it('sends public meal text and image to multimodal moderation before storing it', function () {
|
||||
Storage::fake();
|
||||
Http::fake([
|
||||
'https://api.openai.com/v1/moderations' => Http::response([
|
||||
'results' => [['flagged' => false]],
|
||||
]),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$this->postJson('/api/meal-posts', [
|
||||
'title' => 'Bowl printanier',
|
||||
'caption' => 'Une assiette fraîche',
|
||||
'eaten_at' => now()->toISOString(),
|
||||
'image' => UploadedFile::fake()->image('meal.jpg'),
|
||||
'type' => 'lunch',
|
||||
'visibility' => MealPostVisibility::Public->value,
|
||||
])->assertCreated();
|
||||
|
||||
Http::assertSent(function (Request $request): bool {
|
||||
$input = $request->data()['input'] ?? [];
|
||||
|
||||
return $request->url() === 'https://api.openai.com/v1/moderations'
|
||||
&& ($input[0]['type'] ?? null) === 'text'
|
||||
&& str_contains((string) ($input[0]['text'] ?? ''), 'Bowl printanier')
|
||||
&& ($input[1]['type'] ?? null) === 'image_url'
|
||||
&& str_starts_with((string) ($input[1]['image_url']['url'] ?? ''), 'data:image/jpeg;base64,');
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an explicitly forbidden review even when remote moderation is disabled', function () {
|
||||
config()->set('moderation.content.enabled', false);
|
||||
$user = User::factory()->create();
|
||||
$meal = MealPosts::factory()->create();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$this->postJson("/api/meal-posts/{$meal->id}/reviews", [
|
||||
'rating' => 1,
|
||||
'comment' => 'Kill yourself',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('content');
|
||||
|
||||
$this->assertDatabaseCount('post_reviews', 0);
|
||||
});
|
||||
|
||||
it('moderates existing content when a private meal becomes public', function () {
|
||||
Http::fake([
|
||||
'https://api.openai.com/v1/moderations' => Http::response([
|
||||
'results' => [['flagged' => true]],
|
||||
]),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
$meal = MealPosts::factory()->for($user, 'user')->create([
|
||||
'title' => 'Ancien contenu privé',
|
||||
'image_url' => 'https://example.com/private-meal.jpg',
|
||||
'visibility' => MealPostVisibility::Private,
|
||||
]);
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$this->patchJson("/api/meal-posts/{$meal->id}", [
|
||||
'visibility' => MealPostVisibility::Public->value,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('content');
|
||||
|
||||
expect($meal->fresh()->visibility)->toBe(MealPostVisibility::Private);
|
||||
|
||||
Http::assertSent(function (Request $request): bool {
|
||||
$input = $request->data()['input'] ?? [];
|
||||
|
||||
return str_contains((string) ($input[0]['text'] ?? ''), 'Ancien contenu privé')
|
||||
&& ($input[1]['image_url']['url'] ?? null) === 'https://example.com/private-meal.jpg';
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when production moderation cannot be reached', function () {
|
||||
Http::fake([
|
||||
'https://api.openai.com/v1/moderations' => Http::response([], 500),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
$meal = MealPosts::factory()->create();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$this->postJson("/api/meal-posts/{$meal->id}/reviews", [
|
||||
'rating' => 5,
|
||||
'comment' => 'Très bon repas.',
|
||||
])
|
||||
->assertServiceUnavailable()
|
||||
->assertJsonPath('message', __('api.moderation.unavailable'));
|
||||
|
||||
$this->assertDatabaseCount('post_reviews', 0);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\MealPostVisibility;
|
||||
use App\Models\MealPosts;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('searches public meals by title caption and ingredient with pagination', function () {
|
||||
$viewer = User::factory()->create();
|
||||
$owner = User::factory()->create();
|
||||
|
||||
$titleMatch = MealPosts::factory()->for($owner, 'user')->create([
|
||||
'title' => 'Bowl méditerranéen',
|
||||
'visibility' => MealPostVisibility::Public,
|
||||
]);
|
||||
$ingredientMatch = MealPosts::factory()->for($owner, 'user')->create([
|
||||
'title' => 'Déjeuner coloré',
|
||||
'caption' => 'Simple et frais',
|
||||
'visibility' => MealPostVisibility::Public,
|
||||
]);
|
||||
$ingredientMatch->ingredients()->create([
|
||||
'ingredient' => 'Tomates cerises',
|
||||
'position' => 0,
|
||||
'quantity' => 100,
|
||||
'unit' => 'g',
|
||||
]);
|
||||
MealPosts::factory()->for($owner, 'user')->create([
|
||||
'title' => 'Bowl privé',
|
||||
'visibility' => MealPostVisibility::Private,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($viewer);
|
||||
|
||||
$this->getJson('/api/search?q=bowl&type=meals&per_page=1')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $titleMatch->id)
|
||||
->assertJsonPath('meta.total', 1);
|
||||
|
||||
$this->getJson('/api/search?q=tomates&type=meals')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $ingredientMatch->id);
|
||||
});
|
||||
|
||||
it('searches users while excluding suspended and blocked accounts', function () {
|
||||
$viewer = User::factory()->create(['name' => 'viewer']);
|
||||
$visible = User::factory()->create(['name' => 'alex_bowli', 'bio' => 'Cuisine végétale']);
|
||||
$blocked = User::factory()->create(['name' => 'alex_blocked']);
|
||||
User::factory()->create(['name' => 'alex_suspended', 'suspended_at' => now()]);
|
||||
$viewer->blockedUsers()->attach($blocked);
|
||||
|
||||
Sanctum::actingAs($viewer);
|
||||
|
||||
$this->getJson('/api/search?q=alex&type=users')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $visible->id)
|
||||
->assertJsonMissing(['id' => $blocked->id]);
|
||||
});
|
||||
|
||||
it('validates the search query and result type', function () {
|
||||
Sanctum::actingAs(User::factory()->create());
|
||||
|
||||
$this->getJson('/api/search?q=a&type=unknown')
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['q', 'type']);
|
||||
});
|
||||
Reference in New Issue
Block a user