Files
weerscherm/tests/DashboardServiceTest.php
theoandClaude Sonnet 5 48d68b86fa fix: treat a stale/legacy-shaped forecast cache as unavailable, not silently empty
Na de deploy van het uuroverzicht (commit 8e90eb1) bleef de nog geldige
cache op de server een tijdlang in het oude platte 'daily'-blok staan
i.p.v. het nieuwe {daily, hourly}. DashboardService::forecast() las
daaruit stil een lege verwachting zonder errors-melding. Nu wordt een
cache- of bronresultaat zonder geldig 'daily'-blok behandeld als een
storing (forecast_unavailable), zichtbaar in plaats van stil.

Handmatig verholpen op productie door de verouderde cachefile te
wissen; deze fix voorkomt herhaling bij een volgende formaatwijziging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 10:20:00 +02:00

213 lines
9.0 KiB
PHP

<?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, 'pressure' => 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', 'pressure', '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', 'humidity' => 71.0], $p['outdoor']);
self::assertCount(3, $p['indoor']);
self::assertSame(612, $p['indoor'][0]['co2']);
self::assertSame(48.0, $p['indoor'][0]['humidity']);
self::assertSame(1014.2, $p['pressure']['current']);
self::assertSame('up', $p['pressure']['trend']);
self::assertCount(49, $p['pressure']['history']);
self::assertSame('2026-09-10T14:30:00+02:00', $p['pressure']['history'][0]['at']);
self::assertFalse($p['pressure']['stale']);
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(6, $p['forecast']);
self::assertSame('2026-09-11', $p['forecast'][0]['date']);
self::assertCount(12, $p['forecast'][0]['hourly']);
self::assertSame(['at', 'temp', 'precip_mm', 'wind_kmh', 'wind_dir'], array_keys($p['forecast'][0]['hourly'][0]));
self::assertSame('2026-09-11T00:00:00+02:00', $p['forecast'][0]['hourly'][0]['at']);
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 testPressureHistoryIsRequestedForLast24Hours(): void
{
$this->service()->build();
[$deviceId, $begin, $end] = $this->source->lastArgs['pressure'];
$now = (new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp();
self::assertSame('70:ee:50:00:00:01', $deviceId);
self::assertSame($now - 86400, $begin);
self::assertSame($now, $end);
}
public function testPressureFailureKeepsCurrentValueAndMarksStale(): void
{
$this->source->responses['pressure'] = new UpstreamException('500');
$p = $this->service()->build();
self::assertSame(1014.2, $p['pressure']['current']);
self::assertSame([], $p['pressure']['history']);
self::assertTrue($p['pressure']['stale']);
self::assertSame(['netatmo_unavailable'], $p['errors']);
}
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(0, $this->source->calls['pressure'], 'zonder device-id geen luchtdruk-call');
self::assertSame(['current' => null, 'trend' => null, 'history' => [], 'stale' => false], $p['pressure']);
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 testLegacyCachedForecastShapeIsTreatedAsUnavailableNotSilentlyEmpty(): void
{
// Vóór 2026-09-13 cachete DashboardService het platte "daily"-blok direct onder de sleutel
// "forecast". Na de uitbreiding met uurdata verwacht de code {daily, hourly}. Een nog niet
// verlopen cache in het oude formaat mag niet stil een lege verwachting zonder foutmelding geven.
$legacyDailyOnly = json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['daily'];
(new Cache($this->dir))->write('forecast', $legacyDailyOnly, (new \DateTimeImmutable('2026-09-11 12:30:00 UTC'))->getTimestamp());
$p = $this->service()->build();
self::assertSame([], $p['forecast']);
self::assertTrue($p['forecast_stale']);
self::assertSame(['forecast_unavailable'], $p['errors']);
self::assertSame(0, $this->source->calls['forecast'], 'de cache was nog vers, dus geen live call verwacht');
}
public function testSecondBuildWithinTtlUsesCache(): void
{
$this->service()->build();
$this->service('2026-09-11 12:36:05 UTC')->build();
self::assertSame(['stations' => 1, 'measure' => 1, 'pressure' => 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');
}
}