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.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace GitContributionGraph\Tests\Unit\Command;
|
||||
|
||||
use GitContributionGraph\Command\RefetchContributionsCommand;
|
||||
use GitContributionGraph\Service\ContributionStore;
|
||||
use GitContributionGraph\Service\Provider\ProviderInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
#[CoversClass(RefetchContributionsCommand::class)]
|
||||
final class RefetchContributionsCommandTest extends TestCase
|
||||
{
|
||||
/** @param ?array<string, int> $fresh */
|
||||
private function makeProvider(
|
||||
string $name,
|
||||
bool $configured = true,
|
||||
?array $fresh = null,
|
||||
?\Throwable $throws = null,
|
||||
): ProviderInterface {
|
||||
$provider = $this->createStub(ProviderInterface::class);
|
||||
$provider->method('getName')->willReturn($name);
|
||||
$provider->method('isConfigured')->willReturn($configured);
|
||||
|
||||
if ($throws !== null) {
|
||||
$provider->method('startFetch')->willThrowException($throws);
|
||||
} elseif ($fresh !== null) {
|
||||
$provider->method('resolveFetch')->willReturn($fresh);
|
||||
}
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
/** @param iterable<ProviderInterface> $providers */
|
||||
private function makeTester(iterable $providers, ContributionStore $store): CommandTester
|
||||
{
|
||||
$command = new RefetchContributionsCommand($providers, $store);
|
||||
|
||||
$application = new Application();
|
||||
$application->add($command);
|
||||
|
||||
return new CommandTester($application->find('graph:contributions:refetch'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_refetches_the_default_365_day_window_for_all_configured_providers(): void
|
||||
{
|
||||
$store = new ContributionStore(':memory:');
|
||||
$provider = $this->makeProvider('github', fresh: ['2024-06-10' => 3]);
|
||||
|
||||
$tester = $this->makeTester([$provider], $store);
|
||||
$exitCode = $tester->execute([]);
|
||||
|
||||
$this->assertSame(0, $exitCode);
|
||||
$this->assertCount(1, $store->all('github'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_skips_unconfigured_providers_by_default(): void
|
||||
{
|
||||
$store = new ContributionStore(':memory:');
|
||||
$provider = $this->makeProvider('github', configured: false);
|
||||
|
||||
$tester = $this->makeTester([$provider], $store);
|
||||
$tester->execute([]);
|
||||
|
||||
$this->assertCount(0, $store->all('github'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_fails_cleanly_on_an_unknown_provider_name(): void
|
||||
{
|
||||
$store = new ContributionStore(':memory:');
|
||||
$provider = $this->makeProvider('github');
|
||||
|
||||
$tester = $this->makeTester([$provider], $store);
|
||||
$exitCode = $tester->execute(['--provider' => 'bogus']);
|
||||
|
||||
$this->assertSame(1, $exitCode);
|
||||
$this->assertStringContainsString('Unknown provider: bogus', $tester->getDisplay());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_restricts_to_the_named_provider(): void
|
||||
{
|
||||
$store = new ContributionStore(':memory:');
|
||||
$github = $this->makeProvider('github', fresh: ['2024-06-10' => 1]);
|
||||
$gitlab = $this->makeProvider('gitlab', fresh: ['2024-06-10' => 1]);
|
||||
|
||||
$tester = $this->makeTester([$github, $gitlab], $store);
|
||||
$tester->execute(['--provider' => 'github']);
|
||||
|
||||
$this->assertCount(1, $store->all('github'));
|
||||
$this->assertCount(0, $store->all('gitlab'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_continues_with_remaining_providers_when_one_throws(): void
|
||||
{
|
||||
$store = new ContributionStore(':memory:');
|
||||
$failing = $this->makeProvider('gitlab', throws: new \RuntimeException('boom'));
|
||||
$healthy = $this->makeProvider('github', fresh: ['2024-06-10' => 2]);
|
||||
|
||||
$tester = $this->makeTester([$failing, $healthy], $store);
|
||||
$exitCode = $tester->execute([]);
|
||||
|
||||
$this->assertSame(0, $exitCode);
|
||||
$this->assertCount(1, $store->all('github'));
|
||||
$this->assertCount(0, $store->all('gitlab'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_fails_when_every_provider_errors(): void
|
||||
{
|
||||
$store = new ContributionStore(':memory:');
|
||||
$failing = $this->makeProvider('github', throws: new \RuntimeException('boom'));
|
||||
|
||||
$tester = $this->makeTester([$failing], $store);
|
||||
$exitCode = $tester->execute([]);
|
||||
|
||||
$this->assertSame(1, $exitCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_splits_a_multi_year_range_into_365_day_chunks(): void
|
||||
{
|
||||
$store = new ContributionStore(':memory:');
|
||||
$calls = 0;
|
||||
|
||||
$provider = $this->createStub(ProviderInterface::class);
|
||||
$provider->method('getName')->willReturn('github');
|
||||
$provider->method('isConfigured')->willReturn(true);
|
||||
$provider->method('resolveFetch')->willReturnCallback(function () use (&$calls): array {
|
||||
$calls++;
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
$tester = $this->makeTester([$provider], $store);
|
||||
$tester->execute(['--from' => '2020-01-01', '--to' => '2023-01-01']);
|
||||
|
||||
$this->assertGreaterThan(1, $calls);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user