diff --git a/README.md b/README.md index 625f625..b33be47 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,24 @@ 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. + +| 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. + ## Screenshots ![Admin catalog](img/screenshots/admin-catalog.png) 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 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); + } +}