From 0d51def9d2be6a1c6f9e19c228263258d1593625 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:17:21 -0300 Subject: [PATCH 1/4] chore: point documentation to README Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/README.md b/README.md index 625f625..17f2cdb 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,49 @@ This makes the app useful for internal directories, support operations, partner The public API contract for this app is published as [openapi-full.json](https://github.com/LibreCodeCoop/profile_fields/blob/main/openapi-full.json). +## Data backup and import + +Run the app commands from the Nextcloud stack root, not from the host PHP environment. + +Export the current Profile Fields catalog and stored values: + +```bash +docker compose exec -u www-data -w /var/www/html nextcloud \ + php occ profile_fields:data:export \ + --output=/tmp/profile_fields-export.json +``` + +Validate an import payload without writing anything: + +```bash +docker compose exec -u www-data -w /var/www/html nextcloud \ + php occ profile_fields:data:import \ + --input=/tmp/profile_fields-export.json \ + --dry-run +``` + +Apply the non-destructive `upsert` import: + +```bash +docker compose exec -u www-data -w /var/www/html nextcloud \ + php occ profile_fields:data:import \ + --input=/tmp/profile_fields-export.json +``` + +Notes: + +- The import contract is versioned with `schema_version` and reconciles definitions by `field_key` and values by `field_key + user_uid`. +- The first delivery is non-destructive: missing items in the payload do not delete existing definitions or values. +- Validation is all-or-nothing. If the payload contains an incompatible definition or a missing destination user, no database write is performed. +- For a restore in the same environment, clear app data explicitly before reimporting: + +```bash +docker compose exec -u www-data -w /var/www/html nextcloud \ + php occ profile_fields:data:clear \ + --definitions \ + --force +``` + ## Screenshots ![Admin catalog](img/screenshots/admin-catalog.png) From ead6ce3660d695a4b5510fabec3e0725b9ed0a1d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:27:57 -0300 Subject: [PATCH 2/4] feat: describe commands Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- README.md | 39 +++++++-------------------------------- 1 file changed, 7 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 17f2cdb..b33be47 100644 --- a/README.md +++ b/README.md @@ -23,44 +23,19 @@ The public API contract for this app is published as [openapi-full.json](https:/ Run the app commands from the Nextcloud stack root, not from the host PHP environment. -Export the current Profile Fields catalog and stored values: - -```bash -docker compose exec -u www-data -w /var/www/html nextcloud \ - php occ profile_fields:data:export \ - --output=/tmp/profile_fields-export.json -``` - -Validate an import payload without writing anything: - -```bash -docker compose exec -u www-data -w /var/www/html nextcloud \ - php occ profile_fields:data:import \ - --input=/tmp/profile_fields-export.json \ - --dry-run -``` - -Apply the non-destructive `upsert` import: - -```bash -docker compose exec -u www-data -w /var/www/html nextcloud \ - php occ profile_fields:data:import \ - --input=/tmp/profile_fields-export.json -``` +| Command | Description | +| --- | --- | +| `occ profile_fields:data:export --output=/tmp/profile_fields-export.json` | Export the current Profile Fields catalog and stored values. | +| `occ profile_fields:data:import --input=/tmp/profile_fields-export.json --dry-run` | Validate an import payload without writing anything. | +| `occ profile_fields:data:import --input=/tmp/profile_fields-export.json` | Apply the non-destructive `upsert` import. | +| `occ profile_fields:data:clear --definitions --force` | Clear app definitions explicitly before reimporting into the same environment. | Notes: - The import contract is versioned with `schema_version` and reconciles definitions by `field_key` and values by `field_key + user_uid`. - The first delivery is non-destructive: missing items in the payload do not delete existing definitions or values. - Validation is all-or-nothing. If the payload contains an incompatible definition or a missing destination user, no database write is performed. -- For a restore in the same environment, clear app data explicitly before reimporting: - -```bash -docker compose exec -u www-data -w /var/www/html nextcloud \ - php occ profile_fields:data:clear \ - --definitions \ - --force -``` +- For a restore in the same environment, clear app data explicitly before reimporting. ## Screenshots From 6456602840015646815bbd22348baedef523215d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:28:23 -0300 Subject: [PATCH 3/4] chore: point documentation to README Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index f1dd01d..c01cae8 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -28,7 +28,7 @@ Developed with ❤️ by [LibreCode](https://librecode.coop). Help us transform LibreCode ProfileFields - https://github.com/LibreCodeCoop/profile_fields/blob/main/openapi-full.json + https://github.com/LibreCodeCoop/profile_fields/ organization tools From 606df5911992fb8c8db91e82250ad3e2c60cad34 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:28:58 -0300 Subject: [PATCH 4/4] feat: add test file Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Command/Data/ImportTest.php | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 tests/php/ControllerIntegration/Command/Data/ImportTest.php diff --git a/tests/php/ControllerIntegration/Command/Data/ImportTest.php b/tests/php/ControllerIntegration/Command/Data/ImportTest.php new file mode 100644 index 0000000..b99df45 --- /dev/null +++ b/tests/php/ControllerIntegration/Command/Data/ImportTest.php @@ -0,0 +1,256 @@ + */ + private array $createdUserIds = []; + /** @var list */ + private array $createdFieldKeys = []; + /** @var list */ + private array $payloadFiles = []; + + protected function setUp(): void { + parent::setUp(); + + $container = (new App(Application::APP_ID))->getContainer(); + $this->fieldDefinitionMapper = $container->get(FieldDefinitionMapper::class); + $this->fieldValueMapper = $container->get(FieldValueMapper::class); + $this->dataImportService = $container->get(DataImportService::class); + $this->userManager = $container->get(IUserManager::class); + + self::ensureSchemaExists($container->get(IDBConnection::class)); + } + + protected function tearDown(): void { + foreach ($this->payloadFiles as $payloadFile) { + @unlink($payloadFile); + } + + foreach ($this->createdUserIds as $userId) { + foreach ($this->fieldValueMapper->findByUserUid($userId) as $value) { + $this->fieldValueMapper->delete($value); + } + } + + foreach ($this->createdFieldKeys as $fieldKey) { + $definition = $this->fieldDefinitionMapper->findByFieldKey($fieldKey); + if ($definition instanceof FieldDefinition) { + $this->fieldDefinitionMapper->delete($definition); + } + } + + foreach ($this->createdUserIds as $userId) { + $user = $this->userManager->get($userId); + if ($user instanceof IUser) { + $user->delete(); + } + } + + parent::tearDown(); + } + + public function testExecuteImportsValidPayloadIntoDatabase(): void { + $userId = $this->createUser('pf_import_cli_valid_user'); + $fieldKey = 'pf_import_cli_valid_region'; + $payloadFile = $this->createPayloadFile([ + 'schema_version' => 1, + 'definitions' => [[ + 'field_key' => $fieldKey, + 'label' => 'Region', + 'type' => 'text', + 'admin_only' => false, + 'user_editable' => true, + 'user_visible' => true, + 'initial_visibility' => 'users', + 'sort_order' => 10, + 'active' => true, + 'created_at' => '2026-03-16T10:00:00+00:00', + 'updated_at' => '2026-03-16T10:00:00+00:00', + ]], + 'values' => [[ + 'field_key' => $fieldKey, + 'user_uid' => $userId, + 'value' => ['value' => 'LATAM'], + 'current_visibility' => 'users', + 'updated_by_uid' => $userId, + 'updated_at' => '2026-03-16T10:30:00+00:00', + ]], + ]); + + $tester = new CommandTester(new Import($this->dataImportService)); + $exitCode = $tester->execute([ + '--input' => $payloadFile, + ]); + + self::assertSame(0, $exitCode); + self::assertStringContainsString('Profile Fields data imported.', $tester->getDisplay()); + self::assertStringContainsString('Definitions: 1 created, 0 updated, 0 skipped.', $tester->getDisplay()); + self::assertStringContainsString('Values: 1 created, 0 updated, 0 skipped.', $tester->getDisplay()); + + $definition = $this->fieldDefinitionMapper->findByFieldKey($fieldKey); + self::assertInstanceOf(FieldDefinition::class, $definition); + self::assertSame('Region', $definition->getLabel()); + + $value = $this->fieldValueMapper->findByFieldDefinitionIdAndUserUid($definition->getId(), $userId); + self::assertInstanceOf(FieldValue::class, $value); + self::assertSame('{"value":"LATAM"}', $value->getValueJson()); + } + + public function testExecuteDryRunDoesNotPersistValidatedPayload(): void { + $userId = $this->createUser('pf_import_cli_dry_run_user'); + $fieldKey = 'pf_import_cli_dry_run_alias'; + $payloadFile = $this->createPayloadFile([ + 'schema_version' => 1, + 'definitions' => [[ + 'field_key' => $fieldKey, + 'label' => 'Alias', + 'type' => 'text', + 'admin_only' => false, + 'user_editable' => true, + 'user_visible' => true, + 'initial_visibility' => 'private', + 'sort_order' => 20, + 'active' => true, + ]], + 'values' => [[ + 'field_key' => $fieldKey, + 'user_uid' => $userId, + 'value' => ['value' => 'ops-latam'], + 'current_visibility' => 'private', + 'updated_by_uid' => $userId, + 'updated_at' => '2026-03-16T11:00:00+00:00', + ]], + ]); + + $tester = new CommandTester(new Import($this->dataImportService)); + $exitCode = $tester->execute([ + '--input' => $payloadFile, + '--dry-run' => true, + ]); + + self::assertSame(0, $exitCode); + self::assertStringContainsString('Profile Fields data import dry-run completed.', $tester->getDisplay()); + self::assertNull($this->fieldDefinitionMapper->findByFieldKey($fieldKey)); + } + + public function testExecuteFailsValidationWithoutPersistingData(): void { + $fieldKey = 'pf_import_cli_invalid_user'; + $payloadFile = $this->createPayloadFile([ + 'schema_version' => 1, + 'definitions' => [[ + 'field_key' => $fieldKey, + 'label' => 'Specialty', + 'type' => 'text', + 'admin_only' => false, + 'user_editable' => true, + 'user_visible' => true, + 'initial_visibility' => 'users', + 'sort_order' => 30, + 'active' => true, + ]], + 'values' => [[ + 'field_key' => $fieldKey, + 'user_uid' => 'pf_import_cli_missing_user', + 'value' => ['value' => 'support'], + 'current_visibility' => 'users', + 'updated_by_uid' => 'pf_import_cli_missing_user', + 'updated_at' => '2026-03-16T11:30:00+00:00', + ]], + ]); + + $tester = new CommandTester(new Import($this->dataImportService)); + $exitCode = $tester->execute([ + '--input' => $payloadFile, + ]); + + self::assertSame(1, $exitCode); + self::assertStringContainsString('Import validation failed.', $tester->getDisplay()); + self::assertStringContainsString('values[0].user_uid does not exist in destination instance', $tester->getDisplay()); + self::assertNull($this->fieldDefinitionMapper->findByFieldKey($fieldKey)); + } + + private function createUser(string $userId): string { + if ($this->userManager->get($userId) === null) { + $this->userManager->createUser($userId, $userId); + $this->createdUserIds[$userId] = $userId; + } + + return $userId; + } + + /** + * @param array $payload + */ + private function createPayloadFile(array $payload): string { + $payloadFile = tempnam(sys_get_temp_dir(), 'profile-fields-cli-import-'); + if ($payloadFile === false) { + throw new \RuntimeException('Failed to create temporary payload file'); + } + + file_put_contents($payloadFile, json_encode($payload, JSON_THROW_ON_ERROR)); + $this->payloadFiles[] = $payloadFile; + + foreach (($payload['definitions'] ?? []) as $definition) { + if (is_array($definition) && is_string($definition['field_key'] ?? null)) { + $this->createdFieldKeys[] = $definition['field_key']; + } + } + + return $payloadFile; + } + + private static function ensureSchemaExists(IDBConnection $connection): void { + if ($connection->tableExists('profile_fields_definitions') && $connection->tableExists('profile_fields_values')) { + return; + } + + $nullOutputClass = '\\OC\\Migration\\NullOutput'; + $schemaWrapperClass = '\\OC\\DB\\SchemaWrapper'; + + if (!class_exists($nullOutputClass) || !class_exists($schemaWrapperClass) || !method_exists($connection, 'getInner')) { + throw new \RuntimeException('Expected ConnectionAdapter in integration test setup'); + } + + $innerConnection = call_user_func([$connection, 'getInner']); + /** @var ISchemaWrapper $schema */ + $schema = new $schemaWrapperClass($innerConnection->createSchema()); + $migration = new Version1000Date20260309120000(); + $schema = $migration->changeSchema(new $nullOutputClass(), static fn () => $schema, ['appVersion' => '0.0.1']); + if (!$schema instanceof ISchemaWrapper) { + throw new \RuntimeException('Expected schema wrapper after migration'); + } + + call_user_func([$connection, 'migrateToSchema'], $schema); + } +}