feat: NetatmoClient with token rotation, OpenMeteoClient, LiveSource, netatmo-init
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -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}}
|
||||
Executable
+42
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user