command: sed -n '1,280p' tests/Feature/Api/AdminThemeApiTest.php

aggregated_output:
<?php

use App\Enums\ThemeStatus;
use App\Models\Store;
use App\Models\Theme;
use App\Models\ThemeFile;
use App\Models\ThemeSettings;
use App\Models\User;
use App\Services\WebhookService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function adminThemeApiStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

function adminThemeApiUser(): User
{
    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
}

/**
 * @param  list<string>  $abilities
 * @return array{token: \App\Models\PersonalAccessToken, plain_text: string}
 */
function adminThemeApiToken(Store $store, array $abilities): array
{
    return app(WebhookService::class)->createApiToken($store, 'Theme integration', $abilities);
}

/**
 * @param  array<string, string|null>  $overrides
 */
function adminThemeApiArchiveUpload(array $overrides = []): UploadedFile
{
    $path = tempnam(sys_get_temp_dir(), 'theme-api-');
    $zip = new ZipArchive;

    $zip->open($path, ZipArchive::OVERWRITE);

    foreach (array_merge([
        'theme.json' => json_encode([
            'name' => 'API Dawn',
            'version' => '2.0.0',
            'settings_json' => [
                'home' => [
                    'hero' => [
                        'heading' => 'Archive Hero',
                    ],
                ],
            ],
        ], JSON_THROW_ON_ERROR),
        'layouts/storefront.blade.php' => '<x-layouts.storefront>{{ $slot }}</x-layouts.storefront>',
        'sections/hero.blade.php' => '
API Hero
',
        'sections/featured-products.blade.php' => '
API Products
',
    ], $overrides) as $file => $contents) {
        if ($contents === null) {
            continue;
        }

        $zip->addFromString("api-theme/{$file}", $contents);
    }

    $zip->close();

    return new UploadedFile($path, 'theme.zip', 'application/zip', null, true);
}

function adminThemeApiDraftTheme(Store $store): Theme
{
    $theme = Theme::factory()->create([
        'store_id' => $store->getKey(),
        'name' => 'API Draft Theme',
    ]);

    foreach ([
        'layouts/storefront.blade.php',
        'sections/hero.blade.php',
        'sections/featured-products.blade.php',
    ] as $path) {
        ThemeFile::factory()->create([
            'theme_id' => $theme->getKey(),
            'path' => $path,
        ]);
    }

    ThemeSettings::factory()->create([
        'theme_id' => $theme->getKey(),
    ]);

    return $theme;
}

test('admin theme api installs uploaded archives', function (): void {
    $store = adminThemeApiStore();
    $user = adminThemeApiUser();

    $response = $this->actingAs($user)
        ->post("/api/admin/v1/stores/{$store->getKey()}/themes", [
            'name' => 'Uploaded API Theme',
            'file' => adminThemeApiArchiveUpload(),
        ], ['Accept' => 'application/json'])
        ->assertCreated()
        ->assertJsonPath('data.name', 'Uploaded API Theme')
        ->assertJsonPath('data.version', '2.0.0')
        ->assertJsonPath('data.status', 'draft')
        ->assertJsonPath('data.files_count', 4)
        ->assertJsonPath('data.settings_json.home.hero.heading', 'Archive Hero');

    $theme = Theme::withoutGlobalScopes()->findOrFail($response->json('data.id'));
    $hero = $theme->files()->withoutGlobalScopes()->where('path', 'sections/hero.blade.php')->firstOrFail();

    expect($theme->status)->toBe(ThemeStatus::Draft)
        ->and(Storage::disk('local')->get($hero->storage_key))->toBe('
API Hero
');
});

test('admin theme api updates settings and publishes one active theme', function (): void {
    $store = adminThemeApiStore();
    $published = Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->where('status', ThemeStatus::Published)->firstOrFail();
    $draft = adminThemeApiDraftTheme($store);

    $this->actingAs(adminThemeApiUser())
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [
            'settings_json' => [
                'home' => [
                    'hero' => [
                        'heading' => 'API Saved Hero',
                    ],
                ],
            ],
        ])
        ->assertOk()
        ->assertJsonPath('data.settings_json.home.hero.heading', 'API Saved Hero');

    $this->actingAs(adminThemeApiUser())
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
        ->assertOk()
        ->assertJsonPath('data.status', 'published');

    expect(ThemeSettings::withoutGlobalScopes()->where('theme_id', $draft->getKey())->first()?->settings_json['home']['hero']['heading'])->toBe('API Saved Hero')
        ->and($draft->refresh()->status)->toBe(ThemeStatus::Published)
        ->and($published->refresh()->status)->toBe(ThemeStatus::Draft)
        ->and(Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->where('status', ThemeStatus::Published)->count())->toBe(1);
});

test('admin theme api enforces token abilities and store scope', function (): void {
    $store = adminThemeApiStore();
    $otherStore = Store::factory()->create();
    $draft = adminThemeApiDraftTheme($store);
    $readToken = adminThemeApiToken($store, ['read-settings']);
    $writeToken = adminThemeApiToken($store, ['write-themes']);
    $otherStoreToken = adminThemeApiToken($otherStore, ['write-themes']);

    $this->withToken($readToken['plain_text'])
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
        ->assertForbidden();

    $this->withToken($otherStoreToken['plain_text'])
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
        ->assertForbidden();

    $this->withToken($writeToken['plain_text'])
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [
            'settings_json' => [
                'announcement' => [
                    'enabled' => false,
                ],
            ],
        ])
        ->assertOk()
        ->assertJsonPath('data.settings_json.announcement.enabled', false);
});

test('admin theme api validates archives settings and publishable files', function (): void {
    $store = adminThemeApiStore();
    $draft = Theme::factory()->create([
        'store_id' => $store->getKey(),
        'name' => 'Incomplete API Theme',
    ]);

    $this->actingAs(adminThemeApiUser())
        ->post("/api/admin/v1/stores/{$store->getKey()}/themes", [
            'file' => adminThemeApiArchiveUpload([
                'sections/hero.blade.php' => null,
            ]),
        ], ['Accept' => 'application/json'])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['file']);

    $this->actingAs(adminThemeApiUser())
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [
            'settings_json' => ['invalid-list-value'],
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['settings_json']);

    $this->actingAs(adminThemeApiUser())
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['theme']);
});


output:
Chunk ID: 534cb3
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1887
Output:
<?php

use App\Enums\ThemeStatus;
use App\Models\Store;
use App\Models\Theme;
use App\Models\ThemeFile;
use App\Models\ThemeSettings;
use App\Models\User;
use App\Services\WebhookService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function adminThemeApiStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

function adminThemeApiUser(): User
{
    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
}

/**
 * @param  list<string>  $abilities
 * @return array{token: \App\Models\PersonalAccessToken, plain_text: string}
 */
function adminThemeApiToken(Store $store, array $abilities): array
{
    return app(WebhookService::class)->createApiToken($store, 'Theme integration', $abilities);
}

/**
 * @param  array<string, string|null>  $overrides
 */
function adminThemeApiArchiveUpload(array $overrides = []): UploadedFile
{
    $path = tempnam(sys_get_temp_dir(), 'theme-api-');
    $zip = new ZipArchive;

    $zip->open($path, ZipArchive::OVERWRITE);

    foreach (array_merge([
        'theme.json' => json_encode([
            'name' => 'API Dawn',
            'version' => '2.0.0',
            'settings_json' => [
                'home' => [
                    'hero' => [
                        'heading' => 'Archive Hero',
                    ],
                ],
            ],
        ], JSON_THROW_ON_ERROR),
        'layouts/storefront.blade.php' => '<x-layouts.storefront>{{ $slot }}</x-layouts.storefront>',
        'sections/hero.blade.php' => '
API Hero
',
        'sections/featured-products.blade.php' => '
API Products
',
    ], $overrides) as $file => $contents) {
        if ($contents === null) {
            continue;
        }

        $zip->addFromString("api-theme/{$file}", $contents);
    }

    $zip->close();

    return new UploadedFile($path, 'theme.zip', 'application/zip', null, true);
}

function adminThemeApiDraftTheme(Store $store): Theme
{
    $theme = Theme::factory()->create([
        'store_id' => $store->getKey(),
        'name' => 'API Draft Theme',
    ]);

    foreach ([
        'layouts/storefront.blade.php',
        'sections/hero.blade.php',
        'sections/featured-products.blade.php',
    ] as $path) {
        ThemeFile::factory()->create([
            'theme_id' => $theme->getKey(),
            'path' => $path,
        ]);
    }

    ThemeSettings::factory()->create([
        'theme_id' => $theme->getKey(),
    ]);

    return $theme;
}

test('admin theme api installs uploaded archives', function (): void {
    $store = adminThemeApiStore();
    $user = adminThemeApiUser();

    $response = $this->actingAs($user)
        ->post("/api/admin/v1/stores/{$store->getKey()}/themes", [
            'name' => 'Uploaded API Theme',
            'file' => adminThemeApiArchiveUpload(),
        ], ['Accept' => 'application/json'])
        ->assertCreated()
        ->assertJsonPath('data.name', 'Uploaded API Theme')
        ->assertJsonPath('data.version', '2.0.0')
        ->assertJsonPath('data.status', 'draft')
        ->assertJsonPath('data.files_count', 4)
        ->assertJsonPath('data.settings_json.home.hero.heading', 'Archive Hero');

    $theme = Theme::withoutGlobalScopes()->findOrFail($response->json('data.id'));
    $hero = $theme->files()->withoutGlobalScopes()->where('path', 'sections/hero.blade.php')->firstOrFail();

    expect($theme->status)->toBe(ThemeStatus::Draft)
        ->and(Storage::disk('local')->get($hero->storage_key))->toBe('
API Hero
');
});

test('admin theme api updates settings and publishes one active theme', function (): void {
    $store = adminThemeApiStore();
    $published = Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->where('status', ThemeStatus::Published)->firstOrFail();
    $draft = adminThemeApiDraftTheme($store);

    $this->actingAs(adminThemeApiUser())
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [
            'settings_json' => [
                'home' => [
                    'hero' => [
                        'heading' => 'API Saved Hero',
                    ],
                ],
            ],
        ])
        ->assertOk()
        ->assertJsonPath('data.settings_json.home.hero.heading', 'API Saved Hero');

    $this->actingAs(adminThemeApiUser())
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
        ->assertOk()
        ->assertJsonPath('data.status', 'published');

    expect(ThemeSettings::withoutGlobalScopes()->where('theme_id', $draft->getKey())->first()?->settings_json['home']['hero']['heading'])->toBe('API Saved Hero')
        ->and($draft->refresh()->status)->toBe(ThemeStatus::Published)
        ->and($published->refresh()->status)->toBe(ThemeStatus::Draft)
        ->and(Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->where('status', ThemeStatus::Published)->count())->toBe(1);
});

