From 5545412a465a1992d4f8521c05c42f89b11a831e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Nov=C3=A1k?= Date: Thu, 9 Jul 2026 12:40:11 +0200 Subject: [PATCH 1/4] feat: AJDA-2445 add command to migrate data-apps orchestrator/flow tasks to data-app-control Adds manage:migrate-data-apps-orchestrator-tasks, which rewrites orchestration and conditional-flow tasks pointing at the legacy keboola.data-apps component so they use keboola.data-app-control instead. Supports a single project or an entire stack via a Manage token, defaults to dry-run, and is idempotent. --- README.md | 29 +++ cli.php | 2 + .../DataAppOrchestratorTaskMigrator.php | 175 +++++++++++++++++ .../MigrateDataAppsOrchestratorTasks.php | 180 ++++++++++++++++++ 4 files changed, 386 insertions(+) create mode 100644 src/Keboola/Console/Command/DataAppOrchestratorTaskMigrator.php create mode 100644 src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php diff --git a/README.md b/README.md index 2c5d390..e748154 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,35 @@ Behavior: - With `--force`, unlinks each shared and linked bucket and confirms the action. - Prints a summary of unlinked or would-be-unlinked buckets. +### Migrate data-apps orchestrator/flow tasks to data-app-control +Migrate orchestration/flow tasks that start a data app via the legacy `keboola.data-apps` component so they use +`keboola.data-app-control` instead (see [AJDA-2445](https://linear.app/keboola/issue/AJDA-2445)). Skips tasks whose +`keboola.data-apps` parameters look unlike the "start app" shape used in practice (e.g. `create`/`delete`/`terminate`), +so those are never silently mistransformed. Safe to re-run: already-migrated tasks are skipped. + +``` +php cli.php manage:migrate-data-apps-orchestrator-tasks [-f|--force] +``` +Arguments: +- `token` (required): Manage API token. +- `url` (required): Stack URL, including `https://`. +- `projects` (required): Comma-separated project IDs (e.g. `1,7,146`), or `all` to run on every project on the stack. + +Options: +- `--force` / `-f`: Actually perform the migration. Without this flag, the command only reports what would change (dry-run). + +Behavior: +- For each target project, creates a temporary Storage API token via the Manage API and lists all configurations of + both `keboola.orchestrator` (legacy orchestrations) and `keboola.flow` (next-gen conditional flows) - these are two + separate components on stacks with conditional flows enabled, not variants of the same one. +- Scans each configuration's tasks for ones pointing at `keboola.data-apps` (both inline `configData` and `configId` + references to a saved `keboola.data-apps` configuration). +- Rewrites matching tasks to `keboola.data-app-control` with `parameters.appId` set from the resolved app ID + (`configId` references are flattened into inline `configData` rather than creating a new saved configuration). +- With `--force`, updates the configuration via the Storage API with a `changeDescription`. Without it, only reports + what would be migrated. +- Prints a summary: projects checked/disabled/errored, configurations scanned/touched, tasks migrated/skipped. + ### Mass enablement of dynamic backends for multiple projects Prerequisities: https://keboola.atlassian.net/wiki/spaces/KB/pages/2135982081/Enable+Dynamic+Backends#Enable-for-project diff --git a/cli.php b/cli.php index 030535b..c7f7a9f 100644 --- a/cli.php +++ b/cli.php @@ -17,6 +17,7 @@ use Keboola\Console\Command\MassDeleteProjectWorkspaces; use Keboola\Console\Command\MassProjectEnableDynamicBackends; use Keboola\Console\Command\MassProjectExtendExpiration; +use Keboola\Console\Command\MigrateDataAppsOrchestratorTasks; use Keboola\Console\Command\OrganizationIntoMaintenanceMode; use Keboola\Console\Command\OrganizationResetWorkspacePasswords; use Keboola\Console\Command\OrganizationsAddFeature; @@ -43,6 +44,7 @@ $application->add(new SetDataRetention()); $application->add(new MassProjectExtendExpiration()); $application->add(new MassProjectEnableDynamicBackends()); +$application->add(new MigrateDataAppsOrchestratorTasks()); $application->add(new AddFeature()); $application->add(new AllStacksIterator()); $application->add(new LineageEventsExport()); diff --git a/src/Keboola/Console/Command/DataAppOrchestratorTaskMigrator.php b/src/Keboola/Console/Command/DataAppOrchestratorTaskMigrator.php new file mode 100644 index 0000000..9f9ff92 --- /dev/null +++ b/src/Keboola/Console/Command/DataAppOrchestratorTaskMigrator.php @@ -0,0 +1,175 @@ + 0, + 'configsTouched' => 0, + 'tasksMigrated' => 0, + 'tasksSkippedUnsupported' => 0, + ]; + + foreach (self::FLOW_COMPONENT_IDS as $flowComponentId) { + $this->migrateFlowComponentConfigs($components, $output, $projectId, $flowComponentId, $isForce, $counts); + } + + return $counts; + } + + /** + * @param array{configsScanned: int, configsTouched: int, tasksMigrated: int, tasksSkippedUnsupported: int} $counts + */ + private function migrateFlowComponentConfigs( + Components $components, + OutputInterface $output, + string $projectId, + string $flowComponentId, + bool $isForce, + array &$counts + ): void { + $prefix = $isForce ? 'FORCE: ' : 'DRY-RUN: '; + + $configurations = $components->listComponentConfigurations( + (new ListComponentConfigurationsOptions()) + ->setComponentId($flowComponentId) + ->setIsDeleted(false) + ); + + foreach ($configurations as $configurationData) { + $counts['configsScanned']++; + $configurationId = (string) $configurationData['id']; + $configuration = $configurationData['configuration']; + + if (!isset($configuration['tasks']) || count($configuration['tasks']) === 0) { + continue; + } + + $tasks = $configuration['tasks']; + $configTouched = false; + + foreach ($tasks as $taskKey => $task) { + if (($task['task']['componentId'] ?? null) !== self::LEGACY_COMPONENT_ID) { + continue; + } + + $taskLabel = sprintf( + 'project "%s", %s config "%s" ("%s"), task "%s"', + $projectId, + $flowComponentId, + $configurationId, + $configurationData['name'] ?? '', + $task['name'] ?? ($task['id'] ?? $taskKey) + ); + + $appId = $this->resolveAppId($components, $task['task']); + if ($appId === null) { + $output->writeln(sprintf('%sSkipping %s: unsupported task shape', $prefix, $taskLabel)); + $counts['tasksSkippedUnsupported']++; + continue; + } + + $output->writeln(sprintf( + '%sMigrating %s: %s -> %s (appId "%s")', + $prefix, + $taskLabel, + self::LEGACY_COMPONENT_ID, + self::NEW_COMPONENT_ID, + $appId + )); + + // Keep every other field (type, mode, delay, retry, variableOverrides, ...) untouched - + // the keboola.flow schema requires "type" alongside componentId/mode, and dropping it + // silently produced an invalid, unreadable task (caught by live verification on canary-orion). + $newTask = $task['task']; + $newTask['componentId'] = self::NEW_COMPONENT_ID; + unset($newTask['configId']); + $newTask['configData'] = ['parameters' => ['appId' => $appId]]; + $tasks[$taskKey]['task'] = $newTask; + $configTouched = true; + $counts['tasksMigrated']++; + } + + if (!$configTouched) { + continue; + } + + $counts['configsTouched']++; + $configuration['tasks'] = $tasks; + + if ($isForce) { + $components->updateConfiguration( + (new Configuration()) + ->setComponentId($flowComponentId) + ->setConfigurationId($configurationId) + ->setConfiguration($configuration) + ->setChangeDescription(self::CHANGE_DESCRIPTION) + ); + } + } + } + + /** + * @param array $legacyTask + */ + private function resolveAppId(Components $components, array $legacyTask): ?string + { + $configData = $legacyTask['configData'] ?? null; + if (is_array($configData) && is_array($configData['parameters'] ?? null)) { + return $this->extractAppId($configData['parameters']); + } + + $configId = $legacyTask['configId'] ?? null; + if (is_string($configId) && $configId !== '') { + $sibling = $components->getConfiguration(self::LEGACY_COMPONENT_ID, $configId); + $siblingConfiguration = is_array($sibling) ? ($sibling['configuration'] ?? null) : null; + if (is_array($siblingConfiguration) && is_array($siblingConfiguration['parameters'] ?? null)) { + return $this->extractAppId($siblingConfiguration['parameters']); + } + } + + return null; + } + + /** + * @param array $parameters + */ + private function extractAppId(array $parameters): ?string + { + $id = $parameters['id'] ?? null; + if (!is_scalar($id)) { + return null; + } + + $task = $parameters['task'] ?? null; + if (!in_array($task, self::SUPPORTED_LEGACY_TASK_VALUES, true)) { + return null; + } + + return (string) $id; + } +} diff --git a/src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php b/src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php new file mode 100644 index 0000000..3f076e7 --- /dev/null +++ b/src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php @@ -0,0 +1,180 @@ +migrator = new DataAppOrchestratorTaskMigrator(); + } + + protected function configure(): void + { + $this + ->setName('manage:migrate-data-apps-orchestrator-tasks') + ->setDescription('Migrate orchestration/flow tasks from keboola.data-apps to keboola.data-app-control') + ->addArgument(self::ARG_TOKEN, InputArgument::REQUIRED, 'manage token') + ->addArgument(self::ARG_URL, InputArgument::REQUIRED, 'Stack URL') + ->addArgument(self::ARG_PROJECTS, InputArgument::REQUIRED, 'list of project IDs separated by comma or "all"') + ->addOption(self::OPT_FORCE, 'f', InputOption::VALUE_NONE, 'Will actually do the work, otherwise it\'s dry run'); + } + + protected function createManageClient(string $host, string $token): Client + { + return new Client([ + 'url' => $host, + 'token' => $token, + ]); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $force = (bool) $input->getOption(self::OPT_FORCE); + $token = $input->getArgument(self::ARG_TOKEN); + assert(is_string($token)); + $url = $input->getArgument(self::ARG_URL); + assert(is_string($url)); + $projectsOption = $input->getArgument(self::ARG_PROJECTS); + assert(is_string($projectsOption)); + + $output->writeln($force + ? 'Running in force mode. Configurations will be updated.' + : 'Running in dry-run mode. No configurations will be updated. Use -f to enable force mode.'); + + $checkAllProjects = strtolower($projectsOption) === 'all'; + $manageClient = $this->createManageClient($url, $token); + + if ($checkAllProjects) { + $this->migrateAllProjects($manageClient, $output, $url, $force); + } else { + $projectIds = array_filter(explode(',', $projectsOption), 'is_numeric'); + $this->migrateSelectedProjects($manageClient, $output, $url, $projectIds, $force); + } + + $output->writeln("\nDONE with following results:\n"); + $this->printResult($output, $checkAllProjects, $force); + + return 0; + } + + protected function migrateAllProjects(Client $manageClient, OutputInterface $output, string $url, bool $force): void + { + $maintainers = $manageClient->listMaintainers(); + foreach ($maintainers as $maintainer) { + $this->maintainersChecked++; + $organizations = $manageClient->listMaintainerOrganizations($maintainer['id']); + foreach ($organizations as $organization) { + $this->orgsChecked++; + $projects = $manageClient->listOrganizationProjects($organization['id']); + foreach ($projects as $project) { + $this->migrateProject($manageClient, $output, $url, (string) $project['id'], $force); + } + } + } + } + + /** + * @param array $projectIds + */ + protected function migrateSelectedProjects(Client $manageClient, OutputInterface $output, string $url, array $projectIds, bool $force): void + { + foreach ($projectIds as $projectId) { + $this->migrateProject($manageClient, $output, $url, $projectId, $force); + } + } + + protected function migrateProject(Client $manageClient, OutputInterface $output, string $url, string $projectId, bool $force): void + { + $output->writeln(sprintf('Processing project "%s"', $projectId)); + try { + $project = $manageClient->getProject($projectId); + if (isset($project['isDisabled']) && $project['isDisabled']) { + $output->writeln(' - project disabled, skipping.'); + $this->projectsDisabled++; + return; + } + + $tokenInfo = $manageClient->createProjectStorageToken($projectId, [ + 'description' => 'AJDA-2445 data-apps orchestrator task migration', + 'canManageBuckets' => false, + 'canManageTokens' => false, + 'expiresIn' => 300, + ]); + + $components = new Components(new StorageClient([ + 'url' => $url, + 'token' => $tokenInfo['token'], + ])); + + $result = $this->migrator->migrateProject($components, $output, $projectId, $force); + + $this->configsScanned += $result['configsScanned']; + $this->configsTouched += $result['configsTouched']; + $this->tasksMigrated += $result['tasksMigrated']; + $this->tasksSkippedUnsupported += $result['tasksSkippedUnsupported']; + $this->projectsChecked++; + } catch (ManageClientException | StorageClientException $e) { + $output->writeln(sprintf(' - error while processing project "%s": %s', $projectId, $e->getMessage())); + $this->projectsError++; + } + $output->write("\n"); + } + + private function printResult(OutputInterface $output, bool $checkAll, bool $force): void + { + $output->writeln( + ($checkAll ? sprintf( + "Checked %d maintainers\n" + . "Checked %d organizations\n", + $this->maintainersChecked, + $this->orgsChecked + ) : '') + . sprintf( + "Checked %d projects\n" + . "%d projects were disabled\n" + . "%d projects had errors\n" + . "Scanned %d orchestrator/flow configurations\n" + . '%d configurations ' . ($force ? 'updated' : 'would be updated in force mode') . "\n" + . '%d tasks ' . ($force ? 'migrated' : 'would be migrated in force mode') . "\n" + . "%d tasks skipped (unsupported shape)\n", + $this->projectsChecked, + $this->projectsDisabled, + $this->projectsError, + $this->configsScanned, + $this->configsTouched, + $this->tasksMigrated, + $this->tasksSkippedUnsupported + ) + ); + } +} From e3ae9cc5d2997a5bffae79472f30e5b90774d6d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Nov=C3=A1k?= Date: Thu, 9 Jul 2026 12:58:22 +0200 Subject: [PATCH 2/4] fix: AJDA-2445 address Copilot review on data-apps migration command Validate the projects argument strictly instead of silently dropping invalid IDs, catch Storage API errors when resolving a configId reference so one bad reference doesn't abort the whole project, cache resolved configIds to avoid repeated lookups, and fix the "Checked N projects" summary to include disabled/errored projects. --- .../DataAppOrchestratorTaskMigrator.php | 44 ++++++++++++++----- .../MigrateDataAppsOrchestratorTasks.php | 34 +++++++++++++- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/src/Keboola/Console/Command/DataAppOrchestratorTaskMigrator.php b/src/Keboola/Console/Command/DataAppOrchestratorTaskMigrator.php index 9f9ff92..685008f 100644 --- a/src/Keboola/Console/Command/DataAppOrchestratorTaskMigrator.php +++ b/src/Keboola/Console/Command/DataAppOrchestratorTaskMigrator.php @@ -2,6 +2,7 @@ namespace Keboola\Console\Command; +use Keboola\StorageApi\ClientException; use Keboola\StorageApi\Components; use Keboola\StorageApi\Options\Components\Configuration; use Keboola\StorageApi\Options\Components\ListComponentConfigurationsOptions; @@ -33,9 +34,13 @@ public function migrateProject(Components $components, OutputInterface $output, 'tasksMigrated' => 0, 'tasksSkippedUnsupported' => 0, ]; + // Keyed by keboola.data-apps configId, resolved appId or null - scoped to a single project + // (config IDs are only unique within a project) to avoid refetching the same referenced + // config across multiple tasks/configurations. + $configIdCache = []; foreach (self::FLOW_COMPONENT_IDS as $flowComponentId) { - $this->migrateFlowComponentConfigs($components, $output, $projectId, $flowComponentId, $isForce, $counts); + $this->migrateFlowComponentConfigs($components, $output, $projectId, $flowComponentId, $isForce, $counts, $configIdCache); } return $counts; @@ -43,6 +48,7 @@ public function migrateProject(Components $components, OutputInterface $output, /** * @param array{configsScanned: int, configsTouched: int, tasksMigrated: int, tasksSkippedUnsupported: int} $counts + * @param array $configIdCache */ private function migrateFlowComponentConfigs( Components $components, @@ -50,7 +56,8 @@ private function migrateFlowComponentConfigs( string $projectId, string $flowComponentId, bool $isForce, - array &$counts + array &$counts, + array &$configIdCache ): void { $prefix = $isForce ? 'FORCE: ' : 'DRY-RUN: '; @@ -86,9 +93,9 @@ private function migrateFlowComponentConfigs( $task['name'] ?? ($task['id'] ?? $taskKey) ); - $appId = $this->resolveAppId($components, $task['task']); + $appId = $this->resolveAppId($components, $task['task'], $configIdCache); if ($appId === null) { - $output->writeln(sprintf('%sSkipping %s: unsupported task shape', $prefix, $taskLabel)); + $output->writeln(sprintf('%sSkipping %s: unsupported task shape or unresolvable app id', $prefix, $taskLabel)); $counts['tasksSkippedUnsupported']++; continue; } @@ -135,8 +142,9 @@ private function migrateFlowComponentConfigs( /** * @param array $legacyTask + * @param array $configIdCache */ - private function resolveAppId(Components $components, array $legacyTask): ?string + private function resolveAppId(Components $components, array $legacyTask, array &$configIdCache): ?string { $configData = $legacyTask['configData'] ?? null; if (is_array($configData) && is_array($configData['parameters'] ?? null)) { @@ -144,15 +152,29 @@ private function resolveAppId(Components $components, array $legacyTask): ?strin } $configId = $legacyTask['configId'] ?? null; - if (is_string($configId) && $configId !== '') { + if (!is_string($configId) || $configId === '') { + return null; + } + + if (array_key_exists($configId, $configIdCache)) { + return $configIdCache[$configId]; + } + + try { $sibling = $components->getConfiguration(self::LEGACY_COMPONENT_ID, $configId); - $siblingConfiguration = is_array($sibling) ? ($sibling['configuration'] ?? null) : null; - if (is_array($siblingConfiguration) && is_array($siblingConfiguration['parameters'] ?? null)) { - return $this->extractAppId($siblingConfiguration['parameters']); - } + } catch (ClientException $e) { + // Referenced config missing/inaccessible - skip just this task rather than aborting + // the whole project's migration. + return $configIdCache[$configId] = null; + } + + $siblingConfiguration = is_array($sibling) ? ($sibling['configuration'] ?? null) : null; + $resolved = null; + if (is_array($siblingConfiguration) && is_array($siblingConfiguration['parameters'] ?? null)) { + $resolved = $this->extractAppId($siblingConfiguration['parameters']); } - return null; + return $configIdCache[$configId] = $resolved; } /** diff --git a/src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php b/src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php index 3f076e7..2587cad 100644 --- a/src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php +++ b/src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php @@ -77,7 +77,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($checkAllProjects) { $this->migrateAllProjects($manageClient, $output, $url, $force); } else { - $projectIds = array_filter(explode(',', $projectsOption), 'is_numeric'); + $projectIds = $this->parseProjectIds($projectsOption); + if ($projectIds === null) { + $output->writeln(sprintf( + 'Invalid "%s" argument: expected a comma-separated list of numeric project IDs or "all", got "%s"', + self::ARG_PROJECTS, + $projectsOption + )); + return 1; + } $this->migrateSelectedProjects($manageClient, $output, $url, $projectIds, $force); } @@ -103,6 +111,24 @@ protected function migrateAllProjects(Client $manageClient, OutputInterface $out } } + /** + * Splits the comma-separated projects argument into a list of numeric project IDs, or returns + * null if any entry is not a plain non-negative integer (e.g. "foo", "1.2", "1e3", ""). + * + * @return array|null + */ + private function parseProjectIds(string $projectsOption): ?array + { + $projectIds = array_map('trim', explode(',', $projectsOption)); + foreach ($projectIds as $projectId) { + if (!ctype_digit($projectId)) { + return null; + } + } + + return $projectIds; + } + /** * @param array $projectIds */ @@ -152,6 +178,10 @@ protected function migrateProject(Client $manageClient, OutputInterface $output, private function printResult(OutputInterface $output, bool $checkAll, bool $force): void { + // "Checked" means "attempted" here - it must include disabled/errored projects too, + // otherwise it undercounts and conflicts with the disabled/error breakdown lines below it. + $totalProjectsChecked = $this->projectsChecked + $this->projectsDisabled + $this->projectsError; + $output->writeln( ($checkAll ? sprintf( "Checked %d maintainers\n" @@ -167,7 +197,7 @@ private function printResult(OutputInterface $output, bool $checkAll, bool $forc . '%d configurations ' . ($force ? 'updated' : 'would be updated in force mode') . "\n" . '%d tasks ' . ($force ? 'migrated' : 'would be migrated in force mode') . "\n" . "%d tasks skipped (unsupported shape)\n", - $this->projectsChecked, + $totalProjectsChecked, $this->projectsDisabled, $this->projectsError, $this->configsScanned, From a8a9c256c9e14b30946b5ef9ed0cb1e5e0d8b253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Nov=C3=A1k?= Date: Fri, 10 Jul 2026 14:07:38 +0200 Subject: [PATCH 3/4] fix: AJDA-2445 address second round of PR review on data-apps migration Distinguish deliberately-skipped tasks (unsupported keboola.data-apps task variants like create/delete/terminate) from tasks that should have migrated but whose app id couldn't be resolved (missing/inaccessible configId reference, malformed parameters) - the latter is now a separate counter/message so it can't hide behind "unsupported shape" and needs manual follow-up. Add an interactive confirmation guard for the "all" + "--force" combination as a safety net against an accidental stack-wide run. Document that the configData replacement on migrated tasks is deliberate, not an oversight. --- README.md | 8 +- .../DataAppOrchestratorTaskMigrator.php | 82 ++++++++++++++----- .../MigrateDataAppsOrchestratorTasks.php | 37 ++++++++- 3 files changed, 103 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index e748154..006ae71 100644 --- a/README.md +++ b/README.md @@ -280,9 +280,15 @@ Behavior: references to a saved `keboola.data-apps` configuration). - Rewrites matching tasks to `keboola.data-app-control` with `parameters.appId` set from the resolved app ID (`configId` references are flattened into inline `configData` rather than creating a new saved configuration). +- Tasks that reference a `keboola.data-apps` config that couldn't be resolved (missing/inaccessible `configId` + reference, or a malformed/non-scalar app id) are reported separately from deliberately-skipped, expected task + shapes (e.g. `create`/`delete`/`terminate`) - the former needs manual follow-up, the latter doesn't. - With `--force`, updates the configuration via the Storage API with a `changeDescription`. Without it, only reports what would be migrated. -- Prints a summary: projects checked/disabled/errored, configurations scanned/touched, tasks migrated/skipped. +- Running with `` set to `all` together with `--force` asks for interactive confirmation first, as a + safety net against an accidental stack-wide mutation. +- Prints a summary: projects checked/disabled/errored, configurations scanned/touched, tasks migrated/skipped + (unsupported vs. unresolvable). ### Mass enablement of dynamic backends for multiple projects Prerequisities: https://keboola.atlassian.net/wiki/spaces/KB/pages/2135982081/Enable+Dynamic+Backends#Enable-for-project diff --git a/src/Keboola/Console/Command/DataAppOrchestratorTaskMigrator.php b/src/Keboola/Console/Command/DataAppOrchestratorTaskMigrator.php index 685008f..ffb267e 100644 --- a/src/Keboola/Console/Command/DataAppOrchestratorTaskMigrator.php +++ b/src/Keboola/Console/Command/DataAppOrchestratorTaskMigrator.php @@ -23,8 +23,22 @@ class DataAppOrchestratorTaskMigrator // is left untouched - those are not used in orchestrations/flows in practice. private const SUPPORTED_LEGACY_TASK_VALUES = [null, 'app-start', 'start']; + // Distinguishes two very different "can't migrate this task" outcomes: UNSUPPORTED_TASK is a + // deliberately-skipped, expected case (e.g. create/delete/terminate - never used in flows in + // practice); UNRESOLVABLE means the task looked like it *should* migrate but its app id couldn't + // be determined (missing/inaccessible configId reference, malformed parameters) - that's a real + // problem that needs human follow-up and must never be hidden inside the "unsupported" count. + private const REASON_UNSUPPORTED_TASK = 'unsupported-task'; + private const REASON_UNRESOLVABLE = 'unresolvable'; + /** - * @return array{configsScanned: int, configsTouched: int, tasksMigrated: int, tasksSkippedUnsupported: int} + * @return array{ + * configsScanned: int, + * configsTouched: int, + * tasksMigrated: int, + * tasksSkippedUnsupported: int, + * tasksSkippedUnresolvable: int + * } */ public function migrateProject(Components $components, OutputInterface $output, string $projectId, bool $isForce): array { @@ -33,8 +47,9 @@ public function migrateProject(Components $components, OutputInterface $output, 'configsTouched' => 0, 'tasksMigrated' => 0, 'tasksSkippedUnsupported' => 0, + 'tasksSkippedUnresolvable' => 0, ]; - // Keyed by keboola.data-apps configId, resolved appId or null - scoped to a single project + // Keyed by keboola.data-apps configId, resolved {appId, reason} - scoped to a single project // (config IDs are only unique within a project) to avoid refetching the same referenced // config across multiple tasks/configurations. $configIdCache = []; @@ -47,8 +62,14 @@ public function migrateProject(Components $components, OutputInterface $output, } /** - * @param array{configsScanned: int, configsTouched: int, tasksMigrated: int, tasksSkippedUnsupported: int} $counts - * @param array $configIdCache + * @param array{ + * configsScanned: int, + * configsTouched: int, + * tasksMigrated: int, + * tasksSkippedUnsupported: int, + * tasksSkippedUnresolvable: int + * } $counts + * @param array $configIdCache */ private function migrateFlowComponentConfigs( Components $components, @@ -93,12 +114,23 @@ private function migrateFlowComponentConfigs( $task['name'] ?? ($task['id'] ?? $taskKey) ); - $appId = $this->resolveAppId($components, $task['task'], $configIdCache); - if ($appId === null) { - $output->writeln(sprintf('%sSkipping %s: unsupported task shape or unresolvable app id', $prefix, $taskLabel)); - $counts['tasksSkippedUnsupported']++; + $resolution = $this->resolveAppId($components, $task['task'], $configIdCache); + if ($resolution['appId'] === null) { + if ($resolution['reason'] === self::REASON_UNRESOLVABLE) { + $output->writeln(sprintf( + '%sSkipping %s: could not resolve app id (missing/inaccessible config reference or invalid' + . ' id) - needs manual follow-up', + $prefix, + $taskLabel + )); + $counts['tasksSkippedUnresolvable']++; + } else { + $output->writeln(sprintf('%sSkipping %s: unsupported task shape', $prefix, $taskLabel)); + $counts['tasksSkippedUnsupported']++; + } continue; } + $appId = $resolution['appId']; $output->writeln(sprintf( '%sMigrating %s: %s -> %s (appId "%s")', @@ -112,6 +144,11 @@ private function migrateFlowComponentConfigs( // Keep every other field (type, mode, delay, retry, variableOverrides, ...) untouched - // the keboola.flow schema requires "type" alongside componentId/mode, and dropping it // silently produced an invalid, unreadable task (caught by live verification on canary-orion). + // + // configData/parameters IS deliberately replaced wholesale (not merged): the target + // component's schema only ever accepts a single "appId" parameter, so there is nothing + // in the legacy keboola.data-apps parameters (variableValuesId, storage/runtime overrides, + // etc.) that keboola.data-app-control understands or would do anything with. $newTask = $task['task']; $newTask['componentId'] = self::NEW_COMPONENT_ID; unset($newTask['configId']); @@ -142,9 +179,10 @@ private function migrateFlowComponentConfigs( /** * @param array $legacyTask - * @param array $configIdCache + * @param array $configIdCache + * @return array{appId: ?string, reason: ?string} */ - private function resolveAppId(Components $components, array $legacyTask, array &$configIdCache): ?string + private function resolveAppId(Components $components, array $legacyTask, array &$configIdCache): array { $configData = $legacyTask['configData'] ?? null; if (is_array($configData) && is_array($configData['parameters'] ?? null)) { @@ -153,7 +191,7 @@ private function resolveAppId(Components $components, array $legacyTask, array & $configId = $legacyTask['configId'] ?? null; if (!is_string($configId) || $configId === '') { - return null; + return ['appId' => null, 'reason' => self::REASON_UNRESOLVABLE]; } if (array_key_exists($configId, $configIdCache)) { @@ -163,35 +201,37 @@ private function resolveAppId(Components $components, array $legacyTask, array & try { $sibling = $components->getConfiguration(self::LEGACY_COMPONENT_ID, $configId); } catch (ClientException $e) { - // Referenced config missing/inaccessible - skip just this task rather than aborting - // the whole project's migration. - return $configIdCache[$configId] = null; + // Referenced config missing/inaccessible - skip just this task (flagged as unresolvable, + // not "unsupported") rather than aborting the whole project's migration. + return $configIdCache[$configId] = ['appId' => null, 'reason' => self::REASON_UNRESOLVABLE]; } $siblingConfiguration = is_array($sibling) ? ($sibling['configuration'] ?? null) : null; - $resolved = null; if (is_array($siblingConfiguration) && is_array($siblingConfiguration['parameters'] ?? null)) { - $resolved = $this->extractAppId($siblingConfiguration['parameters']); + $result = $this->extractAppId($siblingConfiguration['parameters']); + } else { + $result = ['appId' => null, 'reason' => self::REASON_UNRESOLVABLE]; } - return $configIdCache[$configId] = $resolved; + return $configIdCache[$configId] = $result; } /** * @param array $parameters + * @return array{appId: ?string, reason: ?string} */ - private function extractAppId(array $parameters): ?string + private function extractAppId(array $parameters): array { $id = $parameters['id'] ?? null; if (!is_scalar($id)) { - return null; + return ['appId' => null, 'reason' => self::REASON_UNRESOLVABLE]; } $task = $parameters['task'] ?? null; if (!in_array($task, self::SUPPORTED_LEGACY_TASK_VALUES, true)) { - return null; + return ['appId' => null, 'reason' => self::REASON_UNSUPPORTED_TASK]; } - return (string) $id; + return ['appId' => (string) $id, 'reason' => null]; } } diff --git a/src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php b/src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php index 2587cad..f5f4dd1 100644 --- a/src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php +++ b/src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php @@ -8,10 +8,12 @@ use Keboola\StorageApi\ClientException as StorageClientException; use Keboola\StorageApi\Components; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; class MigrateDataAppsOrchestratorTasks extends Command { @@ -29,6 +31,7 @@ class MigrateDataAppsOrchestratorTasks extends Command protected int $configsTouched = 0; protected int $tasksMigrated = 0; protected int $tasksSkippedUnsupported = 0; + protected int $tasksSkippedUnresolvable = 0; private DataAppOrchestratorTaskMigrator $migrator; @@ -57,6 +60,27 @@ protected function createManageClient(string $host, string $token): Client ]); } + // Cheap safety net against an accidental stack-wide mutation: "all" + "--force" is the highest + // blast-radius combination this command supports, so it gets an explicit confirmation on top of + // the dry-run default (mirrors the ConfirmationQuestion pattern used by + // MassProjectEnableDynamicBackends for a less impactful change). + private function confirmStackWideForceRun(InputInterface $input, OutputInterface $output, string $url): bool + { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion( + sprintf( + 'You are about to migrate keboola.data-apps orchestrator/flow tasks on ALL projects on "%s" with' + . ' --force. Continue? (y/n) ', + $url + ), + false, + '/^(y|j)/i' + ); + + return (bool) $helper->ask($input, $output, $question); + } + protected function execute(InputInterface $input, OutputInterface $output): int { $force = (bool) $input->getOption(self::OPT_FORCE); @@ -72,6 +96,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int : 'Running in dry-run mode. No configurations will be updated. Use -f to enable force mode.'); $checkAllProjects = strtolower($projectsOption) === 'all'; + + if ($checkAllProjects && $force && !$this->confirmStackWideForceRun($input, $output, $url)) { + $output->writeln('Aborted.'); + return 0; + } + $manageClient = $this->createManageClient($url, $token); if ($checkAllProjects) { @@ -168,6 +198,7 @@ protected function migrateProject(Client $manageClient, OutputInterface $output, $this->configsTouched += $result['configsTouched']; $this->tasksMigrated += $result['tasksMigrated']; $this->tasksSkippedUnsupported += $result['tasksSkippedUnsupported']; + $this->tasksSkippedUnresolvable += $result['tasksSkippedUnresolvable']; $this->projectsChecked++; } catch (ManageClientException | StorageClientException $e) { $output->writeln(sprintf(' - error while processing project "%s": %s', $projectId, $e->getMessage())); @@ -196,14 +227,16 @@ private function printResult(OutputInterface $output, bool $checkAll, bool $forc . "Scanned %d orchestrator/flow configurations\n" . '%d configurations ' . ($force ? 'updated' : 'would be updated in force mode') . "\n" . '%d tasks ' . ($force ? 'migrated' : 'would be migrated in force mode') . "\n" - . "%d tasks skipped (unsupported shape)\n", + . "%d tasks skipped (unsupported shape - expected, no action needed)\n" + . "%d tasks skipped as UNRESOLVABLE (needs manual follow-up)\n", $totalProjectsChecked, $this->projectsDisabled, $this->projectsError, $this->configsScanned, $this->configsTouched, $this->tasksMigrated, - $this->tasksSkippedUnsupported + $this->tasksSkippedUnsupported, + $this->tasksSkippedUnresolvable ) ); } From 8a4d44fb33bc7a857089704fd416287940081c01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Nov=C3=A1k?= Date: Fri, 10 Jul 2026 14:16:33 +0200 Subject: [PATCH 4/4] test: AJDA-2445 add unit tests for the data-apps migration command Adds phpunit/phpunit as a dev dependency and a small test suite covering DataAppOrchestratorTaskMigrator (inline configData migration, unsupported vs. unresolvable skip reasons, configId caching, phases/other task fields preserved, scans both keboola.orchestrator and keboola.flow, idempotency) and MigrateDataAppsOrchestratorTasks::parseProjectIds validation. Wires the suite into CI. This command rewrites stored project configurations, so the extra coverage is worth the one-off cost of introducing a test harness to a repo that previously had none. --- .github/workflows/build.yaml | 3 + .gitignore | 2 + composer.json | 11 +- composer.lock | 1812 ++++++++++++++++- phpunit.xml.dist | 11 + tests/DataAppOrchestratorTaskMigratorTest.php | 211 ++ tests/FakeComponents.php | 64 + .../MigrateDataAppsOrchestratorTasksTest.php | 36 + 8 files changed, 2124 insertions(+), 26 deletions(-) create mode 100644 phpunit.xml.dist create mode 100644 tests/DataAppOrchestratorTaskMigratorTest.php create mode 100644 tests/FakeComponents.php create mode 100644 tests/MigrateDataAppsOrchestratorTasksTest.php diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index c8a0be1..927d0e5 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -25,6 +25,9 @@ jobs: - name: Static analysis run: docker compose run --rm app composer phpstan + - name: Run tests + run: docker compose run --rm app composer tests + - name: Push if: startsWith(github.ref, 'refs/tags/') run: | diff --git a/.gitignore b/.gitignore index d3f537e..82d7bb9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ composer.phar /.env /projects.csv *.env.json +/.phpunit.result.cache +/phpunit.xml diff --git a/composer.json b/composer.json index 2706962..394adf7 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ }, "require-dev": { "squizlabs/php_codesniffer": "3.*", - "phpstan/phpstan": "^2.0" + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.0" }, "authors": [ { @@ -29,9 +30,15 @@ "Keboola\\Console": "src/" } }, + "autoload-dev": { + "psr-4": { + "Keboola\\Console\\Tests\\": "tests/" + } + }, "scripts": { "phpstan": "phpstan analyse --memory-limit=512M", "phpcs": "phpcs --standard=PSR2 -n src/", - "phpcbf": "phpcbf --standard=PSR2 -n src/" + "phpcbf": "phpcbf --standard=PSR2 -n src/", + "tests": "phpunit" } } diff --git a/composer.lock b/composer.lock index 1abf321..197a11b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7687a2df94ddc6afa27a6c1fab3c2827", + "content-hash": "6c410b9cc4f26fe9217d3f703d2987c6", "packages": [ { "name": "aws/aws-crt-php", @@ -3890,58 +3890,1720 @@ } ], "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, { "name": "phpstan/phpstan", "version": "2.1.40", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", - "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-02-23T15:04:35+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.56", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f83edffa6967c3db468d48a695ec7bcb02e9256", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.56" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:52:39+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", "shasum": "" }, "require": { - "php": "^7.4|^8.0" + "php": ">=8.2" }, - "conflict": { - "phpstan/phpstan-shim": "*" + "require-dev": { + "phpunit/phpunit": "^11.0" }, - "bin": [ - "phpstan", - "phpstan.phar" + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, "autoload": { - "files": [ - "bootstrap.php" + "classmap": [ + "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], - "description": "PHPStan - PHP Static Analysis Tool", - "keywords": [ - "dev", - "static analysis" + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { - "docs": "https://phpstan.org/user-guide/getting-started", - "forum": "https://github.com/phpstan/phpstan/discussions", - "issues": "https://github.com/phpstan/phpstan/issues", - "security": "https://github.com/phpstan/phpstan/security/policy", - "source": "https://github.com/phpstan/phpstan-src" + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" }, "funding": [ { - "url": "https://github.com/ondrejmirtes", + "url": "https://github.com/sebastianbergmann", "type": "github" }, { - "url": "https://github.com/phpstan", + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2026-02-23T15:04:35+00:00" + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" }, { "name": "squizlabs/php_codesniffer", @@ -4021,6 +5683,108 @@ } ], "time": "2025-11-04T16:30:35+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" } ], "aliases": [], diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..e974760 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,11 @@ + + + + + tests + + + diff --git a/tests/DataAppOrchestratorTaskMigratorTest.php b/tests/DataAppOrchestratorTaskMigratorTest.php new file mode 100644 index 0000000..046331b --- /dev/null +++ b/tests/DataAppOrchestratorTaskMigratorTest.php @@ -0,0 +1,211 @@ + $taskId, + 'name' => $taskId, + 'phase' => 'p1', + 'enabled' => true, + 'task' => array_merge(['type' => 'job', 'componentId' => 'keboola.data-apps', 'mode' => 'run'], $task), + ]; + } + + public function testMigratesInlineConfigDataStartTask(): void + { + $config = [ + 'id' => 'c1', + 'name' => 'my flow', + 'configuration' => [ + 'phases' => [['id' => 'p1', 'name' => 'Phase 1']], + 'tasks' => [ + $this->legacyTask('c1', 't1', ['configData' => ['parameters' => ['id' => 'app-123']]]), + ], + ], + ]; + $components = new FakeComponents(['keboola.flow' => [$config]]); + $migrator = new DataAppOrchestratorTaskMigrator(); + + $result = $migrator->migrateProject($components, new BufferedOutput(), '500', true); + + $this->assertSame( + ['configsScanned' => 1, 'configsTouched' => 1, 'tasksMigrated' => 1, 'tasksSkippedUnsupported' => 0, 'tasksSkippedUnresolvable' => 0], + $result + ); + $this->assertCount(1, $components->updateCalls); + $updated = $components->updateCalls[0]; + $this->assertSame('keboola.flow', $updated['componentId']); + $this->assertSame('AJDA-2445: migrate keboola.data-apps orchestrator/flow tasks to keboola.data-app-control', $updated['changeDescription']); + + $updatedTask = $updated['configuration']['tasks'][0]['task']; + $this->assertSame('keboola.data-app-control', $updatedTask['componentId']); + $this->assertSame(['parameters' => ['appId' => 'app-123']], $updatedTask['configData']); + $this->assertArrayNotHasKey('configId', $updatedTask); + // Fields unrelated to componentId/configData must survive untouched (keboola.flow requires "type"). + $this->assertSame('job', $updatedTask['type']); + $this->assertSame('run', $updatedTask['mode']); + + // phases must round-trip byte-for-byte. + $this->assertSame($config['configuration']['phases'], $updated['configuration']['phases']); + } + + public function testDryRunReportsButDoesNotCallUpdateConfiguration(): void + { + $config = [ + 'id' => 'c1', + 'name' => 'my flow', + 'configuration' => [ + 'phases' => [], + 'tasks' => [$this->legacyTask('c1', 't1', ['configData' => ['parameters' => ['id' => 'app-123']]])], + ], + ]; + $components = new FakeComponents(['keboola.flow' => [$config]]); + $migrator = new DataAppOrchestratorTaskMigrator(); + + $result = $migrator->migrateProject($components, new BufferedOutput(), '500', false); + + $this->assertSame(1, $result['tasksMigrated']); + $this->assertCount(0, $components->updateCalls); + } + + public function testSkipsDeliberatelyUnsupportedTaskVariant(): void + { + $config = [ + 'id' => 'c1', + 'name' => 'my flow', + 'configuration' => [ + 'phases' => [], + 'tasks' => [$this->legacyTask('c1', 't1', ['configData' => ['parameters' => ['id' => 'app-123', 'task' => 'delete']]])], + ], + ]; + $components = new FakeComponents(['keboola.flow' => [$config]]); + $migrator = new DataAppOrchestratorTaskMigrator(); + + $result = $migrator->migrateProject($components, new BufferedOutput(), '500', true); + + $this->assertSame(0, $result['tasksMigrated']); + $this->assertSame(1, $result['tasksSkippedUnsupported']); + $this->assertSame(0, $result['tasksSkippedUnresolvable']); + $this->assertCount(0, $components->updateCalls); + } + + public function testFlagsUnresolvableConfigIdReferenceSeparatelyAndCachesTheLookup(): void + { + $config = [ + 'id' => 'c1', + 'name' => 'my flow', + 'configuration' => [ + 'phases' => [], + 'tasks' => [ + $this->legacyTask('c1', 't1', ['configId' => 'missing-config']), + $this->legacyTask('c1', 't2', ['configId' => 'missing-config']), + ], + ], + ]; + $components = new FakeComponents(['keboola.flow' => [$config]]); + $migrator = new DataAppOrchestratorTaskMigrator(); + + $result = $migrator->migrateProject($components, new BufferedOutput(), '500', true); + + $this->assertSame(0, $result['tasksMigrated']); + $this->assertSame(0, $result['tasksSkippedUnsupported']); + $this->assertSame(2, $result['tasksSkippedUnresolvable']); + // Same configId referenced by two tasks - the failed lookup must be cached, not repeated. + $this->assertSame(1, $components->getConfigurationCalls); + } + + public function testResolvesAppIdFromReferencedConfigId(): void + { + $config = [ + 'id' => 'c1', + 'name' => 'my flow', + 'configuration' => [ + 'phases' => [], + 'tasks' => [$this->legacyTask('c1', 't1', ['configId' => 'sibling-1'])], + ], + ]; + $sibling = ['configuration' => ['parameters' => ['id' => 'app-from-sibling']]]; + $components = new FakeComponents( + ['keboola.flow' => [$config]], + ['keboola.data-apps/sibling-1' => $sibling] + ); + $migrator = new DataAppOrchestratorTaskMigrator(); + + $result = $migrator->migrateProject($components, new BufferedOutput(), '500', true); + + $this->assertSame(1, $result['tasksMigrated']); + $updatedTask = $components->updateCalls[0]['configuration']['tasks'][0]['task']; + $this->assertSame(['parameters' => ['appId' => 'app-from-sibling']], $updatedTask['configData']); + } + + public function testScansBothLegacyOrchestratorAndConditionalFlowComponents(): void + { + $orchestratorConfig = [ + 'id' => 'o1', + 'name' => 'legacy orchestration', + 'configuration' => [ + 'phases' => [], + 'tasks' => [$this->legacyTask('o1', 't1', ['configData' => ['parameters' => ['id' => 'app-o']]])], + ], + ]; + $flowConfig = [ + 'id' => 'f1', + 'name' => 'conditional flow', + 'configuration' => [ + 'phases' => [], + 'tasks' => [$this->legacyTask('f1', 't1', ['configData' => ['parameters' => ['id' => 'app-f']]])], + ], + ]; + $components = new FakeComponents([ + 'keboola.orchestrator' => [$orchestratorConfig], + 'keboola.flow' => [$flowConfig], + ]); + $migrator = new DataAppOrchestratorTaskMigrator(); + + $result = $migrator->migrateProject($components, new BufferedOutput(), '500', true); + + $this->assertSame(2, $result['configsScanned']); + $this->assertSame(2, $result['tasksMigrated']); + $this->assertCount(2, $components->updateCalls); + $touchedComponentIds = array_column($components->updateCalls, 'componentId'); + sort($touchedComponentIds); + $this->assertSame(['keboola.flow', 'keboola.orchestrator'], $touchedComponentIds); + } + + public function testIsIdempotentOnAlreadyMigratedTasks(): void + { + $alreadyMigratedTask = [ + 'id' => 't1', + 'name' => 't1', + 'phase' => 'p1', + 'enabled' => true, + 'task' => [ + 'type' => 'job', + 'componentId' => 'keboola.data-app-control', + 'configData' => ['parameters' => ['appId' => 'app-123']], + 'mode' => 'run', + ], + ]; + $config = [ + 'id' => 'c1', + 'name' => 'my flow', + 'configuration' => ['phases' => [], 'tasks' => [$alreadyMigratedTask]], + ]; + $components = new FakeComponents(['keboola.flow' => [$config]]); + $migrator = new DataAppOrchestratorTaskMigrator(); + + $result = $migrator->migrateProject($components, new BufferedOutput(), '500', true); + + $this->assertSame(0, $result['tasksMigrated']); + $this->assertSame(0, $result['configsTouched']); + $this->assertCount(0, $components->updateCalls); + } +} diff --git a/tests/FakeComponents.php b/tests/FakeComponents.php new file mode 100644 index 0000000..f1f0164 --- /dev/null +++ b/tests/FakeComponents.php @@ -0,0 +1,64 @@ +>> componentId => list of configuration data */ + private array $configsByComponent; + + /** @var array> "componentId/configurationId" => configuration data, used by getConfiguration() */ + private array $singleConfigsById; + + /** @var array> */ + public array $updateCalls = []; + + public int $getConfigurationCalls = 0; + + /** + * @param array>> $configsByComponent + * @param array> $singleConfigsById + */ + public function __construct(array $configsByComponent, array $singleConfigsById = []) + { + $this->configsByComponent = $configsByComponent; + $this->singleConfigsById = $singleConfigsById; + } + + public function listComponentConfigurations(ListComponentConfigurationsOptions $options) + { + return $this->configsByComponent[$options->getComponentId()] ?? []; + } + + public function getConfiguration($componentId, $configurationId) + { + $this->getConfigurationCalls++; + $key = $componentId . '/' . $configurationId; + if (!isset($this->singleConfigsById[$key])) { + throw new ClientException(sprintf('Configuration "%s" of component "%s" not found', $configurationId, $componentId)); + } + + return $this->singleConfigsById[$key]; + } + + public function updateConfiguration(Configuration $options) + { + $this->updateCalls[] = [ + 'componentId' => $options->getComponentId(), + 'configurationId' => $options->getConfigurationId(), + 'configuration' => $options->getConfiguration(), + 'changeDescription' => $options->getChangeDescription(), + ]; + + return []; + } +} diff --git a/tests/MigrateDataAppsOrchestratorTasksTest.php b/tests/MigrateDataAppsOrchestratorTasksTest.php new file mode 100644 index 0000000..7347c35 --- /dev/null +++ b/tests/MigrateDataAppsOrchestratorTasksTest.php @@ -0,0 +1,36 @@ +getMethod('parseProjectIds'); + $method->setAccessible(true); + + $this->assertSame($expected, $method->invoke($command, $input)); + } + + /** + * @return iterable|null}> + */ + public static function provideProjectIdsInput(): iterable + { + yield 'plain list' => ['1,2,3', ['1', '2', '3']]; + yield 'whitespace around entries is trimmed' => ['1, 2 ,3', ['1', '2', '3']]; + yield 'leading zeros are kept as-is' => ['007', ['007']]; + yield 'non-numeric entry invalidates the whole list' => ['1,foo', null]; + yield 'decimal is rejected' => ['1.2', null]; + yield 'exponential notation is rejected' => ['1e3', null]; + yield 'negative number is rejected' => ['-1', null]; + yield 'empty string is rejected' => ['', null]; + } +}