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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/utils/facetSchema.js
Original file line number Diff line number Diff line change
@@ -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, () => string>} dimensionLabels Dimension key → label thunk.
* @return {{properties: Record<string, object>}} 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 }
}
36 changes: 23 additions & 13 deletions src/views/FacetedCatalogIndexView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ generic route-query-to-filter passthrough never sees it (see the
<div class="faceted-catalog-index__body">
<CnFacetSidebar
:title="t('softwarecatalog', 'GEMMA filters')"
:filters="facetDimensionFilters"
:schema="facetDimensionSchema"
:facet-data="facetStore.facetDataFor(schema)"
:active-filters="facetStore[schema].activeFilters"
:loading="facetStore[schema].loading"
Expand Down Expand Up @@ -135,6 +135,7 @@ import FolderOutline from 'vue-material-design-icons/FolderOutline.vue'
import FolderStarOutline from 'vue-material-design-icons/FolderStarOutline.vue'

import { useFacetStore } from '../store/modules/facets.js'
import { buildFacetDimensionSchema } from '../utils/facetSchema.js'
import SaveFacetViewModal from '../modals/SaveFacetViewModal.vue'

/** Dimension key -> translated label, matching `FacetController`'s query params. */
Expand Down Expand Up @@ -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<object>} 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. */
Expand Down
225 changes: 225 additions & 0 deletions tests/Unit/Controller/ReviewControllerContractTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
<?php

/**
* Wire-contract tests for ReviewController.
*
* `GET /api/reviews/aggregate` (`review#aggregate`) is a `#[PublicPage]`
* endpoint: an anonymous visitor on a module or dienst detail page reads it.
* That makes its response shape and its status codes part of the app's public
* surface, so they are pinned here rather than left to be discovered by a
* consumer.
*
* These assert the CONTRACT — the exact keys on the wire and the status code —
* not merely that a JSONResponse came back.
*
* @category Test
* @package OCA\SoftwareCatalog\Tests\Unit\Controller
* @author Conduction b.v. <info@conduction.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
* @version GIT: <git_id>
* @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. <info@conduction.nl>
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
* @version GIT: <git_id>
* @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
2 changes: 2 additions & 0 deletions tests/e2e/spec-coverage/license-posture.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading