Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions lib/Service/ExampleSet/ExampleSetCatalogue.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ public function sets(): array {
* @param string $set The set id.
*
* @return array<string, mixed> `set`, `title`, `timezone`, `cast` (people, organisations and groups by key,
* plus `entries`, all three merged), `requires` (OpenRegister register slugs),
* plus `entries`, all three merged), `requires` (OpenRegister register slug to `{mock}`),
* `fragments` (each with `app`, `file`, `objects`, `changes` and `groups`) and
* `skipped` (named apps that are not enabled).
*
Expand Down Expand Up @@ -161,7 +161,7 @@ public function gather(string $set): array {
'title' => (string) ($castFile['title'] ?? $set),
'timezone' => (string) ($castFile['timezone'] ?? 'UTC'),
'cast' => $this->cast(cast: $castFile['cast'], file: $castFile['@file'], set: $set),
'requires' => array_values(array_unique(array_map('strval', (array) ($castFile['requires'] ?? [])))),
'requires' => $this->requiredRegisters(declared: (array) ($castFile['requires'] ?? []), file: $castFile['@file']),
'organisations' => $this->organisationEntities(declared: (array) ($castFile['cast']['openregisterOrganisations'] ?? []), file: $castFile['@file']),
'fragments' => [],
'skipped' => $ordered['skipped'],
Expand Down Expand Up @@ -388,6 +388,43 @@ private function userPreferences(string $app, string $file, array $declared): ar
return $entries;
}//end userPreferences()

/**
* The OpenRegister registers the cast requires, keyed by slug.
*
* An entry is a slug, imported from OpenRegister's real descriptor, or
* `{"register": "<slug>", "mock": true}`, imported from its mock (sample)
* descriptor.
*
* @param array<int, mixed> $declared The declared requirements.
* @param string $file The cast file, for messages.
*
* @return array<string, array{mock: bool}> Slug to how it is imported.
*
* @throws ExampleSetException When an entry names no register.
*
* @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-set-imports-the-openregister-registers-it-requires-req-exs-009
*/
private function requiredRegisters(array $declared, string $file): array {
$registers = [];
foreach ($declared as $index => $entry) {
if (is_array($entry) === false) {
$entry = ['register' => $entry];
}

$slug = $entry['register'] ?? '';
$mock = $entry['mock'] ?? false;
if (is_string($slug) === false || $slug === '' || is_bool($mock) === false) {
throw new ExampleSetException(
$file.': requires '.$index.' needs a register slug, or {"register": "<slug>", "mock": true}.'
);
}

$registers[$slug] = ['mock' => $mock];
}

return $registers;
}//end requiredRegisters()

