diff --git a/.coverage-baseline b/.coverage-baseline
index 90737ba31..265fbfb0d 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-49.47
+80.00
diff --git a/README.md b/README.md
index d4911e6e3..18f800d51 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,12 @@ LaunchPad supercharges the Nextcloud dashboard. Create multiple personalized wor
📚 **[Step-by-step tutorials](https://launchpad.conduction.nl/docs/category/tutorials)** — user + admin walkthroughs with screenshots, kept in sync with the live UI via the [journeydoc](https://github.com/ConductionNL/hydra/blob/development/openspec/architecture/adr-030-journeydoc-pattern.md) capture spec.
+## Test coverage
+
+
+Measured test coverage: 81.0% of statements (PHP 81.0% of 21,952 statements), at commit `b3fd2179` on 2026-11-11. The JavaScript frontend is not in this figure, because it has no coverage measurement yet. The figures come from [`coverage-summary.json`](coverage-summary.json); refresh them with `composer coverage:publish`.
+
+
## Screenshots
diff --git a/composer.json b/composer.json
index 37a2083b8..a903e9afd 100644
--- a/composer.json
+++ b/composer.json
@@ -74,6 +74,11 @@
"@phpqa:full"
],
"test:coverage": "./vendor/bin/phpunit --configuration phpunit.xml --coverage-html=coverage/html --coverage-clover=coverage/clover.xml --colors=always",
+ "coverage:publish": [
+ "@putenv XDEBUG_MODE=coverage",
+ "@php -d pcov.enabled=1 -d pcov.directory=lib vendor/bin/phpunit -c phpunit.xml --do-not-fail-on-risky --coverage-clover=coverage/clover.xml",
+ "@php scripts/coverage-publish.php"
+ ],
"coverage:check": "php -r \"\\$xml = simplexml_load_file('coverage/clover.xml'); \\$metrics = \\$xml->project->metrics; \\$statements = (int)\\$metrics['statements']; \\$covered = (int)\\$metrics['coveredstatements']; \\$percentage = \\$statements > 0 ? round((\\$covered / \\$statements) * 100, 2) : 0; echo 'Coverage: ' . \\$percentage . '%' . PHP_EOL; exit(\\$percentage < 75 ? 1 : 0);\"",
"quality:phpcs-score": "./vendor/bin/phpcs --standard=phpcs.xml --report=json lib/ | php -r \"\\$json = json_decode(file_get_contents('php://stdin'), true); \\$errors = \\$json['totals']['errors'] ?? 0; \\$warnings = \\$json['totals']['warnings'] ?? 0; \\$score = 1000 - \\$errors - (\\$warnings / 2); echo 'PHPCS Score: ' . \\$score . ' (Errors: ' . \\$errors . ', Warnings: ' . \\$warnings . ')' . PHP_EOL;\"",
"quality:phpmd-score": "phpmd lib/ json phpmd.xml | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$violations = count(\\$json['files'] ?? []); \\$score = 1000 - (\\$violations * 10); echo 'PHPMD Score: ' . \\$score . ' (Violations: ' . \\$violations . ')' . PHP_EOL;\" || echo 'PHPMD not available'",
diff --git a/coverage-summary.json b/coverage-summary.json
new file mode 100644
index 000000000..755175ffc
--- /dev/null
+++ b/coverage-summary.json
@@ -0,0 +1,47 @@
+{
+ "total": {
+ "lines": {
+ "total": 21952,
+ "covered": 17784,
+ "skipped": 0,
+ "pct": 81.01
+ },
+ "statements": {
+ "total": 21952,
+ "covered": 17784,
+ "skipped": 0,
+ "pct": 81.01
+ },
+ "functions": {
+ "total": 2509,
+ "covered": 1727,
+ "skipped": 0,
+ "pct": 68.83
+ }
+ },
+ "suites": {
+ "phpunit": {
+ "statements": {
+ "total": 21952,
+ "covered": 17784,
+ "skipped": 0,
+ "pct": 81.01
+ },
+ "functions": {
+ "total": 2509,
+ "covered": 1727,
+ "skipped": 0,
+ "pct": 68.83
+ },
+ "lines": {
+ "total": 21952,
+ "covered": 17784,
+ "skipped": 0,
+ "pct": 81.01
+ }
+ }
+ },
+ "measuredAt": "2026-11-11",
+ "commit": "b3fd2179",
+ "generator": "scripts/coverage-publish.php"
+}
diff --git a/scripts/coverage-publish.php b/scripts/coverage-publish.php
new file mode 100644
index 000000000..844563f5f
--- /dev/null
+++ b/scripts/coverage-publish.php
@@ -0,0 +1,139 @@
+#!/usr/bin/env php
+ 0) {
+ $pct = round(($covered / $total) * 100, 2);
+ }
+
+ return [
+ 'total' => $total,
+ 'covered' => $covered,
+ 'skipped' => 0,
+ 'pct' => $pct,
+ ];
+}
+
+$suites = [];
+
+if (is_file($clover) === false) {
+ fwrite(STDERR, "coverage-publish: $clover not found; run the PHPUnit suite with --coverage-clover first\n");
+ exit(2);
+}
+$xml = simplexml_load_file($clover);
+if ($xml === false || isset($xml->project->metrics) === false) {
+ fwrite(STDERR, "coverage-publish: $clover is not a clover report\n");
+ exit(2);
+}
+$m = $xml->project->metrics;
+$suites['phpunit'] = [
+ 'statements' => metric((int) $m['statements'], (int) $m['coveredstatements']),
+ 'functions' => metric((int) $m['methods'], (int) $m['coveredmethods']),
+];
+$suites['phpunit']['lines'] = $suites['phpunit']['statements'];
+
+if (is_file($vitest) === true) {
+ $js = json_decode((string) file_get_contents($vitest), true);
+ if (is_array($js) === false || isset($js['total']['statements']) === false) {
+ fwrite(STDERR, "coverage-publish: $vitest is not a json-summary report\n");
+ exit(2);
+ }
+ foreach (['lines', 'statements', 'functions'] as $key) {
+ $suites['vitest'][$key] = metric((int) $js['total'][$key]['total'], (int) $js['total'][$key]['covered']);
+ }
+}
+
+$total = [];
+foreach (['lines', 'statements', 'functions'] as $key) {
+ $sum = 0;
+ $hit = 0;
+ foreach ($suites as $suite) {
+ $sum += $suite[$key]['total'];
+ $hit += $suite[$key]['covered'];
+ }
+ $total[$key] = metric($sum, $hit);
+}
+
+$commit = trim((string) shell_exec('git -C ' . escapeshellarg($root) . ' rev-parse --short HEAD 2>/dev/null'));
+$summary = [
+ 'total' => $total,
+ 'suites' => $suites,
+ 'measuredAt' => gmdate('Y-m-d'),
+ 'commit' => $commit,
+ 'generator' => 'scripts/coverage-publish.php',
+];
+file_put_contents($out, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
+
+$fmt = static fn (float $pct): string => number_format($pct, 1) . '%';
+$parts = ['PHP ' . $fmt($suites['phpunit']['statements']['pct']) . ' of ' . number_format($suites['phpunit']['statements']['total']) . ' statements'];
+if (isset($suites['vitest']) === true) {
+ $parts[] = 'JavaScript ' . $fmt($suites['vitest']['statements']['pct'])
+ . ' of ' . number_format($suites['vitest']['statements']['total']) . ' statements';
+}
+
+$jsNote = 'The JavaScript frontend is not in this figure, because it has no coverage measurement yet. ';
+if (isset($suites['vitest']) === true) {
+ $jsNote = '';
+}
+$block = "\n"
+ . 'Measured test coverage: ' . $fmt($total['statements']['pct']) . ' of statements (' . implode(', ', $parts) . '), '
+ . 'at commit `' . $commit . '` on ' . $summary['measuredAt'] . '. '
+ . $jsNote
+ . "The figures come from [`coverage-summary.json`](coverage-summary.json); refresh them with `composer coverage:publish`.\n"
+ . '';
+
+if (is_file($readme) === true) {
+ $text = (string) file_get_contents($readme);
+ $pattern = '/.*?/s';
+ if (preg_match($pattern, $text) === 1) {
+ $text = preg_replace($pattern, $block, $text);
+ file_put_contents($readme, $text);
+ } else {
+ fwrite(
+ STDERR,
+ "coverage-publish: no coverage markers in README.md; add where the line belongs\n"
+ );
+ }
+}
+
+echo 'Coverage ' . $fmt($total['statements']['pct']) . ' (' . implode(', ', $parts) . ")\n";
+exit(0);
diff --git a/tests/Unit/Controller/ActionMatrixControllerTest.php b/tests/Unit/Controller/ActionMatrixControllerTest.php
new file mode 100644
index 000000000..a1caf699c
--- /dev/null
+++ b/tests/Unit/Controller/ActionMatrixControllerTest.php
@@ -0,0 +1,198 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use JsonException;
+use OCA\LaunchPad\Controller\ActionMatrixController;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCA\LaunchPad\Settings\LaunchPadAdmin;
+use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
+use OCP\IGroup;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use ReflectionMethod;
+
+class ActionMatrixControllerTest extends TestCase {
+ /**
+ * Controller.
+ *
+ * @var ActionMatrixController
+ */
+ private ActionMatrixController $controller;
+
+ /**
+ * Request.
+ *
+ * @var IRequest
+ */
+ private IRequest&MockObject $request;
+
+ /**
+ * Action auth.
+ *
+ * @var ActionAuthService
+ */
+ private ActionAuthService&MockObject $actionAuth;
+
+ /**
+ * Group manager.
+ *
+ * @var IGroupManager
+ */
+ private IGroupManager&MockObject $groupManager;
+
+ /**
+ * Set up.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ $this->request = $this->createMock(originalClassName: IRequest::class);
+ $this->actionAuth = $this->createMock(originalClassName: ActionAuthService::class);
+ $this->groupManager = $this->createMock(originalClassName: IGroupManager::class);
+
+ $this->controller = new ActionMatrixController(
+ request: $this->request,
+ actionAuth: $this->actionAuth,
+ groupManager: $this->groupManager,
+ );
+ }//end setUp()
+
+ /**
+ * @return array
+ */
+ public static function adminEndpoints(): array {
+ return [
+ 'read' => ['getMatrix'],
+ 'write' => ['setMatrix'],
+ ];
+ }//end adminEndpoints()
+
+ /**
+ * @dataProvider adminEndpoints
+ * @param string $method Method.
+ * @return void
+ */
+ public function testEndpointIsGatedByTheAdminSetting(string $method): void {
+ $attributes = (new ReflectionMethod(ActionMatrixController::class, $method))
+ ->getAttributes(AuthorizedAdminSetting::class);
+
+ $this->assertCount(expectedCount: 1, haystack: $attributes);
+ $this->assertSame(
+ expected: [LaunchPadAdmin::class],
+ actual: $attributes[0]->getArguments()
+ );
+ }//end testEndpointIsGatedByTheAdminSetting()
+
+ /**
+ * Get matrix merges seed actions sorted and lists groups.
+ *
+ * @return void
+ */
+ public function testGetMatrixMergesSeedActionsSortedAndListsGroups(): void {
+ $this->actionAuth->method('getMatrix')->willReturn(value: [
+ 'zz.custom-action' => ['editors'],
+ 'admin.get-my-role' => ['@all'],
+ ]);
+ $admin = $this->createMock(originalClassName: IGroup::class);
+ $admin->method('getGID')->willReturn(value: 'admin');
+ $editors = $this->createMock(originalClassName: IGroup::class);
+ $editors->method('getGID')->willReturn(value: 'editors');
+ $this->groupManager->method('search')->with('')->willReturn(value: [$admin, $editors]);
+
+ $data = $this->controller->getMatrix()->getData();
+
+ $seedKeys = array_keys(
+ json_decode(
+ (string)file_get_contents(__DIR__ . '/../../../lib/actions.seed.json'),
+ associative: true,
+ flags: JSON_THROW_ON_ERROR
+ )['actions']
+ );
+ $expected = array_values(array_unique(array_merge(['zz.custom-action'], $seedKeys)));
+ sort($expected);
+
+ $this->assertSame(expected: $expected, actual: $data['actions']);
+ // A key in both the matrix and the seed appears once.
+ $this->assertSame(
+ expected: 1,
+ actual: count(array_keys($data['actions'], 'admin.get-my-role', true))
+ );
+ $this->assertSame(expected: ['admin', 'editors'], actual: $data['groups']);
+ $this->assertSame(expected: ['editors'], actual: $data['matrix']['zz.custom-action']);
+ }//end testGetMatrixMergesSeedActionsSortedAndListsGroups()
+
+ /**
+ * Set matrix writes the submitted matrix and returns the stored one.
+ *
+ * @return void
+ */
+ public function testSetMatrixWritesTheSubmittedMatrixAndReturnsTheStoredOne(): void {
+ $submitted = ['dashboards.create' => ['staff']];
+ $this->request->method('getParam')->with('matrix')->willReturn(value: $submitted);
+ $this->actionAuth->expects($this->once())->method('setMatrix')->with($submitted);
+ $this->actionAuth->method('getMatrix')->willReturn(value: $submitted);
+
+ $response = $this->controller->setMatrix();
+
+ $this->assertSame(expected: 200, actual: $response->getStatus());
+ $this->assertSame(expected: ['matrix' => $submitted], actual: $response->getData());
+ }//end testSetMatrixWritesTheSubmittedMatrixAndReturnsTheStoredOne()
+
+ /**
+ * Set matrix treats anon array matrix as empty.
+ *
+ * @return void
+ */
+ public function testSetMatrixTreatsANonArrayMatrixAsEmpty(): void {
+ $this->request->method('getParam')->with('matrix')->willReturn(value: 'not-a-matrix');
+ $this->actionAuth->expects($this->once())->method('setMatrix')->with([]);
+ $this->actionAuth->method('getMatrix')->willReturn(value: []);
+
+ $this->assertSame(
+ expected: ['matrix' => []],
+ actual: $this->controller->setMatrix()->getData()
+ );
+ }//end testSetMatrixTreatsANonArrayMatrixAsEmpty()
+
+ /**
+ * Set matrix reports an encoding failure as bad request.
+ *
+ * @return void
+ */
+ public function testSetMatrixReportsAnEncodingFailureAsBadRequest(): void {
+ $this->request->method('getParam')->willReturn(value: ['a' => ['b']]);
+ $this->actionAuth->method('setMatrix')
+ ->willThrowException(exception: new JsonException(message: 'Malformed UTF-8'));
+
+ $response = $this->controller->setMatrix();
+
+ $this->assertSame(expected: 400, actual: $response->getStatus());
+ $this->assertSame(
+ expected: ['error' => 'Could not encode the action matrix: Malformed UTF-8'],
+ actual: $response->getData()
+ );
+ }//end testSetMatrixReportsAnEncodingFailureAsBadRequest()
+}//end class
diff --git a/tests/Unit/Controller/AdminBulkStatusGoldenTest.php b/tests/Unit/Controller/AdminBulkStatusGoldenTest.php
index e92f8826c..0f00e4391 100644
--- a/tests/Unit/Controller/AdminBulkStatusGoldenTest.php
+++ b/tests/Unit/Controller/AdminBulkStatusGoldenTest.php
@@ -54,6 +54,7 @@
* @uses \OCA\LaunchPad\Service\BulkOperationAccess
* @uses \OCA\LaunchPad\Service\BulkStatusRequest
* @uses \OCA\LaunchPad\Service\BulkStatusChange
+ * @uses \OCA\LaunchPad\Service\BulkOutcome
* @uses \OCA\LaunchPad\Service\RequestGuard
* @uses \OCA\LaunchPad\Service\PermissionDeniedException
* @uses \OCA\LaunchPad\Activity\ActivityRow
diff --git a/tests/Unit/Controller/AdminControllerRolesAndTemplatesTest.php b/tests/Unit/Controller/AdminControllerRolesAndTemplatesTest.php
new file mode 100644
index 000000000..3a829e3bf
--- /dev/null
+++ b/tests/Unit/Controller/AdminControllerRolesAndTemplatesTest.php
@@ -0,0 +1,402 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\AdminCallerAndRoles;
+use OCA\LaunchPad\Controller\AdminController;
+use OCA\LaunchPad\Controller\AdminSettingsTools;
+use OCA\LaunchPad\Controller\AdminTemplateTools;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Db\RoleAssignment;
+use OCA\LaunchPad\Exception\DuplicateRoleAssignmentException;
+use OCA\LaunchPad\Exception\InvalidRoleAssignmentException;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCA\LaunchPad\Service\AdminSettingsService;
+use OCA\LaunchPad\Service\AdminTemplateService;
+use OCA\LaunchPad\Service\ExportService;
+use OCA\LaunchPad\Service\FeedRefreshService;
+use OCA\LaunchPad\Service\FooterService;
+use OCA\LaunchPad\Service\ImportService;
+use OCA\LaunchPad\Service\RoleService;
+use OCA\LaunchPad\Service\SetupWizardService;
+use OCA\LaunchPad\Service\TemplateResyncService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\OCS\OCSForbiddenException;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use RuntimeException;
+
+class AdminControllerRolesAndTemplatesTest extends TestCase {
+ /**
+ * Controller.
+ *
+ * @var AdminController
+ */
+ private AdminController $controller;
+
+ /**
+ * Template service.
+ *
+ * @var AdminTemplateService
+ */
+ private AdminTemplateService&MockObject $templateService;
+
+ /**
+ * Group manager.
+ *
+ * @var IGroupManager
+ */
+ private IGroupManager&MockObject $groupManager;
+
+ /**
+ * User session.
+ *
+ * @var IUserSession
+ */
+ private IUserSession&MockObject $userSession;
+
+ /**
+ * Role service.
+ *
+ * @var RoleService
+ */
+ private RoleService&MockObject $roleService;
+
+ /**
+ * Action auth.
+ *
+ * @var ActionAuthService
+ */
+ private ActionAuthService&MockObject $actionAuth;
+
+ /**
+ * Feed refresh.
+ *
+ * @var FeedRefreshService
+ */
+ private FeedRefreshService&MockObject $feedRefresh;
+
+ /**
+ * Set up.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ $this->templateService = $this->createMock(originalClassName: AdminTemplateService::class);
+ $this->groupManager = $this->createMock(originalClassName: IGroupManager::class);
+ $this->userSession = $this->createMock(originalClassName: IUserSession::class);
+ $this->roleService = $this->createMock(originalClassName: RoleService::class);
+ $this->actionAuth = $this->createMock(originalClassName: ActionAuthService::class);
+ $this->feedRefresh = $this->createMock(originalClassName: FeedRefreshService::class);
+
+ $this->controller = new AdminController(
+ request: $this->createMock(originalClassName: IRequest::class),
+ templates: new AdminTemplateTools(
+ templateService: $this->templateService,
+ exportService: $this->createMock(originalClassName: ExportService::class),
+ importService: $this->createMock(originalClassName: ImportService::class),
+ resyncService: $this->createMock(originalClassName: TemplateResyncService::class)
+ ),
+ settings: new AdminSettingsTools(
+ settingsService: $this->createMock(originalClassName: AdminSettingsService::class),
+ groupManager: $this->groupManager,
+ footerService: $this->createMock(originalClassName: FooterService::class),
+ setupWizardService: $this->createMock(originalClassName: SetupWizardService::class)
+ ),
+ caller: new AdminCallerAndRoles(
+ roleService: $this->roleService,
+ feedRefresh: $this->feedRefresh,
+ userSession: $this->userSession,
+ actionAuth: $this->actionAuth
+ )
+ );
+ }//end setUp()
+
+ /**
+ * Sign in.
+ *
+ * @param string $uid Uid.
+ * @return IUser
+ */
+ private function signIn(string $uid): IUser {
+ $user = $this->createMock(originalClassName: IUser::class);
+ $user->method('getUID')->willReturn(value: $uid);
+ $this->userSession->method('getUser')->willReturn(value: $user);
+ return $user;
+ }//end signIn()
+
+ /**
+ * Admin guard refuses an anonymous caller.
+ *
+ * @return void
+ */
+ public function testAdminGuardRefusesAnAnonymousCaller(): void {
+ $this->userSession->method('getUser')->willReturn(value: null);
+ $this->feedRefresh->expects($this->never())->method('refreshAll');
+
+ $response = $this->controller->refreshFeedsNow();
+
+ $this->assertSame(expected: Http::STATUS_UNAUTHORIZED, actual: $response->getStatus());
+ $this->assertSame(expected: ['error' => 'Not authenticated'], actual: $response->getData());
+ }//end testAdminGuardRefusesAnAnonymousCaller()
+
+ /**
+ * List roles serialises every assignment.
+ *
+ * @return void
+ */
+ public function testListRolesSerialisesEveryAssignment(): void {
+ $row = new RoleAssignment();
+ $row->setUserId('alice');
+ $row->setRole('editor');
+ $this->roleService->method('listAssignments')->willReturn(value: [$row]);
+
+ $response = $this->controller->listRoles();
+
+ $this->assertSame(expected: Http::STATUS_OK, actual: $response->getStatus());
+ $this->assertSame(expected: 'alice', actual: $response->getData()[0]['userId']);
+ $this->assertSame(expected: 'editor', actual: $response->getData()[0]['role']);
+ }//end testListRolesSerialisesEveryAssignment()
+
+ /**
+ * Create role records the acting admin and answers201.
+ *
+ * @return void
+ */
+ public function testCreateRoleRecordsTheActingAdminAndAnswers201(): void {
+ $this->signIn(uid: 'root');
+ $created = new RoleAssignment();
+ $created->setGroupId('staff');
+ $created->setRole('viewer');
+ $created->setAssignedBy('root');
+ $this->roleService->expects($this->once())->method('assignRole')
+ ->with(null, 'staff', 'viewer', 'root')
+ ->willReturn(value: $created);
+
+ $response = $this->controller->createRole(groupId: 'staff', role: 'viewer');
+
+ $this->assertSame(expected: Http::STATUS_CREATED, actual: $response->getStatus());
+ $this->assertSame(expected: 'root', actual: $response->getData()['assignedBy']);
+ }//end testCreateRoleRecordsTheActingAdminAndAnswers201()
+
+ /**
+ * Create role answers aduplicate with409.
+ *
+ * @return void
+ */
+ public function testCreateRoleAnswersADuplicateWith409(): void {
+ $this->signIn(uid: 'root');
+ $this->roleService->method('assignRole')
+ ->willThrowException(exception: new DuplicateRoleAssignmentException());
+
+ $response = $this->controller->createRole(userId: 'alice', role: 'editor');
+
+ $this->assertSame(expected: Http::STATUS_CONFLICT, actual: $response->getStatus());
+ $this->assertSame(
+ expected: 'duplicate_role_assignment',
+ actual: $response->getData()['errorCode']
+ );
+ }//end testCreateRoleAnswersADuplicateWith409()
+
+ /**
+ * Create role answers an invalid payload with400.
+ *
+ * @return void
+ */
+ public function testCreateRoleAnswersAnInvalidPayloadWith400(): void {
+ $this->signIn(uid: 'root');
+ $this->roleService->method('assignRole')
+ ->willThrowException(exception: new InvalidRoleAssignmentException(message: 'Unknown role'));
+
+ $response = $this->controller->createRole(userId: 'alice', role: 'overlord');
+
+ $this->assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $response->getStatus());
+ $this->assertSame(
+ expected: ['error' => 'Unknown role', 'errorCode' => 'invalid_role_assignment'],
+ actual: $response->getData()
+ );
+ }//end testCreateRoleAnswersAnInvalidPayloadWith400()
+
+ /**
+ * Delete role answers204.
+ *
+ * @return void
+ */
+ public function testDeleteRoleAnswers204(): void {
+ $this->roleService->expects($this->once())->method('removeRole')->with(12);
+
+ $this->assertSame(
+ expected: Http::STATUS_NO_CONTENT,
+ actual: $this->controller->deleteRole(id: 12)->getStatus()
+ );
+ }//end testDeleteRoleAnswers204()
+
+ /**
+ * Delete role answers amissing row with404.
+ *
+ * @return void
+ */
+ public function testDeleteRoleAnswersAMissingRowWith404(): void {
+ $this->roleService->method('removeRole')
+ ->willThrowException(exception: new DoesNotExistException(msg: 'gone'));
+
+ $this->assertSame(
+ expected: Http::STATUS_NOT_FOUND,
+ actual: $this->controller->deleteRole(id: 12)->getStatus()
+ );
+ }//end testDeleteRoleAnswersAMissingRowWith404()
+
+ /**
+ * Get my role refuses an anonymous caller.
+ *
+ * @return void
+ */
+ public function testGetMyRoleRefusesAnAnonymousCaller(): void {
+ $this->userSession->method('getUser')->willReturn(value: null);
+ $this->roleService->expects($this->never())->method('getEffectiveRole');
+
+ $this->assertSame(
+ expected: Http::STATUS_UNAUTHORIZED,
+ actual: $this->controller->getMyRole()->getStatus()
+ );
+ }//end testGetMyRoleRefusesAnAnonymousCaller()
+
+ /**
+ * Get my role refuses acaller the matrix denies.
+ *
+ * @return void
+ */
+ public function testGetMyRoleRefusesACallerTheMatrixDenies(): void {
+ $this->signIn(uid: 'alice');
+ $this->actionAuth->method('requireAction')
+ ->willThrowException(exception: new OCSForbiddenException());
+ $this->roleService->expects($this->never())->method('getEffectiveRole');
+
+ $response = $this->controller->getMyRole();
+
+ $this->assertSame(expected: Http::STATUS_FORBIDDEN, actual: $response->getStatus());
+ $this->assertSame(expected: ['error' => 'Forbidden'], actual: $response->getData());
+ }//end testGetMyRoleRefusesACallerTheMatrixDenies()
+
+ /**
+ * Get my role answers the callers own role and source.
+ *
+ * @return void
+ */
+ public function testGetMyRoleAnswersTheCallersOwnRoleAndSource(): void {
+ $user = $this->signIn(uid: 'alice');
+ $this->actionAuth->expects($this->once())->method('requireAction')
+ ->with($user, 'admin.get-my-role');
+ $this->roleService->method('getEffectiveRole')->with('alice')->willReturn(value: 'editor');
+ $this->roleService->method('getRoleSource')->with('alice')->willReturn(value: 'group');
+
+ $this->assertSame(
+ expected: ['role' => 'editor', 'source' => 'group'],
+ actual: $this->controller->getMyRole()->getData()
+ );
+ }//end testGetMyRoleAnswersTheCallersOwnRoleAndSource()
+
+ /**
+ * List templates serialises every template.
+ *
+ * @return void
+ */
+ public function testListTemplatesSerialisesEveryTemplate(): void {
+ $template = new Dashboard();
+ $template->setName('Onboarding');
+ $this->templateService->method('listTemplates')->willReturn(value: [$template]);
+
+ $data = $this->controller->listTemplates()->getData();
+
+ $this->assertCount(expectedCount: 1, haystack: $data);
+ $this->assertSame(expected: 'Onboarding', actual: $data[0]['name']);
+ }//end testListTemplatesSerialisesEveryTemplate()
+
+ /**
+ * Get template answers the template with its placements.
+ *
+ * @return void
+ */
+ public function testGetTemplateAnswersTheTemplateWithItsPlacements(): void {
+ $template = new Dashboard();
+ $template->setName('Onboarding');
+ $this->templateService->method('getTemplateWithPlacements')->with(3)
+ ->willReturn(value: ['template' => $template, 'placements' => []]);
+
+ $response = $this->controller->getTemplate(id: 3);
+
+ $this->assertSame(expected: Http::STATUS_OK, actual: $response->getStatus());
+ $this->assertSame(expected: 'Onboarding', actual: $response->getData()['template']['name']);
+ $this->assertSame(expected: [], actual: $response->getData()['placements']);
+ }//end testGetTemplateAnswersTheTemplateWithItsPlacements()
+
+ /**
+ * Get template answers amissing template with404.
+ *
+ * @return void
+ */
+ public function testGetTemplateAnswersAMissingTemplateWith404(): void {
+ $this->templateService->method('getTemplateWithPlacements')
+ ->willThrowException(exception: new DoesNotExistException(msg: 'gone'));
+
+ $response = $this->controller->getTemplate(id: 3);
+
+ $this->assertSame(expected: Http::STATUS_NOT_FOUND, actual: $response->getStatus());
+ // The internal message is not echoed to the client.
+ $this->assertSame(expected: ['error' => 'Operation failed'], actual: $response->getData());
+ }//end testGetTemplateAnswersAMissingTemplateWith404()
+
+ /**
+ * Delete template confirms the deletion.
+ *
+ * @return void
+ */
+ public function testDeleteTemplateConfirmsTheDeletion(): void {
+ $this->templateService->expects($this->once())->method('deleteTemplate')->with(3);
+
+ $this->assertSame(
+ expected: ['status' => 'ok'],
+ actual: $this->controller->deleteTemplate(id: 3)->getData()
+ );
+ }//end testDeleteTemplateConfirmsTheDeletion()
+
+ /**
+ * Delete template answers afailure with400.
+ *
+ * @return void
+ */
+ public function testDeleteTemplateAnswersAFailureWith400(): void {
+ $this->templateService->method('deleteTemplate')
+ ->willThrowException(exception: new RuntimeException(message: 'db down'));
+
+ $response = $this->controller->deleteTemplate(id: 3);
+
+ $this->assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $response->getStatus());
+ $this->assertSame(expected: ['error' => 'Operation failed'], actual: $response->getData());
+ }//end testDeleteTemplateAnswersAFailureWith400()
+}//end class
diff --git a/tests/Unit/Controller/DashboardTranslationCreateGoldenTest.php b/tests/Unit/Controller/DashboardTranslationCreateGoldenTest.php
index 78dc6a27b..70d166995 100644
--- a/tests/Unit/Controller/DashboardTranslationCreateGoldenTest.php
+++ b/tests/Unit/Controller/DashboardTranslationCreateGoldenTest.php
@@ -49,6 +49,7 @@
* @uses \OCA\LaunchPad\Db\DashboardTranslationMapper
* @uses \OCA\LaunchPad\Controller\ActionCaller
* @uses \OCA\LaunchPad\Controller\TranslationSources
+ * @uses \OCA\LaunchPad\Controller\TranslationVariantDraft
*/
class DashboardTranslationCreateGoldenTest extends TestCase {
diff --git a/tests/Unit/Controller/FilesWidgetControllerContentsGoldenTest.php b/tests/Unit/Controller/FilesWidgetControllerContentsGoldenTest.php
index 361894176..cd885e883 100644
--- a/tests/Unit/Controller/FilesWidgetControllerContentsGoldenTest.php
+++ b/tests/Unit/Controller/FilesWidgetControllerContentsGoldenTest.php
@@ -46,6 +46,7 @@
*
* @covers \OCA\LaunchPad\Controller\FilesWidgetController
* @uses \OCA\LaunchPad\Controller\FilesWidgetSources
+ * @uses \OCA\LaunchPad\Controller\FilesWidgetAccess
* @uses \OCA\LaunchPad\Service\FilesWidgetService
* @uses \OCA\LaunchPad\Db\WidgetPlacement
* @uses \OCA\LaunchPad\Exception\FolderNotFoundException
diff --git a/tests/Unit/Service/ConfluenceImportServiceTest.php b/tests/Unit/Service/ConfluenceImportServiceTest.php
index 03dddf073..89b0f3cc7 100644
--- a/tests/Unit/Service/ConfluenceImportServiceTest.php
+++ b/tests/Unit/Service/ConfluenceImportServiceTest.php
@@ -55,6 +55,8 @@
* @uses \OCA\LaunchPad\Service\SlugGenerator
* @uses \OCA\LaunchPad\Service\ConfluencePageReaders
* @uses \OCA\LaunchPad\Service\ConfluenceDashboardWriters
+ * @uses \OCA\LaunchPad\Service\ConfluenceImportRun
+ * @uses \OCA\LaunchPad\Service\ParentFirstPageOrder
*/
class ConfluenceImportServiceTest extends TestCase {
diff --git a/tests/Unit/Service/RoleFeaturePermissionSaveTest.php b/tests/Unit/Service/RoleFeaturePermissionSaveTest.php
new file mode 100644
index 000000000..9d45a1337
--- /dev/null
+++ b/tests/Unit/Service/RoleFeaturePermissionSaveTest.php
@@ -0,0 +1,334 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Service;
+
+use InvalidArgumentException;
+use OCA\LaunchPad\Db\RoleFeaturePermission;
+use OCA\LaunchPad\Db\RoleFeaturePermissionMapper;
+use OCA\LaunchPad\Db\RoleLayoutDefault;
+use OCA\LaunchPad\Db\RoleLayoutDefaultMapper;
+use OCA\LaunchPad\Db\WidgetPlacementMapper;
+use OCA\LaunchPad\Service\AdminSettingsService;
+use OCA\LaunchPad\Service\AdminTemplateService;
+use OCA\LaunchPad\Service\RoleFeaturePermissionService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IGroupManager;
+use OCP\IUserManager;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+
+class RoleFeaturePermissionSaveTest extends TestCase {
+ /**
+ * Service.
+ *
+ * @var RoleFeaturePermissionService
+ */
+ private RoleFeaturePermissionService $service;
+
+ /**
+ * Perm mapper.
+ *
+ * @var RoleFeaturePermissionMapper
+ */
+ private RoleFeaturePermissionMapper&MockObject $permMapper;
+
+ /**
+ * Default mapper.
+ *
+ * @var RoleLayoutDefaultMapper
+ */
+ private RoleLayoutDefaultMapper&MockObject $defaultMapper;
+
+ /**
+ * Set up.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ $this->permMapper = $this->createMock(originalClassName: RoleFeaturePermissionMapper::class);
+ $this->defaultMapper = $this->createMock(originalClassName: RoleLayoutDefaultMapper::class);
+
+ $this->service = new RoleFeaturePermissionService(
+ permissionMapper: $this->permMapper,
+ defaultMapper: $this->defaultMapper,
+ placementMapper: $this->createMock(originalClassName: WidgetPlacementMapper::class),
+ adminSettings: $this->createMock(originalClassName: AdminSettingsService::class),
+ adminTemplateService: $this->createMock(originalClassName: AdminTemplateService::class),
+ userManager: $this->createMock(originalClassName: IUserManager::class),
+ groupManager: $this->createMock(originalClassName: IGroupManager::class),
+ );
+ }//end setUp()
+
+ /**
+ * List permissions and layout defaults return mapper rows.
+ *
+ * @return void
+ */
+ public function testListPermissionsAndLayoutDefaultsReturnMapperRows(): void {
+ $perm = new RoleFeaturePermission();
+ $default = new RoleLayoutDefault();
+ $this->permMapper->method('findAll')->willReturn(value: [$perm]);
+ $this->defaultMapper->method('findAll')->willReturn(value: [$default]);
+
+ $this->assertSame(expected: [$perm], actual: $this->service->listPermissions());
+ $this->assertSame(expected: [$default], actual: $this->service->listLayoutDefaults());
+ }//end testListPermissionsAndLayoutDefaultsReturnMapperRows()
+
+ /**
+ * Save permission rejects missing group id.
+ *
+ * @return void
+ */
+ public function testSavePermissionRejectsMissingGroupId(): void {
+ $this->permMapper->expects($this->never())->method('insert');
+ $this->permMapper->expects($this->never())->method('update');
+
+ $this->expectException(exception: InvalidArgumentException::class);
+ $this->expectExceptionMessage(message: 'groupId is required');
+ $this->service->savePermission(data: ['name' => 'Staff']);
+ }//end testSavePermissionRejectsMissingGroupId()
+
+ /**
+ * Save permission rejects new row without name.
+ *
+ * @return void
+ */
+ public function testSavePermissionRejectsNewRowWithoutName(): void {
+ $this->permMapper->method('findByGroupId')
+ ->willThrowException(exception: new DoesNotExistException(msg: 'none'));
+ $this->permMapper->expects($this->never())->method('insert');
+
+ $this->expectException(exception: InvalidArgumentException::class);
+ $this->expectExceptionMessage(message: 'name is required');
+ $this->service->savePermission(data: ['groupId' => 'staff']);
+ }//end testSavePermissionRejectsNewRowWithoutName()
+
+ /**
+ * Save permission inserts new row with normalised lists.
+ *
+ * @return void
+ */
+ public function testSavePermissionInsertsNewRowWithNormalisedLists(): void {
+ $this->permMapper->method('findByGroupId')
+ ->willThrowException(exception: new DoesNotExistException(msg: 'none'));
+ $this->permMapper->expects($this->never())->method('update');
+ $this->permMapper->expects($this->once())->method('insert')
+ ->willReturnArgument(argumentIndex: 0);
+
+ $saved = $this->service->savePermission(data: [
+ 'groupId' => 'staff',
+ 'name' => 'Staff',
+ 'description' => 'All employees',
+ 'allowedWidgets' => ['b' => 'news', 'a' => 'files'],
+ 'deniedWidgets' => 'not-a-list',
+ 'priorityWeights' => ['news' => 5],
+ ]);
+
+ $this->assertSame(expected: 'staff', actual: $saved->getGroupId());
+ $this->assertSame(expected: 'Staff', actual: $saved->getName());
+ $this->assertSame(expected: 'All employees', actual: $saved->getDescription());
+ // A keyed list is re-indexed so it stores as a JSON array, not an object.
+ $this->assertSame(expected: '["news","files"]', actual: $saved->getAllowedWidgets());
+ // A scalar submitted for a list stores as an empty list.
+ $this->assertSame(expected: '[]', actual: $saved->getDeniedWidgets());
+ $this->assertSame(expected: '{"news":5}', actual: $saved->getPriorityWeights());
+ $this->assertNotNull(actual: $saved->getCreatedAt());
+ $this->assertNotNull(actual: $saved->getUpdatedAt());
+ }//end testSavePermissionInsertsNewRowWithNormalisedLists()
+
+ /**
+ * Save permission updates existing row and keeps omitted fields.
+ *
+ * @return void
+ */
+ public function testSavePermissionUpdatesExistingRowAndKeepsOmittedFields(): void {
+ $existing = new RoleFeaturePermission();
+ $existing->setId(1);
+ $existing->setName('Kept name');
+ $existing->setDescription('Old description');
+ $existing->setAllowedWidgets('["news"]');
+ $this->permMapper->method('findByGroupId')->willReturn(value: $existing);
+ $this->permMapper->expects($this->never())->method('insert');
+ $this->permMapper->expects($this->once())->method('update')
+ ->willReturnArgument(argumentIndex: 0);
+
+ // An existing row needs no name, and an explicit null clears the description.
+ $saved = $this->service->savePermission(data: [
+ 'groupId' => 'staff',
+ 'description' => null,
+ ]);
+
+ $this->assertSame(expected: $existing, actual: $saved);
+ $this->assertSame(expected: 'Kept name', actual: $saved->getName());
+ $this->assertNull(actual: $saved->getDescription());
+ $this->assertSame(expected: '["news"]', actual: $saved->getAllowedWidgets());
+ }//end testSavePermissionUpdatesExistingRowAndKeepsOmittedFields()
+
+ /**
+ * Delete permission deletes the found row.
+ *
+ * @return void
+ */
+ public function testDeletePermissionDeletesTheFoundRow(): void {
+ $row = new RoleFeaturePermission();
+ $this->permMapper->expects($this->once())->method('find')
+ ->with(7)->willReturn(value: $row);
+ $this->permMapper->expects($this->once())->method('delete')->with($row);
+
+ $this->service->deletePermission(id: 7);
+ }//end testDeletePermissionDeletesTheFoundRow()
+
+ /**
+ * Delete permission propagates amissing row.
+ *
+ * @return void
+ */
+ public function testDeletePermissionPropagatesAMissingRow(): void {
+ $this->permMapper->method('find')
+ ->willThrowException(exception: new DoesNotExistException(msg: 'gone'));
+ $this->permMapper->expects($this->never())->method('delete');
+
+ $this->expectException(exception: DoesNotExistException::class);
+ $this->service->deletePermission(id: 7);
+ }//end testDeletePermissionPropagatesAMissingRow()
+
+ /**
+ * @return array}>
+ */
+ public static function incompleteLayoutKeys(): array {
+ return [
+ 'no group' => [['widgetId' => 'news', 'name' => 'x']],
+ 'no widget' => [['groupId' => 'staff', 'name' => 'x']],
+ 'empty' => [['groupId' => '', 'widgetId' => '']],
+ ];
+ }//end incompleteLayoutKeys()
+
+ /**
+ * @dataProvider incompleteLayoutKeys
+ * @param array $data Data.
+ * @return void
+ */
+ public function testSaveLayoutDefaultRejectsMissingKeys(array $data): void {
+ $this->defaultMapper->expects($this->never())->method('insert');
+
+ $this->expectException(exception: InvalidArgumentException::class);
+ $this->expectExceptionMessage(message: 'groupId and widgetId are required');
+ $this->service->saveLayoutDefault(data: $data);
+ }//end testSaveLayoutDefaultRejectsMissingKeys()
+
+ /**
+ * Save layout default rejects new row without name.
+ *
+ * @return void
+ */
+ public function testSaveLayoutDefaultRejectsNewRowWithoutName(): void {
+ $this->defaultMapper->method('findByGroupAndWidget')
+ ->willThrowException(exception: new DoesNotExistException(msg: 'none'));
+ $this->defaultMapper->expects($this->never())->method('insert');
+
+ $this->expectException(exception: InvalidArgumentException::class);
+ $this->expectExceptionMessage(message: 'name is required');
+ $this->service->saveLayoutDefault(data: ['groupId' => 'staff', 'widgetId' => 'news']);
+ }//end testSaveLayoutDefaultRejectsNewRowWithoutName()
+
+ /**
+ * Save layout default inserts with floored geometry.
+ *
+ * @return void
+ */
+ public function testSaveLayoutDefaultInsertsWithFlooredGeometry(): void {
+ $this->defaultMapper->method('findByGroupAndWidget')
+ ->willThrowException(exception: new DoesNotExistException(msg: 'none'));
+ $this->defaultMapper->expects($this->once())->method('insert')
+ ->willReturnArgument(argumentIndex: 0);
+
+ $saved = $this->service->saveLayoutDefault(data: [
+ 'groupId' => 'staff',
+ 'widgetId' => 'news',
+ 'name' => 'News first',
+ 'description' => 'Top left',
+ 'gridX' => '2',
+ 'gridY' => 3,
+ 'gridWidth' => 0,
+ 'gridHeight' => -4,
+ 'sortOrder' => '9',
+ 'isCompulsory' => 'yes',
+ ]);
+
+ $this->assertSame(expected: 'staff', actual: $saved->getGroupId());
+ $this->assertSame(expected: 'news', actual: $saved->getWidgetId());
+ $this->assertSame(expected: 'News first', actual: $saved->getName());
+ $this->assertSame(expected: 'Top left', actual: $saved->getDescription());
+ $this->assertSame(expected: 2, actual: $saved->getGridX());
+ $this->assertSame(expected: 3, actual: $saved->getGridY());
+ // A zero or negative span would make the widget unrenderable.
+ $this->assertSame(expected: 1, actual: $saved->getGridWidth());
+ $this->assertSame(expected: 1, actual: $saved->getGridHeight());
+ $this->assertSame(expected: 9, actual: $saved->getSortOrder());
+ $this->assertSame(expected: 1, actual: $saved->getIsCompulsory());
+ $this->assertNotNull(actual: $saved->getCreatedAt());
+ }//end testSaveLayoutDefaultInsertsWithFlooredGeometry()
+
+ /**
+ * Save layout default updates existing row.
+ *
+ * @return void
+ */
+ public function testSaveLayoutDefaultUpdatesExistingRow(): void {
+ $existing = new RoleLayoutDefault();
+ $existing->setId(4);
+ $existing->setName('Existing');
+ $existing->setIsCompulsory(1);
+ $existing->setGridWidth(3);
+ $this->defaultMapper->method('findByGroupAndWidget')->willReturn(value: $existing);
+ $this->defaultMapper->expects($this->never())->method('insert');
+ $this->defaultMapper->expects($this->once())->method('update')
+ ->willReturnArgument(argumentIndex: 0);
+
+ $saved = $this->service->saveLayoutDefault(data: [
+ 'groupId' => 'staff',
+ 'widgetId' => 'news',
+ 'description' => null,
+ 'isCompulsory' => 0,
+ ]);
+
+ $this->assertSame(expected: 'Existing', actual: $saved->getName());
+ $this->assertNull(actual: $saved->getDescription());
+ $this->assertSame(expected: 0, actual: $saved->getIsCompulsory());
+ $this->assertSame(expected: 3, actual: $saved->getGridWidth());
+ $this->assertNotNull(actual: $saved->getUpdatedAt());
+ }//end testSaveLayoutDefaultUpdatesExistingRow()
+
+ /**
+ * Delete layout default deletes the found row.
+ *
+ * @return void
+ */
+ public function testDeleteLayoutDefaultDeletesTheFoundRow(): void {
+ $row = new RoleLayoutDefault();
+ $this->defaultMapper->expects($this->once())->method('find')
+ ->with(5)->willReturn(value: $row);
+ $this->defaultMapper->expects($this->once())->method('delete')->with($row);
+
+ $this->service->deleteLayoutDefault(id: 5);
+ }//end testDeleteLayoutDefaultDeletesTheFoundRow()
+}//end class
diff --git a/tests/Unit/Service/RuleEvaluatorServiceTest.php b/tests/Unit/Service/RuleEvaluatorServiceTest.php
new file mode 100644
index 000000000..5242b839a
--- /dev/null
+++ b/tests/Unit/Service/RuleEvaluatorServiceTest.php
@@ -0,0 +1,300 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Service;
+
+use DateTimeImmutable;
+use OCA\LaunchPad\Db\ConditionalRule;
+use OCA\LaunchPad\Service\AdminTemplateService;
+use OCA\LaunchPad\Service\RuleEvaluatorService;
+use OCA\LaunchPad\Service\UserAttributeResolver;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+
+class RuleEvaluatorServiceTest extends TestCase {
+ /**
+ * Service.
+ *
+ * @var RuleEvaluatorService
+ */
+ private RuleEvaluatorService $service;
+
+ /**
+ * Admin template service.
+ *
+ * @var AdminTemplateService
+ */
+ private AdminTemplateService&MockObject $adminTemplateService;
+
+ /**
+ * Attr resolver.
+ *
+ * @var UserAttributeResolver
+ */
+ private UserAttributeResolver&MockObject $attrResolver;
+
+ /**
+ * Set up.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ $this->adminTemplateService = $this->createMock(originalClassName: AdminTemplateService::class);
+ $this->attrResolver = $this->createMock(originalClassName: UserAttributeResolver::class);
+ $this->service = new RuleEvaluatorService(
+ adminTemplateService: $this->adminTemplateService,
+ attrResolver: $this->attrResolver,
+ );
+ }//end setUp()
+
+ /**
+ * Rule.
+ *
+ * @param string $type Type.
+ * @param array $config Config.
+ * @return ConditionalRule
+ */
+ private function rule(string $type, array $config): ConditionalRule {
+ $rule = new ConditionalRule();
+ $rule->setRuleType($type);
+ $rule->setRuleConfigArray($config);
+ return $rule;
+ }//end rule()
+
+ /**
+ * Group rule matches when the user shares agroup.
+ *
+ * @return void
+ */
+ public function testGroupRuleMatchesWhenTheUserSharesAGroup(): void {
+ $this->adminTemplateService->method('getUserGroupIdsFor')->with('alice')
+ ->willReturn(value: ['staff', 'editors']);
+
+ $this->assertTrue(
+ condition: $this->service->evaluateRule(
+ rule: $this->rule(type: ConditionalRule::TYPE_GROUP, config: ['groups' => ['editors']]),
+ userId: 'alice'
+ )
+ );
+ }//end testGroupRuleMatchesWhenTheUserSharesAGroup()
+
+ /**
+ * Group rule does not match adisjoint user.
+ *
+ * @return void
+ */
+ public function testGroupRuleDoesNotMatchADisjointUser(): void {
+ $this->adminTemplateService->method('getUserGroupIdsFor')->willReturn(value: ['staff']);
+
+ $this->assertFalse(
+ condition: $this->service->evaluateRule(
+ rule: $this->rule(type: ConditionalRule::TYPE_GROUP, config: ['groups' => ['admin']]),
+ userId: 'alice'
+ )
+ );
+ }//end testGroupRuleDoesNotMatchADisjointUser()
+
+ /**
+ * Group rule without target groups never matches.
+ *
+ * @return void
+ */
+ public function testGroupRuleWithoutTargetGroupsNeverMatches(): void {
+ $this->adminTemplateService->expects($this->never())->method('getUserGroupIdsFor');
+
+ $this->assertFalse(
+ condition: $this->service->evaluateRule(
+ rule: $this->rule(type: ConditionalRule::TYPE_GROUP, config: []),
+ userId: 'alice'
+ )
+ );
+ }//end testGroupRuleWithoutTargetGroupsNeverMatches()
+
+ /**
+ * Group rule uses the preview override instead of the live groups.
+ *
+ * @return void
+ */
+ public function testGroupRuleUsesThePreviewOverrideInsteadOfTheLiveGroups(): void {
+ $this->adminTemplateService->expects($this->never())->method('getUserGroupIdsFor');
+ $rule = $this->rule(type: ConditionalRule::TYPE_GROUP, config: ['groups' => ['admin']]);
+
+ $this->assertTrue(condition: $this->service->evaluateRule(rule: $rule, userId: 'alice', groupsOverride: ['admin']));
+ // An empty override is a user in no groups, not "use the live groups".
+ $this->assertFalse(condition: $this->service->evaluateRule(rule: $rule, userId: 'alice', groupsOverride: []));
+ }//end testGroupRuleUsesThePreviewOverrideInsteadOfTheLiveGroups()
+
+ /**
+ * @return array, 2: bool}>
+ */
+ public static function timeCases(): array {
+ // 2026-11-11 is a Wednesday.
+ return [
+ 'inside the window' => ['2026-11-11 10:30', ['startTime' => '09:00', 'endTime' => '17:00'], true],
+ 'before the window' => ['2026-11-11 08:59', ['startTime' => '09:00', 'endTime' => '17:00'], false],
+ 'after the window' => ['2026-11-11 17:01', ['startTime' => '09:00', 'endTime' => '17:00'], false],
+ 'on the end minute' => ['2026-11-11 17:00', ['startTime' => '09:00', 'endTime' => '17:00'], true],
+ 'listed day' => ['2026-11-11 12:00', ['days' => ['mon', 'wed']], true],
+ 'unlisted day' => ['2026-11-11 12:00', ['days' => ['sat', 'sun']], false],
+ 'no config is the whole day' => ['2026-11-11 23:59', [], true],
+ ];
+ }//end timeCases()
+
+ /**
+ * @dataProvider timeCases
+ * @param string $now Now.
+ * @param array $config Config.
+ * @param bool $expected Expected.
+ * @return void
+ */
+ public function testTimeRule(string $now, array $config, bool $expected): void {
+ $this->assertSame(
+ expected: $expected,
+ actual: $this->service->evaluateRule(
+ rule: $this->rule(type: ConditionalRule::TYPE_TIME, config: $config),
+ userId: 'alice',
+ nowOverride: new DateTimeImmutable($now)
+ )
+ );
+ }//end testTimeRule()
+
+ /**
+ * @return array, 2: bool}>
+ */
+ public static function dateCases(): array {
+ $window = ['startDate' => '2026-11-01', 'endDate' => '2026-11-30'];
+ return [
+ 'inside' => ['2026-11-11', $window, true],
+ 'first day' => ['2026-11-01', $window, true],
+ 'last day' => ['2026-11-30', $window, true],
+ 'before' => ['2026-10-31', $window, false],
+ 'after' => ['2026-12-01', $window, false],
+ 'open start' => ['2020-01-01', ['endDate' => '2026-11-30'], true],
+ 'open end' => ['2099-01-01', ['startDate' => '2026-11-01'], true],
+ ];
+ }//end dateCases()
+
+ /**
+ * @dataProvider dateCases
+ * @param string $now Now.
+ * @param array $config Config.
+ * @param bool $expected Expected.
+ * @return void
+ */
+ public function testDateRule(string $now, array $config, bool $expected): void {
+ $this->assertSame(
+ expected: $expected,
+ actual: $this->service->evaluateRule(
+ rule: $this->rule(type: ConditionalRule::TYPE_DATE, config: $config),
+ userId: 'alice',
+ nowOverride: new DateTimeImmutable($now . ' 12:00')
+ )
+ );
+ }//end testDateRule()
+
+ /**
+ * Attribute rule delegates the comparison to the resolver.
+ *
+ * @return void
+ */
+ public function testAttributeRuleDelegatesTheComparisonToTheResolver(): void {
+ $this->attrResolver->method('getUserAttributeValue')->with('alice', 'locale')
+ ->willReturn(value: 'nl');
+ $this->attrResolver->expects($this->once())->method('evaluateOperator')
+ ->with('nl', 'contains', 'n')->willReturn(value: true);
+
+ $this->assertTrue(
+ condition: $this->service->evaluateRule(
+ rule: $this->rule(
+ type: ConditionalRule::TYPE_ATTRIBUTE,
+ config: ['attribute' => 'locale', 'operator' => 'contains', 'value' => 'n']
+ ),
+ userId: 'alice'
+ )
+ );
+ }//end testAttributeRuleDelegatesTheComparisonToTheResolver()
+
+ /**
+ * Attribute rule defaults to equals.
+ *
+ * @return void
+ */
+ public function testAttributeRuleDefaultsToEquals(): void {
+ $this->attrResolver->method('getUserAttributeValue')->willReturn(value: 'nl');
+ $this->attrResolver->expects($this->once())->method('evaluateOperator')
+ ->with('nl', 'equals', 'nl')->willReturn(value: true);
+
+ $this->assertTrue(
+ condition: $this->service->evaluateRule(
+ rule: $this->rule(type: ConditionalRule::TYPE_ATTRIBUTE, config: ['attribute' => 'locale', 'value' => 'nl']),
+ userId: 'alice'
+ )
+ );
+ }//end testAttributeRuleDefaultsToEquals()
+
+ /**
+ * Attribute rule without an attribute never matches.
+ *
+ * @return void
+ */
+ public function testAttributeRuleWithoutAnAttributeNeverMatches(): void {
+ $this->attrResolver->expects($this->never())->method('getUserAttributeValue');
+
+ $this->assertFalse(
+ condition: $this->service->evaluateRule(
+ rule: $this->rule(type: ConditionalRule::TYPE_ATTRIBUTE, config: ['value' => 'nl']),
+ userId: 'alice'
+ )
+ );
+ }//end testAttributeRuleWithoutAnAttributeNeverMatches()
+
+ /**
+ * Attribute rule does not match auser without the attribute.
+ *
+ * @return void
+ */
+ public function testAttributeRuleDoesNotMatchAUserWithoutTheAttribute(): void {
+ $this->attrResolver->method('getUserAttributeValue')->willReturn(value: null);
+ $this->attrResolver->expects($this->never())->method('evaluateOperator');
+
+ $this->assertFalse(
+ condition: $this->service->evaluateRule(
+ rule: $this->rule(type: ConditionalRule::TYPE_ATTRIBUTE, config: ['attribute' => 'department']),
+ userId: 'alice'
+ )
+ );
+ }//end testAttributeRuleDoesNotMatchAUserWithoutTheAttribute()
+
+ /**
+ * Unknown rule type never matches.
+ *
+ * @return void
+ */
+ public function testUnknownRuleTypeNeverMatches(): void {
+ $this->assertFalse(
+ condition: $this->service->evaluateRule(
+ rule: $this->rule(type: 'moon-phase', config: []),
+ userId: 'alice'
+ )
+ );
+ }//end testUnknownRuleTypeNeverMatches()
+}//end class