test('admin theme api enforces token abilities and store scope', function (): void {
    $store = adminThemeApiStore();
    $otherStore = Store::factory()->create();
    $draft = adminThemeApiDraftTheme($store);
    $readToken = adminThemeApiToken($store, ['read-settings']);
    $writeToken = adminThemeApiToken($store, ['write-themes']);
    $otherStoreToken = adminThemeApiToken($otherStore, ['write-themes']);

    $this->withToken($readToken['plain_text'])
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
        ->assertForbidden();

    $this->withToken($otherStoreToken['plain_text'])
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
        ->assertForbidden();

    $this->withToken($writeToken['plain_text'])
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [
            'settings_json' => [
                'announcement' => [
                    'enabled' => false,
                ],
            ],
        ])
        ->assertOk()
        ->assertJsonPath('data.settings_json.announcement.enabled', false);
});

test('admin theme api validates archives settings and publishable files', function (): void {
    $store = adminThemeApiStore();
    $draft = Theme::factory()->create([
        'store_id' => $store->getKey(),
        'name' => 'Incomplete API Theme',
    ]);

    $this->actingAs(adminThemeApiUser())
        ->post("/api/admin/v1/stores/{$store->getKey()}/themes", [
            'file' => adminThemeApiArchiveUpload([
                'sections/hero.blade.php' => null,
            ]),
        ], ['Accept' => 'application/json'])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['file']);

    $this->actingAs(adminThemeApiUser())
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [
            'settings_json' => ['invalid-list-value'],
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['settings_json']);

    $this->actingAs(adminThemeApiUser())
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['theme']);
});


status: completed

input:
*** Begin Patch
*** Update File: tests/Feature/Api/AdminShippingSettingsApiTest.php
@@
     $store = adminShippingSettingsApiStore();
     $user = adminShippingSettingsApiUser();
+    $readToken = adminApiBearerToken($store, ['read-settings'], $user);
+    $writeToken = adminApiBearerToken($store, ['write-settings'], $user);

-    $this->actingAs($user)
+    $this->withToken($readToken)
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones")
@@
-    $createResponse = $this->actingAs($user)
+    $createResponse = $this->withToken($writeToken)
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones", [
@@
-    $this->actingAs($user)
+    $this->withToken($writeToken)
         ->putJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones/{$zone->getKey()}", [
@@
-    $rateResponse = $this->actingAs($user)
+    $rateResponse = $this->withToken($writeToken)
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones/{$zone->getKey()}/rates", [
@@
-    $this->actingAs(adminShippingSettingsApiUser())
+    $this->withToken(adminApiBearerToken($store, ['write-settings'], adminShippingSettingsApiUser()))
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones", [
@@
-    $this->actingAs(adminShippingSettingsApiUser())
+    $this->withToken(adminApiBearerToken($store, ['write-settings'], adminShippingSettingsApiUser()))
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones/{$zone->getKey()}/rates", [
*** Update File: tests/Feature/Api/AdminTaxSettingsApiTest.php
@@
     $store = adminTaxSettingsApiStore();
     $user = adminTaxSettingsApiUser();
+    $readToken = adminApiBearerToken($store, ['read-settings'], $user);
+    $writeToken = adminApiBearerToken($store, ['write-settings'], $user);

