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>
213 lines
9.0 KiB
PHP
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');
|
|
}
|
|
}
|