feat: NetatmoClient with token rotation, OpenMeteoClient, LiveSource, netatmo-init
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -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