logger = $this->createStub(LoggerInterface::class); $this->store = new ContributionStore(':memory:'); } /** @param ?array $fresh */ private function makeProvider(string $name, bool $configured = true, ?array $fresh = null): ProviderInterface { $provider = $this->createStub(ProviderInterface::class); $provider->method('getName')->willReturn($name); $provider->method('isConfigured')->willReturn($configured); if ($fresh !== null) { $provider->method('resolveFetch')->willReturn($fresh); } return $provider; } #[Test] public function it_returns_empty_array_when_no_providers_are_given(): void { $aggregator = new ContributionAggregator([], $this->store, $this->logger); $result = $aggregator->aggregate(); $this->assertSame([], $result); } #[Test] public function it_skips_unconfigured_providers(): void { $provider = $this->makeProvider('github', configured: false); $aggregator = new ContributionAggregator([$provider], $this->store, $this->logger); $result = $aggregator->aggregate(); $this->assertSame([], $result); } #[Test] public function it_returns_contributions_from_a_configured_provider(): void { $date = (new \DateTimeImmutable('-1 day'))->format('Y-m-d'); $provider = $this->makeProvider('github', fresh: [$date => 3]); $aggregator = new ContributionAggregator([$provider], $this->store, $this->logger); $result = $aggregator->aggregate(); $this->assertSame([$date => 3], $result); } #[Test] public function it_sums_contributions_from_multiple_providers_on_the_same_date(): void { $dateA = (new \DateTimeImmutable('-1 day'))->format('Y-m-d'); $dateB = (new \DateTimeImmutable('-2 days'))->format('Y-m-d'); $providerA = $this->makeProvider('github', fresh: [$dateA => 3, $dateB => 1]); $providerB = $this->makeProvider('gitlab', fresh: [$dateA => 2]); $aggregator = new ContributionAggregator([$providerA, $providerB], $this->store, $this->logger); $result = $aggregator->aggregate(); $this->assertSame([$dateB => 1, $dateA => 5], $result); } #[Test] public function it_continues_fetching_remaining_providers_when_one_throws_on_start(): void { $date = (new \DateTimeImmutable('-1 day'))->format('Y-m-d'); $failing = $this->createStub(ProviderInterface::class); $failing->method('getName')->willReturn('gitlab'); $failing->method('isConfigured')->willReturn(true); $failing->method('startFetch')->willThrowException(new \RuntimeException('Network error')); $healthy = $this->makeProvider('github', fresh: [$date => 7]); $aggregator = new ContributionAggregator([$failing, $healthy], $this->store, $this->logger); $result = $aggregator->aggregate(); $this->assertSame([$date => 7], $result); } #[Test] public function it_continues_fetching_remaining_providers_when_one_throws_on_resolve(): void { $date = (new \DateTimeImmutable('-1 day'))->format('Y-m-d'); $failing = $this->createStub(ProviderInterface::class); $failing->method('getName')->willReturn('gitlab'); $failing->method('isConfigured')->willReturn(true); $failing->method('resolveFetch')->willThrowException(new \RuntimeException('Network error')); $healthy = $this->makeProvider('github', fresh: [$date => 7]); $aggregator = new ContributionAggregator([$failing, $healthy], $this->store, $this->logger); $result = $aggregator->aggregate(); $this->assertSame([$date => 7], $result); } #[Test] public function it_logs_a_warning_when_a_provider_start_fetch_throws(): void { $logger = $this->createMock(LoggerInterface::class); $logger->expects($this->once())->method('warning'); $provider = $this->createStub(ProviderInterface::class); $provider->method('getName')->willReturn('github'); $provider->method('isConfigured')->willReturn(true); $provider->method('startFetch')->willThrowException(new \RuntimeException('fail')); (new ContributionAggregator([$provider], $this->store, $logger))->aggregate(); } #[Test] public function it_logs_a_warning_when_a_provider_resolve_fetch_throws(): void { $logger = $this->createMock(LoggerInterface::class); $logger->expects($this->once())->method('warning'); $provider = $this->createStub(ProviderInterface::class); $provider->method('getName')->willReturn('github'); $provider->method('isConfigured')->willReturn(true); $provider->method('resolveFetch')->willThrowException(new \RuntimeException('fail')); (new ContributionAggregator([$provider], $this->store, $logger))->aggregate(); } #[Test] public function it_consults_latest_date_and_narrows_the_since_window_by_three_days(): void { $latest = (new \DateTimeImmutable('-30 days'))->getTimestamp(); $this->store->add('github', $latest, 1); $provider = $this->createMock(ProviderInterface::class); $provider->method('getName')->willReturn('github'); $provider->method('isConfigured')->willReturn(true); $provider->expects($this->once()) ->method('startFetch') ->with($this->callback( fn (?\DateTimeImmutable $since): bool => $since !== null && $since->getTimestamp() === $latest - 3 * 86400 )) ->willReturn(null); $provider->method('resolveFetch')->willReturn([]); (new ContributionAggregator([$provider], $this->store, $this->logger))->aggregate(); } #[Test] public function it_merges_freshly_fetched_contributions_into_the_store(): void { $date = (new \DateTimeImmutable('-1 day'))->format('Y-m-d'); $provider = $this->makeProvider('github', fresh: [$date => 4]); (new ContributionAggregator([$provider], $this->store, $this->logger))->aggregate(); $stored = iterator_to_array($this->store->all('github')); $this->assertCount(1, $stored); $this->assertSame(4, $stored[0]->count); } #[Test] public function it_prunes_the_store_once_per_aggregate_call(): void { $store = $this->createMock(ContributionStore::class); $store->method('latestDate')->willReturn(null); $store->method('all')->willReturn(new \GitContributionGraph\Entity\ContributionCollection()); $store->expects($this->once())->method('prune'); $provider = $this->makeProvider('github', fresh: []); (new ContributionAggregator([$provider], $store, $this->logger))->aggregate(); } #[Test] public function it_leaves_other_providers_stored_data_intact_when_one_provider_fails(): void { $date = (new \DateTimeImmutable('-1 day'))->getTimestamp(); $this->store->add('gitlab', $date, 9); $failing = $this->createStub(ProviderInterface::class); $failing->method('getName')->willReturn('gitlab'); $failing->method('isConfigured')->willReturn(true); $failing->method('startFetch')->willThrowException(new \RuntimeException('Network error')); $healthy = $this->makeProvider('github', fresh: []); (new ContributionAggregator([$failing, $healthy], $this->store, $this->logger))->aggregate(); $stored = iterator_to_array($this->store->all('gitlab')); $this->assertCount(1, $stored); $this->assertSame(9, $stored[0]->count); } }