diff --git a/tests/Unit/Service/ContributionAggregatorTest.php b/tests/Unit/Service/ContributionAggregatorTest.php new file mode 100644 index 0000000..95f1d8f --- /dev/null +++ b/tests/Unit/Service/ContributionAggregatorTest.php @@ -0,0 +1,109 @@ +logger = $this->createStub(LoggerInterface::class); + } + + #[Test] + public function it_returns_empty_array_when_no_providers_are_given(): void + { + $aggregator = new ContributionAggregator([], $this->logger); + + $result = $aggregator->aggregate(); + + $this->assertSame([], $result); + } + + #[Test] + public function it_skips_unconfigured_providers(): void + { + $provider = $this->createStub(ProviderInterface::class); + $provider->method('isConfigured')->willReturn(false); + + $aggregator = new ContributionAggregator([$provider], $this->logger); + + $result = $aggregator->aggregate(); + + $this->assertSame([], $result); + } + + #[Test] + public function it_returns_contributions_from_a_configured_provider(): void + { + $provider = $this->createStub(ProviderInterface::class); + $provider->method('isConfigured')->willReturn(true); + $provider->method('fetch')->willReturn(['2024-01-01' => 3]); + + $aggregator = new ContributionAggregator([$provider], $this->logger); + + $result = $aggregator->aggregate(); + + $this->assertSame(['2024-01-01' => 3], $result); + } + + #[Test] + public function it_sums_contributions_from_multiple_providers_on_the_same_date(): void + { + $providerA = $this->createStub(ProviderInterface::class); + $providerA->method('isConfigured')->willReturn(true); + $providerA->method('fetch')->willReturn(['2024-01-01' => 3, '2024-01-02' => 1]); + + $providerB = $this->createStub(ProviderInterface::class); + $providerB->method('isConfigured')->willReturn(true); + $providerB->method('fetch')->willReturn(['2024-01-01' => 2, '2024-01-03' => 5]); + + $aggregator = new ContributionAggregator([$providerA, $providerB], $this->logger); + + $result = $aggregator->aggregate(); + + $this->assertSame(['2024-01-01' => 5, '2024-01-02' => 1, '2024-01-03' => 5], $result); + } + + #[Test] + public function it_continues_fetching_remaining_providers_when_one_throws(): void + { + $failing = $this->createStub(ProviderInterface::class); + $failing->method('isConfigured')->willReturn(true); + $failing->method('fetch')->willThrowException(new \RuntimeException('Network error')); + + $healthy = $this->createStub(ProviderInterface::class); + $healthy->method('isConfigured')->willReturn(true); + $healthy->method('fetch')->willReturn(['2024-01-01' => 7]); + + $aggregator = new ContributionAggregator([$failing, $healthy], $this->logger); + + $result = $aggregator->aggregate(); + + $this->assertSame(['2024-01-01' => 7], $result); + } + + #[Test] + public function it_logs_a_warning_when_a_provider_fetch_throws(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('warning'); + + $provider = $this->createStub(ProviderInterface::class); + $provider->method('isConfigured')->willReturn(true); + $provider->method('fetch')->willThrowException(new \RuntimeException('fail')); + + (new ContributionAggregator([$provider], $logger))->aggregate(); + } +} diff --git a/tests/Unit/Service/GitHubProviderTest.php b/tests/Unit/Service/GitHubProviderTest.php new file mode 100644 index 0000000..daeace5 --- /dev/null +++ b/tests/Unit/Service/GitHubProviderTest.php @@ -0,0 +1,146 @@ +createStub(GraphQLQuery::class); + $graphqlQuery->method('getGraphQLQuery')->willReturn('query {}'); + + $graphqlClient = $this->createStub(GraphQLApiClient::class); + $graphqlClient->method('buildQuery')->willReturn($graphqlQuery); + + $registry = $this->createStub(GraphQLApiClientRegistryInterface::class); + $registry->method('get')->willReturn($graphqlClient); + } + + return new GitHubProvider( + $client ?? $this->createStub(HttpClientInterface::class), + $registry, + $username, + $token, + $this->createStub(LoggerInterface::class), + ); + } + + private function stubGraphqlResponse(array $weeks): ResponseInterface + { + $response = $this->createStub(ResponseInterface::class); + $response->method('toArray')->willReturn([ + 'data' => [ + 'user' => [ + 'contributionsCollection' => [ + 'contributionCalendar' => ['weeks' => $weeks], + ], + ], + ], + ]); + + return $response; + } + + #[Test] + public function it_returns_github_as_name(): void + { + $this->assertSame('github', $this->makeProvider()->getName()); + } + + #[Test] + public function it_is_configured_when_credentials_are_set(): void + { + $this->assertTrue($this->makeProvider()->isConfigured()); + } + + #[Test] + public function it_is_not_configured_when_username_is_empty(): void + { + $this->assertFalse($this->makeProvider(username: '')->isConfigured()); + } + + #[Test] + public function it_is_not_configured_when_token_is_empty(): void + { + $this->assertFalse($this->makeProvider(token: '')->isConfigured()); + } + + #[Test] + public function it_parses_contribution_days_from_the_graphql_response(): void + { + $client = $this->createStub(HttpClientInterface::class); + $client->method('request')->willReturn($this->stubGraphqlResponse([ + ['contributionDays' => [ + ['date' => '2024-06-10', 'contributionCount' => 4], + ['date' => '2024-06-11', 'contributionCount' => 2], + ]], + ])); + + $result = $this->makeProvider(client: $client)->fetch(); + + $this->assertSame(4, $result['2024-06-10']); + $this->assertSame(2, $result['2024-06-11']); + } + + #[Test] + public function it_skips_days_with_zero_contributions(): void + { + $client = $this->createStub(HttpClientInterface::class); + $client->method('request')->willReturn($this->stubGraphqlResponse([ + ['contributionDays' => [ + ['date' => '2024-06-11', 'contributionCount' => 0], + ]], + ])); + + $result = $this->makeProvider(client: $client)->fetch(); + + $this->assertArrayNotHasKey('2024-06-11', $result); + } + + #[Test] + public function it_throws_service_unavailable_exception_on_graphql_errors(): void + { + $response = $this->createStub(ResponseInterface::class); + $response->method('toArray')->willReturn([ + 'errors' => [['message' => 'Bad credentials']], + ]); + + $client = $this->createStub(HttpClientInterface::class); + $client->method('request')->willReturn($response); + + $this->expectException(ServiceUnavailableHttpException::class); + + $this->makeProvider(client: $client)->fetch(); + } + + #[Test] + public function it_returns_empty_when_response_has_no_weeks(): void + { + $client = $this->createStub(HttpClientInterface::class); + $client->method('request')->willReturn($this->stubGraphqlResponse([])); + + $result = $this->makeProvider(client: $client)->fetch(); + + $this->assertSame([], $result); + } +} diff --git a/tests/Unit/Service/GitLabProviderTest.php b/tests/Unit/Service/GitLabProviderTest.php new file mode 100644 index 0000000..5e09aa7 --- /dev/null +++ b/tests/Unit/Service/GitLabProviderTest.php @@ -0,0 +1,128 @@ +createStub(HttpClientInterface::class), + $username, + $token, + $this->createStub(LoggerInterface::class), + $baseUrl, + ); + } + + private function stubResponse(array $data): ResponseInterface + { + $response = $this->createStub(ResponseInterface::class); + $response->method('toArray')->willReturn($data); + + return $response; + } + + #[Test] + public function it_returns_gitlab_as_name(): void + { + $this->assertSame('gitlab', $this->makeProvider()->getName()); + } + + #[Test] + public function it_is_configured_when_credentials_are_set(): void + { + $this->assertTrue($this->makeProvider()->isConfigured()); + } + + #[Test] + public function it_is_not_configured_when_username_is_empty(): void + { + $this->assertFalse($this->makeProvider(username: '')->isConfigured()); + } + + #[Test] + public function it_is_not_configured_when_token_is_empty(): void + { + $this->assertFalse($this->makeProvider(token: '')->isConfigured()); + } + + #[Test] + public function it_throws_not_found_exception_when_user_does_not_exist(): void + { + $client = $this->createStub(HttpClientInterface::class); + $client->method('request')->willReturn($this->stubResponse([])); + + $this->expectException(NotFoundHttpException::class); + + $this->makeProvider(client: $client)->fetch(); + } + + #[Test] + public function it_fetches_events_and_counts_them_by_date(): void + { + $client = $this->createMock(HttpClientInterface::class); + $client->method('request')->willReturnCallback( + function (string $method, string $url): ResponseInterface { + if (str_contains($url, '/events')) { + return $this->stubResponse([ + ['created_at' => '2024-06-10T12:00:00.000Z'], + ['created_at' => '2024-06-10T14:00:00.000Z'], + ['created_at' => '2024-06-11T08:00:00.000Z'], + ]); + } + + return $this->stubResponse([['id' => 42]]); + } + ); + + $result = $this->makeProvider(client: $client)->fetch(); + + $this->assertSame(2, $result['2024-06-10']); + $this->assertSame(1, $result['2024-06-11']); + } + + #[Test] + public function it_fetches_multiple_pages_until_page_has_fewer_than_100_events(): void + { + $callCount = 0; + + $client = $this->createMock(HttpClientInterface::class); + $client->method('request')->willReturnCallback( + function (string $method, string $url) use (&$callCount): ResponseInterface { + if (!str_contains($url, '/events')) { + return $this->stubResponse([['id' => 42]]); + } + + $callCount++; + $data = $callCount === 1 + ? array_fill(0, 100, ['created_at' => '2024-06-10T12:00:00.000Z']) + : [['created_at' => '2024-06-11T08:00:00.000Z']]; + + return $this->stubResponse($data); + } + ); + + $result = $this->makeProvider(client: $client)->fetch(); + + $this->assertSame(2, $callCount); + $this->assertSame(100, $result['2024-06-10']); + $this->assertSame(1, $result['2024-06-11']); + } +} diff --git a/tests/Unit/Service/GiteaProviderTest.php b/tests/Unit/Service/GiteaProviderTest.php new file mode 100644 index 0000000..9593f29 --- /dev/null +++ b/tests/Unit/Service/GiteaProviderTest.php @@ -0,0 +1,111 @@ +createStub(HttpClientInterface::class), + $username, + $token, + $baseUrl, + $this->createStub(LoggerInterface::class), + ); + } + + private function stubResponse(array $data): ResponseInterface + { + $response = $this->createStub(ResponseInterface::class); + $response->method('toArray')->willReturn($data); + + return $response; + } + + #[Test] + public function it_returns_gitea_as_name(): void + { + $this->assertSame('gitea', $this->makeProvider()->getName()); + } + + #[Test] + public function it_is_configured_when_all_credentials_are_set(): void + { + $this->assertTrue($this->makeProvider()->isConfigured()); + } + + #[Test] + public function it_is_not_configured_when_username_is_empty(): void + { + $this->assertFalse($this->makeProvider(username: '')->isConfigured()); + } + + #[Test] + public function it_is_not_configured_when_token_is_empty(): void + { + $this->assertFalse($this->makeProvider(token: '')->isConfigured()); + } + + #[Test] + public function it_is_not_configured_when_base_url_is_empty(): void + { + $this->assertFalse($this->makeProvider(baseUrl: '')->isConfigured()); + } + + #[Test] + public function it_parses_heatmap_entries_into_contributions(): void + { + $now = time(); + + $client = $this->createStub(HttpClientInterface::class); + $client->method('request')->willReturn($this->stubResponse([ + ['timestamp' => $now, 'contributions' => 5], + ])); + + $result = $this->makeProvider(client: $client)->fetch(); + + $this->assertSame(5, $result[date('Y-m-d', $now)]); + } + + #[Test] + public function it_filters_out_entries_older_than_365_days(): void + { + $old = (new \DateTimeImmutable('-366 days'))->getTimestamp(); + + $client = $this->createStub(HttpClientInterface::class); + $client->method('request')->willReturn($this->stubResponse([ + ['timestamp' => $old, 'contributions' => 3], + ])); + + $result = $this->makeProvider(client: $client)->fetch(); + + $this->assertSame([], $result); + } + + #[Test] + public function it_returns_empty_when_response_has_no_entries(): void + { + $client = $this->createStub(HttpClientInterface::class); + $client->method('request')->willReturn($this->stubResponse([])); + + $result = $this->makeProvider(client: $client)->fetch(); + + $this->assertSame([], $result); + } +} diff --git a/tests/Unit/Service/ProbeTraitTest.php b/tests/Unit/Service/ProbeTraitTest.php new file mode 100644 index 0000000..64b5a28 --- /dev/null +++ b/tests/Unit/Service/ProbeTraitTest.php @@ -0,0 +1,128 @@ +configured; } + public function getName(): string { return 'test'; } + public function ping(): void { ($this->ping)(); } + public function fetch(): array { return []; } + }; + } + + #[Test] + public function it_returns_not_configured_when_provider_is_not_configured(): void + { + $provider = $this->makeProvider(false, fn() => null); + + $status = $provider->probe(); + + $this->assertSame(ProviderStatusType::NotConfigured, $status->status); + $this->assertNull($status->error); + } + + #[Test] + public function it_returns_ok_when_ping_succeeds(): void + { + $provider = $this->makeProvider(true, fn() => null); + + $status = $provider->probe(); + + $this->assertSame(ProviderStatusType::Ok, $status->status); + $this->assertNull($status->error); + } + + #[Test] + public function it_returns_url_unreachable_on_transport_failure(): void + { + $exception = $this->createStub(TransportExceptionInterface::class); + $provider = $this->makeProvider(true, fn() => throw $exception); + + $status = $provider->probe(); + + $this->assertSame(ProviderStatusType::Error, $status->status); + $this->assertSame(ProviderErrorCode::UrlUnreachable, $status->error); + } + + #[Test] + public function it_returns_auth_failed_on_401(): void + { + $response = $this->createStub(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(401); + + $exception = $this->createStub(ClientExceptionInterface::class); + $exception->method('getResponse')->willReturn($response); + + $status = $this->makeProvider(true, fn() => throw $exception)->probe(); + + $this->assertSame(ProviderStatusType::Error, $status->status); + $this->assertSame(ProviderErrorCode::AuthFailed, $status->error); + } + + #[Test] + public function it_returns_auth_failed_on_403(): void + { + $response = $this->createStub(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(403); + + $exception = $this->createStub(ClientExceptionInterface::class); + $exception->method('getResponse')->willReturn($response); + + $status = $this->makeProvider(true, fn() => throw $exception)->probe(); + + $this->assertSame(ProviderStatusType::Error, $status->status); + $this->assertSame(ProviderErrorCode::AuthFailed, $status->error); + } + + #[Test] + public function it_returns_url_unreachable_on_404(): void + { + $response = $this->createStub(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(404); + + $exception = $this->createStub(ClientExceptionInterface::class); + $exception->method('getResponse')->willReturn($response); + + $status = $this->makeProvider(true, fn() => throw $exception)->probe(); + + $this->assertSame(ProviderStatusType::Error, $status->status); + $this->assertSame(ProviderErrorCode::UrlUnreachable, $status->error); + } + + #[Test] + public function it_returns_unknown_error_on_unexpected_exception(): void + { + $provider = $this->makeProvider(true, fn() => throw new \RuntimeException('Something went wrong')); + + $status = $provider->probe(); + + $this->assertSame(ProviderStatusType::Error, $status->status); + $this->assertSame(ProviderErrorCode::Unknown, $status->error); + $this->assertSame('Something went wrong', $status->message); + } +} diff --git a/tests/Unit/Service/ProviderHealthCheckerTest.php b/tests/Unit/Service/ProviderHealthCheckerTest.php new file mode 100644 index 0000000..ebda929 --- /dev/null +++ b/tests/Unit/Service/ProviderHealthCheckerTest.php @@ -0,0 +1,88 @@ +createStub(ProviderInterface::class); + $provider->method('probe')->willReturn(new ProviderStatus('test', ProviderStatusType::Ok)); + + $result = (new ProviderHealthChecker([$provider]))->check(); + + $this->assertSame('ok', $result['status']); + } + + #[Test] + public function it_returns_degraded_when_a_provider_has_an_error(): void + { + $provider = $this->createStub(ProviderInterface::class); + $provider->method('probe')->willReturn( + new ProviderStatus('test', ProviderStatusType::Error, ProviderErrorCode::AuthFailed, 'Invalid token'), + ); + + $result = (new ProviderHealthChecker([$provider]))->check(); + + $this->assertSame('degraded', $result['status']); + } + + #[Test] + public function it_does_not_degrade_when_a_provider_is_not_configured(): void + { + $provider = $this->createStub(ProviderInterface::class); + $provider->method('probe')->willReturn(new ProviderStatus('test', ProviderStatusType::NotConfigured)); + + $result = (new ProviderHealthChecker([$provider]))->check(); + + $this->assertSame('ok', $result['status']); + } + + #[Test] + public function it_indexes_provider_statuses_by_name(): void + { + $provider = $this->createStub(ProviderInterface::class); + $provider->method('probe')->willReturn(new ProviderStatus('github', ProviderStatusType::Ok)); + + $result = (new ProviderHealthChecker([$provider]))->check(); + + $this->assertArrayHasKey('github', $result['providers']); + } + + #[Test] + public function it_returns_ok_with_no_providers(): void + { + $result = (new ProviderHealthChecker([]))->check(); + + $this->assertSame('ok', $result['status']); + $this->assertSame([], $result['providers']); + } + + #[Test] + public function it_includes_all_provider_statuses_in_output(): void + { + $github = $this->createStub(ProviderInterface::class); + $github->method('probe')->willReturn(new ProviderStatus('github', ProviderStatusType::Ok)); + + $gitlab = $this->createStub(ProviderInterface::class); + $gitlab->method('probe')->willReturn(new ProviderStatus('gitlab', ProviderStatusType::NotConfigured)); + + $result = (new ProviderHealthChecker([$github, $gitlab]))->check(); + + $this->assertArrayHasKey('github', $result['providers']); + $this->assertArrayHasKey('gitlab', $result['providers']); + } +} diff --git a/tests/Unit/Service/ProviderStatusTest.php b/tests/Unit/Service/ProviderStatusTest.php new file mode 100644 index 0000000..9ff6119 --- /dev/null +++ b/tests/Unit/Service/ProviderStatusTest.php @@ -0,0 +1,64 @@ +assertSame(['status' => 'ok'], $status->toArray()); + } + + #[Test] + public function it_serializes_not_configured_status(): void + { + $status = new ProviderStatus('github', ProviderStatusType::NotConfigured); + + $this->assertSame(['status' => 'not_configured'], $status->toArray()); + } + + #[Test] + public function it_serializes_error_status_with_code_and_message(): void + { + $status = new ProviderStatus('github', ProviderStatusType::Error, ProviderErrorCode::AuthFailed, 'Token expired'); + + $this->assertSame([ + 'status' => 'error', + 'error' => 'auth_failed', + 'message' => 'Token expired', + ], $status->toArray()); + } + + #[Test] + public function it_omits_null_error_fields_from_array(): void + { + $status = new ProviderStatus('github', ProviderStatusType::Ok); + + $array = $status->toArray(); + + $this->assertArrayNotHasKey('error', $array); + $this->assertArrayNotHasKey('message', $array); + } + + #[Test] + public function it_exposes_typed_status_property(): void + { + $status = new ProviderStatus('github', ProviderStatusType::Error, ProviderErrorCode::Unknown, 'msg'); + + $this->assertSame(ProviderStatusType::Error, $status->status); + $this->assertSame(ProviderErrorCode::Unknown, $status->error); + } +}