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.
224 lines
8.2 KiB
PHP
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);
|
|
}
|
|
}
|