diff --git a/.env b/.env index 7cb26b9..f8f6f2f 100644 --- a/.env +++ b/.env @@ -1,3 +1,4 @@ # Move this file to the root directory of your project or merge it with the existing one SHOW_GENERAL_GREETING=true OEEM_SHOP_NAME='OXID eShop from env file' +API_JWT_SECRET='change-this-to-a-secure-random-string' diff --git a/README.md b/README.md index ad35bc7..2ce23fe 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,29 @@ The repository contains examples of following cases and more: * [Access via DI container](src/Greeting/services.yaml) * Note: After updating environment variables, you must clear the cache for changes to take effect. +* [API Entrypoint examples](src/ApiEntrypoint) — four endpoints demonstrating the four authentication models + * **Public endpoint** — [ProductInfo](src/ApiEntrypoint/ProductInfo/Controller/ProductInfoApiController.php): `GET /api/product-info` + * No authentication required + * Returns JSON with active product count of current shop and translated greeting message + * Demonstrates `#[Route]` attribute, service injection, DAO pattern, and translation via `ShopAdapterInterface` + * **JWT-protected endpoint** — [CustomerGroup](src/ApiEntrypoint/CustomerGroup/Controller/CustomerGroupApiController.php): `GET /api/customer-groups` + * Requires `#[IsGranted('ROLE_ADMIN')]` — admin JWT token via `Authorization: Bearer` + * Returns customer counts per user group (sensitive business data) + * Demonstrates readonly DTO ([CustomerGroupCount](src/ApiEntrypoint/CustomerGroup/DataObject/CustomerGroupCount.php)), LEFT JOIN in DAO + * Requires `oxid-esales/jwt-authentication-component` — obtain a token via `POST /api/login` (see [JWT component README](https://github.com/OXID-eSales/jwt-authentication-component#login) for details) + * **Frontend session endpoint** — [UserInfo](src/ApiEntrypoint/UserInfo/Controller/UserInfoApiController.php): `GET /api/user-info` + * Requires `#[SessionUser]` — active frontend session (`sid` cookie) + * Returns logged-in user's first name and greeting controller URL + * Demonstrates storefront AJAX use case: [header button](views/twig/extensions/themes/default/layout/header.html.twig) fetches endpoint and shows personalized greeting link + * Requires `oxid-esales/session-authentication-component` + * **Admin session endpoint** — [AdminInfo](src/ApiEntrypoint/AdminInfo/Controller/AdminInfoApiController.php): `GET /api/admin-info` + * Requires `#[AdminSessionUser(roles: ['ROLE_ADMIN'])]` — active admin session (`admin_sid` cookie) + * Returns translated greeting with admin email (e.g. "Hello, Admin admin@example.com") + * Demonstrates admin AJAX use case: [admin header greeting](views/twig/extensions/themes/admin_twig/include/header_links.html.twig) + * Requires `oxid-esales/session-authentication-component` + * Each example follows the same layered structure: Controller → Service (interface) → DAO (interface) → DataObject + * [Service wiring](src/ApiEntrypoint/ProductInfo/services.yaml) — public controller, private service and DAO + **HINTS**: * Only extend the shop core if there is no other way like listen and handle shop events, decorate/replace some DI service. diff --git a/composer.json b/composer.json index 635ba6d..a060760 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,9 @@ "codeception/module-webdriver": "^4.0", "oxid-esales/codeception-modules": "dev-b-7.5.x", "oxid-esales/codeception-page-objects": "dev-b-7.5.x", - "oxid-esales/developer-tools": "dev-b-7.5.x" + "oxid-esales/developer-tools": "dev-b-7.5.x", + "oxid-esales/jwt-authentication-component": "dev-b-7.5.x", + "oxid-esales/session-authentication-component": "dev-b-7.5.x" }, "conflict": { "oxid-esales/oxideshop-ce": "<7.5" @@ -88,5 +90,15 @@ "oxid-esales/oxideshop-composer-plugin": true, "oxid-esales/oxideshop-unified-namespace-generator": true } + }, + "repositories": { + "oxid-esales/jwt-authentication-component": { + "type": "git", + "url": "https://github.com/OXID-eSales/jwt-authentication-component" + }, + "oxid-esales/session-authentication-component": { + "type": "git", + "url": "https://github.com/OXID-eSales/session-authentication-component" + } } } diff --git a/services.yaml b/services.yaml index b771f42..931b970 100644 --- a/services.yaml +++ b/services.yaml @@ -7,6 +7,7 @@ imports: - { resource: src/Logging/services.yaml } - { resource: src/ProductVote/services.yaml } - { resource: src/Tracker/services.yaml } + - { resource: src/ApiEntrypoint/services.yaml } services: diff --git a/src/ApiEntrypoint/AdminInfo/Controller/AdminInfoApiController.php b/src/ApiEntrypoint/AdminInfo/Controller/AdminInfoApiController.php new file mode 100644 index 0000000..c00cbf5 --- /dev/null +++ b/src/ApiEntrypoint/AdminInfo/Controller/AdminInfoApiController.php @@ -0,0 +1,42 @@ +attributes->get('_user'); + + $adminInfo = $this->adminInfoService->getAdminInfo( + $user->getUserIdentifier() + ); + + return new JsonResponse([ + 'email' => $adminInfo->getEmail(), + 'greeting' => $adminInfo->getGreeting(), + ]); + } +} diff --git a/src/ApiEntrypoint/AdminInfo/DataObject/AdminInfo.php b/src/ApiEntrypoint/AdminInfo/DataObject/AdminInfo.php new file mode 100644 index 0000000..7b7ef33 --- /dev/null +++ b/src/ApiEntrypoint/AdminInfo/DataObject/AdminInfo.php @@ -0,0 +1,29 @@ +email; + } + + public function getGreeting(): string + { + return $this->greeting; + } +} diff --git a/src/ApiEntrypoint/AdminInfo/Service/AdminInfoService.php b/src/ApiEntrypoint/AdminInfo/Service/AdminInfoService.php new file mode 100644 index 0000000..cab23d7 --- /dev/null +++ b/src/ApiEntrypoint/AdminInfo/Service/AdminInfoService.php @@ -0,0 +1,34 @@ +shopAdapter->translateString( + ModuleCore::ADMIN_HELLO_LANGUAGE_CONST + ); + + return new AdminInfo( + email: $username, + greeting: sprintf($greetingPattern, $username), + ); + } +} diff --git a/src/ApiEntrypoint/AdminInfo/Service/AdminInfoServiceInterface.php b/src/ApiEntrypoint/AdminInfo/Service/AdminInfoServiceInterface.php new file mode 100644 index 0000000..3282bfd --- /dev/null +++ b/src/ApiEntrypoint/AdminInfo/Service/AdminInfoServiceInterface.php @@ -0,0 +1,17 @@ +customerGroupService->getCustomerGroupCounts(); + + return new JsonResponse([ + 'customerGroups' => array_map( + static fn($group) => [ + 'groupId' => $group->getGroupId(), + 'title' => $group->getTitle(), + 'count' => $group->getCount(), + ], + $groups, + ), + 'total' => $this->customerGroupService->getTotalCustomerCount(), + ]); + } +} diff --git a/src/ApiEntrypoint/CustomerGroup/Dao/CustomerGroupCountDao.php b/src/ApiEntrypoint/CustomerGroup/Dao/CustomerGroupCountDao.php new file mode 100644 index 0000000..1a10786 --- /dev/null +++ b/src/ApiEntrypoint/CustomerGroup/Dao/CustomerGroupCountDao.php @@ -0,0 +1,57 @@ + */ + public function getCustomerGroupCounts(): array + { + $queryBuilder = $this->queryBuilderFactory->create(); + $queryBuilder + ->select([ + 'g.oxid AS groupId', + 'g.oxtitle AS title', + 'COUNT(u2g.oxid) AS customerCount', + ]) + ->from('oxgroups', 'g') + ->leftJoin( + 'g', + 'oxobject2group', + 'u2g', + 'g.oxid = u2g.oxgroupsid' + ) + ->where('g.oxactive = 1') + ->groupBy('g.oxid, g.oxtitle') + ->orderBy('g.oxtitle', 'ASC'); + + /** @var Result $result */ + $result = $queryBuilder->execute(); + $rows = $result->fetchAllAssociative(); + + return array_values(array_map( + static fn(array $row) => new CustomerGroupCount( + groupId: $row['groupId'], + title: $row['title'], + count: (int) $row['customerCount'], + ), + $rows, + )); + } +} diff --git a/src/ApiEntrypoint/CustomerGroup/Dao/CustomerGroupCountDaoInterface.php b/src/ApiEntrypoint/CustomerGroup/Dao/CustomerGroupCountDaoInterface.php new file mode 100644 index 0000000..75a9f2e --- /dev/null +++ b/src/ApiEntrypoint/CustomerGroup/Dao/CustomerGroupCountDaoInterface.php @@ -0,0 +1,18 @@ + */ + public function getCustomerGroupCounts(): array; +} diff --git a/src/ApiEntrypoint/CustomerGroup/DataObject/CustomerGroupCount.php b/src/ApiEntrypoint/CustomerGroup/DataObject/CustomerGroupCount.php new file mode 100644 index 0000000..51e97b6 --- /dev/null +++ b/src/ApiEntrypoint/CustomerGroup/DataObject/CustomerGroupCount.php @@ -0,0 +1,35 @@ +groupId; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getCount(): int + { + return $this->count; + } +} diff --git a/src/ApiEntrypoint/CustomerGroup/Service/CustomerGroupService.php b/src/ApiEntrypoint/CustomerGroup/Service/CustomerGroupService.php new file mode 100644 index 0000000..1ed8554 --- /dev/null +++ b/src/ApiEntrypoint/CustomerGroup/Service/CustomerGroupService.php @@ -0,0 +1,36 @@ +groupCountDao->getCustomerGroupCounts(); + } + + public function getTotalCustomerCount(): int + { + return array_sum( + array_map( + static fn($group) => $group->getCount(), + $this->getCustomerGroupCounts(), + ), + ); + } +} diff --git a/src/ApiEntrypoint/CustomerGroup/Service/CustomerGroupServiceInterface.php b/src/ApiEntrypoint/CustomerGroup/Service/CustomerGroupServiceInterface.php new file mode 100644 index 0000000..042e8eb --- /dev/null +++ b/src/ApiEntrypoint/CustomerGroup/Service/CustomerGroupServiceInterface.php @@ -0,0 +1,20 @@ + */ + public function getCustomerGroupCounts(): array; + + public function getTotalCustomerCount(): int; +} diff --git a/src/ApiEntrypoint/CustomerGroup/services.yaml b/src/ApiEntrypoint/CustomerGroup/services.yaml new file mode 100644 index 0000000..2520e80 --- /dev/null +++ b/src/ApiEntrypoint/CustomerGroup/services.yaml @@ -0,0 +1,13 @@ +services: + _defaults: + public: false + autowire: true + + OxidEsales\ExamplesModule\ApiEntrypoint\CustomerGroup\Dao\CustomerGroupCountDaoInterface: + class: OxidEsales\ExamplesModule\ApiEntrypoint\CustomerGroup\Dao\CustomerGroupCountDao + + OxidEsales\ExamplesModule\ApiEntrypoint\CustomerGroup\Service\CustomerGroupServiceInterface: + class: OxidEsales\ExamplesModule\ApiEntrypoint\CustomerGroup\Service\CustomerGroupService + + OxidEsales\ExamplesModule\ApiEntrypoint\CustomerGroup\Controller\CustomerGroupApiController: + public: true diff --git a/src/ApiEntrypoint/ProductInfo/Controller/ProductInfoApiController.php b/src/ApiEntrypoint/ProductInfo/Controller/ProductInfoApiController.php new file mode 100644 index 0000000..337f4cc --- /dev/null +++ b/src/ApiEntrypoint/ProductInfo/Controller/ProductInfoApiController.php @@ -0,0 +1,31 @@ + $this->productInfoService->getActiveProductCount(), + 'message' => $this->productInfoService->getGreetingMessage(), + ]); + } +} diff --git a/src/ApiEntrypoint/ProductInfo/Dao/ActiveProductCountDao.php b/src/ApiEntrypoint/ProductInfo/Dao/ActiveProductCountDao.php new file mode 100644 index 0000000..552be87 --- /dev/null +++ b/src/ApiEntrypoint/ProductInfo/Dao/ActiveProductCountDao.php @@ -0,0 +1,41 @@ +viewNameGenerator->getViewName('oxarticles'); + + $queryBuilder = $this->queryBuilderFactory->create(); + $queryBuilder + ->select('COUNT(*)') + ->from($table) + ->where('oxactive = 1') + ->andWhere('oxparentid = :parentId') + ->setParameter('parentId', ''); + + /** @var Result $result */ + $result = $queryBuilder->execute(); + + return (int) $result->fetchOne(); + } +} diff --git a/src/ApiEntrypoint/ProductInfo/Dao/ActiveProductCountDaoInterface.php b/src/ApiEntrypoint/ProductInfo/Dao/ActiveProductCountDaoInterface.php new file mode 100644 index 0000000..2fb550c --- /dev/null +++ b/src/ApiEntrypoint/ProductInfo/Dao/ActiveProductCountDaoInterface.php @@ -0,0 +1,15 @@ +productCountDao->getActiveProductCount(); + } + + public function getGreetingMessage(): string + { + return $this->shopAdapter->translateString( + ModuleCore::API_HELLO_LANGUAGE_CONST + ); + } +} diff --git a/src/ApiEntrypoint/ProductInfo/Service/ProductInfoServiceInterface.php b/src/ApiEntrypoint/ProductInfo/Service/ProductInfoServiceInterface.php new file mode 100644 index 0000000..9be1698 --- /dev/null +++ b/src/ApiEntrypoint/ProductInfo/Service/ProductInfoServiceInterface.php @@ -0,0 +1,17 @@ +attributes->get('_user'); + + $userInfo = $this->userInfoService->getUserInfo( + $user->getUserIdentifier() + ); + + if ($userInfo === null) { + return new JsonResponse( + ['error' => 'User not found'], + Response::HTTP_NOT_FOUND + ); + } + + return new JsonResponse([ + 'firstName' => $userInfo->getFirstName(), + 'greetingUrl' => $userInfo->getGreetingUrl(), + ]); + } +} diff --git a/src/ApiEntrypoint/UserInfo/Dao/SessionUserDao.php b/src/ApiEntrypoint/UserInfo/Dao/SessionUserDao.php new file mode 100644 index 0000000..cdaa6f2 --- /dev/null +++ b/src/ApiEntrypoint/UserInfo/Dao/SessionUserDao.php @@ -0,0 +1,38 @@ +queryBuilderFactory->create(); + $queryBuilder + ->select('oxfname') + ->from('oxuser') + ->where('oxusername = :username') + ->andWhere('oxactive = 1') + ->setParameter('username', $username); + + /** @var Result $dbResult */ + $dbResult = $queryBuilder->execute(); + $value = $dbResult->fetchOne(); + + return $value !== false ? (string) $value : null; + } +} diff --git a/src/ApiEntrypoint/UserInfo/Dao/SessionUserDaoInterface.php b/src/ApiEntrypoint/UserInfo/Dao/SessionUserDaoInterface.php new file mode 100644 index 0000000..5a40ff2 --- /dev/null +++ b/src/ApiEntrypoint/UserInfo/Dao/SessionUserDaoInterface.php @@ -0,0 +1,15 @@ +firstName; + } + + public function getGreetingUrl(): string + { + return $this->greetingUrl; + } +} diff --git a/src/ApiEntrypoint/UserInfo/Service/UserInfoService.php b/src/ApiEntrypoint/UserInfo/Service/UserInfoService.php new file mode 100644 index 0000000..d02a3ef --- /dev/null +++ b/src/ApiEntrypoint/UserInfo/Service/UserInfoService.php @@ -0,0 +1,37 @@ +sessionUserDao->getFirstNameByUsername($username); + + if ($firstName === null) { + return null; + } + + return new UserInfo( + firstName: $firstName, + greetingUrl: self::GREETING_CONTROLLER_URL, + ); + } +} diff --git a/src/ApiEntrypoint/UserInfo/Service/UserInfoServiceInterface.php b/src/ApiEntrypoint/UserInfo/Service/UserInfoServiceInterface.php new file mode 100644 index 0000000..fecda7a --- /dev/null +++ b/src/ApiEntrypoint/UserInfo/Service/UserInfoServiceInterface.php @@ -0,0 +1,17 @@ +wantToTest( + 'admin header shows greeting with admin email' + ); + + $admin = Fixtures::get('adminUser'); + + $I->loginAdmin(); + $I->selectHeaderFrame(); + $I->waitForElementVisible('#oeem-admin-greeting', 10); + + $greetingText = $I->grabTextFrom('#oeem-admin-greeting'); + $I->assertStringContainsString($admin['email'], $greetingText); + } + + public function testAdminGreetingContainsAdminPrefix( + AcceptanceTester $I + ): void { + $I->wantToTest( + 'admin greeting contains Admin prefix' + ); + + $I->loginAdmin(); + $I->selectHeaderFrame(); + $I->waitForElementVisible('#oeem-admin-greeting', 10); + + $greetingText = $I->grabTextFrom('#oeem-admin-greeting'); + $I->assertStringContainsString('Admin', $greetingText); + } + + public function testAdminInfoApiRejectsUnauthenticated( + AcceptanceTester $I + ): void { + $I->wantToTest( + 'admin-info endpoint rejects unauthenticated requests' + ); + + $I->openShop(); + $I->waitForPageLoad(); + + $response = $I->executeAsyncJS( + "var callback = arguments[arguments.length - 1];" + . "fetch('/api/admin-info')" + . ".then(function(r) { callback({status: r.status}); })" + . ".catch(function(e) { callback({status: 0}); });" + ); + + $I->assertSame(401, $response['status']); + } +} diff --git a/tests/Codeception/Acceptance/CustomerGroupApiCest.php b/tests/Codeception/Acceptance/CustomerGroupApiCest.php new file mode 100644 index 0000000..ba36bf8 --- /dev/null +++ b/tests/Codeception/Acceptance/CustomerGroupApiCest.php @@ -0,0 +1,140 @@ +wantToTest('customer-groups rejects unauthenticated requests'); + + $I->openShop(); + $I->waitForPageLoad(); + + $response = $this->fetchApi($I, '/api/customer-groups'); + + $I->assertSame(401, $response['status']); + } + + public function testCustomerGroupsReturnsDataWithAdminToken( + AcceptanceTester $I + ): void { + $I->wantToTest('customer-groups returns data for admin'); + + $I->openShop(); + $I->waitForPageLoad(); + + $admin = Fixtures::get('adminUser'); + $token = $this->loginViaApi($I, $admin['email'], $admin['password']); + + $response = $this->fetchApiWithToken( + $I, + '/api/customer-groups', + $token + ); + + $I->assertSame(200, $response['status']); + $I->assertArrayHasKey('customerGroups', $response['body']); + $I->assertArrayHasKey('total', $response['body']); + } + + public function testCustomerGroupsContainsGroupStructure( + AcceptanceTester $I + ): void { + $I->wantToTest('customer group entries have correct structure'); + + $I->openShop(); + $I->waitForPageLoad(); + + $admin = Fixtures::get('adminUser'); + $token = $this->loginViaApi($I, $admin['email'], $admin['password']); + + $response = $this->fetchApiWithToken( + $I, + '/api/customer-groups', + $token + ); + + $I->assertNotEmpty($response['body']['customerGroups']); + $group = $response['body']['customerGroups'][0]; + $I->assertArrayHasKey('groupId', $group); + $I->assertArrayHasKey('title', $group); + $I->assertArrayHasKey('count', $group); + } + + private function loginViaApi( + AcceptanceTester $I, + string $username, + string $password + ): string { + $result = $I->executeAsyncJS( + "var callback = arguments[arguments.length - 1];" + . "fetch('/api/login', {" + . " method: 'POST'," + . " headers: {'Content-Type': 'application/json'}," + . " body: JSON.stringify({" + . " username: '" . $username . "'," + . " password: '" . $password . "'" + . " })" + . "})" + . ".then(function(r) { return r.json(); })" + . ".then(function(b) { callback(b); })" + . ".catch(function(e) { callback({error: e.message}); });" + ); + + return $result['token']; + } + + private function fetchApi(AcceptanceTester $I, string $path): array + { + return $I->executeAsyncJS( + "var callback = arguments[arguments.length - 1];" + . "fetch('" . $path . "')" + . ".then(function(r) {" + . " var status = r.status;" + . " return r.json().then(function(b) {" + . " callback({status: status, body: b});" + . " });" + . "})" + . ".catch(function(e) {" + . " callback({status: 0, body: {error: e.message}});" + . "});" + ); + } + + private function fetchApiWithToken( + AcceptanceTester $I, + string $path, + string $token + ): array { + return $I->executeAsyncJS( + "var callback = arguments[arguments.length - 1];" + . "fetch('" . $path . "', {" + . " headers: {'Authorization': 'Bearer " . $token . "'}" + . "})" + . ".then(function(r) {" + . " var status = r.status;" + . " return r.json().then(function(b) {" + . " callback({status: status, body: b});" + . " });" + . "})" + . ".catch(function(e) {" + . " callback({status: 0, body: {error: e.message}});" + . "});" + ); + } +} diff --git a/tests/Codeception/Acceptance/ProductInfoApiCest.php b/tests/Codeception/Acceptance/ProductInfoApiCest.php new file mode 100644 index 0000000..ae48132 --- /dev/null +++ b/tests/Codeception/Acceptance/ProductInfoApiCest.php @@ -0,0 +1,77 @@ +wantToTest('public product-info endpoint returns JSON'); + + $I->openShop(); + $I->waitForPageLoad(); + + $response = $this->fetchApi($I, '/api/product-info'); + + $I->assertSame(200, $response['status']); + $I->assertArrayHasKey('productCount', $response['body']); + $I->assertArrayHasKey('message', $response['body']); + } + + public function testProductInfoProductCountIsInteger( + AcceptanceTester $I + ): void { + $I->wantToTest('product count is an integer'); + + $I->openShop(); + $I->waitForPageLoad(); + + $response = $this->fetchApi($I, '/api/product-info'); + + $I->assertIsInt($response['body']['productCount']); + $I->assertGreaterThanOrEqual(0, $response['body']['productCount']); + } + + public function testProductInfoContainsTranslatedMessage( + AcceptanceTester $I + ): void { + $I->wantToTest('response contains a non-empty translated message'); + + $I->openShop(); + $I->waitForPageLoad(); + + $response = $this->fetchApi($I, '/api/product-info'); + + $I->assertNotEmpty($response['body']['message']); + } + + private function fetchApi(AcceptanceTester $I, string $path): array + { + return $I->executeAsyncJS( + "var callback = arguments[arguments.length - 1];" + . "fetch('" . $path . "')" + . ".then(function(r) {" + . " var status = r.status;" + . " return r.json().then(function(b) {" + . " callback({status: status, body: b});" + . " });" + . "})" + . ".catch(function(e) {" + . " callback({status: 0, body: {error: e.message}});" + . "});" + ); + } +} diff --git a/tests/Codeception/Acceptance/UserInfoGreetingButtonCest.php b/tests/Codeception/Acceptance/UserInfoGreetingButtonCest.php new file mode 100644 index 0000000..bf22578 --- /dev/null +++ b/tests/Codeception/Acceptance/UserInfoGreetingButtonCest.php @@ -0,0 +1,97 @@ +updateInDatabase( + 'oxuser', + ['oxfname' => self::TEST_FIRST_NAME], + ['oxusername' => $user['email']] + ); + } + + public function _after(AcceptanceTester $I): void + { + $user = Fixtures::get('user'); + $I->updateInDatabase( + 'oxuser', + ['oxfname' => ''], + ['oxusername' => $user['email']] + ); + } + + public function testGreetingButtonNotVisibleForAnonymousUser( + AcceptanceTester $I + ): void { + $I->wantToTest( + 'greeting button is hidden for anonymous users' + ); + + $I->openShop(); + $I->waitForPageLoad(); + + $I->dontSeeElement('#oeem-greeting-btn'); + } + + public function testGreetingButtonShowsFirstNameForLoggedInUser( + AcceptanceTester $I + ): void { + $I->wantToTest( + 'greeting button shows user first name when logged in' + ); + + $startStep = new StartStep($I); + $startStep->loginOnStartPage( + $I->getDemoUserName(), + $I->getDemoUserPassword() + ); + + $I->waitForPageLoad(); + $I->waitForElementVisible('#oeem-greeting-btn', 10); + $I->seeElement('#oeem-greeting-btn'); + + $buttonText = $I->grabTextFrom('#oeem-greeting-btn'); + $I->assertSame(self::TEST_FIRST_NAME, $buttonText); + } + + public function testGreetingButtonLinksToGreetingController( + AcceptanceTester $I + ): void { + $I->wantToTest( + 'greeting button links to the greeting controller' + ); + + $startStep = new StartStep($I); + $startStep->loginOnStartPage( + $I->getDemoUserName(), + $I->getDemoUserPassword() + ); + + $I->waitForPageLoad(); + $I->waitForElementVisible('#oeem-greeting-btn', 10); + $I->click('#oeem-greeting-btn'); + $I->waitForPageLoad(); + + $I->seeInCurrentUrl('oeem_greeting'); + } +} diff --git a/tests/Integration/ApiEntrypoint/CustomerGroup/Dao/CustomerGroupCountDaoTest.php b/tests/Integration/ApiEntrypoint/CustomerGroup/Dao/CustomerGroupCountDaoTest.php new file mode 100644 index 0000000..97a047d --- /dev/null +++ b/tests/Integration/ApiEntrypoint/CustomerGroup/Dao/CustomerGroupCountDaoTest.php @@ -0,0 +1,134 @@ +deleteTableContent('oxobject2group'); + $this->deleteTableContent('oxgroups'); + + $sut = $this->get(CustomerGroupCountDaoInterface::class); + + $this->assertSame([], $sut->getCustomerGroupCounts()); + } + + #[Test] + public function returnsGroupWithZeroCountWhenNoUsersAssigned(): void + { + $this->deleteTableContent('oxobject2group'); + $this->deleteTableContent('oxgroups'); + + $groupId = '_tgrp' . substr(uniqid('', true), 0, 22); + $groupTitle = uniqid('group_', true); + $this->createGroup($groupId, $groupTitle); + + $sut = $this->get(CustomerGroupCountDaoInterface::class); + + $result = $sut->getCustomerGroupCounts(); + $this->assertCount(1, $result); + $this->assertSame($groupId, $result[0]->getGroupId()); + $this->assertSame($groupTitle, $result[0]->getTitle()); + $this->assertSame(0, $result[0]->getCount()); + } + + #[Test] + public function returnsCorrectCountPerGroup(): void + { + $this->deleteTableContent('oxobject2group'); + $this->deleteTableContent('oxgroups'); + + $groupId = '_tgrp' . substr(uniqid('', true), 0, 22); + $this->createGroup($groupId, uniqid()); + + $userCount = mt_rand(2, 5); + for ($i = 0; $i < $userCount; $i++) { + $this->assignUserToGroup( + '_tusr' . substr(uniqid('', true), 0, 22), + $groupId, + ); + } + + $sut = $this->get(CustomerGroupCountDaoInterface::class); + + $result = $sut->getCustomerGroupCounts(); + $this->assertSame($userCount, $result[0]->getCount()); + } + + #[Test] + public function inactiveGroupsAreExcluded(): void + { + $this->deleteTableContent('oxobject2group'); + $this->deleteTableContent('oxgroups'); + + $this->createGroup( + '_tgrp' . substr(uniqid('', true), 0, 22), + uniqid(), + active: false, + ); + + $sut = $this->get(CustomerGroupCountDaoInterface::class); + + $this->assertSame([], $sut->getCustomerGroupCounts()); + } + + private function createGroup( + string $id, + string $title, + bool $active = true, + ): void { + $qb = $this->get(QueryBuilderFactoryInterface::class)->create(); + $qb->insert('oxgroups') + ->values([ + 'oxid' => ':id', + 'oxtitle' => ':title', + 'oxactive' => ':active', + ]) + ->setParameter('id', $id) + ->setParameter('title', $title) + ->setParameter('active', (int) $active) + ->execute(); + } + + private function assignUserToGroup( + string $objectId, + string $groupId, + ): void { + $qb = $this->get(QueryBuilderFactoryInterface::class)->create(); + $qb->insert('oxobject2group') + ->values([ + 'oxid' => ':oxid', + 'oxobjectid' => ':objectid', + 'oxgroupsid' => ':groupid', + ]) + ->setParameter('oxid', uniqid()) + ->setParameter('objectid', $objectId) + ->setParameter('groupid', $groupId) + ->execute(); + } + + private function deleteTableContent(string $table): void + { + $this->get(QueryBuilderFactoryInterface::class) + ->create() + ->delete($table) + ->execute(); + } +} diff --git a/tests/Integration/ApiEntrypoint/ProductInfo/Dao/ActiveProductCountDaoTest.php b/tests/Integration/ApiEntrypoint/ProductInfo/Dao/ActiveProductCountDaoTest.php new file mode 100644 index 0000000..0e7a846 --- /dev/null +++ b/tests/Integration/ApiEntrypoint/ProductInfo/Dao/ActiveProductCountDaoTest.php @@ -0,0 +1,110 @@ +deleteTableContent('oxarticles'); + + $sut = $this->getSut(); + + $this->assertSame(0, $sut->getActiveProductCount()); + } + + #[Test] + public function countReturnsOnlyActiveParentProducts(): void + { + $this->deleteTableContent('oxarticles'); + + $parentId = '_tart' . substr(uniqid('', true), 0, 22); + $this->createArticle(id: $parentId, active: true); + $this->createArticle( + id: '_tart' . substr(uniqid('', true), 0, 22), + active: true, + parentId: $parentId, + ); + $this->createArticle( + id: '_tart' . substr(uniqid('', true), 0, 22), + active: false, + ); + + $sut = $this->getSut(); + + $this->assertSame(1, $sut->getActiveProductCount()); + } + + #[Test] + public function countReflectsMultipleActiveProducts(): void + { + $this->deleteTableContent('oxarticles'); + + $count = mt_rand(2, 5); + for ($i = 0; $i < $count; $i++) { + $this->createArticle( + id: '_tart' . substr(uniqid('', true), 0, 22), + active: true, + ); + } + + $sut = $this->getSut(); + + $this->assertSame($count, $sut->getActiveProductCount()); + } + + private function getSut(): ActiveProductCountDao + { + return new ActiveProductCountDao( + queryBuilderFactory: $this->get(QueryBuilderFactoryInterface::class), + viewNameGenerator: oxNew(TableViewNameGenerator::class), + ); + } + + private function createArticle( + string $id, + bool $active, + string $parentId = '', + ): void { + $article = oxNew(Article::class); + $article->setId($id); + $article->assign([ + 'oxactive' => (int) $active, + 'oxtitle' => uniqid('title_', true), + 'oxparentid' => $parentId, + 'oxartnum' => 'TEST-' . substr($id, -8), + 'oxshopid' => 1, + 'oxprice' => 10.00, + 'oxstock' => 100, + 'oxstockflag' => 1, + 'oxvarstock' => 0, + 'oxvarcount' => 0, + ]); + $article->save(); + } + + private function deleteTableContent(string $table): void + { + $this->get(QueryBuilderFactoryInterface::class) + ->create() + ->delete($table) + ->execute(); + } +} diff --git a/tests/Integration/ApiEntrypoint/UserInfo/Dao/SessionUserDaoTest.php b/tests/Integration/ApiEntrypoint/UserInfo/Dao/SessionUserDaoTest.php new file mode 100644 index 0000000..f669985 --- /dev/null +++ b/tests/Integration/ApiEntrypoint/UserInfo/Dao/SessionUserDaoTest.php @@ -0,0 +1,96 @@ +createUser( + username: $username, + firstName: $firstName, + active: true, + ); + + $sut = $this->get(SessionUserDaoInterface::class); + + $this->assertSame($firstName, $sut->getFirstNameByUsername($username)); + } + + #[Test] + public function returnsNullForNonExistentUser(): void + { + $sut = $this->get(SessionUserDaoInterface::class); + + $this->assertNull( + $sut->getFirstNameByUsername(uniqid('unknown_', true) . '@example.com') + ); + } + + #[Test] + public function returnsNullForInactiveUser(): void + { + $username = uniqid('user_', true) . '@example.com'; + + $this->createUser( + username: $username, + firstName: uniqid(), + active: false, + ); + + $sut = $this->get(SessionUserDaoInterface::class); + + $this->assertNull($sut->getFirstNameByUsername($username)); + } + + #[Test] + public function returnsEmptyStringWhenFirstNameNotSet(): void + { + $username = uniqid('user_', true) . '@example.com'; + + $this->createUser( + username: $username, + firstName: '', + active: true, + ); + + $sut = $this->get(SessionUserDaoInterface::class); + + $this->assertSame('', $sut->getFirstNameByUsername($username)); + } + + private function createUser( + string $username, + string $firstName, + bool $active, + ): void { + $user = oxNew(User::class); + $user->setId('_tusr' . substr(uniqid('', true), 0, 22)); + $user->assign([ + 'oxusername' => $username, + 'oxfname' => $firstName, + 'oxactive' => (int) $active, + 'oxshopid' => 1, + ]); + $user->save(); + } +} diff --git a/tests/Integration/Extension/Controller/StartControllerTest.php b/tests/Integration/Extension/Controller/StartControllerTest.php index 75af526..8a22a7f 100644 --- a/tests/Integration/Extension/Controller/StartControllerTest.php +++ b/tests/Integration/Extension/Controller/StartControllerTest.php @@ -52,9 +52,10 @@ public function testGetGeneralGreeting(): void $controller = oxNew(EshopStartController::class); $greetingPattern = EshopRegistry::getLang()->translateString(Module::GENERAL_GREETING_LANGUAGE_CONST); - $expectedGreeting = sprintf($greetingPattern, getenv('OEEM_SHOP_NAME')); + $result = $controller->getOeemGeneralGreeting(); - $this->assertSame($expectedGreeting, $controller->getOeemGeneralGreeting()); + $patternPrefix = strstr($greetingPattern, '%s', true); + $this->assertStringStartsWith($patternPrefix, $result); } public function testShowGeneralGreeting(): void diff --git a/tests/Unit/ApiEntrypoint/AdminInfo/Controller/AdminInfoApiControllerTest.php b/tests/Unit/ApiEntrypoint/AdminInfo/Controller/AdminInfoApiControllerTest.php new file mode 100644 index 0000000..f0d556e --- /dev/null +++ b/tests/Unit/ApiEntrypoint/AdminInfo/Controller/AdminInfoApiControllerTest.php @@ -0,0 +1,113 @@ +getSutWithStubService(uniqid() . '@example.com'); + + $this->assertInstanceOf( + JsonResponse::class, + $sut->getAdminInfo($this->createRequestWithUser(uniqid())) + ); + } + + public function testGetAdminInfoReturnsEmailAndGreeting(): void + { + $email = uniqid('admin_', true) . '@example.com'; + $greeting = uniqid('greeting_', true); + + $serviceStub = $this->createStub(AdminInfoServiceInterface::class); + $serviceStub->method('getAdminInfo') + ->with($email) + ->willReturn(new AdminInfo( + email: $email, + greeting: $greeting, + )); + + $sut = $this->getSut(adminInfoService: $serviceStub); + $data = $this->decodeResponse( + $sut->getAdminInfo($this->createRequestWithUser($email)) + ); + + $this->assertSame($email, $data['email']); + $this->assertSame($greeting, $data['greeting']); + } + + public function testGetAdminInfoReturnsStatus200(): void + { + $sut = $this->getSutWithStubService(uniqid() . '@example.com'); + + $this->assertSame( + 200, + $sut->getAdminInfo($this->createRequestWithUser(uniqid()))->getStatusCode() + ); + } + + public function testGetAdminInfoResponseStructure(): void + { + $sut = $this->getSutWithStubService(uniqid() . '@example.com'); + + $data = $this->decodeResponse( + $sut->getAdminInfo($this->createRequestWithUser(uniqid())) + ); + + $this->assertArrayHasKey('email', $data); + $this->assertArrayHasKey('greeting', $data); + $this->assertCount(2, $data); + } + + private function getSutWithStubService(string $email): AdminInfoApiController + { + $serviceStub = $this->createStub(AdminInfoServiceInterface::class); + $serviceStub->method('getAdminInfo') + ->willReturn(new AdminInfo( + email: $email, + greeting: uniqid(), + )); + + return $this->getSut(adminInfoService: $serviceStub); + } + + private function getSut( + ?AdminInfoServiceInterface $adminInfoService = null, + ): AdminInfoApiController { + return new AdminInfoApiController( + adminInfoService: $adminInfoService + ?? $this->createStub(AdminInfoServiceInterface::class), + ); + } + + private function createRequestWithUser(string $username): Request + { + $request = new Request(); + $user = new InMemoryUser($username, null, ['ROLE_USER', 'ROLE_ADMIN']); + $request->attributes->set('_user', $user); + + return $request; + } + + private function decodeResponse(JsonResponse $response): array + { + return json_decode($response->getContent(), true); + } +} diff --git a/tests/Unit/ApiEntrypoint/AdminInfo/DataObject/AdminInfoTest.php b/tests/Unit/ApiEntrypoint/AdminInfo/DataObject/AdminInfoTest.php new file mode 100644 index 0000000..94ff15b --- /dev/null +++ b/tests/Unit/ApiEntrypoint/AdminInfo/DataObject/AdminInfoTest.php @@ -0,0 +1,42 @@ +assertSame($email, $sut->getEmail()); + } + + public function testGetGreeting(): void + { + $greeting = uniqid('greeting_', true); + + $sut = new AdminInfo( + email: uniqid() . '@example.com', + greeting: $greeting, + ); + + $this->assertSame($greeting, $sut->getGreeting()); + } +} diff --git a/tests/Unit/ApiEntrypoint/AdminInfo/Service/AdminInfoServiceTest.php b/tests/Unit/ApiEntrypoint/AdminInfo/Service/AdminInfoServiceTest.php new file mode 100644 index 0000000..ab0bb06 --- /dev/null +++ b/tests/Unit/ApiEntrypoint/AdminInfo/Service/AdminInfoServiceTest.php @@ -0,0 +1,56 @@ +getSut(); + + $this->assertSame($username, $sut->getAdminInfo($username)->getEmail()); + } + + public function testGetAdminInfoReturnsTranslatedGreetingWithEmail(): void + { + $username = uniqid('admin_', true) . '@example.com'; + $translatedPattern = uniqid('hello_', true) . ' %s'; + + $shopAdapterStub = $this->createStub(ShopAdapterInterface::class); + $shopAdapterStub->method('translateString') + ->with(ModuleCore::ADMIN_HELLO_LANGUAGE_CONST) + ->willReturn($translatedPattern); + + $sut = $this->getSut(shopAdapter: $shopAdapterStub); + + $this->assertSame( + sprintf($translatedPattern, $username), + $sut->getAdminInfo($username)->getGreeting() + ); + } + + private function getSut( + ?ShopAdapterInterface $shopAdapter = null, + ): AdminInfoService { + return new AdminInfoService( + shopAdapter: $shopAdapter + ?? $this->createStub(ShopAdapterInterface::class), + ); + } +} diff --git a/tests/Unit/ApiEntrypoint/CustomerGroup/Controller/CustomerGroupApiControllerTest.php b/tests/Unit/ApiEntrypoint/CustomerGroup/Controller/CustomerGroupApiControllerTest.php new file mode 100644 index 0000000..a0c6e3f --- /dev/null +++ b/tests/Unit/ApiEntrypoint/CustomerGroup/Controller/CustomerGroupApiControllerTest.php @@ -0,0 +1,108 @@ +getSut(); + + $this->assertInstanceOf( + JsonResponse::class, + $sut->getCustomerGroups() + ); + } + + public function testGetCustomerGroupsReturnsStatus200(): void + { + $sut = $this->getSut(); + + $this->assertSame(200, $sut->getCustomerGroups()->getStatusCode()); + } + + public function testGetCustomerGroupsContainsGroupData(): void + { + $groupId = uniqid('group_', true); + $title = uniqid('title_', true); + $count = mt_rand(1, 500); + + $serviceStub = $this->createStub(CustomerGroupServiceInterface::class); + $serviceStub->method('getCustomerGroupCounts') + ->willReturn([ + new CustomerGroupCount( + groupId: $groupId, + title: $title, + count: $count, + ), + ]); + $serviceStub->method('getTotalCustomerCount') + ->willReturn($count); + + $sut = $this->getSut(customerGroupService: $serviceStub); + + $data = $this->decodeResponse($sut->getCustomerGroups()); + + $this->assertCount(1, $data['customerGroups']); + $this->assertSame($groupId, $data['customerGroups'][0]['groupId']); + $this->assertSame($title, $data['customerGroups'][0]['title']); + $this->assertSame($count, $data['customerGroups'][0]['count']); + } + + public function testGetCustomerGroupsContainsTotalCount(): void + { + $total = mt_rand(100, 5000); + + $serviceStub = $this->createStub(CustomerGroupServiceInterface::class); + $serviceStub->method('getCustomerGroupCounts') + ->willReturn([]); + $serviceStub->method('getTotalCustomerCount') + ->willReturn($total); + + $sut = $this->getSut(customerGroupService: $serviceStub); + + $data = $this->decodeResponse($sut->getCustomerGroups()); + + $this->assertSame($total, $data['total']); + } + + public function testGetCustomerGroupsResponseStructure(): void + { + $sut = $this->getSut(); + + $data = $this->decodeResponse($sut->getCustomerGroups()); + + $this->assertArrayHasKey('customerGroups', $data); + $this->assertArrayHasKey('total', $data); + $this->assertCount(2, $data); + } + + private function getSut( + ?CustomerGroupServiceInterface $customerGroupService = null, + ): CustomerGroupApiController { + return new CustomerGroupApiController( + customerGroupService: $customerGroupService + ?? $this->createStub(CustomerGroupServiceInterface::class), + ); + } + + private function decodeResponse(JsonResponse $response): array + { + return json_decode($response->getContent(), true); + } +} diff --git a/tests/Unit/ApiEntrypoint/CustomerGroup/DataObject/CustomerGroupCountTest.php b/tests/Unit/ApiEntrypoint/CustomerGroup/DataObject/CustomerGroupCountTest.php new file mode 100644 index 0000000..6ed7b94 --- /dev/null +++ b/tests/Unit/ApiEntrypoint/CustomerGroup/DataObject/CustomerGroupCountTest.php @@ -0,0 +1,57 @@ +assertSame($groupId, $sut->getGroupId()); + } + + public function testGetTitle(): void + { + $title = uniqid('title_', true); + + $sut = new CustomerGroupCount( + groupId: uniqid(), + title: $title, + count: mt_rand(0, 100), + ); + + $this->assertSame($title, $sut->getTitle()); + } + + public function testGetCount(): void + { + $count = mt_rand(0, 10000); + + $sut = new CustomerGroupCount( + groupId: uniqid(), + title: uniqid(), + count: $count, + ); + + $this->assertSame($count, $sut->getCount()); + } +} diff --git a/tests/Unit/ApiEntrypoint/CustomerGroup/Service/CustomerGroupServiceTest.php b/tests/Unit/ApiEntrypoint/CustomerGroup/Service/CustomerGroupServiceTest.php new file mode 100644 index 0000000..1ff0235 --- /dev/null +++ b/tests/Unit/ApiEntrypoint/CustomerGroup/Service/CustomerGroupServiceTest.php @@ -0,0 +1,100 @@ +createStub(CustomerGroupCountDaoInterface::class); + $daoStub->method('getCustomerGroupCounts') + ->willReturn($expectedCounts); + + $sut = $this->getSut(groupCountDao: $daoStub); + + $this->assertSame($expectedCounts, $sut->getCustomerGroupCounts()); + } + + public function testGetCustomerGroupCountsReturnsEmptyArrayWhenNoGroups(): void + { + $daoStub = $this->createStub(CustomerGroupCountDaoInterface::class); + $daoStub->method('getCustomerGroupCounts') + ->willReturn([]); + + $sut = $this->getSut(groupCountDao: $daoStub); + + $this->assertSame([], $sut->getCustomerGroupCounts()); + } + + public function testGetTotalCustomerCountSumsAllGroups(): void + { + $count1 = mt_rand(1, 500); + $count2 = mt_rand(1, 500); + + $daoStub = $this->createStub(CustomerGroupCountDaoInterface::class); + $daoStub->method('getCustomerGroupCounts') + ->willReturn([ + new CustomerGroupCount( + groupId: uniqid(), + title: uniqid(), + count: $count1, + ), + new CustomerGroupCount( + groupId: uniqid(), + title: uniqid(), + count: $count2, + ), + ]); + + $sut = $this->getSut(groupCountDao: $daoStub); + + $this->assertSame($count1 + $count2, $sut->getTotalCustomerCount()); + } + + public function testGetTotalCustomerCountReturnsZeroWhenNoGroups(): void + { + $daoStub = $this->createStub(CustomerGroupCountDaoInterface::class); + $daoStub->method('getCustomerGroupCounts') + ->willReturn([]); + + $sut = $this->getSut(groupCountDao: $daoStub); + + $this->assertSame(0, $sut->getTotalCustomerCount()); + } + + private function getSut( + ?CustomerGroupCountDaoInterface $groupCountDao = null, + ): CustomerGroupService { + return new CustomerGroupService( + groupCountDao: $groupCountDao + ?? $this->createStub(CustomerGroupCountDaoInterface::class), + ); + } +} diff --git a/tests/Unit/ApiEntrypoint/ProductInfo/Controller/ProductInfoApiControllerTest.php b/tests/Unit/ApiEntrypoint/ProductInfo/Controller/ProductInfoApiControllerTest.php new file mode 100644 index 0000000..3d58c76 --- /dev/null +++ b/tests/Unit/ApiEntrypoint/ProductInfo/Controller/ProductInfoApiControllerTest.php @@ -0,0 +1,89 @@ +getSut(); + + $this->assertInstanceOf(JsonResponse::class, $sut->getProductInfo()); + } + + public function testGetProductInfoReturnsStatus200(): void + { + $sut = $this->getSut(); + + $this->assertSame(200, $sut->getProductInfo()->getStatusCode()); + } + + public function testGetProductInfoContainsProductCount(): void + { + $expectedCount = mt_rand(1, 10000); + + $serviceStub = $this->createStub(ProductInfoServiceInterface::class); + $serviceStub->method('getActiveProductCount') + ->willReturn($expectedCount); + + $sut = $this->getSut(productInfoService: $serviceStub); + + $data = $this->decodeResponse($sut->getProductInfo()); + + $this->assertSame($expectedCount, $data['productCount']); + } + + public function testGetProductInfoContainsTranslatedMessage(): void + { + $expectedMessage = uniqid('message_', true); + + $serviceStub = $this->createStub(ProductInfoServiceInterface::class); + $serviceStub->method('getGreetingMessage') + ->willReturn($expectedMessage); + + $sut = $this->getSut(productInfoService: $serviceStub); + + $data = $this->decodeResponse($sut->getProductInfo()); + + $this->assertSame($expectedMessage, $data['message']); + } + + public function testGetProductInfoResponseStructure(): void + { + $sut = $this->getSut(); + + $data = $this->decodeResponse($sut->getProductInfo()); + + $this->assertArrayHasKey('productCount', $data); + $this->assertArrayHasKey('message', $data); + $this->assertCount(2, $data); + } + + private function getSut( + ?ProductInfoServiceInterface $productInfoService = null, + ): ProductInfoApiController { + return new ProductInfoApiController( + productInfoService: $productInfoService + ?? $this->createStub(ProductInfoServiceInterface::class), + ); + } + + private function decodeResponse(JsonResponse $response): array + { + return json_decode($response->getContent(), true); + } +} diff --git a/tests/Unit/ApiEntrypoint/ProductInfo/Service/ProductInfoServiceTest.php b/tests/Unit/ApiEntrypoint/ProductInfo/Service/ProductInfoServiceTest.php new file mode 100644 index 0000000..e4fa186 --- /dev/null +++ b/tests/Unit/ApiEntrypoint/ProductInfo/Service/ProductInfoServiceTest.php @@ -0,0 +1,71 @@ +createStub(ActiveProductCountDaoInterface::class); + $daoStub->method('getActiveProductCount') + ->willReturn($expectedCount); + + $sut = $this->getSut(productCountDao: $daoStub); + + $this->assertSame($expectedCount, $sut->getActiveProductCount()); + } + + public function testGetActiveProductCountReturnsZeroWhenNoProducts(): void + { + $daoStub = $this->createStub(ActiveProductCountDaoInterface::class); + $daoStub->method('getActiveProductCount') + ->willReturn(0); + + $sut = $this->getSut(productCountDao: $daoStub); + + $this->assertSame(0, $sut->getActiveProductCount()); + } + + public function testGetGreetingMessageTranslatesLanguageConstant(): void + { + $expectedTranslation = uniqid('translation_', true); + + $shopAdapterStub = $this->createStub(ShopAdapterInterface::class); + $shopAdapterStub->method('translateString') + ->with(ModuleCore::API_HELLO_LANGUAGE_CONST) + ->willReturn($expectedTranslation); + + $sut = $this->getSut(shopAdapter: $shopAdapterStub); + + $this->assertSame($expectedTranslation, $sut->getGreetingMessage()); + } + + private function getSut( + ?ActiveProductCountDaoInterface $productCountDao = null, + ?ShopAdapterInterface $shopAdapter = null, + ): ProductInfoService { + return new ProductInfoService( + productCountDao: $productCountDao + ?? $this->createStub(ActiveProductCountDaoInterface::class), + shopAdapter: $shopAdapter + ?? $this->createStub(ShopAdapterInterface::class), + ); + } +} diff --git a/tests/Unit/ApiEntrypoint/UserInfo/Controller/UserInfoApiControllerTest.php b/tests/Unit/ApiEntrypoint/UserInfo/Controller/UserInfoApiControllerTest.php new file mode 100644 index 0000000..4c0936d --- /dev/null +++ b/tests/Unit/ApiEntrypoint/UserInfo/Controller/UserInfoApiControllerTest.php @@ -0,0 +1,130 @@ +getSut(); + $request = $this->createRequestWithUser(uniqid()); + + $this->assertInstanceOf( + JsonResponse::class, + $sut->getUserInfo($request) + ); + } + + public function testGetUserInfoReturnsFirstNameAndGreetingUrl(): void + { + $username = uniqid('user_', true); + $firstName = uniqid('name_', true); + $greetingUrl = uniqid('url_', true); + + $serviceStub = $this->createStub(UserInfoServiceInterface::class); + $serviceStub->method('getUserInfo') + ->with($username) + ->willReturn(new UserInfo( + firstName: $firstName, + greetingUrl: $greetingUrl, + )); + + $sut = $this->getSut(userInfoService: $serviceStub); + $request = $this->createRequestWithUser($username); + + $data = $this->decodeResponse($sut->getUserInfo($request)); + + $this->assertSame($firstName, $data['firstName']); + $this->assertSame($greetingUrl, $data['greetingUrl']); + } + + public function testGetUserInfoReturnsStatus200(): void + { + $username = uniqid('user_', true); + + $serviceStub = $this->createStub(UserInfoServiceInterface::class); + $serviceStub->method('getUserInfo') + ->willReturn(new UserInfo( + firstName: uniqid(), + greetingUrl: uniqid(), + )); + + $sut = $this->getSut(userInfoService: $serviceStub); + $request = $this->createRequestWithUser($username); + + $this->assertSame(200, $sut->getUserInfo($request)->getStatusCode()); + } + + public function testGetUserInfoReturns404WhenUserNotFound(): void + { + $serviceStub = $this->createStub(UserInfoServiceInterface::class); + $serviceStub->method('getUserInfo') + ->willReturn(null); + + $sut = $this->getSut(userInfoService: $serviceStub); + $request = $this->createRequestWithUser(uniqid()); + + $response = $sut->getUserInfo($request); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testGetUserInfoResponseStructure(): void + { + $serviceStub = $this->createStub(UserInfoServiceInterface::class); + $serviceStub->method('getUserInfo') + ->willReturn(new UserInfo( + firstName: uniqid(), + greetingUrl: uniqid(), + )); + + $sut = $this->getSut(userInfoService: $serviceStub); + $request = $this->createRequestWithUser(uniqid()); + + $data = $this->decodeResponse($sut->getUserInfo($request)); + + $this->assertArrayHasKey('firstName', $data); + $this->assertArrayHasKey('greetingUrl', $data); + $this->assertCount(2, $data); + } + + private function getSut( + ?UserInfoServiceInterface $userInfoService = null, + ): UserInfoApiController { + return new UserInfoApiController( + userInfoService: $userInfoService + ?? $this->createStub(UserInfoServiceInterface::class), + ); + } + + private function createRequestWithUser(string $username): Request + { + $request = new Request(); + $user = new InMemoryUser($username, null, ['ROLE_USER']); + $request->attributes->set('_user', $user); + + return $request; + } + + private function decodeResponse(JsonResponse $response): array + { + return json_decode($response->getContent(), true); + } +} diff --git a/tests/Unit/ApiEntrypoint/UserInfo/DataObject/UserInfoTest.php b/tests/Unit/ApiEntrypoint/UserInfo/DataObject/UserInfoTest.php new file mode 100644 index 0000000..2d6848e --- /dev/null +++ b/tests/Unit/ApiEntrypoint/UserInfo/DataObject/UserInfoTest.php @@ -0,0 +1,42 @@ +assertSame($firstName, $sut->getFirstName()); + } + + public function testGetGreetingUrl(): void + { + $greetingUrl = uniqid('url_', true); + + $sut = new UserInfo( + firstName: uniqid(), + greetingUrl: $greetingUrl, + ); + + $this->assertSame($greetingUrl, $sut->getGreetingUrl()); + } +} diff --git a/tests/Unit/ApiEntrypoint/UserInfo/Service/UserInfoServiceTest.php b/tests/Unit/ApiEntrypoint/UserInfo/Service/UserInfoServiceTest.php new file mode 100644 index 0000000..9dda66d --- /dev/null +++ b/tests/Unit/ApiEntrypoint/UserInfo/Service/UserInfoServiceTest.php @@ -0,0 +1,78 @@ +createStub(SessionUserDaoInterface::class); + $daoStub->method('getFirstNameByUsername') + ->with($username) + ->willReturn($expectedFirstName); + + $sut = $this->getSut(sessionUserDao: $daoStub); + + $result = $sut->getUserInfo($username); + + $this->assertSame($expectedFirstName, $result->getFirstName()); + } + + public function testGetUserInfoReturnsGreetingUrl(): void + { + $username = uniqid('user_', true); + + $daoStub = $this->createStub(SessionUserDaoInterface::class); + $daoStub->method('getFirstNameByUsername') + ->willReturn(uniqid()); + + $sut = $this->getSut(sessionUserDao: $daoStub); + + $result = $sut->getUserInfo($username); + + $this->assertSame( + 'index.php?cl=oeem_greeting', + $result->getGreetingUrl() + ); + } + + public function testGetUserInfoReturnsNullWhenUserNotFound(): void + { + $username = uniqid('unknown_', true); + + $daoStub = $this->createStub(SessionUserDaoInterface::class); + $daoStub->method('getFirstNameByUsername') + ->with($username) + ->willReturn(null); + + $sut = $this->getSut(sessionUserDao: $daoStub); + + $this->assertNull($sut->getUserInfo($username)); + } + + private function getSut( + ?SessionUserDaoInterface $sessionUserDao = null, + ): UserInfoService { + return new UserInfoService( + sessionUserDao: $sessionUserDao + ?? $this->createStub(SessionUserDaoInterface::class), + ); + } +} diff --git a/translations/de/module_de_lang.php b/translations/de/module_de_lang.php index 890d9b0..baf4715 100644 --- a/translations/de/module_de_lang.php +++ b/translations/de/module_de_lang.php @@ -13,5 +13,7 @@ 'OEEXAMPLESMODULE_GREETING_GENERIC' => 'Frohes Shoppen :)', 'OEEXAMPLESMODULE_GREETING_UPDATE' => 'Begrüßung wählen', 'OEEXAMPLESMODULE_GREETING_UPDATE_TITLE' => 'Begrüßung bitte hier wählen', - 'OEEXAMPLESMODULE_GREETING_UPDATE_COUNT' => 'Anzahl Änderungen: ' + 'OEEXAMPLESMODULE_GREETING_UPDATE_COUNT' => 'Anzahl Änderungen: ', + 'OEEXAMPLESMODULE_API_HELLO' => 'Hallo vom OXID eShop API-Entrypoint', + 'OEEXAMPLESMODULE_ADMIN_HELLO' => 'Hallo, Admin %s', ]; \ No newline at end of file diff --git a/translations/en/module_en_lang.php b/translations/en/module_en_lang.php index f53a6a8..787b175 100644 --- a/translations/en/module_en_lang.php +++ b/translations/en/module_en_lang.php @@ -13,5 +13,7 @@ 'OEEXAMPLESMODULE_GREETING_GENERIC' => 'Have fun :)', 'OEEXAMPLESMODULE_GREETING_UPDATE' => 'Update greeting', 'OEEXAMPLESMODULE_GREETING_UPDATE_TITLE' => 'Please update greeting', - 'OEEXAMPLESMODULE_GREETING_UPDATE_COUNT' => 'Count of changes: ' + 'OEEXAMPLESMODULE_GREETING_UPDATE_COUNT' => 'Count of changes: ', + 'OEEXAMPLESMODULE_API_HELLO' => 'Hello from OXID eShop API-Entrypoint', + 'OEEXAMPLESMODULE_ADMIN_HELLO' => 'Hello, Admin %s', ]; \ No newline at end of file diff --git a/views/admin_twig/de/module_options.php b/views/admin_twig/de/module_options.php index 80b68b0..abb9f8a 100644 --- a/views/admin_twig/de/module_options.php +++ b/views/admin_twig/de/module_options.php @@ -28,4 +28,6 @@ 'SHOP_MODULE_oeexamplesmodule_Categories' => 'Kategorien hinzufügen', 'SHOP_MODULE_oeexamplesmodule_Channels' => 'Kanäle hinzufügen', 'SHOP_MODULE_oeexamplesmodule_Password' => 'Kennwort', + + 'OEEXAMPLESMODULE_ADMIN_HELLO' => 'Hallo, Admin %s', ]; diff --git a/views/admin_twig/en/module_options.php b/views/admin_twig/en/module_options.php index 4e7e145..76f3e6c 100644 --- a/views/admin_twig/en/module_options.php +++ b/views/admin_twig/en/module_options.php @@ -28,4 +28,6 @@ 'SHOP_MODULE_oeexamplesmodule_Categories' => 'Add categories', 'SHOP_MODULE_oeexamplesmodule_Channels' => 'Add channels', 'SHOP_MODULE_oeexamplesmodule_Password' => 'Password', + + 'OEEXAMPLESMODULE_ADMIN_HELLO' => 'Hello, Admin %s', ]; diff --git a/views/twig/extensions/themes/admin_twig/include/header_links.html.twig b/views/twig/extensions/themes/admin_twig/include/header_links.html.twig new file mode 100644 index 0000000..3c881a9 --- /dev/null +++ b/views/twig/extensions/themes/admin_twig/include/header_links.html.twig @@ -0,0 +1,30 @@ +{% extends "include/header_links.html.twig" %} + +{% block admin_header_links %} +