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