Output:
Chunk ID: 099246
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1017
Output:
<?php

namespace Database\Seeders;

use App\Models\Collection as ProductCollection;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductMedia;
use App\Models\ProductOption;
use App\Models\ProductOptionValue;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Database\Seeder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

class ProductSeeder extends Seeder
{
    public function run(): void
    {
        DB::transaction(function (): void {
            $this->seedProducts('acme-fashion', $this->fashionProducts());
            $this->seedProducts('acme-electronics', $this->electronicsProducts());
        });
    }

    /**
     * @param  array<int, array<string, mixed>>  $products
     */
    private function seedProducts(string $storeHandle, array $products): void
    {
        $store = Store::query()->where('handle', $storeHandle)->firstOrFail();
        $collections = ProductCollection::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->get()
            ->keyBy('handle');

        foreach ($products as $index => $data) {
            $product = Product::withoutGlobalScopes()->updateOrCreate(
                [
                    'store_id' => $store->getKey(),
                    'handle' => $data['handle'],
                ],
                [
                    ...Arr::only($data, ['title', 'status', 'description_html', 'vendor', 'product_type', 'tags', 'published_at']),
                    'store_id' => $store->getKey(),
                ],
            );

            $this->replaceCatalogChildren($product);

            $optionValueIds = $this->createOptions($product, $data['options'] ?? []);
            $this->createVariants($store, $product, $data, $optionValueIds);

            $collectionIds = collect($data['collections'] ?? [])
                ->map(fn (string $handle): ?int => $collections->get($handle)?->getKey())
                ->filter()
                ->mapWithKeys(fn (int $collectionId): array => [$collectionId => ['position' => $index]])
                ->all();

            $product->collections()->sync($collectionIds);
        }
    }

    private function replaceCatalogChildren(Product $product): void
    {
        ProductMedia::withoutGlobalScopes()
            ->where('product_id', $product->getKey())
            ->get()
            ->each
            ->delete();

        ProductVariant::withoutGlobalScopes()
            ->where('product_id', $product->getKey())
            ->get()
            ->each
            ->delete();

        ProductOption::withoutGlobalScopes()
            ->where('product_id', $product->getKey())
            ->delete();
    }

    /**
     * @param  array<string, array<int, string>>  $options
     * @return array<string, array<string, int>>
     */
    private function createOptions(Product $product, array $options): array
    {
        $optionValueIds = [];

        foreach ($options as $optionPosition => $valuesByName) {
            $optionName = (string) array_key_first($valuesByName);
            $option = ProductOption::withoutGlobalScopes()->create([
                'product_id' => $product->getKey(),
                'name' => $optionName,
                'position' => $optionPosition,
            ]);

            foreach (array_values($valuesByName[$optionName]) as $valuePosition => $value) {
                $optionValue = ProductOptionValue::withoutGlobalScopes()->create([
                    'product_option_id' => $option->getKey(),
                    'value' => $value,
                    'position' => $valuePosition,
                ]);

                $optionValueIds[$optionName][$value] = $optionValue->getKey();
            }
        }

        return $optionValueIds;
    }

    /**
     * @param  array<string, mixed>  $data
     * @param  array<string, array<string, int>>  $optionValueIds
     */
    private function createVariants(Store $store, Product $product, array $data, array $optionValueIds): void
    {


rg "app\(\)->forgetInstance\('current_store'\)|forgetInstance\(\"current_store\"\)|current_store" tests -n

rg "app\\(\\)->forgetInstance\\('current_store'\\)|forgetInstance\\(\"current_store\"\\)|current_store" tests -n

Question: app\(\)->forgetInstance\('current_store'\)|forgetInstance\(\"current_store\"\)|current_store

tests/Feature/Storefront/OrderViewsTest.php:33:    app()->instance('current_store', $store);
tests/Feature/Checkout/PricingServicesTest.php:25:    app()->instance('current_store', $store);
tests/Feature/Orders/OrderServiceTest.php:34:    app()->instance('current_store', $store);
tests/Feature/Admin/SearchSettingsTest.php:41:    app()->instance('current_store', $store);
tests/Feature/Admin/SearchSettingsTest.php:60:    app()->instance('current_store', $store);
tests/Feature/Storefront/CartCheckoutUiTest.php:32:    app()->instance('current_store', $store);
tests/Feature/Checkout/CheckoutServiceTest.php:24:    app()->instance('current_store', $store);
tests/Feature/Admin/AnalyticsDashboardTest.php:58:    app()->instance('current_store', $store);
tests/Feature/Orders/BankTransferOrderTest.php:32:    app()->instance('current_store', $store);
tests/Feature/Catalog/ProductServiceTest.php:34:    app()->instance('current_store', $store);
tests/Feature/Catalog/ProductServiceTest.php:50:    app()->instance('current_store', $store);
tests/Feature/Catalog/ProductServiceTest.php:70:    app()->instance('current_store', $store);
tests/Feature/Catalog/ProductServiceTest.php:119:    app()->instance('current_store', $store);
tests/Feature/Catalog/ProductServiceTest.php:144:    app()->instance('current_store', $store);
tests/Feature/Admin/ProductMediaManagementTest.php:40:    app()->instance('current_store', $store);
tests/Feature/Admin/ProductMediaManagementTest.php:75:    app()->instance('current_store', $store);
tests/Feature/Admin/ProductMediaManagementTest.php:110:    app()->instance('current_store', $store);
tests/Feature/Admin/ProductMediaManagementTest.php:145:    app()->instance('current_store', $store);
tests/Feature/Admin/CustomerManagementTest.php:24:    app()->instance('current_store', $store);
tests/Feature/Admin/CustomerManagementTest.php:72:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/CustomerManagementTest.php:80:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/CustomerManagementTest.php:88:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/CustomerManagementTest.php:159:    app()->instance('current_store', $store);
tests/Feature/Admin/AppsDevelopersTest.php:60:    app()->instance('current_store', $store);
tests/Feature/Storefront/ThemeDataTest.php:52:    app()->instance('current_store', $firstStore);
tests/Feature/Storefront/ThemeDataTest.php:74:    app()->instance('current_store', $firstStore);
tests/Feature/Orders/RefundServiceTest.php:28:    app()->instance('current_store', $store);
tests/Feature/Foundation/TenancyTest.php:56:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/DashboardTest.php:24:    app()->instance('current_store', $store);
tests/Feature/Admin/DashboardTest.php:80:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Catalog/CatalogFoundationTest.php:44:    app()->instance('current_store', $firstStore);
tests/Feature/Catalog/CatalogFoundationTest.php:64:    app()->instance('current_store', $firstStore);
tests/Feature/Catalog/CatalogFoundationTest.php:77:    app()->instance('current_store', $resolvedStore);
tests/Feature/Admin/OrderManagementTest.php:34:    app()->instance('current_store', $store);
tests/Feature/Admin/OrderManagementTest.php:225:    app()->instance('current_store', $store);
tests/Feature/Admin/DiscountManagementTest.php:31:    app()->instance('current_store', $store);
tests/Feature/Admin/DiscountManagementTest.php:77:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/DiscountManagementTest.php:86:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/DiscountManagementTest.php:92:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/DiscountManagementTest.php:195:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/DiscountManagementTest.php:200:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/DiscountManagementTest.php:205:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Orders/FulfillmentServiceTest.php:28:    app()->instance('current_store', $store);
tests/Feature/Foundation/AuthorizationTest.php:34:    app()->instance('current_store', $store);
tests/Feature/Foundation/AuthorizationTest.php:50:    app()->instance('current_store', $store);
tests/Feature/Admin/SettingsManagementTest.php:30:    app()->instance('current_store', $store);
tests/Feature/Admin/SettingsManagementTest.php:58:            ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/SettingsManagementTest.php:63:            ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/ContentManagementTest.php:33:    app()->instance('current_store', $store);
tests/Feature/Admin/ContentManagementTest.php:66:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/ContentManagementTest.php:73:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/ContentManagementTest.php:79:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/ContentManagementTest.php:85:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/ContentManagementTest.php:91:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Catalog/InventoryServiceTest.php:14:    app()->instance('current_store', $store);
tests/Feature/Catalog/InventoryServiceTest.php:39:    app()->instance('current_store', $store);
tests/Feature/Foundation/CustomerAuthTest.php:29:    app()->instance('current_store', $firstStore);
tests/Feature/Foundation/CustomerAuthTest.php:43:    app()->instance('current_store', $secondStore);
tests/Feature/Foundation/CustomerAuthTest.php:59:    app()->instance('current_store', $store);
tests/Feature/Foundation/CustomerAuthTest.php:74:    app()->instance('current_store', $store);
tests/Feature/Catalog/VariantMatrixServiceTest.php:15:    app()->instance('current_store', $store);
tests/Feature/Catalog/VariantMatrixServiceTest.php:43:    app()->instance('current_store', $store);
tests/Feature/Catalog/VariantMatrixServiceTest.php:56:    app()->instance('current_store', $store);
tests/Feature/Cart/CartServiceTest.php:22:    app()->instance('current_store', $store);
tests/Feature/Catalog/CatalogUiTest.php:78:    app()->instance('current_store', $store);
tests/Feature/Catalog/CatalogUiTest.php:158:    app()->instance('current_store', $store);
tests/Feature/Auth/CustomerPasswordResetTest.php:59:    app()->instance('current_store', $store);
tests/Feature/Auth/CustomerPasswordResetTest.php:92:    app()->instance('current_store', $store);
tests/Feature/Auth/CustomerPasswordResetTest.php:115:    app()->instance('current_store', $store);
tests/Feature/Auth/CustomerPasswordResetTest.php:146:    app()->instance('current_store', $store);
tests/Feature/Auth/CustomerPasswordResetTest.php:177:    app()->instance('current_store', $otherStore);
tests/Feature/Auth/CustomerPasswordResetTest.php:193:    app()->instance('current_store', $store);


Output:
Chunk ID: 6beff7
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1811
Output:
tests/Feature/Storefront/OrderViewsTest.php:33:    app()->instance('current_store', $store);
tests/Feature/Checkout/PricingServicesTest.php:25:    app()->instance('current_store', $store);
tests/Feature/Orders/OrderServiceTest.php:34:    app()->instance('current_store', $store);
tests/Feature/Admin/SearchSettingsTest.php:41:    app()->instance('current_store', $store);
tests/Feature/Admin/SearchSettingsTest.php:60:    app()->instance('current_store', $store);
tests/Feature/Storefront/CartCheckoutUiTest.php:32:    app()->instance('current_store', $store);
tests/Feature/Checkout/CheckoutServiceTest.php:24:    app()->instance('current_store', $store);
tests/Feature/Admin/AnalyticsDashboardTest.php:58:    app()->instance('current_store', $store);
tests/Feature/Orders/BankTransferOrderTest.php:32:    app()->instance('current_store', $store);
tests/Feature/Catalog/ProductServiceTest.php:34:    app()->instance('current_store', $store);
tests/Feature/Catalog/ProductServiceTest.php:50:    app()->instance('current_store', $store);
tests/Feature/Catalog/ProductServiceTest.php:70:    app()->instance('current_store', $store);
tests/Feature/Catalog/ProductServiceTest.php:119:    app()->instance('current_store', $store);
tests/Feature/Catalog/ProductServiceTest.php:144:    app()->instance('current_store', $store);
tests/Feature/Admin/ProductMediaManagementTest.php:40:    app()->instance('current_store', $store);
tests/Feature/Admin/ProductMediaManagementTest.php:75:    app()->instance('current_store', $store);
tests/Feature/Admin/ProductMediaManagementTest.php:110:    app()->instance('current_store', $store);
tests/Feature/Admin/ProductMediaManagementTest.php:145:    app()->instance('current_store', $store);
tests/Feature/Admin/CustomerManagementTest.php:24:    app()->instance('current_store', $store);
tests/Feature/Admin/CustomerManagementTest.php:72:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/CustomerManagementTest.php:80:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/CustomerManagementTest.php:88:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/CustomerManagementTest.php:159:    app()->instance('current_store', $store);
tests/Feature/Admin/AppsDevelopersTest.php:60:    app()->instance('current_store', $store);
tests/Feature/Storefront/ThemeDataTest.php:52:    app()->instance('current_store', $firstStore);
tests/Feature/Storefront/ThemeDataTest.php:74:    app()->instance('current_store', $firstStore);
tests/Feature/Orders/RefundServiceTest.php:28:    app()->instance('current_store', $store);
tests/Feature/Foundation/TenancyTest.php:56:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/DashboardTest.php:24:    app()->instance('current_store', $store);
tests/Feature/Admin/DashboardTest.php:80:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Catalog/CatalogFoundationTest.php:44:    app()->instance('current_store', $firstStore);
tests/Feature/Catalog/CatalogFoundationTest.php:64:    app()->instance('current_store', $firstStore);
tests/Feature/Catalog/CatalogFoundationTest.php:77:    app()->instance('current_store', $resolvedStore);
tests/Feature/Admin/OrderManagementTest.php:34:    app()->instance('current_store', $store);
tests/Feature/Admin/OrderManagementTest.php:225:    app()->instance('current_store', $store);
tests/Feature/Admin/DiscountManagementTest.php:31:    app()->instance('current_store', $store);
tests/Feature/Admin/DiscountManagementTest.php:77:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/DiscountManagementTest.php:86:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/DiscountManagementTest.php:92:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/DiscountManagementTest.php:195:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/DiscountManagementTest.php:200:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/DiscountManagementTest.php:205:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Orders/FulfillmentServiceTest.php:28:    app()->instance('current_store', $store);
tests/Feature/Foundation/AuthorizationTest.php:34:    app()->instance('current_store', $store);
tests/Feature/Foundation/AuthorizationTest.php:50:    app()->instance('current_store', $store);
tests/Feature/Admin/SettingsManagementTest.php:30:    app()->instance('current_store', $store);
tests/Feature/Admin/SettingsManagementTest.php:58:            ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/SettingsManagementTest.php:63:            ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/ContentManagementTest.php:33:    app()->instance('current_store', $store);
tests/Feature/Admin/ContentManagementTest.php:66:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/ContentManagementTest.php:73:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/ContentManagementTest.php:79:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/ContentManagementTest.php:85:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Admin/ContentManagementTest.php:91:        ->withSession(['current_store_id' => $store->getKey()])
tests/Feature/Catalog/InventoryServiceTest.php:14:    app()->instance('current_store', $store);
tests/Feature/Catalog/InventoryServiceTest.php:39:    app()->instance('current_store', $store);
tests/Feature/Foundation/CustomerAuthTest.php:29:    app()->instance('current_store', $firstStore);
tests/Feature/Foundation/CustomerAuthTest.php:43:    app()->instance('current_store', $secondStore);
tests/Feature/Foundation/CustomerAuthTest.php:59:    app()->instance('current_store', $store);
tests/Feature/Foundation/CustomerAuthTest.php:74:    app()->instance('current_store', $store);
tests/Feature/Catalog/VariantMatrixServiceTest.php:15:    app()->instance('current_store', $store);
tests/Feature/Catalog/VariantMatrixServiceTest.php:43:    app()->instance('current_store', $store);
tests/Feature/Catalog/VariantMatrixServiceTest.php:56:    app()->instance('current_store', $store);
tests/Feature/Cart/CartServiceTest.php:22:    app()->instance('current_store', $store);
tests/Feature/Catalog/CatalogUiTest.php:78:    app()->instance('current_store', $store);
tests/Feature/Catalog/CatalogUiTest.php:158:    app()->instance('current_store', $store);
tests/Feature/Auth/CustomerPasswordResetTest.php:59:    app()->instance('current_store', $store);
tests/Feature/Auth/CustomerPasswordResetTest.php:92:    app()->instance('current_store', $store);
tests/Feature/Auth/CustomerPasswordResetTest.php:115:    app()->instance('current_store', $store);
tests/Feature/Auth/CustomerPasswordResetTest.php:146:    app()->instance('current_store', $store);
tests/Feature/Auth/CustomerPasswordResetTest.php:177:    app()->instance('current_store', $otherStore);
tests/Feature/Auth/CustomerPasswordResetTest.php:193:    app()->instance('current_store', $store);


sed -n '1,100p' database/seeders/StoreUserSeeder.php


sed -n '1,100p' database/seeders/StoreUserSeeder.php

<?php

namespace Database\Seeders;

use App\Models\Store;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class StoreUserSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $user = User::query()->where('email', 'admin@acme.test')->firstOrFail();

        Store::query()->each(function (Store $store) use ($user): void {
            DB::table('store_users')->updateOrInsert(
                [
                    'store_id' => $store->getKey(),
                    'user_id' => $user->getKey(),
                ],
                [
                    'role' => 'owner',
                    'created_at' => now(),
                ],
            );
        });
    }
}


Output:
Chunk ID: 14bc7e
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 194
Output:
<?php

namespace Database\Seeders;

use App\Models\Store;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class StoreUserSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $user = User::query()->where('email', 'admin@acme.test')->firstOrFail();

