feat: NetatmoClient with token rotation, OpenMeteoClient, LiveSource, netatmo-init

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 14:45:54 +02:00
co-authored by Claude Fable 5.1
parent dababbd1f2
commit 4250fa5abf
9 changed files with 551 additions and 6 deletions
+1 -1
View File
@@ -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}}
{"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}}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
* Eenmalig: sla de Netatmo refresh token op.
*
* php bin/netatmo-init.php <refresh_token> [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 <refresh_token> [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";
+17 -5
View File
@@ -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);
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/**
* Netatmo Weather API met OAuth2 refresh-token-flow.
* De refresh token roteert: elk refresh-antwoord bevat een nieuwe, die direct wordt opgeslagen.
* Verversen gebeurt onder een lock (TokenStore::withLock) zodat parallelle requests niet dubbel verversen.
*/
final class NetatmoClient
{
private const TOKEN_URL = 'https://api.netatmo.com/oauth2/token';
private const API_URL = 'https://api.netatmo.com/api/';
private const REFRESH_MARGIN = 300; // seconden vóór verloop waarop proactief ververst wordt
/** @var \Closure(): int */
private readonly \Closure $now;
public function __construct(
private readonly HttpClient $http,
private readonly TokenStore $store,
private readonly string $clientId,
private readonly string $clientSecret,
?\Closure $now = null,
) {
$this->now = $now ?? static fn(): int => time();
}
/** @return array<string, mixed> 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<string|int, mixed> 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<string, string|int|float> $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;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/** Open-Meteo dagverwachting. Gratis voor niet-commercieel gebruik, geen API-key. */
final class OpenMeteoClient
{
private const URL = 'https://api.open-meteo.com/v1/forecast';
public function __construct(private readonly HttpClient $http)
{
}
/** @return array<string, mixed> 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'];
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use Weerscherm\HttpClient;
use Weerscherm\HttpResponse;
/** Testdubbel: geeft geplande responses in volgorde terug en registreert elk request. */
final class FakeHttp implements HttpClient
{
/** @var list<HttpResponse> */
private array $queue = [];
/** @var list<array{method: string, url: string, params: array, headers: array}> */
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<array> */
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);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use Weerscherm\HttpClient;
use Weerscherm\HttpResponse;
use Weerscherm\TokenStore;
/** Basis om in een test gedrag rond een FakeHttp te hangen (bijvoorbeeld een ander proces nabootsen). */
abstract class FakeHttpDecorator implements HttpClient
{
public function __construct(protected readonly FakeHttp $inner, protected readonly TokenStore $store)
{
}
public function get(string $url, array $query = [], array $headers = []): HttpResponse
{
return $this->inner->get($url, $query, $headers);
}
public function postForm(string $url, array $fields): HttpResponse
{
return $this->inner->postForm($url, $fields);
}
}
+201
View File
@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use PHPUnit\Framework\TestCase;
use Weerscherm\NetatmoAuthException;
use Weerscherm\NetatmoClient;
use Weerscherm\TokenStore;
use Weerscherm\UpstreamException;
final class NetatmoClientTest extends TestCase
{
private const NOW = 1_800_000_000;
private string $dir;
private TokenStore $store;
private FakeHttp $http;
protected function setUp(): void
{
$this->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']);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use PHPUnit\Framework\TestCase;
use Weerscherm\OpenMeteoClient;
use Weerscherm\UpstreamException;
final class OpenMeteoClientTest extends TestCase
{
public function testRequestsSixDaysWithAllDailyVariables(): void
{
$http = new FakeHttp();
$http->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');
}
}