diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 6195c11724f..4775fd174fa 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -342,6 +342,17 @@ 'array' => true, 'filters' => [], ], + [ + '$id' => ID::custom('onboarding'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 65536, + 'signed' => true, + 'required' => false, + 'default' => [], + 'array' => false, + 'filters' => ['json'], + ], [ '$id' => 'status', 'type' => Database::VAR_STRING, diff --git a/app/config/onboarding.php b/app/config/onboarding.php new file mode 100644 index 00000000000..626ddd91cad --- /dev/null +++ b/app/config/onboarding.php @@ -0,0 +1,30 @@ + 'create_database', + 'sdk' => 'databases.create', + ], + [ + 'id' => 'create_bucket', + 'sdk' => 'storage.createBucket', + ], + [ + 'id' => 'create_function', + 'sdk' => 'functions.create', + ], +]; + +$sdkIndex = []; +foreach ($stages as $stage) { + $sdkIndex[$stage['sdk']] = $stage['id']; +} + +return [ + 'stages' => $stages, + 'sdkIndex' => $sdkIndex, +]; diff --git a/app/config/roles.php b/app/config/roles.php index 116e8ac9325..0e9d3914183 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -95,6 +95,8 @@ 'tokens.write', 'schedules.read', 'schedules.write', + 'stages.read', + 'stages.write', ]; return [ diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 6c7f75c08eb..c195f1e9cb5 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -151,6 +151,12 @@ 'schedules.write' => [ 'description' => 'Access to create, update, and delete your project\'s schedules', ], + 'stages.read' => [ + 'description' => 'Access to read your project\'s stages', + ], + 'stages.write' => [ + 'description' => 'Access to update your project\'s stages', + ], 'migrations.read' => [ 'description' => 'Access to read your project\'s migrations', ], diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index fa96d2ae80e..6f132504058 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -800,16 +800,76 @@ ->inject('queueForWebhooks') ->inject('queueForRealtime') ->inject('dbForProject') + ->inject('dbForPlatform') ->inject('authorization') ->inject('timelimit') ->inject('eventProcessor') ->inject('bus') ->inject('apiKey') ->inject('mode') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Database $dbForPlatform, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { $responsePayload = $response->getPayload(); + /** + * Persist completed stage when the route matches a configured SDK method (stored on project as `onboarding`: stageId → row). + */ + if ($response->getStatusCode() >= 200 && $response->getStatusCode() < 300 && $project->getId() !== 'console') { + $sdkLabel = $utopia->getRoute()?->getLabel('sdk', false); + $stageId = null; + if ($sdkLabel !== false && $sdkLabel !== null) { + $sdkIndex = Config::getParam('onboarding', [])['sdkIndex'] ?? []; + foreach ($sdkLabel instanceof Method ? [$sdkLabel] : (\is_array($sdkLabel) ? $sdkLabel : []) as $sdkMethod) { + if ($sdkMethod instanceof Method && isset($sdkIndex[$k = $sdkMethod->getNamespace() . '.' . $sdkMethod->getMethodName()])) { + $stageId = $sdkIndex[$k]; + break; + } + } + } + if ($stageId !== null) { + $byStageId = $project->getAttribute('onboarding', []); + if (! \is_array($byStageId)) { + $byStageId = []; + } + $done = \is_array($byStageId[$stageId] ?? null) ? ($byStageId[$stageId]['status'] ?? '') : ''; + if ($done !== ONBOARDING_STATUS_COMPLETED && $done !== ONBOARDING_STATUS_SKIPPED) { + $actorType = ($apiKey !== null && $apiKey->getRole() === User::ROLE_APPS) + ? match ($apiKey->getType()) { + API_KEY_ACCOUNT => ACTIVITY_TYPE_KEY_ACCOUNT, + API_KEY_ORGANIZATION => ACTIVITY_TYPE_KEY_ORGANIZATION, + API_KEY_STANDARD, API_KEY_DYNAMIC => ACTIVITY_TYPE_KEY_PROJECT, + default => ACTIVITY_TYPE_KEY_PROJECT, + } + : (! $user->isEmpty() + ? ($mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER) + : ACTIVITY_TYPE_GUEST); + $byStageId[$stageId] = [ + 'status' => ONBOARDING_STATUS_COMPLETED, + 'at' => DateTime::now(), + 'actorType' => $actorType, + ]; + $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'onboarding' => $byStageId, + ]))); + + $queueForRealtime->reset(); + $queueForRealtime + ->setProject($project) + ->setSubscribers(['console']) + ->setEvent('projects.[projectId].stages.[stageId].complete') + ->setParam('projectId', $project->getId()) + ->setParam('stageId', $stageId) + ->setPayload([ + 'stageId' => $stageId, + 'status' => ONBOARDING_STATUS_COMPLETED, + 'at' => $byStageId[$stageId]['at'], + 'actorType' => $actorType, + ]) + ->trigger(); + } + } + } + if (! empty($queueForEvents->getEvent())) { if (empty($queueForEvents->getPayload())) { $queueForEvents->setPayload($responsePayload); diff --git a/app/init/configs.php b/app/init/configs.php index 35c8e3899d3..1eba1d5dcd6 100644 --- a/app/init/configs.php +++ b/app/init/configs.php @@ -26,6 +26,7 @@ Config::load('organizationScopes', __DIR__ . '/../config/scopes/organization.php', $configAdapter); Config::load('accountScopes', __DIR__ . '/../config/scopes/account.php', $configAdapter); Config::load('services', __DIR__ . '/../config/services.php', $configAdapter); // List of services +Config::load('onboarding', __DIR__ . '/../config/onboarding.php', $configAdapter); // Project onboarding stages → routes Config::load('variables', __DIR__ . '/../config/variables.php', $configAdapter); // List of env variables Config::load('regions', __DIR__ . '/../config/regions.php', $configAdapter); // List of available regions Config::load('avatar-browsers', __DIR__ . '/../config/avatars/browsers.php', $configAdapter); diff --git a/app/init/constants.php b/app/init/constants.php index 3b907572ab8..6e842a74a0d 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -163,6 +163,12 @@ const ACTIVITY_TYPE_KEY_ACCOUNT = 'keyAccount'; const ACTIVITY_TYPE_KEY_ORGANIZATION = 'keyOrganization'; +/** + * Project onboarding stage status (stored per stage id under project.onboarding JSON). + */ +const ONBOARDING_STATUS_COMPLETED = 'completed'; +const ONBOARDING_STATUS_SKIPPED = 'skipped'; + /** * MFA */ diff --git a/app/init/models.php b/app/init/models.php index dd97b036520..646ff45af92 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -126,6 +126,7 @@ use Appwrite\Utopia\Response\Model\Session; use Appwrite\Utopia\Response\Model\Site; use Appwrite\Utopia\Response\Model\Specification; +use Appwrite\Utopia\Response\Model\Stage; use Appwrite\Utopia\Response\Model\Subscriber; use Appwrite\Utopia\Response\Model\Table; use Appwrite\Utopia\Response\Model\Target; @@ -212,6 +213,7 @@ Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS)); Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE)); Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE)); +Response::setModel(new BaseList('Stages List', Response::MODEL_STAGE_LIST, 'stages', Response::MODEL_STAGE, false, false)); Response::setModel(new BaseList('Locale codes list', Response::MODEL_LOCALE_CODE_LIST, 'localeCodes', Response::MODEL_LOCALE_CODE)); Response::setModel(new BaseList('Provider list', Response::MODEL_PROVIDER_LIST, 'providers', Response::MODEL_PROVIDER)); Response::setModel(new BaseList('Message list', Response::MODEL_MESSAGE_LIST, 'messages', Response::MODEL_MESSAGE)); @@ -373,6 +375,7 @@ Response::setModel(new Specification()); Response::setModel(new Rule()); Response::setModel(new Schedule()); +Response::setModel(new Stage()); Response::setModel(new TemplateSMS()); Response::setModel(new TemplateEmail()); Response::setModel(new ConsoleVariables()); diff --git a/docs/references/projects/list-stages.md b/docs/references/projects/list-stages.md new file mode 100644 index 00000000000..3a2f6cc4014 --- /dev/null +++ b/docs/references/projects/list-stages.md @@ -0,0 +1 @@ +Get the onboarding stages for the current project, including each stage’s SDK method key and status (for example pending, completed, or skipped). diff --git a/docs/references/projects/update-stage.md b/docs/references/projects/update-stage.md new file mode 100644 index 00000000000..9e6f43919eb --- /dev/null +++ b/docs/references/projects/update-stage.md @@ -0,0 +1 @@ +Update an onboarding stage for the current project. Use this endpoint to skip a stage or leave it unchanged without performing the related API action. diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Stages/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Stages/Update.php new file mode 100644 index 00000000000..a489dd2b3f7 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Stages/Update.php @@ -0,0 +1,150 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/projects/:projectId/stages/:stageId') + ->desc('Update stage') + ->groups(['api', 'projects']) + ->label('scope', 'stages.write') + ->label('audits.event', 'stages.update') + ->label('audits.resource', 'project/{request.projectId}') + ->label('sdk', new Method( + namespace: 'projects', + group: 'stages', + name: 'updateStage', + description: '/docs/references/projects/update-stage.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_STAGE, + ) + ], + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('stageId', '', new Text(64), 'Stage ID.') + ->param('skip', true, new Boolean(), 'Mark the stage as skipped.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('apiKey') + ->inject('user') + ->inject('mode') + ->callback($this->action(...)); + } + + public function action(string $projectId, string $stageId, bool $skip, Response $response, Database $dbForPlatform, ?Key $apiKey, User $user, string $mode): void + { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $definition = $this->getStageDefinition($stageId); + + $byStageId = $project->getAttribute('onboarding', []); + if (! \is_array($byStageId)) { + $byStageId = []; + } + + $row = \is_array($byStageId[$stageId] ?? null) ? $byStageId[$stageId] : null; + + if ($skip) { + $prev = \is_array($row) ? ($row['status'] ?? '') : ''; + if ($prev !== ONBOARDING_STATUS_COMPLETED) { + $byStageId[$stageId] = [ + 'status' => ONBOARDING_STATUS_SKIPPED, + 'at' => DateTime::now(), + 'actorType' => $this->resolveActorType($apiKey, $user, $mode), + ]; + $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('onboarding', $byStageId)); + $byStageId = $project->getAttribute('onboarding', []); + $row = \is_array($byStageId[$stageId] ?? null) ? $byStageId[$stageId] : null; + } + } + + $response->dynamic(new Document($this->formatStageRow($definition, $row)), Response::MODEL_STAGE); + } + + /** + * @return array{id: string, sdk: string} + */ + private function getStageDefinition(string $stageId): array + { + foreach (Config::getParam('onboarding', [])['stages'] ?? [] as $definition) { + if (($definition['id'] ?? '') === $stageId) { + return $definition; + } + } + + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Unknown stage ID: ' . $stageId); + } + + /** + * @param array|null $row + * @return array + */ + private function formatStageRow(array $definition, ?array $row): array + { + $stageId = $definition['id']; + $status = \is_array($row) ? ($row['status'] ?? null) : null; + $at = \is_array($row) ? ($row['at'] ?? '') : ''; + $actorType = \is_array($row) ? ($row['actorType'] ?? '') : ''; + + return [ + 'id' => $stageId, + 'sdk' => $definition['sdk'] ?? '', + 'status' => $status ?? 'pending', + 'at' => $at, + 'actorType' => $actorType, + ]; + } + + private function resolveActorType(?Key $apiKey, User $user, string $mode): string + { + if ($apiKey !== null && $apiKey->getRole() === User::ROLE_APPS) { + return match ($apiKey->getType()) { + API_KEY_ACCOUNT => ACTIVITY_TYPE_KEY_ACCOUNT, + API_KEY_ORGANIZATION => ACTIVITY_TYPE_KEY_ORGANIZATION, + API_KEY_STANDARD, API_KEY_DYNAMIC => ACTIVITY_TYPE_KEY_PROJECT, + default => ACTIVITY_TYPE_KEY_PROJECT, + }; + } + + if (! $user->isEmpty()) { + return $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER; + } + + return ACTIVITY_TYPE_GUEST; + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Stages/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Stages/XList.php new file mode 100644 index 00000000000..e0f4f4074ba --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Stages/XList.php @@ -0,0 +1,97 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/projects/:projectId/stages') + ->desc('List stages') + ->groups(['api', 'projects']) + ->label('scope', 'stages.read') + ->label('sdk', new Method( + namespace: 'projects', + group: 'stages', + name: 'listStages', + description: '/docs/references/projects/list-stages.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_STAGE_LIST, + ) + ], + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->inject('response') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action(string $projectId, Response $response, Database $dbForPlatform): void + { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $response->dynamic(new Document([ + 'stages' => $this->buildStagesList($project), + ]), Response::MODEL_STAGE_LIST); + } + + /** + * @return array> + */ + private function buildStagesList(Document $project): array + { + $definitions = Config::getParam('onboarding', [])['stages'] ?? []; + $byStageId = $project->getAttribute('onboarding', []); + if (! \is_array($byStageId)) { + $byStageId = []; + } + + $out = []; + foreach ($definitions as $definition) { + $stageId = $definition['id']; + $row = $byStageId[$stageId] ?? null; + $status = \is_array($row) ? ($row['status'] ?? null) : null; + + $at = \is_array($row) ? ($row['at'] ?? '') : ''; + $actorType = \is_array($row) ? ($row['actorType'] ?? '') : ''; + + $out[] = [ + 'id' => $stageId, + 'sdk' => $definition['sdk'] ?? '', + 'status' => $status ?? 'pending', + 'at' => $at, + 'actorType' => $actorType, + ]; + } + + return $out; + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Services/Http.php b/src/Appwrite/Platform/Modules/Projects/Services/Http.php index 8275e664d51..56e8c26d730 100644 --- a/src/Appwrite/Platform/Modules/Projects/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Projects/Services/Http.php @@ -14,6 +14,8 @@ use Appwrite\Platform\Modules\Projects\Http\Schedules\Create as CreateSchedule; use Appwrite\Platform\Modules\Projects\Http\Schedules\Get as GetSchedule; use Appwrite\Platform\Modules\Projects\Http\Schedules\XList as ListSchedules; +use Appwrite\Platform\Modules\Projects\Http\Stages\Update as UpdateStages; +use Appwrite\Platform\Modules\Projects\Http\Stages\XList as ListStages; use Utopia\Platform\Service; class Http extends Service @@ -35,5 +37,8 @@ public function __construct() $this->addAction(CreateSchedule::getName(), new CreateSchedule()); $this->addAction(GetSchedule::getName(), new GetSchedule()); $this->addAction(ListSchedules::getName(), new ListSchedules()); + + $this->addAction(ListStages::getName(), new ListStages()); + $this->addAction(UpdateStages::getName(), new UpdateStages()); } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 295348c6657..c4a272f5bf3 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -247,6 +247,8 @@ class Response extends SwooleResponse // Project public const MODEL_PROJECT = 'project'; public const MODEL_PROJECT_LIST = 'projectList'; + public const MODEL_STAGE = 'stage'; + public const MODEL_STAGE_LIST = 'stageList'; public const MODEL_WEBHOOK = 'webhook'; public const MODEL_WEBHOOK_LIST = 'webhookList'; public const MODEL_KEY = 'key'; diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 1ef73aa7699..2c46d512438 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -308,6 +308,12 @@ public function __construct() 'default' => 'active', 'example' => 'active', ]) + ->addRule('onboarding', [ + 'type' => self::TYPE_JSON, + 'description' => 'Stage progress (completed or skipped) with timestamps and actor types, keyed by stage id.', + 'default' => [], + 'example' => [], + ]) ; $services = Config::getParam('services', []); diff --git a/src/Appwrite/Utopia/Response/Model/Stage.php b/src/Appwrite/Utopia/Response/Model/Stage.php new file mode 100644 index 00000000000..8adaaaae47e --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/Stage.php @@ -0,0 +1,61 @@ +addRule('id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Stage ID.', + 'default' => '', + 'example' => 'create_database', + ]) + ->addRule('sdk', [ + 'type' => self::TYPE_STRING, + 'description' => 'SDK method key (namespace.name) for this stage.', + 'default' => '', + 'example' => 'databases.create', + ]) + ->addRule('status', [ + 'type' => self::TYPE_STRING, + 'description' => 'Stage status.', + 'default' => 'pending', + 'example' => 'completed', + ]) + ->addRule('at', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'When the stage was completed or skipped, in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('actorType', [ + 'type' => self::TYPE_STRING, + 'description' => 'Actor type when the stage was recorded.', + 'default' => '', + 'example' => 'user', + ]) + ; + } + + public function getName(): string + { + return 'Stage'; + } + + public function getType(): string + { + return Response::MODEL_STAGE; + } + + public function filter(Document $document): Document + { + return $document; + } +} diff --git a/tests/e2e/Services/Project/StagesBase.php b/tests/e2e/Services/Project/StagesBase.php new file mode 100644 index 00000000000..9bf3f21f4b7 --- /dev/null +++ b/tests/e2e/Services/Project/StagesBase.php @@ -0,0 +1,179 @@ +createTeamAndProject(); + + $response = $this->listStages($projectId); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertIsArray($response['body']['stages']); + $this->assertCount(3, $response['body']['stages']); + + $ids = array_column($response['body']['stages'], 'id'); + $this->assertSame(['create_database', 'create_bucket', 'create_function'], $ids); + + foreach ($response['body']['stages'] as $stage) { + $this->assertArrayHasKey('sdk', $stage); + $this->assertArrayHasKey('status', $stage); + $this->assertArrayHasKey('at', $stage); + $this->assertArrayHasKey('actorType', $stage); + $this->assertSame('pending', $stage['status']); + } + + // Verify via GET for unknown project + $response = $this->listStages(ID::unique()); + $this->assertSame(404, $response['headers']['status-code']); + $this->assertSame(Exception::PROJECT_NOT_FOUND, $response['body']['type']); + } + + public function testListProjectStagesWithoutAuthentication(): void + { + $projectId = $this->createTeamAndProject(); + + $response = $this->listStages($projectId, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Update stage (skip) tests + // ========================================================================= + + public function testUpdateProjectStageSkip(): void + { + $projectId = $this->createTeamAndProject(); + + $response = $this->updateStage($projectId, 'create_database', true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('create_database', $response['body']['id']); + $this->assertSame('databases.create', $response['body']['sdk']); + $this->assertSame(ONBOARDING_STATUS_SKIPPED, $response['body']['status']); + $this->assertNotEmpty($response['body']['at']); + $this->assertSame(ACTIVITY_TYPE_USER, $response['body']['actorType']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($response['body']['at'])); + + $list = $this->listStages($projectId); + $this->assertSame(200, $list['headers']['status-code']); + $first = $list['body']['stages'][0]; + $this->assertSame('create_database', $first['id']); + $this->assertSame(ONBOARDING_STATUS_SKIPPED, $first['status']); + $this->assertSame(ACTIVITY_TYPE_USER, $first['actorType']); + + $response = $this->updateStage($projectId, 'unknown_stage_xyz', true); + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame(Exception::GENERAL_ARGUMENT_INVALID, $response['body']['type']); + + $response = $this->updateStage(ID::unique(), 'create_bucket', true); + $this->assertSame(404, $response['headers']['status-code']); + $this->assertSame(Exception::PROJECT_NOT_FOUND, $response['body']['type']); + } + + public function testUpdateProjectStageSkipFalseLeavesPending(): void + { + $projectId = $this->createTeamAndProject(); + + $response = $this->updateStage($projectId, 'create_bucket', false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('create_bucket', $response['body']['id']); + $this->assertSame('pending', $response['body']['status']); + } + + public function testUpdateProjectStageWithoutAuthentication(): void + { + $projectId = $this->createTeamAndProject(); + + $response = $this->updateStage($projectId, 'create_database', true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + protected function listStages(string $projectId, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/stages', $headers, []); + } + + protected function updateStage(string $projectId, string $stageId, bool $skip, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/stages/' . $stageId, $headers, [ + 'skip' => $skip, + ]); + } + + /** + * Creates a new team and child project under the console session (isolated from cached getProject()). + */ + protected function createTeamAndProject(): string + { + $teamId = ID::unique(); + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + ], $this->getHeaders()), [ + 'teamId' => $teamId, + 'name' => 'Stages E2E Team', + ]); + + $this->assertContains($team['headers']['status-code'], [201, 409]); + if ($team['headers']['status-code'] === 201) { + $teamId = $team['body']['$id']; + } + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Stages E2E Project', + 'teamId' => $teamId, + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertSame(201, $project['headers']['status-code']); + + return $project['body']['$id']; + } +} diff --git a/tests/e2e/Services/Project/StagesConsoleClientTest.php b/tests/e2e/Services/Project/StagesConsoleClientTest.php new file mode 100644 index 00000000000..cde4f4ab795 --- /dev/null +++ b/tests/e2e/Services/Project/StagesConsoleClientTest.php @@ -0,0 +1,14 @@ +