        Store::query()->each(function (Store $store) use ($user): void {
            DB::table('store_users')->updateOrInsert(
                [
                    'store_id' => $store->getKey(),
                    'user_id' => $user->getKey(),
                ],
                [
                    'role' => 'owner',
                    'created_at' => now(),
                ],
            );
        });
    }
}


php artisan test --compact tests/Feature/Admin/ProductMediaManagementTest.php tests/Feature/Catalog/CatalogUiTest.php


Output:
Chunk ID: 6dea03
Wall time: 3.2729 seconds
Process exited with code 0
Original token count: 18
Output:

  ........

  Tests:    8 passed (68 assertions)
  Duration: 3.00s


php artisan test --compact tests/Feature/Admin/ProductMediaManagementTest.php tests/Feature/Catalog/CatalogUiTest.php

  ........

  Tests:    8 passed (68 assertions)
  Duration: 3.00s


The catalog failure does not reproduce in isolation or when run immediately after the new media tests, so it looks like an existing suite-order/session-store leak rather than the media code itself. I’m rerunning the full suite once more to confirm whether it is intermittent before deciding on a test isolation fix.


Output:
Chunk ID: 8da481
Wall time: 30.0020 seconds
Process running with session ID 56122
Original token count: 42
Output:

  ............................................................................
  ............................................................................
  ....


Output:
Chunk ID: f7d056
Wall time: 1.0666 seconds
Process exited with code 0
Original token count: 25
Output:
...................................

  Tests:    191 passed (964 assertions)
  Duration: 36.99s


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/specs/progress.md
@@
-- 2026-05-04: `php artisan test --compact tests/Feature/Admin/ProductMediaManagementTest.php` passed: 3 tests, 20 assertions.
-- 2026-05-04: `php artisan test --compact tests/Feature/Admin/ProductMediaManagementTest.php tests/Feature/Catalog/MediaProcessingTest.php tests/Feature/Catalog/CatalogUiTest.php` passed: 9 tests, 81 assertions.
-- 2026-05-04: `php artisan test --compact tests/Feature/Admin` passed after the product media UI changes: 40 tests, 242 assertions.
-- 2026-05-04: `php artisan test --compact` passed after the product media UI changes: 190 tests, 960 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Admin/ProductMediaManagementTest.php` passed: 4 tests, 24 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Admin/ProductMediaManagementTest.php tests/Feature/Catalog/MediaProcessingTest.php tests/Feature/Catalog/CatalogUiTest.php` passed: 9 tests, 81 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Admin` passed after the product media UI changes: 41 tests, 246 assertions.
+- 2026-05-04: `php artisan test --compact` passed after the product media UI changes: 191 tests, 964 assertions.
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/specs/progress.md

@@ -242,6 +242,6 @@
 - 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding product form media upload code.
-- 2026-05-04: `php artisan test --compact tests/Feature/Admin/ProductMediaManagementTest.php` passed: 3 tests, 20 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Admin/ProductMediaManagementTest.php` passed: 4 tests, 24 assertions.
 - 2026-05-04: `php artisan test --compact tests/Feature/Admin/ProductMediaManagementTest.php tests/Feature/Catalog/MediaProcessingTest.php tests/Feature/Catalog/CatalogUiTest.php` passed: 9 tests, 81 assertions.
