feat: DashboardService with per-source cache and stale fallback, FixtureSource

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 14:39:03 +02:00
co-authored by Claude Fable 5.1
parent 89e1e544c5
commit 216b6616fc
6 changed files with 471 additions and 1 deletions
+161
View File
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use PHPUnit\Framework\TestCase;
use Weerscherm\Cache;
use Weerscherm\DashboardService;
use Weerscherm\NetatmoAuthException;
use Weerscherm\UpstreamException;
final class DashboardServiceTest extends TestCase
{
private string $dir;
private FakeSource $source;
protected function setUp(): void
{
$this->dir = sys_get_temp_dir() . '/weerscherm-dash-' . uniqid();
$this->source = FakeSource::fromFixtures();
}
protected function tearDown(): void
{
array_map('unlink', glob($this->dir . '/*') ?: []);
@rmdir($this->dir);
}
private function service(?string $now = '2026-09-11 12:32:05 UTC', ?float $lat = null, ?float $lon = null): DashboardService
{
return new DashboardService(
$this->source,
new Cache($this->dir),
['stations' => 300, 'measure' => 900, 'forecast' => 3600],
'Europe/Amsterdam',
$lat,
$lon,
[],
null,
$now === null ? null : new \DateTimeImmutable($now),
);
}
public function testHappyPathMatchesContract(): void
{
$p = $this->service()->build();
self::assertSame(['generated_at', 'timezone', 'station', 'outdoor', 'indoor', 'rain', 'forecast', 'forecast_stale', 'errors'], array_keys($p));
self::assertSame('2026-09-11T14:32:05+02:00', $p['generated_at']);
self::assertSame('Europe/Berlin', $p['timezone']);
self::assertSame(['measured_at' => '2026-09-11T14:30:12+02:00', 'stale' => false], $p['station']);
self::assertSame(['temp' => 16.4, 'trend' => 'down', 'min' => 9.2, 'min_at' => '2026-09-11T06:10:00+02:00', 'max' => 19.8, 'max_at' => '2026-09-11T13:05:00+02:00'], $p['outdoor']);
self::assertCount(3, $p['indoor']);
self::assertSame(2.4, $p['rain']['today']);
self::assertSame(['2026-09-08', '2026-09-09', '2026-09-10'], array_column($p['rain']['previous_days'], 'date'));
self::assertEqualsWithDelta(38.5, $p['rain']['month_total'], 0.0001);
self::assertFalse($p['rain']['stale']);
self::assertCount(5, $p['forecast']);
self::assertSame('2026-09-12', $p['forecast'][0]['date']);
self::assertFalse($p['forecast_stale']);
self::assertSame([], $p['errors']);
}
public function testMeasureIsRequestedForRainModuleFromMidnightOfDateBegin(): void
{
$this->service()->build();
[$deviceId, $moduleId, $begin, $end] = $this->source->lastArgs['measure'];
self::assertSame('70:ee:50:00:00:01', $deviceId);
self::assertSame('05:00:00:00:00:04', $moduleId);
self::assertSame((new \DateTimeImmutable('2026-09-01 00:00', new \DateTimeZone('Europe/Berlin')))->getTimestamp(), $begin);
self::assertSame((new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp(), $end);
}
public function testForecastUsesStationLocationUnlessConfigured(): void
{
$this->service()->build();
self::assertSame([51.82, 6.55, 'Europe/Berlin'], $this->source->lastArgs['forecast']);
$this->source = FakeSource::fromFixtures();
array_map('unlink', glob($this->dir . '/*') ?: []);
$this->service(lat: 52.1, lon: 5.1)->build();
self::assertSame([52.1, 5.1, 'Europe/Berlin'], $this->source->lastArgs['forecast']);
}
public function testStationsFailureWithoutCacheGivesNullsAndError(): void
{
$this->source->responses['stations'] = new UpstreamException('timeout');
$p = $this->service()->build();
self::assertNull($p['outdoor']['temp']);
self::assertNull($p['station']['measured_at']);
self::assertTrue($p['station']['stale']);
self::assertSame([], $p['indoor']);
self::assertNull($p['rain']['today']);
self::assertSame(0, $this->source->calls['measure'], 'zonder regenmeter-id geen getmeasure-call');
self::assertSame('Europe/Amsterdam', $p['timezone'], 'fallback-tijdzone uit config');
self::assertSame(['netatmo_unavailable', 'forecast_unavailable'], $p['errors']);
}
public function testStationsFailureWithCacheServesStaleData(): void
{
$this->service()->build();
$this->source->responses['stations'] = new UpstreamException('timeout');
$p = $this->service('2026-09-11 13:00:00 UTC')->build();
self::assertSame(16.4, $p['outdoor']['temp']);
self::assertTrue($p['station']['stale']);
self::assertSame(['netatmo_unavailable'], $p['errors']);
}
public function testAuthFailureGivesNetatmoAuthError(): void
{
$this->source->responses['stations'] = new NetatmoAuthException('invalid_grant');
$p = $this->service()->build();
self::assertContains('netatmo_auth', $p['errors']);
self::assertNotContains('netatmo_unavailable', $p['errors']);
}
public function testMeasureFailureFallsBackToSumRain24(): void
{
$this->source->responses['measure'] = new UpstreamException('500');
$p = $this->service()->build();
self::assertSame(2.4, $p['rain']['today']);
self::assertSame([null, null, null], array_column($p['rain']['previous_days'], 'mm'));
self::assertNull($p['rain']['month_total']);
self::assertTrue($p['rain']['stale']);
self::assertSame(['netatmo_unavailable'], $p['errors']);
}
public function testForecastFailureLeavesRestIntact(): void
{
$this->source->responses['forecast'] = new UpstreamException('down');
$p = $this->service()->build();
self::assertSame([], $p['forecast']);
self::assertTrue($p['forecast_stale']);
self::assertSame(['forecast_unavailable'], $p['errors']);
self::assertSame(16.4, $p['outdoor']['temp']);
}
public function testSecondBuildWithinTtlUsesCache(): void
{
$this->service()->build();
$this->service('2026-09-11 12:36:05 UTC')->build();
self::assertSame(['stations' => 1, 'measure' => 1, 'forecast' => 1], $this->source->calls);
$this->service('2026-09-11 12:38:05 UTC')->build();
self::assertSame(2, $this->source->calls['stations'], 'stations na 5 min opnieuw');
self::assertSame(1, $this->source->calls['measure'], 'measure nog binnen 15 min');
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use Weerscherm\WeatherSource;
/** Testdubbel: geeft per methode een array terug of gooit de ingestelde exception, en telt de aanroepen. */
final class FakeSource implements WeatherSource
{
/** @var array<string, array|\Throwable> */
public array $responses = [];
/** @var array<string, int> */
public array $calls = ['stations' => 0, 'measure' => 0, 'forecast' => 0];
/** @var array<string, array> */
public array $lastArgs = [];
public static function fromFixtures(): self
{
$s = new self();
$s->responses = [
'stations' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getstationsdata.json'), true)['body'],
'measure' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure.json'), true)['body'],
'forecast' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['daily'],
];
return $s;
}
public function stationsData(): array
{
return $this->answer('stations', []);
}
public function measure(string $deviceId, string $moduleId, int $begin, int $end): array
{
return $this->answer('measure', func_get_args());
}
public function forecast(float $lat, float $lon, string $timezone): array
{
return $this->answer('forecast', func_get_args());
}
private function answer(string $key, array $args): array
{
$this->calls[$key]++;
$this->lastArgs[$key] = $args;
$r = $this->responses[$key] ?? new \RuntimeException("geen response voor $key");
if ($r instanceof \Throwable) {
throw $r;
}
return $r;
}
}