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, 'temperature' => 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', 'outdoor_history', '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 testOutdoorHistoryCoversTodayFromLocalMidnight(): void { $p = $this->service()->build(); [$deviceId, $moduleId, $begin, $end] = $this->source->lastArgs['temperature']; self::assertSame('70:ee:50:00:00:01', $deviceId); self::assertSame('02:00:00:00:00:01', $moduleId, 'de buitenmodule, niet het basisstation'); self::assertSame((new \DateTimeImmutable('2026-09-11 00:00', new \DateTimeZone('Europe/Berlin')))->getTimestamp(), $begin); self::assertSame((new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp(), $end); self::assertNotEmpty($p['outdoor_history']['points']); self::assertSame(['at', 'temp'], array_keys($p['outdoor_history']['points'][0])); self::assertFalse($p['outdoor_history']['stale']); } public function testOutdoorHistoryFailureLeavesTheRestIntact(): void { $this->source->responses['temperature'] = new UpstreamException('500'); $p = $this->service()->build(); self::assertSame(['points' => [], 'stale' => true], $p['outdoor_history']); self::assertSame(16.4, $p['outdoor']['temp'], 'de actuele waarden komen uit stationsdata en blijven staan'); self::assertSame(['netatmo_unavailable'], $p['errors']); } 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(0, $this->source->calls['temperature'], 'zonder buitenmodule geen temperatuur-call'); self::assertSame(['points' => [], 'stale' => false], $p['outdoor_history']); 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, 'temperature' => 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'); } }