Files
git-contribution-graph/tests/Unit/Service/ContributionAggregatorTest.php
T
haylan 4ff45b7c46 feat(store): fetch contributions incrementally and persist history in SQLite
Bound each provider's startFetch()/resolveFetch() to an optional
since/until window, wire a SQLite-backed ContributionStore into
ContributionAggregator (fetch only the trailing window past the last
stored date, merge into the store, prune by CONTRIBUTIONS_RETENTION_DAYS),
and add a graph:contributions:refetch command to force a full or ranged
re-fetch in <=365-day chunks. This is the root fix for the full
365-day-refetch timeout that used to hit on every cache miss.
2026-07-12 23:42:41 +02:00

224 lines
8.2 KiB
PHP

<?php
declare(strict_types=1);
namespace GitContributionGraph\Tests\Unit\Service;
use GitContributionGraph\Service\ContributionAggregator;
use GitContributionGraph\Service\ContributionStore;
use GitContributionGraph\Service\Provider\ProviderInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
#[CoversClass(ContributionAggregator::class)]
final class ContributionAggregatorTest extends TestCase
{
private LoggerInterface $logger;
private ContributionStore $store;
protected function setUp(): void
{
$this->logger = $this->createStub(LoggerInterface::class);
$this->store = new ContributionStore(':memory:');
}
/** @param ?array<string, int> $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);
}
}