set('filesystems.default', 's3'); Storage::fake('s3'); config()->set('meal_posts.ai_generation.user_name', 'Chef IA'); config()->set('meal_posts.ai_generation.user_email', 'chef-ia@example.test'); config()->set('meal_posts.ai_generation.image_path', 'meal-posts/ai-generated'); config()->set('meal_posts.ai_generation.image_quality', 'medium'); foreach (range(1, 11) as $index) { $mealPost = MealPosts::factory()->create([ 'eaten_at' => now()->subMinutes(11 - $index), ]); $mealPost->ingredients()->createMany([ [ 'position' => 1, 'ingredient' => $index === 1 ? 'Topinambour ancien' : "Ingredient recent {$index}", 'quantity' => 120, 'unit' => IngredientUnit::GRAM->value, ], [ 'position' => 2, 'ingredient' => $index === 11 ? 'Asperges' : "Accompagnement recent {$index}", 'quantity' => 80, 'unit' => IngredientUnit::GRAM->value, ], [ 'position' => 4, 'ingredient' => "Garniture ignoree {$index}", 'quantity' => 10, 'unit' => IngredientUnit::GRAM->value, ], ]); } MealMaker::fake([ [ 'title' => 'Bowl de saumon croustillant', 'caption' => 'Un bowl colore avec saumon, riz vinaigre et legumes croquants.', 'type' => MealPostType::LUNCH->value, 'calories' => 640, 'proteins' => 42.4, 'carbs' => 62.2, 'fats' => 24.8, 'ingredients' => [ [ 'position' => 12, 'ingredient' => 'Riz vinaigre', 'quantity' => 160, 'unit' => IngredientUnit::GRAM->value, ], [ 'position' => 13, 'ingredient' => 'Saumon', 'quantity' => 130, 'unit' => IngredientUnit::GRAM->value, ], ], 'image_prompt' => 'Photorealistic crispy salmon bowl with vinegared rice and crunchy vegetables', ], ]); Image::fake([ base64_encode('fake-image-content'), ]); $this->artisan('meal-posts:generate-ai') ->assertExitCode(0); $mealPost = MealPosts::query() ->where('ai_generated', true) ->with('user', 'ingredients') ->firstOrFail(); expect($mealPost->ai_generated)->toBeTrue() ->and($mealPost->title)->toBe('Bowl de saumon croustillant') ->and($mealPost->type)->toBe(MealPostType::LUNCH) ->and($mealPost->visibility)->toBe(MealPostVisibility::Public) ->and($mealPost->user->email)->toBe('chef-ia@example.test') ->and($mealPost->user->name)->toBe('Chef IA') ->and($mealPost->ingredients)->toHaveCount(2) ->and($mealPost->ingredients[0]->position)->toBe(1) ->and($mealPost->ingredients[0]->ingredient)->toBe('Riz vinaigre'); expect($mealPost->image_url)->toStartWith('meal-posts/ai-generated/'); Storage::disk('s3')->assertExists($mealPost->image_url); $this->assertDatabaseHas('meal_posts', [ 'id' => $mealPost->id, 'ai_generated' => true, 'visibility' => MealPostVisibility::Public->value, ]); MealMaker::assertPrompted( fn ($prompt): bool => str_contains($prompt->prompt, 'Genere un plat completement aleatoire') && str_contains($prompt->prompt, 'sans tenir compte du mois actuel') && str_contains($prompt->prompt, 'Evite les ingredients principaux deja utilises dans les 10 derniers repas') && str_contains($prompt->prompt, 'Ingredient recent 2') && str_contains($prompt->prompt, 'Asperges') && ! str_contains($prompt->prompt, 'Topinambour ancien') && ! str_contains($prompt->prompt, 'Garniture ignoree 11') ); Image::assertGenerated( fn ($prompt): bool => $prompt->contains('crispy salmon bowl') && $prompt->isLandscape() && $prompt->quality === 'medium' ); $usage = AiUsage::query()->sole(); expect($usage->user_id)->toBe($mealPost->user_id) ->and($usage->type)->toBe(AiUsageType::MEAL_POST_GENERATION) ->and($usage->status)->toBe(AiUsageStatus::SUCCESS) ->and($usage->related_type)->toBe(MealPosts::class) ->and($usage->related_id)->toBe($mealPost->id) ->and($usage->input['image'])->toMatchArray([ 'size' => '3:2', 'quality' => 'medium', 'storage_path' => 'meal-posts/ai-generated', ]) ->and($usage->output)->toMatchArray([ 'title' => 'Bowl de saumon croustillant', 'type' => MealPostType::LUNCH->value, 'calories' => 640, 'image_url' => $mealPost->image_url, 'ingredients' => [ [ 'position' => 1, 'ingredient' => 'Riz vinaigre', 'quantity' => 160, 'unit' => IngredientUnit::GRAM->value, ], [ 'position' => 2, 'ingredient' => 'Saumon', 'quantity' => 130, 'unit' => IngredientUnit::GRAM->value, ], ], ]); }); it('records a failed ai meal post generation attempt', function () { config()->set('filesystems.default', 's3'); Storage::fake('s3'); Exceptions::fake(); config()->set('meal_posts.ai_generation.user_name', 'Chef IA'); config()->set('meal_posts.ai_generation.user_email', 'chef-ia@example.test'); config()->set('meal_posts.ai_generation.image_quality', 'medium'); MealMaker::fake([ [ 'title' => 'Bowl impossible', 'caption' => 'Un bowl qui ne sera pas publie.', 'type' => MealPostType::LUNCH->value, 'calories' => 640, 'proteins' => 42.4, 'carbs' => 62.2, 'fats' => 24.8, 'ingredients' => [], 'image_prompt' => 'Photorealistic impossible bowl', ], ]); Image::fake( fn () => throw new \RuntimeException('Image provider unavailable') ); $this->artisan('meal-posts:generate-ai') ->assertExitCode(1); Exceptions::assertReported(\RuntimeException::class); expect(MealPosts::query()->where('ai_generated', true)->count())->toBe(0); $usage = AiUsage::query()->sole(); expect($usage->user->email)->toBe('chef-ia@example.test') ->and($usage->type)->toBe(AiUsageType::MEAL_POST_GENERATION) ->and($usage->status)->toBe(AiUsageStatus::FAILED) ->and($usage->input['image_prompt'])->toContain('impossible bowl') ->and($usage->output)->toBeNull() ->and($usage->error_message)->toContain('Image provider unavailable'); });