/**
* The OpenRegister organisations the cast needs, each with a key, a name and a slug.
*
Expand Down
8 changes: 6 additions & 2 deletions lib/Service/ExampleSet/ExampleSetPrerequisites.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,17 @@ public function context(array $plan, DateTimeImmutable $today): array {
*/
private function ensureRegisters(array $plan, array &$record): array {
$result = ['refusals' => [], 'outcome' => []];
foreach ((array) ($plan['requires'] ?? []) as $slug) {
foreach ((array) ($plan['requires'] ?? []) as $slug => $required) {
$slug = (string) $slug;
$result['outcome'][$slug] = 'kept';
if ($this->registers->exists(slug: $slug) === true) {
continue;
}

$import = $this->registers->import(slug: $slug);
$import = match (($required['mock'] ?? false) === true) {
true => $this->registers->importMock(slug: $slug),
false => $this->registers->import(slug: $slug),
};
if ($import['jobs'] !== []) {
$record['registers'][$slug] = array_merge(($record['registers'][$slug] ?? []), $import['jobs']);
$this->record->write(set: (string) $plan['set'], record: $record);
Expand Down
42 changes: 36 additions & 6 deletions lib/Service/ExampleSet/ExampleSetRegisters.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
* The OpenRegister registers an example set needs, named in the cast file's
* `requires` list. A required register that is not on the instance is
* imported from OpenRegister's own descriptor, the same way
* `POST /api/register-descriptors/openregister/{slug}/import` does. The
* `POST /api/register-descriptors/openregister/{slug}/import` does, or from
* its mock descriptor when the set requires the mock. The
* import jobs that import created are returned, so the set can remove exactly
* those objects again and leave an import somebody else ran alone.
*
Expand Down Expand Up @@ -100,13 +101,42 @@ public function exists(string $slug): bool {
* @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-set-imports-the-openregister-registers-it-requires-req-exs-009
*/
public function import(string $slug): array {
try {
$before = $this->jobIds();
$result = $this->container->get(RegisterDescriptorService::class)->reimport(
return $this->recordedImport(
import: fn (RegisterDescriptorService $descriptors): array => $descriptors->reimport(
appId: self::APP,
slug: $slug,
via: 'example set install'
);
)
);
}//end import()

/**
* Import a register from OpenRegister's mock (sample) descriptor that declares the slug.
*
* @param string $slug The register slug.
*
* @return array{error: string, jobs: array<int, array{appId: string, jobId: string}>} Why it failed, or ''; and
* the import jobs it created.
*
* @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-set-imports-the-openregister-registers-it-requires-req-exs-009
*/
public function importMock(string $slug): array {
return $this->recordedImport(
import: fn (RegisterDescriptorService $descriptors): array => $descriptors->reimportMock(appId: self::APP, slug: $slug)
);
}//end importMock()

/**
* Run one descriptor import and return why it failed and the import jobs it created.
*
* @param callable(RegisterDescriptorService): array<string, mixed> $import The import.
*
* @return array{error: string, jobs: array<int, array{appId: string, jobId: string}>}
*/
private function recordedImport(callable $import): array {
try {
$before = $this->jobIds();
$result = $import($this->container->get(RegisterDescriptorService::class));
$after = $this->jobIds();
} catch (Throwable $e) {
return ['error' => $e->getMessage(), 'jobs' => []];
Expand All @@ -125,7 +155,7 @@ public function import(string $slug): array {
}

return ['error' => $error, 'jobs' => $jobs];
}//end import()
}//end recordedImport()

/**
* Soft-delete the objects one import job created, and forget the job when that went cleanly.
Expand Down
10 changes: 8 additions & 2 deletions lib/Settings/example-sets/zuiddrecht.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@
"$comment": "The cast of the Zuiddrecht example set, and OpenRegister's own fragment: a BRP row per cast person with a BSN and a KVK row and branch per business. Installed with `occ openregister:example-set:install zuiddrecht`; every other app adds its part in its own lib/Settings/example-sets/zuiddrecht.json and refers to cast members with {{cast:key.field}} and to other apps' objects only through a lookup on a business key (BSN, KVK number). Zuiddrecht is fictitious. BSNs are 11-proof numbers from the BRP test block 999990000-999999999 and appear nowhere else in the fleet's registers; KVK numbers are fictitious; municipality code 9999 belongs to no municipality.",
"timezone": "Europe/Amsterdam",
"requires": [
"brp",
"kvk"
{
"register": "brp",
"mock": true
},
{
"register": "kvk",
"mock": true
}
],
"apps": [
"openregister",
Expand Down
8 changes: 8 additions & 0 deletions openspec/changes/example-sets/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,14 @@ the Woo journey depends on them. That is a separate portaliq PR.
elevation call-site set stays pinned (ADR-099 rule 9). A
register that was already there is never imported or removed. The register
and schema definitions stay; only the objects of the import go.
Amended 15 Oct: `brp` and `kvk` are mock-only descriptors, and the
real-configuration re-import (#4618) correctly refuses a mock, so a fresh
instance refused the set. A `requires` entry now says which descriptor it
wants: a slug for the real one, `{"register": "<slug>", "mock": true}` for
the mock, imported through `RegisterDescriptorService::reimportMock()`
under `openregister.mock`. The choice is the set's, not a command flag, so
the nightly reset runs the same command as a hand install and a plain slug
never quietly falls back to sample data.
8. **Changes to objects the set did not create.** A sign-in mode lives on the
portal row that the site install made, so it cannot be a new object. A
fragment's `changes` find one object by business key and `set` or `append`
Expand Down
12 changes: 11 additions & 1 deletion openspec/changes/example-sets/specs/example-sets/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ After writing, the loader SHALL read every object it created in this run back fr

### Requirement: A set imports the OpenRegister registers it requires (REQ-EXS-009)

The cast file MAY name OpenRegister registers in `requires` (for example `["brp", "kvk"]`). Before the check, the loader SHALL import every required register that is not on the instance from OpenRegister's own register descriptor, the way the descriptor import endpoint does, and SHALL record the import jobs that import created. A required register that is already on the instance SHALL be used as it is and SHALL NOT be recorded. A second run SHALL import nothing. Remove SHALL soft-delete exactly the objects of the recorded import jobs, after the set's own objects, and SHALL NOT touch an import it did not make. A required register that cannot be imported SHALL refuse the set.
The cast file MAY name OpenRegister registers in `requires`. An entry SHALL be either a register slug (for example `"publication"`), imported from OpenRegister's real register descriptor, or `{"register": "<slug>", "mock": true}`, imported from OpenRegister's mock (sample) descriptor that declares the slug, the way `occ openregister:descriptors:list --app=openregister --import=<slug> --mock` does. An example set is sample data, so it MAY require a register OpenRegister ships only as a mock (zuiddrecht requires `brp` and `kvk` that way). An entry that names no register slug SHALL refuse the set when it is gathered. Before the check, the loader SHALL import every required register that is not on the instance from the descriptor its entry names, and SHALL record the import jobs that import created. A required register that is already on the instance SHALL be used as it is and SHALL NOT be recorded. A second run SHALL import nothing. Remove SHALL soft-delete exactly the objects of the recorded import jobs, after the set's own objects, and SHALL NOT touch an import it did not make. A required register that cannot be imported SHALL refuse the set.

#### Scenario: A missing register is imported once and removed again
- **GIVEN** a set that requires `brp` and an instance without it
Expand All @@ -142,6 +142,16 @@ The cast file MAY name OpenRegister registers in `requires` (for example `["brp"
- **WHEN** the set is installed and removed
- **THEN** nothing SHALL be imported and remove SHALL leave every import of `brp` alone

#### Scenario: A register shipped only as a mock is imported from the mock
- **GIVEN** a set whose `requires` holds `{"register": "brp", "mock": true}` and a fresh instance without `brp`
- **WHEN** `occ openregister:example-set:install <set> --check` runs
- **THEN** `brp` SHALL be imported from OpenRegister's mock `brp` descriptor under the `openregister.mock` configuration id, reported as `imported`, and recorded so remove deletes its objects

#### Scenario: A plain slug never falls back to a mock
- **GIVEN** a set whose `requires` holds `"brp"` and OpenRegister ships `brp` only as a mock
- **WHEN** the set is checked
- **THEN** it SHALL be refused naming `brp`, and SHALL NOT import the mock

#### Scenario: A register that cannot be imported refuses the set
- **GIVEN** a set that requires a register OpenRegister ships no descriptor for
- **WHEN** the set is installed
Expand Down
1 change: 1 addition & 0 deletions openspec/changes/example-sets/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
- [x] 3.10 Remove from occ: files by node, archival and held objects, reasons in the output (`ExampleSetRemoveLiveTest`, `ExampleSetStoreTest`)
- [x] 3.11 Partial remove then reinstall: kept publications follow the theme written again, a further install writes nothing, remove is whole (`ExampleSetReferencesTest`)
- [x] 3.12 User preferences written, kept, restored, left when changed, and refused for a foreign account, an administrator and a secret (`ExampleSetUserPreferencesTest`)
- [x] 3.13 A required register shipped only as a mock is declared `{"register": "<slug>", "mock": true}` and imported from the mock; a plain slug never falls back to it; the shipped zuiddrecht requirements import on a fresh instance (`ExampleSetMockRegisterImportTest`)
- [x] 3.6 Required registers imported once and removed only when imported; admin refused; changes made, proved and reverted exactly; JSON text fields

## 4. Verification
Expand Down
2 changes: 1 addition & 1 deletion tests/Unit/Service/ExampleSet/ExampleSetCatalogueTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public function testTheShippedZuiddrechtSetIsWhole(): void {
$plan = $this->catalogue->gather(set: 'zuiddrecht');

$this->assertCount(16, $plan['cast']['people']);
$this->assertSame(['brp', 'kvk'], $plan['requires']);
$this->assertSame(['brp' => ['mock' => true], 'kvk' => ['mock' => true]], $plan['requires']);
$this->assertSame(['vergunningen'], array_values(array_diff($plan['cast']['people']['marleen']['groups'], ['medewerkers'])));
$this->assertSame('Teamleider', $plan['cast']['people']['marleen']['function']);
$this->assertNotContains('admin', array_column($plan['cast']['groups'], 'gid'));
Expand Down
Loading
Loading