From d42cb54c219c740e9c9e5cb75f9caec5ac328035 Mon Sep 17 00:00:00 2001 From: Lauri Timmanee Date: Thu, 6 Aug 2026 02:58:10 +0300 Subject: [PATCH 1/3] Provision Acquia trials from the CLI and offer them in dev:init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `acli trials:create`: creates a free 14-day trial through the trials service (POST /trials, polled at GET /api/trials/{id} with percent progress), resumes an in-flight trial, retries a failed one, and reports an existing one — always safe to re-run. The template catalog is hard-coded until the service exposes one. dev:init uses it so a developer with no application never dead-ends: the zero-application path offers a trial, the application picker gains a "Create a new application" choice, and --new skips straight to trial creation. After the trial completes, dev:init waits for the new application and a cloneable environment, then continues to a local site with the usual resumability. Verified live against the production trials service: created a real trial, watched provisioning, and exercised the retry and timeout paths. Co-Authored-By: Claude Fable 5 --- infection.json5 | 13 +- src/Command/CommandBase.php | 40 +- src/Command/Dev/DevInitCommand.php | 153 +++++- src/Command/Trials/TrialsCreateCommand.php | 236 +++++++++ tests/phpunit/src/Application/KernelTest.php | 2 + .../src/Commands/Dev/DevInitCommandTest.php | 470 ++++++++++++++++++ .../Trials/TrialsCreateCommandTest.php | 329 ++++++++++++ 7 files changed, 1239 insertions(+), 4 deletions(-) create mode 100644 src/Command/Trials/TrialsCreateCommand.php create mode 100644 tests/phpunit/src/Commands/Trials/TrialsCreateCommandTest.php diff --git a/infection.json5 b/infection.json5 index d557b029..f854e92f 100644 --- a/infection.json5 +++ b/infection.json5 @@ -17,7 +17,18 @@ "\\$this->logger.*", // Cache TTLs only affect expiry timing, which is not observable in a // unit test without manipulating the clock. - ".*->expiresAfter\\(.*" + ".*->expiresAfter\\(.*", + // Poll pacing (sleep intervals, backoff, wall-clock timeout + // arithmetic) is likewise not observable without manipulating the + // clock. + ".*time\\(\\) - \\$\\w+ >= .*", + ".*sleep\\(\\$delay\\).*", + ".*\\$delay = .*", + ".*getenv\\('ACLI_TRIAL_TIMEOUT'\\).*", + // Reverting this to private would break only SetupCommand's + // override dispatch, which SetupCommandTest asserts — but the + // covering tests of this base line cannot be attributed to it. + ".*protected function promptChooseApplication.*" ] }, "timeout": 30, diff --git a/src/Command/CommandBase.php b/src/Command/CommandBase.php index 650b0e35..e5942f0a 100644 --- a/src/Command/CommandBase.php +++ b/src/Command/CommandBase.php @@ -417,7 +417,7 @@ private function promptChooseSubscription( * * @throws \Acquia\Cli\Exception\AcquiaCliException */ - private function promptChooseApplication( + protected function promptChooseApplication( Client $acquiaCloudClient ): object|array|null { $applicationsResource = new Applications($acquiaCloudClient); @@ -912,7 +912,7 @@ protected function determineSiteInstance(InputInterface $input): ?SiteInstanceRe /** * @throws \Acquia\Cli\Exception\AcquiaCliException */ - private function promptChooseEnvironmentConsiderProd(Client $acquiaCloudClient, string $applicationUuid, bool $allowProduction, bool $allowNode): EnvironmentResponse + protected function promptChooseEnvironmentConsiderProd(Client $acquiaCloudClient, string $applicationUuid, bool $allowProduction, bool $allowNode): EnvironmentResponse { $environmentResource = new Environments($acquiaCloudClient); $applicationEnvironments = iterator_to_array($environmentResource->getAll($applicationUuid)); @@ -2452,6 +2452,42 @@ protected function checkAuthentication(): void } } + /** + * Poll an API until $check returns a non-null value. + * + * @param callable(): mixed $check + * @throws \Acquia\Cli\Exception\AcquiaCliException On timeout. + */ + protected function pollCloud(callable $check, string $waitingMessage, string $timeoutMessage): mixed + { + $timeout = is_numeric(getenv('ACLI_TRIAL_TIMEOUT')) ? (int) getenv('ACLI_TRIAL_TIMEOUT') : 1800; + $start = time(); + // ponytail: dumb sleep loop instead of LoopHelper — its 45-minute + // watchdog is hard-coded and untestable, and it cannot distinguish + // timeout from success. + $delay = 1; + $lastNote = null; + while (true) { + $result = $check(); + if ($result !== null) { + return $result; + } + if (time() - $start >= $timeout) { + throw new AcquiaCliException($timeoutMessage); + } + if ($lastNote === null) { + $this->io->writeln($waitingMessage); + $this->io->writeln('Press Ctrl+C to stop waiting — re-running the command resumes where you left off.'); + $lastNote = time(); + } elseif (time() - $lastNote >= 60) { + $this->io->writeln(sprintf('Still waiting (%d minute(s) elapsed)...', intdiv(time() - $start, 60))); + $lastNote = time(); + } + sleep($delay); + $delay = min($delay * 2, 30); + } + } + protected function waitForNotificationToComplete(Client $acquiaCloudClient, string $uuid, string $message, ?callable $success = null): bool { $notificationsResource = new Notifications($acquiaCloudClient); diff --git a/src/Command/Dev/DevInitCommand.php b/src/Command/Dev/DevInitCommand.php index 2b342f42..3c7838a9 100644 --- a/src/Command/Dev/DevInitCommand.php +++ b/src/Command/Dev/DevInitCommand.php @@ -7,8 +7,12 @@ use Acquia\Cli\Command\Pull\PullCommandBase; use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Helpers\SshCommandTrait; +use AcquiaCloudApi\Connector\Client; use AcquiaCloudApi\Endpoints\Account; +use AcquiaCloudApi\Endpoints\Applications; +use AcquiaCloudApi\Endpoints\Environments; use AcquiaCloudApi\Endpoints\SshKeys; +use AcquiaCloudApi\Response\ApplicationResponse; use AcquiaCloudApi\Response\EnvironmentResponse; use FilesystemIterator; use Symfony\Component\Console\Attribute\AsCommand; @@ -26,14 +30,23 @@ final class DevInitCommand extends PullCommandBase use DevStackTrait; use SshCommandTrait; + /** + * Sentinel "uuid" for the create-a-new-application choice in the + * application picker. Real application UUIDs are UUID-formatted, so this + * cannot collide. + */ + private const NEW_APPLICATION_CHOICE = 'new'; + protected function configure(): void { $this ->acceptEnvironmentId() ->addOption('dir', null, InputOption::VALUE_REQUIRED, 'The directory to clone the application into (defaults to ./)') + ->addOption('new', null, InputOption::VALUE_NONE, 'Create a new application with a free Acquia Cloud Platform trial instead of selecting an existing one') ->addUsage('myapp.dev --dir=./myapp --no-interaction') ->setHelp('This command takes you from nothing to a working local copy of an Acquia application: it authenticates with the Cloud Platform, helps you pick an application and environment, registers an SSH key if needed, clones your code, provisions a local stack with ddev, imports the database and files, and opens the site in your browser.' . "\n\nPrerequisites: git, Docker, and ddev (the command checks for these and tells you how to install anything missing)." + . "\n\nIf your account has no applications yet — or you choose Create a new application from the application list, or pass --new — this command creates one for you with a free 14-day Acquia Cloud Platform trial (see acli trials:create), waits for the new application and its environments to be provisioned, and then continues automatically." . "\n\nEvery step is skipped automatically if it is already done, so if setup fails partway you can fix the problem and re-run acli dev:init to resume where it left off." . "\n\nUse acli dev:start and acli dev:stop for the daily start/stop loop; use ddev directly for everything else (drush, logs, ssh)." . "\n\nFor non-interactive use (CI, scripts), pass the environment ID and credentials: ACLI_KEY=... ACLI_SECRET=... acli dev:init myapp.dev --no-interaction. This requires an SSH key already registered with the Cloud Platform."); @@ -44,7 +57,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->writeln(["Let's get you a local development environment.", '']); $this->checkPrerequisites(); $this->ensureAuthenticated($input, $output); - $environment = $this->determineEnvironment($input, $output); + $environment = $this->determineDevEnvironment($input, $output); $this->ensureSshKey($input, $output); $this->dir = $this->determineTargetDirectory($input, $environment); $this->ensureCode($environment, $output); @@ -120,6 +133,144 @@ private function ensureAuthenticated(InputInterface $input, OutputInterface $out $this->io->writeln('✓ Authenticated as ' . $account->get()->mail . ''); } + /** + * Like determineEnvironment(), but when the account has no applications + * at all — or --new was passed — first creates one with a free trial, + * then waits for its environments to be provisioned. + * + * @throws \Acquia\Cli\Exception\AcquiaCliException + */ + private function determineDevEnvironment(InputInterface $input, OutputInterface $output): array|string|EnvironmentResponse + { + $application = $this->maybeCreateTrialApplication($input); + if ($application === null) { + return $this->determineEnvironment($input, $output); + } + $this->waitForEnvironmentProvisioning($application); + $output->writeln(sprintf('Using Cloud Application %s', $application->name)); + return $this->promptChooseEnvironmentConsiderProd($this->cloudApiClientService->getClient(), $application->uuid, false, false); + } + + /** + * Applications cannot be created through the Cloud Platform API, only + * through an Acquia trial (see TrialsCreateCommand), so offer that to + * accounts with no applications and to anyone passing --new. + * + * @return \AcquiaCloudApi\Response\ApplicationResponse|null The newly + * created application, or null when the normal select-an-existing- + * application flow should run instead. + * @throws \Acquia\Cli\Exception\AcquiaCliException + */ + private function maybeCreateTrialApplication(InputInterface $input): ?ApplicationResponse + { + if ($input->getArgument('environmentId')) { + return null; + } + $applications = new Applications($this->cloudApiClientService->getClient()); + $existing = []; + foreach ($applications->getAll() as $application) { + $existing[] = $application->uuid; + } + if ($existing !== [] && !$input->getOption('new')) { + // The application picker offers creating a new one too. + return null; + } + if ($existing === [] && !$input->getOption('new')) { + if (!$input->isInteractive()) { + throw new AcquiaCliException('Your account has no Cloud applications yet. Create one with a free trial first: re-run with `acli dev:init --new`, run `acli trials:create`, or run `acli dev:init` interactively.'); + } + $this->io->writeln([ + "You don't have any Cloud applications yet — let's create one with a free Acquia Cloud Platform trial (14 days, no credit card required).", + 'The trial provisions a new application with Dev, Stage, and Prod environments and a ready-to-use Drupal site.', + ]); + if (!$this->io->confirm('Create a free trial now?')) { + throw new AcquiaCliException('There is nothing to set up without an application. Re-run `acli dev:init` when you are ready to create one.'); + } + } + return $this->createTrialApplication($existing); + } + + /** + * Extend the standard application picker with a create-a-new-application + * choice, so the trial path is not gated on having zero applications or + * knowing about --new. + */ + protected function promptChooseApplication(Client $acquiaCloudClient): object|array|null + { + $existing = iterator_to_array((new Applications($acquiaCloudClient))->getAll()); + $choices = $existing; + $choices[] = (object) [ + 'name' => 'Create a new application (free 14-day Acquia trial)', + 'uuid' => self::NEW_APPLICATION_CHOICE, + ]; + $application = $this->promptChooseFromObjectsOrArrays($choices, 'uuid', 'name', 'Select a Cloud Platform application:'); + if ($application->uuid !== self::NEW_APPLICATION_CHOICE) { + return $application; + } + $application = $this->createTrialApplication(array_map(static fn (object $app): string => $app->uuid, $existing)); + // The caller goes straight to environment selection, so the new + // application's environments must exist by the time we return. + $this->waitForEnvironmentProvisioning($application); + return $application; + } + + /** + * Create a trial via `acli trials:create`, then watch the Cloud API for + * an application that was not there before. + * + * @param string[] $existingUuids + * @throws \Acquia\Cli\Exception\AcquiaCliException + */ + private function createTrialApplication(array $existingUuids): ApplicationResponse + { + // Run the sub-command non-interactively so the trial is created with + // sensible defaults; `acli trials:create` can be run directly to + // choose the site name, template, or region. + $trialsInput = new ArrayInput(['command' => 'trials:create']); + $trialsInput->setInteractive(false); + $exitCode = $this->getApplication()->find('trials:create')->run($trialsInput, $this->output); + if ($exitCode !== Command::SUCCESS) { + throw new AcquiaCliException('Trial creation failed.'); + } + $applications = new Applications($this->cloudApiClientService->getClient()); + return $this->pollCloud( + function () use ($applications, $existingUuids): ?ApplicationResponse { + foreach ($applications->getAll() as $application) { + if (!in_array($application->uuid, $existingUuids, true)) { + $this->io->writeln("✓ Found your new application $application->name"); + return $application; + } + } + return null; + }, + 'Waiting for your new application to appear in the Cloud API.', + 'The trial exists but its application has not appeared yet. Re-run `acli dev:init` in a minute to continue where you left off.' + ); + } + + /** + * A fresh trial application's environments can take a few minutes to + * provision; wait until one is cloneable before continuing. + * + * @throws \Acquia\Cli\Exception\AcquiaCliException + */ + private function waitForEnvironmentProvisioning(ApplicationResponse $application): void + { + $environments = new Environments($this->cloudApiClientService->getClient()); + $this->pollCloud( + static function () use ($environments, $application): ?object { + foreach ($environments->getAll($application->uuid) as $environment) { + if (!$environment->flags->production && !empty($environment->vcs->url)) { + return $environment; + } + } + return null; + }, + 'Your environments are still being provisioned — this can take a few minutes. Checking every few seconds.', + 'Your application exists, but its environments are still being provisioned. Re-run `acli dev:init` in a few minutes to continue where you left off.' + ); + } + /** * Ensure at least one local SSH key is registered with the Cloud Platform * so that git cloning and file syncing work. diff --git a/src/Command/Trials/TrialsCreateCommand.php b/src/Command/Trials/TrialsCreateCommand.php new file mode 100644 index 00000000..7cd3fb72 --- /dev/null +++ b/src/Command/Trials/TrialsCreateCommand.php @@ -0,0 +1,236 @@ +localMachineHelper, $this->datastoreCloud, $this->datastoreAcli, $this->cloudCredentials, $this->telemetryHelper, $this->projectDir, $this->cloudApiClientService, $this->sshHelper, $this->sshDir, $logger, $this->selfUpdateManager); + } + + protected function configure(): void + { + $this + ->addOption('site-name', null, InputOption::VALUE_REQUIRED, 'A name for the trial site (defaults to "' . self::DEFAULT_SITE_NAME . '")') + ->addOption('template', null, InputOption::VALUE_REQUIRED, 'The site template to install: ' . implode(', ', self::TEMPLATES), self::DEFAULT_TEMPLATE) + ->addOption('region', null, InputOption::VALUE_REQUIRED, 'The cloud region to provision in', self::DEFAULT_REGION) + ->setHelp('Creates a free 14-day Acquia Cloud Platform trial: a new subscription with an application, Dev/Stage/Prod environments, and a ready-to-use Drupal site built from the chosen template.' + . "\n\nIf your account already has a trial, this command reports it, resumes waiting for it to finish provisioning, or retries it if it failed — so it is always safe to re-run." + . "\n\nProvisioning takes a few minutes; the command waits (up to 30 minutes, or ACLI_TRIAL_TIMEOUT seconds) and reports the site URL when ready."); + } + + /** + * @throws \Acquia\Cli\Exception\AcquiaCliException + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $template = $input->getOption('template'); + if (!in_array($template, self::TEMPLATES, true)) { + throw new AcquiaCliException('Unknown template {template}. Available templates: {templates}', [ + 'template' => $template, + 'templates' => implode(', ', self::TEMPLATES), + ]); + } + $trial = $this->trialsRequest('GET', '/api/trials'); + if ($trial === null) { + $siteName = $input->getOption('site-name'); + if ($siteName === null) { + // In non-interactive mode ask() returns the default. + $siteName = $this->io->ask('What should the trial site be called?', self::DEFAULT_SITE_NAME); + } + $this->io->writeln("Creating a free 14-day Acquia Cloud Platform trial site named $siteName ($template, {$input->getOption('region')})..."); + $trial = $this->trialsRequest('POST', '/trials', [ + 'region' => $input->getOption('region'), + 'site_name' => $siteName, + 'site_template_id' => $template, + ]); + } elseif ($trial->status === 'COMPLETED') { + $this->io->writeln('Your account already has a trial.'); + $this->printTrial($trial); + return Command::SUCCESS; + } elseif ($this->trialFailed($trial)) { + $this->io->writeln('Your trial failed to provision (' . ($trial->failure_reason ?? $trial->status) . ') — retrying it.'); + $this->trialsRequest('POST', '/api/trials/' . $trial->trial_id); + } else { + $this->io->writeln('Your trial is already being provisioned — waiting for it to finish.'); + } + $lastPercent = null; + $trial = $this->pollCloud( + function () use ($trial, &$lastPercent): ?object { + $current = $this->trialsRequest('GET', '/api/trials/' . $trial->trial_id); + if ($current === null) { + return null; + } + $percent = $current->percent_complete ?? null; + if ($percent !== null && $percent !== $lastPercent) { + $this->io->writeln(" $percent% complete ($current->status)"); + $lastPercent = $percent; + } + if (!$this->trialFailed($current) && $current->status !== 'COMPLETED') { + return null; + } + return $current; + }, + 'Provisioning your trial site — this usually takes a few minutes.', + 'The trial is still being provisioned. Re-run `acli trials:create` to keep waiting for it.' + ); + if ($this->trialFailed($trial)) { + throw new AcquiaCliException('Trial provisioning failed: {reason}. Re-run `acli trials:create` to retry it.', [ + 'reason' => $trial->failure_reason ?? $trial->status, + ]); + } + $this->io->success('Your trial site is ready.'); + $this->printTrial($trial); + + return Command::SUCCESS; + } + + private function trialFailed(object $trial): bool + { + return str_contains($trial->status, 'FAILED'); + } + + private function printTrial(object $trial): void + { + $this->io->writeln([ + 'Site: ' . ($trial->site_url ?? '(not yet available)'), + 'Site name: ' . $trial->site_name, + 'Subscription: ' . ($trial->subscription_id ?? '(pending)'), + ]); + } + + /** + * Call the trials service. Mind the inconsistent path prefixes: trials + * are created with POST /trials and retried with POST /api/trials/{id}, + * while reads all live under /api/trials. + * + * @param array|null $body + * @return object|null The decoded response, or null for a GET 404 (no + * such trial). + * @throws \Acquia\Cli\Exception\AcquiaCliException + */ + private function trialsRequest(string $method, string $path, ?array $body = null): ?object + { + $url = (getenv('ACLI_TRIALS_SERVICE_URL') ?: self::TRIALS_SERVICE_URL) . $path; + $options = [ + 'headers' => ['Authorization' => 'Bearer ' . $this->trialsToken()], + 'http_errors' => false, + ]; + if ($body !== null) { + $options['json'] = $body; + } + $response = $this->httpClient->request($method, $url, $options); + if ($response->getStatusCode() === 401) { + // The token expired mid-wait: fetch a fresh one and retry once. + $this->trialsToken = null; + $options['headers']['Authorization'] = 'Bearer ' . $this->trialsToken(); + $response = $this->httpClient->request($method, $url, $options); + } + if ($response->getStatusCode() === 404 && $method === 'GET') { + return null; + } + $data = json_decode((string) $response->getBody()); + if ($response->getStatusCode() >= 400) { + throw new AcquiaCliException('Trials service error (HTTP {code}): {message}', [ + 'code' => $response->getStatusCode(), + 'message' => $data->message ?? $data->error ?? (string) $response->getBody(), + ]); + } + return is_object($data) ? $data : null; + } + + /** + * The trials service accepts the same OAuth client_credentials tokens as + * the Cloud Platform API. + * + * @throws \Acquia\Cli\Exception\AcquiaCliException + */ + private function trialsToken(): string + { + if ($this->trialsToken === null) { + $response = $this->httpClient->request('POST', ConnectorInterface::URL_ACCESS_TOKEN, [ + 'form_params' => [ + 'client_id' => $this->cloudCredentials->getCloudKey(), + 'client_secret' => $this->cloudCredentials->getCloudSecret(), + 'grant_type' => 'client_credentials', + ], + 'http_errors' => false, + ]); + $data = json_decode((string) $response->getBody()); + if (!isset($data->access_token)) { + throw new AcquiaCliException('Could not authenticate with the trials service. Check your Cloud Platform credentials (`acli auth:login`).'); + } + $this->trialsToken = $data->access_token; + } + return $this->trialsToken; + } +} diff --git a/tests/phpunit/src/Application/KernelTest.php b/tests/phpunit/src/Application/KernelTest.php index 9ec6eded..0b819d80 100644 --- a/tests/phpunit/src/Application/KernelTest.php +++ b/tests/phpunit/src/Application/KernelTest.php @@ -116,6 +116,8 @@ private function getEnd(): string ssh-key:info Print information about an SSH key ssh-key:list List your local and remote SSH keys ssh-key:upload Upload a local SSH key to the Cloud Platform + trials + trials:create [trial:create] Create a free Acquia Cloud Platform trial site EOD; } diff --git a/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php b/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php index ec59a966..d68a5ae6 100644 --- a/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php +++ b/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php @@ -6,11 +6,15 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Dev\DevInitCommand; +use Acquia\Cli\Command\Trials\TrialsCreateCommand; use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\Commands\Ide\IdeHelper; use Acquia\Cli\Tests\Commands\Pull\PullCommandTestBase; +use AcquiaCloudApi\Connector\ConnectorInterface; use ArrayIterator; use GuzzleHttp\Client; +use GuzzleHttp\Psr7\Response; +use PHPUnit\Framework\Attributes\Group; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ResponseInterface; @@ -320,6 +324,472 @@ public function testDevInitFreshNonInteractive(): void $this->assertStringContainsString($environment->application->uuid, file_get_contents(Path::join($dir, '.acquia-cli.yml'))); } + private static string $trialsUrl = 'https://trials-service-prod.prod.mesh.cicd.acquia.io'; + + /** + * Register the real trials:create command in the test application so the + * setup command can run it as a sub-command, sharing the same mocked + * HTTP client and Cloud API client. + */ + private function registerTrialsCreateCommand(): void + { + $this->application->add(new TrialsCreateCommand( + $this->localMachineHelper, + $this->datastoreCloud, + $this->datastoreAcli, + $this->cloudCredentials, + $this->telemetryHelper, + $this->acliRepoRoot, + $this->clientServiceProphecy->reveal(), + $this->sshHelper, + $this->sshDir, + $this->logger, + $this->selfUpdateManager, + $this->httpClientProphecy->reveal() + )); + } + + /** + * Mock the trials service: no existing trial, creation succeeds, and the + * trial completes on the first status poll. + */ + private function mockTrialsService(): void + { + $this->httpClientProphecy->request('POST', ConnectorInterface::URL_ACCESS_TOKEN, Argument::any()) + ->willReturn(new Response(200, [], json_encode(['access_token' => 'trials-token']))); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials', Argument::any()) + ->willReturn(new Response(404, [], '')); + $trial = json_encode([ + 'failure_reason' => null, + 'percent_complete' => 100, + 'site_name' => 'My trial site', + 'site_url' => 'https://abc123.acquia-sites.com', + 'status' => 'COMPLETED', + 'subscription_id' => '9d5b0730-5898-45e9-8683-ef50dfc3d119', + 'trial_id' => 'test-trial-id', + ]); + $this->httpClientProphecy->request('POST', self::$trialsUrl . '/trials', Argument::any()) + ->willReturn(new Response(200, [], $trial)) + ->shouldBeCalled(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials/test-trial-id', Argument::any()) + ->willReturn(new Response(200, [], $trial)); + } + + /** + * With zero applications, non-interactive setup must not create a trial + * implicitly: it fails with precise instructions instead. + */ + public function testDevInitZeroApplicationsNonInteractive(): void + { + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockPrerequisitesFound($localMachineHelper); + $this->mockRequest('getAccount'); + $this->clientProphecy->request('get', '/applications') + ->willReturn([]) + ->shouldBeCalled(); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Your account has no Cloud applications yet. Create one with a free trial first: re-run with `acli dev:init --new`, run `acli trials:create`, or run `acli dev:init` interactively.'); + $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL, false); + } + + /** + * Declining the trial offer ends setup with a clear message instead of a + * dead end. + */ + public function testDevInitZeroApplicationsTrialDeclined(): void + { + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockPrerequisitesFound($localMachineHelper); + $this->mockRequest('getAccount'); + $this->clientProphecy->request('get', '/applications') + ->willReturn([]) + ->shouldBeCalled(); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('There is nothing to set up without an application. Re-run `acli dev:init` when you are ready to create one.'); + $this->executeCommand([], [ + // Create a free trial now? + 'n', + ], OutputInterface::VERBOSITY_NORMAL); + } + + /** + * Without --new, an account that has applications must go down the + * normal select-an-existing-application path, not the trial path. + */ + public function testDevInitExistingApplicationsSkipTrialFlow(): void + { + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockPrerequisitesFound($localMachineHelper); + $this->mockRequest('getAccount'); + $applicationsResponse = self::getMockResponseFromSpec('/applications', 'get', '200'); + $this->clientProphecy->request('get', '/applications') + ->willReturn($applicationsResponse->_embedded->items) + ->shouldBeCalled(); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Could not determine Cloud Application'); + $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL, false); + } + + /** + * The help text documents the trial flow, in reading order. + */ + public function testDevInitHelpDocumentsTrialFlow(): void + { + $help = $this->command->getHelp(); + $lastPosition = -1; + foreach ( + [ + 'takes you from nothing', + 'Prerequisites: git, Docker, and ddev', + 'If your account has no applications yet', + 'Every step is skipped automatically', + 'For non-interactive use', + ] as $paragraph + ) { + $position = strpos($help, $paragraph); + $this->assertIsInt($position, "Help text mentions: $paragraph"); + $this->assertGreaterThan($lastPosition, $position, "Help text paragraph out of order: $paragraph"); + $lastPosition = $position; + } + } + + /** + * With zero applications, setup creates a trial via trials:create, polls + * until the new application appears, waits for a provisioned + * environment, and continues to a working local site. + */ + public function testDevInitCreatesTrialWhenNoApplications(): void + { + $dir = Path::join($this->projectDir, 'site'); + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockPrerequisitesFound($localMachineHelper); + $this->mockRequest('getAccount'); + $this->registerTrialsCreateCommand(); + $this->mockTrialsService(); + + // First call: the zero-applications check. Second call: the first + // poll after the trial completes finds the new application. + $applicationsResponse = self::getMockResponseFromSpec('/applications', 'get', '200'); + $application = $applicationsResponse->_embedded->items[0]; + $this->clientProphecy->request('get', '/applications') + ->willReturn([], [$application]) + ->shouldBeCalled(); + $localMachineHelper->isBrowserAvailable()->willReturn(false); + $localMachineHelper->startBrowser(Argument::any())->shouldNotBeCalled(); + // The provisioning wait first sees only a production environment (not + // cloneable), then Dev appears; the environment prompt then gets the + // full list including the node environment, which it must filter out. + $environmentsResponse = self::getMockResponseFromSpec('/applications/{applicationUuid}/environments', 'get', '200'); + $environments = $environmentsResponse->_embedded->items; + $this->clientProphecy->request('get', "/applications/$application->uuid/environments") + ->willReturn([$environments[1]], [$environments[0], $environments[1]], $environments) + ->shouldBeCalled(); + $environment = $environments[0]; + + $sshKeys = $this->mockRequest('getAccountSshKeys'); + $this->mockLocalSshKey($localMachineHelper, $sshKeys[0]->public_key); + $this->mockGetFilesystem($localMachineHelper); + + // Clone. + $localMachineHelper->checkRequiredBinariesExist(['git']) + ->shouldBeCalled(); + $process = $this->mockProcess(); + $localMachineHelper->execute([ + 'git', + 'clone', + $environment->vcs->url, + $dir, + ], Argument::type('callable'), null, false, null, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=accept-new']) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + $localMachineHelper->execute([ + 'git', + 'checkout', + $environment->vcs->path, + ], Argument::type('callable'), $dir, false) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + + $this->mockDdev($localMachineHelper, $dir, false); + + // Database download and import. + $sshHelper = $this->mockSshHelper(); + $this->mockListSites($sshHelper); + $this->command->sshHelper = $sshHelper->reveal(); + $this->mockGetBackup($environment); + $dumpPath = Path::join(sys_get_temp_dir(), 'dev-my_db-my_dbdev-2012-05-15T12:00:00Z.sql.gz'); + $localMachineHelper->checkRequiredBinariesExist(['gunzip']) + ->shouldBeCalled(); + $localMachineHelper->executeFromCmd('bash -o pipefail -c "gunzip -c \"$DUMP_FILEPATH\" | ddev import-db"', Argument::type('callable'), $dir, false, null, ['DUMP_FILEPATH' => $dumpPath]) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + + // Files. + $localMachineHelper->checkRequiredBinariesExist(['rsync']) + ->shouldBeCalled(); + $localMachineHelper->execute([ + 'rsync', + '-avPhze', + 'ssh -o StrictHostKeyChecking=accept-new', + $environment->ssh_url . ':/mnt/files/site.dev/sites/default/files/', + $dir . '/docroot/sites/default/files', + ], Argument::type('callable'), null, false) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + + // Drush cache rebuild and sanitization. + $localMachineHelper->execute(['ddev', 'drush', 'cache:rebuild', '--yes'], Argument::type('callable'), $dir, false, null) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + $localMachineHelper->execute(['ddev', 'drush', 'sql:sanitize', '--yes'], Argument::type('callable'), $dir, false, null) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + + $this->executeCommand([ + '--dir' => $dir, + ], [ + // Create a free trial now? + 'y', + // Choose a Cloud Platform environment (default: Dev). + '', + // Choose a database (default: my_db). + '', + ], OutputInterface::VERBOSITY_NORMAL); + + $output = $this->getDisplay(); + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString("You don't have any Cloud applications yet", $output); + $this->assertStringContainsString('Your trial site is ready', $output); + // The sub-command must run non-interactively (site name defaulted). + $this->assertStringNotContainsString('What should the trial site be called?', $output); + $this->assertStringContainsString('Found your new application Sample application 1', $output); + $this->assertStringContainsString('environments are still being provisioned', $output); + $this->assertStringContainsString('Press Ctrl+C to stop waiting', $output); + $this->assertStringContainsString('Using Cloud Application Sample application 1', $output); + // Production and node environments are not offered for local setup. + $this->assertStringNotContainsString('Production, prod', $output); + $this->assertStringNotContainsString('Stage, test', $output); + $this->assertStringContainsString('Your local development environment is ready: https://site.ddev.site', $output); + $this->assertFileExists(Path::join($dir, '.acquia-cli.yml')); + $this->assertStringContainsString($environment->application->uuid, file_get_contents(Path::join($dir, '.acquia-cli.yml'))); + } + + /** + * --new works non-interactively (the trial is created with defaults), + * and a failed trial surfaces the trials service's reason. + */ + public function testDevInitNewOptionTrialFailure(): void + { + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockPrerequisitesFound($localMachineHelper); + $this->mockRequest('getAccount'); + $this->registerTrialsCreateCommand(); + $applicationsResponse = self::getMockResponseFromSpec('/applications', 'get', '200'); + $this->clientProphecy->request('get', '/applications') + ->willReturn([$applicationsResponse->_embedded->items[0]]) + ->shouldBeCalled(); + $this->httpClientProphecy->request('POST', ConnectorInterface::URL_ACCESS_TOKEN, Argument::any()) + ->willReturn(new Response(200, [], json_encode(['access_token' => 'trials-token']))); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials', Argument::any()) + ->willReturn(new Response(404, [], '')); + $trial = static fn (string $status, ?string $reason) => json_encode([ + 'failure_reason' => $reason, + 'percent_complete' => 14, + 'site_name' => 'My trial site', + 'site_url' => null, + 'status' => $status, + 'subscription_id' => null, + 'trial_id' => 'test-trial-id', + ]); + $this->httpClientProphecy->request('POST', self::$trialsUrl . '/trials', Argument::any()) + ->willReturn(new Response(200, [], $trial('SUBSCRIPTION_CLAIM_INITIATED', null))) + ->shouldBeCalled(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials/test-trial-id', Argument::any()) + ->willReturn(new Response(200, [], $trial('SUBSCRIPTION_CLAIM_FAILED', 'no subscription unit available'))) + ->shouldBeCalled(); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Trial provisioning failed: no subscription unit available. Re-run `acli trials:create` to retry it.'); + $this->executeCommand(['--new' => true], [], OutputInterface::VERBOSITY_NORMAL, false); + } + + /** + * --new with zero applications skips the explanation/confirmation and + * goes straight to trial creation, interactively or not. + */ + public function testDevInitNewOptionZeroApplications(): void + { + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockPrerequisitesFound($localMachineHelper); + $this->mockRequest('getAccount'); + $this->registerTrialsCreateCommand(); + $this->clientProphecy->request('get', '/applications') + ->willReturn([]) + ->shouldBeCalled(); + $this->httpClientProphecy->request('POST', ConnectorInterface::URL_ACCESS_TOKEN, Argument::any()) + ->willReturn(new Response(200, [], json_encode(['access_token' => 'trials-token']))); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials', Argument::any()) + ->willReturn(new Response(404, [], '')); + $trial = static fn (string $status, ?string $reason) => json_encode([ + 'failure_reason' => $reason, + 'percent_complete' => 14, + 'site_name' => 'My trial site', + 'site_url' => null, + 'status' => $status, + 'subscription_id' => null, + 'trial_id' => 'test-trial-id', + ]); + $this->httpClientProphecy->request('POST', self::$trialsUrl . '/trials', Argument::any()) + ->willReturn(new Response(200, [], $trial('SUBSCRIPTION_CLAIM_INITIATED', null))) + ->shouldBeCalled(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials/test-trial-id', Argument::any()) + ->willReturn(new Response(200, [], $trial('SUBSCRIPTION_CLAIM_FAILED', 'no subscription unit available'))) + ->shouldBeCalled(); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Trial provisioning failed: no subscription unit available. Re-run `acli trials:create` to retry it.'); + $this->executeCommand(['--new' => true], [], OutputInterface::VERBOSITY_NORMAL, false); + } + + /** + * A user with existing applications can still create a new one: the + * application picker offers a create-a-new-application choice that runs + * the same trial signup flow, without needing --new. + */ + public function testDevInitNewApplicationFromPicker(): void + { + $dir = Path::join($this->projectDir, 'site'); + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockPrerequisitesFound($localMachineHelper); + $this->mockRequest('getAccount'); + + $this->registerTrialsCreateCommand(); + $this->mockTrialsService(); + $applicationsResponse = self::getMockResponseFromSpec('/applications', 'get', '200'); + [$existingApplication, $newApplication] = $applicationsResponse->_embedded->items; + // First call: the zero-applications check. Second: the picker. + // Third: the poll after the trial completes finds the new application. + $this->clientProphecy->request('get', '/applications') + ->willReturn([$existingApplication], [$existingApplication], [$existingApplication, $newApplication]) + ->shouldBeCalled(); + $this->clientProphecy->request('get', "/applications/$newApplication->uuid") + ->willReturn($newApplication) + ->shouldBeCalled(); + $localMachineHelper->isBrowserAvailable()->willReturn(true); + // Provisioning wait: first only production exists, then Dev appears. + $environmentsResponse = self::getMockResponseFromSpec('/applications/{applicationUuid}/environments', 'get', '200'); + $environments = $environmentsResponse->_embedded->items; + $this->clientProphecy->request('get', "/applications/$newApplication->uuid/environments") + ->willReturn([$environments[1]], [$environments[0], $environments[1]]) + ->shouldBeCalled(); + $environment = $environments[0]; + + $sshKeys = $this->mockRequest('getAccountSshKeys'); + $this->mockLocalSshKey($localMachineHelper, $sshKeys[0]->public_key); + $this->mockGetFilesystem($localMachineHelper); + + $localMachineHelper->checkRequiredBinariesExist(['git']) + ->shouldBeCalled(); + $process = $this->mockProcess(); + $localMachineHelper->execute([ + 'git', + 'clone', + $environment->vcs->url, + $dir, + ], Argument::type('callable'), null, false, null, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=accept-new']) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + $localMachineHelper->execute([ + 'git', + 'checkout', + $environment->vcs->path, + ], Argument::type('callable'), $dir, false) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + + $this->mockDdev($localMachineHelper, $dir, false); + $localMachineHelper->startBrowser('https://site.ddev.site') + ->willReturn(true) + ->shouldBeCalled(); + + $sshHelper = $this->mockSshHelper(); + $this->mockListSites($sshHelper); + $this->command->sshHelper = $sshHelper->reveal(); + $this->mockGetBackup($environment); + $dumpPath = Path::join(sys_get_temp_dir(), 'dev-my_db-my_dbdev-2012-05-15T12:00:00Z.sql.gz'); + $localMachineHelper->checkRequiredBinariesExist(['gunzip']) + ->shouldBeCalled(); + $localMachineHelper->executeFromCmd('bash -o pipefail -c "gunzip -c \"$DUMP_FILEPATH\" | ddev import-db"', Argument::type('callable'), $dir, false, null, ['DUMP_FILEPATH' => $dumpPath]) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + $localMachineHelper->checkRequiredBinariesExist(['rsync']) + ->shouldBeCalled(); + $localMachineHelper->execute([ + 'rsync', + '-avPhze', + 'ssh -o StrictHostKeyChecking=accept-new', + $environment->ssh_url . ':/mnt/files/site.dev/sites/default/files/', + $dir . '/docroot/sites/default/files', + ], Argument::type('callable'), null, false) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + $localMachineHelper->execute(['ddev', 'drush', 'cache:rebuild', '--yes'], Argument::type('callable'), $dir, false, null) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + $localMachineHelper->execute(['ddev', 'drush', 'sql:sanitize', '--yes'], Argument::type('callable'), $dir, false, null) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + + $this->executeCommand([ + '--dir' => $dir, + ], [ + // Search for a Cloud application matching the local git config? + 'n', + // Select a Cloud Platform application (the create-new choice). + 'Create a new application (free 14-day Acquia trial)', + // Link the Cloud application to this repository? + 'n', + // Choose a Cloud Platform environment (default: Dev). + '', + // Choose a database (default: my_db). + '', + ], OutputInterface::VERBOSITY_NORMAL); + + $output = $this->getDisplay(); + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('Create a new application (free 14-day Acquia trial)', $output); + $this->assertStringContainsString('Found your new application Sample application 2', $output); + $this->assertStringContainsString('Using Cloud Application Sample application 2', $output); + $this->assertStringContainsString('Your local development environment is ready: https://site.ddev.site', $output); + } + + /** + * If the trial completes but its application never appears in the Cloud + * API, the poll must give up with clear resume instructions rather than + * hanging forever. + */ + #[Group('serial')] + public function testDevInitTrialApplicationTimeout(): void + { + putenv('ACLI_TRIAL_TIMEOUT=0'); + try { + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockPrerequisitesFound($localMachineHelper); + $this->mockRequest('getAccount'); + $this->registerTrialsCreateCommand(); + $this->mockTrialsService(); + $this->clientProphecy->request('get', '/applications') + ->willReturn([]) + ->shouldBeCalled(); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('The trial exists but its application has not appeared yet. Re-run `acli dev:init` in a minute to continue where you left off.'); + $this->executeCommand([], [ + // Create a free trial now? + 'y', + ], OutputInterface::VERBOSITY_NORMAL); + } finally { + putenv('ACLI_TRIAL_TIMEOUT'); + } + } + /** * Re-running setup on an existing checkout with an installed site skips * every completed step instead of redoing it. diff --git a/tests/phpunit/src/Commands/Trials/TrialsCreateCommandTest.php b/tests/phpunit/src/Commands/Trials/TrialsCreateCommandTest.php new file mode 100644 index 00000000..b16331ad --- /dev/null +++ b/tests/phpunit/src/Commands/Trials/TrialsCreateCommandTest.php @@ -0,0 +1,329 @@ +httpClientProphecy = $this->prophet->prophesize(Client::class); + + return new TrialsCreateCommand( + $this->localMachineHelper, + $this->datastoreCloud, + $this->datastoreAcli, + $this->cloudCredentials, + $this->telemetryHelper, + $this->acliRepoRoot, + $this->clientServiceProphecy->reveal(), + $this->sshHelper, + $this->sshDir, + $this->logger, + $this->selfUpdateManager, + $this->httpClientProphecy->reveal() + ); + } + + /** + * @param array $overrides + * @return array + */ + private static function trial(array $overrides = []): array + { + return array_merge([ + 'failure_reason' => null, + 'percent_complete' => 100, + 'region' => 'us-east-1', + 'site_name' => 'My trial site', + 'site_template_id' => 'drupal_cms_starter', + 'site_url' => 'https://abc123.acquia-sites.com', + 'status' => 'COMPLETED', + 'subscription_id' => '9d5b0730-5898-45e9-8683-ef50dfc3d119', + 'trial_id' => self::$trialId, + ], $overrides); + } + + /** + * @param array|null $body + */ + private static function response(int $code, ?array $body = null): Response + { + return new Response($code, [], $body === null ? '' : json_encode($body)); + } + + private function mockTrialsToken(): void + { + $this->httpClientProphecy->request('POST', ConnectorInterface::URL_ACCESS_TOKEN, Argument::any()) + ->willReturn(self::response(200, ['access_token' => 'trials-token'])); + } + + public function testCreateTrial(): void + { + $this->mockTrialsToken(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials', Argument::any()) + ->willReturn(self::response(404, ['error' => 'The resource you are trying to access does not exist, or you do not have access to it.'])) + ->shouldBeCalled(); + $this->httpClientProphecy->request('POST', self::$trialsUrl . '/trials', Argument::that(static function (array $options): bool { + return $options['json'] === [ + 'region' => 'us-east-1', + 'site_name' => 'mysite', + 'site_template_id' => 'drupal_cms_starter', + ] && $options['headers']['Authorization'] === 'Bearer trials-token'; + })) + ->willReturn(self::response(200, self::trial([ + 'percent_complete' => 14, + 'site_url' => null, + 'status' => 'SUBSCRIPTION_CLAIM_INITIATED', + 'subscription_id' => '', + ]))) + ->shouldBeCalled(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials/' . self::$trialId, Argument::any()) + ->willReturn( + self::response(200, self::trial([ + 'percent_complete' => 50, + 'site_url' => null, + 'status' => 'SITE_CREATION_INITIATED', + ])), + self::response(200, self::trial([ + 'percent_complete' => 50, + 'site_url' => null, + 'status' => 'SITE_CREATION_INITIATED', + ])), + self::response(200, self::trial(['site_name' => 'mysite'])) + ) + ->shouldBeCalled(); + + $this->executeCommand(['--site-name' => 'mysite'], [], OutputInterface::VERBOSITY_NORMAL, false); + + $output = $this->getDisplay(); + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('Creating a free 14-day Acquia Cloud Platform trial site named mysite', $output); + // Progress is printed only when the percentage changes. + $this->assertSame(1, substr_count($output, '50% complete (SITE_CREATION_INITIATED)')); + $this->assertStringContainsString('Your trial site is ready', $output); + $this->assertStringContainsString('Site: https://abc123.acquia-sites.com', $output); + $this->assertStringContainsString('Site name: mysite', $output); + $this->assertStringContainsString('Subscription: 9d5b0730-5898-45e9-8683-ef50dfc3d119', $output); + } + + /** + * The option descriptions and help document the template catalog and + * flow, in reading order. + */ + public function testHelpDocumentsOptions(): void + { + $definition = $this->command->getDefinition(); + $this->assertSame('A name for the trial site (defaults to "My trial site")', $definition->getOption('site-name')->getDescription()); + $this->assertSame('The site template to install: archimedes, byte, caresphere, convene, convivial_gov, drupal_cms_starter, haven, healthcare, local, provus_edu, pulse', $definition->getOption('template')->getDescription()); + $help = $this->command->getHelp(); + $lastPosition = -1; + foreach ( + [ + 'Creates a free 14-day Acquia Cloud Platform trial', + 'If your account already has a trial', + 'Provisioning takes a few minutes', + ] as $paragraph + ) { + $position = strpos($help, $paragraph); + $this->assertIsInt($position, "Help text mentions: $paragraph"); + $this->assertGreaterThan($lastPosition, $position, "Help text paragraph out of order: $paragraph"); + $lastPosition = $position; + } + } + + public function testExistingTrialReported(): void + { + $this->mockTrialsToken(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials', Argument::any()) + ->willReturn(self::response(200, self::trial())) + ->shouldBeCalled(); + + $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL, false); + + $output = $this->getDisplay(); + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('Your account already has a trial.', $output); + $this->assertStringContainsString('https://abc123.acquia-sites.com', $output); + } + + public function testResumeInProgressTrial(): void + { + $this->mockTrialsToken(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials', Argument::any()) + ->willReturn(self::response(200, self::trial([ + 'percent_complete' => 14, + 'site_url' => null, + 'status' => 'SUBSCRIPTION_CLAIM_INITIATED', + ]))) + ->shouldBeCalled(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials/' . self::$trialId, Argument::any()) + ->willReturn(self::response(200, self::trial())) + ->shouldBeCalled(); + + $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL, false); + + $output = $this->getDisplay(); + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('Your trial is already being provisioned — waiting for it to finish.', $output); + $this->assertStringContainsString('Your trial site is ready', $output); + } + + public function testRetryFailedTrial(): void + { + $this->mockTrialsToken(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials', Argument::any()) + ->willReturn(self::response(200, self::trial([ + 'failure_reason' => 'step 1 failed: claim subscription: no subscription unit available', + 'site_url' => null, + 'status' => 'SUBSCRIPTION_CLAIM_FAILED', + 'subscription_id' => null, + ]))) + ->shouldBeCalled(); + $this->httpClientProphecy->request('POST', self::$trialsUrl . '/api/trials/' . self::$trialId, Argument::any()) + ->willReturn(self::response(200)) + ->shouldBeCalled(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials/' . self::$trialId, Argument::any()) + ->willReturn(self::response(200, self::trial())) + ->shouldBeCalled(); + + $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL, false); + + $output = $this->getDisplay(); + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('Your trial failed to provision (step 1 failed: claim subscription: no subscription unit available) — retrying it.', $output); + $this->assertStringContainsString('Your trial site is ready', $output); + } + + public function testTrialProvisioningFails(): void + { + $this->mockTrialsToken(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials', Argument::any()) + ->willReturn(self::response(404)) + ->shouldBeCalled(); + $this->httpClientProphecy->request('POST', self::$trialsUrl . '/trials', Argument::any()) + ->willReturn(self::response(200, self::trial([ + 'site_url' => null, + 'status' => 'SUBSCRIPTION_CLAIM_INITIATED', + ]))) + ->shouldBeCalled(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials/' . self::$trialId, Argument::any()) + ->willReturn(self::response(200, self::trial([ + 'failure_reason' => 'site creation failed', + 'site_url' => null, + 'status' => 'SITE_CREATION_FAILED', + ]))) + ->shouldBeCalled(); + + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Trial provisioning failed: site creation failed. Re-run `acli trials:create` to retry it.'); + $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL, false); + } + + public function testUnknownTemplate(): void + { + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Unknown template nope. Available templates: archimedes, byte, caresphere, convene, convivial_gov, drupal_cms_starter, haven, healthcare, local, provus_edu, pulse'); + $this->executeCommand(['--template' => 'nope'], [], OutputInterface::VERBOSITY_NORMAL, false); + } + + public function testTokenFailure(): void + { + $this->httpClientProphecy->request('POST', ConnectorInterface::URL_ACCESS_TOKEN, Argument::any()) + ->willReturn(self::response(400, ['error' => 'invalid_client'])) + ->shouldBeCalled(); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Could not authenticate with the trials service. Check your Cloud Platform credentials (`acli auth:login`).'); + $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL, false); + } + + /** + * An expired token mid-flow is refreshed and the request retried once. + */ + public function testExpiredTokenIsRefreshed(): void + { + $this->httpClientProphecy->request('POST', ConnectorInterface::URL_ACCESS_TOKEN, Argument::any()) + ->willReturn( + self::response(200, ['access_token' => 'expired-token']), + self::response(200, ['access_token' => 'fresh-token']) + ) + ->shouldBeCalled(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials', Argument::any()) + ->willReturn( + self::response(401, ['error' => 'The access token has expired.']), + self::response(200, self::trial()) + ) + ->shouldBeCalled(); + + $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL, false); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('Your account already has a trial.', $this->getDisplay()); + } + + public function testTrialsServiceError(): void + { + $this->mockTrialsToken(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials', Argument::any()) + ->willReturn(self::response(500, ['message' => 'Internal server error'])) + ->shouldBeCalled(); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Trials service error (HTTP 500): Internal server error'); + $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL, false); + } + + /** + * If provisioning outlasts the timeout, the command must give up with + * clear resume instructions. + */ + #[Group('serial')] + public function testTrialProvisioningTimeout(): void + { + putenv('ACLI_TRIAL_TIMEOUT=0'); + try { + $this->mockTrialsToken(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials', Argument::any()) + ->willReturn(self::response(404)) + ->shouldBeCalled(); + $this->httpClientProphecy->request('POST', self::$trialsUrl . '/trials', Argument::any()) + ->willReturn(self::response(200, self::trial([ + 'site_url' => null, + 'status' => 'SUBSCRIPTION_CLAIM_INITIATED', + ]))) + ->shouldBeCalled(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials/' . self::$trialId, Argument::any()) + ->willReturn(self::response(200, self::trial([ + 'site_url' => null, + 'status' => 'SUBSCRIPTION_CLAIM_INITIATED', + ]))) + ->shouldBeCalled(); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('The trial is still being provisioned. Re-run `acli trials:create` to keep waiting for it.'); + $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL, false); + } finally { + putenv('ACLI_TRIAL_TIMEOUT'); + } + } +} From 57d3c44ffad2408eac7f7b76c3d2a84e72d3ae87 Mon Sep 17 00:00:00 2001 From: Lauri Timmanee Date: Thu, 6 Aug 2026 03:25:31 +0300 Subject: [PATCH 2/3] Kill mutation survivors in the trials service plumbing Assert the token grant, Bearer headers on both sides of a 401 refresh, and http_errors in the request matchers; cover all three error-message fallback arms and the HTTP 400 boundary; and assert the dev:start help paragraph's position. Co-Authored-By: Claude Fable 5 --- .../src/Commands/Dev/DevInitCommandTest.php | 1 + .../Trials/TrialsCreateCommandTest.php | 44 ++++++++++++++++--- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php b/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php index d68a5ae6..bf925482 100644 --- a/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php +++ b/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php @@ -443,6 +443,7 @@ public function testDevInitHelpDocumentsTrialFlow(): void 'Prerequisites: git, Docker, and ddev', 'If your account has no applications yet', 'Every step is skipped automatically', + 'Use acli dev:start and acli dev:stop', 'For non-interactive use', ] as $paragraph ) { diff --git a/tests/phpunit/src/Commands/Trials/TrialsCreateCommandTest.php b/tests/phpunit/src/Commands/Trials/TrialsCreateCommandTest.php index b16331ad..89b5b759 100644 --- a/tests/phpunit/src/Commands/Trials/TrialsCreateCommandTest.php +++ b/tests/phpunit/src/Commands/Trials/TrialsCreateCommandTest.php @@ -76,7 +76,11 @@ private static function response(int $code, ?array $body = null): Response private function mockTrialsToken(): void { - $this->httpClientProphecy->request('POST', ConnectorInterface::URL_ACCESS_TOKEN, Argument::any()) + $this->httpClientProphecy->request('POST', ConnectorInterface::URL_ACCESS_TOKEN, Argument::that(static function (array $options): bool { + return ($options['form_params']['grant_type'] ?? null) === 'client_credentials' + && isset($options['form_params']['client_id'], $options['form_params']['client_secret']) + && $options['http_errors'] === false; + })) ->willReturn(self::response(200, ['access_token' => 'trials-token'])); } @@ -91,7 +95,8 @@ public function testCreateTrial(): void 'region' => 'us-east-1', 'site_name' => 'mysite', 'site_template_id' => 'drupal_cms_starter', - ] && $options['headers']['Authorization'] === 'Bearer trials-token'; + ] && $options['headers']['Authorization'] === 'Bearer trials-token' + && $options['http_errors'] === false; })) ->willReturn(self::response(200, self::trial([ 'percent_complete' => 14, @@ -270,7 +275,10 @@ public function testExpiredTokenIsRefreshed(): void self::response(200, ['access_token' => 'fresh-token']) ) ->shouldBeCalled(); - $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials', Argument::any()) + // Both the stale and the fresh token must be sent as Bearer headers. + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials', Argument::that(static function (array $options): bool { + return in_array($options['headers']['Authorization'], ['Bearer expired-token', 'Bearer fresh-token'], true); + })) ->willReturn( self::response(401, ['error' => 'The access token has expired.']), self::response(200, self::trial()) @@ -283,14 +291,40 @@ public function testExpiredTokenIsRefreshed(): void $this->assertStringContainsString('Your account already has a trial.', $this->getDisplay()); } + /** + * Service errors surface the message field when present, falling back to + * the error field and then the raw body. + */ public function testTrialsServiceError(): void { $this->mockTrialsToken(); $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials', Argument::any()) - ->willReturn(self::response(500, ['message' => 'Internal server error'])) + ->willReturn(self::response(400, ['message' => 'Bad request'])) + ->shouldBeCalled(); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Trials service error (HTTP 400): Bad request'); + $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL, false); + } + + public function testTrialsServiceErrorWithoutMessage(): void + { + $this->mockTrialsToken(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials', Argument::any()) + ->willReturn(self::response(403, ['error' => 'Forbidden'])) + ->shouldBeCalled(); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Trials service error (HTTP 403): Forbidden'); + $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL, false); + } + + public function testTrialsServiceErrorWithNonJsonBody(): void + { + $this->mockTrialsToken(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials', Argument::any()) + ->willReturn(new Response(502, [], 'Bad gateway')) ->shouldBeCalled(); $this->expectException(AcquiaCliException::class); - $this->expectExceptionMessage('Trials service error (HTTP 500): Internal server error'); + $this->expectExceptionMessage('Trials service error (HTTP 502): Bad gateway'); $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL, false); } From a2dc02fef75e224c90e3c34a3b700607d39530f5 Mon Sep 17 00:00:00 2001 From: Lauri Timmanee Date: Thu, 6 Aug 2026 03:49:25 +0300 Subject: [PATCH 3/3] Cover the error-message fallback order and ignore the equivalent body cast Co-Authored-By: Claude Fable 5 --- infection.json5 | 9 ++++++--- src/Command/Trials/TrialsCreateCommand.php | 5 +++-- .../src/Commands/Trials/TrialsCreateCommandTest.php | 11 +++++++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/infection.json5 b/infection.json5 index f854e92f..619f52fd 100644 --- a/infection.json5 +++ b/infection.json5 @@ -25,10 +25,13 @@ ".*sleep\\(\\$delay\\).*", ".*\\$delay = .*", ".*getenv\\('ACLI_TRIAL_TIMEOUT'\\).*", - // Reverting this to private would break only SetupCommand's - // override dispatch, which SetupCommandTest asserts — but the + // Reverting this to private would break only DevInitCommand's + // override dispatch, which DevInitCommandTest asserts — but the // covering tests of this base line cannot be attributed to it. - ".*protected function promptChooseApplication.*" + ".*protected function promptChooseApplication.*", + // Removing this cast is behaviorally equivalent: PSR-7 streams + // are cast back to strings wherever the value is consumed. + ".*\\(string\\) \\$response->getBody\\(\\).*" ] }, "timeout": 30, diff --git a/src/Command/Trials/TrialsCreateCommand.php b/src/Command/Trials/TrialsCreateCommand.php index 7cd3fb72..406e1184 100644 --- a/src/Command/Trials/TrialsCreateCommand.php +++ b/src/Command/Trials/TrialsCreateCommand.php @@ -198,11 +198,12 @@ private function trialsRequest(string $method, string $path, ?array $body = null if ($response->getStatusCode() === 404 && $method === 'GET') { return null; } - $data = json_decode((string) $response->getBody()); + $body = (string) $response->getBody(); + $data = json_decode($body); if ($response->getStatusCode() >= 400) { throw new AcquiaCliException('Trials service error (HTTP {code}): {message}', [ 'code' => $response->getStatusCode(), - 'message' => $data->message ?? $data->error ?? (string) $response->getBody(), + 'message' => $data->message ?? $data->error ?? $body, ]); } return is_object($data) ? $data : null; diff --git a/tests/phpunit/src/Commands/Trials/TrialsCreateCommandTest.php b/tests/phpunit/src/Commands/Trials/TrialsCreateCommandTest.php index 89b5b759..cd2e7e79 100644 --- a/tests/phpunit/src/Commands/Trials/TrialsCreateCommandTest.php +++ b/tests/phpunit/src/Commands/Trials/TrialsCreateCommandTest.php @@ -306,6 +306,17 @@ public function testTrialsServiceError(): void $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL, false); } + public function testTrialsServiceErrorPrefersMessageOverError(): void + { + $this->mockTrialsToken(); + $this->httpClientProphecy->request('GET', self::$trialsUrl . '/api/trials', Argument::any()) + ->willReturn(self::response(400, ['error' => 'error-field', 'message' => 'message-field'])) + ->shouldBeCalled(); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Trials service error (HTTP 400): message-field'); + $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL, false); + } + public function testTrialsServiceErrorWithoutMessage(): void { $this->mockTrialsToken();