2026-08-05 11:50:11 +00:00
< ? php
2026-08-06 19:29:26 +00:00
it ( 'keeps the volume backup executions table horizontally scrollable on mobile' , function () {
$view = file_get_contents ( resource_path ( 'views/livewire/project/shared/storages/volume-backups/executions.blade.php' ));
$css = file_get_contents ( resource_path ( 'css/app.css' ));
expect ( $view )
-> toContain ( 'volume-backup-executions-grid' )
-> toContain ( 'data-table w-full overflow-x-auto' )
-> and ( $css )
-> toContain ( '.volume-backup-executions-grid' )
-> toContain ( 'min-width: 50rem;' )
-> not -> toContain ( '.data-table-header.volume-backup-executions-grid' );
});
2026-08-07 19:48:01 +00:00
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 ( '<x-reicon name="upload" class="size-3.5 rotate-180" />' )
-> toContain ( 'title="Delete backup" aria-label="Delete backup"' )
-> toContain ( '<x-reicon name="trash" class="size-3.5" />' );
});
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;/' );
});
2026-08-28 07:26:57 +00:00
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 }}"' );
});
2026-08-05 13:15:50 +00:00
use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup ;
2026-08-05 11:50:11 +00:00
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 ;
2026-08-05 13:15:50 +00:00
use App\Models\ScheduledVolumeBackup ;
2026-08-05 11:50:11 +00:00
use App\Models\Server ;
2026-08-05 13:15:50 +00:00
use App\Models\Service ;
use App\Models\ServiceApplication ;
2026-08-05 11:50:11 +00:00
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 ;
2026-08-13 20:56:52 +00:00
use Illuminate\Support\Facades\Process ;
2026-08-05 11:50:11 +00:00
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' ));
$showView = file_get_contents ( resource_path ( 'views/livewire/project/shared/storages/show.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 ( 'Volume Name' )
-> toContain ( 'Source Path' )
-> toContain ( 'Destination Path' )
2026-08-05 12:07:19 +00:00
-> toContain ( 'volumes-col-backup' )
2026-08-05 11:50:11 +00:00
-> toContain ( 'supportsPreviewSuffix' )
2026-08-05 13:15:50 +00:00
-> toContain ( 'x-modal-input' )
-> not -> toContain ( 'wire:click="openBackupModal' )
2026-08-05 11:50:11 +00:00
-> toContain ( 'data-table-row' )
-> toContain ( 'volumes-mobile-label' )
2026-08-05 12:07:19 +00:00
-> not -> toContain ( 'table-badge table-badge-success' )
2026-08-05 11:50:11 +00:00
-> not -> toContain ( 'livewire:project.shared.storages.show' )
2026-08-05 12:07:19 +00:00
-> not -> toContain ( 'x-status-badge' )
2026-08-05 13:15:50 +00:00
-> not -> toContain ( 'font-mono' )
2026-08-05 12:07:19 +00:00
-> not -> toContain ( 'Service volume mounts are read-only here.' );
2026-08-05 11:50:11 +00:00
// Show remains available for isolated embeds/tests but is no longer nested from All.
expect ( $showView )
-> toContain ( 'data-table-row' )
2026-08-05 13:15:50 +00:00
-> toContain ( 'volumes-table-grid' )
-> not -> toContain ( 'font-mono' );
2026-08-05 11:50:11 +00:00
// Service stack page: one settings-section card per compose service/resource.
expect ( $storageView )
-> toContain ( 'Str::headline($resource->name)' )
-> toContain ( ':flush="true"' )
2026-08-05 12:07:19 +00:00
-> 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 " );
2026-08-05 11:50:11 +00:00
2026-08-05 13:15:50 +00:00
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 ( '<code' )
-> toMatch ( '/<x-callout[^>]*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 ( '<span>Time</span>' )
2026-08-21 10:12:56 +00:00
-> toContain ( 'x-forms.copy-button' )
2026-08-05 13:15:50 +00:00
-> toContain ( 'col-span-6' );
2026-08-05 11:50:11 +00:00
$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' );
2026-08-14 11:34:00 +00:00
expect ( $css )
-> toContain ( '12rem' )
-> not -> toContain ( '17.5rem' );
2026-08-05 12:07:19 +00:00
2026-09-05 13:58:12 +00:00
expect ( $allView )
-> toContain ( " 'table-badge', 'table-badge-success' => \$ hasS3Backup " )
-> not -> toContain ( '<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />' );
expect ( $css ) -> toMatch ( '/@media \(max-width: 768px\)[\s\S]*?\.volumes-col-backup\s*\{[^}]*flex-direction:\s*row;[^}]*align-items:\s*center;/' );
2026-08-05 11:50:11 +00:00
// Settings form labels are 13px (not Tailwind text-sm 14px).
expect ( $css )
-> toMatch ( '/\.application-settings-form label\s*\{[^}]*font-size:\s*13px/s' );
});
2026-09-05 13:58:12 +00:00
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 ( '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 (
'/<x-forms\.listbox\s+id="forms\.\{\{ \$id \}\}\.isPreviewSuffixEnabled"[\s\S]*?\/>/' ,
$view ,
$previewSuffixListbox
);
expect ( $previewSuffixListbox [ 0 ] ? ? '' )
-> toContain ( 'canGate="update"' )
-> toContain ( ':canResource="$resource"' );
preg_match_all ( '/<x-forms\.button\b[^>]*>\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 ( '<span class="volumes-col-pr flex items-center gap-1.5">' )
-> not -> toContain ( '<span class="volumes-mobile-label volumes-field-label flex items-center gap-1.5">' );
expect ( substr_count ( $view , '<x-helper helper="Adds -pr-N to the storage name or path so each preview uses isolated data. Disabling it shares production data with previews." />' ))
-> toBe ( 3 );
});
2026-08-05 13:15:50 +00:00
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"' );
});
2026-08-05 11:50:11 +00:00
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' );
2026-08-13 20:56:52 +00:00
[ $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 ();
2026-08-05 11:50:11 +00:00
});
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 () {
$showView = file_get_contents ( resource_path ( 'views/livewire/project/shared/storages/show.blade.php' ));
expect ( $showView )
-> toContain ( 'table-badge-success' )
-> toContain ( 'Volume backup is enabled' )
-> not -> toContain ( 'x-status-badge' )
-> not -> toContain ( 'status="Backup enabled"' );
// Badge label is the short "Backup" text, not the old pill-with-label that broke the input row.
expect ( preg_match ( '/table-badge-success[^>]*>\s*Backup\s*</' , $showView )) -> toBeGreaterThan ( 0 );
});
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' );
});