-- 2026-05-04: `php artisan test --compact tests/Feature/Admin` passed after the product media UI changes: 40 tests, 242 assertions.
-- 2026-05-04: `php artisan test --compact` passed after the product media UI changes: 190 tests, 960 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Admin` passed after the product media UI changes: 41 tests, 246 assertions.
+- 2026-05-04: `php artisan test --compact` passed after the product media UI changes: 191 tests, 964 assertions.
 - 2026-05-04: `npm run build` passed after the product media Blade changes.


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/specs/progress.md\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


 M app/Livewire/Admin/Products/Form.php
 M resources/views/livewire/admin/products/form.blade.php
 M specs/progress.md
?? tests/Feature/Admin/ProductMediaManagementTest.php


Output:
Chunk ID: 9b0486
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 44
Output:
 M app/Livewire/Admin/Products/Form.php
 M resources/views/livewire/admin/products/form.blade.php
 M specs/progress.md
?? tests/Feature/Admin/ProductMediaManagementTest.php


The full suite passed on rerun: 191 tests, 964 assertions. Since the browser smoke logged in and touched local seeded data, I’m refreshing and reseeding the Herd SQLite database before committing so the local app returns to deterministic fixtures.


php artisan migrate:fresh --seed --no-interaction


Output:
Chunk ID: dc3e33
Wall time: 1.1459 seconds
Process exited with code 0
Original token count: 1968
Output:

  Dropping all tables ............................................ 6.34ms DONE

   INFO  Preparing database.

  Creating migration table ....................................... 1.84ms DONE

   INFO  Running migrations.

  0001_01_01_000000_create_users_table ........................... 1.88ms DONE
  0001_01_01_000001_create_cache_table ........................... 0.40ms DONE
  0001_01_01_000002_create_jobs_table ............................ 1.09ms DONE
  2025_08_14_170933_add_two_factor_columns_to_users_table ........ 0.44ms DONE
  2026_05_03_211409_create_organizations_table ................... 0.23ms DONE
  2026_05_03_211413_create_stores_table .......................... 5.13ms DONE
  2026_05_03_211416_create_store_domains_table ................... 0.46ms DONE
  2026_05_03_211419_create_store_settings_table .................. 0.21ms DONE
  2026_05_03_211425_create_store_users_table ..................... 0.39ms DONE
  2026_05_03_211556_create_customers_table ....................... 0.34ms DONE
  2026_05_03_212158_create_customer_password_reset_tokens_table .. 0.19ms DONE
  2026_05_03_213856_create_products_table ........................ 0.57ms DONE
  2026_05_03_213857_create_product_options_table ................. 0.42ms DONE
  2026_05_03_213858_create_product_option_values_table ........... 0.37ms DONE
  2026_05_03_213859_create_product_variants_table ................ 0.65ms DONE
  2026_05_03_213900_create_variant_option_values_table ........... 0.27ms DONE
  2026_05_03_213901_create_inventory_items_table ................. 0.46ms DONE
  2026_05_03_213902_create_collections_table ..................... 0.51ms DONE
  2026_05_03_213903_create_collection_products_table ............. 0.36ms DONE
  2026_05_03_213904_create_product_media_table ................... 0.45ms DONE
  2026_05_03_223824_create_themes_table .......................... 0.32ms DONE
  2026_05_03_223830_create_theme_files_table ..................... 0.30ms DONE
  2026_05_03_223837_create_theme_settings_table .................. 0.16ms DONE
  2026_05_03_223841_create_pages_table ........................... 0.39ms DONE
  2026_05_03_223844_create_navigation_menus_table ................ 0.29ms DONE
  2026_05_03_223848_create_navigation_items_table ................ 0.32ms DONE
  2026_05_03_225400_create_carts_table ........................... 0.39ms DONE
  2026_05_03_225405_create_cart_lines_table ...................... 0.34ms DONE
  2026_05_03_225409_create_checkouts_table ....................... 0.88ms DONE
  2026_05_03_225413_create_shipping_zones_table .................. 0.30ms DONE
  2026_05_03_225418_create_shipping_rates_table .................. 0.40ms DONE
  2026_05_03_225422_create_tax_settings_table .................... 0.25ms DONE
  2026_05_03_225427_create_discounts_table ....................... 0.58ms DONE
  2026_05_04_000408_create_customer_addresses_table .............. 0.36ms DONE
  2026_05_04_000408_create_orders_table .......................... 1.33ms DONE
  2026_05_04_000410_create_order_lines_table ..................... 0.49ms DONE
  2026_05_04_000411_create_payments_table ........................ 0.51ms DONE
  2026_05_04_000412_create_refunds_table ......................... 0.42ms DONE
  2026_05_04_000413_create_fulfillments_table .................... 0.43ms DONE
  2026_05_04_000414_create_fulfillment_lines_table ............... 0.32ms DONE
  2026_05_04_030847_create_search_settings_table ................. 0.20ms DONE
  2026_05_04_030848_create_products_fts_table .................... 0.35ms DONE
  2026_05_04_030848_create_search_queries_table .................. 0.37ms DONE
  2026_05_04_032640_create_analytics_events_table ................ 0.58ms DONE
  2026_05_04_032641_create_analytics_dailies_table ............... 0.50ms DONE
  2026_05_04_034556_create_apps_table ............................ 0.30ms DONE
  2026_05_04_034601_create_app_installations_table ............... 0.51ms DONE
  2026_05_04_034606_create_oauth_clients_table ................... 0.33ms DONE
  2026_05_04_034611_create_oauth_tokens_table .................... 0.41ms DONE
  2026_05_04_034616_create_webhook_subscriptions_table ........... 0.41ms DONE
  2026_05_04_034621_create_webhook_deliveries_table .............. 0.45ms DONE

   INFO  Seeding database.

  Database\Seeders\OrganizationSeeder ................................ RUNNING
  Database\Seeders\OrganizationSeeder .............................. 5 ms DONE

  Database\Seeders\StoreSeeder ....................................... RUNNING
  Database\Seeders\StoreSeeder ..................................... 1 ms DONE

  Database\Seeders\StoreDomainSeeder ................................. RUNNING
  Database\Seeders\StoreDomainSeeder ............................... 1 ms DONE

  Database\Seeders\AppSeeder ......................................... RUNNING
  Database\Seeders\AppSeeder ...................................... 10 ms DONE

  Database\Seeders\UserSeeder ........................................ RUNNING
  Database\Seeders\UserSeeder .................................... 273 ms DONE

  Database\Seeders\StoreUserSeeder ................................... RUNNING
  Database\Seeders\StoreUserSeeder ................................. 1 ms DONE

  Database\Seeders\StoreSettingsSeeder ............................... RUNNING
  Database\Seeders\StoreSettingsSeeder ............................. 1 ms DONE

  Database\Seeders\SearchSettingsSeeder .............................. RUNNING
  Database\Seeders\SearchSettingsSeeder ............................ 1 ms DONE

  Database\Seeders\CollectionSeeder .................................. RUNNING
  Database\Seeders\CollectionSeeder ................................ 4 ms DONE

  Database\Seeders\ProductSeeder ..................................... RUNNING
  Database\Seeders\ProductSeeder ................................. 212 ms DONE

  Database\Seeders\ThemeSeeder ....................................... RUNNING
  Database\Seeders\ThemeSeeder ..................................... 1 ms DONE

  Database\Seeders\ThemeFileSeeder ................................... RUNNING
  Database\Seeders\ThemeFileSeeder ................................. 1 ms DONE

  Database\Seeders\ThemeSettingsSeeder ............................... RUNNING
  Database\Seeders\ThemeSettingsSeeder ............................. 1 ms DONE

  Database\Seeders\PageSeeder ........................................ RUNNING
  Database\Seeders\PageSeeder ...................................... 2 ms DONE

  Database\Seeders\NavigationMenuSeeder .............................. RUNNING
  Database\Seeders\NavigationMenuSeeder ............................ 7 ms DONE

  Database\Seeders\NavigationItemSeeder .............................. RUNNING
  Database\Seeders\NavigationItemSeeder ............................ 4 ms DONE

  Database\Seeders\TaxSettingsSeeder ................................. RUNNING
  Database\Seeders\TaxSettingsSeeder ............................... 1 ms DONE

  Database\Seeders\ShippingZoneSeeder ................................ RUNNING
  Database\Seeders\ShippingZoneSeeder .............................. 1 ms DONE

  Database\Seeders\ShippingRateSeeder ................................ RUNNING
  Database\Seeders\ShippingRateSeeder .............................. 1 ms DONE

  Database\Seeders\DiscountSeeder .................................... RUNNING
  Database\Seeders\DiscountSeeder .................................. 3 ms DONE

  Database\Seeders\CustomerSeeder .................................... RUNNING
  Database\Seeders\CustomerSeeder ................................ 267 ms DONE

  Database\Seeders\AnalyticsSeeder ................................... RUNNING
  Database\Seeders\AnalyticsSeeder ................................ 67 ms DONE


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/specs/progress.md
@@
 - 2026-05-04: `npm run build` passed after the product media Blade changes.
 - 2026-05-04: `mcp__laravel_boost__.get_absolute_url(path: "/admin/products/1/edit")` resolved `http://shop.test/admin/products/1/edit`; Playwright MCP verified the product edit page renders the Media section and current console checks report no warnings/errors.
+- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after product media browser verification to restore seeded fixtures.
*** End Patch


@@ -248,2 +248,3 @@
 - 2026-05-04: `mcp__laravel_boost__.get_absolute_url(path: "/admin/products/1/edit")` resolved `http://shop.test/admin/products/1/edit`; Playwright MCP verified the product edit page renders the Media section and current console checks report no warnings/errors.
+- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after product media browser verification to restore seeded fixtures.


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/specs/progress.md\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Output:
Chunk ID: efe550
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 44
Output:
 M app/Livewire/Admin/Products/Form.php
 M resources/views/livewire/admin/products/form.blade.php
 M specs/progress.md
?? tests/Feature/Admin/ProductMediaManagementTest.php


