diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 0000000..0b3130a --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,31 @@ +name: "Code Coverage" + +on: + push: + branches: [ "main" ,"switch-to-s2s-oauth-apps-tests"] + pull_request: + branches: [ "main" ,"switch-to-s2s-oauth-apps-tests"] + +jobs: + code-coverage: + + runs-on: "ubuntu-latest" + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install PHP with extensions + uses: shivammathur/setup-php@v2 + with: + coverage: xdebug + php-version: '8.1' + + - name: Install dependencies + run: composer update --ansi --no-interaction --no-progress --optimize-autoloader + + - name: Collect code coverage with PHPUnit + run: Packages/Libraries/phpunit/phpunit/phpunit --bootstrap Tests/UnitTestBootstrap.php --configuration Tests/phpunit.xml --colors=always --coverage-clover=.build/logs/clover.xml + + - name: Upload coverage reports to Codecov with GitHub Action + uses: codecov/codecov-action@v3 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..09ee9e3 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,40 @@ +name: PHPUnit Tests + +on: + push: + branches: [ "main" ,"switch-to-s2s-oauth-apps-tests"] + pull_request: + branches: [ "main" ,"switch-to-s2s-oauth-apps-tests"] + +jobs: + run-tests: + name: "Test (PHP ${{ matrix.php-versions }}, Neos ${{ matrix.neos-versions }})" + + strategy: + fail-fast: false + matrix: + php-versions: [ '8.1' ] + neos-versions: [ '7.3' ] + include: + - php-versions: '8.1' + neos-versions: '8.3' + + runs-on: ubuntu-latest + + steps: + - name: "Checkout code" + uses: actions/checkout@v4 + + - name: "Install PHP with extensions" + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + + - name: Set Neos Version + run: composer require neos/neos ^${{ matrix.neos-versions }} --no-progress --no-interaction + + - name: Install Dependencies + run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + + - name: Execute tests via PHPUnit + run: composer test diff --git a/.gitignore b/.gitignore index c340ced..72d5799 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ node_modules *.DS_Store /.idea .remote-sync.json +/bin +/Packages +composer.lock +.phpunit.result.cache +Tests/Reports diff --git a/Classes/Domain/Model/ZoomApiAccessToken.php b/Classes/Domain/Model/ZoomApiAccessToken.php new file mode 100644 index 0000000..8f1403d --- /dev/null +++ b/Classes/Domain/Model/ZoomApiAccessToken.php @@ -0,0 +1,12 @@ +settings['auth']['clientId']; + $zoomApiClientSecret = $this->settings['auth']['clientSecret']; + if (!$zoomApiClientId || !$zoomApiClientSecret) { + throw new ZoomApiException('Please set a Client ID and Secret for CodeQ.ZoomApi to be able to authenticate.', 1695830249149); + } + $this->client = $this->buildClient($zoomApiClientId, $zoomApiClientSecret); + } + + /** + * @return ZoomApiAccessToken + * @throws ZoomApiException + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function createFromConfiguration(): ZoomApiAccessToken + { + $zoomApiAccountId = $this->settings['auth']['accountId']; + if (!$zoomApiAccountId) { + throw new ZoomApiException('Please set a Zoom Account ID for CodeQ.ZoomApi to be able to authenticate.', 1695904285296); + } + $response = $this->client->post('oauth/token', [ + 'http_errors' => false, + 'form_params' => [ + 'grant_type' => 'account_credentials', + 'account_id' => $zoomApiAccountId + ] + ]); + + if ($response->getStatusCode() !== 200) { + throw new ZoomApiException('Could not fetch Zoom access token. Please check the settings for account ID, client ID and client secret, as well as your Zoom app.', 1695040346621); + } + + $responseBodyAsArray = json_decode($response->getBody()->getContents(), true); + + if (!str_contains($responseBodyAsArray['scope'], 'user:read:admin') || !str_contains($responseBodyAsArray['scope'], 'recording:read:admin') || !str_contains($responseBodyAsArray['scope'], 'meeting:read:admin')) { + throw new ZoomApiException('Please ensure your Zoom app has the following scopes: user:read:admin, recording:read:admin, meeting:read:admin', 1695040540417); + } + + return new ZoomApiAccessToken( + $responseBodyAsArray['access_token'], + explode(',', $responseBodyAsArray['scope']) + ); + } + + /** + * @param string $zoomApiClientId + * @param string $zoomApiClientSecret + * + * @return Client + */ + protected function buildClient(string $zoomApiClientId, string $zoomApiClientSecret): Client + { + return (new Client([ + 'base_uri' => 'https://zoom.us/', + 'headers' => [ + 'Authorization' => "Basic " . base64_encode($zoomApiClientId . ':' . $zoomApiClientSecret), + 'Content-Type' => 'application/json', + ], + ])); + } +} diff --git a/Classes/Domain/Service/ZoomApiService.php b/Classes/Domain/Service/ZoomApiService.php index 6e19d19..f4a315e 100644 --- a/Classes/Domain/Service/ZoomApiService.php +++ b/Classes/Domain/Service/ZoomApiService.php @@ -1,7 +1,10 @@ settings['auth']['accountId']; - $zoomApiClientId = $this->settings['auth']['clientId']; - $zoomApiClientSecret = $this->settings['auth']['clientSecret']; - if (!$zoomApiAccountId || !$zoomApiClientId || !$zoomApiClientSecret) { - throw new ZoomApiException('Please set a Zoom Account ID, Client ID and Secret for CodeQ.ZoomApi to be able to authenticate.'); - } - - $accessToken = $this->getAccessToken($zoomApiAccountId, $zoomApiClientId, $zoomApiClientSecret); + $accessToken = $this->accessTokenFactory->createFromConfiguration(); + $this->client = $this->buildClient($accessToken->accessToken); + } - $this->client = (new Client([ + protected function buildClient(string $accessToken): Client + { + return (new Client([ 'base_uri' => 'https://api.zoom.us/v2/', 'headers' => [ 'Authorization' => "Bearer $accessToken", @@ -75,9 +75,9 @@ public function getUpcomingMeetings(bool $skipCache = false): array { $cacheEntryIdentifier = 'upcomingMeetings'; - $upcomingMeetings = $this->getCacheEntry($cacheEntryIdentifier); - if (!$skipCache && $upcomingMeetings !== false) { - return $upcomingMeetings; + $cachedUpcomingMeetings = $this->getCacheEntry($cacheEntryIdentifier); + if (!$skipCache && $cachedUpcomingMeetings !== false) { + return $cachedUpcomingMeetings; } $upcomingMeetings = $this->fetchData( @@ -110,29 +110,19 @@ public function getUpcomingMeetings(bool $skipCache = false): array */ public function getRecordings(DateTime|string $from, DateTime|string $to, bool $skipCache = false): array { - $cacheEntryIdentifier = sprintf('recordings_%s_%s', is_string($from) ? $from : $from->format('Y-m-d'), is_string($to) ? $to : $to->format('Y-m-d')); - - $recordings = $this->getCacheEntry($cacheEntryIdentifier); - if (!$skipCache && $recordings !== false) { - return $recordings; - } - - if (is_string($from)) { - $from = new DateTimeImmutable($from); - } else { - $from = DateTimeImmutable::createFromMutable($from); - } - - if (is_string($to)) { - $to = new DateTimeImmutable($to); - } else { - $to = DateTimeImmutable::createFromMutable($to); - } + $from = TimeUtility::convertStringOrDateTimeToDateTimeImmutable($from); + $to = TimeUtility::convertStringOrDateTimeToDateTimeImmutable($to); if ($from > $to) { throw new InvalidArgumentException('The from date must be after the to date'); } + $cacheEntryIdentifier = sprintf('recordings_%s_%s', $from->format('Y-m-d'), $to->format('Y-m-d')); + $cachedRecordings = $this->getCacheEntry($cacheEntryIdentifier); + if (!$skipCache && $cachedRecordings !== false) { + return $cachedRecordings; + } + $recordings = $this->fetchDataForDateRange($from, $to); try { @@ -166,7 +156,7 @@ private function fetchDataForDateRange(DateTimeImmutable $from, DateTimeImmutabl // The zoom API only returns up to one month per request, so if the date range between $from and $to is // bigger than one month we chunk our requests. We start by our $to date and subtract one month from it. - if ($this->dateDifferenceIsBiggerThanOneMonth($from, $to)) { + if (TimeUtility::dateDifferenceIsBiggerThanOneMonth($from, $to)) { $from = $to->sub(DateInterval::createFromDateString('1 month')); $getMoreMonths = true; } @@ -211,8 +201,10 @@ private function fetchData($uri, string $paginatedDataKey): array if (!array_key_exists($paginatedDataKey, $responseData)) { throw new ZoomApiException("Could not find key $paginatedDataKey. Response data: " - . print_r($aggregatedData, - true)); + . print_r( + $aggregatedData, + true + )); } $aggregatedData = array_merge($aggregatedData, $responseData[$paginatedDataKey]); @@ -231,56 +223,20 @@ private function fetchData($uri, string $paginatedDataKey): array */ private function fetchPaginatedData(string $uri, string $paginatedDataKey): array { - $response = $this->client->get($uri); + $response = $this->client->get($uri, [ + 'http_errors' => false + ]); if ($response->getStatusCode() !== 200) { throw new ZoomApiException(sprintf('Could not fetch Zoom paginated data for data key "%s", returned status "%s"', $paginatedDataKey, $response->getStatusCode()), 1695239983421); } $bodyContents = $response->getBody()->getContents(); - return json_decode($bodyContents, true); - } - - private function dateDifferenceIsBiggerThanOneMonth(DateTimeImmutable $from, DateTimeImmutable $to): bool - { - $dateDifference = $from->diff($to); - $differenceInMonths = $dateDifference->y * 12 + $dateDifference->m; - return $differenceInMonths > 0; - } - - /** - * @param string $accountId - * @param string $zoomApiClientId - * @param string $zoomApiClientSecret - * - * @return string|null - * @throws GuzzleException|ZoomApiException - */ - private function getAccessToken(string $accountId, string $zoomApiClientId, string $zoomApiClientSecret): ?string - { - $client = new Client([ - 'base_uri' => 'https://zoom.us/', - 'headers' => [ - 'Authorization' => "Basic " . base64_encode($zoomApiClientId . ':' . $zoomApiClientSecret), - 'Content-Type' => 'application/json', - ], - ]); - $response = $client->post('oauth/token', [ - 'form_params' => [ - 'grant_type' => 'account_credentials', - 'account_id' => $accountId - ] - ]); - - if ($response->getStatusCode() !== 200) { - throw new ZoomApiException('Could not fetch Zoom access token. Please check the settings for account ID, client ID and client secret, as well as your Zoom app.', 1695040346621); - } - - $responseBodyAsArray = json_decode($response->getBody()->getContents(), true); + $bodyContentsArray = json_decode($bodyContents, true); - if (!str_contains($responseBodyAsArray['scope'], 'user:read:admin') || !str_contains($responseBodyAsArray['scope'], 'recording:read:admin') || !str_contains($responseBodyAsArray['scope'], 'meeting:read:admin')) { - throw new ZoomApiException('Please ensure your Zoom app has the following scopes: user:read:admin, recording:read:admin, meeting:read:admin', 1695040540417); + if ($bodyContentsArray === null || !array_key_exists($paginatedDataKey, $bodyContentsArray)) { + throw new ZoomApiException(sprintf('Could not fetch Zoom paginated data for data key "%s", returned empty response', $paginatedDataKey), 1695828849253); } - return $responseBodyAsArray['access_token']; + return $bodyContentsArray; } /** diff --git a/Classes/Eel/ZoomApiHelper.php b/Classes/Eel/ZoomApiHelper.php index abb2405..ceb0f5b 100644 --- a/Classes/Eel/ZoomApiHelper.php +++ b/Classes/Eel/ZoomApiHelper.php @@ -1,5 +1,7 @@ diff($to); + $differenceInMonths = $dateDifference->y * 12 + $dateDifference->m; + return $differenceInMonths > 0; + } +} diff --git a/Classes/ZoomApiException.php b/Classes/ZoomApiException.php index 564d906..0220f47 100644 --- a/Classes/ZoomApiException.php +++ b/Classes/ZoomApiException.php @@ -6,5 +6,4 @@ class ZoomApiException extends Exception { - } diff --git a/README.md b/README.md index 58290a8..67fcac5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ [![Latest Stable Version](https://poser.pugx.org/codeq/zoom-api/v/stable)](https://packagist.org/packages/codeq/zoom-api) [![License](https://poser.pugx.org/codeq/zoom-api/license)](LICENSE) +[![Code Coverage](https://codecov.io/gh/code-q-web-factory/CodeQ.ZoomApi/branch/switch-to-s2s-oauth-apps-tests/graph/badge.svg)](https://codecov.io/gh/code-q-web-factory/CodeQ.ZoomApi) + # CodeQ.ZoomApi @@ -49,3 +51,11 @@ CodeQ_ZoomApi_Requests: ``` Of course, you can also switch to a different cache backend at your convenience. + + +## Testing + +To run the unit tests, execute the following commands: + +1. `composer install` +2. `composer test` diff --git a/Tests/Unit/TimeUtilityTest.php b/Tests/Unit/TimeUtilityTest.php new file mode 100644 index 0000000..5ea2a4b --- /dev/null +++ b/Tests/Unit/TimeUtilityTest.php @@ -0,0 +1,82 @@ +assertEquals($c, $dateDifferenceIsBiggerThanOneMonth); + } + + public static function validDateTimeInputs(): array + { + return [ + [new \DateTime('1980-01-01'), '1980-01-01'], + ['1980-01-01', '1980-01-01'], + ]; + } + + /** + * @test + * @dataProvider validDateTimeInputs + */ + public function convertStringOrDateTimeToDateTimeImmutableWillConvertValidInputData($dateTime, $dateTimeString): void + { + $dateTimeImmutable = TimeUtility::convertStringOrDateTimeToDateTimeImmutable($dateTime); + + + $this->assertEquals($dateTimeString, $dateTimeImmutable->format('Y-m-d')); + $this->assertInstanceOf(DateTimeImmutable::class, $dateTimeImmutable); + } + + public static function invalidDateTimeInputs(): array + { + return [ + ['I am a teapot.'], + ]; + } + + /** + * @test + * @dataProvider invalidDateTimeInputs + * @param $dateTime + * + * @return void + */ + public function convertStringOrDateTimeToDateTimeImmutableThrowsExceptionOnInvalidInputData($dateTime): void + { + $this->expectException(Exception::class); + + + TimeUtility::convertStringOrDateTimeToDateTimeImmutable($dateTime); + } +} diff --git a/Tests/Unit/ZoomApiAccessTokenFactoryTest.php b/Tests/Unit/ZoomApiAccessTokenFactoryTest.php new file mode 100644 index 0000000..38704f9 --- /dev/null +++ b/Tests/Unit/ZoomApiAccessTokenFactoryTest.php @@ -0,0 +1,130 @@ + '1234567890', 'scope' => 'user:read:admin, recording:read:admin, meeting:read:admin'])) + ]) + ); + $factoryMock = $this->getFactory($handlerStack); + + + $accessToken = $factoryMock->createFromConfiguration(); + $this->assertInstanceOf(ZoomApiAccessToken::class, $accessToken); + } + + public static function getInvalidConfigurations(): array + { + return [ + [['auth' => ['accountId' => '1234567890', 'clientId' => '', 'clientSecret' => '1234567890']]], + [['auth' => ['accountId' => '1234567890', 'clientId' => '1234567890', 'clientSecret' => '']]], + + [['auth' => ['accountId' => '1234567890', 'clientId' => null, 'clientSecret' => '1234567890']]], + [['auth' => ['accountId' => '1234567890', 'clientId' => '1234567890', 'clientSecret' => null]]], + + [['auth' => ['accountId' => '1234567890', 'clientId' => false, 'clientSecret' => '1234567890']]], + [['auth' => ['accountId' => '1234567890', 'clientId' => '1234567890', 'clientSecret' => false]]], + ]; + } + + /** + * @dataProvider getInvalidConfigurations + * @test + */ + public function invalidConfigurationWillThrowException(array $invalidConfiguration): void + { + $this->expectException(ZoomApiException::class); + $this->expectExceptionMessage('Please set a Client ID and Secret for CodeQ.ZoomApi to be able to authenticate.'); + + + $factoryMock = $this->getFactory(); + $this->inject( + $factoryMock, + 'settings', + $invalidConfiguration + ); + + + $factoryMock->initializeObject(); + } + + /** @test */ + public function unsuccessfulRequestThrowsZoomApiException(): void + { + $this->expectException(ZoomApiException::class); + $this->expectExceptionMessage('Could not fetch Zoom access token. Please check the settings for account ID, client ID and client secret, as well as your Zoom app.'); + + $handlerStack = HandlerStack::create( + new MockHandler([ + new Response(400) + ]) + ); + $factoryMock = $this->getFactory($handlerStack); + + + $factoryMock->createFromConfiguration(); + } + + public static function getInvalidAccessTokenScopes(): array + { + return [ + ['recording:read:admin,meeting:read:admin'], + ['user:read:admin,meeting:read:admin'], + ['user:read:admin,recording:read:admin'], + ]; + } + + /** + * @test + * @dataProvider getInvalidAccessTokenScopes + * @param string $scopes + * + * @return void + */ + public function missingAccessTokenScopesThrowsZoomApiException(string $scopes): void + { + $this->expectException(ZoomApiException::class); + $this->expectExceptionMessage('Please ensure your Zoom app has the following scopes: user:read:admin, recording:read:admin, meeting:read:admin'); + + + $handlerStack = HandlerStack::create( + new MockHandler([ + new Response(200, [], json_encode(['access_token' => '1234567890', 'scope' => $scopes])) + ]) + ); + $factoryMock = $this->getFactory($handlerStack); + + + $factoryMock->createFromConfiguration(); + } + + private function getFactory(HandlerStack $handlerStack = null): ZoomApiAccessTokenFactory|MockObject + { + $factory = $this->getAccessibleMock(ZoomApiAccessTokenFactory::class, ['buildClient'], [], '', false); + $client = new Client(['handler' => $handlerStack]); + $factory->method('buildClient')->willReturn($client); + $this->inject( + $factory, + 'settings', + ['auth' => ['accountId' => '1234567890', 'clientId' => '1234567890', 'clientSecret' => '1234567890']] + ); + $factory->initializeObject(); + return $factory; + } +} diff --git a/Tests/Unit/ZoomApiHelperTest.php b/Tests/Unit/ZoomApiHelperTest.php new file mode 100644 index 0000000..ec455e3 --- /dev/null +++ b/Tests/Unit/ZoomApiHelperTest.php @@ -0,0 +1,107 @@ +zoomApiHelper = new ZoomApiHelper(); + + $this->systemLoggerMock = Mockery::mock(LoggerInterface::class); + $this->inject( + $this->zoomApiHelper, + 'systemLogger', + $this->systemLoggerMock + ); + + $this->zoomApiServiceMock = $this->createMock(ZoomApiService::class); + $this->inject( + $this->zoomApiHelper, + 'zoomApiService', + $this->zoomApiServiceMock + ); + } + + /** @test * */ + public function canGetRecordings(): void + { + $this->zoomApiServiceMock + ->expects($this->once()) + ->method('getRecordings') + ->with('2021-01-01', '2021-01-02') + ->willReturn([]); + + + $result = $this->zoomApiHelper->getRecordings('2021-01-01', '2021-01-02'); + + + $this->assertSame([], $result); + } + + /** @test * */ + public function canHandleGetRecordingsException(): void + { + $this->zoomApiServiceMock + ->expects($this->once()) + ->method('getRecordings') + ->with('2021-01-01', '2021-01-02') + ->willThrowException(new \Exception('Test Exception')); + + $this->systemLoggerMock->shouldReceive('error')->once(); + + + $result = $this->zoomApiHelper->getRecordings('2021-01-01', '2021-01-02'); + + + $this->assertFalse($result); + } + + /** @test * */ + public function canGetUpcomingMeetings(): void + { + $this->zoomApiServiceMock + ->expects($this->once()) + ->method('getUpcomingMeetings') + ->with() + ->willReturn([]); + + + $result = $this->zoomApiHelper->getUpcomingMeetings(); + + + $this->assertSame([], $result); + } + + /** @test * */ + public function canHandleGetUpcomingMeetingsException(): void + { + $this->zoomApiServiceMock + ->expects($this->once()) + ->method('getUpcomingMeetings') + ->with() + ->willThrowException(new \Exception('Test Exception')); + + $this->systemLoggerMock->shouldReceive('error')->once(); + + + $result = $this->zoomApiHelper->getUpcomingMeetings(); + + + $this->assertFalse($result); + } +} diff --git a/Tests/Unit/ZoomApiServiceTest.php b/Tests/Unit/ZoomApiServiceTest.php new file mode 100644 index 0000000..f5ed8d7 --- /dev/null +++ b/Tests/Unit/ZoomApiServiceTest.php @@ -0,0 +1,256 @@ +accessTokenFactoryMock = Mockery::mock(ZoomApiAccessTokenFactory::class, [ + 'createFromConfiguration' => new ZoomApiAccessToken('123456789', []) + ]); + $this->cacheMock = $this->getMockBuilder(VariableFrontend::class)->disableOriginalConstructor()->getMock(); + $this->systemLoggerMock = Mockery::mock(LoggerInterface::class); + } + + /** @test */ + public function canFetchData(): void + { + $handlerStack = HandlerStack::create( + new MockHandler([ + new Response(200, [], json_encode(['meetings' => ['meeting1'], 'next_page_token' => '1234567890'])), + new Response(200, [], json_encode(['meetings' => ['meeting2'], 'next_page_token' => ''])), + ]) + ); + + + + $this->cacheMock->expects($this->once()) + ->method('get') + ->willReturn(false); + + $service = $this->getService($handlerStack); + $service->initializeObject(); + + + $result = $service->getUpcomingMeetings(); + + + $this->assertEquals(['meeting1', 'meeting2'], $result); + } + + /** @test */ + public function getRecordingsWithStringDatesAndValidCacheReturnsCachedData() + { + $this->cacheMock = $this->getMockBuilder(VariableFrontend::class)->disableOriginalConstructor()->getMock(); + $this->cacheMock->expects($this->once()) + ->method('get') + ->willReturn(['cached123']); + + $service = $this->getService(); + $service->initializeObject(); + + + $result = $service->getRecordings('2023-01-01', '2023-01-02'); + + $this->assertEquals(['cached123'], $result); + } + + /** @test */ + public function getRecordingsWithStringDatesEmptyCacheReturnsFetchedData() + { + $handlerStack = HandlerStack::create( + new MockHandler([ + new Response(200, [], json_encode(['meetings' => ['meeting1'], 'next_page_token' => '1234567890'])), + new Response(200, [], json_encode(['meetings' => ['meeting2'], 'next_page_token' => ''])), + ]) + ); + + $service = $this->getService($handlerStack); + $service->initializeObject(); + $this->cacheMock->method('get')->willReturn(false); + + + $result = $service->getRecordings('2023-01-01', '2023-01-02'); + + + $this->assertEquals(['meeting1', 'meeting2'], $result); + } + + /** @test */ + public function getRecordingsWithObjectDatesEmptyCacheReturnsFetchedData() + { + $handlerStack = HandlerStack::create( + new MockHandler([ + new Response(200, [], json_encode(['meetings' => ['meeting1'], 'next_page_token' => '1234567890'])), + new Response(200, [], json_encode(['meetings' => ['meeting2'], 'next_page_token' => ''])), + ]) + ); + + $service = $this->getService($handlerStack); + $service->initializeObject(); + $this->cacheMock->method('get')->willReturn(false); + + + $result = $service->getRecordings(new DateTime('2023-01-01'), new DateTime('2023-01-02')); + + + $this->assertEquals(['meeting1', 'meeting2'], $result); + } + + /** @test */ + public function getRecordingsWithObjectDatesAndCacheExceptionReturnsFetchedData() + { + $handlerStack = HandlerStack::create( + new MockHandler([ + new Response(200, [], json_encode(['meetings' => ['meeting1'], 'next_page_token' => ''])), + new Response(200, [], json_encode(['meetings' => ['meeting2'], 'next_page_token' => ''])), + new Response(200, [], json_encode(['meetings' => ['meeting3'], 'next_page_token' => '1234567890'])), + new Response(200, [], json_encode(['meetings' => ['meeting4'], 'next_page_token' => ''])), + ]) + ); + + $service = $this->getService($handlerStack); + $service->initializeObject(); + $this->cacheMock->method('get')->willReturn(false); + $this->cacheMock->method('set')->willThrowException(new Exception()); + $this->systemLoggerMock->shouldReceive('error')->once(); + + $result = $service->getRecordings(new DateTime('2023-01-01'), new DateTime('2023-03-01')); + + + $this->assertEquals(['meeting1', 'meeting2', 'meeting3', 'meeting4'], $result); + } + +/** @test */ + public function getRecordingsThrowsExceptionIfFromIsBiggerThanTo() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The from date must be after the to date'); + + $service = $this->getService(); + $service->initializeObject(); + $this->cacheMock->method('get')->willReturn(false); + + + $service->getRecordings(new DateTime('2023-01-02'), new DateTime('2023-01-01')); + } + + /** @test */ + public function upcomingMeetingsReturnsValidCachedData() + { + $this->cacheMock = $this->getMockBuilder(VariableFrontend::class)->disableOriginalConstructor()->getMock(); + $this->cacheMock->expects($this->once()) + ->method('get') + ->willReturn(['cached123']); + + $service = $this->getService(); + $service->initializeObject(); + + + $result = $service->getUpcomingMeetings(); + + $this->assertEquals(['cached123'], $result); + } + + /** @test */ + public function upcomingMeetingsWithEmptyCacheReturnsNewData() + { + $handlerStack = HandlerStack::create( + new MockHandler([ + new Response(200, [], json_encode(['meetings' => ['meeting1'], 'next_page_token' => '1234567890'])), + new Response(200, [], json_encode(['meetings' => ['meeting2'], 'next_page_token' => ''])), + ]) + ); + + $service = $this->getService($handlerStack); + $service->initializeObject(); + $this->cacheMock->method('get')->willReturn(false); + + + $result = $service->getUpcomingMeetings(true); + + + $this->assertEquals(['meeting1', 'meeting2'], $result); + } + + /** @test */ + public function upcomingMeetingsWithCacheExceptionReturnsNewData() + { + $handlerStack = HandlerStack::create( + new MockHandler([ + new Response(200, [], json_encode(['meetings' => ['meeting1'], 'next_page_token' => '1234567890'])), + new Response(200, [], json_encode(['meetings' => ['meeting2'], 'next_page_token' => ''])), + ]) + ); + + $service = $this->getService($handlerStack); + $service->initializeObject(); + $this->cacheMock->method('get')->willReturn(false); + $this->cacheMock->method('set')->willThrowException(new Exception()); + $this->systemLoggerMock->shouldReceive('error')->once(); + + + $result = $service->getUpcomingMeetings(true); + + + $this->assertEquals(['meeting1', 'meeting2'], $result); + } + + /** @test */ + public function getUpcomingMeetingsWithEmptyResponseThrowsException() + { + $this->expectException(ZoomApiException::class); + + $handlerStack = HandlerStack::create( + new MockHandler([ + new Response(200, [], ''), + ]) + ); + + $service = $this->getService($handlerStack); + $service->initializeObject(); + $this->cacheMock->method('get')->willReturn(false); + + + $service->getUpcomingMeetings(true); + } + + /** + * @param HandlerStack|null $handlerStack + * + * @return ZoomApiService|MockObject + */ + private function getService(HandlerStack $handlerStack = null): ZoomApiService|MockObject + { + $service = $this->getAccessibleMock(ZoomApiService::class, ['buildClient'], [], '', false); + $client = new Client(['handler' => $handlerStack]); + $service->method('buildClient')->willReturn($client); + $this->inject($service, 'accessTokenFactory', $this->accessTokenFactoryMock); + $this->inject($service, 'requestsCache', $this->cacheMock); + $this->inject($service, 'systemLogger', $this->systemLoggerMock); + + return $service; + } +} diff --git a/Tests/UnitTestBootstrap.php b/Tests/UnitTestBootstrap.php new file mode 100644 index 0000000..10492fe --- /dev/null +++ b/Tests/UnitTestBootstrap.php @@ -0,0 +1,65 @@ +isDir() || $fileInfo->isDot() || $fileInfo->getFilename() === 'Libraries') continue; + + $classFilePathAndName = $fileInfo->getPathname() . '/'; + foreach ($classNameParts as $index => $classNamePart) { + $classFilePathAndName .= $classNamePart; + if (file_exists($classFilePathAndName . '/Classes')) { + $packageKeyParts = array_slice($classNameParts, 0, $index + 1); + $classesOrTests = ($classNameParts[$index + 1] === 'Tests' && isset($classNameParts[$index + 2]) && $classNameParts[$index + 2] === 'Unit') ? '/' : '/Classes/' . implode('/', $packageKeyParts) . '/'; + $classesFilePathAndName = $classFilePathAndName . $classesOrTests . implode('/', array_slice($classNameParts, $index + 1)) . '.php'; + if (is_file($classesFilePathAndName)) { + require($classesFilePathAndName); + break; + } + } + $classFilePathAndName .= '.'; + } + } +} diff --git a/Tests/phpunit.xml b/Tests/phpunit.xml new file mode 100644 index 0000000..3f828f3 --- /dev/null +++ b/Tests/phpunit.xml @@ -0,0 +1,27 @@ + + + + + Unit + + + + + ../Classes + + + + + + + + + diff --git a/composer.json b/composer.json index be1de1e..f1f4b97 100644 --- a/composer.json +++ b/composer.json @@ -6,13 +6,18 @@ "require": { "neos/neos": "^7.0 || ^8.0 || dev-master", "guzzlehttp/guzzle": "^7.4", - "php": "^8" + "php": "^8.1" }, "autoload": { "psr-4": { "CodeQ\\ZoomApi\\": "Classes/" } }, + "autoload-dev": { + "psr-4": { + "CodeQ\\ZoomApi\\Tests\\": "Tests/" + } + }, "authors": [ { "name": "Roland Schütz", @@ -33,5 +38,26 @@ "neos": { "package-key": "CodeQ.ZoomApi" } - } + }, + "config": { + "allow-plugins": { + "neos/composer-plugin": true + }, + "vendor-dir": "Packages/Libraries", + "bin-dir": "bin" + }, + "require-dev": { + "phpstan/phpstan": "1.10.37", + "squizlabs/php_codesniffer": "^3.7", + "phpunit/phpunit": "^9", + "mockery/mockery": "@stable", + "mikey179/vfsstream": "@stable" + }, + "scripts": { + "fix:style": "phpcbf --colors --standard=PSR12 Classes", + "test:style": "phpcs --colors -n --standard=PSR12 Classes", + "test:stan": "phpstan analyse Classes", + "test:unit": "Packages/Libraries/phpunit/phpunit/phpunit --bootstrap Tests/UnitTestBootstrap.php --configuration Tests/phpunit.xml", + "phpstan-cc": "phpstan clear cache", + "test": ["composer install", "composer test:unit", "composer test:style" , "composer test:stan"] } }