From 238a044d98c99a8184a92f9c9c19a46ca3a15d72 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Fri, 14 Aug 2026 13:43:35 +0200 Subject: [PATCH] feat: seeder --- database/seeders/DatabaseSeeder.php | 10 +- database/seeders/DemoContentSeeder.php | 507 ++++++++++++++++++++++++ tests/Feature/DemoContentSeederTest.php | 56 +++ 3 files changed, 566 insertions(+), 7 deletions(-) create mode 100644 database/seeders/DemoContentSeeder.php create mode 100644 tests/Feature/DemoContentSeederTest.php diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index fae673a..3b6c240 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,7 +2,6 @@ namespace Database\Seeders; -use App\Models\User; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; @@ -17,11 +16,8 @@ class DatabaseSeeder extends Seeder { $this->call(LegalDocumentSeeder::class); - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + if (app()->environment(['local', 'testing'])) { + $this->call(DemoContentSeeder::class); + } } } diff --git a/database/seeders/DemoContentSeeder.php b/database/seeders/DemoContentSeeder.php new file mode 100644 index 0000000..e62b65b --- /dev/null +++ b/database/seeders/DemoContentSeeder.php @@ -0,0 +1,507 @@ +createUsers(); + + $this->createMeals($users); + $this->createSocialGraph($users); + $this->createProgressHistory($this->demoUser($users)); + }); + } + + /** + * @return Collection + */ + private function createUsers(): Collection + { + $users = collect([ + [ + 'name' => 'Alex Martin', + 'email' => 'test@example.com', + 'locale' => 'fr', + 'bio' => 'Cuisine simple, course à pied et progression régulière.', + 'account_verified_at' => now(), + 'height' => 178, + 'target_weight' => 68, + 'weight_goal' => WeightGoal::LOSE_WEIGHT, + 'physical_activity_level' => PhysicalActivityLevel::MODERATELY_ACTIVE, + 'pace_preference' => PacePreference::NORMAL, + 'sex' => UserSex::MAN, + 'date_of_birth' => '1994-04-16', + 'terms_accepted_at' => now(), + ], + [ + 'name' => 'Camille Green', + 'email' => 'camille@bowli.test', + 'locale' => 'fr', + 'bio' => 'Recettes végétales colorées et faciles à préparer.', + 'account_verified_at' => now(), + ], + [ + 'name' => 'Noah Kitchen', + 'email' => 'noah@bowli.test', + 'locale' => 'en', + 'bio' => 'High-protein meals without complicated preparation.', + ], + [ + 'name' => 'Lina Fraîcheur', + 'email' => 'lina@bowli.test', + 'locale' => 'fr', + 'bio' => 'Bols frais, produits de saison et cuisine méditerranéenne.', + 'account_verified_at' => now(), + ], + [ + 'name' => 'Sam Fit Food', + 'email' => 'sam@bowli.test', + 'locale' => 'en', + 'bio' => 'Balanced meals for active weeks.', + ], + [ + 'name' => 'Inès Brunch', + 'email' => 'ines@bowli.test', + 'locale' => 'fr', + 'bio' => 'Petits-déjeuners gourmands et brunchs du dimanche.', + ], + [ + 'name' => 'Hugo Maison', + 'email' => 'hugo@bowli.test', + 'locale' => 'fr', + 'bio' => 'Cuisine maison généreuse avec des ingrédients simples.', + ], + [ + 'name' => 'Maya Plant', + 'email' => 'maya@bowli.test', + 'locale' => 'en', + 'bio' => 'Plant-based bowls, soups and snacks.', + 'account_verified_at' => now(), + ], + [ + 'name' => 'Louis Endurance', + 'email' => 'louis@bowli.test', + 'locale' => 'fr', + 'bio' => 'Des repas adaptés aux longues sorties et à la récupération.', + ], + ])->map( + fn (array $profile): User => User::factory()->create($profile) + ); + + User::factory()->withoutNutritionOnboarding()->create([ + 'name' => 'Nouveau profil', + 'email' => 'onboarding@example.com', + 'locale' => 'fr', + 'terms_accepted_at' => now(), + ]); + + return $users; + } + + /** + * @param Collection $users + */ + private function createMeals(Collection $users): void + { + $templates = collect($this->mealTemplates()); + $reviewComments = collect([ + 'Très bonne idée, je vais essayer cette semaine.', + 'Simple, équilibré et vraiment appétissant.', + 'Les proportions sont parfaites pour mon déjeuner.', + 'Testé hier soir, toute la famille a aimé.', + 'Belle assiette et excellente source d’inspiration.', + 'Parfait après une séance de sport.', + ]); + + foreach (range(0, 47) as $index) { + $owner = $users->get($index % $users->count()); + $template = $templates->get($index % $templates->count()); + $eatenAt = now()->subHours(($index * 4) + 1); + + if (! $owner instanceof User || ! is_array($template)) { + throw new LogicException('Unable to build the demo meal collection.'); + } + + $meal = $owner->mealPosts()->create([ + 'image_url' => $template['imageUrl'], + 'caption' => $template['caption'], + 'calories' => $template['calories'], + 'proteins' => $template['proteins'], + 'carbs' => $template['carbs'], + 'fats' => $template['fats'], + 'title' => $template['title'], + 'eaten_at' => $eatenAt, + 'visibility' => MealPostVisibility::Public, + 'type' => $template['type'], + 'ai_generated' => $index % 9 === 0, + 'diet_type' => $template['dietType'], + ]); + + $meal->ingredients()->createMany( + collect($template['ingredients']) + ->values() + ->map(fn (array $ingredient, int $position): array => [ + ...$ingredient, + 'position' => $position + 1, + ]) + ->all() + ); + + $eligibleUsers = $users + ->reject(fn (User $user): bool => $user->is($owner)) + ->values(); + $engagedUsers = $eligibleUsers + ->slice($index % $eligibleUsers->count()) + ->concat($eligibleUsers->take($index % $eligibleUsers->count())) + ->values(); + + $meal->likedByUsers()->attach( + $engagedUsers->take(2 + ($index % 5))->pluck('id')->all(), + [ + 'created_at' => $eatenAt->addMinutes(30), + 'updated_at' => $eatenAt->addMinutes(30), + ] + ); + + $engagedUsers->take(2 + ($index % 2))->each( + function (User $reviewer, int $reviewerIndex) use ($index, $meal, $reviewComments): void { + $meal->reviews()->create([ + 'rating' => 5 - (($index + $reviewerIndex) % 2), + 'comment' => $reviewComments->get( + ($index + $reviewerIndex) % $reviewComments->count() + ), + 'user_id' => $reviewer->getKey(), + ]); + } + ); + } + } + + /** + * @param Collection $users + */ + private function createSocialGraph(Collection $users): void + { + $demoUser = $this->demoUser($users); + $creators = $users + ->reject(fn (User $user): bool => $user->is($demoUser)) + ->values(); + $pivot = [ + 'status' => FollowStatus::Accepted->value, + 'created_at' => now()->subDays(10), + 'updated_at' => now()->subDays(10), + ]; + + $demoUser->following()->attach($creators->pluck('id')->all(), $pivot); + + $creators->take(5)->each( + fn (User $creator) => $creator->following()->attach($demoUser->getKey(), $pivot) + ); + + $creators->each(function (User $creator, int $index) use ($creators, $pivot): void { + $nextCreator = $creators->get(($index + 1) % $creators->count()); + + if ($nextCreator instanceof User) { + $creator->following()->attach($nextCreator->getKey(), $pivot); + } + }); + } + + private function createProgressHistory(User $demoUser): void + { + $demoUser->weightEntries()->delete(); + + foreach (range(11, 0) as $weeksAgo) { + WeightEntry::factory()->for($demoUser)->create([ + 'weight_kg' => round(74.8 - ((11 - $weeksAgo) * 0.28), 2), + 'measured_at' => now()->startOfDay()->subWeeks($weeksAgo), + ]); + } + + $demoUser->nutritionPlans() + ->where('is_active', true) + ->update([ + 'calories' => 2050, + 'proteins' => 130, + 'carbs' => 235, + 'fats' => 65, + 'maintenance_calories' => 2350, + 'calorie_adjustment_percent' => -0.1277, + 'calculation_details' => [ + 'activityFactor' => 1.55, + 'inputWeightKg' => 71.72, + ], + ]); + + $workouts = collect([ + ['title' => 'Course facile', 'type' => 'running', 'duration' => 2700, 'calories' => 390, 'distance' => 7200], + ['title' => 'Renforcement complet', 'type' => 'strength', 'duration' => 3300, 'calories' => 310, 'distance' => 0], + ['title' => 'Sortie vélo', 'type' => 'cycling', 'duration' => 5400, 'calories' => 620, 'distance' => 32000], + ['title' => 'Marche active', 'type' => 'walking', 'duration' => 3600, 'calories' => 260, 'distance' => 6100], + ]); + + foreach (range(0, 11) as $index) { + $workout = $workouts->get($index % $workouts->count()); + $source = $index % 4 === 0 ? WorkoutSource::STRAVA : WorkoutSource::MANUAL; + + if (! is_array($workout)) { + throw new LogicException('Unable to build the demo workout collection.'); + } + + WorkoutSessions::factory()->for($demoUser)->create([ + 'external_id' => $source === WorkoutSource::STRAVA ? "demo-strava-{$index}" : null, + 'source' => $source, + 'type' => $workout['type'], + 'title' => $workout['title'], + 'duration_seconds' => $workout['duration'], + 'calories_burned' => $workout['calories'], + 'distance_meters' => $workout['distance'], + 'started_at' => now()->subDays($index)->setTime(18, 30), + ]); + } + } + + /** + * @param Collection $users + */ + private function demoUser(Collection $users): User + { + $demoUser = $users->first( + fn (User $user): bool => $user->email === 'test@example.com' + ); + + if (! $demoUser instanceof User) { + throw new LogicException('The demo user could not be created.'); + } + + return $demoUser; + } + + /** + * @return list> + */ + private function mealTemplates(): array + { + return [ + [ + 'title' => 'Bowl saumon et avocat', + 'caption' => 'Un déjeuner complet avec du riz, des légumes croquants et une sauce citronnée.', + 'imageUrl' => 'https://images.unsplash.com/photo-1546069901-ba9599a7e63c?auto=format&fit=crop&w=1200&q=85', + 'calories' => 620, + 'proteins' => 38, + 'carbs' => 58, + 'fats' => 24, + 'type' => MealPostType::LUNCH, + 'dietType' => DietType::PESCATARIAN, + 'ingredients' => [ + ['ingredient' => 'Saumon', 'quantity' => 140, 'unit' => IngredientUnit::GRAM], + ['ingredient' => 'Riz complet', 'quantity' => 160, 'unit' => IngredientUnit::GRAM], + ['ingredient' => 'Avocat', 'quantity' => 1, 'unit' => IngredientUnit::PIECE], + ], + ], + [ + 'title' => 'Pancakes aux fruits rouges', + 'caption' => 'Des pancakes moelleux accompagnés de fruits rouges et de yaourt.', + 'imageUrl' => 'https://images.unsplash.com/photo-1528207776546-365bb710ee93?auto=format&fit=crop&w=1200&q=85', + 'calories' => 480, + 'proteins' => 24, + 'carbs' => 64, + 'fats' => 14, + 'type' => MealPostType::BREAKFAST, + 'dietType' => DietType::VEGETARIAN, + 'ingredients' => [ + ['ingredient' => 'Flocons d’avoine', 'quantity' => 80, 'unit' => IngredientUnit::GRAM], + ['ingredient' => 'Œufs', 'quantity' => 2, 'unit' => IngredientUnit::PIECE], + ['ingredient' => 'Fruits rouges', 'quantity' => 120, 'unit' => IngredientUnit::GRAM], + ], + ], + [ + 'title' => 'Poulet rôti et légumes', + 'caption' => 'Une assiette familiale, riche en protéines et pleine de couleurs.', + 'imageUrl' => 'https://images.unsplash.com/photo-1532550907401-a500c9a57435?auto=format&fit=crop&w=1200&q=85', + 'calories' => 590, + 'proteins' => 52, + 'carbs' => 46, + 'fats' => 19, + 'type' => MealPostType::DINNER, + 'dietType' => DietType::OMNIVORE, + 'ingredients' => [ + ['ingredient' => 'Blanc de poulet', 'quantity' => 180, 'unit' => IngredientUnit::GRAM], + ['ingredient' => 'Pommes de terre', 'quantity' => 220, 'unit' => IngredientUnit::GRAM], + ['ingredient' => 'Légumes rôtis', 'quantity' => 180, 'unit' => IngredientUnit::GRAM], + ], + ], + [ + 'title' => 'Curry de pois chiches', + 'caption' => 'Un curry végétal doux, crémeux et parfait pour le batch cooking.', + 'imageUrl' => 'https://images.unsplash.com/photo-1603894584373-5ac82b2ae398?auto=format&fit=crop&w=1200&q=85', + 'calories' => 540, + 'proteins' => 21, + 'carbs' => 72, + 'fats' => 18, + 'type' => MealPostType::DINNER, + 'dietType' => DietType::VEGAN, + 'ingredients' => [ + ['ingredient' => 'Pois chiches', 'quantity' => 180, 'unit' => IngredientUnit::GRAM], + ['ingredient' => 'Lait de coco', 'quantity' => 120, 'unit' => IngredientUnit::MILLILITER], + ['ingredient' => 'Épinards', 'quantity' => 100, 'unit' => IngredientUnit::GRAM], + ], + ], + [ + 'title' => 'Pâtes au pesto maison', + 'caption' => 'Un classique rapide avec tomates cerises, roquette et parmesan.', + 'imageUrl' => 'https://images.unsplash.com/photo-1473093295043-cdd812d0e601?auto=format&fit=crop&w=1200&q=85', + 'calories' => 670, + 'proteins' => 25, + 'carbs' => 88, + 'fats' => 24, + 'type' => MealPostType::LUNCH, + 'dietType' => DietType::VEGETARIAN, + 'ingredients' => [ + ['ingredient' => 'Pâtes complètes', 'quantity' => 190, 'unit' => IngredientUnit::GRAM], + ['ingredient' => 'Pesto', 'quantity' => 2, 'unit' => IngredientUnit::TABLESPOON], + ['ingredient' => 'Tomates cerises', 'quantity' => 120, 'unit' => IngredientUnit::GRAM], + ], + ], + [ + 'title' => 'Toast avocat et œufs', + 'caption' => 'Le brunch simple qui tient toute la matinée.', + 'imageUrl' => 'https://images.unsplash.com/photo-1482049016688-2d3e1b311543?auto=format&fit=crop&w=1200&q=85', + 'calories' => 510, + 'proteins' => 26, + 'carbs' => 42, + 'fats' => 27, + 'type' => MealPostType::BRUNCH, + 'dietType' => DietType::VEGETARIAN, + 'ingredients' => [ + ['ingredient' => 'Pain complet', 'quantity' => 2, 'unit' => IngredientUnit::PIECE], + ['ingredient' => 'Avocat', 'quantity' => 1, 'unit' => IngredientUnit::PIECE], + ['ingredient' => 'Œufs', 'quantity' => 2, 'unit' => IngredientUnit::PIECE], + ], + ], + [ + 'title' => 'Tacos de poisson', + 'caption' => 'Poisson grillé, chou croquant et sauce au yaourt citronnée.', + 'imageUrl' => 'https://images.unsplash.com/photo-1551504734-5ee1c4a1479b?auto=format&fit=crop&w=1200&q=85', + 'calories' => 560, + 'proteins' => 36, + 'carbs' => 61, + 'fats' => 20, + 'type' => MealPostType::DINNER, + 'dietType' => DietType::PESCATARIAN, + 'ingredients' => [ + ['ingredient' => 'Poisson blanc', 'quantity' => 160, 'unit' => IngredientUnit::GRAM], + ['ingredient' => 'Tortillas', 'quantity' => 3, 'unit' => IngredientUnit::PIECE], + ['ingredient' => 'Chou rouge', 'quantity' => 100, 'unit' => IngredientUnit::GRAM], + ], + ], + [ + 'title' => 'Buddha bowl au tofu', + 'caption' => 'Tofu croustillant, quinoa, légumes et sauce sésame.', + 'imageUrl' => 'https://images.unsplash.com/photo-1512621776951-a57141f2eefd?auto=format&fit=crop&w=1200&q=85', + 'calories' => 575, + 'proteins' => 29, + 'carbs' => 69, + 'fats' => 22, + 'type' => MealPostType::LUNCH, + 'dietType' => DietType::VEGAN, + 'ingredients' => [ + ['ingredient' => 'Tofu', 'quantity' => 160, 'unit' => IngredientUnit::GRAM], + ['ingredient' => 'Quinoa', 'quantity' => 150, 'unit' => IngredientUnit::GRAM], + ['ingredient' => 'Brocoli', 'quantity' => 140, 'unit' => IngredientUnit::GRAM], + ], + ], + [ + 'title' => 'Steak et patate douce', + 'caption' => 'Une assiette protéinée avec patate douce rôtie et haricots verts.', + 'imageUrl' => 'https://images.unsplash.com/photo-1600891964092-4316c288032e?auto=format&fit=crop&w=1200&q=85', + 'calories' => 710, + 'proteins' => 49, + 'carbs' => 65, + 'fats' => 28, + 'type' => MealPostType::DINNER, + 'dietType' => DietType::OMNIVORE, + 'ingredients' => [ + ['ingredient' => 'Steak de bœuf', 'quantity' => 180, 'unit' => IngredientUnit::GRAM], + ['ingredient' => 'Patate douce', 'quantity' => 240, 'unit' => IngredientUnit::GRAM], + ['ingredient' => 'Haricots verts', 'quantity' => 150, 'unit' => IngredientUnit::GRAM], + ], + ], + [ + 'title' => 'Overnight oats banane', + 'caption' => 'Préparé la veille avec banane, graines de chia et beurre de cacahuète.', + 'imageUrl' => 'https://images.unsplash.com/photo-1517673400267-0251440c45dc?auto=format&fit=crop&w=1200&q=85', + 'calories' => 445, + 'proteins' => 18, + 'carbs' => 62, + 'fats' => 15, + 'type' => MealPostType::BREAKFAST, + 'dietType' => DietType::VEGETARIAN, + 'ingredients' => [ + ['ingredient' => 'Flocons d’avoine', 'quantity' => 70, 'unit' => IngredientUnit::GRAM], + ['ingredient' => 'Banane', 'quantity' => 1, 'unit' => IngredientUnit::PIECE], + ['ingredient' => 'Graines de chia', 'quantity' => 2, 'unit' => IngredientUnit::TEASPOON], + ], + ], + [ + 'title' => 'Salade grecque complète', + 'caption' => 'Tomates, concombre, feta, olives et pain pita.', + 'imageUrl' => 'https://images.unsplash.com/photo-1540420773420-3366772f4999?auto=format&fit=crop&w=1200&q=85', + 'calories' => 495, + 'proteins' => 19, + 'carbs' => 47, + 'fats' => 26, + 'type' => MealPostType::LUNCH, + 'dietType' => DietType::VEGETARIAN, + 'ingredients' => [ + ['ingredient' => 'Tomates', 'quantity' => 180, 'unit' => IngredientUnit::GRAM], + ['ingredient' => 'Feta', 'quantity' => 80, 'unit' => IngredientUnit::GRAM], + ['ingredient' => 'Pain pita', 'quantity' => 1, 'unit' => IngredientUnit::PIECE], + ], + ], + [ + 'title' => 'Ramen au poulet', + 'caption' => 'Un bouillon chaud avec nouilles, poulet, œuf et légumes.', + 'imageUrl' => 'https://images.unsplash.com/photo-1569718212165-3a8278d5f624?auto=format&fit=crop&w=1200&q=85', + 'calories' => 640, + 'proteins' => 42, + 'carbs' => 78, + 'fats' => 18, + 'type' => MealPostType::DINNER, + 'dietType' => DietType::OMNIVORE, + 'ingredients' => [ + ['ingredient' => 'Nouilles', 'quantity' => 180, 'unit' => IngredientUnit::GRAM], + ['ingredient' => 'Poulet', 'quantity' => 140, 'unit' => IngredientUnit::GRAM], + ['ingredient' => 'Œuf', 'quantity' => 1, 'unit' => IngredientUnit::PIECE], + ], + ], + ]; + } +} diff --git a/tests/Feature/DemoContentSeederTest.php b/tests/Feature/DemoContentSeederTest.php new file mode 100644 index 0000000..f320555 --- /dev/null +++ b/tests/Feature/DemoContentSeederTest.php @@ -0,0 +1,56 @@ +seed(); + + $demoUser = User::query()->where('email', 'test@example.com')->firstOrFail(); + $onboardingUser = User::query()->where('email', 'onboarding@example.com')->firstOrFail(); + + expect(User::query()->count())->toBe(10) + ->and($demoUser->hasCompletedNutritionOnboarding())->toBeTrue() + ->and($onboardingUser->hasCompletedNutritionOnboarding())->toBeFalse() + ->and($demoUser->weightEntries()->count())->toBe(12) + ->and($demoUser->workouts()->count())->toBe(12) + ->and($demoUser->following()->count())->toBe(8) + ->and(MealPosts::query()->where('visibility', MealPostVisibility::Public)->count())->toBe(48) + ->and(MealPostIngredients::query()->count())->toBe(144) + ->and(PostReviews::query()->count())->toBe(120) + ->and( + MealPosts::query() + ->withCount('likedByUsers') + ->get() + ->sum('liked_by_users_count') + )->toBeGreaterThan(100); +}); + +it('fills more than one explorer page with usable meal images', function () { + $this->seed(DemoContentSeeder::class); + + $demoUser = User::query()->where('email', 'test@example.com')->firstOrFail(); + + Sanctum::actingAs($demoUser); + + $this->getJson('/api/meal-posts?per_page=30&page=1') + ->assertOk() + ->assertJsonCount(30, 'data') + ->assertJsonPath('meta.current_page', 1) + ->assertJsonPath('meta.last_page', 2) + ->assertJsonPath('meta.total', 48) + ->assertJsonPath('data.0.imageUrl', fn (string $url): bool => str_starts_with($url, 'https://images.unsplash.com/')); + + $this->getJson('/api/meal-posts?per_page=30&page=2') + ->assertOk() + ->assertJsonCount(18, 'data') + ->assertJsonPath('meta.current_page', 2); +});