From ad6efdfdefa1751926c949b722a2a5c134af401d Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Fri, 28 Aug 2026 21:43:44 +0200 Subject: [PATCH 1/3] feat(setup): a wizard that offers the demo data this app already ships This app ships lib/Settings/*_mock_register.json - a dataset generated from its own schemas, conformant by construction, validated by the generator's --check - and had no way for an operator to reach it. There was no setup wizard at all. welcome -> demo-data -> done. Nothing app-specific is invented: the only action is the demo-data import the descriptor already supports. A wizard that asked questions the app does not act on would be worse than none, which is why there are no configuration steps here yet. completed is TRUE and the demo-data step is optional, so setup never gates the app. skip-demo-data records its outcome just as installing does: since nextcloud-vue 2.21 an OUTSTANDING OPTIONAL step opens the wizard over every page (nextcloud-vue#806), so a step that can never be marked done is a dialog that never closes - the defect buildiq was failing 37 E2E specs on. Verified: manifest validates against schema 2.26.0, gate-100 PASS, routes.php and both PHP files parse. The template was checked on launchpad against phpcs, phpstan, psalm and phpmd - all clean. --- appinfo/routes.php | 3 + lib/Controller/SetupController.php | 176 +++++++++++++++++++++++++++ lib/Service/DemoDataService.php | 185 +++++++++++++++++++++++++++++ src/manifest.json | 25 ++++ 4 files changed, 389 insertions(+) create mode 100644 lib/Controller/SetupController.php create mode 100644 lib/Service/DemoDataService.php diff --git a/appinfo/routes.php b/appinfo/routes.php index f3034733..4efe549c 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -18,6 +18,9 @@ return [ 'routes' => [ // Dashboard routes + // First-time setup wizard (ADR-042) - the standard CnSetupWizard contract. + ['name' => 'setup#status', 'url' => '/api/setup/status', 'verb' => 'GET'], + ['name' => 'setup#runAction', 'url' => '/api/setup/action/{actionId}', 'verb' => 'POST', 'requirements' => ['actionId' => '[a-z0-9\\-]+']], ['name' => 'dashboard#page', 'url' => '/', 'verb' => 'GET'], ['name' => 'dashboard#index', 'url' => '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/api/dashboard', 'verb' => 'GET'], diff --git a/lib/Controller/SetupController.php b/lib/Controller/SetupController.php new file mode 100644 index 00000000..816765b9 --- /dev/null +++ b/lib/Controller/SetupController.php @@ -0,0 +1,176 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Controller; + +use OCA\Stackiq\AppInfo\Application; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IAppConfig; +use OCP\IRequest; +use Psr\Log\LoggerInterface; +use OCA\Stackiq\Service\DemoDataService; + +/** + * First-time setup wizard endpoints. + * + * @spec exclude First-time-setup action dispatch; ADR-042 contract, no per-app behavioural spec. + */ +class SetupController extends Controller { + /** + * Setup contract version; matches manifest.setup.version. + * + * @var integer + */ + private const SETUP_VERSION = 1; + + /** + * App-config key recording that the demo-data step was DEALT WITH. + * + * Not "objects exist": an operator who declines has finished the step, and + * re-offering the import on every visit would make "no thanks" impossible to + * express. Since @conduction/nextcloud-vue 2.21 that also matters visually — + * an OUTSTANDING OPTIONAL step opens the wizard over every page + * (nextcloud-vue#806), so a step that can never be marked done is a dialog + * that never closes. + * + * @var string + */ + private const DEMO_DECIDED_KEY = 'demo_data_decided'; + + /** + * Constructor. + * + * @param IRequest $request The request. + * @param IAppConfig $appConfig Records the demo-data decision. + * @param LoggerInterface $logger Records a failed import. + * @param DemoDataService $demoDataService Imports the shipped demo dataset. + * + * @return void + */ + public function __construct( + IRequest $request, + private readonly IAppConfig $appConfig, + private readonly LoggerInterface $logger, + private readonly DemoDataService $demoDataService, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + + }//end __construct() + + /** + * Report per-step setup status for the wizard. + * + * `completed` is deliberately TRUE: this app declares no REQUIRED step, so + * setup must never gate the app. The demo-data step is reported so the wizard + * can stop asking once it has an answer. + * + * @return JSONResponse The status document. + * + * @spec exclude Setup status document; ADR-042 contract, no per-app behavioural spec. + */ + public function status(): JSONResponse { + $demoDecided = $this->appConfig->getValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, '') !== ''; + + return new JSONResponse( + data: [ + 'version' => self::SETUP_VERSION, + 'completed' => true, + 'steps' => [ + 'demo-data' => ['done' => $demoDecided], + ], + ] + ); + + }//end status() + + /** + * Run a privileged server-side setup action. + * + * Admin-only by Nextcloud's default for an un-attributed method. + * + * @param string $actionId One of `install-demo-data` | `skip-demo-data`. + * + * @return JSONResponse `{ success, message }`. + * + * @spec exclude Setup action dispatch; ADR-042 contract, no per-app behavioural spec. + */ + public function runAction(string $actionId): JSONResponse { + if ($actionId === 'install-demo-data') { + return $this->installDemoData(); + } + + // DECLINING IS AN ANSWER — see DEMO_DECIDED_KEY. + if ($actionId === 'skip-demo-data') { + $this->appConfig->setValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, 'skipped'); + + return new JSONResponse(data: ['success' => true, 'message' => 'Demo data skipped.']); + } + + return new JSONResponse( + data: ['success' => false, 'message' => 'Unknown setup action: ' . $actionId], + statusCode: Http::STATUS_NOT_FOUND, + ); + + }//end runAction() + + /** + * Import the shipped demo dataset. + * + * Reports the FAILURE rather than a quiet success: an operator who asked for + * demo data and got none must be told, which is why DemoDataService::install() + * throws instead of returning an empty result. + * + * @return JSONResponse `{ success, message }`. + */ + private function installDemoData(): JSONResponse { + try { + $imported = $this->demoDataService->install(); + } catch (\Throwable $e) { + $this->logger->error( + 'Setup install-demo-data failed: ' . $e->getMessage(), + ['app' => Application::APP_ID, 'exception' => $e] + ); + + return new JSONResponse( + data: ['success' => false, 'message' => 'Could not import the demo data: ' . $e->getMessage()], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR, + ); + } + + $this->appConfig->setValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, 'installed'); + + return new JSONResponse( + data: [ + 'success' => true, + 'message' => 'Imported ' . $imported['objects'] . ' demo object(s).', + ] + ); + + }//end installDemoData() +}//end class diff --git a/lib/Service/DemoDataService.php b/lib/Service/DemoDataService.php new file mode 100644 index 00000000..2ced6fb4 --- /dev/null +++ b/lib/Service/DemoDataService.php @@ -0,0 +1,185 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service; + +use OCA\Stackiq\AppInfo\Application; +use OCP\App\IAppManager; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Imports the shipped demo dataset on request. + * + * @spec exclude Demo-data import; ADR-111 rule 1, no per-app behavioural spec. + */ +class DemoDataService { + /** + * App-relative path to the generated mock descriptor. + * + * @var string + */ + private const DESCRIPTOR = '/lib/Settings/stackiq_mock_register.json'; + + /** + * Configuration identity for the demo import. + * + * 🔴 ITS OWN NAMESPACE, not the app id. Sharing the app's identity would make + * the demo import and the real configuration import share one version gate, so + * installing demo data could mask a pending configuration update — or be + * masked by one. + * + * @var string + */ + private const CONFIG_APP_ID = Application::APP_ID . '.demo'; + + /** + * Constructor. + * + * @param IAppManager $appManager Resolves this app's path and version. + * @param ContainerInterface $container Resolves OpenRegister's importer. + * @param LoggerInterface $logger Records what was imported. + * + * @return void + */ + public function __construct( + private readonly IAppManager $appManager, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Whether this app ships a demo dataset at all. + * + * @return boolean True when the descriptor is present on disk. + * + * @spec exclude Demo-data availability probe; ADR-111 rule 1 has no per-app behavioural spec. + */ + public function isAvailable(): bool { + return is_file($this->descriptorPath()) === true; + }//end isAvailable() + + /** + * Import the demo dataset. + * + * 🔴 THROWS RATHER THAN RETURNING A QUIET FAILURE. The caller reports the + * outcome to an operator who just asked for this, so "nothing happened" must + * not be presentable as success. + * + * @return array{objects: integer, registers: integer, schemas: integer} What was imported. + * + * @throws RuntimeException When the descriptor is missing, unreadable, or OpenRegister is absent. + * + * @spec exclude Demo-data import; ADR-111 rule 1 has no per-app behavioural spec. + */ + public function install(): array { + $path = $this->descriptorPath(); + if (is_file($path) === false) { + throw new RuntimeException('No demo dataset ships with this app (' . self::DESCRIPTOR . ' not found).'); + } + + $raw = file_get_contents($path); + if ($raw === false) { + throw new RuntimeException('The demo dataset could not be read: ' . $path); + } + + $data = json_decode($raw, true); + if (is_array($data) === false) { + throw new RuntimeException('The demo dataset is not valid JSON: ' . $path); + } + + // Counted from the FILE, not the importer's reply, so the number reported + // is the number ASKED FOR. An object whose schema does not resolve is + // SKIPPED rather than errored, so a discrepancy here is a real condition + // an operator should be able to see. + $objects = 0; + $components = ($data['components'] ?? []); + if (is_array($components) === true && is_array(($components['objects'] ?? null)) === true) { + $objects = count($components['objects']); + } + + $result = $this->configurationService()->importFromApp( + appId: self::CONFIG_APP_ID, + data: $data, + version: $this->appManager->getAppVersion(Application::APP_ID), + force: true + ); + + $imported = [ + 'objects' => $objects, + 'registers' => count((array)($result['registers'] ?? [])), + 'schemas' => count((array)($result['schemas'] ?? [])), + ]; + + $this->logger->info( + '[DemoDataService] imported demo data: ' + . $imported['objects'] . ' object(s), ' + . $imported['registers'] . ' register(s), ' + . $imported['schemas'] . ' schema(s).', + ['app' => Application::APP_ID] + ); + + return $imported; + }//end install() + + /** + * Absolute path to the shipped descriptor. + * + * @return string The path. + */ + private function descriptorPath(): string { + return $this->appManager->getAppPath(Application::APP_ID) . self::DESCRIPTOR; + }//end descriptorPath() + + /** + * OpenRegister's configuration importer. + * + * 🔴 A CROSS-APP CLASS IS A RUNTIME LOOKUP. OpenRegister may not be installed, + * and asking the container for a class from a missing app raises something the + * caller cannot act on. Check first and say which app is missing. + * + * 🔴 THE RETURN TYPE IS `object`, NOT THE CLASS, AND THAT IS THE POINT. Naming + * a class from an OPTIONAL app in a native return type makes PHP resolve it + * whenever this method returns, so on an instance without OpenRegister the + * failure is a TypeError about a class nobody mentioned instead of the + * RuntimeException above that names the missing app. + * + * @return object The importer — an OCA\OpenRegister\Service\ConfigurationService. + * + * @psalm-return \OCA\OpenRegister\Service\ConfigurationService + * + * @throws RuntimeException When OpenRegister is not installed. + */ + private function configurationService(): object { + if (in_array('openregister', $this->appManager->getInstalledApps(), true) === false) { + throw new RuntimeException('Demo data needs OpenRegister, which is not installed.'); + } + + return $this->container->get('OCA\OpenRegister\Service\ConfigurationService'); + }//end configurationService() +}//end class diff --git a/src/manifest.json b/src/manifest.json index 7ca4763a..8f323153 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,5 +1,30 @@ { "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json", + "setup": { + "version": 1, + "completionConfigKey": "setup_completed_version", + "steps": [ + { + "id": "welcome", + "type": "info", + "title": "Welcome", + "body": "A short setup to get this app ready. Nothing here is required; you can close it and come back later." + }, + { + "id": "demo-data", + "type": "run-action", + "action": "install-demo-data", + "title": "Demo data (optional)", + "required": false, + "body": "Load a small example dataset so the lists, detail pages and dashboards show a working product straight away. The data is obviously sample data, it is safe to run more than once, and it can be removed afterwards. Skip this on a production install." + }, + { + "id": "done", + "type": "summary", + "title": "All set" + } + ] + }, "version": "1.1.0", "dependencies": [ "openregister" From 69528343c5279da2b520a2e73cbe7a7547bca2ca Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Fri, 28 Aug 2026 21:55:25 +0200 Subject: [PATCH 2/3] fix(setup): declare the endpoints' auth, and translate the wizard's strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gate findings on the previous push. gate-5 route-auth — status() and runAction() carried no auth attribute. The docblock said 'admin-only by Nextcloud's default for an un-attributed method', which is true and is not a declaration: the gate exists because a missing attribute silently makes an endpoint unreachable, and a comment cannot be checked by middleware. Both now carry #[AuthorizedAdminSetting(Application::APP_ID)], placed DIRECTLY above the declaration - gate-5 walks upward from the method and a long docblock between attribute and declaration costs the attribute its visibility, which the gate documents as a false FAIL it had to repair. gate-102 manifest-l10n-coverage — the wizard's title and body strings had no l10n/nl.json key, so a Dutch user would read them in English. Added, and the browser catalogue rebuilt where the app ships one: nl.json alone is not enough, because the browser reads nl.js. The catalogue edit is insertions only, proven against the same change applied structurally - an earlier attempt on another app re-serialised the whole file (410 lines) before being reverted. --- l10n/nl.js | 5 +++++ l10n/nl.json | 5 +++++ lib/Controller/SetupController.php | 3 +++ 3 files changed, 13 insertions(+) diff --git a/l10n/nl.js b/l10n/nl.js index 3960d0e6..106951d9 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1,6 +1,11 @@ OC.L10N.register( "stackiq", { + "Welcome": "Welkom", + "A short setup to get this app ready. Nothing here is required; you can close it and come back later.": "Een korte installatie om deze app klaar te zetten. Niets hiervan is verplicht; je kunt dit sluiten en later terugkomen.", + "Demo data (optional)": "Demovoorbeelddata (optioneel)", + "Load a small example dataset so the lists, detail pages and dashboards show a working product straight away. The data is obviously sample data, it is safe to run more than once, and it can be removed afterwards. Skip this on a production install.": "Laad een kleine voorbeeldset zodat de lijsten, detailpagina's en dashboards meteen een werkend product laten zien. De data is duidelijk voorbeelddata, veilig om meerdere keren uit te voeren en achteraf te verwijderen. Sla dit over op een productie-installatie.", + "All set": "Klaar", "AMEF elements": "Amef elementen", "AMEF standards": "Standaarden AMEF", "Acquisition start date": "Startdatum Verwerving", diff --git a/l10n/nl.json b/l10n/nl.json index e508ec4e..00ea2be2 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1,5 +1,10 @@ { "translations": { + "Welcome": "Welkom", + "A short setup to get this app ready. Nothing here is required; you can close it and come back later.": "Een korte installatie om deze app klaar te zetten. Niets hiervan is verplicht; je kunt dit sluiten en later terugkomen.", + "Demo data (optional)": "Demovoorbeelddata (optioneel)", + "Load a small example dataset so the lists, detail pages and dashboards show a working product straight away. The data is obviously sample data, it is safe to run more than once, and it can be removed afterwards. Skip this on a production install.": "Laad een kleine voorbeeldset zodat de lijsten, detailpagina's en dashboards meteen een werkend product laten zien. De data is duidelijk voorbeelddata, veilig om meerdere keren uit te voeren en achteraf te verwijderen. Sla dit over op een productie-installatie.", + "All set": "Klaar", "AMEF elements": "Amef elementen", "AMEF standards": "Standaarden AMEF", "Acquisition start date": "Startdatum Verwerving", diff --git a/lib/Controller/SetupController.php b/lib/Controller/SetupController.php index 816765b9..ae13c221 100644 --- a/lib/Controller/SetupController.php +++ b/lib/Controller/SetupController.php @@ -29,6 +29,7 @@ use OCA\Stackiq\AppInfo\Application; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; use OCP\AppFramework\Http; use OCP\AppFramework\Http\JSONResponse; use OCP\IAppConfig; @@ -94,6 +95,7 @@ public function __construct( * * @spec exclude Setup status document; ADR-042 contract, no per-app behavioural spec. */ + #[AuthorizedAdminSetting(Application::APP_ID)] public function status(): JSONResponse { $demoDecided = $this->appConfig->getValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, '') !== ''; @@ -120,6 +122,7 @@ public function status(): JSONResponse { * * @spec exclude Setup action dispatch; ADR-042 contract, no per-app behavioural spec. */ + #[AuthorizedAdminSetting(Application::APP_ID)] public function runAction(string $actionId): JSONResponse { if ($actionId === 'install-demo-data') { return $this->installDemoData(); From 11af0500b801d527a1684b010baf92d69ef18545 Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Sat, 29 Aug 2026 03:36:52 +0200 Subject: [PATCH 3/3] fix(setup): authorize against the admin settings class, and test what it guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AuthorizedAdminSetting` takes a `class-string`, not an app id, so `Application::APP_ID` — a plain string — was rejected by phpstan. The apps where this shipped green (larpinq, shillinq) already pass their admin settings class; match them. gate-47 and the coverage ratchet were both right to fail this. The change adds an admin-authorized endpoint pair and ~364 lines of PHP with nothing behind them. Two assertions are worth naming: - a FAILED install must leave the step UNDECIDED. Recording the decision in the catch block would close the step for an operator who asked for demo data and received none. - the object count comes from the FILE, not the importer's reply, so the number reported is the number ASKED FOR. Both verified by mutation on openregister: reversing each behaviour fails exactly the test that claims to guard it. The e2e spec issues both calls from inside the logged-in admin page, which is the only place that middleware can be observed admitting a real session. --- lib/Controller/SetupController.php | 5 +- tests/Unit/Controller/SetupControllerTest.php | 115 +++++++++++++ tests/Unit/Service/DemoDataServiceTest.php | 134 +++++++++++++++ .../demo-data-setup-step.spec.ts | 161 ++++++++++++++++++ 4 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Controller/SetupControllerTest.php create mode 100644 tests/Unit/Service/DemoDataServiceTest.php create mode 100644 tests/e2e/spec-coverage/demo-data-setup-step.spec.ts diff --git a/lib/Controller/SetupController.php b/lib/Controller/SetupController.php index ae13c221..a6e7ceaa 100644 --- a/lib/Controller/SetupController.php +++ b/lib/Controller/SetupController.php @@ -28,6 +28,7 @@ namespace OCA\Stackiq\Controller; use OCA\Stackiq\AppInfo\Application; +use OCA\Stackiq\Settings\StackiqAdmin; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; use OCP\AppFramework\Http; @@ -95,7 +96,7 @@ public function __construct( * * @spec exclude Setup status document; ADR-042 contract, no per-app behavioural spec. */ - #[AuthorizedAdminSetting(Application::APP_ID)] + #[AuthorizedAdminSetting(StackiqAdmin::class)] public function status(): JSONResponse { $demoDecided = $this->appConfig->getValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, '') !== ''; @@ -122,7 +123,7 @@ public function status(): JSONResponse { * * @spec exclude Setup action dispatch; ADR-042 contract, no per-app behavioural spec. */ - #[AuthorizedAdminSetting(Application::APP_ID)] + #[AuthorizedAdminSetting(StackiqAdmin::class)] public function runAction(string $actionId): JSONResponse { if ($actionId === 'install-demo-data') { return $this->installDemoData(); diff --git a/tests/Unit/Controller/SetupControllerTest.php b/tests/Unit/Controller/SetupControllerTest.php new file mode 100644 index 00000000..1f41c1cf --- /dev/null +++ b/tests/Unit/Controller/SetupControllerTest.php @@ -0,0 +1,115 @@ +appConfig = $this->createMock(IAppConfig::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->demoData = $this->createMock(DemoDataService::class); + + $this->controller = new SetupController( + $this->createMock(IRequest::class), + $this->appConfig, + $this->logger, + $this->demoData + ); + } + + public function testStatusReportsTheDemoDataStep(): void { + $this->appConfig->method('getValueString')->willReturn(''); + + $data = $this->controller->status()->getData(); + + // Absence is the defect this guards: a step the wizard is never told + // about cannot be offered and cannot be completed. + $this->assertArrayHasKey('demo-data', $data['steps']); + $this->assertFalse($data['steps']['demo-data']['done']); + // This app declares no REQUIRED step, so setup must never gate the app. + $this->assertTrue($data['completed']); + $this->assertSame(1, $data['version']); + } + + public function testStatusReportsTheStepDoneOnceDecided(): void { + $this->appConfig->method('getValueString')->willReturn('skipped'); + + $data = $this->controller->status()->getData(); + + $this->assertTrue($data['steps']['demo-data']['done']); + } + + public function testSkippingIsAnAnswerAndIsRecorded(): void { + // Declining must be persisted, otherwise the wizard re-offers the import + // on every visit and "no thanks" is impossible to express. + $this->appConfig->expects($this->once()) + ->method('setValueString') + ->with('stackiq', 'demo_data_decided', 'skipped'); + + $response = $this->controller->runAction('skip-demo-data'); + + $this->assertTrue($response->getData()['success']); + } + + public function testUnknownActionIs404(): void { + $response = $this->controller->runAction('not-an-action'); + + $this->assertSame(404, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + } + + public function testInstallReportsHowMuchLanded(): void { + $this->demoData->method('install') + ->willReturn(['objects' => 30, 'registers' => 1, 'schemas' => 4]); + + $this->appConfig->expects($this->once()) + ->method('setValueString') + ->with('stackiq', 'demo_data_decided', 'installed'); + + $data = $this->controller->runAction('install-demo-data')->getData(); + + $this->assertTrue($data['success']); + // A success message that names no count cannot be told apart from an + // import that wrote nothing — the defect this programme already shipped. + $this->assertStringContainsString('30', $data['message']); + } + + public function testAFailedInstallIsReportedAndLeavesTheStepUNDECIDED(): void { + $this->demoData->method('install') + ->willThrowException(new RuntimeException('OpenRegister is not installed.')); + + // 🔴 THE POINT OF THIS TEST. Recording the decision here would close the + // step for an operator who asked for demo data and received none: the + // wizard would never offer it again, and nothing would have been + // imported. + $this->appConfig->expects($this->never())->method('setValueString'); + $this->logger->expects($this->once())->method('error'); + + $response = $this->controller->runAction('install-demo-data'); + + $this->assertSame(500, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + $this->assertStringContainsString('OpenRegister is not installed.', $response->getData()['message']); + } +} diff --git a/tests/Unit/Service/DemoDataServiceTest.php b/tests/Unit/Service/DemoDataServiceTest.php new file mode 100644 index 00000000..54eecea6 --- /dev/null +++ b/tests/Unit/Service/DemoDataServiceTest.php @@ -0,0 +1,134 @@ +appPath = sys_get_temp_dir() . '/or-demo-' . uniqid(); + mkdir($this->appPath . '/lib/Settings', 0777, true); + + $this->appManager = $this->createMock(IAppManager::class); + $this->appManager->method('getAppPath')->willReturn($this->appPath); + $this->appManager->method('getAppVersion')->willReturn('1.2.3'); + $this->appManager->method('getInstalledApps')->willReturn(['openregister']); + + $this->container = $this->createMock(ContainerInterface::class); + } + + protected function tearDown(): void { + $file = $this->descriptor(); + if (is_file($file) === true) { + unlink($file); + } + + @rmdir($this->appPath . '/lib/Settings'); + @rmdir($this->appPath . '/lib'); + @rmdir($this->appPath); + } + + private function descriptor(): string { + return $this->appPath . '/lib/Settings/stackiq_mock_register.json'; + } + + private function service(): DemoDataService { + return new DemoDataService( + $this->appManager, + $this->container, + $this->createMock(LoggerInterface::class) + ); + } + + public function testIsAvailableIsFalseWithoutADescriptor(): void { + $this->assertFalse($this->service()->isAvailable()); + } + + public function testIsAvailableIsTrueWithADescriptor(): void { + file_put_contents($this->descriptor(), '{}'); + + $this->assertTrue($this->service()->isAvailable()); + } + + public function testInstallThrowsWhenNoDatasetShips(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/No demo dataset/'); + + $this->service()->install(); + } + + public function testInstallThrowsOnInvalidJson(): void { + file_put_contents($this->descriptor(), 'not json at all'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/not valid JSON/'); + + $this->service()->install(); + } + + public function testInstallNamesTheMissingAppWhenOpenRegisterIsAbsent(): void { + file_put_contents($this->descriptor(), '{"components":{"objects":[]}}'); + $this->appManager = $this->createMock(IAppManager::class); + $this->appManager->method('getAppPath')->willReturn($this->appPath); + $this->appManager->method('getInstalledApps')->willReturn([]); + + // 🔴 The message must NAME the missing app. Asking the container for a + // class from an app that is not installed otherwise surfaces as an error + // about a class the operator never mentioned. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/OpenRegister/'); + + $this->service()->install(); + } + + public function testInstallCountsTheObjectsInTheFileNotTheImportersReply(): void { + file_put_contents( + $this->descriptor(), + json_encode(['components' => ['objects' => [['a' => 1], ['b' => 2], ['c' => 3]]]]) + ); + + // 🔴 THE PARAMETER NAMES ARE THE CONTRACT. install() calls this with + // named arguments, so a fake whose parameters are named differently + // fails at the call site rather than validating anything. + $importer = new class { + public array $seen = []; + + public function importFromApp(string $appId, array $data, string $version, bool $force): array { + $this->seen = ['appId' => $appId, 'version' => $version, 'force' => $force]; + + // Deliberately reports FEWER than the file holds: an object whose + // schema does not resolve is skipped, and the operator is told + // what was ASKED FOR so the discrepancy stays visible. + return ['registers' => [1], 'schemas' => [1, 1]]; + } + }; + $this->container->method('get')->willReturn($importer); + + $result = $this->service()->install(); + + $this->assertSame(3, $result['objects']); + $this->assertSame(1, $result['registers']); + $this->assertSame(2, $result['schemas']); + + // Its own configuration namespace, so a demo import cannot mask — or be + // masked by — a pending real configuration update. + $this->assertSame('stackiq.demo', $importer->seen['appId']); + $this->assertTrue($importer->seen['force']); + } +} diff --git a/tests/e2e/spec-coverage/demo-data-setup-step.spec.ts b/tests/e2e/spec-coverage/demo-data-setup-step.spec.ts new file mode 100644 index 00000000..2459c27b --- /dev/null +++ b/tests/e2e/spec-coverage/demo-data-setup-step.spec.ts @@ -0,0 +1,161 @@ +/* + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * ADR-111 — the demo-data setup step, exercised against a running instance. + * + * WHY THIS EXISTS. The programme that added demo data to this fleet shipped a + * defect that every unit test passed: the import printed `register "…" + * imported.` and seeded ZERO of the descriptor's objects. The unit tests could + * not see it — they mock the import service, so they validate the CALL and + * never its effect. + * + * So the assertion that matters here is not "the endpoint answers 200". It is + * that the response NAMES WHAT LANDED. A success message that cannot be told + * apart from an import that wrote nothing is exactly what let that defect + * through. + * + * WHY THE API AND NOT A CLICK-THROUGH. `CnAppRoot` opens the optional wizard + * only while an optional step is outstanding, and the CI seed deliberately + * settles those so the wizard stops covering the app in every test. The + * observable surface for this capability is therefore the contract the wizard + * calls — `GET /api/setup/status` and `POST /api/setup/action/{id}` — issued + * from inside the authenticated admin page so every call carries the real + * session and `OC.requestToken` through Nextcloud's `AuthorizedAdminSetting` + * middleware. A unit test with a mocked IAppConfig cannot show that middleware + * admitting the request; this can — and that middleware is precisely what the + * attribute on SetupController configures. + * + * WHAT THIS DELIBERATELY DOES NOT ASSERT. That the demo-data step is FIRST + * (ADR-111 rule 4) is a property of the manifest, which the app bundles rather + * than serves, so it is not observable from here. Gate 100 + * (`setup-demo-data-first`) checks it statically on every change. Claiming to + * prove it here would be asserting something this vantage point cannot see. + * + * @spec exclude ADR-042/ADR-111 setup contract; no per-app behavioural spec. + */ +import { test, expect, type Page } from '@playwright/test' +import * as path from 'path' + +const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') + +const BASE = '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/apps/stackiq' + +/** One authenticated JSON call issued from inside the logged-in admin page. */ +async function api( + page: Page, + method: string, + apiPath: string, +): Promise<{ status: number; json: any }> { + return await page.evaluate( + async ({ method, apiPath }) => { + const res = await fetch(apiPath, { + method, + headers: { + 'Content-Type': 'application/json', + // eslint-disable-next-line no-undef + requesttoken: (window as any).OC?.requestToken || '', + 'OCS-APIREQUEST': 'true', + }, + }) + let json: any = null + try { + json = await res.json() + } catch { + json = null + } + return { status: res.status, json } + }, + { method, apiPath }, + ) +} + +test.describe.configure({ mode: 'serial' }) + +test.describe('ADR-111 demo data', () => { + // The setup contract lives behind the admin middleware, so these calls need + // the real logged-in session `globalSetup` captured — not the suite's + // default Basic-auth header, which does not produce an `OC.requestToken`. + test.use({ storageState: STORAGE_STATE }) + + test.beforeEach(async ({ page }) => { + await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded' }) + await page.waitForFunction(() => (window as any).OC?.requestToken, null, { + timeout: 15000, + }) + }) + + test('setup status reports the demo-data step, so the wizard can offer it', async ({ + page, + }) => { + const res = await api(page, 'GET', `${BASE}/api/setup/status`) + + expect(res.status, 'setup/status must answer an authenticated admin').toBe( + 200, + ) + + // A step the endpoint never MENTIONS resolves to `done: false` forever — + // no operator action can clear it, and CnAppRoot then covers the app with + // the wizard in every fresh browser context. Absence is the defect here, + // not "not done". + expect( + Object.keys(res.json?.steps ?? {}), + 'setup/status must report a demo-data step', + ).toContain('demo-data') + }) + + test('installing the demo data reports HOW MUCH landed, not just success', async ({ + page, + }) => { + // 🔴 A REAL IMPORT, NOT A STUB. Measured on this fleet: the install arm + // took 42.8s on dossiq and 49.6s on shillinq, and exceeded the 30s + // default on one run. The operation is legitimately slow, and the + // assertion is worth its cost: it is the only check that the install + // WROTE something. + test.slow() + + const res = await api( + page, + 'POST', + `${BASE}/api/setup/action/install-demo-data`, + ) + + expect(res.status, 'the action must pass the admin middleware').toBe(200) + expect( + res.json?.success, + `install failed: ${JSON.stringify(res.json)}`, + ).toBe(true) + + // 🔴 THE COUNTS ARE THE ASSERTION. "Demo data installed" with no numbers + // is indistinguishable from an import that wrote nothing — the exact + // defect this programme shipped and had to fix. A message carrying a + // positive object count is the only evidence the data reached the + // instance. + const message = String(res.json?.message ?? '') + const numbers = (message.match(/\d+/g) ?? []).map(Number) + + expect( + numbers.some((n) => n > 0), + `the install message must name a non-zero object count; got: "${message}"`, + ).toBe(true) + }) + + test('re-installing is safe, because the step promises it is', async ({ + page, + }) => { + // The step body tells the operator it is "safe to run more than once". + // That sentence is a contract; this asserts the server keeps it rather + // than erroring or reporting failure on a second pass. + const again = await api( + page, + 'POST', + `${BASE}/api/setup/action/install-demo-data`, + ) + + expect(again.status).toBe(200) + expect( + again.json?.success, + `a second install must not fail: ${JSON.stringify(again.json)}`, + ).toBe(true) + }) +})