Files
weerscherm/tests/NetatmoClientTest.php

216 lines
8.8 KiB
PHP

<?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']);
}
public function testGetPressureHistoryQuery(): 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-pressure.json'), true));
$body = $this->client()->getPressureHistory('dev', 1000, 2000);
self::assertCount(49, $body);
self::assertSame([
'device_id' => 'dev', 'scale' => '30min', 'type' => 'Pressure',
'date_begin' => 1000, 'date_end' => 2000, 'optimize' => 'false', 'real_time' => 'false',
], $this->http->requests[0]['params']);
}
}