199 lines
6.3 KiB
PHP
199 lines
6.3 KiB
PHP
<?php
|
||
|
||
use App\Ai\Agents\MealImageAnalyzer;
|
||
use App\Enums\AiUsageStatus;
|
||
use App\Enums\AiUsageType;
|
||
use App\Enums\GenerationCreditTransactionType;
|
||
use App\Enums\IngredientUnit;
|
||
use App\Enums\MealPostType;
|
||
use App\Models\AiUsage;
|
||
use App\Models\GenerationCreditBalance;
|
||
use App\Models\GenerationCreditTransaction;
|
||
use App\Models\User;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Http\UploadedFile;
|
||
use Illuminate\Support\Facades\Exceptions;
|
||
use Laravel\Sanctum\Sanctum;
|
||
|
||
uses(RefreshDatabase::class);
|
||
|
||
function fakeMealAnalysisImage(): UploadedFile
|
||
{
|
||
$path = tempnam(sys_get_temp_dir(), 'meal-analysis-');
|
||
|
||
file_put_contents($path, base64_decode(
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lmHqWQAAAABJRU5ErkJggg=='
|
||
));
|
||
|
||
return new UploadedFile($path, 'meal.png', 'image/png', null, true);
|
||
}
|
||
|
||
function grantMealAnalysisCredit(User $user): void
|
||
{
|
||
GenerationCreditBalance::factory()->create([
|
||
'user_id' => $user->id,
|
||
'balance' => 1,
|
||
]);
|
||
|
||
GenerationCreditTransaction::factory()->create([
|
||
'user_id' => $user->id,
|
||
'amount' => 1,
|
||
'type' => GenerationCreditTransactionType::AD_REWARD,
|
||
'daily_reward_key' => 'ad:'.now()->toDateString(),
|
||
]);
|
||
}
|
||
|
||
it('analyzes a meal image into a draft', function () {
|
||
$user = User::factory()->create();
|
||
grantMealAnalysisCredit($user);
|
||
|
||
Sanctum::actingAs($user);
|
||
|
||
MealImageAnalyzer::fake([
|
||
[
|
||
'title' => ' Bowl saumon avocat ',
|
||
'caption' => 'Un bowl avec du saumon, du riz et de l’avocat.',
|
||
'type' => MealPostType::LUNCH->value,
|
||
'calories' => 612,
|
||
'proteins' => 39.4,
|
||
'carbs' => 58.2,
|
||
'fats' => 25.1,
|
||
'ingredients' => [
|
||
[
|
||
'position' => 12,
|
||
'ingredient' => ' Riz ',
|
||
'quantity' => 150,
|
||
'unit' => IngredientUnit::GRAM->value,
|
||
],
|
||
[
|
||
'position' => 13,
|
||
'ingredient' => 'Saumon',
|
||
'quantity' => 120,
|
||
'unit' => IngredientUnit::GRAM->value,
|
||
],
|
||
],
|
||
],
|
||
]);
|
||
|
||
$response = $this
|
||
->withHeader('Accept', 'application/json')
|
||
->post('/api/meal-posts/analyze-image', [
|
||
'image' => fakeMealAnalysisImage(),
|
||
]);
|
||
|
||
$response
|
||
->assertOk()
|
||
->assertJsonPath('data.title', 'Bowl saumon avocat')
|
||
->assertJsonPath('data.type', MealPostType::LUNCH->value)
|
||
->assertJsonPath('data.calories', 612)
|
||
->assertJsonPath('data.proteins', 39.4)
|
||
->assertJsonPath('data.ingredients.0.position', 1)
|
||
->assertJsonPath('data.ingredients.0.ingredient', 'Riz')
|
||
->assertJsonPath('data.ingredients.1.ingredient', 'Saumon');
|
||
|
||
MealImageAnalyzer::assertPrompted(
|
||
fn ($prompt): bool => str_contains($prompt->prompt, "Analyse l'image")
|
||
&& $prompt->attachments->count() === 1
|
||
);
|
||
|
||
$usage = AiUsage::query()->sole();
|
||
|
||
expect($usage->user_id)->toBe($user->id)
|
||
->and($usage->type)->toBe(AiUsageType::MEAL_IMAGE_ANALYSIS)
|
||
->and($usage->status)->toBe(AiUsageStatus::SUCCESS)
|
||
->and($usage->input['image'])->toMatchArray([
|
||
'client_original_name' => 'meal.png',
|
||
'mime_type' => 'image/png',
|
||
])
|
||
->and($usage->output)->toMatchArray([
|
||
'title' => 'Bowl saumon avocat',
|
||
'type' => MealPostType::LUNCH->value,
|
||
'calories' => 612,
|
||
'proteins' => 39.4,
|
||
'ingredients' => [
|
||
[
|
||
'position' => 1,
|
||
'ingredient' => 'Riz',
|
||
'quantity' => 150,
|
||
'unit' => IngredientUnit::GRAM->value,
|
||
],
|
||
[
|
||
'position' => 2,
|
||
'ingredient' => 'Saumon',
|
||
'quantity' => 120,
|
||
'unit' => IngredientUnit::GRAM->value,
|
||
],
|
||
],
|
||
]);
|
||
|
||
expect(GenerationCreditBalance::query()->whereKey($user->id)->value('balance'))->toBe(0);
|
||
});
|
||
|
||
it('records a failed meal image analysis attempt', function () {
|
||
$user = User::factory()->create();
|
||
grantMealAnalysisCredit($user);
|
||
|
||
Sanctum::actingAs($user);
|
||
Exceptions::fake();
|
||
|
||
MealImageAnalyzer::fake(
|
||
fn () => throw new \RuntimeException('Provider unavailable')
|
||
);
|
||
|
||
$this
|
||
->withHeader('Accept', 'application/json')
|
||
->post('/api/meal-posts/analyze-image', [
|
||
'image' => fakeMealAnalysisImage(),
|
||
])
|
||
->assertStatus(502)
|
||
->assertJsonPath('message', __('api.meal_image_analysis.failed'));
|
||
|
||
Exceptions::assertReported(\RuntimeException::class);
|
||
|
||
$usage = AiUsage::query()->sole();
|
||
|
||
expect($usage->user_id)->toBe($user->id)
|
||
->and($usage->type)->toBe(AiUsageType::MEAL_IMAGE_ANALYSIS)
|
||
->and($usage->status)->toBe(AiUsageStatus::FAILED)
|
||
->and($usage->input['image'])->toMatchArray([
|
||
'client_original_name' => 'meal.png',
|
||
'mime_type' => 'image/png',
|
||
])
|
||
->and($usage->output)->toBeNull()
|
||
->and($usage->error_message)->toContain('Provider unavailable');
|
||
|
||
expect(GenerationCreditBalance::query()->whereKey($user->id)->value('balance'))->toBe(1);
|
||
});
|
||
|
||
it('requires a generation credit to analyze a meal image', function () {
|
||
Sanctum::actingAs(User::factory()->create());
|
||
|
||
$this
|
||
->withHeader('Accept', 'application/json')
|
||
->post('/api/meal-posts/analyze-image', [
|
||
'image' => fakeMealAnalysisImage(),
|
||
])
|
||
->assertStatus(402)
|
||
->assertJsonPath('message', __('api.generation_credits.insufficient'));
|
||
|
||
expect(AiUsage::query()->count())->toBe(0);
|
||
});
|
||
|
||
it('requires authentication to analyze a meal image', function () {
|
||
$this
|
||
->withHeader('Accept', 'application/json')
|
||
->post('/api/meal-posts/analyze-image', [
|
||
'image' => fakeMealAnalysisImage(),
|
||
])
|
||
->assertUnauthorized();
|
||
});
|
||
|
||
it('validates the analyzed image', function () {
|
||
Sanctum::actingAs(User::factory()->create());
|
||
|
||
$this
|
||
->postJson('/api/meal-posts/analyze-image')
|
||
->assertUnprocessable()
|
||
->assertJsonValidationErrors(['image']);
|
||
});
|