-    $this->actingAs($user)
+    $this->withToken($readToken)
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings")
@@
-    $this->actingAs($user)
+    $this->withToken($writeToken)
         ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [
@@
-    $this->actingAs(adminTaxSettingsApiUser())
+    $this->withToken(adminApiBearerToken($store, ['write-settings'], adminTaxSettingsApiUser()))
         ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [
@@
-    $this->actingAs(adminTaxSettingsApiUser())
+    $this->withToken(adminApiBearerToken($store, ['write-settings'], adminTaxSettingsApiUser()))
         ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [
*** Update File: tests/Feature/Api/AdminThemeApiTest.php
@@
     $store = adminThemeApiStore();
     $user = adminThemeApiUser();
+    $writeToken = adminApiBearerToken($store, ['write-themes'], $user);

-    $response = $this->actingAs($user)
+    $response = $this->withToken($writeToken)
         ->post("/api/admin/v1/stores/{$store->getKey()}/themes", [
@@
     $store = adminThemeApiStore();
     $published = Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->where('status', ThemeStatus::Published)->firstOrFail();
     $draft = adminThemeApiDraftTheme($store);
+    $writeToken = adminApiBearerToken($store, ['write-themes'], adminThemeApiUser());

-    $this->actingAs(adminThemeApiUser())
+    $this->withToken($writeToken)
         ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [
@@
-    $this->actingAs(adminThemeApiUser())
+    $this->withToken($writeToken)
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
@@
-    $this->actingAs(adminThemeApiUser())
+    $this->withToken(adminApiBearerToken($store, ['write-themes'], adminThemeApiUser()))
         ->post("/api/admin/v1/stores/{$store->getKey()}/themes", [
@@
-    $this->actingAs(adminThemeApiUser())
+    $this->withToken(adminApiBearerToken($store, ['write-themes'], adminThemeApiUser()))
         ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [
@@
-    $this->actingAs(adminThemeApiUser())
+    $this->withToken(adminApiBearerToken($store, ['write-themes'], adminThemeApiUser()))
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
*** End Patch


stdout:
Success. Updated the following files:
M tests/Feature/Api/AdminShippingSettingsApiTest.php
M tests/Feature/Api/AdminTaxSettingsApiTest.php
M tests/Feature/Api/AdminThemeApiTest.php

type: update

unified_diff:
@@ -39,4 +39,6 @@
     $user = adminTaxSettingsApiUser();
+    $readToken = adminApiBearerToken($store, ['read-settings'], $user);
+    $writeToken = adminApiBearerToken($store, ['write-settings'], $user);

-    $this->actingAs($user)
+    $this->withToken($readToken)
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings")
@@ -47,3 +49,3 @@

-    $this->actingAs($user)
+    $this->withToken($writeToken)
         ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [
@@ -119,3 +121,3 @@

-    $this->actingAs(adminTaxSettingsApiUser())
+    $this->withToken(adminApiBearerToken($store, ['write-settings'], adminTaxSettingsApiUser()))
         ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [
@@ -128,3 +130,3 @@

-    $this->actingAs(adminTaxSettingsApiUser())
+    $this->withToken(adminApiBearerToken($store, ['write-settings'], adminTaxSettingsApiUser()))
         ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [

unified_diff:
@@ -40,4 +40,6 @@
     $user = adminShippingSettingsApiUser();
+    $readToken = adminApiBearerToken($store, ['read-settings'], $user);
+    $writeToken = adminApiBearerToken($store, ['write-settings'], $user);

-    $this->actingAs($user)
+    $this->withToken($readToken)
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones")
@@ -47,3 +49,3 @@

-    $createResponse = $this->actingAs($user)
+    $createResponse = $this->withToken($writeToken)
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones", [
@@ -60,3 +62,3 @@

-    $this->actingAs($user)
+    $this->withToken($writeToken)
         ->putJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones/{$zone->getKey()}", [
@@ -70,3 +72,3 @@

-    $rateResponse = $this->actingAs($user)
+    $rateResponse = $this->withToken($writeToken)
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones/{$zone->getKey()}/rates", [
@@ -130,3 +132,3 @@

-    $this->actingAs(adminShippingSettingsApiUser())
+    $this->withToken(adminApiBearerToken($store, ['write-settings'], adminShippingSettingsApiUser()))
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones", [
@@ -138,3 +140,3 @@

-    $this->actingAs(adminShippingSettingsApiUser())
+    $this->withToken(adminApiBearerToken($store, ['write-settings'], adminShippingSettingsApiUser()))
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones/{$zone->getKey()}/rates", [

unified_diff:
@@ -106,4 +106,5 @@
     $user = adminThemeApiUser();
+    $writeToken = adminApiBearerToken($store, ['write-themes'], $user);

-    $response = $this->actingAs($user)
+    $response = $this->withToken($writeToken)
         ->post("/api/admin/v1/stores/{$store->getKey()}/themes", [
@@ -130,4 +131,5 @@
     $draft = adminThemeApiDraftTheme($store);
+    $writeToken = adminApiBearerToken($store, ['write-themes'], adminThemeApiUser());

-    $this->actingAs(adminThemeApiUser())
+    $this->withToken($writeToken)
         ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [
@@ -144,3 +146,3 @@

-    $this->actingAs(adminThemeApiUser())
+    $this->withToken($writeToken)
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
@@ -190,3 +192,3 @@

-    $this->actingAs(adminThemeApiUser())
+    $this->withToken(adminApiBearerToken($store, ['write-themes'], adminThemeApiUser()))
         ->post("/api/admin/v1/stores/{$store->getKey()}/themes", [
@@ -199,3 +201,3 @@

-    $this->actingAs(adminThemeApiUser())
+    $this->withToken(adminApiBearerToken($store, ['write-themes'], adminThemeApiUser()))
         ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [
@@ -206,3 +208,3 @@

-    $this->actingAs(adminThemeApiUser())
+    $this->withToken(adminApiBearerToken($store, ['write-themes'], adminThemeApiUser()))
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")

status: completed


output: {"output":"Success. Updated the following files:\nM tests/Feature/Api/AdminShippingSettingsApiTest.php\nM tests/Feature/Api/AdminTaxSettingsApiTest.php\nM tests/Feature/Api/AdminThemeApiTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


command:
/bin/zsh
-lc
rg -n "actingAs\(" tests/Feature/Api

type: search

cmd: rg -n "actingAs\\(" tests/Feature/Api

query: actingAs\(

path: Api

aggregated_output: tests/Feature/Api/AdminCatalogApiTest.php:342:    $this->actingAs(adminCatalogApiUser())


output:
Chunk ID: b7fc12
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 23
Output:
tests/Feature/Api/AdminCatalogApiTest.php:342:    $this->actingAs(adminCatalogApiUser())


cmd: rg -n "sessions|session auth|OauthToken|oauth_tokens|admin_api_oauth_token|abilities_json" tests/Feature/Api tests/Feature/Admin app/Http app/Livewire app/Services specs/progress.md


command: rg -n "sessions|session auth|OauthToken|oauth_tokens|admin_api_oauth_token|abilities_json" tests/Feature/Api tests/Feature/Admin app/Http app/Livewire app/Services specs/progress.md

type: search

cmd: rg -n 'sessions|session auth|OauthToken|oauth_tokens|admin_api_oauth_token|abilities_json' tests/Feature/Api tests/Feature/Admin app/Http app/Livewire app/Services specs/progress.md

query: sessions|session auth|OauthToken|oauth_tokens|admin_api_oauth_token|abilities_json

path: Api

aggregated_output:
specs/progress.md:29:| Database schema | `specs/01-DATABASE-SCHEMA.md` | complete | Phase 1 tenancy/auth tables, Phase 2 catalog tables, Phase 3 theme/content/navigation tables including `navigation_items.parent_id` nesting, Phase 4 cart/checkout/pricing tables, Phase 5 order/payment/fulfillment tables, Laravel framework runtime tables including `personal_access_tokens`, and Phase 7 search/analytics/apps/webhooks/export tables are implemented: search_settings, search_queries, SQLite FTS5 `products_fts`, analytics_events, analytics_daily, apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries, and data_exports. |
specs/progress.md:33:| Business logic | `specs/05-BUSINESS-LOGIC.md` | complete | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, store-scoped `CustomerPasswordResetService`, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `SearchService` with per-store synonym expansion and stop-word removal, product observer FTS sync, search query logging, search API rate limiting, `AnalyticsService`, analytics event deduplication, daily analytics aggregation, scheduled `AggregateAnalytics`, analytics API rate limiting, `WebhookService`, timestamped HMAC signing/verification, database-queued `DeliverWebhook`, delivery logging/retry backoff metadata, webhook circuit breaker pausing, domain-event webhook dispatch listener, developer token generation backed by `oauth_tokens`, `OrderExportService`, `CartService`, `DiscountService` including one-per-customer code discount redemption checks and automatic/code order-line attribution, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, storefront cart stock-limit feedback for deny-policy inventory, `PaymentService`, mock PSP, idempotent order creation, checkout UI/API order submission, session/customer/token-gated order confirmation and lookup, customer order-history scoping, customer address book management, customer logout session invalidation, admin dashboard order aggregates, admin analytics reporting, admin customer address management, admin discount rule management, admin content/theme/navigation/settings/search/apps/developers orchestration, admin order UI/API action orchestration, `RefundService`, `FulfillmentService`, bank-transfer payment confirmation, and unpaid bank-transfer cancellation are implemented. |
specs/progress.md:225:- 2026-05-04: Boost query counts after seed confirmed apps 2, app_installations 4, oauth_clients 4, oauth_tokens 4, webhook_subscriptions 8, and webhook_deliveries 0.
specs/progress.md:470:- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing, host/session auth testing, and Laravel database testing docs before adding the Spec 08 tenant-isolation browser suite.
specs/progress.md:629:- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are platform organization/store creation, store membership/invite endpoints, product read/create/update/archive/media presign, customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, search status/reindex, analytics summary, shipping zone/rate settings, tax settings read/update, and order fulfillment/refund mutations.
specs/progress.md:653:- OAuth/Passport app authorization remains deferred per the roadmap; `/oauth/*` and `/api/apps/v1/*` now return explicit `501 Not Implemented` responses, while the developer token UI uses the existing app installation and `oauth_tokens` schema as a self-contained local token store.
specs/progress.md:654:- Admin order API routes use the `admin.api` middleware, which accepts either the existing session-authenticated admin user or a hashed `oauth_tokens` bearer token scoped to the route store; token requests require `read-orders` for GET routes and `write-orders` for refund/fulfillment mutations and update `last_used_at` on successful use.
app/Livewire/Admin/Analytics/Index.php:71:     * @var list<array{source: string, sessions: int, orders: int, conversion_rate: float}>
app/Livewire/Admin/Analytics/Index.php:231:                $sessions = $events->pluck('session_id')->filter()->unique()->count();
app/Livewire/Admin/Analytics/Index.php:236:                    'sessions' => $sessions,
app/Livewire/Admin/Analytics/Index.php:238:                    'conversion_rate' => $sessions > 0 ? round(($orders / $sessions) * 100, 2) : 0.0,
app/Livewire/Admin/Analytics/Index.php:241:            ->sortByDesc('sessions')
app/Http/Controllers/Api/Admin/V1/PageController.php:99:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php:46:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php:60:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/OrderExportController.php:50:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php:59:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/CustomerController.php:56:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/OrderRefundController.php:46:        if (! $request->attributes->has('admin_api_oauth_token')) {
tests/Feature/Admin/AppsDevelopersTest.php:6:use App\Models\OauthToken;
tests/Feature/Admin/AppsDevelopersTest.php:63:    $initialTokenCount = OauthToken::query()
tests/Feature/Admin/AppsDevelopersTest.php:81:    expect(OauthToken::query()
app/Http/Controllers/Api/Admin/V1/AnalyticsSummaryController.php:66:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php:90:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/ProductController.php:231:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/ThemeController.php:67:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/SearchIndexController.php:68:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/CollectionController.php:110:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/ShippingRateController.php:54:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/DiscountController.php:77:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/OrderController.php:62:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/StoreInviteController.php:42:        if ($request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/OrderFulfillmentController.php:40:        if (! $request->attributes->has('admin_api_oauth_token')) {


output:
Chunk ID: 8b66ba
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1876
Output:
specs/progress.md:29:| Database schema | `specs/01-DATABASE-SCHEMA.md` | complete | Phase 1 tenancy/auth tables, Phase 2 catalog tables, Phase 3 theme/content/navigation tables including `navigation_items.parent_id` nesting, Phase 4 cart/checkout/pricing tables, Phase 5 order/payment/fulfillment tables, Laravel framework runtime tables including `personal_access_tokens`, and Phase 7 search/analytics/apps/webhooks/export tables are implemented: search_settings, search_queries, SQLite FTS5 `products_fts`, analytics_events, analytics_daily, apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries, and data_exports. |
specs/progress.md:33:| Business logic | `specs/05-BUSINESS-LOGIC.md` | complete | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, store-scoped `CustomerPasswordResetService`, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `SearchService` with per-store synonym expansion and stop-word removal, product observer FTS sync, search query logging, search API rate limiting, `AnalyticsService`, analytics event deduplication, daily analytics aggregation, scheduled `AggregateAnalytics`, analytics API rate limiting, `WebhookService`, timestamped HMAC signing/verification, database-queued `DeliverWebhook`, delivery logging/retry backoff metadata, webhook circuit breaker pausing, domain-event webhook dispatch listener, developer token generation backed by `oauth_tokens`, `OrderExportService`, `CartService`, `DiscountService` including one-per-customer code discount redemption checks and automatic/code order-line attribution, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, storefront cart stock-limit feedback for deny-policy inventory, `PaymentService`, mock PSP, idempotent order creation, checkout UI/API order submission, session/customer/token-gated order confirmation and lookup, customer order-history scoping, customer address book management, customer logout session invalidation, admin dashboard order aggregates, admin analytics reporting, admin customer address management, admin discount rule management, admin content/theme/navigation/settings/search/apps/developers orchestration, admin order UI/API action orchestration, `RefundService`, `FulfillmentService`, bank-transfer payment confirmation, and unpaid bank-transfer cancellation are implemented. |
specs/progress.md:225:- 2026-05-04: Boost query counts after seed confirmed apps 2, app_installations 4, oauth_clients 4, oauth_tokens 4, webhook_subscriptions 8, and webhook_deliveries 0.
specs/progress.md:470:- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing, host/session auth testing, and Laravel database testing docs before adding the Spec 08 tenant-isolation browser suite.
specs/progress.md:629:- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are platform organization/store creation, store membership/invite endpoints, product read/create/update/archive/media presign, customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, search status/reindex, analytics summary, shipping zone/rate settings, tax settings read/update, and order fulfillment/refund mutations.
specs/progress.md:653:- OAuth/Passport app authorization remains deferred per the roadmap; `/oauth/*` and `/api/apps/v1/*` now return explicit `501 Not Implemented` responses, while the developer token UI uses the existing app installation and `oauth_tokens` schema as a self-contained local token store.
specs/progress.md:654:- Admin order API routes use the `admin.api` middleware, which accepts either the existing session-authenticated admin user or a hashed `oauth_tokens` bearer token scoped to the route store; token requests require `read-orders` for GET routes and `write-orders` for refund/fulfillment mutations and update `last_used_at` on successful use.
app/Livewire/Admin/Analytics/Index.php:71:     * @var list<array{source: string, sessions: int, orders: int, conversion_rate: float}>
app/Livewire/Admin/Analytics/Index.php:231:                $sessions = $events->pluck('session_id')->filter()->unique()->count();
app/Livewire/Admin/Analytics/Index.php:236:                    'sessions' => $sessions,
app/Livewire/Admin/Analytics/Index.php:238:                    'conversion_rate' => $sessions > 0 ? round(($orders / $sessions) * 100, 2) : 0.0,
app/Livewire/Admin/Analytics/Index.php:241:            ->sortByDesc('sessions')
app/Http/Controllers/Api/Admin/V1/PageController.php:99:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php:46:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php:60:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/OrderExportController.php:50:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php:59:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/CustomerController.php:56:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/OrderRefundController.php:46:        if (! $request->attributes->has('admin_api_oauth_token')) {
tests/Feature/Admin/AppsDevelopersTest.php:6:use App\Models\OauthToken;
tests/Feature/Admin/AppsDevelopersTest.php:63:    $initialTokenCount = OauthToken::query()
tests/Feature/Admin/AppsDevelopersTest.php:81:    expect(OauthToken::query()
app/Http/Controllers/Api/Admin/V1/AnalyticsSummaryController.php:66:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php:90:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/ProductController.php:231:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/ThemeController.php:67:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/SearchIndexController.php:68:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/CollectionController.php:110:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/ShippingRateController.php:54:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/DiscountController.php:77:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/OrderController.php:62:        if (! $request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/StoreInviteController.php:42:        if ($request->attributes->has('admin_api_oauth_token')) {
app/Http/Controllers/Api/Admin/V1/OrderFulfillmentController.php:40:        if (! $request->attributes->has('admin_api_oauth_token')) {


cmd: sed -n '1,100p' app/Http/Controllers/Api/Admin/V1/StoreInviteController.php


command: sed -n '1,100p' app/Http/Controllers/Api/Admin/V1/StoreInviteController.php

StoreInviteController.php

type: read

aggregated_output:
<?php

namespace App\Http\Controllers\Api\Admin\V1;

use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Admin\V1\StoreInviteRequest;
use App\Models\Store;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class StoreInviteController extends Controller
{
    public function store(StoreInviteRequest $request, Store $store): JsonResponse
    {
        $this->authorizeInvite($request, $store);

        $validated = $request->validated();
        $userId = User::query()
            ->where('email', $validated['email'])
            ->value('id');

        if ($userId !== null && DB::table('store_users')->where('store_id', $store->getKey())->where('user_id', $userId)->exists()) {
            return response()->json(['message' => 'User already belongs to this store.'], 409);
        }

        $invitedAt = now();

        return response()->json([
            'data' => [
                'email' => $validated['email'],
                'role' => $validated['role'],
                'invited_at' => $invitedAt->toIso8601String(),
                'expires_at' => $invitedAt->copy()->addDays(7)->toIso8601String(),
            ],
        ], 201);
    }

    private function authorizeInvite(Request $request, Store $store): void
    {
        if ($request->attributes->has('admin_api_oauth_token')) {
            return;
        }

        $role = $request->user()?->roleForStore($store);

        abort_unless(in_array($role?->value, ['owner', 'admin'], true), 403);
    }
}


output:
Chunk ID: ef1e70
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 390
Output:
<?php

namespace App\Http\Controllers\Api\Admin\V1;

use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Admin\V1\StoreInviteRequest;
use App\Models\Store;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class StoreInviteController extends Controller
{
    public function store(StoreInviteRequest $request, Store $store): JsonResponse
    {
        $this->authorizeInvite($request, $store);

        $validated = $request->validated();
        $userId = User::query()
            ->where('email', $validated['email'])
            ->value('id');

        if ($userId !== null && DB::table('store_users')->where('store_id', $store->getKey())->where('user_id', $userId)->exists()) {
            return response()->json(['message' => 'User already belongs to this store.'], 409);
        }

        $invitedAt = now();

        return response()->json([
            'data' => [
                'email' => $validated['email'],
                'role' => $validated['role'],
                'invited_at' => $invitedAt->toIso8601String(),
                'expires_at' => $invitedAt->copy()->addDays(7)->toIso8601String(),
            ],
        ], 201);
    }

    private function authorizeInvite(Request $request, Store $store): void
    {
        if ($request->attributes->has('admin_api_oauth_token')) {
            return;
        }

        $role = $request->user()?->roleForStore($store);

        abort_unless(in_array($role?->value, ['owner', 'admin'], true), 403);
    }
}


status: completed

input:
*** Begin Patch
*** Update File: tests/Feature/Admin/AppsDevelopersTest.php
@@
 use App\Enums\StoreUserRole;
 use App\Enums\WebhookEventType;
 use App\Livewire\Admin\Developers\Index as DevelopersIndex;
-use App\Models\OauthToken;
+use App\Models\PersonalAccessToken;
 use App\Models\Store;
@@
-    $initialTokenCount = OauthToken::query()
-        ->whereHas('installation', fn ($query) => $query->withoutGlobalScopes()->where('store_id', $store->getKey()))
+    $initialTokenCount = PersonalAccessToken::query()
+        ->where('tokenable_type', (new User)->getMorphClass())
+        ->whereIn('tokenable_id', $store->users()->pluck('users.id'))
         ->count();
@@
-    expect(OauthToken::query()
-        ->whereHas('installation', fn ($query) => $query->withoutGlobalScopes()->where('store_id', $store->getKey()))
+    expect(PersonalAccessToken::query()
+        ->where('tokenable_type', (new User)->getMorphClass())
+        ->whereIn('tokenable_id', $store->users()->pluck('users.id'))
         ->count())->toBe($initialTokenCount + 1)
*** End Patch


stdout:
Success. Updated the following files:
M tests/Feature/Admin/AppsDevelopersTest.php

unified_diff:
@@ -5,3 +5,3 @@
 use App\Livewire\Admin\Developers\Index as DevelopersIndex;
-use App\Models\OauthToken;
+use App\Models\PersonalAccessToken;
 use App\Models\Store;
@@ -62,4 +62,5 @@

-    $initialTokenCount = OauthToken::query()
-        ->whereHas('installation', fn ($query) => $query->withoutGlobalScopes()->where('store_id', $store->getKey()))
+    $initialTokenCount = PersonalAccessToken::query()
+        ->where('tokenable_type', (new User)->getMorphClass())
+        ->whereIn('tokenable_id', $store->users()->pluck('users.id'))
         ->count();
@@ -80,4 +81,5 @@

-    expect(OauthToken::query()
-        ->whereHas('installation', fn ($query) => $query->withoutGlobalScopes()->where('store_id', $store->getKey()))
+    expect(PersonalAccessToken::query()
+        ->where('tokenable_type', (new User)->getMorphClass())
+        ->whereIn('tokenable_id', $store->users()->pluck('users.id'))
         ->count())->toBe($initialTokenCount + 1)


output: {"output":"Success. Updated the following files:\nM tests/Feature/Admin/AppsDevelopersTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


cmd: sed -n '1,190p' tests/Feature/Storefront/OrderViewsTest.php


command: sed -n '1,190p' tests/Feature/Storefront/OrderViewsTest.php

aggregated_output:
<?php

use App\Enums\CartStatus;
use App\Enums\CheckoutStatus;
use App\Livewire\Storefront\Account\Orders\Index as AccountOrdersIndex;
use App\Livewire\Storefront\Account\Orders\Show as AccountOrderShow;
use App\Livewire\Storefront\Checkout\Confirmation;
use App\Livewire\Storefront\Checkout\Show as CheckoutShow;
use App\Models\Checkout;
use App\Models\Customer;
use App\Models\InventoryItem;
use App\Models\Order;
use App\Models\OrderLine;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\ShippingRate;
use App\Models\Store;
use App\Services\CartService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;

uses(RefreshDatabase::class);

beforeEach(function () {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function orderViewsStore(): Store
{
    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    app()->instance('current_store', $store);

    return $store;
}

function orderViewsVariant(Store $store): ProductVariant
{
    $product = Product::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('handle', 'classic-cotton-t-shirt')
        ->firstOrFail();

    return ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->oldest('position')
        ->firstOrFail();
}

function orderViewsShippingRate(Store $store): ShippingRate
{
    return ShippingRate::withoutGlobalScopes()
        ->whereHas('zone', fn ($query) => $query->withoutGlobalScopes()->where('store_id', $store->getKey()))
        ->where('name', 'Standard Shipping')
        ->firstOrFail();
}

test('checkout page places an order and redirects to confirmation', function () {
    $store = orderViewsStore();
    $variant = orderViewsVariant($store);
    $cart = app(CartService::class)->create($store);
    session(['cart_id' => $cart->getKey()]);
    app(CartService::class)->addLine($cart, $variant->getKey(), 2);

    $component = Livewire::test(CheckoutShow::class)
        ->set('email', 'buyer@example.test')
        ->set('shippingAddress.first_name', 'Test')
        ->set('shippingAddress.last_name', 'Buyer')
        ->set('shippingAddress.address1', 'Main Street 1')
        ->set('shippingAddress.city', 'Berlin')
        ->set('shippingAddress.country', 'DE')
        ->set('shippingAddress.postal_code', '10115')
        ->call('saveAddress')
        ->set('selectedShippingRateId', orderViewsShippingRate($store)->getKey())
        ->call('selectShippingMethod')
        ->set('paymentMethod', 'credit_card')
        ->call('selectPaymentMethod')
        ->set('cardNumber', '4242 4242 4242 4242')
        ->call('placeOrder');

    $order = Order::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('email', 'buyer@example.test')
        ->latest('id')
        ->firstOrFail();

    $component->assertRedirect(route('checkout.confirmation', $order));

    expect($order->email)->toBe('buyer@example.test')
        ->and($order->lines)->toHaveCount(1)
        ->and($cart->refresh()->status)->toBe(CartStatus::Converted)
        ->and(session('last_order_id'))->toBe($order->getKey())
        ->and(session('cart_id'))->toBeNull();
});

test('checkout page surfaces payment failures and releases reservations', function () {
    $store = orderViewsStore();
    $variant = orderViewsVariant($store);
    $ordersCount = Order::withoutGlobalScopes()->count();
    $cart = app(CartService::class)->create($store);
    session(['cart_id' => $cart->getKey()]);
    app(CartService::class)->addLine($cart, $variant->getKey(), 2);

    Livewire::test(CheckoutShow::class)
        ->set('email', 'buyer@example.test')
        ->set('shippingAddress.first_name', 'Test')
        ->set('shippingAddress.last_name', 'Buyer')
        ->set('shippingAddress.address1', 'Main Street 1')
        ->set('shippingAddress.city', 'Berlin')
        ->set('shippingAddress.country', 'DE')
        ->set('shippingAddress.postal_code', '10115')
        ->call('saveAddress')
        ->set('selectedShippingRateId', orderViewsShippingRate($store)->getKey())
        ->call('selectShippingMethod')
        ->set('paymentMethod', 'credit_card')
        ->call('selectPaymentMethod')
        ->set('cardNumber', '4000 0000 0000 0002')
        ->call('placeOrder')
        ->assertHasErrors('cardNumber')
        ->assertSet('step', 'payment');

    $checkout = Checkout::withoutGlobalScopes()->where('cart_id', $cart->getKey())->firstOrFail();
    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();

    expect(Order::withoutGlobalScopes()->count())->toBe($ordersCount)
        ->and(Order::withoutGlobalScopes()->where('email', 'buyer@example.test')->exists())->toBeFalse()
        ->and($checkout->status)->toBe(CheckoutStatus::ShippingSelected)
        ->and($inventory->quantity_reserved)->toBe(0);
});

test('order confirmation is visible only for the session order or customer order', function () {
    $store = orderViewsStore();
    $customer = Customer::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();
    $otherCustomer = Customer::factory()->create(['store_id' => $store->getKey()]);
    $order = Order::factory()->forCustomer($customer)->paid()->create([
        'store_id' => $store->getKey(),
        'customer_id' => $customer->getKey(),
        'order_number' => '#7770',
        'email' => $customer->email,
    ]);

    session(['last_order_id' => $order->getKey()]);

    Livewire::test(Confirmation::class, ['order' => $order])
        ->assertSee($order->order_number);

    session()->forget('last_order_id');

    Livewire::test(Confirmation::class, ['order' => $order])
        ->assertStatus(404);

    $this->actingAs($otherCustomer, 'customer');

    Livewire::test(Confirmation::class, ['order' => $order])
        ->assertStatus(404);

    $this->actingAs($customer, 'customer');

    Livewire::test(Confirmation::class, ['order' => $order])
        ->assertSee($order->order_number);
});

test('customer account lists and shows customer orders', function () {
    $store = orderViewsStore();
    $customer = Customer::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();
    $otherCustomer = Customer::factory()->create(['store_id' => $store->getKey()]);
    $order = Order::factory()->forCustomer($customer)->paid()->create([
        'store_id' => $store->getKey(),
        'customer_id' => $customer->getKey(),
        'order_number' => '#7777',
    ]);
    $otherOrder = Order::factory()->forCustomer($otherCustomer)->paid()->create([
        'store_id' => $store->getKey(),
        'customer_id' => $otherCustomer->getKey(),
        'order_number' => '#8888',
    ]);
    OrderLine::factory()->create([
        'order_id' => $order->getKey(),
        'title_snapshot' => 'Classic Cotton T-Shirt',
    ]);

    $this->actingAs($customer, 'customer');

    Livewire::test(AccountOrdersIndex::class)
        ->assertSee('#7777')
        ->assertDontSee('#8888');

    Livewire::test(AccountOrderShow::class, ['order' => $order])
        ->assertSee('Classic Cotton T-Shirt')


output:
Chunk ID: 1ad338
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1804
Output:
<?php

use App\Enums\CartStatus;
use App\Enums\CheckoutStatus;
use App\Livewire\Storefront\Account\Orders\Index as AccountOrdersIndex;
use App\Livewire\Storefront\Account\Orders\Show as AccountOrderShow;
use App\Livewire\Storefront\Checkout\Confirmation;
use App\Livewire\Storefront\Checkout\Show as CheckoutShow;
use App\Models\Checkout;
use App\Models\Customer;
use App\Models\InventoryItem;
use App\Models\Order;
use App\Models\OrderLine;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\ShippingRate;
use App\Models\Store;
use App\Services\CartService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;

uses(RefreshDatabase::class);

beforeEach(function () {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function orderViewsStore(): Store
{
    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    app()->instance('current_store', $store);

    return $store;
}

function orderViewsVariant(Store $store): ProductVariant
{
    $product = Product::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('handle', 'classic-cotton-t-shirt')
        ->firstOrFail();

    return ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->oldest('position')
        ->firstOrFail();
}

function orderViewsShippingRate(Store $store): ShippingRate
{
    return ShippingRate::withoutGlobalScopes()
        ->whereHas('zone', fn ($query) => $query->withoutGlobalScopes()->where('store_id', $store->getKey()))
        ->where('name', 'Standard Shipping')
        ->firstOrFail();
}

test('checkout page places an order and redirects to confirmation', function () {
    $store = orderViewsStore();
    $variant = orderViewsVariant($store);
    $cart = app(CartService::class)->create($store);
    session(['cart_id' => $cart->getKey()]);
    app(CartService::class)->addLine($cart, $variant->getKey(), 2);

    $component = Livewire::test(CheckoutShow::class)
        ->set('email', 'buyer@example.test')
        ->set('shippingAddress.first_name', 'Test')
        ->set('shippingAddress.last_name', 'Buyer')
        ->set('shippingAddress.address1', 'Main Street 1')
        ->set('shippingAddress.city', 'Berlin')
        ->set('shippingAddress.country', 'DE')
        ->set('shippingAddress.postal_code', '10115')
        ->call('saveAddress')
        ->set('selectedShippingRateId', orderViewsShippingRate($store)->getKey())
        ->call('selectShippingMethod')
        ->set('paymentMethod', 'credit_card')
        ->call('selectPaymentMethod')
        ->set('cardNumber', '4242 4242 4242 4242')
        ->call('placeOrder');

    $order = Order::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('email', 'buyer@example.test')
        ->latest('id')
        ->firstOrFail();

    $component->assertRedirect(route('checkout.confirmation', $order));

    expect($order->email)->toBe('buyer@example.test')
        ->and($order->lines)->toHaveCount(1)
        ->and($cart->refresh()->status)->toBe(CartStatus::Converted)
        ->and(session('last_order_id'))->toBe($order->getKey())
        ->and(session('cart_id'))->toBeNull();
});

test('checkout page surfaces payment failures and releases reservations', function () {
    $store = orderViewsStore();
    $variant = orderViewsVariant($store);
    $ordersCount = Order::withoutGlobalScopes()->count();
    $cart = app(CartService::class)->create($store);
    session(['cart_id' => $cart->getKey()]);
    app(CartService::class)->addLine($cart, $variant->getKey(), 2);

    Livewire::test(CheckoutShow::class)
        ->set('email', 'buyer@example.test')
        ->set('shippingAddress.first_name', 'Test')
        ->set('shippingAddress.last_name', 'Buyer')
        ->set('shippingAddress.address1', 'Main Street 1')
        ->set('shippingAddress.city', 'Berlin')
        ->set('shippingAddress.country', 'DE')
        ->set('shippingAddress.postal_code', '10115')
        ->call('saveAddress')
        ->set('selectedShippingRateId', orderViewsShippingRate($store)->getKey())
        ->call('selectShippingMethod')
        ->set('paymentMethod', 'credit_card')
        ->call('selectPaymentMethod')
        ->set('cardNumber', '4000 0000 0000 0002')
        ->call('placeOrder')
        ->assertHasErrors('cardNumber')
        ->assertSet('step', 'payment');

    $checkout = Checkout::withoutGlobalScopes()->where('cart_id', $cart->getKey())->firstOrFail();
    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();

    expect(Order::withoutGlobalScopes()->count())->toBe($ordersCount)
        ->and(Order::withoutGlobalScopes()->where('email', 'buyer@example.test')->exists())->toBeFalse()
        ->and($checkout->status)->toBe(CheckoutStatus::ShippingSelected)
        ->and($inventory->quantity_reserved)->toBe(0);
});

test('order confirmation is visible only for the session order or customer order', function () {
    $store = orderViewsStore();
    $customer = Customer::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();
    $otherCustomer = Customer::factory()->create(['store_id' => $store->getKey()]);
    $order = Order::factory()->forCustomer($customer)->paid()->create([
        'store_id' => $store->getKey(),
        'customer_id' => $customer->getKey(),
        'order_number' => '#7770',
        'email' => $customer->email,
    ]);

    session(['last_order_id' => $order->getKey()]);

    Livewire::test(Confirmation::class, ['order' => $order])
        ->assertSee($order->order_number);

    session()->forget('last_order_id');

    Livewire::test(Confirmation::class, ['order' => $order])
        ->assertStatus(404);

    $this->actingAs($otherCustomer, 'customer');

    Livewire::test(Confirmation::class, ['order' => $order])
        ->assertStatus(404);

    $this->actingAs($customer, 'customer');

    Livewire::test(Confirmation::class, ['order' => $order])
        ->assertSee($order->order_number);
});

test('customer account lists and shows customer orders', function () {
    $store = orderViewsStore();
    $customer = Customer::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();
    $otherCustomer = Customer::factory()->create(['store_id' => $store->getKey()]);
    $order = Order::factory()->forCustomer($customer)->paid()->create([
        'store_id' => $store->getKey(),
        'customer_id' => $customer->getKey(),
        'order_number' => '#7777',
    ]);
    $otherOrder = Order::factory()->forCustomer($otherCustomer)->paid()->create([
        'store_id' => $store->getKey(),
        'customer_id' => $otherCustomer->getKey(),
        'order_number' => '#8888',
    ]);
    OrderLine::factory()->create([
        'order_id' => $order->getKey(),
        'title_snapshot' => 'Classic Cotton T-Shirt',
    ]);

    $this->actingAs($customer, 'customer');

    Livewire::test(AccountOrdersIndex::class)
        ->assertSee('#7777')
        ->assertDontSee('#8888');

    Livewire::test(AccountOrderShow::class, ['order' => $order])
        ->assertSee('Classic Cotton T-Shirt')


cmd: sed -n '1,180p' database/factories/CheckoutFactory.php


command: sed -n '1,180p' database/factories/CheckoutFactory.php

aggregated_output:
<?php

namespace Database\Factories;

use App\Enums\CheckoutStatus;
use App\Models\Cart;
use App\Models\Customer;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Checkout>
 */
class CheckoutFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'cart_id' => Cart::factory(),
            'customer_id' => null,
            'status' => CheckoutStatus::Started,
            'payment_method' => null,
            'email' => null,
            'shipping_address_json' => null,
            'billing_address_json' => null,
            'shipping_method_id' => null,
            'discount_code' => null,
            'tax_provider_snapshot_json' => null,
            'totals_json' => null,
            'expires_at' => null,
        ];
    }

    public function forCustomer(?Customer $customer = null): static
    {
        return $this->state(fn (array $attributes): array => [
            'customer_id' => $customer?->getKey() ?? Customer::factory(),
        ]);
    }
}


output:
Chunk ID: bb2e9a
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 314
Output:
<?php

namespace Database\Factories;

use App\Enums\CheckoutStatus;
use App\Models\Cart;
use App\Models\Customer;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Checkout>
 */
class CheckoutFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'cart_id' => Cart::factory(),
            'customer_id' => null,
            'status' => CheckoutStatus::Started,
            'payment_method' => null,
            'email' => null,
            'shipping_address_json' => null,
            'billing_address_json' => null,
            'shipping_method_id' => null,
            'discount_code' => null,
            'tax_provider_snapshot_json' => null,
            'totals_json' => null,
            'expires_at' => null,
        ];
    }

    public function forCustomer(?Customer $customer = null): static
    {
        return $this->state(fn (array $attributes): array => [
            'customer_id' => $customer?->getKey() ?? Customer::factory(),
        ]);
    }
}


status: completed

input:
*** Begin Patch
*** Update File: tests/Feature/Storefront/OrderViewsTest.php
@@
-    $component->assertRedirect(route('checkout.confirmation', $order));
+    $checkout = Checkout::withoutGlobalScopes()
+        ->where('cart_id', $cart->getKey())
+        ->firstOrFail();
+
+    $component->assertRedirect(route('checkout.confirmation', ['checkout' => $checkout->getKey()]));
@@
     $customer = Customer::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();
     $otherCustomer = Customer::factory()->create(['store_id' => $store->getKey()]);
+    $cart = app(CartService::class)->create($store, $customer);
+    $checkout = Checkout::factory()->forCustomer($customer)->create([
+        'store_id' => $store->getKey(),
+        'cart_id' => $cart->getKey(),
+        'status' => CheckoutStatus::Completed,
+    ]);
     $order = Order::factory()->forCustomer($customer)->paid()->create([
         'store_id' => $store->getKey(),
+        'checkout_id' => $checkout->getKey(),
         'customer_id' => $customer->getKey(),
         'order_number' => '#7770',
@@
-    Livewire::test(Confirmation::class, ['order' => $order])
+    Livewire::test(Confirmation::class, ['checkout' => $checkout])
         ->assertSee($order->order_number);
@@
-    Livewire::test(Confirmation::class, ['order' => $order])
+    Livewire::test(Confirmation::class, ['checkout' => $checkout])
         ->assertStatus(404);
@@
-    Livewire::test(Confirmation::class, ['order' => $order])
+    Livewire::test(Confirmation::class, ['checkout' => $checkout])
         ->assertStatus(404);
@@
-    Livewire::test(Confirmation::class, ['order' => $order])
+    Livewire::test(Confirmation::class, ['checkout' => $checkout])
         ->assertSee($order->order_number);
*** End Patch


stdout:
Success. Updated the following files:
M tests/Feature/Storefront/OrderViewsTest.php

unified_diff:
@@ -87,3 +87,7 @@

-    $component->assertRedirect(route('checkout.confirmation', $order));
+    $checkout = Checkout::withoutGlobalScopes()
+        ->where('cart_id', $cart->getKey())
+        ->firstOrFail();
+
+    $component->assertRedirect(route('checkout.confirmation', ['checkout' => $checkout->getKey()]));

@@ -135,4 +139,11 @@
     $otherCustomer = Customer::factory()->create(['store_id' => $store->getKey()]);
+    $cart = app(CartService::class)->create($store, $customer);
+    $checkout = Checkout::factory()->forCustomer($customer)->create([
+        'store_id' => $store->getKey(),
+        'cart_id' => $cart->getKey(),
+        'status' => CheckoutStatus::Completed,
+    ]);
     $order = Order::factory()->forCustomer($customer)->paid()->create([
         'store_id' => $store->getKey(),
+        'checkout_id' => $checkout->getKey(),
         'customer_id' => $customer->getKey(),
@@ -144,3 +155,3 @@

-    Livewire::test(Confirmation::class, ['order' => $order])
+    Livewire::test(Confirmation::class, ['checkout' => $checkout])
         ->assertSee($order->order_number);
@@ -149,3 +160,3 @@

-    Livewire::test(Confirmation::class, ['order' => $order])
+    Livewire::test(Confirmation::class, ['checkout' => $checkout])
         ->assertStatus(404);
@@ -154,3 +165,3 @@

-    Livewire::test(Confirmation::class, ['order' => $order])
+    Livewire::test(Confirmation::class, ['checkout' => $checkout])
         ->assertStatus(404);
@@ -159,3 +170,3 @@

-    Livewire::test(Confirmation::class, ['order' => $order])
+    Livewire::test(Confirmation::class, ['checkout' => $checkout])
         ->assertSee($order->order_number);


output: {"output":"Success. Updated the following files:\nM tests/Feature/Storefront/OrderViewsTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


cmd: sed -n '1,70p' app/Services/CartService.php


command: sed -n '1,70p' app/Services/CartService.php

aggregated_output:
<?php

namespace App\Services;

use App\Enums\CartStatus;
use App\Enums\ProductStatus;
use App\Enums\VariantStatus;
use App\Exceptions\CartVersionMismatchException;
use App\Exceptions\InvalidCartOperationException;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\Customer;
use App\Models\InventoryItem;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Support\Facades\DB;

class CartService
{
    public function __construct(
        private readonly InventoryService $inventory,
    ) {}

    public function create(Store $store, ?Customer $customer = null): Cart
    {
        return Cart::withoutGlobalScopes()->create([
            'store_id' => $store->getKey(),
            'customer_id' => $customer?->getKey(),
            'currency' => $store->default_currency,
            'cart_version' => 1,
            'status' => CartStatus::Active,
        ]);
    }

    public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart
    {
        $sessionCartId = session('cart_id');

        if ($customer instanceof Customer) {
            $customerCart = Cart::withoutGlobalScopes()
                ->where('store_id', $store->getKey())
                ->where('customer_id', $customer->getKey())
                ->where('status', CartStatus::Active)
                ->latest('id')
                ->first() ?? $this->create($store, $customer);

            if ($sessionCartId) {
                $guestCart = Cart::withoutGlobalScopes()
                    ->where('store_id', $store->getKey())
                    ->whereNull('customer_id')
                    ->where('status', CartStatus::Active)
                    ->find($sessionCartId);

                if ($guestCart instanceof Cart && $guestCart->isNot($customerCart)) {
                    $customerCart = $this->mergeOnLogin($guestCart, $customerCart);
                }

                session()->forget('cart_id');
            }

            return $customerCart->refresh();
        }

        if ($sessionCartId) {
            $cart = Cart::withoutGlobalScopes()
                ->where('store_id', $store->getKey())
                ->whereNull('customer_id')
                ->where('status', CartStatus::Active)
                ->find($sessionCartId);


output:
Chunk ID: 7d3684
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 570
Output:
<?php

namespace App\Services;

use App\Enums\CartStatus;
use App\Enums\ProductStatus;
use App\Enums\VariantStatus;
use App\Exceptions\CartVersionMismatchException;
use App\Exceptions\InvalidCartOperationException;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\Customer;
use App\Models\InventoryItem;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Support\Facades\DB;

class CartService
{
    public function __construct(
        private readonly InventoryService $inventory,
    ) {}

    public function create(Store $store, ?Customer $customer = null): Cart
    {
        return Cart::withoutGlobalScopes()->create([
            'store_id' => $store->getKey(),
            'customer_id' => $customer?->getKey(),
            'currency' => $store->default_currency,
            'cart_version' => 1,
            'status' => CartStatus::Active,
        ]);
    }

    public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart
    {
        $sessionCartId = session('cart_id');

        if ($customer instanceof Customer) {
            $customerCart = Cart::withoutGlobalScopes()
                ->where('store_id', $store->getKey())
                ->where('customer_id', $customer->getKey())
                ->where('status', CartStatus::Active)
                ->latest('id')
                ->first() ?? $this->create($store, $customer);

            if ($sessionCartId) {
                $guestCart = Cart::withoutGlobalScopes()
                    ->where('store_id', $store->getKey())
                    ->whereNull('customer_id')
                    ->where('status', CartStatus::Active)
                    ->find($sessionCartId);

                if ($guestCart instanceof Cart && $guestCart->isNot($customerCart)) {
                    $customerCart = $this->mergeOnLogin($guestCart, $customerCart);
                }

                session()->forget('cart_id');
            }

            return $customerCart->refresh();
        }

        if ($sessionCartId) {
            $cart = Cart::withoutGlobalScopes()
                ->where('store_id', $store->getKey())
                ->whereNull('customer_id')
                ->where('status', CartStatus::Active)
                ->find($sessionCartId);


cmd: sed -n '100,245p' tests/Browser/Storefront/CheckoutTest.php


command: sed -n '100,245p' tests/Browser/Storefront/CheckoutTest.php

aggregated_output:
}

function storefrontCheckoutApplyCartDiscount(string $code): mixed
{
    return visit('/products/classic-cotton-t-shirt', storefrontCheckoutHost())
        ->click('M')
        ->wait(1)
        ->click('Black')
        ->wait(1)
        ->click('Add to cart')
        ->wait(1)
        ->navigate('/cart')
        ->wait(1)
        ->fill('input[wire\\:model="discountCode"]', $code)
        ->click('button:has-text("Apply")')
        ->wait(1);
}

test('completes full checkout with credit card', function (): void {
    storefrontCheckoutFillSuccessfulCard(storefrontCheckoutReachPayment())
        ->assertSee('29.98')
        ->click('button:has-text("Pay now")')
        ->wait(2)
        ->assertPathBeginsWith('/checkout/confirmation')
        ->assertSee('Thank you')
        ->assertSee('#1016')
        ->assertNoJavaScriptErrors();
});

test('shows shipping methods based on german address', function (): void {
    storefrontCheckoutReachShipping([
        'email' => 'test@example.com',
        'first_name' => 'Hans',
        'last_name' => 'Mueller',
        'address1' => 'Berliner Str. 10',
        'city' => 'Munich',
        'postal_code' => '80331',
        'country' => 'DE',
    ])
        ->assertSee('Standard Shipping')
        ->assertSee('4.99')
        ->assertNoJavaScriptErrors();
});

test('shows international shipping methods for non german address', function (): void {
    storefrontCheckoutReachShipping([
        'email' => 'test@example.com',
        'first_name' => 'John',
        'last_name' => 'Smith',
        'address1' => '123 Main St',
        'city' => 'New York',
        'postal_code' => '10001',
        'country' => 'US',
    ])
        ->assertSee('International Shipping')
        ->assertSee('14.99')
        ->assertNoJavaScriptErrors();
});

test('applies discount during checkout', function (): void {
    $page = storefrontCheckoutApplyCartDiscount('FLAT5')
        ->assertSee('FLAT5')
        ->click('main button:has-text("Checkout")')
        ->wait(1)
        ->assertPathIs('/checkout');

    storefrontCheckoutSubmitAddress(storefrontCheckoutFillAddress($page))
        ->click('button:has-text("Standard Shipping")')
        ->wait(1)
        ->click('button[wire\\:click="selectShippingMethod"]')
        ->wait(1)
        ->assertSee('FLAT5')
        ->assertSee('5.00')
        ->assertSee('24.98')
        ->assertNoJavaScriptErrors();
});

test('validates required contact email', function (): void {
    storefrontCheckoutStart()
        ->click('form[wire\\:submit="saveAddress"] button[type="submit"]')
        ->wait(1)
        ->assertSee('email field is required')
        ->assertNoJavaScriptErrors();
});

test('validates required shipping address fields', function (): void {
    storefrontCheckoutStart()
        ->fill('input[wire\\:model="email"]', 'test@example.com')
        ->click('form[wire\\:submit="saveAddress"] button[type="submit"]')
        ->wait(1)
        ->assertSee('first name field is required')
        ->assertSee('last name field is required')
        ->assertSee('address1 field is required')
        ->assertSee('city field is required')
        ->assertSee('postal code field is required')
        ->assertNoJavaScriptErrors();
});

test('validates invalid postal code format', function (): void {
    storefrontCheckoutSubmitAddress(storefrontCheckoutFillAddress(storefrontCheckoutStart(), [
        'postal_code' => 'INVALID',
    ]))
        ->assertSee('postal code format is invalid')
        ->assertNoJavaScriptErrors();
});

test('prevents checkout with empty cart', function (): void {
    visit('/cart', storefrontCheckoutHost())
        ->assertSee('Your cart is empty')
        ->assertButtonDisabled('main button:has-text("Checkout")')
        ->assertNoJavaScriptErrors();
});

test('completes checkout with paypal', function (): void {
    storefrontCheckoutReachPayment()
        ->select('select[wire\\:model\\.live="paymentMethod"]', 'paypal')
        ->wait(1)
        ->assertSee('Pay with PayPal')
        ->click('button:has-text("Pay with PayPal")')
        ->wait(2)
        ->assertPathBeginsWith('/checkout/confirmation')
        ->assertSee('Thank you')
        ->assertSee('PayPal')
        ->assertNoJavaScriptErrors();
});

test('completes checkout with bank transfer', function (): void {
    storefrontCheckoutReachPayment()
        ->select('select[wire\\:model\\.live="paymentMethod"]', 'bank_transfer')
        ->wait(1)
        ->assertSee('bank transfer instructions')
        ->click('button:has-text("Place order")')
        ->wait(2)
        ->assertPathBeginsWith('/checkout/confirmation')
        ->assertSee('Thank you')
        ->assertSee('IBAN')
        ->assertSee('BIC')
        ->assertSee('Reference')
        ->assertSee('#1016')
        ->assertNoJavaScriptErrors();
});

test('shows error for declined credit card', function (): void {
    storefrontCheckoutReachPayment()
        ->fill('input[wire\\:model="cardNumber"]', '4000 0000 0000 0002')
        ->fill('input[wire\\:model="cardName"]', 'Test Buyer')


output:
Chunk ID: bb53a0
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1269
Output:
}

function storefrontCheckoutApplyCartDiscount(string $code): mixed
{
    return visit('/products/classic-cotton-t-shirt', storefrontCheckoutHost())
        ->click('M')
        ->wait(1)
        ->click('Black')
        ->wait(1)
        ->click('Add to cart')
        ->wait(1)
        ->navigate('/cart')
        ->wait(1)
        ->fill('input[wire\\:model="discountCode"]', $code)
        ->click('button:has-text("Apply")')
        ->wait(1);
}

test('completes full checkout with credit card', function (): void {
    storefrontCheckoutFillSuccessfulCard(storefrontCheckoutReachPayment())
        ->assertSee('29.98')
        ->click('button:has-text("Pay now")')
        ->wait(2)
        ->assertPathBeginsWith('/checkout/confirmation')
        ->assertSee('Thank you')
        ->assertSee('#1016')
        ->assertNoJavaScriptErrors();
});

test('shows shipping methods based on german address', function (): void {
    storefrontCheckoutReachShipping([
        'email' => 'test@example.com',
        'first_name' => 'Hans',
        'last_name' => 'Mueller',
        'address1' => 'Berliner Str. 10',
        'city' => 'Munich',
        'postal_code' => '80331',
        'country' => 'DE',
    ])
        ->assertSee('Standard Shipping')
        ->assertSee('4.99')
        ->assertNoJavaScriptErrors();
});

test('shows international shipping methods for non german address', function (): void {
    storefrontCheckoutReachShipping([
        'email' => 'test@example.com',
        'first_name' => 'John',
        'last_name' => 'Smith',
        'address1' => '123 Main St',
        'city' => 'New York',
        'postal_code' => '10001',
        'country' => 'US',
    ])
        ->assertSee('International Shipping')
        ->assertSee('14.99')
        ->assertNoJavaScriptErrors();
});

test('applies discount during checkout', function (): void {
    $page = storefrontCheckoutApplyCartDiscount('FLAT5')
        ->assertSee('FLAT5')
        ->click('main button:has-text("Checkout")')
        ->wait(1)
        ->assertPathIs('/checkout');

    storefrontCheckoutSubmitAddress(storefrontCheckoutFillAddress($page))
        ->click('button:has-text("Standard Shipping")')
        ->wait(1)
        ->click('button[wire\\:click="selectShippingMethod"]')
        ->wait(1)
        ->assertSee('FLAT5')
        ->assertSee('5.00')
        ->assertSee('24.98')
        ->assertNoJavaScriptErrors();
});

test('validates required contact email', function (): void {
    storefrontCheckoutStart()
        ->click('form[wire\\:submit="saveAddress"] button[type="submit"]')
        ->wait(1)
        ->assertSee('email field is required')
        ->assertNoJavaScriptErrors();
});

test('validates required shipping address fields', function (): void {
    storefrontCheckoutStart()
        ->fill('input[wire\\:model="email"]', 'test@example.com')
        ->click('form[wire\\:submit="saveAddress"] button[type="submit"]')
        ->wait(1)
        ->assertSee('first name field is required')
        ->assertSee('last name field is required')
        ->assertSee('address1 field is required')
        ->assertSee('city field is required')
        ->assertSee('postal code field is required')
        ->assertNoJavaScriptErrors();
});

test('validates invalid postal code format', function (): void {
    storefrontCheckoutSubmitAddress(storefrontCheckoutFillAddress(storefrontCheckoutStart(), [
        'postal_code' => 'INVALID',
    ]))
        ->assertSee('postal code format is invalid')
        ->assertNoJavaScriptErrors();
});

test('prevents checkout with empty cart', function (): void {
    visit('/cart', storefrontCheckoutHost())
        ->assertSee('Your cart is empty')
        ->assertButtonDisabled('main button:has-text("Checkout")')
        ->assertNoJavaScriptErrors();
});

test('completes checkout with paypal', function (): void {
    storefrontCheckoutReachPayment()
        ->select('select[wire\\:model\\.live="paymentMethod"]', 'paypal')
        ->wait(1)
        ->assertSee('Pay with PayPal')
        ->click('button:has-text("Pay with PayPal")')
        ->wait(2)
        ->assertPathBeginsWith('/checkout/confirmation')
        ->assertSee('Thank you')
        ->assertSee('PayPal')
        ->assertNoJavaScriptErrors();
});

test('completes checkout with bank transfer', function (): void {
    storefrontCheckoutReachPayment()
        ->select('select[wire\\:model\\.live="paymentMethod"]', 'bank_transfer')
        ->wait(1)
        ->assertSee('bank transfer instructions')
        ->click('button:has-text("Place order")')
        ->wait(2)
        ->assertPathBeginsWith('/checkout/confirmation')
        ->assertSee('Thank you')
        ->assertSee('IBAN')
        ->assertSee('BIC')
        ->assertSee('Reference')
        ->assertSee('#1016')
        ->assertNoJavaScriptErrors();
});

test('shows error for declined credit card', function (): void {
    storefrontCheckoutReachPayment()
        ->fill('input[wire\\:model="cardNumber"]', '4000 0000 0000 0002')
        ->fill('input[wire\\:model="cardName"]', 'Test Buyer')


cmd: sed -n '1,100p' tests/Browser/Storefront/CheckoutTest.php


command: sed -n '1,100p' tests/Browser/Storefront/CheckoutTest.php

aggregated_output:
<?php

use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Pest\Browser\Playwright\Playwright;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    Playwright::setHost('shop.test');

    $this->seed(DatabaseSeeder::class);
});

afterEach(function (): void {
    Playwright::setHost(null);
});

function storefrontCheckoutHost(): array
{
    return ['host' => 'shop.test'];
}

function storefrontCheckoutStart(): mixed
{
    return visit('/products/classic-cotton-t-shirt', storefrontCheckoutHost())
        ->click('M')
        ->wait(1)
        ->click('Black')
        ->wait(1)
        ->click('Add to cart')
        ->wait(1)
        ->navigate('/cart')
        ->wait(1)
        ->click('main button:has-text("Checkout")')
        ->wait(1)
        ->assertPathIs('/checkout')
        ->assertSee('Checkout');
}

/**
 * @param  array<string, string>  $overrides
 */
function storefrontCheckoutFillAddress(mixed $page, array $overrides = []): mixed
{
    $address = [
        'email' => 'test-buyer@example.com',
        'first_name' => 'Test',
        'last_name' => 'Buyer',
        'address1' => 'Teststrasse 1',
        'city' => 'Berlin',
        'postal_code' => '10115',
        'country' => 'DE',
    ];

    $address = array_replace($address, $overrides);

    return $page
        ->fill('input[wire\\:model="email"]', $address['email'])
        ->fill('input[wire\\:model="shippingAddress.first_name"]', $address['first_name'])
        ->fill('input[wire\\:model="shippingAddress.last_name"]', $address['last_name'])
        ->fill('input[wire\\:model="shippingAddress.address1"]', $address['address1'])
        ->fill('input[wire\\:model="shippingAddress.city"]', $address['city'])
        ->fill('input[wire\\:model="shippingAddress.postal_code"]', $address['postal_code'])
        ->select('select[wire\\:model="shippingAddress.country"]', $address['country']);
}

function storefrontCheckoutSubmitAddress(mixed $page): mixed
{
    return $page
        ->click('form[wire\\:submit="saveAddress"] button[type="submit"]')
        ->wait(1);
}

function storefrontCheckoutReachShipping(array $address = []): mixed
{
    return storefrontCheckoutSubmitAddress(
        storefrontCheckoutFillAddress(storefrontCheckoutStart(), $address),
    );
}

function storefrontCheckoutReachPayment(array $address = []): mixed
{
    return storefrontCheckoutReachShipping($address)
        ->click('button:has-text("Standard Shipping")')
        ->wait(1)
        ->click('button[wire\\:click="selectShippingMethod"]')
        ->wait(1)
        ->assertSee('Payment')
        ->assertSee('Pay now');
}

function storefrontCheckoutFillSuccessfulCard(mixed $page): mixed
{
    return $page
        ->fill('input[wire\\:model="cardNumber"]', '4242 4242 4242 4242')
        ->fill('input[wire\\:model="cardName"]', 'Test Buyer')
        ->fill('input[wire\\:model="cardExpiry"]', '12/28')
        ->fill('input[wire\\:model="cardCvc"]', '123');
}


output:
Chunk ID: 28dee9
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 750
Output:
<?php

use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Pest\Browser\Playwright\Playwright;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    Playwright::setHost('shop.test');

    $this->seed(DatabaseSeeder::class);
});

afterEach(function (): void {
    Playwright::setHost(null);
});

function storefrontCheckoutHost(): array
{
    return ['host' => 'shop.test'];
}

function storefrontCheckoutStart(): mixed
{
    return visit('/products/classic-cotton-t-shirt', storefrontCheckoutHost())
        ->click('M')
        ->wait(1)
        ->click('Black')
        ->wait(1)
        ->click('Add to cart')
        ->wait(1)
        ->navigate('/cart')
        ->wait(1)
        ->click('main button:has-text("Checkout")')
        ->wait(1)
        ->assertPathIs('/checkout')
        ->assertSee('Checkout');
}

/**
 * @param  array<string, string>  $overrides
 */
function storefrontCheckoutFillAddress(mixed $page, array $overrides = []): mixed
{
    $address = [
        'email' => 'test-buyer@example.com',
        'first_name' => 'Test',
        'last_name' => 'Buyer',
        'address1' => 'Teststrasse 1',
        'city' => 'Berlin',
        'postal_code' => '10115',
        'country' => 'DE',
    ];

    $address = array_replace($address, $overrides);

    return $page
        ->fill('input[wire\\:model="email"]', $address['email'])
        ->fill('input[wire\\:model="shippingAddress.first_name"]', $address['first_name'])
        ->fill('input[wire\\:model="shippingAddress.last_name"]', $address['last_name'])
        ->fill('input[wire\\:model="shippingAddress.address1"]', $address['address1'])
        ->fill('input[wire\\:model="shippingAddress.city"]', $address['city'])
        ->fill('input[wire\\:model="shippingAddress.postal_code"]', $address['postal_code'])
        ->select('select[wire\\:model="shippingAddress.country"]', $address['country']);
}

function storefrontCheckoutSubmitAddress(mixed $page): mixed
{
    return $page
        ->click('form[wire\\:submit="saveAddress"] button[type="submit"]')
        ->wait(1);
}

function storefrontCheckoutReachShipping(array $address = []): mixed
{
    return storefrontCheckoutSubmitAddress(
        storefrontCheckoutFillAddress(storefrontCheckoutStart(), $address),
    );
}

function storefrontCheckoutReachPayment(array $address = []): mixed
{
    return storefrontCheckoutReachShipping($address)
        ->click('button:has-text("Standard Shipping")')
        ->wait(1)
        ->click('button[wire\\:click="selectShippingMethod"]')
        ->wait(1)
        ->assertSee('Payment')
        ->assertSee('Pay now');
}

function storefrontCheckoutFillSuccessfulCard(mixed $page): mixed
{
    return $page
        ->fill('input[wire\\:model="cardNumber"]', '4242 4242 4242 4242')
        ->fill('input[wire\\:model="cardName"]', 'Test Buyer')
        ->fill('input[wire\\:model="cardExpiry"]', '12/28')
        ->fill('input[wire\\:model="cardCvc"]', '123');
}


status: completed

input:
*** Begin Patch
*** Update File: tests/Browser/Storefront/CheckoutTest.php
@@
         ->navigate('/cart')
         ->wait(1)
         ->click('main button:has-text("Checkout")')
         ->wait(1)
-        ->assertPathIs('/checkout')
+        ->assertPathBeginsWith('/checkout/')
         ->assertSee('Checkout');
@@
         ->click('button:has-text("Pay now")')
         ->wait(2)
-        ->assertPathBeginsWith('/checkout/confirmation')
+        ->assertPathBeginsWith('/checkout/')
+        ->assertPathMatches('#^/checkout/\\d+/confirmation$#')
@@
         ->click('main button:has-text("Checkout")')
         ->wait(1)
-        ->assertPathIs('/checkout');
+        ->assertPathBeginsWith('/checkout/');
@@
         ->click('button:has-text("Pay with PayPal")')
         ->wait(2)
-        ->assertPathBeginsWith('/checkout/confirmation')
+        ->assertPathBeginsWith('/checkout/')
+        ->assertPathMatches('#^/checkout/\\d+/confirmation$#')
@@
         ->click('button:has-text("Place order")')
         ->wait(2)
-        ->assertPathBeginsWith('/checkout/confirmation')
+        ->assertPathBeginsWith('/checkout/')
+        ->assertPathMatches('#^/checkout/\\d+/confirmation$#')
*** End Patch


stdout:
Success. Updated the following files:
M tests/Browser/Storefront/CheckoutTest.php

unified_diff:
@@ -36,3 +36,3 @@
         ->wait(1)
-        ->assertPathIs('/checkout')
+        ->assertPathBeginsWith('/checkout/')
         ->assertSee('Checkout');
@@ -122,3 +122,4 @@
         ->wait(2)
-        ->assertPathBeginsWith('/checkout/confirmation')
+        ->assertPathBeginsWith('/checkout/')
+        ->assertPathMatches('#^/checkout/\\d+/confirmation$#')
         ->assertSee('Thank you')
@@ -163,3 +164,3 @@
         ->wait(1)
-        ->assertPathIs('/checkout');
+        ->assertPathBeginsWith('/checkout/');

@@ -219,3 +220,4 @@
         ->wait(2)
-        ->assertPathBeginsWith('/checkout/confirmation')
+        ->assertPathBeginsWith('/checkout/')
+        ->assertPathMatches('#^/checkout/\\d+/confirmation$#')
         ->assertSee('Thank you')
@@ -232,3 +234,4 @@
         ->wait(2)
-        ->assertPathBeginsWith('/checkout/confirmation')
+        ->assertPathBeginsWith('/checkout/')
+        ->assertPathMatches('#^/checkout/\\d+/confirmation$#')
         ->assertSee('Thank you')


output: {"output":"Success. Updated the following files:\nM tests/Browser/Storefront/CheckoutTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


cmd: rg -n "assertPathMatches|assertUrl|assertPathBeginsWith" tests/Browser vendor/pestphp -g '*.php'


command: rg -n "assertPathMatches|assertUrl|assertPathBeginsWith" tests/Browser vendor/pestphp -g '*.php'

type: search

cmd: rg -n 'assertPathMatches|assertUrl|assertPathBeginsWith' tests/Browser vendor/pestphp -g '*.php'

query: assertPathMatches|assertUrl|assertPathBeginsWith

path: Browser

aggregated_output:
tests/Browser/Storefront/CheckoutTest.php:37:        ->assertPathBeginsWith('/checkout/')
tests/Browser/Storefront/CheckoutTest.php:123:        ->assertPathBeginsWith('/checkout/')
tests/Browser/Storefront/CheckoutTest.php:124:        ->assertPathMatches('#^/checkout/\\d+/confirmation$#')
tests/Browser/Storefront/CheckoutTest.php:165:        ->assertPathBeginsWith('/checkout/');
tests/Browser/Storefront/CheckoutTest.php:221:        ->assertPathBeginsWith('/checkout/')
tests/Browser/Storefront/CheckoutTest.php:222:        ->assertPathMatches('#^/checkout/\\d+/confirmation$#')
tests/Browser/Storefront/CheckoutTest.php:235:        ->assertPathBeginsWith('/checkout/')
tests/Browser/Storefront/CheckoutTest.php:236:        ->assertPathMatches('#^/checkout/\\d+/confirmation$#')
vendor/pestphp/pest-plugin-browser/src/Api/Concerns/MakesUrlAssertions.php:18:    public function assertUrlIs(string $url): Webpage
vendor/pestphp/pest-plugin-browser/src/Api/Concerns/MakesUrlAssertions.php:136:    public function assertPathBeginsWith(string $path): Webpage


output:
Chunk ID: 9e5bc2
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 265
Output:
tests/Browser/Storefront/CheckoutTest.php:37:        ->assertPathBeginsWith('/checkout/')
tests/Browser/Storefront/CheckoutTest.php:123:        ->assertPathBeginsWith('/checkout/')
tests/Browser/Storefront/CheckoutTest.php:124:        ->assertPathMatches('#^/checkout/\\d+/confirmation$#')
tests/Browser/Storefront/CheckoutTest.php:165:        ->assertPathBeginsWith('/checkout/');
tests/Browser/Storefront/CheckoutTest.php:221:        ->assertPathBeginsWith('/checkout/')
tests/Browser/Storefront/CheckoutTest.php:222:        ->assertPathMatches('#^/checkout/\\d+/confirmation$#')
tests/Browser/Storefront/CheckoutTest.php:235:        ->assertPathBeginsWith('/checkout/')
tests/Browser/Storefront/CheckoutTest.php:236:        ->assertPathMatches('#^/checkout/\\d+/confirmation$#')
vendor/pestphp/pest-plugin-browser/src/Api/Concerns/MakesUrlAssertions.php:18:    public function assertUrlIs(string $url): Webpage
vendor/pestphp/pest-plugin-browser/src/Api/Concerns/MakesUrlAssertions.php:136:    public function assertPathBeginsWith(string $path): Webpage


cmd: sed -n '1,180p' vendor/pestphp/pest-plugin-browser/src/Api/Concerns/MakesUrlAssertions.php


command: sed -n '1,180p' vendor/pestphp/pest-plugin-browser/src/Api/Concerns/MakesUrlAssertions.php

aggregated_output:
<?php

declare(strict_types=1);

namespace Pest\Browser\Api\Concerns;

use Pest\Browser\Api\Webpage;
use RuntimeException;

/**
 * @mixin Webpage
 */
trait MakesUrlAssertions
{
    /**
     * Assert that the current URL (without the query string) matches the given string.
     */
    public function assertUrlIs(string $url): Webpage
    {
        $pattern = str_replace('\*', '.*', preg_quote($url, '/'));

        /** @var array{ scheme: string, host: string, port?: int, path?: string} $segments */
        $segments = parse_url($this->page->url());

        $currentUrl = sprintf(
            '%s://%s%s%s',
            $segments['scheme'],
            $segments['host'],
            isset($segments['port']) ? ':'.$segments['port'] : '',
            $segments['path'] ?? ''
        );

        $currentUrl = mb_rtrim($currentUrl, '/');

        $message = "Actual URL [{$currentUrl}] does not equal expected URL [{$url}].";

        expect($currentUrl)->toMatch('/^'.$pattern.'$/u', $message);

        return $this;
    }

    /**
     * Assert that the current URL scheme matches the given scheme.
     */
    public function assertSchemeIs(string $scheme): Webpage
    {
        $pattern = str_replace('\*', '.*', preg_quote($scheme, '/'));

        $actual = parse_url($this->page->url(), PHP_URL_SCHEME) ?? '';

        $message = "Actual scheme [{$actual}] does not equal expected scheme [{$pattern}].";
        expect($actual)->toMatch('/^'.$pattern.'$/u', $message);

        return $this;
    }

    /**
     * Assert that the current URL scheme does not match the given scheme.
     */
    public function assertSchemeIsNot(string $scheme): Webpage
    {
        $actual = parse_url($this->page->url(), PHP_URL_SCHEME) ?? '';

        $message = "Scheme [{$scheme}] should not equal the actual value.";
        expect($actual)->not->toBe($scheme, $message);

        return $this;
    }

    /**
     * Assert that the current URL host matches the given host.
     */
    public function assertHostIs(string $host): Webpage
    {
        $pattern = str_replace('\*', '.*', preg_quote($host, '/'));

        $actual = parse_url($this->page->url(), PHP_URL_HOST) ?? '';

        $message = "Actual host [{$actual}] does not equal expected host [{$pattern}].";
        expect($actual)->toMatch('/^'.$pattern.'$/u', $message);

        return $this;
    }

    /**
     * Assert that the current URL host does not match the given host.
     */
    public function assertHostIsNot(string $host): Webpage
    {
        $actual = parse_url($this->page->url(), PHP_URL_HOST) ?? '';

        $message = "Host [{$host}] should not equal the actual value.";
        expect($actual)->not->toBe($host, $message);

        return $this;
    }

    /**
     * Assert that the current URL port matches the given port.
     */
    public function assertPortIs(string $port): Webpage
    {
        $pattern = str_replace('\*', '.*', preg_quote($port, '/'));

        $actual = (string) (parse_url($this->page->url(), PHP_URL_PORT) ?? '80');

        $message = "Actual port [{$actual}] does not equal expected port [{$pattern}].";

        // Perform an assertion that will always be recorded
        expect(true)->toBeTrue();

        // Check if the port matches the pattern
        if (preg_match('/^'.$pattern.'$/u', $actual) !== 1) {
            throw new \PHPUnit\Framework\ExpectationFailedException($message);
        }

        return $this;
    }

    /**
     * Assert that the current URL port does not match the given port.
     */
    public function assertPortIsNot(string $port): Webpage
    {
        $actual = (string) (parse_url($this->page->url(), PHP_URL_PORT) ?? '80');

        $message = "Port [{$port}] should not equal the actual value.";
        expect($actual)->not->toBe($port, $message);

        return $this;
    }

    /**
     * Assert that the current URL path begins with the given path.
     */
    public function assertPathBeginsWith(string $path): Webpage
    {
        /** @var non-empty-string $actualPath */
        $actualPath = parse_url($this->page->url(), PHP_URL_PATH) ?? '';

        $message = "Actual path [{$actualPath}] does not begin with expected path [{$path}].";

        assert($path !== '', 'Expected path to not be empty.');

        expect($actualPath)->toStartWith($path, $message);

        return $this;
    }

    /**
     * Assert that the current URL path matches the given route.
     *
     * @param  array<string, mixed>  $parameters
     */
    public function assertRoute(string $route, array $parameters = []): Webpage
    {
        if (function_exists('route') === false) {
            throw new RuntimeException('The [route] function is not available. Ensure you are using a framework that provides this function.');
        }

        return $this->assertPathIs(route($route, $parameters, false));
    }

    /**
     * Assert that the current URL path ends with the given path.
     */
    public function assertPathEndsWith(string $path): Webpage
    {
        $actualPath = parse_url($this->page->url(), PHP_URL_PATH) ?? '';

        $message = "Actual path [{$actualPath}] does not end with expected path [{$path}].";

        assert($path !== '', 'Expected path to not be empty.');

        expect($actualPath)->toEndWith($path, $message);

        return $this;
    }

    /**