Output:
Chunk ID: 5919ef
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


 app/Livewire/Admin/Products/Form.php               | 209 ++++++++++++++++++++-
 .../views/livewire/admin/products/form.blade.php   |  73 +++++++
 specs/progress.md                                  |  19 +-
 3 files changed, 294 insertions(+), 7 deletions(-)


Output:
Chunk ID: d96ae8
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 65
Output:
 app/Livewire/Admin/Products/Form.php               | 209 ++++++++++++++++++++-
 .../views/livewire/admin/products/form.blade.php   |  73 +++++++
 specs/progress.md                                  |  19 +-
 3 files changed, 294 insertions(+), 7 deletions(-)


Inspect product media spec, existing model/job/service, and admin product form

Implement product media upload/reorder/delete UI through existing media processing boundaries

Add focused Pest coverage for product media admin behavior

Run formatter, focused/full tests, build/browser smoke

Update progress log and commit the media slice


Output: Plan updated


The media slice is verified and the progress log is updated. I’m staging the product form, Blade UI, focused tests, and progress log for a separate commit.


git add app/Livewire/Admin/Products/Form.php resources/views/livewire/admin/products/form.blade.php tests/Feature/Admin/ProductMediaManagementTest.php specs/progress.md && git diff --cached --stat


git add app/Livewire/Admin/Products/Form.php resources/views/livewire/admin/products/form.blade.php tests/Feature/Admin/ProductMediaManagementTest.php specs/progress.md && git diff --cached --stat

 app/Livewire/Admin/Products/Form.php               | 209 ++++++++++++++++++++-
 .../views/livewire/admin/products/form.blade.php   |  73 +++++++
 specs/progress.md                                  |  19 +-
 tests/Feature/Admin/ProductMediaManagementTest.php | 165 ++++++++++++++++
 4 files changed, 459 insertions(+), 7 deletions(-)


Output:
Chunk ID: 37fb66
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 84
Output:
 app/Livewire/Admin/Products/Form.php               | 209 ++++++++++++++++++++-
 .../views/livewire/admin/products/form.blade.php   |  73 +++++++
 specs/progress.md                                  |  19 +-
 tests/Feature/Admin/ProductMediaManagementTest.php | 165 ++++++++++++++++
 4 files changed, 459 insertions(+), 7 deletions(-)


git commit -m "Add admin product media management"


git commit -m "Add admin product media management"

[2026-05-04-codex-5-5-goal 66285017] Add admin product media management
 4 files changed, 459 insertions(+), 7 deletions(-)
 create mode 100644 tests/Feature/Admin/ProductMediaManagementTest.php


Output:
Chunk ID: 3d8910
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 49
Output:
[2026-05-04-codex-5-5-goal 66285017] Add admin product media management
 4 files changed, 459 insertions(+), 7 deletions(-)
 create mode 100644 tests/Feature/Admin/ProductMediaManagementTest.php


Output:
Chunk ID: ce34e4
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


Committed `66285017 Add admin product media management`; the worktree is clean. I’m moving to the next bounded gap: enforcing the existing `one_per_customer` discount rule in `DiscountService`, since the admin UI already captures it but the pricing layer still ignores usage history.


Inspect discount spec, order schema, and existing DiscountService/tests

Implement customer usage-history enforcement for one_per_customer discounts

Add focused pricing/order tests for redemption enforcement

Update progress log and commit the discount rule slice

Explanation: Starting the next completion gap: enforce discount one_per_customer history.


Output: Plan updated


Using `laravel-best-practices` and `pest-testing` for this backend/test slice. I’ll also refresh version-specific docs with Boost before touching the discount service.


eloquent where json contains sqlite
query json column where contains
database testing json assertions
pest datasets feature tests


rg -n "one_per_customer|discount|DiscountService|discount_allocations|discount_code" specs/05-BUSINESS-LOGIC.md specs/01-DATABASE-SCHEMA.md specs/03-ADMIN-UI.md specs/09-IMPLEMENTATION-ROADMAP.md


sed -n '1,360p' app/Services/DiscountService.php


sed -n '1,300p' tests/Feature/Checkout/PricingServicesTest.php


# Search Results

## Plugins Laravel
Plugins | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/plugins.md

## Laravel

