diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 067d162..db26766 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":{"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":7,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":7},"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0.001,"Weerscherm\\Tests\\StationMapperTest::testStationMetadata":0,"Weerscherm\\Tests\\StationMapperTest::testOutdoor":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorDefaultOrderAndUnreachableModule":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorConfigDeterminesOrderAndLabels":0,"Weerscherm\\Tests\\StationMapperTest::testRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testWithoutRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testUnreachableOutdoorModuleGivesNulls":0,"Weerscherm\\Tests\\StationMapperTest::testUnknownDeviceIdFallsBackToFirstStation":0,"Weerscherm\\Tests\\StationMapperTest::testEmptyDevicesGivesNullsWithoutCrashing":0,"Weerscherm\\Tests\\RainCalculatorTest::testNormalWeek":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingPreviousDayIsNullAndExcludedFromTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingTodayFallsBackToSumRain24":0,"Weerscherm\\Tests\\RainCalculatorTest::testMonthBoundaryKeepsPreviousDaysButNotInMonthTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testDaylightSavingTransitionMapsTimestampsToLocalDates":0,"Weerscherm\\Tests\\RainCalculatorTest::testEmptyBodyGivesNulls":0,"Weerscherm\\Tests\\RainCalculatorTest::testDateBeginIsMinOfFirstOfMonthAndThreeDaysAgo":0,"Weerscherm\\Tests\\CacheTest::testWriteThenReadRoundTrip":0.003,"Weerscherm\\Tests\\CacheTest::testMissingKeyReadsNull":0.005,"Weerscherm\\Tests\\CacheTest::testCorruptFileReadsNull":0,"Weerscherm\\Tests\\CacheTest::testFreshness":0.001,"Weerscherm\\Tests\\CacheTest::testKeyIsSanitised":0,"Weerscherm\\Tests\\CacheTest::testWriteCreatesDirectory":0.001,"Weerscherm\\Tests\\ForecastMapperTest::testSkipsTodayAndReturnsFiveDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testNullValuesStayNull":0,"Weerscherm\\Tests\\ForecastMapperTest::testShorterResponseGivesFewerDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testMissingTimeArrayGivesEmptyList":0,"Weerscherm\\Tests\\DashboardServiceTest::testHappyPathMatchesContract":0.002,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureIsRequestedForRainModuleFromMidnightOfDateBegin":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithoutCacheGivesNullsAndError":0,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithCacheServesStaleData":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testAuthFailureGivesNetatmoAuthError":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":0,"Weerscherm\\Tests\\DashboardServiceTest::testForecastFailureLeavesRestIntact":0,"Weerscherm\\Tests\\DashboardServiceTest::testSecondBuildWithinTtlUsesCache":0.001,"Weerscherm\\Tests\\TokenStoreTest::testWriteReadRoundTripAndMode":0,"Weerscherm\\Tests\\TokenStoreTest::testMissingOrInvalidFileReadsNull":0,"Weerscherm\\Tests\\TokenStoreTest::testWithLockReturnsValueAndReleasesAfterException":0,"Weerscherm\\Tests\\TokenStoreTest::testConcurrentProcessesAreSerialised":0.442}} \ No newline at end of file +{"version":2,"defects":{"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":7,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":7,"Weerscherm\\Tests\\NetatmoClientTest::testRejectedTokenAlreadyReplacedByOtherProcessIsReusedWithoutRefresh":8},"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0,"Weerscherm\\Tests\\StationMapperTest::testStationMetadata":0,"Weerscherm\\Tests\\StationMapperTest::testOutdoor":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorDefaultOrderAndUnreachableModule":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorConfigDeterminesOrderAndLabels":0,"Weerscherm\\Tests\\StationMapperTest::testRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testWithoutRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testUnreachableOutdoorModuleGivesNulls":0,"Weerscherm\\Tests\\StationMapperTest::testUnknownDeviceIdFallsBackToFirstStation":0,"Weerscherm\\Tests\\StationMapperTest::testEmptyDevicesGivesNullsWithoutCrashing":0,"Weerscherm\\Tests\\RainCalculatorTest::testNormalWeek":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingPreviousDayIsNullAndExcludedFromTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingTodayFallsBackToSumRain24":0,"Weerscherm\\Tests\\RainCalculatorTest::testMonthBoundaryKeepsPreviousDaysButNotInMonthTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testDaylightSavingTransitionMapsTimestampsToLocalDates":0,"Weerscherm\\Tests\\RainCalculatorTest::testEmptyBodyGivesNulls":0,"Weerscherm\\Tests\\RainCalculatorTest::testDateBeginIsMinOfFirstOfMonthAndThreeDaysAgo":0,"Weerscherm\\Tests\\CacheTest::testWriteThenReadRoundTrip":0.001,"Weerscherm\\Tests\\CacheTest::testMissingKeyReadsNull":0.002,"Weerscherm\\Tests\\CacheTest::testCorruptFileReadsNull":0,"Weerscherm\\Tests\\CacheTest::testFreshness":0,"Weerscherm\\Tests\\CacheTest::testKeyIsSanitised":0,"Weerscherm\\Tests\\CacheTest::testWriteCreatesDirectory":0,"Weerscherm\\Tests\\ForecastMapperTest::testSkipsTodayAndReturnsFiveDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testNullValuesStayNull":0,"Weerscherm\\Tests\\ForecastMapperTest::testShorterResponseGivesFewerDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testMissingTimeArrayGivesEmptyList":0,"Weerscherm\\Tests\\DashboardServiceTest::testHappyPathMatchesContract":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureIsRequestedForRainModuleFromMidnightOfDateBegin":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithoutCacheGivesNullsAndError":0,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithCacheServesStaleData":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testAuthFailureGivesNetatmoAuthError":0,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testForecastFailureLeavesRestIntact":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testSecondBuildWithinTtlUsesCache":0.001,"Weerscherm\\Tests\\TokenStoreTest::testWriteReadRoundTripAndMode":0,"Weerscherm\\Tests\\TokenStoreTest::testMissingOrInvalidFileReadsNull":0,"Weerscherm\\Tests\\TokenStoreTest::testWithLockReturnsValueAndReleasesAfterException":0,"Weerscherm\\Tests\\TokenStoreTest::testConcurrentProcessesAreSerialised":0.437,"Weerscherm\\Tests\\NetatmoClientTest::testValidTokenIsUsedWithoutRefresh":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testExpiringTokenIsRefreshedAndRotatedRefreshTokenStored":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testFreshlyInitialisedStoreWithoutAccessTokenRefreshesFirst":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testUnauthorizedResponseTriggersOneRefreshAndRetry":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testSecondUnauthorizedResponseIsAuthError":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testInvalidGrantIsAuthError":0,"Weerscherm\\Tests\\NetatmoClientTest::testMissingTokenFileIsAuthError":0,"Weerscherm\\Tests\\NetatmoClientTest::testServerErrorIsUpstreamError":0,"Weerscherm\\Tests\\NetatmoClientTest::testRefreshServerErrorIsUpstreamNotAuth":0,"Weerscherm\\Tests\\NetatmoClientTest::testConcurrentClientsShareOneRefresh":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testRejectedTokenAlreadyReplacedByOtherProcessIsReusedWithoutRefresh":0.001,"Weerscherm\\Tests\\NetatmoClientTest::testGetMeasureQuery":0,"Weerscherm\\Tests\\OpenMeteoClientTest::testRequestsSixDaysWithAllDailyVariables":0,"Weerscherm\\Tests\\OpenMeteoClientTest::testErrorStatusIsUpstreamException":0,"Weerscherm\\Tests\\OpenMeteoClientTest::testMissingDailyIsUpstreamException":0}} \ No newline at end of file diff --git a/bin/netatmo-init.php b/bin/netatmo-init.php new file mode 100755 index 0000000..c591e71 --- /dev/null +++ b/bin/netatmo-init.php @@ -0,0 +1,42 @@ +#!/usr/bin/env php + [access_token] + * + * De token komt van de token generator op dev.netatmo.com (scope read_station). + * Het access token is optioneel; zonder wordt er bij het eerste verzoek ververst. + * Let op: de refresh token roteert bij elke verversing. Gebruik daarna nooit meer de oude. + */ + +use Weerscherm\Config; +use Weerscherm\TokenStore; + +require dirname(__DIR__) . '/vendor/autoload.php'; + +$refresh = $argv[1] ?? ''; +$access = $argv[2] ?? ''; +if ($refresh === '' || $refresh === '-h' || $refresh === '--help') { + fwrite(STDERR, "Gebruik: php bin/netatmo-init.php [access_token]\n"); + exit(1); +} + +$configFile = dirname(__DIR__) . '/config.php'; +if (!is_file($configFile)) { + fwrite(STDERR, "config.php ontbreekt. Kopieer config.example.php naar config.php en vul de Netatmo-gegevens in.\n"); + exit(1); +} +$config = Config::load($configFile); +$file = rtrim((string) $config->get('storage_path'), '/') . '/netatmo-token.json'; + +$store = new TokenStore($file); +$store->withLock(static function () use ($store, $refresh, $access): void { + $store->write(['access_token' => $access, 'refresh_token' => $refresh, 'expires_at' => 0]); +}); + +printf("Token opgeslagen in %s (mode %s).\n", $file, substr(sprintf('%o', fileperms($file)), -4)); +echo "Bij het eerste verzoek wordt het access token ververst; de nieuwe refresh token wordt dan automatisch bewaard.\n"; diff --git a/src/LiveSource.php b/src/LiveSource.php index a086e7c..2a56a4f 100644 --- a/src/LiveSource.php +++ b/src/LiveSource.php @@ -4,26 +4,38 @@ declare(strict_types=1); namespace Weerscherm; -/** Echte bronnen: Netatmo (stationsdata, regenmetingen) en Open-Meteo (verwachting). Wordt in fase 3/4 ingevuld. */ +/** Echte bronnen: Netatmo (stationsdata, regenmetingen) en Open-Meteo (verwachting). */ final class LiveSource implements WeatherSource { + public function __construct(private readonly NetatmoClient $netatmo, private readonly OpenMeteoClient $openMeteo) + { + } + public static function fromConfig(Config $config): self { - throw new \RuntimeException('LiveSource is nog niet beschikbaar; gebruik data_source = fixture'); + $clientId = (string) $config->get('netatmo_client_id'); + $clientSecret = (string) $config->get('netatmo_client_secret'); + if ($clientId === '' || $clientSecret === '') { + throw new \RuntimeException('netatmo_client_id en netatmo_client_secret ontbreken in config.php'); + } + $http = new Http(); + $store = new TokenStore(rtrim((string) $config->get('storage_path'), '/') . '/netatmo-token.json'); + + return new self(new NetatmoClient($http, $store, $clientId, $clientSecret), new OpenMeteoClient($http)); } public function stationsData(): array { - throw new UpstreamException('niet geïmplementeerd'); + return $this->netatmo->getStationsData(); } public function measure(string $deviceId, string $moduleId, int $begin, int $end): array { - throw new UpstreamException('niet geïmplementeerd'); + return $this->netatmo->getMeasure($deviceId, $moduleId, $begin, $end); } public function forecast(float $lat, float $lon, string $timezone): array { - throw new UpstreamException('niet geïmplementeerd'); + return $this->openMeteo->dailyForecast($lat, $lon, $timezone); } } diff --git a/src/NetatmoClient.php b/src/NetatmoClient.php new file mode 100644 index 0000000..d66bb32 --- /dev/null +++ b/src/NetatmoClient.php @@ -0,0 +1,135 @@ +now = $now ?? static fn(): int => time(); + } + + /** @return array de "body" van getstationsdata */ + public function getStationsData(): array + { + $json = $this->apiGet('getstationsdata', []); + if (!is_array($json['body'] ?? null)) { + throw new UpstreamException('getstationsdata: geen body in antwoord'); + } + return $json['body']; + } + + /** @return array timestamp => [mm] */ + public function getMeasure(string $deviceId, string $moduleId, int $begin, int $end): array + { + $json = $this->apiGet('getmeasure', [ + 'device_id' => $deviceId, + 'module_id' => $moduleId, + 'scale' => '1day', + 'type' => 'sum_rain', + 'date_begin' => $begin, + 'date_end' => $end, + 'optimize' => 'false', + 'real_time' => 'false', + ]); + if (!is_array($json['body'] ?? null)) { + throw new UpstreamException('getmeasure: geen body in antwoord'); + } + return $json['body']; + } + + /** @param array $query */ + private function apiGet(string $path, array $query): array + { + $token = $this->accessToken(null); + $res = $this->http->get(self::API_URL . $path, $query, ["Authorization: Bearer $token"]); + + if ($res->status === 401 || $res->status === 403) { + $token = $this->accessToken($token); + $res = $this->http->get(self::API_URL . $path, $query, ["Authorization: Bearer $token"]); + if ($res->status === 401 || $res->status === 403) { + throw new NetatmoAuthException("Netatmo weigert het access token ($path: HTTP {$res->status})"); + } + } + if ($res->status !== 200) { + throw new UpstreamException("Netatmo $path: HTTP {$res->status}"); + } + return $res->json(); + } + + /** + * Geeft een geldig access token. Met $rejected wordt het token doorgegeven dat zojuist geweigerd is: + * is er intussen (door een ander proces) al een ander token opgeslagen, dan wordt dát gebruikt, + * anders wordt er ververst. + */ + private function accessToken(?string $rejected): string + { + return $this->store->withLock(function () use ($rejected): string { + $token = $this->store->read(); + if ($token === null) { + throw new NetatmoAuthException('Geen Netatmo-token opgeslagen; voer bin/netatmo-init.php uit'); + } + $usable = $token['access_token'] !== '' && $token['expires_at'] - ($this->now)() > self::REFRESH_MARGIN; + if ($usable && $token['access_token'] !== $rejected) { + return $token['access_token']; + } + return $this->refresh($token['refresh_token']); + }); + } + + private function refresh(string $refreshToken): string + { + $res = $this->http->postForm(self::TOKEN_URL, [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + ]); + + if ($res->status !== 200) { + $error = null; + try { + $error = $res->json()['error'] ?? null; + } catch (UpstreamException) { + } + if (in_array($error, ['invalid_grant', 'invalid_client', 'invalid_request'], true)) { + throw new NetatmoAuthException("Netatmo token refresh geweigerd: $error"); + } + throw new UpstreamException("Netatmo token refresh: HTTP {$res->status}"); + } + + $json = $res->json(); + $access = $json['access_token'] ?? null; + $refresh = $json['refresh_token'] ?? $refreshToken; + $expiresIn = $json['expires_in'] ?? 10800; + if (!is_string($access) || $access === '' || !is_string($refresh)) { + throw new UpstreamException('Netatmo token refresh: onvolledig antwoord'); + } + + $this->store->write([ + 'access_token' => $access, + 'refresh_token' => $refresh, + 'expires_at' => ($this->now)() + (int) $expiresIn, + ]); + return $access; + } +} diff --git a/src/OpenMeteoClient.php b/src/OpenMeteoClient.php new file mode 100644 index 0000000..3452b32 --- /dev/null +++ b/src/OpenMeteoClient.php @@ -0,0 +1,35 @@ + het "daily"-blok */ + public function dailyForecast(float $lat, float $lon, string $timezone): array + { + $res = $this->http->get(self::URL, [ + 'latitude' => $lat, + 'longitude' => $lon, + 'daily' => 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max', + 'timezone' => $timezone, + 'forecast_days' => 6, + ]); + if ($res->status !== 200) { + throw new UpstreamException("Open-Meteo: HTTP {$res->status}"); + } + $json = $res->json(); + if (!is_array($json['daily'] ?? null)) { + throw new UpstreamException('Open-Meteo: geen daily-blok in antwoord'); + } + return $json['daily']; + } +} diff --git a/tests/FakeHttp.php b/tests/FakeHttp.php new file mode 100644 index 0000000..caa34a4 --- /dev/null +++ b/tests/FakeHttp.php @@ -0,0 +1,49 @@ + */ + private array $queue = []; + + /** @var list */ + public array $requests = []; + + public function queue(int $status, array|string $body): self + { + $this->queue[] = new HttpResponse($status, is_string($body) ? $body : json_encode($body, JSON_THROW_ON_ERROR)); + return $this; + } + + public function get(string $url, array $query = [], array $headers = []): HttpResponse + { + return $this->take('GET', $url, $query, $headers); + } + + public function postForm(string $url, array $fields): HttpResponse + { + return $this->take('POST', $url, $fields, []); + } + + /** @return list */ + public function requestsOf(string $method): array + { + return array_values(array_filter($this->requests, fn(array $r) => $r['method'] === $method)); + } + + private function take(string $method, string $url, array $params, array $headers): HttpResponse + { + $this->requests[] = ['method' => $method, 'url' => $url, 'params' => $params, 'headers' => $headers]; + if ($this->queue === []) { + throw new \LogicException("Geen response gepland voor $method $url"); + } + return array_shift($this->queue); + } +} diff --git a/tests/FakeHttpDecorator.php b/tests/FakeHttpDecorator.php new file mode 100644 index 0000000..738abe3 --- /dev/null +++ b/tests/FakeHttpDecorator.php @@ -0,0 +1,27 @@ +inner->get($url, $query, $headers); + } + + public function postForm(string $url, array $fields): HttpResponse + { + return $this->inner->postForm($url, $fields); + } +} diff --git a/tests/NetatmoClientTest.php b/tests/NetatmoClientTest.php new file mode 100644 index 0000000..52985f4 --- /dev/null +++ b/tests/NetatmoClientTest.php @@ -0,0 +1,201 @@ +dir = sys_get_temp_dir() . '/weerscherm-netatmo-' . uniqid(); + $this->store = new TokenStore($this->dir . '/netatmo-token.json'); + $this->http = new FakeHttp(); + } + + protected function tearDown(): void + { + array_map('unlink', glob($this->dir . '/*') ?: []); + @rmdir($this->dir); + } + + private function client(): NetatmoClient + { + return new NetatmoClient($this->http, $this->store, 'cid', 'csecret', static fn(): int => self::NOW); + } + + private function stationsBody(): array + { + return json_decode((string) file_get_contents(__DIR__ . '/fixtures/getstationsdata.json'), true); + } + + public function testValidTokenIsUsedWithoutRefresh(): void + { + $this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]); + $this->http->queue(200, $this->stationsBody()); + + $body = $this->client()->getStationsData(); + + self::assertSame('70:ee:50:00:00:01', $body['devices'][0]['_id']); + self::assertCount(1, $this->http->requests); + self::assertSame('https://api.netatmo.com/api/getstationsdata', $this->http->requests[0]['url']); + self::assertContains('Authorization: Bearer A1', $this->http->requests[0]['headers']); + } + + public function testExpiringTokenIsRefreshedAndRotatedRefreshTokenStored(): void + { + $this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 200]); + $this->http + ->queue(200, ['access_token' => 'A2', 'refresh_token' => 'R2', 'expires_in' => 10800]) + ->queue(200, $this->stationsBody()); + + $this->client()->getStationsData(); + + [$post, $get] = $this->http->requests; + self::assertSame('POST', $post['method']); + self::assertSame('https://api.netatmo.com/oauth2/token', $post['url']); + self::assertSame(['grant_type' => 'refresh_token', 'refresh_token' => 'R1', 'client_id' => 'cid', 'client_secret' => 'csecret'], $post['params']); + self::assertContains('Authorization: Bearer A2', $get['headers']); + self::assertSame(['access_token' => 'A2', 'refresh_token' => 'R2', 'expires_at' => self::NOW + 10800], $this->store->read()); + } + + public function testFreshlyInitialisedStoreWithoutAccessTokenRefreshesFirst(): void + { + $this->store->write(['access_token' => '', 'refresh_token' => 'R1', 'expires_at' => 0]); + $this->http + ->queue(200, ['access_token' => 'A2', 'refresh_token' => 'R2', 'expires_in' => 10800]) + ->queue(200, $this->stationsBody()); + + $this->client()->getStationsData(); + + self::assertCount(1, $this->http->requestsOf('POST')); + } + + public function testUnauthorizedResponseTriggersOneRefreshAndRetry(): void + { + $this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]); + $this->http + ->queue(403, ['error' => ['code' => 3, 'message' => 'Access token expired']]) + ->queue(200, ['access_token' => 'A2', 'refresh_token' => 'R2', 'expires_in' => 10800]) + ->queue(200, $this->stationsBody()); + + $this->client()->getStationsData(); + + self::assertSame(['GET', 'POST', 'GET'], array_column($this->http->requests, 'method')); + self::assertContains('Authorization: Bearer A2', $this->http->requests[2]['headers']); + } + + public function testSecondUnauthorizedResponseIsAuthError(): void + { + $this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]); + $this->http + ->queue(401, '') + ->queue(200, ['access_token' => 'A2', 'refresh_token' => 'R2', 'expires_in' => 10800]) + ->queue(401, ''); + + $this->expectException(NetatmoAuthException::class); + $this->client()->getStationsData(); + } + + public function testInvalidGrantIsAuthError(): void + { + $this->store->write(['access_token' => '', 'refresh_token' => 'R-oud', 'expires_at' => 0]); + $this->http->queue(400, ['error' => 'invalid_grant']); + + $this->expectException(NetatmoAuthException::class); + $this->client()->getStationsData(); + } + + public function testMissingTokenFileIsAuthError(): void + { + $this->expectException(NetatmoAuthException::class); + $this->client()->getStationsData(); + } + + public function testServerErrorIsUpstreamError(): void + { + $this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]); + $this->http->queue(500, 'kapot'); + + $this->expectException(UpstreamException::class); + $this->client()->getStationsData(); + } + + public function testRefreshServerErrorIsUpstreamNotAuth(): void + { + $this->store->write(['access_token' => '', 'refresh_token' => 'R1', 'expires_at' => 0]); + $this->http->queue(503, ''); + + $this->expectException(UpstreamException::class); + $this->expectExceptionMessage('HTTP 503'); + $this->client()->getStationsData(); + } + + public function testConcurrentClientsShareOneRefresh(): void + { + $this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 100]); + $this->http + ->queue(200, ['access_token' => 'A2', 'refresh_token' => 'R2', 'expires_in' => 10800]) + ->queue(200, $this->stationsBody()) + ->queue(200, $this->stationsBody()); + + $first = $this->client(); + $second = $this->client(); + $first->getStationsData(); + $second->getStationsData(); + + self::assertCount(1, $this->http->requestsOf('POST'), 'de tweede client leest het verse token en ververst niet'); + self::assertSame('R2', $this->store->read()['refresh_token']); + } + + public function testRejectedTokenAlreadyReplacedByOtherProcessIsReusedWithoutRefresh(): void + { + $this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]); + $this->http->queue(403, '')->queue(200, $this->stationsBody()); + + // Nabootsen dat een ander proces al ververst heeft zodra ons token geweigerd wordt. + $http = new class($this->http, $this->store) extends FakeHttpDecorator { + public function get(string $url, array $query = [], array $headers = []): \Weerscherm\HttpResponse + { + $res = parent::get($url, $query, $headers); + if ($res->status === 403) { + $this->store->write(['access_token' => 'A9', 'refresh_token' => 'R9', 'expires_at' => 1_800_000_000 + 3600]); + } + return $res; + } + }; + $client = new NetatmoClient($http, $this->store, 'cid', 'csecret', static fn(): int => self::NOW); + + $client->getStationsData(); + + self::assertSame([], $this->http->requestsOf('POST'), 'geen eigen refresh nodig'); + self::assertContains('Authorization: Bearer A9', $this->http->requests[1]['headers']); + } + + public function testGetMeasureQuery(): void + { + $this->store->write(['access_token' => 'A1', 'refresh_token' => 'R1', 'expires_at' => self::NOW + 3600]); + $this->http->queue(200, json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure.json'), true)); + + $body = $this->client()->getMeasure('dev', 'mod', 1000, 2000); + + self::assertCount(11, $body); + self::assertSame('https://api.netatmo.com/api/getmeasure', $this->http->requests[0]['url']); + self::assertSame([ + 'device_id' => 'dev', 'module_id' => 'mod', 'scale' => '1day', 'type' => 'sum_rain', + 'date_begin' => 1000, 'date_end' => 2000, 'optimize' => 'false', 'real_time' => 'false', + ], $this->http->requests[0]['params']); + } +} diff --git a/tests/OpenMeteoClientTest.php b/tests/OpenMeteoClientTest.php new file mode 100644 index 0000000..7b9c6d0 --- /dev/null +++ b/tests/OpenMeteoClientTest.php @@ -0,0 +1,44 @@ +queue(200, json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)); + + $daily = (new OpenMeteoClient($http))->dailyForecast(51.82, 6.55, 'Europe/Berlin'); + + self::assertCount(6, $daily['time']); + self::assertSame('https://api.open-meteo.com/v1/forecast', $http->requests[0]['url']); + self::assertSame([ + 'latitude' => 51.82, + 'longitude' => 6.55, + 'daily' => 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max', + 'timezone' => 'Europe/Berlin', + 'forecast_days' => 6, + ], $http->requests[0]['params']); + } + + public function testErrorStatusIsUpstreamException(): void + { + $http = (new FakeHttp())->queue(429, ['reason' => 'rate limit']); + $this->expectException(UpstreamException::class); + (new OpenMeteoClient($http))->dailyForecast(1, 2, 'UTC'); + } + + public function testMissingDailyIsUpstreamException(): void + { + $http = (new FakeHttp())->queue(200, ['hourly' => []]); + $this->expectException(UpstreamException::class); + (new OpenMeteoClient($http))->dailyForecast(1, 2, 'UTC'); + } +}