Skip to content

Commit 1f5a976

Browse files
authored
Merge pull request #6 from LibreCodeCoop/chore/organize-code
chore: organize code
2 parents 4e518cf + 606df59 commit 1f5a976

File tree

3 files changed

+275
-1
lines changed

3 files changed

+275
-1
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,24 @@ This makes the app useful for internal directories, support operations, partner
1919

2020
The public API contract for this app is published as [openapi-full.json](https://github.com/LibreCodeCoop/profile_fields/blob/main/openapi-full.json).
2121

22+
## Data backup and import
23+
24+
Run the app commands from the Nextcloud stack root, not from the host PHP environment.
25+
26+
| Command | Description |
27+
| --- | --- |
28+
| `occ profile_fields:data:export --output=/tmp/profile_fields-export.json` | Export the current Profile Fields catalog and stored values. |
29+
| `occ profile_fields:data:import --input=/tmp/profile_fields-export.json --dry-run` | Validate an import payload without writing anything. |
30+
| `occ profile_fields:data:import --input=/tmp/profile_fields-export.json` | Apply the non-destructive `upsert` import. |
31+
| `occ profile_fields:data:clear --definitions --force` | Clear app definitions explicitly before reimporting into the same environment. |
32+
33+
Notes:
34+
35+
- The import contract is versioned with `schema_version` and reconciles definitions by `field_key` and values by `field_key + user_uid`.
36+
- The first delivery is non-destructive: missing items in the payload do not delete existing definitions or values.
37+
- Validation is all-or-nothing. If the payload contains an incompatible definition or a missing destination user, no database write is performed.
38+
- For a restore in the same environment, clear app data explicitly before reimporting.
39+
2240
## Screenshots
2341

2442
![Admin catalog](img/screenshots/admin-catalog.png)

appinfo/info.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Developed with ❤️ by [LibreCode](https://librecode.coop). Help us transform
2828
<author mail="contact@librecode.coop" homepage="https://librecode.coop">LibreCode</author>
2929
<namespace>ProfileFields</namespace>
3030
<documentation>
31-
<developer>https://github.com/LibreCodeCoop/profile_fields/blob/main/openapi-full.json</developer>
31+
<developer>https://github.com/LibreCodeCoop/profile_fields/</developer>
3232
</documentation>
3333
<category>organization</category>
3434
<category>tools</category>
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace OCA\ProfileFields\Tests\ControllerIntegration\Command\Data;
11+
12+
use OCA\ProfileFields\AppInfo\Application;
13+
use OCA\ProfileFields\Command\Data\Import;
14+
use OCA\ProfileFields\Db\FieldDefinition;
15+
use OCA\ProfileFields\Db\FieldDefinitionMapper;
16+
use OCA\ProfileFields\Db\FieldValue;
17+
use OCA\ProfileFields\Db\FieldValueMapper;
18+
use OCA\ProfileFields\Migration\Version1000Date20260309120000;
19+
use OCA\ProfileFields\Service\DataImportService;
20+
use OCP\AppFramework\App;
21+
use OCP\DB\ISchemaWrapper;
22+
use OCP\IDBConnection;
23+
use OCP\IUser;
24+
use OCP\IUserManager;
25+
use PHPUnit\Framework\TestCase;
26+
use Symfony\Component\Console\Tester\CommandTester;
27+
28+
/**
29+
* @group DB
30+
*/
31+
class ImportTest extends TestCase {
32+
private FieldDefinitionMapper $fieldDefinitionMapper;
33+
private FieldValueMapper $fieldValueMapper;
34+
private DataImportService $dataImportService;
35+
private IUserManager $userManager;
36+
/** @var array<string, string> */
37+
private array $createdUserIds = [];
38+
/** @var list<string> */
39+
private array $createdFieldKeys = [];
40+
/** @var list<string> */
41+
private array $payloadFiles = [];
42+
43+
protected function setUp(): void {
44+
parent::setUp();
45+
46+
$container = (new App(Application::APP_ID))->getContainer();
47+
$this->fieldDefinitionMapper = $container->get(FieldDefinitionMapper::class);
48+
$this->fieldValueMapper = $container->get(FieldValueMapper::class);
49+
$this->dataImportService = $container->get(DataImportService::class);
50+
$this->userManager = $container->get(IUserManager::class);
51+
52+
self::ensureSchemaExists($container->get(IDBConnection::class));
53+
}
54+
55+
protected function tearDown(): void {
56+
foreach ($this->payloadFiles as $payloadFile) {
57+
@unlink($payloadFile);
58+
}
59+
60+
foreach ($this->createdUserIds as $userId) {
61+
foreach ($this->fieldValueMapper->findByUserUid($userId) as $value) {
62+
$this->fieldValueMapper->delete($value);
63+
}
64+
}
65+
66+
foreach ($this->createdFieldKeys as $fieldKey) {
67+
$definition = $this->fieldDefinitionMapper->findByFieldKey($fieldKey);
68+
if ($definition instanceof FieldDefinition) {
69+
$this->fieldDefinitionMapper->delete($definition);
70+
}
71+
}
72+
73+
foreach ($this->createdUserIds as $userId) {
74+
$user = $this->userManager->get($userId);
75+
if ($user instanceof IUser) {
76+
$user->delete();
77+
}
78+
}
79+
80+
parent::tearDown();
81+
}
82+
83+
public function testExecuteImportsValidPayloadIntoDatabase(): void {
84+
$userId = $this->createUser('pf_import_cli_valid_user');
85+
$fieldKey = 'pf_import_cli_valid_region';
86+
$payloadFile = $this->createPayloadFile([
87+
'schema_version' => 1,
88+
'definitions' => [[
89+
'field_key' => $fieldKey,
90+
'label' => 'Region',
91+
'type' => 'text',
92+
'admin_only' => false,
93+
'user_editable' => true,
94+
'user_visible' => true,
95+
'initial_visibility' => 'users',
96+
'sort_order' => 10,
97+
'active' => true,
98+
'created_at' => '2026-03-16T10:00:00+00:00',
99+
'updated_at' => '2026-03-16T10:00:00+00:00',
100+
]],
101+
'values' => [[
102+
'field_key' => $fieldKey,
103+
'user_uid' => $userId,
104+
'value' => ['value' => 'LATAM'],
105+
'current_visibility' => 'users',
106+
'updated_by_uid' => $userId,
107+
'updated_at' => '2026-03-16T10:30:00+00:00',
108+
]],
109+
]);
110+
111+
$tester = new CommandTester(new Import($this->dataImportService));
112+
$exitCode = $tester->execute([
113+
'--input' => $payloadFile,
114+
]);
115+
116+
self::assertSame(0, $exitCode);
117+
self::assertStringContainsString('Profile Fields data imported.', $tester->getDisplay());
118+
self::assertStringContainsString('Definitions: 1 created, 0 updated, 0 skipped.', $tester->getDisplay());
119+
self::assertStringContainsString('Values: 1 created, 0 updated, 0 skipped.', $tester->getDisplay());
120+
121+
$definition = $this->fieldDefinitionMapper->findByFieldKey($fieldKey);
122+
self::assertInstanceOf(FieldDefinition::class, $definition);
123+
self::assertSame('Region', $definition->getLabel());
124+
125+
$value = $this->fieldValueMapper->findByFieldDefinitionIdAndUserUid($definition->getId(), $userId);
126+
self::assertInstanceOf(FieldValue::class, $value);
127+
self::assertSame('{"value":"LATAM"}', $value->getValueJson());
128+
}
129+
130+
public function testExecuteDryRunDoesNotPersistValidatedPayload(): void {
131+
$userId = $this->createUser('pf_import_cli_dry_run_user');
132+
$fieldKey = 'pf_import_cli_dry_run_alias';
133+
$payloadFile = $this->createPayloadFile([
134+
'schema_version' => 1,
135+
'definitions' => [[
136+
'field_key' => $fieldKey,
137+
'label' => 'Alias',
138+
'type' => 'text',
139+
'admin_only' => false,
140+
'user_editable' => true,
141+
'user_visible' => true,
142+
'initial_visibility' => 'private',
143+
'sort_order' => 20,
144+
'active' => true,
145+
]],
146+
'values' => [[
147+
'field_key' => $fieldKey,
148+
'user_uid' => $userId,
149+
'value' => ['value' => 'ops-latam'],
150+
'current_visibility' => 'private',
151+
'updated_by_uid' => $userId,
152+
'updated_at' => '2026-03-16T11:00:00+00:00',
153+
]],
154+
]);
155+
156+
$tester = new CommandTester(new Import($this->dataImportService));
157+
$exitCode = $tester->execute([
158+
'--input' => $payloadFile,
159+
'--dry-run' => true,
160+
]);
161+
162+
self::assertSame(0, $exitCode);
163+
self::assertStringContainsString('Profile Fields data import dry-run completed.', $tester->getDisplay());
164+
self::assertNull($this->fieldDefinitionMapper->findByFieldKey($fieldKey));
165+
}
166+
167+
public function testExecuteFailsValidationWithoutPersistingData(): void {
168+
$fieldKey = 'pf_import_cli_invalid_user';
169+
$payloadFile = $this->createPayloadFile([
170+
'schema_version' => 1,
171+
'definitions' => [[
172+
'field_key' => $fieldKey,
173+
'label' => 'Specialty',
174+
'type' => 'text',
175+
'admin_only' => false,
176+
'user_editable' => true,
177+
'user_visible' => true,
178+
'initial_visibility' => 'users',
179+
'sort_order' => 30,
180+
'active' => true,
181+
]],
182+
'values' => [[
183+
'field_key' => $fieldKey,
184+
'user_uid' => 'pf_import_cli_missing_user',
185+
'value' => ['value' => 'support'],
186+
'current_visibility' => 'users',
187+
'updated_by_uid' => 'pf_import_cli_missing_user',
188+
'updated_at' => '2026-03-16T11:30:00+00:00',
189+
]],
190+
]);
191+
192+
$tester = new CommandTester(new Import($this->dataImportService));
193+
$exitCode = $tester->execute([
194+
'--input' => $payloadFile,
195+
]);
196+
197+
self::assertSame(1, $exitCode);
198+
self::assertStringContainsString('Import validation failed.', $tester->getDisplay());
199+
self::assertStringContainsString('values[0].user_uid does not exist in destination instance', $tester->getDisplay());
200+
self::assertNull($this->fieldDefinitionMapper->findByFieldKey($fieldKey));
201+
}
202+
203+
private function createUser(string $userId): string {
204+
if ($this->userManager->get($userId) === null) {
205+
$this->userManager->createUser($userId, $userId);
206+
$this->createdUserIds[$userId] = $userId;
207+
}
208+
209+
return $userId;
210+
}
211+
212+
/**
213+
* @param array<string, mixed> $payload
214+
*/
215+
private function createPayloadFile(array $payload): string {
216+
$payloadFile = tempnam(sys_get_temp_dir(), 'profile-fields-cli-import-');
217+
if ($payloadFile === false) {
218+
throw new \RuntimeException('Failed to create temporary payload file');
219+
}
220+
221+
file_put_contents($payloadFile, json_encode($payload, JSON_THROW_ON_ERROR));
222+
$this->payloadFiles[] = $payloadFile;
223+
224+
foreach (($payload['definitions'] ?? []) as $definition) {
225+
if (is_array($definition) && is_string($definition['field_key'] ?? null)) {
226+
$this->createdFieldKeys[] = $definition['field_key'];
227+
}
228+
}
229+
230+
return $payloadFile;
231+
}
232+
233+
private static function ensureSchemaExists(IDBConnection $connection): void {
234+
if ($connection->tableExists('profile_fields_definitions') && $connection->tableExists('profile_fields_values')) {
235+
return;
236+
}
237+
238+
$nullOutputClass = '\\OC\\Migration\\NullOutput';
239+
$schemaWrapperClass = '\\OC\\DB\\SchemaWrapper';
240+
241+
if (!class_exists($nullOutputClass) || !class_exists($schemaWrapperClass) || !method_exists($connection, 'getInner')) {
242+
throw new \RuntimeException('Expected ConnectionAdapter in integration test setup');
243+
}
244+
245+
$innerConnection = call_user_func([$connection, 'getInner']);
246+
/** @var ISchemaWrapper $schema */
247+
$schema = new $schemaWrapperClass($innerConnection->createSchema());
248+
$migration = new Version1000Date20260309120000();
249+
$schema = $migration->changeSchema(new $nullOutputClass(), static fn () => $schema, ['appVersion' => '0.0.1']);
250+
if (!$schema instanceof ISchemaWrapper) {
251+
throw new \RuntimeException('Expected schema wrapper after migration');
252+
}
253+
254+
call_user_func([$connection, 'migrateToSchema'], $schema);
255+
}
256+
}

0 commit comments

Comments
 (0)