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' => '/api/dashboard', 'verb' => 'GET'], 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 new file mode 100644 index 00000000..a6e7ceaa --- /dev/null +++ b/lib/Controller/SetupController.php @@ -0,0 +1,180 @@ + + * @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 OCA\Stackiq\Settings\StackiqAdmin; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +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. + */ + #[AuthorizedAdminSetting(StackiqAdmin::class)] + 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. + */ + #[AuthorizedAdminSetting(StackiqAdmin::class)] + 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" 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 = '/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) + }) +})