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/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..5ea376de --- /dev/null +++ b/tests/e2e/spec-coverage/page-surfaces.spec.ts @@ -0,0 +1,143 @@ +// 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 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) + * - src/views/suites/SuitesIndexView.vue (Suites) + * - src/views/organisaties/PortfolioReport.vue (Portfolio rationalization) + * - src/views/settings/sections/EolSyncSettings.vue (admin settings section) + * + * @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, type Page } from '@playwright/test' +import { APP_MAIN, collectAppErrors, expectNoAppErrors, navClickTo } from './_helpers' + +/** + * 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 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 the same sidebar for the dienst subject', async ({ page }) => { + const bag = collectAppErrors(page) + await navClickTo(page, 'Services') + + // 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 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 }) + + // 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 report chrome', 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 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) +}) + +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). + await page.goto('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/settings/admin/softwarecatalog', { waitUntil: 'domcontentloaded' }) + + const host = page.locator('#softwarecatalog-settings') + await expect(host).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) +}) 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 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: {} }) + }) +})