create(); 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, ], ], ]); }); it('records a failed meal image analysis attempt', function () { $user = User::factory()->create(); 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'); }); 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']); });