From e6f53199e950be04cd036dd80db3f1aec972286d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 9 Aug 2026 17:35:57 +0200 Subject: [PATCH 1/4] test: pin the public review-aggregate wire contract and prove five page surfaces render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gate-25 contract-coverage 1 -> PASS, gate-26 visual-coverage 6 -> PASS, measured with hydra-gates 48c88ba against origin/beta — the scope a push to development actually runs. gate-25 — GET /api/reviews/aggregate had no automated proof `review#aggregate` is `#[PublicPage]`: an anonymous visitor on a module or dienst detail page reads it, so its response shape and status codes are part of the app's public surface. ReviewControllerContractTest pins them: - success is exactly `{average, count, items}` with HTTP 200; - the service's internal `ok`/`reason` bookkeeping never reaches the wire; - a subject with no approved reviews is a 200 with a null average, not a 404 — a consumer rendering a star widget has to tell "nothing approved yet" from "bad request"; - a rejected request is a 400 carrying exactly `message`; - subjectType/subjectId reach the service unaltered. gate-26 — five page components with no browser-level proof tests/e2e/spec-coverage/page-surfaces.spec.ts drives the REAL UI by clicking the app's own navigation, then asserts the page's own content rendered and that the app logged no console error and returned no 5xx: FacetedCatalogIndexView (Applications and Services), SuitesIndexView, PortfolioReport, and EolSyncSettings inside the admin settings shell. Asserting the shell alone would pass on a blank page, so each test also asserts something the page itself puts on screen. KwetsbaarhedenView and LicensePostureView already had real behavioural specs that the gate could not attribute to them; those two specs now name their page component in the docblock. No new assertion was invented for them and none was needed — the coverage already existed. Can-fail proof: removing the new files and reverting the two docblocks puts both counts back exactly — gate-25 1, gate-26 6. Mutating the controller to leak `ok` onto the wire turns 2 of the 5 contract tests red. Unit suite 524 tests green; phpcs lib/ unchanged at 0 errors / 87 warnings. --- .../ReviewControllerContractTest.php | 225 ++++++++++++++++++ .../e2e/spec-coverage/license-posture.spec.ts | 2 + tests/e2e/spec-coverage/page-surfaces.spec.ts | 91 +++++++ .../vulnerability-tracking.spec.ts | 2 + 4 files changed, 320 insertions(+) create mode 100644 tests/Unit/Controller/ReviewControllerContractTest.php create mode 100644 tests/e2e/spec-coverage/page-surfaces.spec.ts diff --git a/tests/Unit/Controller/ReviewControllerContractTest.php b/tests/Unit/Controller/ReviewControllerContractTest.php new file mode 100644 index 00000000..53e348c2 --- /dev/null +++ b/tests/Unit/Controller/ReviewControllerContractTest.php @@ -0,0 +1,225 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT: + * @link https://codeberg.org/Conduction/SoftwareCatalog + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Controller; + +use OCA\SoftwareCatalog\Controller\ReviewController; +use OCA\SoftwareCatalog\Service\ReviewAggregateService; +use OCA\SoftwareCatalog\Service\ReviewService; +use OCP\AppFramework\Http; +use OCP\IRequest; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Contract tests for the public review aggregate endpoint. + * + * @category Test + * @package OCA\SoftwareCatalog\Tests\Unit\Controller + * @author Conduction b.v. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT: + * @link https://codeberg.org/Conduction/SoftwareCatalog + */ +class ReviewControllerContractTest extends TestCase +{ + + /** @var ReviewAggregateService|MockObject */ + private ReviewAggregateService|MockObject $aggregateService; + + private ReviewController $controller; + + + /** + * Set up the controller with mocked services. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->aggregateService = $this->createMock(ReviewAggregateService::class); + + $this->controller = new ReviewController( + $this->createMock(IRequest::class), + $this->createMock(ReviewService::class), + $this->aggregateService + ); + + }//end setUp() + + + /** + * A successful aggregate returns exactly average/count/items with HTTP 200. + * + * @return void + */ + public function testAggregateSuccessBodyCarriesExactlyTheContractKeys(): void + { + $items = [ + ['uuid' => 'r-1', 'waardering' => 4], + ['uuid' => 'r-2', 'waardering' => 5], + ]; + + $this->aggregateService + ->method('getAggregate') + ->willReturn( + [ + 'ok' => true, + 'reason' => 'ok', + 'average' => 4.5, + 'count' => 2, + 'items' => $items, + ] + ); + + $response = $this->controller->aggregate('module', 'module-uuid'); + $body = $response->getData(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(['average', 'count', 'items'], array_keys($body)); + $this->assertSame(4.5, $body['average']); + $this->assertSame(2, $body['count']); + $this->assertSame($items, $body['items']); + + }//end testAggregateSuccessBodyCarriesExactlyTheContractKeys() + + + /** + * The service's internal `ok`/`reason` bookkeeping must never reach the wire. + * + * @return void + */ + public function testAggregateDoesNotLeakInternalBookkeepingKeys(): void + { + $this->aggregateService + ->method('getAggregate') + ->willReturn( + [ + 'ok' => true, + 'reason' => 'ok', + 'average' => null, + 'count' => 0, + 'items' => [], + ] + ); + + $body = $this->controller->aggregate('dienst', 'dienst-uuid')->getData(); + + $this->assertArrayNotHasKey('ok', $body); + $this->assertArrayNotHasKey('reason', $body); + + }//end testAggregateDoesNotLeakInternalBookkeepingKeys() + + + /** + * A subject with no approved reviews is a 200 with a null average, not a 404. + * + * A consumer rendering a star widget has to distinguish "nothing approved + * yet" from "bad request"; that difference is the contract. + * + * @return void + */ + public function testAggregateWithNoApprovedReviewsIsAnEmptyTwoHundred(): void + { + $this->aggregateService + ->method('getAggregate') + ->willReturn( + [ + 'ok' => true, + 'reason' => 'ok', + 'average' => null, + 'count' => 0, + 'items' => [], + ] + ); + + $response = $this->controller->aggregate('module', 'module-uuid'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertNull($response->getData()['average']); + $this->assertSame(0, $response->getData()['count']); + $this->assertSame([], $response->getData()['items']); + + }//end testAggregateWithNoApprovedReviewsIsAnEmptyTwoHundred() + + + /** + * A rejected request is a 400 carrying `message`, and no aggregate keys. + * + * @return void + */ + public function testAggregateFailureIsFourHundredWithAMessage(): void + { + $this->aggregateService + ->method('getAggregate') + ->willReturn( + [ + 'ok' => false, + 'reason' => 'invalid subject type', + 'average' => null, + 'count' => 0, + 'items' => [], + ] + ); + + $response = $this->controller->aggregate('bogus', 'some-uuid'); + $body = $response->getData(); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $this->assertSame(['message'], array_keys($body)); + $this->assertSame('invalid subject type', $body['message']); + + }//end testAggregateFailureIsFourHundredWithAMessage() + + + /** + * The caller's subjectType/subjectId reach the service unaltered. + * + * @return void + */ + public function testAggregatePassesTheSubjectThroughToTheService(): void + { + $this->aggregateService + ->expects($this->once()) + ->method('getAggregate') + ->with('dienst', 'the-subject-uuid') + ->willReturn( + [ + 'ok' => true, + 'reason' => 'ok', + 'average' => 3.0, + 'count' => 1, + 'items' => [], + ] + ); + + $this->controller->aggregate('dienst', 'the-subject-uuid'); + + }//end testAggregatePassesTheSubjectThroughToTheService() + + +}//end class diff --git a/tests/e2e/spec-coverage/license-posture.spec.ts b/tests/e2e/spec-coverage/license-posture.spec.ts index 84575dc7..ccf60995 100644 --- a/tests/e2e/spec-coverage/license-posture.spec.ts +++ b/tests/e2e/spec-coverage/license-posture.spec.ts @@ -3,6 +3,8 @@ /** * Behavioural e2e coverage for the software license-posture (SAM) surface. * + * Page component under test: src/views/LicensePostureView.vue. + * * Drives the REAL UI of the LicensePosture manifest custom page: the nav entry * reaches the posture dashboard, which renders the portfolio open-vs-closed * share (weighted by in-production deployment), the per-vendor rollup diff --git a/tests/e2e/spec-coverage/page-surfaces.spec.ts b/tests/e2e/spec-coverage/page-surfaces.spec.ts new file mode 100644 index 00000000..7a8aa348 --- /dev/null +++ b/tests/e2e/spec-coverage/page-surfaces.spec.ts @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2026 Conduction B.V. +/** + * Behavioural e2e coverage for four manifest page surfaces that had no + * browser-level proof at all, and for the EOL-sync settings section. + * + * Every test below drives the REAL UI: it clicks the app's own navigation the + * way a user does (or opens the Nextcloud admin settings section), then asserts + * the page's own content region rendered and that the app logged no console + * error and returned no 5xx. Asserting the shell alone would pass on a blank + * page, so each test also asserts something the page itself puts on screen. + * + * Components proved reachable here: + * - src/views/FacetedCatalogIndexView.vue (Applications and Services) + * - src/views/suites/SuitesIndexView.vue (Suites) + * - src/views/organisaties/PortfolioReport.vue (Portfolio rationalization) + * - src/views/settings/sections/EolSyncSettings.vue (admin settings section) + * + * LIVE-RUN NOTE: authored against the built app and run in CI's Playwright job + * against a freshly deployed instance; deploying a worktree to the shared dev + * instance is disallowed by policy, so these were not run by hand locally. + * + * @spec openspec/specs/gemma-faceted-search/spec.md + * @spec openspec/specs/suite-wizard/spec.md + * @spec openspec/specs/portfolio-rationalization-time/spec.md + * @spec openspec/specs/eol-feed-integration/spec.md + */ +import { test, expect } from '@playwright/test' +import { APP_MAIN, collectAppErrors, expectNoAppErrors, navClickTo } from './_helpers' + +test('applications index: FacetedCatalogIndexView renders its list surface', async ({ page }) => { + const bag = collectAppErrors(page) + await navClickTo(page, 'Applications') + + const main = page.locator(APP_MAIN).first() + await expect(main).toBeVisible({ timeout: 30000 }) + // The faceted index renders either rows or its own empty state — both are + // the page; a blank main region is not. + await expect(main.getByText(/Applications/i).first()).toBeVisible({ timeout: 30000 }) + + expectNoAppErrors(bag) +}) + +test('services index: FacetedCatalogIndexView renders for the dienst subject too', async ({ page }) => { + const bag = collectAppErrors(page) + await navClickTo(page, 'Services') + + const main = page.locator(APP_MAIN).first() + await expect(main).toBeVisible({ timeout: 30000 }) + await expect(main.getByText(/Services/i).first()).toBeVisible({ timeout: 30000 }) + + expectNoAppErrors(bag) +}) + +test('suites index: SuitesIndexView renders its list surface', async ({ page }) => { + const bag = collectAppErrors(page) + await navClickTo(page, 'Suites') + + const main = page.locator(APP_MAIN).first() + await expect(main).toBeVisible({ timeout: 30000 }) + await expect(main.getByText(/Suites/i).first()).toBeVisible({ timeout: 30000 }) + + expectNoAppErrors(bag) +}) + +test('portfolio rationalization: PortfolioReport renders its quadrant report', async ({ page }) => { + const bag = collectAppErrors(page) + await navClickTo(page, 'Portfolio rationalization') + + const main = page.locator(APP_MAIN).first() + await expect(main).toBeVisible({ timeout: 30000 }) + // The TIME report always renders its quadrant table header, with or without + // rows behind it. + await expect(main.getByText(/Quadrant|Portfolio rationalization/i).first()) + .toBeVisible({ timeout: 30000 }) + + expectNoAppErrors(bag) +}) + +test('admin settings: EolSyncSettings section renders inside the settings shell', async ({ page }) => { + const bag = collectAppErrors(page) + // `domcontentloaded`, not `networkidle`: Nextcloud keeps long-lived polls + // open so the network never goes idle (ADR-074 rule 4). + await page.goto('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/settings/admin/softwarecatalog', { waitUntil: 'domcontentloaded' }) + + const host = page.locator('#softwarecatalog-settings') + await expect(host).toBeVisible({ timeout: 30000 }) + await expect(host.getByText(/End.of.life|EOL/i).first()).toBeVisible({ timeout: 30000 }) + + expectNoAppErrors(bag) +}) diff --git a/tests/e2e/spec-coverage/vulnerability-tracking.spec.ts b/tests/e2e/spec-coverage/vulnerability-tracking.spec.ts index f46d1625..da70e641 100644 --- a/tests/e2e/spec-coverage/vulnerability-tracking.spec.ts +++ b/tests/e2e/spec-coverage/vulnerability-tracking.spec.ts @@ -3,6 +3,8 @@ /** * Behavioural e2e coverage for module vulnerability tracking & exposure. * + * Page component under test: src/views/KwetsbaarhedenView.vue. + * * Drives the REAL UI of the Kwetsbaarheden manifest custom page: the nav entry * reaches the vulnerability index, which shows the derived-severity table with * severity quick-filter tabs and a "Report vulnerability" action that opens the From 2f30d4b7cd199c2b7eaaeac90fdef22158b8ca82 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 9 Aug 2026 17:35:57 +0200 Subject: [PATCH 2/4] test: pin the public review-aggregate wire contract and prove five page surfaces render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gate-25 contract-coverage 1 -> PASS, gate-26 visual-coverage 6 -> PASS, measured with hydra-gates 48c88ba against origin/beta — the scope a push to development actually runs. gate-25 — GET /api/reviews/aggregate had no automated proof `review#aggregate` is `#[PublicPage]`: an anonymous visitor on a module or dienst detail page reads it, so its response shape and status codes are part of the app's public surface. ReviewControllerContractTest pins them: - success is exactly `{average, count, items}` with HTTP 200; - the service's internal `ok`/`reason` bookkeeping never reaches the wire; - a subject with no approved reviews is a 200 with a null average, not a 404 — a consumer rendering a star widget has to tell "nothing approved yet" from "bad request"; - a rejected request is a 400 carrying exactly `message`; - subjectType/subjectId reach the service unaltered. gate-26 — five page components with no browser-level proof tests/e2e/spec-coverage/page-surfaces.spec.ts drives the REAL UI by clicking the app's own navigation, then asserts the page's own content rendered and that the app logged no console error and returned no 5xx: FacetedCatalogIndexView (Applications and Services), SuitesIndexView, PortfolioReport, and EolSyncSettings inside the admin settings shell. Asserting the shell alone would pass on a blank page, so each test also asserts something the page itself puts on screen. KwetsbaarhedenView and LicensePostureView already had real behavioural specs that the gate could not attribute to them; those two specs now name their page component in the docblock. No new assertion was invented for them and none was needed — the coverage already existed. Can-fail proof: removing the new files and reverting the two docblocks puts both counts back exactly — gate-25 1, gate-26 6. Mutating the controller to leak `ok` onto the wire turns 2 of the 5 contract tests red. Unit suite 524 tests green; phpcs lib/ unchanged at 0 errors / 87 warnings. --- .../ReviewControllerContractTest.php | 225 ++++++++++++++++++ .../e2e/spec-coverage/license-posture.spec.ts | 2 + tests/e2e/spec-coverage/page-surfaces.spec.ts | 91 +++++++ .../vulnerability-tracking.spec.ts | 2 + 4 files changed, 320 insertions(+) create mode 100644 tests/Unit/Controller/ReviewControllerContractTest.php create mode 100644 tests/e2e/spec-coverage/page-surfaces.spec.ts diff --git a/tests/Unit/Controller/ReviewControllerContractTest.php b/tests/Unit/Controller/ReviewControllerContractTest.php new file mode 100644 index 00000000..53e348c2 --- /dev/null +++ b/tests/Unit/Controller/ReviewControllerContractTest.php @@ -0,0 +1,225 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT: + * @link https://codeberg.org/Conduction/SoftwareCatalog + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Controller; + +use OCA\SoftwareCatalog\Controller\ReviewController; +use OCA\SoftwareCatalog\Service\ReviewAggregateService; +use OCA\SoftwareCatalog\Service\ReviewService; +use OCP\AppFramework\Http; +use OCP\IRequest; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Contract tests for the public review aggregate endpoint. + * + * @category Test + * @package OCA\SoftwareCatalog\Tests\Unit\Controller + * @author Conduction b.v. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT: + * @link https://codeberg.org/Conduction/SoftwareCatalog + */ +class ReviewControllerContractTest extends TestCase +{ + + /** @var ReviewAggregateService|MockObject */ + private ReviewAggregateService|MockObject $aggregateService; + + private ReviewController $controller; + + + /** + * Set up the controller with mocked services. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->aggregateService = $this->createMock(ReviewAggregateService::class); + + $this->controller = new ReviewController( + $this->createMock(IRequest::class), + $this->createMock(ReviewService::class), + $this->aggregateService + ); + + }//end setUp() + + + /** + * A successful aggregate returns exactly average/count/items with HTTP 200. + * + * @return void + */ + public function testAggregateSuccessBodyCarriesExactlyTheContractKeys(): void + { + $items = [ + ['uuid' => 'r-1', 'waardering' => 4], + ['uuid' => 'r-2', 'waardering' => 5], + ]; + + $this->aggregateService + ->method('getAggregate') + ->willReturn( + [ + 'ok' => true, + 'reason' => 'ok', + 'average' => 4.5, + 'count' => 2, + 'items' => $items, + ] + ); + + $response = $this->controller->aggregate('module', 'module-uuid'); + $body = $response->getData(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(['average', 'count', 'items'], array_keys($body)); + $this->assertSame(4.5, $body['average']); + $this->assertSame(2, $body['count']); + $this->assertSame($items, $body['items']); + + }//end testAggregateSuccessBodyCarriesExactlyTheContractKeys() + + + /** + * The service's internal `ok`/`reason` bookkeeping must never reach the wire. + * + * @return void + */ + public function testAggregateDoesNotLeakInternalBookkeepingKeys(): void + { + $this->aggregateService + ->method('getAggregate') + ->willReturn( + [ + 'ok' => true, + 'reason' => 'ok', + 'average' => null, + 'count' => 0, + 'items' => [], + ] + ); + + $body = $this->controller->aggregate('dienst', 'dienst-uuid')->getData(); + + $this->assertArrayNotHasKey('ok', $body); + $this->assertArrayNotHasKey('reason', $body); + + }//end testAggregateDoesNotLeakInternalBookkeepingKeys() + + + /** + * A subject with no approved reviews is a 200 with a null average, not a 404. + * + * A consumer rendering a star widget has to distinguish "nothing approved + * yet" from "bad request"; that difference is the contract. + * + * @return void + */ + public function testAggregateWithNoApprovedReviewsIsAnEmptyTwoHundred(): void + { + $this->aggregateService + ->method('getAggregate') + ->willReturn( + [ + 'ok' => true, + 'reason' => 'ok', + 'average' => null, + 'count' => 0, + 'items' => [], + ] + ); + + $response = $this->controller->aggregate('module', 'module-uuid'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertNull($response->getData()['average']); + $this->assertSame(0, $response->getData()['count']); + $this->assertSame([], $response->getData()['items']); + + }//end testAggregateWithNoApprovedReviewsIsAnEmptyTwoHundred() + + + /** + * A rejected request is a 400 carrying `message`, and no aggregate keys. + * + * @return void + */ + public function testAggregateFailureIsFourHundredWithAMessage(): void + { + $this->aggregateService + ->method('getAggregate') + ->willReturn( + [ + 'ok' => false, + 'reason' => 'invalid subject type', + 'average' => null, + 'count' => 0, + 'items' => [], + ] + ); + + $response = $this->controller->aggregate('bogus', 'some-uuid'); + $body = $response->getData(); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $this->assertSame(['message'], array_keys($body)); + $this->assertSame('invalid subject type', $body['message']); + + }//end testAggregateFailureIsFourHundredWithAMessage() + + + /** + * The caller's subjectType/subjectId reach the service unaltered. + * + * @return void + */ + public function testAggregatePassesTheSubjectThroughToTheService(): void + { + $this->aggregateService + ->expects($this->once()) + ->method('getAggregate') + ->with('dienst', 'the-subject-uuid') + ->willReturn( + [ + 'ok' => true, + 'reason' => 'ok', + 'average' => 3.0, + 'count' => 1, + 'items' => [], + ] + ); + + $this->controller->aggregate('dienst', 'the-subject-uuid'); + + }//end testAggregatePassesTheSubjectThroughToTheService() + + +}//end class diff --git a/tests/e2e/spec-coverage/license-posture.spec.ts b/tests/e2e/spec-coverage/license-posture.spec.ts index 84575dc7..ccf60995 100644 --- a/tests/e2e/spec-coverage/license-posture.spec.ts +++ b/tests/e2e/spec-coverage/license-posture.spec.ts @@ -3,6 +3,8 @@ /** * Behavioural e2e coverage for the software license-posture (SAM) surface. * + * Page component under test: src/views/LicensePostureView.vue. + * * Drives the REAL UI of the LicensePosture manifest custom page: the nav entry * reaches the posture dashboard, which renders the portfolio open-vs-closed * share (weighted by in-production deployment), the per-vendor rollup diff --git a/tests/e2e/spec-coverage/page-surfaces.spec.ts b/tests/e2e/spec-coverage/page-surfaces.spec.ts new file mode 100644 index 00000000..7a8aa348 --- /dev/null +++ b/tests/e2e/spec-coverage/page-surfaces.spec.ts @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2026 Conduction B.V. +/** + * Behavioural e2e coverage for four manifest page surfaces that had no + * browser-level proof at all, and for the EOL-sync settings section. + * + * Every test below drives the REAL UI: it clicks the app's own navigation the + * way a user does (or opens the Nextcloud admin settings section), then asserts + * the page's own content region rendered and that the app logged no console + * error and returned no 5xx. Asserting the shell alone would pass on a blank + * page, so each test also asserts something the page itself puts on screen. + * + * Components proved reachable here: + * - src/views/FacetedCatalogIndexView.vue (Applications and Services) + * - src/views/suites/SuitesIndexView.vue (Suites) + * - src/views/organisaties/PortfolioReport.vue (Portfolio rationalization) + * - src/views/settings/sections/EolSyncSettings.vue (admin settings section) + * + * LIVE-RUN NOTE: authored against the built app and run in CI's Playwright job + * against a freshly deployed instance; deploying a worktree to the shared dev + * instance is disallowed by policy, so these were not run by hand locally. + * + * @spec openspec/specs/gemma-faceted-search/spec.md + * @spec openspec/specs/suite-wizard/spec.md + * @spec openspec/specs/portfolio-rationalization-time/spec.md + * @spec openspec/specs/eol-feed-integration/spec.md + */ +import { test, expect } from '@playwright/test' +import { APP_MAIN, collectAppErrors, expectNoAppErrors, navClickTo } from './_helpers' + +test('applications index: FacetedCatalogIndexView renders its list surface', async ({ page }) => { + const bag = collectAppErrors(page) + await navClickTo(page, 'Applications') + + const main = page.locator(APP_MAIN).first() + await expect(main).toBeVisible({ timeout: 30000 }) + // The faceted index renders either rows or its own empty state — both are + // the page; a blank main region is not. + await expect(main.getByText(/Applications/i).first()).toBeVisible({ timeout: 30000 }) + + expectNoAppErrors(bag) +}) + +test('services index: FacetedCatalogIndexView renders for the dienst subject too', async ({ page }) => { + const bag = collectAppErrors(page) + await navClickTo(page, 'Services') + + const main = page.locator(APP_MAIN).first() + await expect(main).toBeVisible({ timeout: 30000 }) + await expect(main.getByText(/Services/i).first()).toBeVisible({ timeout: 30000 }) + + expectNoAppErrors(bag) +}) + +test('suites index: SuitesIndexView renders its list surface', async ({ page }) => { + const bag = collectAppErrors(page) + await navClickTo(page, 'Suites') + + const main = page.locator(APP_MAIN).first() + await expect(main).toBeVisible({ timeout: 30000 }) + await expect(main.getByText(/Suites/i).first()).toBeVisible({ timeout: 30000 }) + + expectNoAppErrors(bag) +}) + +test('portfolio rationalization: PortfolioReport renders its quadrant report', async ({ page }) => { + const bag = collectAppErrors(page) + await navClickTo(page, 'Portfolio rationalization') + + const main = page.locator(APP_MAIN).first() + await expect(main).toBeVisible({ timeout: 30000 }) + // The TIME report always renders its quadrant table header, with or without + // rows behind it. + await expect(main.getByText(/Quadrant|Portfolio rationalization/i).first()) + .toBeVisible({ timeout: 30000 }) + + expectNoAppErrors(bag) +}) + +test('admin settings: EolSyncSettings section renders inside the settings shell', async ({ page }) => { + const bag = collectAppErrors(page) + // `domcontentloaded`, not `networkidle`: Nextcloud keeps long-lived polls + // open so the network never goes idle (ADR-074 rule 4). + await page.goto('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/settings/admin/softwarecatalog', { waitUntil: 'domcontentloaded' }) + + const host = page.locator('#softwarecatalog-settings') + await expect(host).toBeVisible({ timeout: 30000 }) + await expect(host.getByText(/End.of.life|EOL/i).first()).toBeVisible({ timeout: 30000 }) + + expectNoAppErrors(bag) +}) diff --git a/tests/e2e/spec-coverage/vulnerability-tracking.spec.ts b/tests/e2e/spec-coverage/vulnerability-tracking.spec.ts index f46d1625..da70e641 100644 --- a/tests/e2e/spec-coverage/vulnerability-tracking.spec.ts +++ b/tests/e2e/spec-coverage/vulnerability-tracking.spec.ts @@ -3,6 +3,8 @@ /** * Behavioural e2e coverage for module vulnerability tracking & exposure. * + * Page component under test: src/views/KwetsbaarhedenView.vue. + * * Drives the REAL UI of the Kwetsbaarheden manifest custom page: the nav entry * reaches the vulnerability index, which shows the derived-severity table with * severity quick-filter tabs and a "Report vulnerability" action that opens the From 0d990cb63b9ad377c339ac6bfb4d549b8f6c3c55 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 9 Aug 2026 21:53:50 +0200 Subject: [PATCH 3/4] test(e2e): assert on the ITEM, not the container, in the page-surface specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first draft asserted the page's title text inside
. That passes on a blank page and on the WRONG page: the shell renders
for every route and the nav echoes the same label, so the assertion could not distinguish 'the page rendered' from 'something rendered'. Each test now asserts on markup only the component under test declares: - FacetedCatalogIndexView -> the CnFacetSidebar title plus all four GEMMA dimensions it builds from DIMENSION_LABELS (Reference component, Standard, Application service, Domain); - SuitesIndexView -> the 'New suite' wizard trigger in its own action slot; - PortfolioReport -> [data-testid=pr-summary] and its five declared column headers, read as columnheader roles; - EolSyncSettings -> the 'End-of-life feed sync' section name and its 'Sync now' control. These anchors are declared by the component rather than derived from rows, so an empty seed makes them ABSENT rather than merely empty — which is what makes them a check that can fail. --- tests/e2e/spec-coverage/page-surfaces.spec.ts | 103 +++++++++++++----- 1 file changed, 74 insertions(+), 29 deletions(-) diff --git a/tests/e2e/spec-coverage/page-surfaces.spec.ts b/tests/e2e/spec-coverage/page-surfaces.spec.ts index 7a8aa348..35a7e477 100644 --- a/tests/e2e/spec-coverage/page-surfaces.spec.ts +++ b/tests/e2e/spec-coverage/page-surfaces.spec.ts @@ -6,9 +6,22 @@ * * Every test below drives the REAL UI: it clicks the app's own navigation the * way a user does (or opens the Nextcloud admin settings section), then asserts - * the page's own content region rendered and that the app logged no console - * error and returned no 5xx. Asserting the shell alone would pass on a blank - * page, so each test also asserts something the page itself puts on screen. + * the app logged no console error and returned no 5xx. + * + * ASSERTION LEVEL — these assert on the ITEM, not the container. Asserting the + * `
` region, the page title, or a word that also appears in the nav would + * pass on a blank page and on the WRONG page: the shell renders `main` and + * echoes the nav label for every route. So each test asserts on markup only the + * page component under test declares — the GEMMA dimension filters that + * `FacetedCatalogIndexView` builds from `DIMENSION_LABELS`, the "New suite" + * action `SuitesIndexView` puts in its own action slot, the quadrant table + * `PortfolioReport` renders from its own `quadrantSummary`, and the "Sync now" + * control that exists only inside `EolSyncSettings`. + * + * Those anchors are also dataset-independent: they are declared by the + * component rather than derived from rows, so an empty seed makes them absent, + * not merely empty — which is exactly the property that makes them a real + * check rather than one that cannot fail. * * Components proved reachable here: * - src/views/FacetedCatalogIndexView.vue (Applications and Services) @@ -16,68 +29,94 @@ * - src/views/organisaties/PortfolioReport.vue (Portfolio rationalization) * - src/views/settings/sections/EolSyncSettings.vue (admin settings section) * - * LIVE-RUN NOTE: authored against the built app and run in CI's Playwright job - * against a freshly deployed instance; deploying a worktree to the shared dev - * instance is disallowed by policy, so these were not run by hand locally. - * * @spec openspec/specs/gemma-faceted-search/spec.md * @spec openspec/specs/suite-wizard/spec.md * @spec openspec/specs/portfolio-rationalization-time/spec.md * @spec openspec/specs/eol-feed-integration/spec.md */ -import { test, expect } from '@playwright/test' +import { test, expect, type Page } from '@playwright/test' import { APP_MAIN, collectAppErrors, expectNoAppErrors, navClickTo } from './_helpers' -test('applications index: FacetedCatalogIndexView renders its list surface', async ({ page }) => { - const bag = collectAppErrors(page) - await navClickTo(page, 'Applications') +/** + * The four GEMMA dimensions `FacetedCatalogIndexView` declares in + * `DIMENSION_LABELS` and passes to `CnFacetSidebar` as its `filters` prop. + * No other page in the app renders this set. + */ +const GEMMA_DIMENSIONS = ['Reference component', 'Standard', 'Application service', 'Domain'] +/** Assert the faceted index's own sidebar rendered, dimension by dimension. */ +async function expectGemmaFacetSidebar(page: Page): Promise { const main = page.locator(APP_MAIN).first() await expect(main).toBeVisible({ timeout: 30000 }) - // The faceted index renders either rows or its own empty state — both are - // the page; a blank main region is not. - await expect(main.getByText(/Applications/i).first()).toBeVisible({ timeout: 30000 }) + + // The sidebar's own title, which this view supplies. + await expect(main.getByText('GEMMA filters', { exact: false }).first()) + .toBeVisible({ timeout: 30000 }) + + for (const dimension of GEMMA_DIMENSIONS) { + await expect( + main.getByText(dimension, { exact: false }).first(), + `GEMMA dimension "${dimension}" missing — the facet sidebar did not render`, + ).toBeVisible({ timeout: 30000 }) + } +} + +test('applications index: FacetedCatalogIndexView renders its GEMMA facet sidebar', async ({ page }) => { + const bag = collectAppErrors(page) + await navClickTo(page, 'Applications') + + await expectGemmaFacetSidebar(page) expectNoAppErrors(bag) }) -test('services index: FacetedCatalogIndexView renders for the dienst subject too', async ({ page }) => { +test('services index: FacetedCatalogIndexView renders the same sidebar for the dienst subject', async ({ page }) => { const bag = collectAppErrors(page) await navClickTo(page, 'Services') - const main = page.locator(APP_MAIN).first() - await expect(main).toBeVisible({ timeout: 30000 }) - await expect(main.getByText(/Services/i).first()).toBeVisible({ timeout: 30000 }) + // Same component, second schema — this is the assertion that proves the + // view is reused for `dienst` and not only for `module`. + await expectGemmaFacetSidebar(page) expectNoAppErrors(bag) }) -test('suites index: SuitesIndexView renders its list surface', async ({ page }) => { +test('suites index: SuitesIndexView renders its own New suite action', async ({ page }) => { const bag = collectAppErrors(page) await navClickTo(page, 'Suites') const main = page.locator(APP_MAIN).first() await expect(main).toBeVisible({ timeout: 30000 }) - await expect(main.getByText(/Suites/i).first()).toBeVisible({ timeout: 30000 }) + + // The wizard trigger lives in this view's own CnIndexPage action slot; no + // other index page declares it. + await expect(main.getByRole('button', { name: 'New suite' }).first()) + .toBeVisible({ timeout: 30000 }) expectNoAppErrors(bag) }) -test('portfolio rationalization: PortfolioReport renders its quadrant report', async ({ page }) => { +test('portfolio rationalization: PortfolioReport renders its quadrant summary table', async ({ page }) => { const bag = collectAppErrors(page) await navClickTo(page, 'Portfolio rationalization') - const main = page.locator(APP_MAIN).first() - await expect(main).toBeVisible({ timeout: 30000 }) - // The TIME report always renders its quadrant table header, with or without - // rows behind it. - await expect(main.getByText(/Quadrant|Portfolio rationalization/i).first()) - .toBeVisible({ timeout: 30000 }) + const summary = page.locator('[data-testid="pr-summary"]') + await expect(summary).toBeVisible({ timeout: 30000 }) + + // The TIME report's own column set. The header row is declared by the + // component, so it is present with or without rows behind it — and absent + // on any other page. + for (const column of ['Quadrant', 'Count', 'EOL exposed', 'Cloud-transition share', 'Annualised cost']) { + await expect( + summary.getByRole('columnheader', { name: column, exact: false }).first(), + `quadrant-summary column "${column}" missing`, + ).toBeVisible({ timeout: 30000 }) + } expectNoAppErrors(bag) }) -test('admin settings: EolSyncSettings section renders inside the settings shell', async ({ page }) => { +test('admin settings: EolSyncSettings renders its section and sync control', async ({ page }) => { const bag = collectAppErrors(page) // `domcontentloaded`, not `networkidle`: Nextcloud keeps long-lived polls // open so the network never goes idle (ADR-074 rule 4). @@ -85,7 +124,13 @@ test('admin settings: EolSyncSettings section renders inside the settings shell' const host = page.locator('#softwarecatalog-settings') await expect(host).toBeVisible({ timeout: 30000 }) - await expect(host.getByText(/End.of.life|EOL/i).first()).toBeVisible({ timeout: 30000 }) + + // The section name and the manual-trigger button are both declared by + // EolSyncSettings.vue and by nothing else in the settings shell. + await expect(host.getByText('End-of-life feed sync', { exact: false }).first()) + .toBeVisible({ timeout: 30000 }) + await expect(host.getByRole('button', { name: 'Sync now' }).first()) + .toBeVisible({ timeout: 30000 }) expectNoAppErrors(bag) }) From a13f0b53f66d61b38662fee577f8718133a0c9f9 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 9 Aug 2026 22:18:18 +0200 Subject: [PATCH 4/4] fix(facets): the GEMMA facet sidebar never rendered a single filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by strengthening the gate-26 e2e assertions from the container to the ITEM. The first draft asserted the page title inside
; it passed. Asserting the four GEMMA dimensions the page claims to render failed immediately. FacetedCatalogIndexView passed `:filters="facetDimensionFilters"` to CnFacetSidebar. CnFacetSidebar declares no `filters` prop — its props are `schema`, `facetData`, `activeFilters`, `loading`, `title`, `clearLabel`, `userIsAdmin` — and it derives its own list with `effectiveFilters() => filtersFromSchema(this.schema)`. Vue drops an undeclared prop into `$attrs` silently, `schema` was never passed, and `filtersFromSchema(null)` returns []. So the sidebar rendered its "GEMMA filters" title over an empty body: no console error, no build error, no failing test. Verified against the SHIPPED dist of @conduction/nextcloud-vue 1.0.0-beta.213, not only its src/. The fix passes what the component actually declares. `buildFacetDimensionSchema` (src/utils/facetSchema.js) builds the schema document `filtersFromSchema` reads — `facetable: true` per property, `title` for the label, `order` for the sequence — so the four dimensions become four selects whose options come from the live facet counts this feature already fetches. tests/vitest/facetSchema.spec.js pins that contract against the REAL `filtersFromSchema` imported from the installed package, not a local copy of its rules — a copied rule set is only as fresh as its last manual edit and fails in both directions. One test is a positive control: it feeds the pre-fix shape (the derived filter LIST, no `properties` key) to the real function and asserts it yields [], which is the defect reproduced. Also corrected in the e2e suite: the PortfolioReport assertion targeted [data-testid=pr-summary], which sits behind `v-else-if="selectedOrg && report"`. On an instance with no organisation selected the page correctly renders its empty state, so that assertion was asserting on seed data rather than on the page. It now asserts the unconditional "Refresh report" control plus whichever of the page's two legitimate states is showing. vitest 220/220 (5 new). check:manifest Ajv PASS. gates 25 and 26 PASS. --- src/utils/facetSchema.js | 43 ++++++++++ src/views/FacetedCatalogIndexView.vue | 36 ++++++--- tests/e2e/spec-coverage/page-surfaces.spec.ts | 31 ++++--- tests/vitest/facetSchema.spec.js | 81 +++++++++++++++++++ 4 files changed, 166 insertions(+), 25 deletions(-) create mode 100644 src/utils/facetSchema.js create mode 100644 tests/vitest/facetSchema.spec.js diff --git a/src/utils/facetSchema.js b/src/utils/facetSchema.js new file mode 100644 index 00000000..2d31102c --- /dev/null +++ b/src/utils/facetSchema.js @@ -0,0 +1,43 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * Builder for the schema document `CnFacetSidebar` consumes. + * + * `CnFacetSidebar` does NOT take a ready-made filter list. Its props are + * `schema`, `facetData`, `activeFilters`, `loading`, `title`, `clearLabel` and + * `userIsAdmin`; it derives its own filters with + * `effectiveFilters() => filtersFromSchema(this.schema)`. Passing a `filters` + * prop is silently dropped into `$attrs`, and `filtersFromSchema(null)` + * returns `[]` — a sidebar with a title and an empty body, no console error. + * + * This builder produces the shape `filtersFromSchema` actually reads, and + * `tests/vitest/facetSchema.spec.js` asserts that against the REAL function + * loaded from the installed package rather than a local copy of its rules. + */ + +/** + * Build a schema document whose facetable properties are the given dimensions. + * + * @param {Record string>} dimensionLabels Dimension key → label thunk. + * @return {{properties: Record}} A schema document for `CnFacetSidebar`. + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-sidebar-ui-on-the-module-and-dienst-index-pages + */ +export function buildFacetDimensionSchema(dimensionLabels) { + const properties = {} + + Object.keys(dimensionLabels || {}).forEach((key, index) => { + properties[key] = { + type: 'string', + // `filtersFromSchema` labels a filter from `title`, falling back to + // the raw key — a missing title degrades to "referentiecomponent". + title: dimensionLabels[key](), + // Without this the property is filtered out entirely. + facetable: true, + order: index, + } + }) + + return { properties } +} diff --git a/src/views/FacetedCatalogIndexView.vue b/src/views/FacetedCatalogIndexView.vue index 0f0777cf..b1ee8850 100644 --- a/src/views/FacetedCatalogIndexView.vue +++ b/src/views/FacetedCatalogIndexView.vue @@ -93,7 +93,7 @@ generic route-query-to-filter passthrough never sees it (see the
translated label, matching `FacetController`'s query params. */ @@ -238,20 +239,29 @@ export default { computed: { /** - * `CnFacetSidebar`'s `filters` prop — one `select` entry per GEMMA - * dimension. `options` is left empty: `CnFacetSidebar.getFilterOptions()` - * prefers live `facetData` (this feature's counts) over static - * `options` whenever both are present. + * `CnFacetSidebar`'s `schema` prop — a schema-shaped document whose + * facetable properties are the four GEMMA dimensions. * - * @return {Array} The filter definitions. + * ⚠️ This USED to be a `filters` prop carrying the already-derived + * filter list. `CnFacetSidebar` declares no `filters` prop: its props + * are `schema`, `facetData`, `activeFilters`, `loading`, `title`, + * `clearLabel`, `userIsAdmin`, and it derives its filter list itself + * via `effectiveFilters() => filtersFromSchema(this.schema)`. Vue drops + * an undeclared prop into `$attrs` silently, so the four dimensions + * were passed, discarded, and `filtersFromSchema(null)` returned `[]` — + * the sidebar rendered its title and an empty body, and no console + * error was logged. Verified against the shipped + * `@conduction/nextcloud-vue` dist, not only its `src/`. + * + * `filtersFromSchema` keeps only properties with `facetable: true`, + * orders them by `order`, labels them from `title`, and (absent an + * `enum`) makes each a `select` whose options come from live + * `facetData` — which is exactly what this feature supplies. + * + * @return {object} A schema document with the four facetable dimensions. */ - facetDimensionFilters() { - return Object.keys(DIMENSION_LABELS).map((key) => ({ - key, - label: DIMENSION_LABELS[key](), - type: 'select', - options: [], - })) + facetDimensionSchema() { + return buildFacetDimensionSchema(DIMENSION_LABELS) }, /** @return {string} The current free-text search term for this schema. */ diff --git a/tests/e2e/spec-coverage/page-surfaces.spec.ts b/tests/e2e/spec-coverage/page-surfaces.spec.ts index 35a7e477..5ea376de 100644 --- a/tests/e2e/spec-coverage/page-surfaces.spec.ts +++ b/tests/e2e/spec-coverage/page-surfaces.spec.ts @@ -96,22 +96,29 @@ test('suites index: SuitesIndexView renders its own New suite action', async ({ expectNoAppErrors(bag) }) -test('portfolio rationalization: PortfolioReport renders its quadrant summary table', async ({ page }) => { +test('portfolio rationalization: PortfolioReport renders its report chrome', async ({ page }) => { const bag = collectAppErrors(page) await navClickTo(page, 'Portfolio rationalization') - const summary = page.locator('[data-testid="pr-summary"]') - await expect(summary).toBeVisible({ timeout: 30000 }) + const main = page.locator(APP_MAIN).first() + await expect(main).toBeVisible({ timeout: 30000 }) - // The TIME report's own column set. The header row is declared by the - // component, so it is present with or without rows behind it — and absent - // on any other page. - for (const column of ['Quadrant', 'Count', 'EOL exposed', 'Cloud-transition share', 'Annualised cost']) { - await expect( - summary.getByRole('columnheader', { name: column, exact: false }).first(), - `quadrant-summary column "${column}" missing`, - ).toBeVisible({ timeout: 30000 }) - } + // The refresh control is declared unconditionally by THIS component and by + // nothing else in the app. + // + // ⚠️ NOT `[data-testid="pr-summary"]`: that section sits behind + // `v-else-if="selectedOrg && report"`. On an instance with no organisation + // selected the page correctly renders its empty state instead, so asserting + // the summary table would be asserting on seed data, not on the page. + await expect(main.getByRole('button', { name: 'Refresh report' }).first()) + .toBeVisible({ timeout: 30000 }) + + // …and the page is in one of its two legitimate states: the org-picker + // empty state, or the rendered quadrant summary. Both are this page's own + // markup; neither is the shell. + const emptyState = main.getByText('Pick an organisation above', { exact: false }).first() + const summary = page.locator('[data-testid="pr-summary"]') + await expect(emptyState.or(summary)).toBeVisible({ timeout: 30000 }) expectNoAppErrors(bag) }) diff --git a/tests/vitest/facetSchema.spec.js b/tests/vitest/facetSchema.spec.js new file mode 100644 index 00000000..f3e5f0ec --- /dev/null +++ b/tests/vitest/facetSchema.spec.js @@ -0,0 +1,81 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * Contract test for the GEMMA facet sidebar's schema document. + * + * The bug this pins: `FacetedCatalogIndexView` passed `:filters` to + * `CnFacetSidebar`, which declares no such prop. Vue drops an undeclared prop + * into `$attrs` silently, so the four GEMMA dimensions were discarded and + * `filtersFromSchema(null)` returned `[]` — the sidebar rendered its title and + * an empty body, with no console error and no failing test. + * + * ⚠️ These assertions run against the REAL `filtersFromSchema` loaded from the + * installed `@conduction/nextcloud-vue`, not a local restatement of its rules. + * A local copy of a dependency's rules is only as fresh as its last manual + * edit and fails in both directions — the same reason the widget-icon spec + * reads the package's own registry. + */ + +import { describe, it, expect } from 'vitest' +import { buildFacetDimensionSchema } from '../../src/utils/facetSchema.js' +// The REAL implementation from the installed package — not a local restatement. +import { filtersFromSchema } from '../../node_modules/@conduction/nextcloud-vue/src/utils/schema.js' + +/** The four GEMMA dimensions FacetedCatalogIndexView declares. */ +const DIMENSION_LABELS = { + referentiecomponent: () => 'Reference component', + standaard: () => 'Standard', + applicatieservice: () => 'Application service', + domein: () => 'Domain', +} + +describe('buildFacetDimensionSchema', () => { + it('produces a document the real filtersFromSchema turns into one filter per dimension', () => { + const filters = filtersFromSchema(buildFacetDimensionSchema(DIMENSION_LABELS)) + + expect(filters).toHaveLength(4) + expect(filters.map((f) => f.key)).toEqual([ + 'referentiecomponent', + 'standaard', + 'applicatieservice', + 'domein', + ]) + }) + + it('labels every filter from the dimension title, never from the raw key', () => { + const filters = filtersFromSchema(buildFacetDimensionSchema(DIMENSION_LABELS)) + + expect(filters.map((f) => f.label)).toEqual([ + 'Reference component', + 'Standard', + 'Application service', + 'Domain', + ]) + }) + + it('makes every dimension a select, so live facet counts become its options', () => { + const filters = filtersFromSchema(buildFacetDimensionSchema(DIMENSION_LABELS)) + + expect(filters.every((f) => f.type === 'select')).toBe(true) + }) + + it('drops every dimension if facetable is not set — the failure mode being guarded', () => { + // Reproduce the pre-fix shape: the already-derived filter LIST, which + // carries no `properties` key at all. + const derivedListShape = Object.keys(DIMENSION_LABELS).map((key) => ({ + key, + label: DIMENSION_LABELS[key](), + type: 'select', + options: [], + })) + + expect(filtersFromSchema(derivedListShape)).toEqual([]) + expect(filtersFromSchema(null)).toEqual([]) + }) + + it('returns an empty properties bag for an empty dimension set', () => { + expect(buildFacetDimensionSchema({})).toEqual({ properties: {} }) + expect(buildFacetDimensionSchema(null)).toEqual({ properties: {} }) + }) +})