276 lines
9.7 KiB
PHP
276 lines
9.7 KiB
PHP
<?php
|
|
|
|
use App\Ai\Agents\MealMaker;
|
|
use App\Enums\AiUsageStatus;
|
|
use App\Enums\AiUsageType;
|
|
use App\Enums\IngredientUnit;
|
|
use App\Enums\MealPostType;
|
|
use App\Enums\MealPostVisibility;
|
|
use App\Models\AiUsage;
|
|
use App\Models\MealPosts;
|
|
use Illuminate\Filesystem\FilesystemAdapter;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Exceptions;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Laravel\Ai\Image;
|
|
use League\Flysystem\Filesystem as Flysystem;
|
|
use League\Flysystem\Local\LocalFilesystemAdapter;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
it('generates an ai meal post with an image', function () {
|
|
config()->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('stores an ai meal image on s3 without requiring public ACL support', function () {
|
|
$root = storage_path('framework/testing/disks/acl-disabled-s3');
|
|
|
|
File::deleteDirectory($root);
|
|
File::ensureDirectoryExists($root);
|
|
|
|
Storage::extend('acl-disabled-s3', function ($app, array $config): FilesystemAdapter {
|
|
$adapter = new LocalFilesystemAdapter($config['root']);
|
|
$driver = new Flysystem($adapter, $config);
|
|
|
|
return new class($driver, $adapter, $config) extends FilesystemAdapter
|
|
{
|
|
public function put($path, $contents, $options = []): string|bool
|
|
{
|
|
$normalizedOptions = is_string($options)
|
|
? ['visibility' => $options]
|
|
: (array) $options;
|
|
|
|
if (($normalizedOptions['visibility'] ?? null) === 'public') {
|
|
return false;
|
|
}
|
|
|
|
return parent::put($path, $contents, $options);
|
|
}
|
|
};
|
|
});
|
|
|
|
config()->set('filesystems.default', 'acl-disabled-s3');
|
|
config()->set('filesystems.disks.acl-disabled-s3', [
|
|
'driver' => 'acl-disabled-s3',
|
|
'root' => $root,
|
|
'throw' => false,
|
|
]);
|
|
config()->set('meal_posts.ai_generation.user_email', 'chef-ia@example.test');
|
|
config()->set('meal_posts.ai_generation.image_path', 'meal-posts/ai-generated');
|
|
|
|
MealMaker::fake([
|
|
[
|
|
'title' => 'Bowl sans ACL publique',
|
|
'caption' => 'Un bowl stocke sur S3 sans ACL objet.',
|
|
'type' => MealPostType::LUNCH->value,
|
|
'calories' => 640,
|
|
'proteins' => 42.4,
|
|
'carbs' => 62.2,
|
|
'fats' => 24.8,
|
|
'ingredients' => [],
|
|
'image_prompt' => 'Photorealistic bowl stored without public ACL',
|
|
],
|
|
]);
|
|
|
|
Image::fake([
|
|
base64_encode('fake-image-content'),
|
|
]);
|
|
|
|
$this->artisan('meal-posts:generate-ai')
|
|
->assertExitCode(0);
|
|
|
|
$mealPost = MealPosts::query()
|
|
->where('ai_generated', true)
|
|
->sole();
|
|
|
|
expect($mealPost->image_url)->toStartWith('meal-posts/ai-generated/');
|
|
Storage::disk('acl-disabled-s3')->assertExists($mealPost->image_url);
|
|
});
|
|
|
|
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');
|
|
});
|