toContain('volume-backup-executions-grid') ->toContain('data-table w-full overflow-x-auto') ->toContain('x-forms.copy-button') ->not->toContain('volume-backup-execution-label') ->and($css) ->toContain('.volume-backup-executions-grid') ->toContain('min-width: 50rem;') ->not->toContain('.data-table-header.volume-backup-executions-grid'); }); it('uses compact icon actions for volume backup executions', function () { $view = file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/executions.blade.php')); expect($view) ->toContain('title="Download backup" aria-label="Download backup"') ->toContain('') ->toContain('title="Delete backup" aria-label="Delete backup"') ->toContain(''); }); it('keeps long volume backup errors inside the table without a separate background panel', function () { $view = file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/executions.blade.php')); expect($view) ->toContain('volume-backup-execution-message col-span-6 min-w-0 max-w-full') ->toContain('max-h-20 overflow-y-auto overflow-x-hidden') ->toContain('break-words whitespace-pre-wrap') ->toContain('bg-transparent') ->not->toContain('volume-backup-execution-message col-span-6 mt-2 max-h-32'); }); it('keeps storage backup schedule tables horizontally scrollable on mobile', function () { $applicationView = file_get_contents(resource_path('views/livewire/project/application/backup/index.blade.php')); $serviceView = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php')); $css = file_get_contents(resource_path('css/app.css')); expect($applicationView)->toContain('class="data-table w-full overflow-x-auto"') ->and($serviceView)->toContain('class="data-table w-full overflow-x-auto"') ->and($css)->toMatch('/\.backup-table-grid\s*\{[^}]*min-width:\s*50rem;/'); }); it('keeps nested storage component keys stable when mounts are added or deleted', function () { $view = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php')); expect($view) ->toContain('wire:key="volumes-{{ $resource->id }}"') ->toContain('wire:key="svc-volumes-{{ $resource->id }}"') ->not->toContain('wire:key="volumes-{{ $resource->id }}-{{ $this->volumeCount }}"') ->not->toContain('wire:key="svc-volumes-{{ $resource->id }}-{{ $this->volumeCount }}"'); }); use App\Livewire\Project\Service\Storage; use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup; use App\Livewire\Project\Shared\Storages\All; use App\Models\Application; use App\Models\Environment; use App\Models\InstanceSettings; use App\Models\LocalPersistentVolume; use App\Models\Project; use App\Models\ScheduledVolumeBackup; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Process; use Illuminate\Support\Str; use Livewire\Livewire; uses(RefreshDatabase::class); beforeEach(function () { $this->withoutVite(); InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate( ['id' => 0], ['id' => 0, 'is_dns_validation_enabled' => false] )); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); $this->actingAs($this->user); session(['currentTeam' => $this->team]); $keyId = DB::table('private_keys')->insertGetId([ 'uuid' => (string) Str::uuid(), 'name' => 'Test Key', 'private_key' => 'test-key', 'team_id' => $this->team->id, 'created_at' => now(), 'updated_at' => now(), ]); $this->server = Server::factory()->create([ 'team_id' => $this->team->id, 'private_key_id' => $keyId, 'ip' => '203.0.113.10', ]); $this->server->settings()->update([ 'is_reachable' => true, 'is_usable' => true, ]); StandaloneDocker::withoutEvents(function () { $this->destination = StandaloneDocker::firstOrCreate( ['server_id' => $this->server->id, 'network' => 'coolify'], ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] ); }); $this->project = Project::factory()->create(['team_id' => $this->team->id]); $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); }); function createApplicationWithVolume(array $applicationAttributes = [], array $volumeAttributes = []): array { $application = Application::factory()->create(array_merge([ 'uuid' => (string) Str::uuid(), 'name' => 'Storage App', 'environment_id' => test()->environment->id, 'destination_id' => test()->destination->id, 'destination_type' => test()->destination->getMorphClass(), 'build_pack' => 'nixpacks', ], $applicationAttributes)); $volume = LocalPersistentVolume::create(array_merge([ 'uuid' => (string) Str::uuid(), 'name' => $application->uuid.'-data', 'mount_path' => '/data', 'host_path' => null, 'resource_id' => $application->id, 'resource_type' => $application->getMorphClass(), 'is_preview_suffix_enabled' => true, ], $volumeAttributes)); return [$application, $volume]; } it('renders volumes as a data table with shared column headers', function () { $allView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php')); $storageView = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php')); expect($allView) ->toContain('data-table') ->toContain('data-table-header') ->toContain('volumes-table-grid') ->toContain('volumes-table-grid-readonly') ->toContain('Storage Name') ->toContain('Destination Path') ->toContain('volumes-col-backup') ->toContain('supportsPreviewSuffix') ->toContain('x-modal-input') ->not->toContain('wire:click="openBackupModal') ->toContain('data-table-row') ->toContain('volumes-mobile-label') ->not->toContain('table-badge table-badge-success') ->not->toContain('x-status-badge') ->not->toContain('font-mono') ->not->toContain('Service volume mounts are read-only here.'); // Service stack page: one settings-section card per compose service/resource. expect($storageView) ->toContain('Str::headline($resource->name)') ->toContain(':flush="true"') ->toContain("'storage-service-'.\$resource->uuid"); $serviceConfigurationView = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php')); expect($serviceConfigurationView) ->toContain('storageSections') ->toContain('menu-subitem') ->toContain('scrollToSettingsSection') ->toContain('Service volume mounts are read-only here.') ->toContain("'storage-service-'.\$resource->uuid"); expect($serviceConfigurationView)->not->toContain('Storage Backups'); expect(file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'))) ->toContain("'label' => 'Backups'") ->toContain('project.service.volume-backups.*'); expect(file_get_contents(resource_path('views/livewire/project/service/volume-backup/show.blade.php'))) ->toContain('context="service-volume"'); expect(file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/general.blade.php'))) ->not->toContain('toMatch('/]*title="File-level consistency"[\s\S]*id="stopDuringBackup"[\s\S]*<\/x-callout>/'); expect(file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/executions.blade.php'))) ->toContain('Time') ->toContain('x-forms.copy-input') ->toContain('col-span-6'); $css = file_get_contents(resource_path('css/app.css')); expect($css) ->toContain('.volumes-table-grid') ->toContain('.volumes-table-grid-with-pr') ->toContain('.volumes-table-grid-readonly') ->toContain('.volumes-mobile-label') ->toContain('font-size: 13px') // same as .application-settings-form label ->toContain('@media (max-width: 768px)') ->toContain('.table-badge-success'); expect($css) ->toContain('12rem') ->not->toContain('17.5rem'); expect($allView) ->toContain("'table-badge', 'table-badge-success' => \$hasS3Backup") ->not->toContain(''); expect($css)->toMatch('/@media \(max-width: 768px\)[\s\S]*?\.volumes-col-backup\s*\{[^}]*flex-direction:\s*row;[^}]*align-items:\s*center;/'); // Settings form labels are 13px (not Tailwind text-sm 14px). expect($css) ->toMatch('/\.application-settings-form label\s*\{[^}]*font-size:\s*13px/s'); }); it('keeps source paths out of the Docker volume creation form', function () { $view = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php')); expect($view) ->not->toContain('id="host_path"') ->not->toContain('label="Source Path"') ->not->toContain('Swarm Mode detected'); }); it('creates named Docker volumes without a source path in swarm mode', function () { [$application] = createApplicationWithVolume(); $application->persistentStorages()->delete(); Livewire::test(Storage::class, ['resource' => $application]) ->set('isSwarm', true) ->set('name', 'storage-app-data') ->set('mount_path', '/data') ->call('submitPersistentVolume') ->assertHasNoErrors(); expect($application->persistentStorages()->first()->host_path)->toBeNull(); }); it('keeps source paths out of the named volume creation form', function () { $allView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php')); expect($allView) ->not->toContain('forms.{{ $id }}.hostPath') ->and(resource_path('views/livewire/project/shared/storages/show.blade.php')) ->not->toBeFile(); }); it('renders volume actions and PR suffix controls as valid markup', function () { [$application] = createApplicationWithVolume(); LocalPersistentVolume::create([ 'uuid' => (string) Str::uuid(), 'name' => $application->uuid.'-cache', 'mount_path' => '/cache', 'resource_id' => $application->id, 'resource_type' => $application->getMorphClass(), 'is_preview_suffix_enabled' => true, ]); $html = Livewire::test(All::class, ['resource' => $application])->html(); $document = new DOMDocument; $previousState = libxml_use_internal_errors(true); $loaded = $document->loadHTML($html); libxml_clear_errors(); libxml_use_internal_errors($previousState); $xpath = new DOMXPath($document); $helperText = 'Adds -pr-N to the storage name or path so each preview uses isolated data. Disabling it shares production data with previews.'; expect($loaded)->toBeTrue() ->and($xpath->query("//*[contains(concat(' ', normalize-space(@class), ' '), ' volumes-col-actions ')]//button[normalize-space(.)='Backup']"))->toHaveCount(2) ->and($xpath->query("//*[contains(concat(' ', normalize-space(@class), ' '), ' volumes-col-actions ')]//button[normalize-space(.)='Backup']//svg"))->toHaveCount(0) ->and($xpath->query("//button[@aria-label='More information']/following-sibling::*[@role='tooltip'][contains(normalize-space(.), '{$helperText}')]"))->toHaveCount(3) ->and($xpath->query("//template[@x-teleport='body']/*[@role='listbox']"))->toHaveCount(2); }); it('keeps editable volume actions on one line', function () { $view = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php')); $css = file_get_contents(resource_path('css/app.css')); expect($view) ->toMatch('/volumes-col-actions volumes-cell-actions flex flex-nowrap items-center justify-end gap-1\.5[\s\S]*?Update[\s\S]*?Backup[\s\S]*?Delete/'); expect($css) ->toMatch('/\.volumes-table-grid\s*\{[^}]*grid-template-columns:[^;}]*15rem;/') ->toMatch('/\.volumes-table-grid-with-pr\s*\{[^}]*grid-template-columns:[^;}]*15rem;/'); }); it('declares explicit authorization on the changed storage controls', function () { $view = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php')); preg_match( '//', $view, $previewSuffixListbox ); expect($previewSuffixListbox[0] ?? '') ->toContain('canGate="update"') ->toContain(':canResource="$resource"'); preg_match_all('/]*>\s*Backup\s*<\/x-forms\.button>/s', $view, $backupButtons); expect($backupButtons[0])->toHaveCount(3); foreach ($backupButtons[0] as $backupButton) { expect($backupButton) ->toContain('canGate="update"') ->toContain(':canResource="$resource"'); } }); it('uses valid block wrappers around PR suffix helpers', function () { $view = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php')); expect($view) ->not->toContain('') ->not->toContain(''); expect(substr_count($view, '')) ->toBe(3); }); it('shows legacy bind mount source paths without an unsafe removal action', function () { $storageView = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php')); $volumesView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php')); expect($storageView) ->not->toContain('id="host_path"') ->not->toContain('Swarm Mode detected') ->and($volumesView) ->not->toContain('submitAction="clearHostPath({{ $id }})"') ->not->toContain('The next deployment will use a named Docker volume instead.'); }); it('shows legacy source paths in read-only inputs beside the destination path', function () { $view = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php')); expect(substr_count($view, '')) ->toBe(2) ->and(substr_count($view, 'class="volumes-cell-source')) ->toBe(2) ->and($view)->not->toContain('volumes-bind-details'); }); it('aligns bind and named volumes when they share the source path column', function () { [$application] = createApplicationWithVolume(volumeAttributes: ['host_path' => '/srv/storage']); LocalPersistentVolume::create([ 'name' => $application->uuid.'-cache', 'mount_path' => '/cache', 'resource_id' => $application->id, 'resource_type' => $application->getMorphClass(), ]); $document = new DOMDocument; $previousState = libxml_use_internal_errors(true); $document->loadHTML(Livewire::test(All::class, ['resource' => $application])->html()); libxml_clear_errors(); libxml_use_internal_errors($previousState); $xpath = new DOMXPath($document); expect($xpath->query("//*[contains(concat(' ', normalize-space(@class), ' '), ' data-table-row ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' volumes-cell-source ')]"))->toHaveCount(2) ->and($xpath->query("//*[contains(concat(' ', normalize-space(@class), ' '), ' volumes-cell-source ')]//span[contains(concat(' ', normalize-space(@class), ' '), ' data-table-cell-dash ')]"))->toHaveCount(1); }); it('creates named volumes without a host path in swarm mode', function () { [$application] = createApplicationWithVolume(); $application->persistentStorages()->delete(); Livewire::test(Storage::class, ['resource' => $application]) ->set('isSwarm', true) ->set('name', 'storage-app-data') ->set('mount_path', '/data') ->call('submitPersistentVolume') ->assertHasNoErrors(); expect($application->persistentStorages()->first()) ->name->toBe($application->uuid.'-storage-app-data') ->host_path->toBeNull(); }); it('uses a resource based default name for new volumes', function () { [$application] = createApplicationWithVolume(['name' => 'Storage App']); Livewire::test(Storage::class, ['resource' => $application]) ->assertSet('name', 'storage-app-data'); }); it('uses a valid fallback default volume name when the resource name has no slug characters', function () { [$application] = createApplicationWithVolume(['name' => '---']); Livewire::test(Storage::class, ['resource' => $application]) ->assertSet('name', 'volume-data'); }); it('preserves existing bind mount source paths in the volume table', function () { [$application, $volume] = createApplicationWithVolume(volumeAttributes: [ 'host_path' => '/srv/storage', ]); Livewire::test(All::class, ['resource' => $application]) ->assertDontSee('Directory mount') ->assertSee('/srv/storage') ->assertDontSee('Remove Source Path'); expect($volume->refresh()->host_path)->toBe('/srv/storage') ->and(method_exists(All::class, 'clearHostPath'))->toBeFalse(); }); it('does not show a source path or removal action for a named volume', function () { [$application] = createApplicationWithVolume(); Livewire::test(All::class, ['resource' => $application]) ->assertDontSee('Directory mount') ->assertSee('Volume mount') ->assertDontSee('Remove Source Path'); }); it('keeps bind mount source paths out of editable form state', function () { [$application, $volume] = createApplicationWithVolume(volumeAttributes: ['host_path' => '/srv/storage']); $component = Livewire::test(All::class, ['resource' => $application]); expect($component->get('forms')[$volume->id])->not->toHaveKey('hostPath'); $component ->call('submit', $volume->id) ->assertHasNoErrors(); expect($volume->refresh()->host_path)->toBe('/srv/storage'); }); it('does not offer bind mount conversion to a team member', function () { [$application, $volume] = createApplicationWithVolume(volumeAttributes: ['host_path' => '/srv/storage']); $member = User::factory()->create(); $this->team->members()->attach($member->id, ['role' => 'member']); $this->actingAs($member); session(['currentTeam' => $this->team]); Livewire::test(All::class, ['resource' => $application]) ->assertDontSee('Directory mount') ->assertSee('/srv/storage') ->assertDontSee('Remove Source Path'); expect($volume->refresh()->host_path)->toBe('/srv/storage'); }); it('does not show a bind mount source path to another team', function () { [$application, $volume] = createApplicationWithVolume(volumeAttributes: ['host_path' => '/srv/storage']); $otherTeam = Team::factory()->create(); $otherUser = User::factory()->create(); $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); $this->actingAs($otherUser); session(['currentTeam' => $otherTeam]); Livewire::test(All::class, ['resource' => $application]) ->assertForbidden(); expect($volume->refresh()->host_path)->toBe('/srv/storage'); }); it('labels bind mounts as directories in deployment configuration', function () { [$application, $volume] = createApplicationWithVolume(volumeAttributes: ['host_path' => '/srv/storage']); $storage = collect(data_get($application->deploymentConfigurationSnapshot(), 'sections.storage.items')); expect($storage->firstWhere('key', 'volume_'.$volume->id)) ->toMatchArray(['label' => 'Directory mount', 'display_value' => '/srv/storage → /data']); $volume->update(['host_path' => null]); $storage = collect(data_get($application->deploymentConfigurationSnapshot(), 'sections.storage.items')); expect($storage->firstWhere('key', 'volume_'.$volume->id)['label'])->toBe('Volume mount'); }); it('creates and exposes volume backups for service storage', function () { $service = Service::factory()->create([ 'environment_id' => $this->environment->id, 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $serviceApplication = ServiceApplication::create([ 'service_id' => $service->id, 'name' => 'buzz', ]); $volume = LocalPersistentVolume::create([ 'uuid' => (string) Str::uuid(), 'name' => 'buzz-data', 'mount_path' => '/data', 'resource_id' => $serviceApplication->id, 'resource_type' => $serviceApplication->getMorphClass(), ]); Livewire::test(CreateServiceVolumeBackup::class, [ 'service' => $service, 'selectedTargetKey' => 'volume:'.$volume->id, ])->set('frequency', 'daily') ->call('submit') ->assertHasNoErrors(); expect($volume->scheduledBackups()->first()) ->not->toBeNull() ->enabled->toBeTrue(); expect(ScheduledVolumeBackup::query()->forService($service)->count())->toBe(1); Livewire::test(All::class, ['resource' => $serviceApplication]) ->assertSet('showActionsColumn', true) ->assertSee('Backup') ->assertSeeHtml('title="Volume backup is enabled"'); }); it('shows PR deployment suffix only for git-based applications', function () { [$gitApp] = createApplicationWithVolume(['build_pack' => 'nixpacks']); Livewire::test(All::class, ['resource' => $gitApp]) ->assertSet('supportsPreviewSuffix', true) ->assertSee('Add suffix'); [$dockerImageApp] = createApplicationWithVolume([ 'build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => 'latest', ]); Livewire::test(All::class, ['resource' => $dockerImageApp]) ->assertSet('supportsPreviewSuffix', false) ->assertDontSee('Add suffix') ->assertDontSee('PR deployment suffix'); [$nonGitComposeApp] = createApplicationWithVolume([ 'build_pack' => 'dockercompose', 'git_repository' => '', 'git_branch' => '', ]); Livewire::test(All::class, ['resource' => $nonGitComposeApp]) ->assertSet('supportsPreviewSuffix', false) ->assertDontSee('Add suffix'); }); it('allows stale compose volume metadata to be deleted', function () { [$application, $volume] = createApplicationWithVolume([ 'build_pack' => 'dockercompose', 'docker_compose_raw' => <<<'YAML' services: app: image: nginx YAML, ]); Livewire::test(All::class, ['resource' => $application]) ->assertSee('Delete stale volume entry') ->call('delete', $volume->id, 'password'); expect($volume->fresh())->toBeNull(); }); it('deletes the Docker volume only when explicitly selected', function () { Process::fake(); DB::table('private_keys')->where('id', $this->server->private_key_id)->update([ 'private_key' => encrypt('test-key'), ]); [$application, $volume] = createApplicationWithVolume([ 'build_pack' => 'dockercompose', 'docker_compose_raw' => <<<'YAML' services: app: image: nginx YAML, ]); Livewire::test(All::class, ['resource' => $application]) ->assertSet('deleteDockerVolume', false) ->call('delete', $volume->id, 'password', ['deleteDockerVolume']) ->assertSet('deleteDockerVolume', true); Process::assertRan(fn () => true); expect($volume->fresh())->toBeNull(); }); it('does not allow compose volume metadata that is still declared to be deleted', function () { [$application, $volume] = createApplicationWithVolume([ 'build_pack' => 'dockercompose', 'docker_compose_raw' => <<<'YAML' services: app: image: nginx volumes: - data:/data volumes: data: YAML, ]); $volume->name = $application->uuid.'_data'; $volume->save(); Livewire::test(All::class, ['resource' => $application]) ->assertDontSee('Delete stale volume entry') ->call('delete', $volume->id, 'password') ->assertDispatched('error'); expect($volume->fresh())->not->toBeNull(); }); it('hides PR deployment suffix for databases', function () { $database = StandalonePostgresql::create([ 'uuid' => (string) Str::uuid(), 'name' => 'pg-test', 'postgres_password' => 'secret', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); LocalPersistentVolume::create([ 'uuid' => (string) Str::uuid(), 'name' => $database->uuid.'-data', 'mount_path' => '/var/lib/postgresql/data', 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), 'is_preview_suffix_enabled' => true, ]); Livewire::test(All::class, ['resource' => $database]) ->assertSet('supportsPreviewSuffix', false) ->assertDontSee('Add suffix') ->assertDontSee('PR deployment suffix'); }); it('uses a compact table badge for enabled backups instead of status-badge', function () { $allView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php')); expect($allView) ->toContain("'table-badge-success' => \$hasS3Backup") ->toContain('Volume backup is enabled') ->not->toContain('x-status-badge') ->not->toContain('status="Backup enabled"'); }); it('gates file storage PR suffix markup behind git_based applications', function () { $view = file_get_contents(resource_path('views/livewire/project/service/file-storage.blade.php')); expect($view) ->toContain('$resource->git_based()') ->toContain('PR deployment suffix'); });