diff --git a/.phpunit.result.cache b/.phpunit.result.cache index e5a434c..067d162 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,"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.002,"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,"Weerscherm\\Tests\\DashboardServiceTest::testForecastFailureLeavesRestIntact":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testSecondBuildWithinTtlUsesCache":0.001}} \ No newline at end of file +{"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 diff --git a/src/Http.php b/src/Http.php new file mode 100644 index 0000000..1be3561 --- /dev/null +++ b/src/Http.php @@ -0,0 +1,56 @@ +request($url, $headers, null); + } + + public function postForm(string $url, array $fields): HttpResponse + { + return $this->request($url, ['Content-Type: application/x-www-form-urlencoded'], http_build_query($fields)); + } + + /** @param list $headers */ + private function request(string $url, array $headers, ?string $postBody): HttpResponse + { + $ch = curl_init($url); + if ($ch === false) { + throw new UpstreamException('curl_init mislukt'); + } + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_CONNECTTIMEOUT => $this->connectTimeout, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_HTTPHEADER => array_merge(['Accept: application/json', 'User-Agent: weerscherm/1.0'], $headers), + CURLOPT_SSL_VERIFYPEER => true, + ]); + if ($postBody !== null) { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $postBody); + } + $body = curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $error = curl_error($ch); + curl_close($ch); + + if (!is_string($body)) { + throw new UpstreamException("Verzoek mislukt: $error"); + } + return new HttpResponse($status, $body); + } +} diff --git a/src/HttpClient.php b/src/HttpClient.php new file mode 100644 index 0000000..d0ac700 --- /dev/null +++ b/src/HttpClient.php @@ -0,0 +1,17 @@ + $query + * @param list $headers + */ + public function get(string $url, array $query = [], array $headers = []): HttpResponse; + + /** @param array $fields */ + public function postForm(string $url, array $fields): HttpResponse; +} diff --git a/src/HttpResponse.php b/src/HttpResponse.php new file mode 100644 index 0000000..d47ae9a --- /dev/null +++ b/src/HttpResponse.php @@ -0,0 +1,26 @@ + */ + public function json(): array + { + try { + $decoded = json_decode($this->body, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new UpstreamException('Ongeldige JSON in antwoord: ' . $e->getMessage(), 0, $e); + } + if (!is_array($decoded)) { + throw new UpstreamException('Antwoord is geen JSON-object'); + } + return $decoded; + } +} diff --git a/src/TokenStore.php b/src/TokenStore.php new file mode 100644 index 0000000..6e200e6 --- /dev/null +++ b/src/TokenStore.php @@ -0,0 +1,92 @@ +file; + } + + /** @return array{access_token: string, refresh_token: string, expires_at: int}|null */ + public function read(): ?array + { + if (!is_file($this->file)) { + return null; + } + $raw = json_decode((string) file_get_contents($this->file), true); + if (!is_array($raw) || !is_string($raw['refresh_token'] ?? null) || $raw['refresh_token'] === '') { + return null; + } + return [ + 'access_token' => is_string($raw['access_token'] ?? null) ? $raw['access_token'] : '', + 'refresh_token' => $raw['refresh_token'], + 'expires_at' => is_int($raw['expires_at'] ?? null) ? $raw['expires_at'] : 0, + ]; + } + + /** @param array{access_token: string, refresh_token: string, expires_at: int} $token */ + public function write(array $token): void + { + $dir = dirname($this->file); + if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) { + throw new \RuntimeException("Map kan niet worden aangemaakt: $dir"); + } + $tmp = $this->file . '.tmp.' . uniqid('', true); + $json = json_encode([ + 'access_token' => $token['access_token'], + 'refresh_token' => $token['refresh_token'], + 'expires_at' => $token['expires_at'], + ], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); + if (file_put_contents($tmp, $json) === false) { + throw new \RuntimeException("Tokenbestand kan niet worden geschreven: $tmp"); + } + chmod($tmp, 0600); + if (!rename($tmp, $this->file)) { + @unlink($tmp); + throw new \RuntimeException("Tokenbestand kan niet worden vervangen: {$this->file}"); + } + } + + /** + * Voert $fn uit onder een exclusieve lock. Ook bij een exception wordt de lock losgelaten. + * + * @template T + * @param callable(): T $fn + * @return T + */ + public function withLock(callable $fn): mixed + { + $dir = dirname($this->file); + if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) { + throw new \RuntimeException("Map kan niet worden aangemaakt: $dir"); + } + $handle = fopen($this->file . '.lock', 'c'); + if ($handle === false) { + throw new \RuntimeException("Lockbestand kan niet worden geopend: {$this->file}.lock"); + } + try { + if (!flock($handle, LOCK_EX)) { + throw new \RuntimeException('Lock kan niet worden verkregen'); + } + try { + return $fn(); + } finally { + flock($handle, LOCK_UN); + } + } finally { + fclose($handle); + } + } +} diff --git a/tests/TokenStoreTest.php b/tests/TokenStoreTest.php new file mode 100644 index 0000000..5077742 --- /dev/null +++ b/tests/TokenStoreTest.php @@ -0,0 +1,87 @@ +dir = sys_get_temp_dir() . '/weerscherm-token-' . uniqid(); + $this->file = $this->dir . '/netatmo-token.json'; + } + + protected function tearDown(): void + { + array_map('unlink', glob($this->dir . '/*') ?: []); + @rmdir($this->dir); + } + + public function testWriteReadRoundTripAndMode(): void + { + $store = new TokenStore($this->file); + $store->write(['access_token' => 'a', 'refresh_token' => 'r', 'expires_at' => 123]); + + self::assertSame(['access_token' => 'a', 'refresh_token' => 'r', 'expires_at' => 123], $store->read()); + self::assertSame('0600', substr(sprintf('%o', fileperms($this->file)), -4)); + self::assertSame([], glob($this->dir . '/*.tmp.*') ?: [], 'geen tijdelijke bestanden achtergelaten'); + } + + public function testMissingOrInvalidFileReadsNull(): void + { + self::assertNull((new TokenStore($this->file))->read()); + + mkdir($this->dir, 0700, true); + file_put_contents($this->file, '{"access_token":"a"}'); + self::assertNull((new TokenStore($this->file))->read(), 'zonder refresh token is het bestand onbruikbaar'); + } + + public function testWithLockReturnsValueAndReleasesAfterException(): void + { + $store = new TokenStore($this->file); + + self::assertSame(42, $store->withLock(fn() => 42)); + + try { + $store->withLock(function (): void { + throw new \RuntimeException('boem'); + }); + self::fail('exception verwacht'); + } catch (\RuntimeException) { + } + + $handle = fopen($this->file . '.lock', 'c'); + self::assertTrue(flock($handle, LOCK_EX | LOCK_NB), 'lock is weer vrij'); + fclose($handle); + } + + public function testConcurrentProcessesAreSerialised(): void + { + $store = new TokenStore($this->file); + $script = sprintf( + 'withLock(function () { usleep(400000); });', + var_export(__DIR__ . '/../vendor/autoload.php', true), + var_export($this->file, true), + ); + $scriptFile = $this->dir . '/hold.php'; + mkdir($this->dir, 0700, true); + file_put_contents($scriptFile, $script); + + $proc = proc_open([PHP_BINARY, $scriptFile], [], $pipes); + usleep(150000); // geef het andere proces tijd om de lock te pakken + + $start = microtime(true); + $store->withLock(fn() => null); + $waited = microtime(true) - $start; + proc_close($proc); + + self::assertGreaterThan(0.15, $waited, 'tweede proces moest wachten op de lock'); + } +}