'array', 'session.driver' => 'array', 'queue.default' => 'sync', 'app.maintenance.driver' => 'file', ]); InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true])); // Create a team with owner $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); session(['currentTeam' => $this->team]); // Create an API token for the user $this->token = $this->user->createToken('test-token', ['*']); $this->bearerToken = $this->token->plainTextToken; // Create a server for the team $this->server = Server::factory()->create(['team_id' => $this->team->id]); }); describe('POST /api/v1/deployments/{uuid}/cancel', function () { test('returns 401 when not authenticated', function () { $response = $this->postJson('/api/v1/deployments/fake-uuid/cancel'); $response->assertStatus(401); }); test('returns 404 when deployment not found', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson('/api/v1/deployments/non-existent-uuid/cancel'); $response->assertStatus(404); $response->assertJson(['message' => 'Deployment not found.']); }); test('returns 403 when user does not own the deployment', function () { // Create another team and server $otherTeam = Team::factory()->create(); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); // Create a deployment on the other team's server $deployment = ApplicationDeploymentQueue::create([ 'deployment_uuid' => 'test-deployment-uuid', 'application_id' => 1, 'server_id' => $otherServer->id, 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, ]); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); $response->assertStatus(403); $response->assertJson(['message' => 'You do not have permission to cancel this deployment.']); }); test('returns 400 when deployment is already finished', function () { $deployment = ApplicationDeploymentQueue::create([ 'deployment_uuid' => 'finished-deployment-uuid', 'application_id' => 1, 'server_id' => $this->server->id, 'status' => ApplicationDeploymentStatus::FINISHED->value, ]); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); $response->assertStatus(400); expect($response->json('message'))->toContain('Deployment cannot be cancelled'); }); test('returns 400 when deployment is already failed', function () { $deployment = ApplicationDeploymentQueue::create([ 'deployment_uuid' => 'failed-deployment-uuid', 'application_id' => 1, 'server_id' => $this->server->id, 'status' => ApplicationDeploymentStatus::FAILED->value, ]); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); $response->assertStatus(400); expect($response->json('message'))->toContain('Deployment cannot be cancelled'); }); test('returns 400 when deployment is already cancelled', function () { $deployment = ApplicationDeploymentQueue::create([ 'deployment_uuid' => 'cancelled-deployment-uuid', 'application_id' => 1, 'server_id' => $this->server->id, 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); $response->assertStatus(400); expect($response->json('message'))->toContain('Deployment cannot be cancelled'); }); test('cancels queued deployment and updates status in database', function () { $otherTeam = Team::factory()->create(); $buildServer = Server::factory()->create(['team_id' => $otherTeam->id]); $deployment = ApplicationDeploymentQueue::create([ 'deployment_uuid' => 'queued-deployment-uuid', 'application_id' => 1, 'server_id' => $this->server->id, 'build_server_id' => $buildServer->id, 'status' => ApplicationDeploymentStatus::QUEUED->value, ]); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); $response->assertSuccessful()->assertJson([ 'message' => 'Deployment cancelled successfully.', 'deployment_uuid' => $deployment->deployment_uuid, 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); $deployment->refresh(); expect($deployment->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value); }); test('starts the next queued deployment after cancellation', function () { Queue::fake(); $otherTeam = Team::factory()->create(); $buildServer = Server::factory()->create(['team_id' => $otherTeam->id]); $destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); $project = Project::factory()->create(['team_id' => $this->team->id]); $environment = Environment::factory()->create(['project_id' => $project->id]); $application = Application::factory()->create([ 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), ]); $deployment = ApplicationDeploymentQueue::create([ 'deployment_uuid' => 'cancelled-queue-head-uuid', 'application_id' => $application->id, 'server_id' => $this->server->id, 'build_server_id' => $buildServer->id, 'destination_id' => $destination->id, 'commit' => 'first-commit', 'pull_request_id' => 0, 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, ]); $nextDeployment = ApplicationDeploymentQueue::create([ 'deployment_uuid' => 'next-queued-deployment-uuid', 'application_id' => $application->id, 'server_id' => $this->server->id, 'destination_id' => $destination->id, 'commit' => 'second-commit', 'pull_request_id' => 0, 'status' => ApplicationDeploymentStatus::QUEUED->value, ]); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); $response->assertSuccessful(); expect($nextDeployment->fresh()->status)->toBe(ApplicationDeploymentStatus::IN_PROGRESS->value); Queue::assertPushed(ApplicationDeploymentJob::class, fn (ApplicationDeploymentJob $job) => $job->application_deployment_queue_id === $nextDeployment->id); }); test('updates only a still cancellable deployment and treats cleanup as best effort', function () { Process::fake(fn () => throw new RuntimeException('SSH unavailable')); $deployment = ApplicationDeploymentQueue::create([ 'deployment_uuid' => 'atomic-cancellation-uuid', 'application_id' => 1, 'server_id' => $this->server->id, 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, ]); $updates = []; DB::listen(function ($query) use (&$updates) { if (str_starts_with(strtolower(ltrim($query->sql)), 'update')) { $updates[] = strtolower($query->sql); } }); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); $response->assertSuccessful(); expect($deployment->fresh()->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value) ->and(collect($updates)->contains( fn (string $sql) => str_contains($sql, 'application_deployment_queues') && str_contains($sql, 'status') && str_contains($sql, ' in '), ))->toBeTrue(); }); test('cancels in-progress deployment and updates status in database', function () { $deployment = ApplicationDeploymentQueue::create([ 'deployment_uuid' => 'in-progress-deployment-uuid', 'application_id' => 1, 'server_id' => $this->server->id, 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, ]); $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); // The controller updates status before SSH calls, so DB state is always correct $deployment->refresh(); expect($deployment->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value); }); });