**Source code**: [github.com/pestphp/pest-plugin-laravel](https://github.com/pestphp/pest-plugin-laravel)

To start using Pest's Laravel plugin, you need to require this plugin via Composer.

```bash
composer require pestphp/pest-plugin-laravel --dev
```

This plugin adds additional Artisan commands and functions to the default Pest installation. For example, to generate a new test in the `tests/Feature` directory, you can now utilize the `pest:test` Artisan command.

```bash
php artisan pest:test UsersTest
```

You may provide the `--unit` option when creating a test to place the test in the `tests/Unit` directory.

```bash
php artisan pest:test UsersTest --unit
```

Executing the `pest:dataset` Artisan command will create a fresh dataset in the `tests/Datasets` directory.

```bash
php artisan pest:dataset Emails
```

As you may know, Laravel provides a variety of assertions you can take advantage of in your feature tests. When using Pest's Laravel plugin, you may access all of those assertions as you typically would.

```php
it('has a welcome page', function () {
    $this->get('/')->assertStatus(200);
});
```

In addition, with the assistance of this plugin, it is possible for you to bypass the `$this` variable while using namespaced functions such as `actingAs`, `get`, `post` and `delete`.

```php
use function Pest\Laravel\{get};

it('has a welcome page', function () {
    get('/')->assertStatus(200);
    // same as $this->get('/')...
});
```

To illustrate this convenient feature using another example, we can write a test acting as an authenticated user accessing the restricted dashboard page.

```php
use App\Models\User;
use function Pest\Laravel\{actingAs};

test('authenticated user can access the dashboard', function () {
    $user = User::factory()->create();

    actingAs($user)->get('/dashboard')
        ->assertStatus(200);
});
```

As you may expect, all of the assertions that were previously accessible via `$this->` are available as namespace functions.

```php
use function Pest\Laravel\{actingAs, get, post, delete, ...};
```

You can find the full testing documentation on the Laravel website: [laravel.com/docs/12.x/testing](https://laravel.com/docs/12.x/testing).

---

---

## Http Tests Testing JSON APIs Fluent JSON Testing
Http Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md

### Fluent JSON Testing

Laravel also offers a beautiful way to fluently test your application's JSON responses. To get started, pass a closure to the `assertJson` method. This closure will be invoked with an instance of `Illuminate\Testing\Fluent\AssertableJson` which can be used to make assertions against the JSON that was returned by your application. The `where` method may be used to make assertions against a particular attribute of the JSON, while the `missing` method may be used to assert that a particular attribute is missing from the JSON:

```php tab=Pest
use Illuminate\Testing\Fluent\AssertableJson;

test('fluent json', function () {
    $response = $this->getJson('/users/1');

    $response
        ->assertJson(fn (AssertableJson $json) =>
            $json->where('id', 1)
                ->where('name', 'Victoria Faith')
                ->where('email', fn (string $email) => str($email)->is('victoria@gmail.com'))
                ->whereNot('status', 'pending')
                ->missing('password')
                ->etc()
        );
});
```

```php tab=PHPUnit
use Illuminate\Testing\Fluent\AssertableJson;

/**
 * A basic functional test example.
 */
public function test_fluent_json(): void
{
    $response = $this->getJson('/users/1');

    $response
        ->assertJson(fn (AssertableJson $json) =>
            $json->where('id', 1)
                ->where('name', 'Victoria Faith')
                ->where('email', fn (string $email) => str($email)->is('victoria@gmail.com'))
                ->whereNot('status', 'pending')
                ->missing('password')
                ->etc()
        );
}
```

#### Understanding the `etc` Method

In the example above, you may have noticed we invoked the `etc` method at the end of our assertion chain. This method informs Laravel that there may be other attributes present on the JSON object. If the `etc` method is not used, the test will fail if other attributes that you did not make assertions against exist on the JSON object.

The intention behind this behavior is to protect you from unintentionally exposing sensitive information in your JSON responses by forcing you to either explicitly make an assertion against the attribute or explicitly allow additional attributes via the `etc` method.

However, you should be aware that not including the `etc` method in your assertion chain does not ensure that additional attributes are not being added to arrays that are nested within your JSON object. The `etc` method only ensures that no additional attributes exist at the nesting level in which the `etc` method is invoked.

---

## Search Full-Text Search Running Full-Text Queries
Search | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/search.md

### Running Full-Text Queries

Once the index is in place, use the `whereFullText` query builder method to search against it. Laravel will generate the appropriate SQL for your database driver — for example, `MATCH(...) AGAINST(...)` on MariaDB and MySQL, and `to_tsvector(...) @@ plainto_tsquery(...)` on PostgreSQL:

```php
$articles = Article::whereFullText('body', 'web developer')->get();
```

When using MariaDB and MySQL, results are automatically ordered by relevance score. On PostgreSQL, `whereFullText` filters matching records but does not order them by relevance — if you need automatic relevance ordering on PostgreSQL, consider using [Scout's database engine](#database-engine), which handles this for you.

If you created a composite full-text index across multiple columns, you may search against all of them by passing the same array of columns to `whereFullText`:

```php
$articles = Article::whereFullText(
    ['title', 'body'], 'web developer'
)->get();
```

The `orWhereFullText` method may be used to add a full-text search clause as an "or" condition. For complete details, consult the [query builder documentation](/docs/{{version}}/queries#full-text-where-clauses).

---

## Urls URLs for Named Routes Eloquent Models
Urls | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/urls.md

#### Eloquent Models

You will often be generating URLs using the route key (typically the primary key) of [Eloquent models](/docs/{{version}}/eloquent). For this reason, you may pass Eloquent models as parameter values. The `route` helper will automatically extract the model's route key:

```php
echo route('post.show', ['post' => $post]);
```

---

## Datasets
Datasets | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/datasets.md

---
title: Datasets
description: With datasets, you can define an array of test data and Pest will run the same test for each set automatically. This saves time and effort by eliminating the need to repeat the same test manually with different data.
---

# Datasets

With datasets, you can define an array of test data and Pest will run the same test for each set automatically. This saves time and effort by eliminating the need to repeat the same test manually with different data.

```php
it('has emails', function (string $email) {
    expect($email)->not->toBeEmpty();
})->with(['enunomaduro@gmail.com', 'other@example.com']);
```

When running your tests, Pest will automatically add informative test descriptions to tests that use datasets, outlining the parameters used in each test, aiding in understanding the data and identifying issues if a test fails.

Naturally, it is possible to supply multiple arguments by providing an array containing arrays of arguments.

```php
it('has emails', function (string $name, string $email) {
    expect($email)->not->toBeEmpty();
})->with([
    ['Nuno', 'enunomaduro@gmail.com'],
    ['Other', 'other@example.com']
]);
```

To manually add your own description to a dataset value, you may simply assign it a key.

```php
it('has emails', function (string $email) {
    expect($email)->not->toBeEmpty();
})->with([
    'james' => 'james@laravel.com',
    'taylor' => 'taylor@laravel.com',
]);
```

If a key is added, Pest will use the key when generating the description for the test.

If the test name includes `:dataset`, the description will be interpolated into the test name at that location.

```bash
  ✓ it validates the "first_name" field
  ✓ it validates the "email" field
```

It is important to notice that when using `closures` in your dataset, you must declare the arguments type in the closure passed to the test function.

```php
it('can sum', function (int $a, int $b, int $result) {
    expect(sum($a, $b))->toBe($result);
})->with([
    'positive numbers' => [1, 2, 3],
    'negative numbers' => [-1, -2, -3],
    'using closure' => [fn () => 1, 2, 3],
]);
```

For larger or more complex scenarios, closures can be used.

```php

// Returning an array
test('The array contains only integers', function ($i) {
    expect($i)->toBeInt();
})->with(fn (): array => range(1, 99));

// Using a generator
test('The generator produces only integers', function ($i) {
    expect($i)->toBeInt();
})->with(function (): Generator {
    for ($i = 1 ; $i < 100_000_000_000 ; $i++) {
        yield $i;
    }
});
```

## Named Parameters

When using datasets with associative arrays, Pest matches the dataset keys to the closure parameter names, regardless of order. This allows you to define your dataset in any key order and have the values automatically mapped to the correct parameters.

```php
it('has user data', function (string $email, string $name) {
    expect($name)->toBeString();
    expect($email)->toContain('@');
})->with([
    ['name' => 'Taylor', 'email' => 'taylor@laravel.com'],
    ['name' => 'Nuno', 'email' => 'enunomaduro@gmail.com'],
]);
```

In the example above, even though the dataset defines `name` before `email`, Pest maps them correctly to the closure parameters `$email` and `$name`.

Named parameters also work with shared datasets and bound closures.

```php
dataset('users', [
    ['name' => 'Taylor', 'email' => 'taylor@laravel.com'],
    ['name' => 'Nuno', 'email' => 'enunomaduro@gmail.com'],
]);

it('has user data', function (string $email, string $name) {
    expect($name)->toBeString();
    expect($email)->toContain('@');
})->with('users');
```

## Bound Datasets

Pest's bound datasets can be used to obtain a dataset that is resolved after the `beforeEach()` method of your tests. This is particularly useful in Laravel applications (or any other Pest integration) where you may need a dataset of `App\Models\User` models that are created after your database schema is prepared by the `beforeEach()` method.

```php
it('can generate the full name of a user', function (User $user) {
    expect($user->full_name)->toBe("{$user->first_name} {$user->last_name}");
})->with([
    fn() => User::factory()->create(['first_name' => 'Nuno', 'last_name' => 'Maduro']),
    fn() => User::factory()->create(['first_name' => 'Luke', 'last_name' => 'Downing']),
    fn() => User::factory()->create(['first_name' => 'Freek', 'last_name' => 'Van Der Herten']),
]);
```

If you want, you can bind a single argument to the test case. However, Pest requires that it must be fully typed in the `it|test` function arguments.

```diff
-it('can generate the full name of a user', function ($user, $fullName) {
+it('can generate the full name of a user', function (User $user, $fullName) {
    expect($user->full_name)->toBe($fullName);
})->with([
    [fn() => User::factory()->create(['first_name' => 'Nuno', 'last_name' => 'Maduro']), 'Nuno Maduro'],
    [fn() => User::factory()->create(['first_name' => 'Luke', 'last_name' => 'Downing']), 'Luke Downing'],
    [fn() => User::factory()->create(['first_name' => 'Freek', 'last_name' => 'Van Der Herten']), 'Freek Van Der Herten'],
]);
```

## Sharing Datasets

By storing your datasets separately in the `tests/Datasets` folder, you can easily distinguish them from your test code and ensure that they do not clutter your main test files.

```diff
// tests/Unit/ExampleTest.php...
it('has emails', function (string $email) {
    expect($email)->not->toBeEmpty();
-})->with(['enunomaduro@gmail.com', 'other@example.com']);
+})->with('emails');

// tests/Datasets/Emails.php...
+dataset('emails', [
+    'enunomaduro@gmail.com',
+    'other@example.com'
+]);
```

Bound datasets, description keys, and other rules that are applicable to inline datasets can also be applied to shared datasets.

### Scoped Datasets

Occasionally, datasets may pertain only to a specific feature or set of folders. In such cases, rather than distributing the dataset globally within the `Datasets` folder, you can generate a `Datasets.php` file within the relevant folder requiring the dataset and restrict the dataset's scope to that folder alone.

```php
// tests/Feature/Products/ExampleTest.php...
it('has products', function (string $product) {
    expect($product)->not->toBeEmpty();
})->with('products');

// tests/Feature/Products/Datasets.php...
dataset('products', [
    'egg',
    'milk'
]);
```

## Combining Datasets

You can easily obtain complex datasets by combining both **inline** and **shared** datasets. When doing so, the datasets will be combined using a [cartesian product](https://en.wikipedia.org/wiki/Cartesian_product) approach.

In the following example, we verify that all of the specified businesses are closed on each of the provided weekdays.

```php
dataset('days_of_the_week', [
    'Saturday',
    'Sunday',
]);

test('business is closed on day', function(string $business, string $day) {
    expect(new $business)->isClosed($day)->toBeTrue();
})->with([
    Office::class,
    Bank::class,
    School::class
])->with('days_of_the_week');
```

When running the example above, Pest's output will contain a description of each of the validated combinations.

## Describe Blocks With Datasets

You can attach a dataset to a `describe()` block, and all tests within that block will receive the dataset values.

```php
describe('user notifications', function () {
    test('can send notification', function (string $channel) {
        expect($channel)->toBeString();
    });

    test('can queue notification', function (string $channel) {
        expect($channel)->toBeIn(['mail', 'sms']);
    });
})->with(['mail', 'sms']);
```

You can also use `beforeEach()->with()` inside a `describe()` block to apply a dataset to all tests within that scope.

```php
describe('user settings', function () {
    beforeEach()->with([10, 20, 30]);

    test('receives the dataset value', function (int $value) {
        expect($value)->toBeGreaterThan(0);
    });
});
```

## Repeating Tests

In some cases, you may need to repeat a test multiple times for debugging purposes or to ensure that the test is stable. On these occasions, you may use the `repeat()` method to repeat a test a given number of times.

```php
it('can repeat a test', function () {
    $result = /** Some code that may be unstable */;

    expect($result)->toBeTrue();
})->repeat(100); // Repeat the test 100 times
```

---

After becoming skilled at utilizing datasets for testing, the next crucial step is to gain an understanding of how to test for exceptions. This involves verifying that your code behaves correctly and throws appropriate exceptions when it encounters unexpected or erroneous input: [Exceptions →](/docs/exceptions)

_[Truncated]_


Output:
Wall time: 4.0263 seconds
Output:
[{"type":"text","text":"# Search Results\n\n## Plugins Laravel\nPlugins | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/plugins.md\n\n\n## Laravel\n\n**Source code**: [github.com/pestphp/pest-plugin-laravel](https://github.com/pestphp/pest-plugin-laravel)\n\nTo start using Pest's Laravel plugin, you need to require this plugin via Composer.\n\n```bash\ncomposer require pestphp/pest-plugin-laravel --dev\n```\n\nThis plugin adds additional Artisan commands and functions to the default Pest installation. For example, to generate a new test in the `tests/Feature` directory, you can now utilize the `pest:test` Artisan command.\n\n```bash\nphp artisan pest:test UsersTest\n```\n\nYou may provide the `--unit` option when creating a test to place the test in the `tests/Unit` directory.\n\n```bash\nphp artisan pest:test UsersTest --unit\n```\n\nExecuting the `pest:dataset` Artisan command will create a fresh dataset in the `tests/Datasets` directory.\n\n```bash\nphp artisan pest:dataset Emails\n```\n\nAs you may know, Laravel provides a variety of assertions you can take advantage of in your feature tests. When using Pest's Laravel plugin, you may access all of those assertions as you typically would.\n\n```php\nit('has a welcome page', function () {\n    $this->get('/')->assertStatus(200);\n});\n```\n\nIn addition, with the assistance of this plugin, it is possible for you to bypass the `$this` variable while using namespaced functions such as `actingAs`, `get`, `post` and `delete`.\n\n```php\nuse function Pest\\Laravel\\{get};\n\nit('has a welcome page', function () {\n    get('/')->assertStatus(200);\n    // same as $this->get('/')...\n});\n```\n\nTo illustrate this convenient feature using another example, we can write a test acting as an authenticated user accessing the restricted dashboard page.\n\n```php\nuse App\\Models\\User;\nuse function Pest\\Laravel\\{actingAs};\n\ntest('authenticated user can access the dashboard', function () {\n    $user = User::factory()->create();\n\n    actingAs($user)->get('/dashboard')\n        ->assertStatus(200);\n});\n```\n\nAs you may expect, all of the assertions that were previously accessible via `$this->` are available as namespace functions.\n\n```php\nuse function Pest\\Laravel\\{actingAs, get, post, delete, ...};\n```\n\nYou can find the full testing documentation on the Laravel website: [laravel.com/docs/12.x/testing](https://laravel.com/docs/12.x/testing).\n\n---\n\n---\n\n## Http Tests Testing JSON APIs Fluent JSON Testing\nHttp Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md\n\n\n### Fluent JSON Testing\n\nLaravel also offers a beautiful way to fluently test your application's JSON responses. To get started, pass a closure to the `assertJson` method. This closure will be invoked with an instance of `Illuminate\\Testing\\Fluent\\AssertableJson` which can be used to make assertions against the JSON that was returned by your application. The `where` method may be used to make assertions against a particular attribute of the JSON, while the `missing` method may be used to assert that a particular attribute is missing from the JSON:\n\n```php tab=Pest\nuse Illuminate\\Testing\\Fluent\\AssertableJson;\n\ntest('fluent json', function () {\n    $response = $this->getJson('/users/1');\n\n    $response\n        ->assertJson(fn (AssertableJson $json) =>\n            $json->where('id', 1)\n                ->where('name', 'Victoria Faith')\n                ->where('email', fn (string $email) => str($email)->is('victoria@gmail.com'))\n                ->whereNot('status', 'pending')\n                ->missing('password')\n                ->etc()\n        );\n});\n```\n\n```php tab=PHPUnit\nuse Illuminate\\Testing\\Fluent\\AssertableJson;\n\n/**\n * A basic functional test example.\n */\npublic function test_fluent_json(): void\n{\n    $response = $this->getJson('/users/1');\n\n    $response\n        ->assertJson(fn (AssertableJson $json) =>\n            $json->where('id', 1)\n                ->where('name', 'Victoria Faith')\n                ->where('email', fn (string $email) => str($email)->is('victoria@gmail.com'))\n                ->whereNot('status', 'pending')\n                ->missing('password')\n                ->etc()\n        );\n}\n```\n\n#### Understanding the `etc` Method\n\nIn the example above, you may have noticed we invoked the `etc` method at the end of our assertion chain. This method informs Laravel that there may be other attributes present on the JSON object. If the `etc` method is not used, the test will fail if other attributes that you did not make assertions against exist on the JSON object.\n\nThe intention behind this behavior is to protect you from unintentionally exposing sensitive information in your JSON responses by forcing you to either explicitly make an assertion against the attribute or explicitly allow additional attributes via the `etc` method.\n\nHowever, you should be aware that not including the `etc` method in your assertion chain does not ensure that additional attributes are not being added to arrays that are nested within your JSON object. The `etc` method only ensures that no additional attributes exist at the nesting level in which the `etc` method is invoked.\n\n---\n\n## Search Full-Text Search Running Full-Text Queries\nSearch | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/search.md\n\n\n### Running Full-Text Queries\n\nOnce the index is in place, use the `whereFullText` query builder method to search against it. Laravel will generate the appropriate SQL for your database driver — for example, `MATCH(...) AGAINST(...)` on MariaDB and MySQL, and `to_tsvector(...) @@ plainto_tsquery(...)` on PostgreSQL:\n\n```php\n$articles = Article::whereFullText('body', 'web developer')->get();\n```\n\nWhen using MariaDB and MySQL, results are automatically ordered by relevance score. On PostgreSQL, `whereFullText` filters matching records but does not order them by relevance — if you need automatic relevance ordering on PostgreSQL, consider using [Scout's database engine](#database-engine), which handles this for you.\n\nIf you created a composite full-text index across multiple columns, you may search against all of them by passing the same array of columns to `whereFullText`:\n\n```php\n$articles = Article::whereFullText(\n    ['title', 'body'], 'web developer'\n)->get();\n```\n\nThe `orWhereFullText` method may be used to add a full-text search clause as an \"or\" condition. For complete details, consult the [query builder documentation](/docs/{{version}}/queries#full-text-where-clauses).\n\n---\n\n## Urls URLs for Named Routes Eloquent Models\nUrls | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/urls.md\n\n\n#### Eloquent Models\n\nYou will often be generating URLs using the route key (typically the primary key) of [Eloquent models](/docs/{{version}}/eloquent). For this reason, you may pass Eloquent models as parameter values. The `route` helper will automatically extract the model's route key:\n\n```php\necho route('post.show', ['post' => $post]);\n```\n\n---\n\n## Datasets\nDatasets | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/datasets.md\n\n---\ntitle: Datasets\ndescription: With datasets, you can define an array of test data and Pest will run the same test for each set automatically. This saves time and effort by eliminating the need to repeat the same test manually with different data.\n---\n\n# Datasets\n\nWith datasets, you can define an array of test data and Pest will run the same test for each set automatically. This saves time and effort by eliminating the need to repeat the same test manually with different data.\n\n```php\nit('has emails', function (string $email) {\n    expect($email)->not->toBeEmpty();\n})->with(['enunomaduro@gmail.com', 'other@example.com']);\n```\n\nWhen running your tests, Pest will automatically add informative test descriptions to tests that use datasets, outlining the parameters used in each test, aiding in understanding the data and identifying issues if a test fails.\n\n
\n    \n
\n\nNaturally, it is possible to supply multiple arguments by providing an array containing arrays of arguments.\n\n```php\nit('has emails', function (string $name, string $email) {\n    expect($email)->not->toBeEmpty();\n})->with([\n    ['Nuno', 'enunomaduro@gmail.com'],\n    ['Other', 'other@example.com']\n]);\n```\n\nTo manually add your own description to a dataset value, you may simply assign it a key.\n\n```php\nit('has emails', function (string $email) {\n    expect($email)->not->toBeEmpty();\n})->with([\n    'james' => 'james@laravel.com',\n    'taylor' => 'taylor@laravel.com',\n]);\n```\n\nIf a key is added, Pest will use the key when generating the description for the test.\n\n
\n    \n
\n\nIf the test name includes `:dataset`, the description will be interpolated into the test name at that location.\n\n```bash\n  ✓ it validates the \"first_name\" field\n  ✓ it validates the \"email\" field\n```\n\nIt is important to notice that when using `closures` in your dataset, you must declare the arguments type in the closure passed to the test function.\n\n```php\nit('can sum', function (int $a, int $b, int $result) {\n    expect(sum($a, $b))->toBe($result);\n})->with([\n    'positive numbers' => [1, 2, 3],\n    'negative numbers' => [-1, -2, -3],\n    'using closure' => [fn () => 1, 2, 3],\n]);\n```\n\nFor larger or more complex scenarios, closures can be used.\n\n```php\n\n// Returning an array\ntest('The array contains only integers', function ($i) {\n    expect($i)->toBeInt();\n})->with(fn (): array => range(1, 99));\n\n// Using a generator\ntest('The generator produces only integers', function ($i) {\n    expect($i)->toBeInt();\n})->with(function (): Generator {\n    for ($i = 1 ; $i < 100_000_000_000 ; $i++) {\n        yield $i;\n    }\n});\n```\n\n## Named Parameters\n\nWhen using datasets with associative arrays, Pest matches the dataset keys to the closure parameter names, regardless of order. This allows you to define your dataset in any key order and have the values automatically mapped to the correct parameters.\n\n```php\nit('has user data', function (string $email, string $name) {\n    expect($name)->toBeString();\n    expect($email)->toContain('@');\n})->with([\n    ['name' => 'Taylor', 'email' => 'taylor@laravel.com'],\n    ['name' => 'Nuno', 'email' => 'enunomaduro@gmail.com'],\n]);\n```\n\nIn the example above, even though the dataset defines `name` before `email`, Pest maps them correctly to the closure parameters `$email` and `$name`.\n\nNamed parameters also work with shared datasets and bound closures.\n\n```php\ndataset('users', [\n    ['name' => 'Taylor', 'email' => 'taylor@laravel.com'],\n    ['name' => 'Nuno', 'email' => 'enunomaduro@gmail.com'],\n]);\n\nit('has user data', function (string $email, string $name) {\n    expect($name)->toBeString();\n    expect($email)->toContain('@');\n})->with('users');\n```\n\n## Bound Datasets\n\nPest's bound datasets can be used to obtain a dataset that is resolved after the `beforeEach()` method of your tests. This is particularly useful in Laravel applications (or any other Pest integration) where you may need a dataset of `App\\Models\\User` models that are created after your database schema is prepared by the `beforeEach()` method.\n\n```php\nit('can generate the full name of a user', function (User $user) {\n    expect($user->full_name)->toBe(\"{$user->first_name} {$user->last_name}\");\n})->with([\n    fn() => User::factory()->create(['first_name' => 'Nuno', 'last_name' => 'Maduro']),\n    fn() => User::factory()->create(['first_name' => 'Luke', 'last_name' => 'Downing']),\n    fn() => User::factory()->create(['first_name' => 'Freek', 'last_name' => 'Van Der Herten']),\n]);\n```\n\nIf you want, you can bind a single argument to the test case. However, Pest requires that it must be fully typed in the `it|test` function arguments.\n\n```diff\n-it('can generate the full name of a user', function ($user, $fullName) {\n+it('can generate the full name of a user', function (User $user, $fullName) {\n    expect($user->full_name)->toBe($fullName);\n})->with([\n    [fn() => User::factory()->create(['first_name' => 'Nuno', 'last_name' => 'Maduro']), 'Nuno Maduro'],\n    [fn() => User::factory()->create(['first_name' => 'Luke', 'last_name' => 'Downing']), 'Luke Downing'],\n    [fn() => User::factory()->create(['first_name' => 'Freek', 'last_name' => 'Van Der Herten']), 'Freek Van Der Herten'],\n]);\n```\n\n## Sharing Datasets\n\nBy storing your datasets separately in the `tests/Datasets` folder, you can easily distinguish them from your test code and ensure that they do not clutter your main test files.\n\n```diff\n// tests/Unit/ExampleTest.php...\nit('has emails', function (string $email) {\n    expect($email)->not->toBeEmpty();\n-})->with(['enunomaduro@gmail.com', 'other@example.com']);\n+})->with('emails');\n\n// tests/Datasets/Emails.php...\n+dataset('emails', [\n+    'enunomaduro@gmail.com',\n+    'other@example.com'\n+]);\n```\n\nBound datasets, description keys, and other rules that are applicable to inline datasets can also be applied to shared datasets.\n\n### Scoped Datasets\n\nOccasionally, datasets may pertain only to a specific feature or set of folders. In such cases, rather than distributing the dataset globally within the `Datasets` folder, you can generate a `Datasets.php` file within the relevant folder requiring the dataset and restrict the dataset's scope to that folder alone.\n\n```php\n// tests/Feature/Products/ExampleTest.php...\nit('has products', function (string $product) {\n    expect($product)->not->toBeEmpty();\n})->with('products');\n\n// tests/Feature/Products/Datasets.php...\ndataset('products', [\n    'egg',\n    'milk'\n]);\n```\n\n## Combining Datasets\n\nYou can easily obtain complex datasets by combining both **inline** and **shared** datasets. When doing so, the datasets will be combined using a [cartesian product](https://en.wikipedia.org/wiki/Cartesian_product) approach.\n\nIn the following example, we verify that all of the specified businesses are closed on each of the provided weekdays.\n\n```php\ndataset('days_of_the_week', [\n    'Saturday',\n    'Sunday',\n]);\n\ntest('business is closed on day', function(string $business, string $day) {\n    expect(new $business)->isClosed($day)->toBeTrue();\n})->with([\n    Office::class,\n    Bank::class,\n    School::class\n])->with('days_of_the_week');\n```\n\nWhen running the example above, Pest's output will contain a description of each of the validated combinations.\n\n
\n    \n
\n\n## Describe Blocks With Datasets\n\nYou can attach a dataset to a `describe()` block, and all tests within that block will receive the dataset values.\n\n```php\ndescribe('user notifications', function () {\n    test('can send notification', function (string $channel) {\n        expect($channel)->toBeString();\n    });\n\n    test('can queue notification', function (string $channel) {\n        expect($channel)->toBeIn(['mail', 'sms']);\n    });\n})->with(['mail', 'sms']);\n```\n\nYou can also use `beforeEach()->with()` inside a `describe()` block to apply a dataset to all tests within that scope.\n\n```php\ndescribe('user settings', function () {\n    beforeEach()->with([10, 20, 30]);\n\n    test('receives the dataset value', function (int $value) {\n        expect($value)->toBeGreaterThan(0);\n    });\n});\n```\n\n## Repeating Tests\n\nIn some cases, you may need to repeat a test multiple times for debugging purposes or to ensure that the test is stable. On these occasions, you may use the `repeat()` method to repeat a test a given number of times.\n\n```php\nit('can repeat a test', function () {\n    $result = /** Some code that may be unstable */;\n\n    expect($result)->toBeTrue();\n})->repeat(100); // Repeat the test 100 times\n```\n\n---\n\nAfter becoming skilled at utilizing datasets for testing, the next crucial step is to gain an understanding of how to test for exceptions. This involves verifying that your code behaves correctly and throws appropriate exceptions when it encounters unexpected or erroneous input: [Exceptions →](/docs/exceptions)\n\n\n_[Truncated]_"}]


sed -n '1,360p' app/Services/DiscountService.php

<?php

namespace App\Services;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Exceptions\InvalidDiscountException;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\Discount;
use App\Models\ProductVariant;
use App\Models\Store;
use App\ValueObjects\DiscountResult;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class DiscountService
{
    public function validate(string $code, Store $store, Cart $cart): Discount
    {
        $normalizedCode = mb_strtolower(trim($code));

        $discount = Discount::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('type', DiscountType::Code)
            ->whereRaw('lower(code) = ?', [$normalizedCode])
            ->first();

        if (! $discount instanceof Discount) {
            throw InvalidDiscountException::because('discount_not_found', 'Discount code was not found.');
        }

        $this->validateDiscountForCart($discount, $cart);

        return $discount;
    }

    /**
     * @return Collection<int, Discount>
     */
    public function automaticForCart(Store $store, Cart $cart): Collection
    {
        return Discount::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('type', DiscountType::Automatic->value)
            ->where('status', DiscountStatus::Active->value)
            ->orderBy('id')
            ->get()
            ->filter(function (Discount $discount) use ($cart): bool {
                try {
                    $this->validateDiscountForCart($discount, $cart);

                    return true;
                } catch (InvalidDiscountException) {
                    return false;
                }
            })
            ->values();
    }

    private function validateDiscountForCart(Discount $discount, Cart $cart): void
    {
        if ($discount->status !== DiscountStatus::Active) {
            throw InvalidDiscountException::because('discount_expired', 'Discount is not active.');
        }

        if ($discount->starts_at->isFuture()) {
            throw InvalidDiscountException::because('discount_not_yet_active', 'Discount is not active yet.');
        }

        if ($discount->ends_at !== null && $discount->ends_at->isPast()) {
            throw InvalidDiscountException::because('discount_expired', 'Discount has expired.');
        }

        if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) {
            throw InvalidDiscountException::because('discount_usage_limit_reached', 'Discount usage limit has been reached.');
        }

        $lines = $this->cartLines($cart);
        $subtotal = $lines->sum('line_subtotal_amount');
        $minimum = (int) data_get($discount->rules_json, 'min_purchase_amount', data_get($discount->rules_json, 'minimum_purchase', 0));

        if ($minimum > 0 && $subtotal < $minimum) {
            throw InvalidDiscountException::because('discount_min_purchase_not_met', 'Cart does not meet the minimum purchase amount.');
        }

        if ($this->qualifyingLines($discount, $lines)->isEmpty()) {
            throw InvalidDiscountException::because('discount_not_applicable', 'Discount does not apply to these cart lines.');
        }
    }

    /**
     * @param  array<int, CartLine>  $lines
     */
    public function calculate(Discount $discount, int $subtotal, array $lines): DiscountResult
    {
        if ($discount->value_type === DiscountValueType::FreeShipping) {
            return new DiscountResult(0, [], true);
        }

        $qualifyingLines = $this->qualifyingLines($discount, collect($lines));
        $qualifyingSubtotal = $qualifyingLines->sum('line_total_amount');

        if ($qualifyingSubtotal <= 0) {
            return new DiscountResult(0, []);
        }

        $discountAmount = match ($discount->value_type) {
            DiscountValueType::Percent => (int) round($qualifyingSubtotal * $discount->value_amount / 100),
            DiscountValueType::Fixed => min($discount->value_amount, $qualifyingSubtotal),
            DiscountValueType::FreeShipping => 0,
        };

        $remaining = $discountAmount;
        $allocations = [];
        $lastIndex = $qualifyingLines->keys()->last();

        foreach ($qualifyingLines as $index => $line) {
            if ($index === $lastIndex) {
                $allocations[$line->getKey()] = $remaining;

                continue;
            }

            $allocation = (int) round($discountAmount * $line->line_total_amount / $qualifyingSubtotal);
            $allocations[$line->getKey()] = $allocation;
            $remaining -= $allocation;
        }

        return new DiscountResult($discountAmount, $allocations);
    }

    public function applyToCart(Cart $cart, Discount $discount): DiscountResult
    {
        return DB::transaction(function () use ($cart, $discount): DiscountResult {
            $lines = $this->cartLines($cart);
            $result = $this->calculate($discount, $lines->sum('line_subtotal_amount'), $lines->all());

            $lines->each(function (CartLine $line) use ($result): void {
                $discountAmount = $result->allocations[$line->getKey()] ?? 0;

                $line->forceFill([
                    'line_discount_amount' => $line->line_discount_amount + $discountAmount,
                    'line_total_amount' => max(0, $line->line_total_amount - $discountAmount),
                ])->save();
            });

            return $result;
        });
    }

    /**
     * @return Collection<int, CartLine>
     */
    private function cartLines(Cart $cart): Collection
    {
        return CartLine::withoutGlobalScopes()
            ->where('cart_id', $cart->getKey())
            ->orderBy('id')
            ->get();
    }

    /**
     * @param  Collection<int, CartLine>  $lines
     * @return Collection<int, CartLine>
     */
    private function qualifyingLines(Discount $discount, Collection $lines): Collection
    {
        $productIds = collect(data_get($discount->rules_json, 'applicable_product_ids', []))
            ->filter()
            ->map(fn (mixed $id): int => (int) $id)
            ->values();
        $collectionIds = collect(data_get($discount->rules_json, 'applicable_collection_ids', []))
            ->filter()
            ->map(fn (mixed $id): int => (int) $id)
            ->values();

        if ($productIds->isEmpty() && $collectionIds->isEmpty()) {
            return $lines;
        }

        return $lines->filter(function (CartLine $line) use ($productIds, $collectionIds): bool {
            $variant = ProductVariant::withoutGlobalScopes()
                ->with(['product' => fn ($query) => $query->withoutGlobalScopes()->with('collections')])
                ->find($line->variant_id);

            if (! $variant instanceof ProductVariant || $variant->product === null) {
                return false;
            }

            if ($productIds->contains((int) $variant->product_id)) {
                return true;
            }

            return $variant->product->collections
                ->pluck('id')
                ->map(fn (int $id): int => $id)
                ->intersect($collectionIds)
                ->isNotEmpty();
        });
    }
}


/bin/zsh
-lc
sed -n '1,240p' app/Models/Order.php

<?php

namespace App\Models;

use App\Enums\FinancialStatus;
use App\Enums\FulfillmentStatus;
use App\Enums\OrderStatus;
use App\Enums\PaymentMethod;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Order extends Model
{
    /** @use HasFactory<\Database\Factories\OrderFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'checkout_id',
        'customer_id',
        'order_number',
        'payment_method',
        'status',
        'financial_status',
        'fulfillment_status',
        'currency',
        'subtotal_amount',
        'discount_amount',
        'shipping_amount',
        'tax_amount',
        'total_amount',
        'email',
        'billing_address_json',
        'shipping_address_json',
        'placed_at',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'status' => 'pending',
        'financial_status' => 'pending',
        'fulfillment_status' => 'unfulfilled',
        'currency' => 'USD',
        'subtotal_amount' => 0,
        'discount_amount' => 0,
        'shipping_amount' => 0,
        'tax_amount' => 0,
        'total_amount' => 0,
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return BelongsTo<Checkout, $this>
     */
    public function checkout(): BelongsTo
    {
        return $this->belongsTo(Checkout::class);
    }

    /**
     * @return BelongsTo<Customer, $this>
     */
    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

    /**
     * @return HasMany<OrderLine, $this>
     */
    public function lines(): HasMany
    {
        return $this->hasMany(OrderLine::class);
    }

    /**
     * @return HasMany<Payment, $this>
     */
    public function payments(): HasMany
    {
        return $this->hasMany(Payment::class);
    }

    /**
     * @return HasMany<Refund, $this>
     */
    public function refunds(): HasMany
    {
        return $this->hasMany(Refund::class);
    }

    /**
     * @return HasMany<Fulfillment, $this>
     */
    public function fulfillments(): HasMany
    {
        return $this->hasMany(Fulfillment::class);
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'payment_method' => PaymentMethod::class,
            'status' => OrderStatus::class,
            'financial_status' => FinancialStatus::class,
            'fulfillment_status' => FulfillmentStatus::class,
            'subtotal_amount' => 'integer',
            'discount_amount' => 'integer',
            'shipping_amount' => 'integer',
            'tax_amount' => 'integer',
            'total_amount' => 'integer',
            'billing_address_json' => 'array',
            'shipping_address_json' => 'array',
            'placed_at' => 'datetime',
        ];
    }
}


/bin/zsh
-lc
sed -n '1,260p' app/Models/Discount.php

<?php

namespace App\Models;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Discount extends Model
{
    /** @use HasFactory<\Database\Factories\DiscountFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'type',
        'code',
        'value_type',
        'value_amount',
        'starts_at',
        'ends_at',
        'usage_limit',
        'usage_count',
        'rules_json',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'type' => 'code',
        'value_amount' => 0,
        'usage_count' => 0,
        'rules_json' => '{}',
        'status' => 'active',
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'type' => DiscountType::class,
            'value_type' => DiscountValueType::class,
            'value_amount' => 'integer',
            'starts_at' => 'datetime',
            'ends_at' => 'datetime',
            'usage_limit' => 'integer',
            'usage_count' => 'integer',
            'rules_json' => 'array',
            'status' => DiscountStatus::class,
        ];
    }
}


sed -n '1,300p' tests/Feature/Checkout/PricingServicesTest.php

<?php

use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Models\Checkout;
use App\Models\Discount;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\ShippingRate;
use App\Models\ShippingZone;
use App\Models\Store;
use App\Models\TaxSettings;
use App\Services\CartService;
use App\Services\PricingEngine;
use App\Services\ShippingCalculator;
use App\Services\TaxCalculator;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

function pricingStore(): Store
{
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    TaxSettings::withoutGlobalScopes()->create([
        'store_id' => $store->getKey(),
        'mode' => 'manual',
        'provider' => 'none',
        'prices_include_tax' => false,
        'config_json' => [
            'name' => 'VAT',
            'default_rate_bps' => 1900,
            'shipping_taxable' => true,
            'rates' => [
                ['country' => 'DE', 'rate_bps' => 1900, 'name' => 'VAT'],
            ],
        ],
    ]);

    return $store;
}

function pricingVariant(Store $store, int $price = 2500, bool $requiresShipping = true): ProductVariant
{
    $product = Product::factory()
        ->withDefaultVariant($price)
        ->create(['store_id' => $store->getKey()]);
    $variant = ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->firstOrFail();

    $variant->forceFill([
        'requires_shipping' => $requiresShipping,
        'weight_g' => $requiresShipping ? 500 : 0,
    ])->save();
    InventoryItem::withoutGlobalScopes()
        ->where('variant_id', $variant->getKey())
        ->update(['quantity_on_hand' => 20]);

    return $variant->refresh();
}

function pricingCheckout(Store $store, ProductVariant $variant, int $quantity = 2): Checkout
{
    $cart = app(CartService::class)->create($store);
    app(CartService::class)->addLine($cart, $variant->getKey(), $quantity);

    return Checkout::withoutGlobalScopes()->create([
        'store_id' => $store->getKey(),
        'cart_id' => $cart->getKey(),
        'status' => 'shipping_selected',
        'email' => 'buyer@example.test',
        'shipping_address_json' => [
            'first_name' => 'Test',
            'last_name' => 'Buyer',
            'address1' => 'Main Street 1',
            'city' => 'Berlin',
            'country' => 'DE',
            'postal_code' => '10115',
        ],
    ]);
}

test('pricing engine calculates deterministic exclusive totals with shipping tax', function () {
    $store = pricingStore();
    $variant = pricingVariant($store);
    $zone = ShippingZone::withoutGlobalScopes()->create([
        'store_id' => $store->getKey(),
        'name' => 'Germany',
        'countries_json' => ['DE'],
        'regions_json' => [],
    ]);
    $rate = ShippingRate::withoutGlobalScopes()->create([
        'zone_id' => $zone->getKey(),
        'name' => 'Standard',
        'type' => 'flat',
        'config_json' => ['amount' => 499],
        'is_active' => true,
    ]);
    $checkout = pricingCheckout($store, $variant);
    $checkout->forceFill(['shipping_method_id' => $rate->getKey()])->save();

    $result = app(PricingEngine::class)->calculate($checkout);

    expect($result->subtotal)->toBe(5000)
        ->and($result->shipping)->toBe(499)
        ->and($result->taxTotal)->toBe(1045)
        ->and($result->total)->toBe(6544)
        ->and($checkout->refresh()->totals_json['total'])->toBe(6544);
});

test('pricing engine applies percent and free shipping discounts', function () {
    $store = pricingStore();
    $variant = pricingVariant($store);
    $zone = ShippingZone::withoutGlobalScopes()->create([
        'store_id' => $store->getKey(),
        'name' => 'Germany',
        'countries_json' => ['DE'],
        'regions_json' => [],
    ]);
    $rate = ShippingRate::withoutGlobalScopes()->create([
        'zone_id' => $zone->getKey(),
        'name' => 'Standard',
        'type' => 'flat',
        'config_json' => ['amount' => 499],
        'is_active' => true,
    ]);
    Discount::factory()->create(['store_id' => $store->getKey(), 'code' => 'SAVE10']);
    Discount::factory()->freeShipping()->create(['store_id' => $store->getKey(), 'code' => 'FREESHIP']);
    $checkout = pricingCheckout($store, $variant);
    $checkout->forceFill([
        'shipping_method_id' => $rate->getKey(),
        'discount_code' => 'save10',
    ])->save();

    $discounted = app(PricingEngine::class)->calculate($checkout);

    expect($discounted->discount)->toBe(500)
        ->and($discounted->shipping)->toBe(499)
        ->and($discounted->total)->toBe(5949);

    $checkout->forceFill(['discount_code' => 'FREESHIP'])->save();
    $freeShipping = app(PricingEngine::class)->calculate($checkout);

    expect($freeShipping->discount)->toBe(0)
        ->and($freeShipping->shipping)->toBe(0)
        ->and($freeShipping->total)->toBe(5950);
});

test('pricing engine applies active automatic discounts', function () {
    $store = pricingStore();
    $variant = pricingVariant($store);
    $checkout = pricingCheckout($store, $variant);
    Discount::factory()->create([
        'store_id' => $store->getKey(),
        'type' => DiscountType::Automatic,
        'code' => null,
        'value_type' => DiscountValueType::Percent,
        'value_amount' => 10,
    ]);

    $result = app(PricingEngine::class)->calculate($checkout);
    $line = $checkout->cart->lines()->firstOrFail();

    expect($result->discount)->toBe(500)
        ->and($result->total)->toBe(5355)
        ->and($line->refresh()->line_discount_amount)->toBe(500)
        ->and($line->line_total_amount)->toBe(4500);
});

test('pricing engine stacks automatic discounts after explicit code discounts', function () {
    $store = pricingStore();
    $variant = pricingVariant($store);
    $checkout = pricingCheckout($store, $variant);
    Discount::factory()->create([
        'store_id' => $store->getKey(),
        'code' => 'SAVE10',
        'value_type' => DiscountValueType::Percent,
        'value_amount' => 10,
    ]);
    Discount::factory()->create([
        'store_id' => $store->getKey(),
        'type' => DiscountType::Automatic,
        'code' => null,
        'value_type' => DiscountValueType::Percent,
        'value_amount' => 10,
    ]);
    $checkout->forceFill(['discount_code' => 'SAVE10'])->save();

    $result = app(PricingEngine::class)->calculate($checkout);
    $line = $checkout->cart->lines()->firstOrFail();

    expect($result->discount)->toBe(950)
        ->and($result->total)->toBe(4820)
        ->and($line->refresh()->line_discount_amount)->toBe(950)
        ->and($line->line_total_amount)->toBe(4050);
});

test('shipping and tax calculators handle matching ranges and inclusive extraction', function () {
    $store = pricingStore();
    $physicalVariant = pricingVariant($store, requiresShipping: true);
    $digitalVariant = pricingVariant($store, requiresShipping: false);
    $cart = app(CartService::class)->create($store);
    app(CartService::class)->addLine($cart, $physicalVariant->getKey(), 2);
    app(CartService::class)->addLine($cart, $digitalVariant->getKey(), 1);
    $zone = ShippingZone::withoutGlobalScopes()->create([
        'store_id' => $store->getKey(),
        'name' => 'Berlin',
        'countries_json' => ['DE'],
        'regions_json' => ['DE-BE'],
    ]);
    $rate = ShippingRate::withoutGlobalScopes()->create([
        'zone_id' => $zone->getKey(),
        'name' => 'Weight',
        'type' => 'weight',
        'config_json' => [
            'ranges' => [
                ['min_g' => 0, 'max_g' => 500, 'amount' => 499],
                ['min_g' => 501, 'max_g' => 2000, 'amount' => 899],
            ],
        ],
        'is_active' => true,
    ]);

    $rates = app(ShippingCalculator::class)->getAvailableRates($store, ['country' => 'DE', 'province_code' => 'DE-BE']);

    expect($rates->first()?->getKey())->toBe($rate->getKey())
        ->and(app(ShippingCalculator::class)->calculate($rate, $cart))->toBe(899)
        ->and(app(TaxCalculator::class)->extractInclusive(11900, 1900))->toBe(1900)
        ->and(app(TaxCalculator::class)->addExclusive(10000, 1900))->toBe(1900);
});