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('code', 'CONTENT_MODERATION_UNAVAILABLE'); $this->assertDatabaseCount('post_reviews', 0); });