fix(v5): backfill missing canvas uuid columns

Add guarded migration for existing v5 application, cluster, and resource
connection tables missing uuid columns.

Add Vitest, jsdom, and React Testing Library coverage for optimistic
updates, canvas hooks, ingress, resource merging, viewport behavior, and
v5 browser canvas and cluster flows.
This commit is contained in:
Andras Bacsai committed 2026-07-06 23:17:32 +02:00
1 parent fbc6fd5ce5
commit a9501d5476
10 files changed
+2234 -28

No files matched your search

+164
View File
@@ -0,0 +1,164 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\User;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ApplicationDomain as V5ApplicationDomain;
use App\Models\V5\Server as V5Server;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Config::set('broadcasting.default', 'log');
InstanceSettings::create(['id' => 0, 'is_sponsorship_popup_enabled' => false]);
$this->user = User::factory()->create([
'name' => 'Root User',
'email' => 'test@example.com',
'password' => Hash::make('password'),
]);
$this->team = $this->user->teams()->firstOrFail();
$this->project = Project::create([
'name' => 'Canvas Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->firstOrFail();
});
function createCanvasV5Server(array $attributes = []): V5Server
{
return V5Server::query()->create([
'team_id' => test()->team->id,
'created_by_user_id' => test()->user->id,
'name' => 'edge-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'last_bootstrapped_at' => now(),
...$attributes,
]);
}
function createCanvasV5Application(V5Server $server, array $attributes = []): V5Application
{
return V5Application::query()->create([
'team_id' => test()->team->id,
'project_id' => test()->project->id,
'environment_id' => test()->environment->id,
'server_id' => $server->id,
'created_by_user_id' => test()->user->id,
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-'.strtolower((string) Str::ulid()),
'status' => 'running',
'mesh_namespace' => 'default',
'canvas_x' => 0,
'canvas_y' => 0,
...$attributes,
]);
}
it('renders seeded applications on the canvas', function () {
$server = createCanvasV5Server();
createCanvasV5Application($server);
$this->actingAs($this->user);
$page = visit('/v5');
$page->assertSee('nginx-test')
->assertSee('docker.io/library/nginx:alpine')
->assertSee('edge-01')
->assertSee('Configure')
->assertSee('1 apps')
->assertDontSee('No applications on this canvas yet.')
->assertNoJavaScriptErrors()
->screenshot();
});
it('shows the selected project and environment in the navbar', function () {
$this->actingAs($this->user);
$page = visit('/v5');
$page->assertSee('Canvas Project')
->assertSee('production')
->assertNoJavaScriptErrors()
->screenshot();
});
it('deploys an nginx container from the toolbar', function () {
createCanvasV5Server();
fakeSuccessfulNginxFluxDeployment();
$this->actingAs($this->user);
$page = visit('/v5');
$page->assertSee('No applications on this canvas yet.')
->click('Deploy')
->assertSee('docker.io/library/nginx:alpine')
->assertDontSee('No applications on this canvas yet.')
->assertNoJavaScriptErrors()
->screenshot();
$application = V5Application::query()->sole();
expect($application->image)->toBe('docker.io/library/nginx:alpine')
->and($application->status)->toBe('running');
});
it('disables app ingress from the inspector sheet', function () {
// Server ingress on, but not "installed", so the Caddy config sync is skipped.
$server = createCanvasV5Server(['is_ingress' => true, 'status' => 'installing']);
$application = createCanvasV5Application($server, ['ingress_enabled' => true, 'internal_port' => 8080]);
V5ApplicationDomain::query()->create([
'application_id' => $application->id,
'domain' => 'app.example.com',
]);
$this->actingAs($this->user);
$page = visit('/v5');
$page->assertSee('nginx-test')
->click('Configure')
->assertSee('App configuration')
->click('Networking')
->assertSee('Public ingress')
->click('[data-slot="sheet-content"] button:has-text("Disable")')
->assertSee('Private')
->assertNoJavaScriptErrors()
->screenshot();
expect($application->refresh()->ingress_enabled)->toBeFalse();
});
it('enables app ingress through the ingress dialog', function () {
$server = createCanvasV5Server(['is_ingress' => true, 'status' => 'installing']);
$application = createCanvasV5Application($server, ['ingress_enabled' => false]);
$this->actingAs($this->user);
$page = visit('/v5');
$page->assertSee('nginx-test')
->click('button:has-text("Enable")')
->assertSee('Enable app ingress')
->fill('[placeholder="example.com, www.example.com"]', 'app.example.com')
->fill('[placeholder="3000"]', '8080')
->click('button:has-text("Enable ingress")')
->assertDontSee('Ingress update failed')
->assertNoJavaScriptErrors()
->screenshot();
expect($application->refresh()->ingress_enabled)->toBeTrue()
->and($application->internal_port)->toBe(8080)
->and($application->domains()->pluck('domain')->all())->toBe(['app.example.com']);
});
+72
View File
@@ -0,0 +1,72 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use App\Models\V5\Cluster as V5Cluster;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function () {
Config::set('broadcasting.default', 'log');
InstanceSettings::create(['id' => 0, 'is_sponsorship_popup_enabled' => false]);
$this->user = User::factory()->create([
'name' => 'Root User',
'email' => 'test@example.com',
'password' => Hash::make('password'),
]);
$this->team = $this->user->teams()->firstOrFail();
});
it('lists existing clusters', function () {
V5Cluster::query()->create([
'team_id' => $this->team->id,
'created_by_user_id' => $this->user->id,
'name' => 'Existing Mesh',
]);
$this->actingAs($this->user);
$page = visit('/v5/clusters');
$page->assertSee('Clusters')
->assertSee('Existing Mesh')
->assertNoJavaScriptErrors()
->screenshot();
});
it('creates a cluster from the clusters page', function () {
$this->actingAs($this->user);
$page = visit('/v5/clusters');
$page->assertSee('Clusters')
->click('button[aria-label="Create cluster"]')
->assertSee('Create cluster')
->fill('[placeholder="Production Mesh"]', 'Test Mesh')
->click('button:has-text("Create cluster")')
->assertSee('Test Mesh')
->assertNoJavaScriptErrors()
->screenshot();
$cluster = V5Cluster::query()->sole();
expect($cluster->name)->toBe('Test Mesh')
->and($cluster->team_id)->toBe($this->team->id);
});
it('shows a validation error when the cluster name is missing', function () {
$this->actingAs($this->user);
$page = visit('/v5/clusters');
$page->click('button[aria-label="Create cluster"]')
->click('button:has-text("Create cluster")')
->assertSee('The name field is required.')
->screenshot();
expect(V5Cluster::query()->count())->toBe(0);
});