diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b03e950..00460f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,17 +10,22 @@ on: - main - "*.x" +permissions: + contents: read + jobs: tests: name: PHP ${{ matrix.php }} Test runs-on: ubuntu-latest + timeout-minutes: 10 strategy: + fail-fast: false matrix: php: ['8.1', '8.2', '8.3', '8.4', '8.5'] steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -29,6 +34,10 @@ jobs: tools: composer:v2 coverage: none + - name: Validate Composer configuration + if: matrix.php == '8.1' + run: composer validate --strict --no-check-lock + - name: Install dependencies run: composer update --prefer-dist --no-interaction --no-progress diff --git a/.gitignore b/.gitignore index 6239dbb..1e99abc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,25 @@ /composer.lock /composer.phar +/auth.json +/.env +/.env.* +!/.env.example /phpunit.xml /.phpunit.result.cache +/.phpunit.cache/ /vendor/ /logs/ -/.idea -/index.php \ No newline at end of file +/coverage/ +/clover.xml +/coverage.xml +/.idea/ +/.vscode/ +/.fleet/ +/index.php +/plans/ + +.DS_Store +Thumbs.db +*.swp +*.swo +*~ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..be36252 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,77 @@ +# Project Instructions + +## Project Overview + +This repository contains a PHP library for accessing OpenWeather APIs. It uses +Composer with PSR-4 autoloading under the +`ProgrammatorDev\OpenWeatherMap` namespace and is built on +`programmatordev/php-api-sdk`. + +## Sources Of Truth + +- Use the official OpenWeather documentation for endpoint paths, parameters, + response fields, and current access requirements. +- Use the installed PHP API SDK documentation and source for its supported + authoring patterns. +- Read existing resources, entities, tests, and documentation before changing + related behavior. +- Do not infer API access from documentation sidebars; verify it against the + current official API documentation. + +## Code Changes + +- Prefer focused changes that follow the architecture and naming conventions + documented for the active target version. Do not preserve legacy patterns + when the active public contract intentionally replaces them. +- Organize source files by clear responsibility or domain and mirror that + structure in tests. Keep the root namespace focused on primary entry points; + avoid both unrelated root-level classes and arbitrary one-file folders. +- Reuse shared entities, helpers, resource behavior, test utilities, and + constants when they fit. +- Keep endpoint construction in resource classes and response mapping in typed + entity or response classes. +- Keep request-local fluent options immutable so they do not affect later + resource calls. +- Treat official documentation and representative response fixtures as + complementary schema evidence; neither source is exhaustive on its own. +- Tolerate missing, explicitly `null`, conditional, and unknown response fields. + Reject known non-null fields with invalid types through descriptive hydration + errors rather than silent coercion. +- Represent returned timestamps as nullable UTC `DateTimeImmutable` values and + keep location timezone identifiers or offsets as separate metadata. +- Make destructive operations explicit in method naming and documentation. +- Do not expose API keys through exceptions, logs, fixtures, or committed + example files. + +## Dependencies And Tooling + +- Run project PHP and Composer commands through DDEV. +- Respect the PHP versions declared by `composer.json` and CI. +- Remove a dependency only when its remaining usages have been eliminated. +- Do not introduce a formatter, static analyzer, or new test framework without + explicit approval. + +## Testing + +- Use PHPUnit and the existing PSR-18 mock-client approach. +- Do not make live OpenWeather requests in the automated test suite. +- Build automated response tests from sanitized real API captures, supplemented + by synthetic edge-case fixtures. Keep credentials and private or + account-specific data out of committed fixtures. +- Test endpoint method, URL, path parameters, query parameters, headers, body, + response mapping, error mapping, and immutable resource-chain behavior. +- Add focused entity tests for present, missing, explicitly `null`, + conditionally present, unknown, invalidly typed, and nested fields. +- Cover empty-body responses for successful write and delete operations. +- Run the full test suite before handing off an implementation batch when + practical. + +## Documentation + +- Update public documentation alongside implemented API areas. +- Keep method signatures, examples, supported endpoints, and response entities + aligned with the implementation. +- Avoid hard-coded claims about plans, prices, quotas, or allowances. Link to + the official OpenWeather documentation for current access requirements. +- Document destructive behavior and actions that send additional requests + prominently. diff --git a/README.md b/README.md index d54fbd1..34505f5 100644 --- a/README.md +++ b/README.md @@ -4,57 +4,118 @@ [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) [![Tests](https://github.com/programmatordev/openweathermap-php-api/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/programmatordev/openweathermap-php-api/actions/workflows/ci.yml?query=branch%3Amain) -OpenWeatherMap PHP library that provides convenient access to the OpenWeatherMap API. +A fluent PHP client for OpenWeather APIs covering current and forecast weather, +air pollution, geocoding, maps, stations, and One Call. Responses use typed +entities that safely handle conditional, missing, and `null` data. -Supports [PSR-18 HTTP clients](https://www.php-fig.org/psr/psr-18), [PSR-17 HTTP factories](https://www.php-fig.org/psr/psr-17), [PSR-6 caches](https://www.php-fig.org/psr/psr-6) and [PSR-3 logs](https://www.php-fig.org/psr/psr-3). +The library is built on +[`programmatordev/php-api-sdk`](https://github.com/programmatordev/php-api-sdk), +which provides HTTP client discovery and optional caching, logging, plugins, +and request hooks. ## Requirements -- PHP 8.1 or higher. - -## API Key - -A key is required to be able to make requests to the API. -You must sign up for an [OpenWeatherMap account](https://openweathermap.org/appid#signup) to get one. +- PHP 8.1 or higher +- An OpenWeather API key ## Installation -Install the library via [Composer](https://getcomposer.org/): +Install the library with Composer: ```bash composer require programmatordev/openweathermap-php-api ``` -## Basic Usage +## Getting Started + +Create the API client with an OpenWeather API key, then choose an API and call +one of its methods: + +```php +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); + +$current = $api->weather()->current( + latitude: 38.7223, + longitude: -9.1393, +); + +echo $current->temperature(); +echo $current->temperatureWithUnit(); +``` + +Response properties may be missing or `null`, so getters return nullable values +where appropriate. Collection getters return empty arrays when the response +does not contain that collection. -Simple usage looks like: +## Configuration + +The client defaults to metric units and English. The equivalent explicit +configuration is: ```php +use ProgrammatorDev\OpenWeatherMap\Enum\Language; +use ProgrammatorDev\OpenWeatherMap\Enum\Units; use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; -// initialize -$api = new OpenWeatherMap('yourapikey'); +$api = new OpenWeatherMap( + apiKey: $_ENV['OPENWEATHERMAP_API_KEY'], + options: [ + 'units' => Units::METRIC, + 'language' => Language::ENGLISH, + ], +); +``` + +Weather and One Call requests can override those values for one fluent request +chain. The client-wide configuration remains unchanged for later requests: -// get current weather by coordinate (latitude, longitude) -$weather = $api->weather()->getCurrent(50, 50); -// show current temperature -echo $weather->getTemperature(); +```php +$current = $api + ->weather() + ->withUnits(Units::IMPERIAL) + ->withLanguage(Language::PORTUGUESE) + ->current(latitude: 38.7223, longitude: -9.1393); ``` +`withLanguage()` also accepts a non-empty language-code string, allowing new +OpenWeather languages to be used without waiting for an enum update. + +See OpenWeather's +[units of measurement](https://openweathermap.org/api/current?collection=current_forecast#data) and +[multilingual support](https://openweathermap.org/api/current?collection=current_forecast#multi) +documentation for the currently supported values. + ## Documentation -- [Usage](docs/01-usage.md) -- [Configuration](docs/02-configuration.md) -- [Supported APIs](docs/03-supported-apis.md) -- [Error Handling](docs/04-error-handling.md) -- [Entities](docs/05-entities.md) +### APIs + +These guides cover each API's endpoints, response entities, and usage examples: + +- [One Call 4.0](docs/one-call.md) +- [Air Pollution](docs/air-pollution.md) +- [Weather](docs/weather.md) +- [Maps](docs/maps.md) +- [Stations](docs/stations.md) +- [Geocoding](docs/geocoding.md) + +### Client Guides + +These guides cover client configuration and failures shared across the APIs: + +- [Setup](docs/setup.md) — Configure caching, logging, HTTP clients, plugins, + and request hooks. +- [Error Handling](docs/errors.md) — Handle OpenWeather API errors and client + failures. -## Contributing +## Upgrading -Any form of contribution to improve this library (including requests) will be welcome and appreciated. -Make sure to open a pull request or issue. +Version 4 is a complete rewrite without backward compatibility. Existing +integrations should treat it as a new implementation. See +[Upgrading To 4.0](UPGRADE-4.0.md) for the release expectations and current +baseline. ## License -This project is licensed under the MIT license. -Please see the [LICENSE](LICENSE) file distributed with this source code for further information regarding copyright and licensing. \ No newline at end of file +This project is licensed under the [MIT License](LICENSE). diff --git a/UPGRADE-4.0.md b/UPGRADE-4.0.md new file mode 100644 index 0000000..8381561 --- /dev/null +++ b/UPGRADE-4.0.md @@ -0,0 +1,33 @@ +# Upgrading To 4.0 + +Version 4 is a complete rewrite. It is not backward compatible with earlier +releases. Existing integrations should adopt it as a new implementation, even +where the usage looks familiar. + +## Upgrade Expectations + +- Previous resources, entities, methods, namespaces, and configuration are not + part of the current public contract. +- Compatibility aliases, transitional APIs, and an old-to-new API mapping are + not provided. +- Integrations should be rebuilt against the current [README](README.md) and + [API guides](README.md#apis). +- Application tests should be reviewed and updated before adopting the new + release. + +## Current Baseline + +- PHP 8.1 or later is required. +- The client is built on + [`programmatordev/php-api-sdk` 3](https://github.com/programmatordev/php-api-sdk). +- Current weather, forecasts, air pollution, geocoding, maps, stations, and One + Call 4.0 are supported. +- Metric units and English are the defaults. They can be configured for the + client or changed for one request chain where supported. +- Response entities accept missing, `null`, conditional, and unknown fields. + Known non-null fields with invalid types produce hydration errors. +- OpenWeather API failures use a documented exception hierarchy; transport, + decoding, and hydration failures remain distinguishable. See + [Error Handling](docs/errors.md). + +The current documentation defines the supported behavior for this release. diff --git a/composer.json b/composer.json index 1a8fe88..f7fe0f5 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,8 @@ { "name": "programmatordev/openweathermap-php-api", - "description": "OpenWeatherMap PHP library that provides convenient access to the OpenWeatherMap API", + "description": "A fluent PHP client for OpenWeather APIs, including weather, air pollution, geocoding, maps, stations, and One Call", "type": "library", - "keywords": ["openweathermap", "api", "php", "php8", "sdk", "psr-18", "psr-17", "psr-6", "psr-3"], + "keywords": ["openweather", "openweathermap", "weather", "forecast", "air-quality", "geocoding", "api", "php", "php8", "sdk", "psr-18", "psr-17", "psr-6", "psr-3"], "license": "MIT", "authors": [ { @@ -13,23 +13,16 @@ ], "require": { "php": ">=8.1", - "myclabs/deep-copy": "^1.13", - "programmatordev/php-api-sdk": "^2.1", - "symfony/options-resolver": "^6.4|^7.4|^8.0" + "php-http/discovery": "^1.20", + "programmatordev/php-api-sdk": "^3.3" }, "require-dev": { - "monolog/monolog": "^3.10", "nyholm/psr7": "^1.8", "php-http/mock-client": "^1.6", "phpunit/phpunit": "^10.5", - "symfony/cache": "^6.4|^7.4|^8.0", "symfony/http-client": "^6.4|^7.4|^8.0", "symfony/var-dumper": "^6.4|^7.4|^8.0" }, - "provide": { - "psr/http-client-implementation": "1.0", - "psr/http-factory-implementation": "1.0" - }, "autoload": { "psr-4": { "ProgrammatorDev\\OpenWeatherMap\\": "src/" diff --git a/docs/01-usage.md b/docs/01-usage.md deleted file mode 100644 index 513bacb..0000000 --- a/docs/01-usage.md +++ /dev/null @@ -1,39 +0,0 @@ -# Using OpenWeatherMap PHP API - -- [Requirements](#requirements) -- [API Key](#api-key) -- [Installation](#installation) -- [Basic Usage](#basic-usage) - -## Requirements - -- PHP 8.1 or higher. - -## API Key - -A key is required to be able to make requests to the API. -You must sign up for an [OpenWeatherMap account](https://openweathermap.org/appid#signup) to get one. - -## Installation - -Install the library via [Composer](https://getcomposer.org/): - -```bash -composer require programmatordev/openweathermap-php-api -``` - -## Basic Usage - -Simple usage looks like: - -```php -use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; - -// initialize -$api = new OpenWeatherMap('yourapikey'); - -// get current weather by coordinate (latitude, longitude) -$weather = $api->weather()->getCurrent(50, 50); -// show current temperature -echo $weather->getTemperature(); -``` \ No newline at end of file diff --git a/docs/02-configuration.md b/docs/02-configuration.md deleted file mode 100644 index 691a74b..0000000 --- a/docs/02-configuration.md +++ /dev/null @@ -1,160 +0,0 @@ -# Configuration - -- [Default Configuration](#default-configuration) -- [Options](#options) - - [unitSystem](#unitsystem) - - [language](#language) -- [Methods](#methods) - - [setClientBuilder](#setclientbuilder) - - [setCacheBuilder](#setcachebuilder) - - [setLoggerBuilder](#setloggerbuilder) - -## Default Configuration - -```php -OpenWeatherMap(string $apiKey, array $options => []); -``` - -```php -use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; - -$api = new OpenWeatherMap('yourapikey', [ - 'unitSystem' => 'metric', - 'language' => 'en' -]); -``` - -## Options - -### `unitSystem` - -Unit system used when retrieving data. -Affects temperature and speed values. - -Available options: -- `metric` -- `imperial` -- `standard` - -Example: - -```php -use ProgrammatorDev\OpenWeatherMap\UnitSystem\UnitSystem; -use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; - -$api = new OpenWeatherMap('yourapikey', [ - 'unitSystem' => UnitSystem::IMPERIAL -]); -``` - -### `language` - -Language used when retrieving data. -It seems to only affect weather conditions descriptions. - -List of all available languages can be found [here](https://openweathermap.org/api/one-call-3#multi). - -Example: - -```php -use ProgrammatorDev\OpenWeatherMap\Language\Language; -use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; - -$api = new OpenWeatherMap('yourapikey', [ - 'language' => Language::PORTUGUESE -]); -``` - -## Methods - -> [!IMPORTANT] -> The [PHP API SDK](https://github.com/programmatordev/php-api-sdk) library was used to create the OpenWeatherMap PHP API. -> To get to know about all the available methods, make sure to check the documentation [here](https://github.com/programmatordev/php-api-sdk?tab=readme-ov-file#documentation). - -The following sections have examples of some of the most important methods, -particularly related to the configuration of the client, cache and logger. - -### `setClientBuilder` - -By default, this library makes use of the [HTTPlug's Discovery](https://github.com/php-http/discovery) library. -This means that it will automatically find and install a well-known PSR-18 client and PSR-17 factory implementation for you -(if they were not found on your project): -- [PSR-18 compatible implementations](https://packagist.org/providers/psr/http-client-implementation) -- [PSR-17 compatible implementations](https://packagist.org/providers/psr/http-factory-implementation) - -If you don't want to rely on the discovery of implementations, you can set the ones you want: - -```php -use Nyholm\Psr7\Factory\Psr17Factory; -use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; -use Symfony\Component\HttpClient\Psr18Client; - -$api = new OpenWeatherMap('yourapikey'); - -$client = new Psr18Client(); -$requestFactory = $streamFactory = new Psr17Factory(); - -$api->setClientBuilder( - new ClientBuilder( - client: $client, - requestFactory: $requestFactory, - streamFactory: $streamFactory - ) -); -``` - -Check the full documentation [here](https://github.com/programmatordev/php-api-sdk?tab=readme-ov-file#http-client-psr-18-and-http-factories-psr-17). - -### `setCacheBuilder` - -This library allows configuring the cache layer of the client for making API requests. -It uses a standard PSR-6 implementation and provides methods to fine-tune how HTTP caching behaves: -- [PSR-6 compatible implementations](https://packagist.org/providers/psr/cache-implementation) - -Example: - -```php -use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; -use Symfony\Component\Cache\Adapter\FilesystemAdapter; - -$api = new OpenWeatherMap('yourapikey'); - -$pool = new FilesystemAdapter(); - -// set a file-based cache adapter with a 1-hour default cache lifetime -$api->setCacheBuilder( - new CacheBuilder( - pool: $pool, - ttl: 3600 - ) -); -``` - -Check the full documentation [here](https://github.com/programmatordev/php-api-sdk?tab=readme-ov-file#cache-psr-6). - -### `setLoggerBuilder` - -This library allows configuring a logger to save data for making API requests. -It uses a standard PSR-3 implementation and provides methods to fine-tune how logging behaves: -- [PSR-3 compatible implementations](https://packagist.org/providers/psr/log-implementation) - -Example: - -```php -use Monolog\Logger; -use Monolog\Handler\StreamHandler; -use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; - -$api = new OpenWeatherMap('yourapikey'); - -$logger = new Logger('api'); -$logger->pushHandler(new StreamHandler('/logs/api.log')); - -$api->setLoggerBuilder( - new LoggerBuilder( - logger: $logger - ) -); -``` - -Check the full documentation [here](https://github.com/programmatordev/php-api-sdk?tab=readme-ov-file#logger-psr-3). \ No newline at end of file diff --git a/docs/03-supported-apis.md b/docs/03-supported-apis.md deleted file mode 100644 index a4de66a..0000000 --- a/docs/03-supported-apis.md +++ /dev/null @@ -1,311 +0,0 @@ -# Supported APIs - -- [APIs](#apis) - - [One Call](#one-call) - - [getWeather](#getweather) - - [getWeatherByDate](#getweatherbydate) - - [getWeatherSummaryByDate](#getweathersummarybydate) - - [getWeatherOverviewByDate](#getweatheroverviewbydate) - - [AI Assistant](#ai-assistant) - - [startSession](#startsession) - - [resumeSession](#resumesession) - - [Weather](#weather) - - [getCurrent](#getcurrent) - - [getForecast](#getforecast) - - [Air Pollution](#air-pollution) - - [getCurrent](#getcurrent-1) - - [getForecast](#getforecast-1) - - [getHistory](#gethistory) - - [Geocoding](#geocoding) - - [getByLocationName](#getbylocationname) - - [getByCoordinate](#getbycoordinate) - - [getByZipCode](#getbyzipcode) -- [Common Methods](#common-methods) - - [withUnitSystem](#withunitsystem) - - [withLanguage](#withlanguage) - - [withCacheTtl](#withcachettl) - -## APIs - -### One Call - -#### `getWeather` - -```php -getWeather(float $latitude, float $longitude): Weather -``` - -Get access to current weather, minute forecast for 1 hour, hourly forecast for 48 hours, -daily forecast for 8 days and government weather alerts. - -Returns a [`Weather`](05-entities.md#weather) object: - -```php -$weather = $api->oneCall()->getWeather(50, 50); -``` - -#### `getWeatherByDate` - -```php -getWeatherByDate(float $latitude, float $longitude, \DateTimeInterface $dateTime): WeatherMoment -``` - -Get access to weather data for any datetime. - -Returns a [`WeatherMoment`](05-entities.md#weathermoment) object: - -```php -$weather = $api->oneCall()->getWeatherByDate(50, 50, new \DateTime('2023-05-13 16:32:00')); -``` - -#### `getWeatherSummaryByDate` - -```php -getWeatherSummaryByDate(float $latitude, float $longitude, \DateTimeInterface $date): WeatherSummary -``` - -Get access to aggregated weather data for a particular date. - -Returns a [`WeatherSummary`](05-entities.md#weathersummary) object: - -```php -$weatherSummary = $api->oneCall()->getWeatherSummaryByDate(50, 50, new \DateTime('1985-07-19')); -``` - -#### `getWeatherOverviewByDate` - -```php -getWeatherOverviewByDate(float $latitude, float $longitude, \DateTimeInterface $date): WeatherOverview -``` - -Get the weather overview with a human-readable summary for today and tomorrow's forecast, -using OpenWeather AI. - -Returns a [`WeatherOverview`](05-entities.md#weatheroverview) object: - -```php -$weatherOverview = $api->oneCall()->getWeatherOverviewByDate(50, 50, new \DateTime('today')); -``` - -### AI Assistant - -#### `startSession` - -```php -startSession(string $prompt): Answer -``` - -Start a new session (create a new conversation) with the Weather AI Assistant. - -Returns a [`Answer`](05-entities.md#answer) object: - -```php -$answer = $api->assistant()->startSession('How is the weather today in Lisbon?'); -``` - -#### `resumeSession` - -```php -resumeSession(string $sessionId, string $prompt): Answer -``` - -Resume a session (continue a conversation) with the Weather AI Assistant. - -Returns a [`Answer`](05-entities.md#answer) object: - -```php -$answer = $api->assistant()->resumeSession('session-id', 'Do I need an umbrella?'); -``` - -### Weather - -#### `getCurrent` - -```php -getCurrent(float $latitude, float $longitude): Weather -``` - -Get access to current weather data. - -Returns a [`Weather`](05-entities.md#weather-2) object: - -```php -$currentWeather = $api->weather()->getCurrent(50, 50); -``` - -#### `getForecast` - -```php -getForecast(float $latitude, float $longitude, int $numResults = 40): WeatherCollection -``` - -Get access to 5-day weather forecast data with 3-hour steps. - -Returns a [`WeatherCollection`](05-entities.md#weathercollection) object: - -```php -// Since it returns data in 3-hour steps, -// passing 8 as the numResults means it will return results for the next 24 hours -$weatherForecast = $api->weather()->getForecast(50, 50, 8); -``` - -### Air Pollution - -#### `getCurrent` - -```php -getCurrent(float $latitude, float $longitude): AirPollution -``` - -Get access to current air pollution data. - -Returns a [`AirPollution`](05-entities.md#airpollution) object: - -```php -$currentAirPollution = $api->airPollution()->getCurrent(50, 50); -``` - -#### `getForecast` - -```php -getForecast(float $latitude, float $longitude): AirPollutionCollection -``` - -Get access to air pollution forecast data per hour. - -Returns a [`AirPollutionCollection`](05-entities.md#airpollutioncollection) object: - -```php -$airPollutionForecast = $api->airPollution()->getForecast(50, 50); -``` - -#### `getHistory` - -```php -getHistory(float $latitude, float $longitude, \DateTimeInterface $startDate, \DateTimeInterface $endDate): AirPollutionCollection -``` - -Get access to historical air pollution data per hour between two dates. - -Returns a [`AirPollutionCollection`](05-entities.md#airpollutioncollection) object: - -```php -$startDate = new \DateTime('-1 day'); -$endDate = new \DateTime('now'); - -// returns air pollution data for the last 24 hours -$airPollutionHistory = $api->airPollution()->getHistory(50, 50, $startDate, $endDate); -``` - -### Geocoding - -#### `getByLocationName` - -```php -/** - * @return Location[] - */ -getByLocationName(string $locationName, int $numResults = 5): array -``` - -Get geographical coordinates (latitude, longitude) by using the name of the location (city name or area name). - -Returns an array of [`Location`](05-entities.md#location) objects. - -```php -$locations = $api->geocoding()->getByLocationName('lisbon'); -``` - -#### `getByCoordinate` - -```php -/** - * @return Location[] - */ -getByCoordinate(float $latitude, float $longitude, int $numResults = 5): array -``` - -Get the name of the location (city name or area name) by using geographical coordinates (latitude, longitude). - -Returns an array of [`Location`](05-entities.md#location) objects. - -```php -$locations = $api->geocoding()->getByCoordinate(50, 50); -``` - -#### `getByZipCode` - -```php -getByZipCode(string $zipCode, string $countryCode): ZipLocation -``` - -Get geographical coordinates (latitude, longitude) by using the zip/postal code. - -Returns a [`ZipLocation`](05-entities.md#ziplocation) object. - -```php -$location = $api->geocoding()->getByZipCode('1000-001', 'pt'); -``` - -## Common Methods - -#### `withLanguage` - -```php -withLanguage(string $language): self -``` - -Set the language per request. -Only available for [`OneCall`](#one-call) and [`Weather`](#weather) API requests. - -```php -use ProgrammatorDev\OpenWeatherMap\Language\Language - -// uses the "pt" language for this request alone -$api->weather() - ->withLanguage(Language::PORTUGUESE) - ->getCurrent(50, 50); -``` - -#### `withUnitSystem` - -```php -withUnitSystem(string $unitSystem): self -``` - -Set the unit system per request. -Only available for [`OneCall`](#one-call) and [`Weather`](#weather) API requests. - -```php -use ProgrammatorDev\OpenWeatherMap\UnitSystem\UnitSystem; - -// uses the "imperial" unit system for this request alone -$api->weather() - ->withUnitSystem(UnitSystem::IMPERIAL) - ->getCurrent(50, 50); -``` - -#### `withCacheTtl` - -```php -withCacheTtl(?int $ttl): self -``` - -Makes a request and saves into cache for the provided duration in seconds. - -Semantics of values: -- `0`, the response will not be cached (if the server specifies no `max-age`). -- `null`, the response will be cached for as long as it can (forever). - -> [!NOTE] -> Setting cache to `null` or `0` seconds will **not** invalidate any existing cache. - -Available for all APIs if a cache adapter is set. -Check the following [documentation](02-configuration.md#setcachebuilder) for more information. - -```php -// cache will be saved for 1 hour for this request alone -$api->weather() - ->withCacheTtl(3600) - ->getCurrent(50, 50); -``` \ No newline at end of file diff --git a/docs/04-error-handling.md b/docs/04-error-handling.md deleted file mode 100644 index b50e764..0000000 --- a/docs/04-error-handling.md +++ /dev/null @@ -1,62 +0,0 @@ -# Error Handling - -## API Errors - -To handle API response errors, multiple exceptions are provided. You can see all available in the following example: - -```php -use ProgrammatorDev\OpenWeatherMap\Exception\BadRequestException; -use ProgrammatorDev\OpenWeatherMap\Exception\NotFoundException; -use ProgrammatorDev\OpenWeatherMap\Exception\TooManyRequestsException; -use ProgrammatorDev\OpenWeatherMap\Exception\UnauthorizedException; -use ProgrammatorDev\OpenWeatherMap\Exception\UnexpectedErrorException; - -try { - // ... - - $weather = $api->oneCall()->getWeather($latitude, $longitude); -} -// bad request to the API -catch (BadRequestException $exception) { - echo $exception->getCode(); // 400 - echo $exception->getMessage(); -} -// invalid API key or trying to request an endpoint with no granted access -catch (UnauthorizedException $exception) { - echo $exception->getCode(); // 401 - echo $exception->getMessage(); -} -// resource not found, -// for example, when trying to get a location with a zip code that does not exist -catch (NotFoundException $exception) { - echo $exception->getCode(); // 404 - echo $exception->getMessage(); -} -// API key requests quota exceeded -catch (TooManyRequestsException $exception) { - echo $exception->getCode(); // 429 - echo $exception->getMessage(); -} -// any other error, probably an internal error -catch (UnexpectedErrorException $exception) { - echo $exception->getCode(); // 5xx - echo $exception->getMessage(); -} -``` - -To catch all API errors with a single exception, `ApiErrorException` is available: - -```php -use ProgrammatorDev\OpenWeatherMap\Exception\ApiErrorException; - -try { - // ... - - $weather = $api->oneCall()->getWeather($latitude, $longitude); -} -// catches all API response errors -catch (ApiErrorException $exception) { - echo $exception->getCode(); - echo $exception->getMessage(); -} -``` \ No newline at end of file diff --git a/docs/05-entities.md b/docs/05-entities.md deleted file mode 100644 index e8a691f..0000000 --- a/docs/05-entities.md +++ /dev/null @@ -1,340 +0,0 @@ -# Entities - -- [One Call](#one-call) - - [Weather](#weather) - - [WeatherMoment](#weathermoment) - - [WeatherSummary](#weathersummary) - - [WeatherOverview](#weatheroverview) - - [WeatherData](#weatherdata) - - [MinuteData](#minutedata) - - [HourData](#hourdata) - - [DayData](#daydata) - - [Alert](#alert) - - [MoonPhase](#moonphase) - - [Temperature](#temperature) -- [AI Assistant](#ai-assistant) - - [Answer](#answer) - - [WeatherData](#weatherdata-1) -- [Weather](#weather-1) - - [Weather](#weather-2) - - [WeatherCollection](#weathercollection) - - [WeatherData](#weatherdata-2) -- [Air Pollution](#air-pollution) - - [AirPollution](#airpollution) - - [AirPollutionCollection](#airpollutioncollection) - - [AirPollutionData](#airpollutiondata) - - [AirQuality](#airquality) -- [Geocoding](#geocoding) - - [ZipLocation](#ziplocation) -- [Common](#common) - - [Coordinate](#coordinate) - - [Condition](#condition) - - [Icon](#icon) - - [Location](#location) - - [Timezone](#timezone) - - [Wind](#wind) - -## One Call - -### Weather - -- `getCoordinate()`: [`Coordinate`](#coordinate) -- `getTimezone()`: [`Timezone`](#timezone) -- `getCurrent()`: [`WeatherData`](#weatherdata) -- `getMinutelyForecast()`: [`?MinuteData[]`](#minutedata) -- `getHourlyForecast()`: [`HourData[]`](#hourdata) -- `getDailyForecast()`: [`DayData[]`](#daydata) -- `getAlerts()`: [`?Alert[]`](#alert) - -### WeatherMoment - -- `getCoordinate()`: [`Coordinate`](#coordinate) -- `getTimezone()`: [`Timezone`](#timezone) -- `getDateTime()`: `\DateTimeImmutable` -- `getTemperature()`: `float` -- `getTemperatureFeelsLike()`: `float` -- `getAtmosphericPressure()`: `int` -- `getHumidity()`: `int` -- `getDewPoint()`: `float` -- `getUltraVioletIndex()`: `?float` -- `getCloudiness()`: `int` -- `getVisibility()`: `?int` -- `getWind()`: [`Wind`](#wind) -- `getConditions()`: [`Condition[]`](#condition) -- `getSummary()`: `?string` -- `getRainVolume()`: `?float` -- `getSnowVolume()`: `?float` -- `getMoonPhase()`: [`?MoonPhase`](#moonphase) -- `getSunriseAt()`: `?\DateTimeImmutable` -- `getSunsetAt()`: `?\DateTimeImmutable` -- `getMoonriseAt()`: `?\DateTimeImmutable` -- `getMoonsetAt()`: `?\DateTimeImmutable` - -### WeatherSummary - -- `getCoordinate()`: [`Coordinate`](#coordinate) -- `getTimezone()`: [`Timezone`](#timezone) -- `getDateTime()`: `\DateTimeImmutable` -- `getCloudiness()`: `int` -- `getHumidity()`: `int` -- `getPrecipitation()`: `float` -- `getTemperature()`: [`Temperature`](#temperature) -- `getAtmosphericPressure()`: `int` -- `getWind()`: [`Wind`](#wind) - -### WeatherOverview - -- `getCoordinate()`: [`Coordinate`](#coordinate) -- `getTimezone()`: [`Timezone`](#timezone) -- `getDateTime()`: `\DateTimeImmutable` -- `getOverview()`: `string` - -### WeatherData - -- `getDateTime()`: `\DateTimeImmutable` -- `getTemperature()`: `float` -- `getTemperatureFeelsLike()`: `float` -- `getAtmosphericPressure()`: `int` -- `getVisibility()`: `?int` -- `getHumidity()`: `int` -- `getDewPoint()`: `float` -- `getUltraVioletIndex()`: `?float` -- `getCloudiness()`: `int` -- `getWind()`: [`Wind`](#wind) -- `getConditions()`: [`Condition[]`](#condition) -- `getRainVolume()`: `?float` -- `getSnowVolume()`: `?float` -- `getSunriseAt()`: `?\DateTimeImmutable` -- `getSunsetAt()`: `?\DateTimeImmutable` - -### MinuteData - -- `getDateTime()`: `\DateTimeImmutable` -- `getPrecipitation()`: `float` - -### HourData - -- `getDateTime()`: `\DateTimeImmutable` -- `getTemperature()`: `float` -- `getTemperatureFeelsLike()`: `float` -- `getVisibility()`: `?int` -- `getPrecipitationProbability()`: `int` -- `getAtmosphericPressure()`: `int` -- `getHumidity()`: `int` -- `getDewPoint()`: `float` -- `getUltraVioletIndex()`: `?float` -- `getCloudiness()`: `int` -- `getWind()`: [`Wind`](#wind) -- `getConditions()`: [`Condition[]`](#condition) -- `getRainVolume()`: `?float` -- `getSnowVolume()`: `?float` - -### DayData - -- `getDateTime()`: `\DateTimeImmutable` -- `getTemperature()`: [`Temperature`](#temperature) -- `getTemperatureFeelsLike()`: [`Temperature`](#temperature) -- `getPrecipitationProbability()`: `int` -- `getAtmosphericPressure()`: `int` -- `getHumidity()`: `int` -- `getDewPoint()`: `float` -- `getUltraVioletIndex()`: `?float` -- `getCloudiness()`: `int` -- `getWind()`: [`Wind`](#wind) -- `getConditions()`: [`Condition[]`](#condition) -- `getRainVolume()`: `?float` -- `getSnowVolume()`: `?float` -- `getSummary()`: `string` -- `getMoonPhase()`: [`MoonPhase`](#moonphase) -- `getSunriseAt()`: `\DateTimeImmutable` -- `getSunsetAt()`: `\DateTimeImmutable` -- `getMoonriseAt()`: `\DateTimeImmutable` -- `getMoonsetAt()`: `\DateTimeImmutable` - -### Alert - -- `getSenderName()`: `string` -- `getEventName()`: `string` -- `getStartsAt()`: `\DateTimeImmutable` -- `getEndsAt()`: `\DateTimeImmutable` -- `getDescription()`: `string` -- `getTags()`: `array` - -### MoonPhase - -- `getValue()`: `float` -- `getName()`: `string` -- `getSystemName()`: `string` - -### Temperature - -- `getMorning()`: `float` -- `getDay()`: `float` -- `getEvening()`: `float` -- `getNight()`: `float` -- `getMin()`: `?float` -- `getMax()`: `?float` - -## AI Assistant - -### Answer - -- `getAnswer()`: `string` -- `getSessionId()`: `string` -- `getData()`: [`WeatherData[]`](#weatherdata-1) - -### WeatherData - -- `getLocationName()`: `string` -- `getDateTime()`: `\DateTimeImmutable` -- `getTemperature()`: `float` -- `getTemperatureFeelsLike()`: `float` -- `getAtmosphericPressure()`: `int` -- `getVisibility()`: `?int` -- `getHumidity()`: `int` -- `getDewPoint()`: `float` -- `getUltraVioletIndex()`: `?float` -- `getCloudiness()`: `int` -- `getWind()`: [`Wind`](#wind) -- `getConditions()`: [`Condition[]`](#condition) -- `getRainVolume()`: `?float` -- `getSnowVolume()`: `?float` -- `getSunriseAt()`: `?\DateTimeImmutable` -- `getSunsetAt()`: `?\DateTimeImmutable` - -## Weather - -### Weather - -- `getLocation()`: [`Location`](#location) -- `getDateTime()`: `\DateTimeImmutable` -- `getTemperature()`: `float` -- `getTemperatureFeelsLike()`: `float` -- `getMinTemperature()`: `float` -- `getMaxTemperature()`: `float` -- `getHumidity()`: `int` -- `getCloudiness()`: `int` -- `getVisibility()`: `?int` -- `getAtmosphericPressure()`: `int` -- `getConditions()`: [`Condition[]`](#condition) -- `getWind()`: [`Wind`](#wind) -- `getPrecipitationProbability()`: `?int` -- `getRainVolume()`: `?float` -- `getSnowVolume()`: `?float` - -### WeatherCollection - -- `getNumResults()`: `int` -- `getLocation()`: [`Location`](#location) -- `getData()`: [`WeatherData[]`](#weatherdata-1) - -### WeatherData - -- `getDateTime()`: `\DateTimeImmutable` -- `getTemperature()`: `float` -- `getTemperatureFeelsLike()`: `float` -- `getMinTemperature()`: `float` -- `getMaxTemperature()`: `float` -- `getHumidity()`: `int` -- `getCloudiness()`: `int` -- `getVisibility()`: `?int` -- `getAtmosphericPressure()`: `int` -- `getConditions()`: [`Condition[]`](#condition) -- `getWind()`: [`Wind`](#wind) -- `getPrecipitationProbability()`: `?int` -- `getRainVolume()`: `?float` -- `getSnowVolume()`: `?float` - -## Air Pollution - -### AirPollution - -- `getCoordinate()`: [`Coordinate`](#coordinate) -- `getDateTime()`: `\DateTimeImmutable` -- `getAirQuality`: [`AirQuality`](#airquality) -- `getCarbonMonoxide()`: `float` -- `getNitrogenMonoxide()`: `float` -- `getNitrogenDioxide()`: `float` -- `getOzone()`: `float` -- `getSulphurDioxide()`: `float` -- `getFineParticulateMatter()`: `float` -- `getCoarseParticulateMatter()`: `float` -- `getAmmonia()`: `float` - -### AirPollutionCollection - -- `getNumResults()`: `int` -- `getCoordinate()`: [`Coordinate`](#coordinate) -- `getData()`: [`AirPollutionData[]`](#airpollutiondata) - -### AirPollutionData - -- `getDateTime()`: `\DateTimeImmutable` -- `getAirQuality`: [`AirQuality`](#airquality) -- `getCarbonMonoxide()`: `float` -- `getNitrogenMonoxide()`: `float` -- `getNitrogenDioxide()`: `float` -- `getOzone()`: `float` -- `getSulphurDioxide()`: `float` -- `getFineParticulateMatter()`: `float` -- `getCoarseParticulateMatter()`: `float` -- `getAmmonia()`: `float` - -### AirQuality - -- `getIndex()`: `int` -- `getQualitativeName()`: `string` - -## Geocoding - -### ZipLocation - -- `getZipCode()`: `string` -- `getName()`: `string` -- `getCountryCode()`: `string` -- `getCoordinate()`: [`Coordinate`](#coordinate) - -## Common - -### Coordinate - -- `getLatitude()`: `float` -- `getLongitude()`: `float` - -### Condition - -- `getId()`: `int` -- `getName()`: `string` -- `getDescription()`: `string` -- `getIcon()`: [`Icon`](#icon) -- `getSystemName()`: `string` - -### Icon - -- `getId()`: `string` -- `getUrl()`: `string` - -### Location - -- `getCoordinate()`: [`Coordinate`](#coordinate) -- `getId()`: `?int` -- `getName()`: `?string` -- `getState()`: `?string` -- `getCountryCode()`: `?string` -- `getLocalNames()`: `?array` -- `getLocalName(string $countryCode)`: `?string` -- `getPopulation()`: `?int` -- `getTimezone()`: [`?Timezone`](#timezone) -- `getSunriseAt()`: `?\DateTimeImmutable` -- `getSunsetAt()`: `?\DateTimeImmutable` - -### Timezone - -- `getOffset()`: `int` -- `getIdentifier()`: `?string` - -### Wind - -- `getSpeed()`: `float` -- `getDirection()`: `int` -- `getGust()`: `?float` \ No newline at end of file diff --git a/docs/air-pollution.md b/docs/air-pollution.md new file mode 100644 index 0000000..180d95a --- /dev/null +++ b/docs/air-pollution.md @@ -0,0 +1,136 @@ +# Air Pollution + +This guide covers current, forecast, and historical air pollution. + +## Current + +See OpenWeather's +[official Air Pollution API documentation](https://openweathermap.org/api/air-pollution) +for API details. + +Use `current()` with a latitude and longitude. + +```php +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); + +$current = $api->airPollution()->current( + latitude: 38.7223, + longitude: -9.1393, +); +``` + +`current()` returns the air-quality observation for the requested coordinates. +Every property may be absent or explicitly `null`. + +```php +echo $current->coordinates()?->latitude(); +echo $current->coordinates()?->longitude(); +echo $current->dateTime()?->format(DATE_ATOM); +echo $current->airQualityIndex()?->value; +``` + +The air quality index uses OpenWeather's native scale from 1 (good) through 5 +(very poor). Pollutant concentrations are grouped under `components()` and use +the fixed `µg/m³` unit documented by OpenWeather; weather unit configuration +does not affect them. + +OpenWeather also documents the UK, European, US, and Mainland China scales in +its [Air Pollution Index levels](https://openweathermap.org/api/air-pollution-index-levels) +reference. + +```php +$components = $current->components(); + +echo $components?->carbonMonoxide(); +echo $components?->nitrogenMonoxide(); +echo $components?->nitrogenDioxide(); +echo $components?->ozone(); +echo $components?->sulphurDioxide(); +echo $components?->fineParticulateMatter(); +echo $components?->coarseParticulateMatter(); +echo $components?->ammonia(); +``` + +Concentration getters return nullable floats. Companion methods provide the +unit and a formatted value: + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\Unit; + +$components?->fineParticulateMatter(); // 5.89 +$components?->fineParticulateMatterUnit(); // Unit::MICROGRAMS_PER_CUBIC_METER +$components?->fineParticulateMatterWithUnit(); // '5.89 µg/m³' +``` + +## Forecast + +The Air Pollution Forecast API provides hourly periods for four days. See the +[official Air Pollution API documentation](https://openweathermap.org/api/air-pollution) +for API details. + +Use `forecast()` with a latitude and longitude. + +```php +$forecast = $api->airPollution()->forecast( + latitude: 38.7223, + longitude: -9.1393, +); +``` + +The returned `Forecast` entity exposes the response coordinates and hourly +periods. Missing or `null` period lists become empty arrays, and every period +property may be absent or explicitly `null`. + +```php +echo $forecast->coordinates()?->latitude(); +echo $forecast->coordinates()?->longitude(); + +foreach ($forecast->periods() as $period) { + echo $period->dateTime()?->format(DATE_ATOM); + echo $period->airQualityIndex()?->value; + echo $period->components()?->fineParticulateMatter(); +} +``` + +Forecast periods use the same OpenWeather Air Quality Index and fixed +`µg/m³` pollutant units as current observations. + +## History + +The Historical Air Pollution API returns hourly observations for a coordinate +and date range. See the +[official Air Pollution API documentation](https://openweathermap.org/api/air-pollution) +for API details. + +Use `history()` with a latitude, longitude, start date, and end date. The date +arguments accept any `DateTimeInterface` implementation. The end must be after +or equal to the start and cannot be in the future. + +```php +$history = $api->airPollution()->history( + latitude: 38.7223, + longitude: -9.1393, + startAt: new DateTimeImmutable('2 days ago'), + endAt: new DateTimeImmutable('1 day ago'), +); +``` + +The returned `History` entity exposes the response coordinates and hourly +periods. A valid range for which OpenWeather has no data returns an empty +collection. + +```php +echo $history->coordinates()?->latitude(); +echo $history->coordinates()?->longitude(); + +foreach ($history->periods() as $period) { + echo $period->dateTime()?->format(DATE_ATOM); + echo $period->airQualityIndex()?->value; + echo $period->components()?->fineParticulateMatter(); +} +``` + +Historical periods use the same OpenWeather Air Quality Index and fixed +`µg/m³` pollutant units as current observations and forecast periods. diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..792ccf6 --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,78 @@ +# Error Handling + +The library separates OpenWeather API errors from invalid input, HTTP client +failures, decoding errors, and invalid response data. + +## OpenWeather API Errors + +Every mapped HTTP error extends `ApiException`, so applications can catch one +type for all unsuccessful OpenWeather responses. + +```php +use ProgrammatorDev\OpenWeatherMap\Exception\ApiException; + +try { + $current = $api->weather()->current( + latitude: 38.7223, + longitude: -9.1393, + ); +} catch (ApiException $exception) { + echo $exception->getMessage(); + echo $exception->statusCode(); + echo $exception->apiCode(); + + $responseData = $exception->responseData(); +} +``` + +`getMessage()` returns OpenWeather's non-empty error message when one is +available. Otherwise, it describes the HTTP status. `statusCode()` always +returns the HTTP response status, while `apiCode()` returns OpenWeather's +numeric `cod` or `code` value when the response provides one. `responseData()` +exposes the decoded error payload. + +Known HTTP statuses use dedicated exceptions: + +| HTTP status | Exception | +| ---: | --- | +| `400` | `BadRequestException` | +| `401` | `UnauthorizedException` | +| `404` | `NotFoundException` | +| `429` | `TooManyRequestsException` | +| Other `4xx` or `5xx` | `UnexpectedErrorException` | + +Catch a specific exception before `ApiException` when the application needs +different behavior for that failure. + +```php +use ProgrammatorDev\OpenWeatherMap\Exception\ApiException; +use ProgrammatorDev\OpenWeatherMap\Exception\TooManyRequestsException; +use ProgrammatorDev\OpenWeatherMap\Exception\UnauthorizedException; + +try { + $current = $api->weather()->current(38.7223, -9.1393); +} catch (TooManyRequestsException $exception) { + // Defer or slow down further requests. +} catch (UnauthorizedException $exception) { + // Check the API key and whether it can use this endpoint. +} catch (ApiException $exception) { + // Handle any other unsuccessful OpenWeather response. +} +``` + +## Other Failures + +Failures that occur before an OpenWeather error response is mapped do not +extend `ApiException`: + +| Type | Meaning | +| --- | --- | +| `InvalidArgumentException` | A method argument or client option is invalid, so no request is sent. | +| `Psr\Http\Client\ClientExceptionInterface` | The PSR-18 client could not complete the HTTP request. | +| `JsonException` | A successful response expected to contain JSON could not be decoded. | +| `HydrationException` | A known response property contains an invalid type or value. | +| `UnexpectedValueException` | A non-JSON response, such as a map tile, has unexpected content or a wrong content type. | + +Missing, explicitly `null`, conditional, and unknown response properties are +tolerated. `HydrationException` is reserved for known non-null properties whose +types or values do not match the response contract. diff --git a/docs/geocoding.md b/docs/geocoding.md new file mode 100644 index 0000000..e4b7940 --- /dev/null +++ b/docs/geocoding.md @@ -0,0 +1,73 @@ +# Geocoding + +See the +[official Geocoding API documentation](https://openweathermap.org/api/geocoding-api) +for API details. + +## Lookup By Name + +Use `byName()` with a city name, optionally followed by a state code and +two-letter ISO 3166 country code: `city`, `city,country`, or +`city,state,country`. The state code is intended for US locations. The optional +result limit must be between one and five; omit it to use the API default. + +```php +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); + +$locations = $api->geocoding()->byName('Springfield,US', limit: 5); +``` + +The method returns an array of `Location` entities and returns an empty array +when no locations match. Every response property may be absent or `null`. + +```php +foreach ($locations as $location) { + echo $location->name(); + echo $location->localName('en'); + echo $location->latitude(); + echo $location->longitude(); + echo $location->countryCode(); + echo $location->state(); +} +``` + +`localNames()` returns all available localized names as an associative array. +The available language codes depend on the returned location. + +## Lookup By Postal Code + +Use `byPostalCode()` with a postal code and a two-letter ISO 3166 country code. +Country codes are case-insensitive. + +```php +$location = $api->geocoding()->byPostalCode( + postalCode: '1000-001', + countryCode: 'PT', +); + +echo $location->postalCode(); +echo $location->name(); +echo $location->latitude(); +echo $location->longitude(); +echo $location->countryCode(); +``` + +The method returns a `PostalLocation`. Every response property may be absent or +`null`. + +## Lookup By Coordinates + +Use `byCoordinates()` for reverse geocoding. The optional result limit must be +at least one; omit it to use the API default. + +```php +$locations = $api->geocoding()->byCoordinates( + latitude: 40.7128, + longitude: -74.006, + limit: 5, +); +``` + +The method returns an array of `Location` entities. diff --git a/docs/maps.md b/docs/maps.md new file mode 100644 index 0000000..13f9b65 --- /dev/null +++ b/docs/maps.md @@ -0,0 +1,116 @@ +# Maps + +Weather Maps API 1.0 provides current cloud, precipitation, sea-level pressure, +wind-speed, and temperature overlays. See the +[official Weather Maps documentation](https://openweathermap.org/api/weathermaps) +for API details. + +## Fetch A Tile + +Use `tile()` with a layer, zoom level, and X and Y tile coordinates. + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\MapLayer; +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); + +$tile = $api->maps()->tile( + layer: MapLayer::PRECIPITATION, + zoom: 6, + x: 31, + y: 20, +); +``` + +`tile()` returns one 256-by-256 PNG overlay as a `MapTile`. The request is made +by the PHP application, so the API key is not included in the returned value. + +```php +header('Content-Type: ' . $tile->contentType()); + +echo $tile->contents(); +``` + +## Generate A Tile URL + +Use `tileUrl()` when an image or another client needs to load one specific tile +directly. Generating the URL does not make an HTTP request. + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\MapLayer; +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); + +$url = $api->maps()->tileUrl( + layer: MapLayer::PRECIPITATION, + zoom: 6, + x: 31, + y: 20, +); +``` + +For example, the URL can be used as an image source: + +```html +Precipitation map tile +``` + +The URL contains the API key passed to `OpenWeatherMap`. Treat it as a +credential and expose it only where direct client loading is intended. + +## Generate A Tile URL Template + +Use `tileUrlTemplate()` with an XYZ mapping library. XYZ is a common map-tile +format in which the library replaces `{z}` with the zoom level, `{x}` with the +horizontal tile index, and `{y}` with the vertical tile index as the map moves. + +```php +$urlTemplate = $api->maps()->tileUrlTemplate( + MapLayer::PRECIPITATION, +); +``` + +The returned URL retains the standard `{z}`, `{x}`, and `{y}` placeholders: + +```text +https://tile.openweathermap.org/map/precipitation_new/{z}/{x}/{y}.png?appid=... +``` + +OpenWeather lists mapping-library integrations in its +[Weather Maps documentation](https://openweathermap.org/api/weathermaps). +The returned format can be passed to +[Leaflet](https://leafletjs.com/reference.html#tilelayer), +[OpenLayers](https://openlayers.org/en/latest/apidoc/module-ol_source_XYZ-XYZ.html), +or a [MapLibre raster source](https://maplibre.org/maplibre-style-spec/sources/). +Like a specific tile URL, the template contains the API key and does not make an +HTTP request when generated. + +## Tile Coordinates + +X and Y are tile indexes, not longitude and latitude. Weather Maps uses the +same square-tile grid as common web mapping libraries: + +- Zoom 0 contains one tile representing the whole world: X 0, Y 0. +- Each additional zoom level doubles the number of tiles along each axis. +- X increases from west to east. +- Y increases from north to south. + +For example: + +| Zoom | Tile grid | Valid X and Y values | +|---:|---:|---:| +| 0 | 1 × 1 | 0 | +| 1 | 2 × 2 | 0–1 | +| 2 | 4 × 4 | 0–3 | +| 6 | 64 × 64 | 0–63 | + +Zoom must be zero or greater. X and Y start at 0, and their highest valid value +is one less than the number of tiles along that axis, as shown in the table. +Mapping libraries normally calculate these indexes from the displayed +geographic area; they should not be replaced directly with a location's +longitude and latitude. + +OpenWeather's attribution requirements depend on the applicable license. See +the [official FAQ](https://openweathermap.org/faq) for current guidance. diff --git a/docs/one-call.md b/docs/one-call.md new file mode 100644 index 0000000..f64e392 --- /dev/null +++ b/docs/one-call.md @@ -0,0 +1,314 @@ +# One Call 4.0 + +OpenWeather manages access and usage terms for One Call 4.0. Consult the +[official One Call documentation](https://openweathermap.org/api/one-call-4) +for current requirements before using these endpoints. + +## Current + +See OpenWeather's +[official One Call API 4.0 documentation](https://openweathermap.org/api/one-call-4#current) +for API details. + +Use `current()` with a latitude and longitude. + +```php +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); + +$current = $api->oneCall()->current( + latitude: 38.7223, + longitude: -9.1393, +); +``` + +Every response property may be absent or `null`. Coordinates and timezone +details describe the requested location. `dateTime()`, sunrise, and sunset use +UTC. + +```php +echo $current->coordinates()?->latitude(); +echo $current->coordinates()?->longitude(); +echo $current->timezone()?->identifier(); +echo $current->dateTime()?->format(DATE_ATOM); +echo $current->temperature(); + +foreach ($current->conditions() as $condition) { + echo $condition->description(); +} +``` + +## Minute Timeline + +See OpenWeather's +[official One Call 4.0 minute forecast documentation](https://openweathermap.org/api/one-call-4#min) +for API details. + +Use `minuteTimeline()` with a latitude and longitude to retrieve up to 60 +one-minute forecast periods. + +```php +$timeline = $api->oneCall()->minuteTimeline( + latitude: 38.7223, + longitude: -9.1393, +); +``` + +The response contains location details and forecast periods. Each period +provides its UTC date and time, precipitation, and any referenced alert IDs. + +```php +echo $timeline->coordinates()?->latitude(); +echo $timeline->timezone()?->identifier(); + +foreach ($timeline->periods() as $period) { + echo $period->dateTime()?->format(DATE_ATOM); + echo $period->precipitation(); + echo $period->precipitationWithUnit(); + + foreach ($period->alertIds() as $alertId) { + echo $alertId; + } +} +``` + +One-minute precipitation is always expressed in millimetres per hour, so its +unit is unaffected by the configured weather unit system. + +## Fifteen-minute Timeline + +See OpenWeather's +[official One Call 4.0 15-minute forecast documentation](https://openweathermap.org/api/one-call-4#15min) +for API details. + +Use `fifteenMinuteTimeline()` with a latitude and longitude to retrieve +15-minute forecast periods. When `startAt` is omitted, OpenWeather starts the +timeline at the current UTC time. + +```php +$timeline = $api->oneCall()->fifteenMinuteTimeline( + latitude: 38.7223, + longitude: -9.1393, + startAt: new DateTimeImmutable('1 day from now'), + count: 10, +); +``` + +Use `startAt` to select a future starting point and `count` to limit the number +of periods returned. Both are optional, and `count` must be positive when +provided. + +The response contains location details, up to 50 periods, and pagination when +OpenWeather provides it. + +```php +echo $timeline->coordinates()?->latitude(); +echo $timeline->timezone()?->identifier(); + +foreach ($timeline->periods() as $period) { + echo $period->dateTime()?->format(DATE_ATOM); + echo $period->temperature(); + echo $period->precipitationProbability(); +} +``` + +Precipitation probability follows the same getter pattern as other +measurements: + +```php +$period->precipitationProbability(); // 91.0 +$period->precipitationProbabilityUnit(); // Unit::PERCENT +$period->precipitationProbabilityWithUnit(); // '91 %' +``` + +## Hour Timeline + +See OpenWeather's +[official One Call 4.0 hourly forecast documentation](https://openweathermap.org/api/one-call-4#hourly) +for API details. + +Use `hourTimeline()` with a latitude and longitude to retrieve hourly +periods. When `startAt` is omitted, OpenWeather starts the timeline at the +current UTC time. + +```php +$timeline = $api->oneCall()->hourTimeline( + latitude: 38.7223, + longitude: -9.1393, +); +``` + +Use `startAt` to select a historical or future starting point and `count` to +limit the number of periods returned. `count` must be positive when provided, +and timeline availability depends on OpenWeather. + +```php +$timeline = $api->oneCall()->hourTimeline( + latitude: 38.7223, + longitude: -9.1393, + startAt: new DateTimeImmutable('2 days ago'), + count: 10, +); +``` + +The response contains up to 20 periods. `dateTime()` returns each period's UTC +date and time. + +```php +foreach ($timeline->periods() as $period) { + echo $period->dateTime()?->format(DATE_ATOM); + echo $period->temperature(); + echo $period->precipitationProbability(); + echo $period->rain()?->lastHour(); + echo $period->snow()?->lastHour(); +} +``` + +## Day Timeline + +See OpenWeather's +[official One Call 4.0 daily forecast documentation](https://openweathermap.org/api/one-call-4#daily) +for API details. + +Use `dayTimeline()` with a latitude and longitude to retrieve daily periods. +When `startAt` is omitted, OpenWeather starts the timeline at the current UTC +time. + +```php +$timeline = $api->oneCall()->dayTimeline( + latitude: 38.7223, + longitude: -9.1393, +); +``` + +Use `startAt` to select a historical or future starting point and `count` to +limit the number of periods returned. Both are optional, and `count` must be +positive when provided. + +```php +$timeline = $api->oneCall()->dayTimeline( + latitude: 38.7223, + longitude: -9.1393, + startAt: new DateTimeImmutable('2 days from now'), + count: 5, +); +``` + +The response contains up to 10 periods. Daily periods provide UTC dates, sun +and moon times, daily temperatures, weather measurements, conditions, +precipitation probability, rain, snow, and alert references. + +```php +foreach ($timeline->periods() as $period) { + echo $period->dateTime()?->format(DATE_ATOM); + echo $period->sunriseAt()?->format(DATE_ATOM); + echo $period->moonPhase(); + echo $period->temperature()?->day(); + echo $period->temperature()?->minimum(); + echo $period->temperature()?->maximum(); + echo $period->rain(); + echo $period->snow(); +} +``` + +OpenWeather does not define units for the daily `rain` and `snow` values, so +these getters return nullable floats without conversion. + +## Timeline Pagination + +The 15-minute, hour, and day timelines support pagination. + +```php +$timeline = $api->oneCall()->hourTimeline( + latitude: 38.7223, + longitude: -9.1393, +); + +$pagination = $timeline->pagination(); + +if ($pagination->hasPreviousPage()) { + $previousTimeline = $pagination->previousPage(); +} + +if ($pagination->hasNextPage()) { + $nextTimeline = $pagination->nextPage(); +} + +echo $pagination->previousPageUrl(); +echo $pagination->nextPageUrl(); +``` + +The availability checks and URL getters do not make another API request. +`previousPage()` and `nextPage()` request the corresponding page when its URL +is available and otherwise return `null`. The library does not fetch every page +automatically. Each page navigation sends a separate API request. + +## Units And Language + +Current and timeline requests use the client configuration by default. Use +`withUnits()` and `withLanguage()` to change those values for one request +chain. + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\Language; +use ProgrammatorDev\OpenWeatherMap\Enum\Units; + +$current = $api + ->oneCall() + ->withUnits(Units::IMPERIAL) + ->withLanguage(Language::PORTUGUESE) + ->current(38.7223, -9.1393); +``` + +Measurement getters return nullable values. Related methods provide the unit +and a formatted value. With the default metric configuration, for example: + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\Unit; + +$current->temperature(); // 24.34 +$current->temperatureUnit(); // Unit::CELSIUS +$current->temperatureWithUnit(); // '24.34 °C' +``` + +## Alert + +See OpenWeather's +[official One Call 4.0 weather alert documentation](https://openweathermap.org/api/one-call-4#alerts) +for API details. + +Current weather and timeline periods may provide alert IDs. Use `alert()` with +one of those IDs to retrieve the corresponding alert. + +```php +$current = $api->oneCall()->current( + latitude: 38.7223, + longitude: -9.1393, +); + +foreach ($current->alertIds() as $id) { + $alert = $api->oneCall()->alert($id); + + echo $alert->id(); + echo $alert->senderName(); + echo $alert->event(); + echo $alert->startsAt()?->format(DATE_ATOM); + echo $alert->endsAt()?->format(DATE_ATOM); + echo $alert->description('en-US'); + + foreach ($alert->descriptions() as $description) { + echo $description->languageCode(); + echo $description->text(); + } + + foreach ($alert->tags() as $tag) { + echo $tag; + } +} +``` + +Alerts provide sender and event information, start and end times, localized +descriptions, and tags. + +`description()` returns the first exact language-code match or `null`. diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..b420233 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,144 @@ +# Setup + +`OpenWeatherMap` uses PHP API SDK's `setup()` method for client-wide HTTP +configuration. Most applications can rely on PHP-HTTP discovery and use the +client without additional setup. Use the options below when the application +needs its own HTTP services or request behavior. + +```php +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); +``` + +The examples below use objects supplied by the application: +`$httpClient` implements PSR-18, `$cachePool` implements PSR-6, and `$logger` +implements PSR-3. + +Setup changes apply to later requests made by the client. Some methods do not +send an HTTP request and are therefore unaffected. Their API guides note this +where relevant. + +## HTTP Client + +PHP-HTTP discovery selects a compatible PSR-18 client and PSR-17 factories by +default. Packagist lists available +[PSR-18 client implementations](https://packagist.org/providers/psr/http-client-implementation) +and [PSR-17 factory implementations](https://packagist.org/providers/psr/http-factory-implementation). +Use `client()` to provide a specific PSR-18 client instead. + +```php +$api->setup()->client($httpClient); +``` + +`client()` returns a builder that can also receive custom request and stream +factories. + +See the official PHP API SDK +[HTTP client documentation](https://github.com/programmatordev/php-api-sdk/blob/main/docs/09-http-client.md) +for all client and factory options. + +## Cache + +Use `cache()` with a PSR-6 cache pool to cache supported HTTP responses. The +default TTL sets the cache lifetime when a response does not provide one. GET +and HEAD requests are cacheable by default. Packagist lists +available [PSR-6 cache implementations](https://packagist.org/providers/psr/cache-implementation). + +```php +$api + ->setup() + ->cache($cachePool) + ->defaultTtl(300); +``` + +Cache configuration is client-wide. After configuring a pool, `withCache()` can +change cache behavior for one request chain without changing the client-wide +settings. + +```php +use ProgrammatorDev\Api\Builder\CacheBuilder; + +$current = $api + ->weather() + ->withCache(fn (CacheBuilder $cache) => $cache->defaultTtl(60)) + ->current(latitude: 38.7223, longitude: -9.1393); +``` + +See the official PHP API SDK +[cache documentation](https://github.com/programmatordev/php-api-sdk/blob/main/docs/10-cache.md) +for all global and request-local options. + +## Logging + +Use `logger()` with a PSR-3 logger. Packagist lists available +[PSR-3 logger implementations](https://packagist.org/providers/psr/log-implementation). + +```php +$api->setup()->logger($logger); +``` + +As with other HTTP logging, ensure the application's logging configuration does +not persist credentials or other sensitive request data. + +See the official PHP API SDK +[logging documentation](https://github.com/programmatordev/php-api-sdk/blob/main/docs/11-logging.md) +for formatter and cache-logging details. + +## Plugins + +Use `plugins()` to add HTTPlug middleware. For example, a retry plugin can +retry failed requests or retryable responses. + +```php +use Http\Client\Common\Plugin\RetryPlugin; + +$retryPlugin = new RetryPlugin([ + 'retries' => 2, +]); + +$api->setup()->plugins()->add($retryPlugin, priority: 25); +``` + +Retries may send additional OpenWeather requests. +Plugin priority controls middleware order. Priority `25` places this retry +plugin after authentication and before cache; consult the linked documentation +when choosing priorities for other plugins. + +See the official PHP API SDK +[plugin documentation](https://github.com/programmatordev/php-api-sdk/blob/main/docs/12-plugins.md) +for middleware ordering and priority guidance. + +## Hooks + +Hooks run immediately before a request is sent or after its response is +received. They can inspect the API context and optionally return a modified +PSR-7 request or response. + +```php +use ProgrammatorDev\Api\Context\RequestContext; + +$api->setup()->hooks()->beforeRequest( + function (RequestContext $context) { + $request = $context->request(); + + // Modify the request here. + + return $request; + }, +); +``` + +Return a PSR-7 request to use it for the current request, or return `null` to +keep the original request. Response hooks follow the same pattern with a PSR-7 +response. + +See the official PHP API SDK +[hook documentation](https://github.com/programmatordev/php-api-sdk/blob/main/docs/13-hooks.md) +for hook return values, context, ordering, and priorities. + +## Complete Setup Reference + +See the complete +[PHP API SDK documentation](https://github.com/programmatordev/php-api-sdk/tree/main/docs) +for every setup method, builder, and extension point. diff --git a/docs/stations.md b/docs/stations.md new file mode 100644 index 0000000..a4b76b4 --- /dev/null +++ b/docs/stations.md @@ -0,0 +1,244 @@ +# Stations + +The Weather Stations API lets you register and manage personal weather stations +associated with your OpenWeather account. + +See the +[official Weather Stations documentation](https://openweathermap.org/api/stations) +for API details. + +## Create A Station + +Use `create()` to register a station with its external ID, name, coordinates, +and altitude. + +```php +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); + +$station = $api->stations()->create( + externalId: 'home-station', + name: 'Home Weather Station', + latitude: 38.7223, + longitude: -9.1393, + altitude: 100, +); +``` + +The method returns the created `Station`. OpenWeather assigns its internal ID, +rank, user ID, source type, and creation and update times. + +## Update A Station + +Use `update()` with the internal station ID and the complete editable station +information. + +```php +$station = $api->stations()->update( + id: 'station-id', + externalId: 'home-station', + name: 'Home Weather Station', + latitude: 38.7223, + longitude: -9.1393, + altitude: 110, +); +``` + +OpenWeather does not support partial station updates, so all five station values +are required. + +## List Stations + +Use `all()` to retrieve every station associated with the authenticated API +key. + +```php +$stations = $api->stations()->all(); +``` + +The method returns an array of `Station` entities and returns an empty array +when the account has no registered stations. + +```php +foreach ($stations as $station) { + echo $station->id(); + echo $station->name(); + echo $station->externalId(); + echo $station->latitude(); + echo $station->longitude(); + echo $station->altitude(); + echo $station->rank(); + echo $station->createdAt()->format(DATE_ATOM); + echo $station->updatedAt()->format(DATE_ATOM); +} +``` + +The main station properties are required because they describe the station +registered with OpenWeather. Creation and update times are returned as UTC +`DateTimeImmutable` values. The create response may also include `userId()` and +`sourceType()`; other station responses omit them. + +## Find A Station + +Use `find()` with the internal station ID returned by OpenWeather. + +```php +$station = $api->stations()->find('station-id'); + +echo $station->name(); +``` + +## Delete A Station + +> **Warning:** Deleting a station also permanently deletes its associated +> measurements. + +Use `delete()` with the internal station ID returned by OpenWeather. + +```php +$api->stations()->delete('station-id'); +``` + +## Submit Measurements + +Create a `Measurement` with the observation time and available readings, then +submit it for a station. + +```php +use ProgrammatorDev\OpenWeatherMap\Request\Stations\Measurement; + +$measurement = new Measurement( + dateTime: new DateTimeImmutable('now'), + temperature: 19.5, + windSpeed: 2.4, + windDirection: 180, + pressure: 1012, + humidity: 68, +); + +$api->stations()->submitMeasurement( + stationId: $station->id(), + measurement: $measurement, +); +``` + +Optional METAR cloud and weather observations can be included in a +measurement: + +```php +use ProgrammatorDev\OpenWeatherMap\Request\Stations\CloudLayer; +use ProgrammatorDev\OpenWeatherMap\Request\Stations\Measurement; +use ProgrammatorDev\OpenWeatherMap\Request\Stations\Weather; + +$measurement = new Measurement( + dateTime: new DateTimeImmutable('now'), + clouds: [ + new CloudLayer( + distance: 1200, + condition: 'BKN', + cumulus: 'CB', + ), + ], + weather: [ + new Weather( + precipitation: 'RA', + intensity: '-', + ), + ], +); +``` + +METAR visibility, cloud, and weather values appear to be submission-only in +this API. OpenWeather's aggregate response does not include them, and the +documentation does not provide an endpoint for retrieving the original +measurement payload. + +Use `submitMeasurements()` to send several observations in one request. + +```php +$api->stations()->submitMeasurements( + stationId: $station->id(), + measurements: [ + $firstMeasurement, + $secondMeasurement, + ], +); +``` + +Measurement units are fixed by the Weather Stations API: Celsius for +temperatures, metres per second for wind, degrees for wind direction, +hectopascals for pressure, percent for humidity, millimetres for rain and snow, +kilometres for visibility, and metres for cloud-layer distance. The API-wide +units configuration does not alter submitted values. + +Visibility prefixes, cloud conditions, cumulus types, and weather values use +standard METAR codes. See the +[NOAA METAR reference](https://aviationweather.gov/help/data/#metar) for their +meanings. + +OpenWeather documents `visibilityPrefix` as a compass-direction string, but its +live API rejected a documented string value during verification. Leaving it +`null` avoids this upstream mismatch. + +Each `CloudLayer` represents one entry in OpenWeather's `clouds` array. Its +distance, METAR cloud condition, and cumulus type are optional, but at least one +value must be provided. + +Each `Weather` represents one entry in the `weather` array. It accepts the +available METAR precipitation, descriptor, intensity, proximity, obscuration, +and other codes. At least one value must be provided, and codes are kept as +strings so additional values accepted by OpenWeather are not restricted. + +## Retrieve Measurements + +Use `measurements()` to retrieve measurements aggregated by minute, hour, or +day for a station and time range. + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\AggregationInterval; + +$measurements = $api->stations()->measurements( + stationId: $station->id(), + interval: AggregationInterval::HOUR, + startAt: new DateTimeImmutable('2 days ago'), + endAt: new DateTimeImmutable('now'), + limit: 100, +); +``` + +> **Processing delay:** OpenWeather processes submitted measurements in the +> background. They may take more than 24 hours to appear, and OpenWeather does +> not document how long processing should take. + +The method returns an array of `MeasurementAggregate` entities and returns an +empty array when no aggregates are available for the requested interval. Each +entity summarizes readings for one requested minute, hour, or day interval and +identifies the timestamp and station returned by OpenWeather. + +```php +foreach ($measurements as $measurement) { + echo $measurement->interval()?->value; + echo $measurement->dateTime()?->format(DATE_ATOM); + echo $measurement->stationId(); + + echo $measurement->temperature()?->average(); // 20.5 + echo $measurement->temperature()?->averageWithUnit(); // 20.5 °C + echo $measurement->humidity()?->averageWithUnit(); // 64 % + echo $measurement->wind()?->speedWithUnit(); // 3.08 m/s + echo $measurement->pressure()?->averageWithUnit(); // 1013 hPa + echo $measurement->precipitation()?->rainWithUnit(); // 0.6 mm +} +``` + +Temperature and pressure aggregates expose `minimum()`, `maximum()`, +`average()`, and `weight()`. Humidity exposes `average()` and `weight()`. Wind +exposes `direction()` and `speed()`, while precipitation exposes `rain()` and +`snow()`. OpenWeather returns `weight` without documenting its meaning, so +`weight()` exposes the nullable integer unchanged. Measurement properties and +nested structures are nullable because OpenWeather may omit data that was +unavailable for an aggregation interval. + +The endpoint returns aggregates rather than the original submitted +measurements. Submitted visibility, cloud layers, METAR weather descriptions, +and other raw fields are not included in the documented aggregate response. diff --git a/docs/weather.md b/docs/weather.md new file mode 100644 index 0000000..855d870 --- /dev/null +++ b/docs/weather.md @@ -0,0 +1,158 @@ +# Weather + +## Current + +See the +[official Current Weather API documentation](https://openweathermap.org/api/current) +for API details. + +Use `current()` with a latitude and longitude. + +```php +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); + +$current = $api->weather()->current( + latitude: 38.7223, + longitude: -9.1393, +); +``` + +`current()` returns a `Current` entity. Every property may be absent or +explicitly `null`; missing or `null` condition lists become empty arrays. +Coordinates are available through `coordinates()`. + +```php +echo $current->name(); +echo $current->coordinates()?->latitude(); +echo $current->coordinates()?->longitude(); +echo $current->temperature(); +echo $current->feelsLikeTemperature(); +echo $current->minimumTemperature(); +echo $current->maximumTemperature(); +echo $current->pressure(); +echo $current->humidity(); +echo $current->visibility(); +``` + +`minimumTemperature()` and `maximumTemperature()` are OpenWeather's optional +minimum and maximum temperatures for the city at the current moment. They are +mainly useful for large cities and often match `temperature()`. They are not +the day's forecast low and high. + +Conditions, wind, and clouds are exposed as nested entities. A condition keeps +the raw OpenWeather icon code and provides its absolute image URL. A response +can contain multiple conditions; OpenWeather defines the first as the primary +condition. + +```php +foreach ($current->conditions() as $condition) { + echo $condition->group(); + echo $condition->description(); + echo $condition->icon(); + echo $condition->iconUrl(); +} + +echo $current->wind()?->speed(); +echo $current->wind()?->direction(); +echo $current->wind()?->gust(); +echo $current->clouds()?->coverage(); +``` + +Rain and snow are conditional. When present, `lastHour()` returns the +precipitation reported for the preceding hour in millimetres per hour. + +```php +echo $current->rain()?->lastHour(); +echo $current->snow()?->lastHour(); +``` + +Observation, sunrise, and sunset timestamps are nullable UTC +`DateTimeImmutable` values. `timezoneOffset()` provides the location's offset +from UTC in seconds. + +## Forecast + +See the +[official forecast documentation](https://openweathermap.org/api/forecast5) +for API details. + +Use `forecast()` with a latitude and longitude to retrieve up to five days of +weather forecasts, with one period every three hours. The optional `count` +limits the number of periods returned and must be positive. + +```php +$forecast = $api->weather()->forecast( + latitude: 38.7223, + longitude: -9.1393, + count: 8, +); +``` + +`forecast()` returns a `Forecast` entity containing its periods and city +metadata. Missing or `null` period lists become empty arrays. + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\PartOfDay; + +foreach ($forecast->periods() as $period) { + echo $period->dateTime()?->format(DATE_ATOM); + echo $period->temperature(); + echo $period->precipitationProbability(); + echo $period->wind()?->speed(); + echo $period->rain()?->lastThreeHours(); + echo $period->snow()?->lastThreeHours(); + + if ($period->partOfDay() === PartOfDay::DAY) { + // This period occurs during daytime at the forecast location. + } +} + +echo $forecast->city()?->name(); +echo $forecast->city()?->coordinates()?->latitude(); +echo $forecast->city()?->coordinates()?->longitude(); +echo $forecast->city()?->timezoneOffset(); +``` + +Precipitation probability follows the same getter pattern as other +measurements: + +```php +$period->precipitationProbability(); // 60.0 +$period->precipitationProbabilityUnit(); // Unit::PERCENT +$period->precipitationProbabilityWithUnit(); // '60 %' +``` + +Forecast, sunrise, and sunset timestamps are nullable UTC +`DateTimeImmutable` values. The city timezone offset remains separate. + +## Units And Language + +Weather requests use the API configuration by default. Configure units and +language for a request with `withUnits()` and `withLanguage()`. + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\Language; +use ProgrammatorDev\OpenWeatherMap\Enum\Units; + +$current = $api + ->weather() + ->withUnits(Units::METRIC) + ->withLanguage(Language::PORTUGUESE) + ->current( + latitude: 38.7223, + longitude: -9.1393, + ); +``` + +Measurement getters remain numeric. Companion `Unit` and `WithUnit` methods +provide the unit and a formatted value. For example, when a metric response +contains a temperature of `22.55`: + +```php +$current->temperature(); // 22.55 +$current->temperatureUnit(); // Unit::CELSIUS +$current->temperatureUnit()->symbol(); // '°C' +$current->temperatureWithUnit(); // '22.55 °C' +``` diff --git a/src/Entity/AirPollution/AirPollution.php b/src/Entity/AirPollution/AirPollution.php deleted file mode 100644 index 9e90556..0000000 --- a/src/Entity/AirPollution/AirPollution.php +++ /dev/null @@ -1,22 +0,0 @@ -coordinate = new Coordinate($data['coord']); - } - - public function getCoordinate(): Coordinate - { - return $this->coordinate; - } -} \ No newline at end of file diff --git a/src/Entity/AirPollution/AirPollutionCollection.php b/src/Entity/AirPollution/AirPollutionCollection.php deleted file mode 100644 index 192834a..0000000 --- a/src/Entity/AirPollution/AirPollutionCollection.php +++ /dev/null @@ -1,38 +0,0 @@ -numResults = count($data['list']); - $this->coordinate = new Coordinate($data['coord']); - $this->data = EntityHelper::createEntityList(AirPollutionData::class, $data['list']); - } - - public function getNumResults(): int - { - return $this->numResults; - } - - public function getCoordinate(): Coordinate - { - return $this->coordinate; - } - - public function getData(): array - { - return $this->data; - } -} \ No newline at end of file diff --git a/src/Entity/AirPollution/AirPollutionData.php b/src/Entity/AirPollution/AirPollutionData.php deleted file mode 100644 index 4b85346..0000000 --- a/src/Entity/AirPollution/AirPollutionData.php +++ /dev/null @@ -1,93 +0,0 @@ -dateTime = \DateTimeImmutable::createFromFormat('U', $data['dt']); - $this->airQuality = new AirQuality($data['main']); - $this->carbonMonoxide = $data['components']['co']; - $this->nitrogenMonoxide = $data['components']['no']; - $this->nitrogenDioxide = $data['components']['no2']; - $this->ozone = $data['components']['o3']; - $this->sulphurDioxide = $data['components']['so2']; - $this->fineParticulateMatter = $data['components']['pm2_5']; - $this->coarseParticulateMatter = $data['components']['pm10']; - $this->ammonia = $data['components']['nh3']; - } - - /** - * DateTime in UTC - */ - public function getDateTime(): \DateTimeImmutable - { - return $this->dateTime; - } - - public function getAirQuality(): AirQuality - { - return $this->airQuality; - } - - public function getCarbonMonoxide(): float - { - return $this->carbonMonoxide; - } - - public function getNitrogenMonoxide(): float - { - return $this->nitrogenMonoxide; - } - - public function getNitrogenDioxide(): float - { - return $this->nitrogenDioxide; - } - - public function getOzone(): float - { - return $this->ozone; - } - - public function getSulphurDioxide(): float - { - return $this->sulphurDioxide; - } - - public function getFineParticulateMatter(): float - { - return $this->fineParticulateMatter; - } - - public function getCoarseParticulateMatter(): float - { - return $this->coarseParticulateMatter; - } - - public function getAmmonia(): float - { - return $this->ammonia; - } -} \ No newline at end of file diff --git a/src/Entity/AirPollution/AirQuality.php b/src/Entity/AirPollution/AirQuality.php index 8e3c50e..db525b9 100644 --- a/src/Entity/AirPollution/AirQuality.php +++ b/src/Entity/AirPollution/AirQuality.php @@ -2,38 +2,51 @@ namespace ProgrammatorDev\OpenWeatherMap\Entity\AirPollution; -class AirQuality -{ - private int $index; +use ProgrammatorDev\Api\Context\Context; +use ProgrammatorDev\Api\Contract\EntityInterface; +use ProgrammatorDev\OpenWeatherMap\Enum\AirQualityIndex; +use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; +use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; - private string $qualitativeName; +final class AirQuality implements EntityInterface +{ + private function __construct( + private readonly ?AirQualityIndex $airQualityIndex, + private readonly ?Components $components, + ) {} - public function __construct(array $data) + public static function fromArray(array $data, ?Context $context = null): static { - $this->index = $data['aqi']; - $this->qualitativeName = $this->findQualitativeName($this->index); - } + $reader = PayloadReader::from($data, self::class); + $airQualityIndex = $reader->nullableInt('main.aqi'); - public function getIndex(): int - { - return $this->index; + if ($airQualityIndex !== null) { + $airQualityIndex = AirQualityIndex::tryFrom($airQualityIndex) + ?? throw HydrationException::invalidValue( + self::class, + 'main.aqi', + 'an integer from 1 through 5', + $airQualityIndex, + ); + } + + $components = $reader->nullableArray('components'); + + return new self( + airQualityIndex: $airQualityIndex, + components: $components === null + ? null + : Components::fromArray($components, $context), + ); } - public function getQualitativeName(): string + public function airQualityIndex(): ?AirQualityIndex { - return $this->qualitativeName; + return $this->airQualityIndex; } - private function findQualitativeName(int $index): string + public function components(): ?Components { - // levels based on https://openweathermap.org/api/air-pollution - return match ($index) { - 1 => 'Good', - 2 => 'Fair', - 3 => 'Moderate', - 4 => 'Poor', - 5 => 'Very Poor', - default => 'Undefined' - }; + return $this->components; } -} \ No newline at end of file +} diff --git a/src/Entity/AirPollution/Components.php b/src/Entity/AirPollution/Components.php new file mode 100644 index 0000000..ee67a94 --- /dev/null +++ b/src/Entity/AirPollution/Components.php @@ -0,0 +1,201 @@ +nullableFloat('co'), + nitrogenMonoxide: $reader->nullableFloat('no'), + nitrogenDioxide: $reader->nullableFloat('no2'), + ozone: $reader->nullableFloat('o3'), + sulphurDioxide: $reader->nullableFloat('so2'), + fineParticulateMatter: $reader->nullableFloat('pm2_5'), + coarseParticulateMatter: $reader->nullableFloat('pm10'), + ammonia: $reader->nullableFloat('nh3'), + ); + } + + /** + * CO (carbon monoxide) concentration. + */ + public function carbonMonoxide(): ?float + { + return $this->carbonMonoxide; + } + + public function carbonMonoxideUnit(): Unit + { + return $this->concentrationUnit(); + } + + public function carbonMonoxideWithUnit(): ?string + { + return $this->format($this->carbonMonoxide); + } + + /** + * NO (nitrogen monoxide) concentration. + */ + public function nitrogenMonoxide(): ?float + { + return $this->nitrogenMonoxide; + } + + public function nitrogenMonoxideUnit(): Unit + { + return $this->concentrationUnit(); + } + + public function nitrogenMonoxideWithUnit(): ?string + { + return $this->format($this->nitrogenMonoxide); + } + + /** + * NO2 (nitrogen dioxide) concentration. + */ + public function nitrogenDioxide(): ?float + { + return $this->nitrogenDioxide; + } + + public function nitrogenDioxideUnit(): Unit + { + return $this->concentrationUnit(); + } + + public function nitrogenDioxideWithUnit(): ?string + { + return $this->format($this->nitrogenDioxide); + } + + /** + * O3 (ozone) concentration. + */ + public function ozone(): ?float + { + return $this->ozone; + } + + public function ozoneUnit(): Unit + { + return $this->concentrationUnit(); + } + + public function ozoneWithUnit(): ?string + { + return $this->format($this->ozone); + } + + /** + * SO2 (sulphur dioxide) concentration. + */ + public function sulphurDioxide(): ?float + { + return $this->sulphurDioxide; + } + + public function sulphurDioxideUnit(): Unit + { + return $this->concentrationUnit(); + } + + public function sulphurDioxideWithUnit(): ?string + { + return $this->format($this->sulphurDioxide); + } + + /** + * PM2.5 (fine particulate matter) concentration. + */ + public function fineParticulateMatter(): ?float + { + return $this->fineParticulateMatter; + } + + public function fineParticulateMatterUnit(): Unit + { + return $this->concentrationUnit(); + } + + public function fineParticulateMatterWithUnit(): ?string + { + return $this->format($this->fineParticulateMatter); + } + + /** + * PM10 (coarse particulate matter) concentration. + */ + public function coarseParticulateMatter(): ?float + { + return $this->coarseParticulateMatter; + } + + public function coarseParticulateMatterUnit(): Unit + { + return $this->concentrationUnit(); + } + + public function coarseParticulateMatterWithUnit(): ?string + { + return $this->format($this->coarseParticulateMatter); + } + + /** + * NH3 (ammonia) concentration. + */ + public function ammonia(): ?float + { + return $this->ammonia; + } + + public function ammoniaUnit(): Unit + { + return $this->concentrationUnit(); + } + + public function ammoniaWithUnit(): ?string + { + return $this->format($this->ammonia); + } + + private function concentrationUnit(): Unit + { + return Unit::MICROGRAMS_PER_CUBIC_METER; + } + + private function format(?float $concentration): ?string + { + return MeasurementFormatter::format( + $concentration, + $this->concentrationUnit(), + ); + } +} diff --git a/src/Entity/AirPollution/Concern/HasAirQuality.php b/src/Entity/AirPollution/Concern/HasAirQuality.php new file mode 100644 index 0000000..f8e409e --- /dev/null +++ b/src/Entity/AirPollution/Concern/HasAirQuality.php @@ -0,0 +1,19 @@ +airQuality->airQualityIndex(); + } + + public function components(): ?Components + { + return $this->airQuality->components(); + } +} diff --git a/src/Entity/AirPollution/Current.php b/src/Entity/AirPollution/Current.php new file mode 100644 index 0000000..dec36d9 --- /dev/null +++ b/src/Entity/AirPollution/Current.php @@ -0,0 +1,47 @@ +nullableArray('coord'); + + // The current endpoint wraps its single observation in a list. + $observation = $reader->nullableArray('list.0') ?? []; + + return new self( + coordinates: $coordinates === null + ? null + : Coordinates::fromArray($coordinates, $context), + dateTime: $reader->nullableTimestamp('list.0.dt'), + airQuality: AirQuality::fromArray($observation, $context), + ); + } + + public function coordinates(): ?Coordinates + { + return $this->coordinates; + } + + public function dateTime(): ?\DateTimeImmutable + { + return $this->dateTime; + } +} diff --git a/src/Entity/AirPollution/Forecast.php b/src/Entity/AirPollution/Forecast.php new file mode 100644 index 0000000..9c0c412 --- /dev/null +++ b/src/Entity/AirPollution/Forecast.php @@ -0,0 +1,61 @@ + $periods + */ + private function __construct( + private readonly ?Coordinates $coordinates, + private readonly array $periods, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $coordinates = $reader->nullableArray('coord'); + $periods = []; + + foreach ($reader->nullableArray('list') ?? [] as $index => $period) { + if (!is_array($period)) { + throw HydrationException::invalidType( + self::class, + sprintf('list.%s', $index), + 'array', + $period, + ); + } + + $periods[] = Period::fromArray($period, $context); + } + + return new self( + coordinates: $coordinates === null + ? null + : Coordinates::fromArray($coordinates, $context), + periods: $periods, + ); + } + + public function coordinates(): ?Coordinates + { + return $this->coordinates; + } + + /** + * @return list + */ + public function periods(): array + { + return $this->periods; + } +} diff --git a/src/Entity/AirPollution/Forecast/Period.php b/src/Entity/AirPollution/Forecast/Period.php new file mode 100644 index 0000000..6d1fe82 --- /dev/null +++ b/src/Entity/AirPollution/Forecast/Period.php @@ -0,0 +1,34 @@ +nullableTimestamp('dt'), + airQuality: AirQuality::fromArray($data, $context), + ); + } + + public function dateTime(): ?\DateTimeImmutable + { + return $this->dateTime; + } +} diff --git a/src/Entity/AirPollution/History.php b/src/Entity/AirPollution/History.php new file mode 100644 index 0000000..e7c5259 --- /dev/null +++ b/src/Entity/AirPollution/History.php @@ -0,0 +1,61 @@ + $periods + */ + private function __construct( + private readonly ?Coordinates $coordinates, + private readonly array $periods, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $coordinates = $reader->nullableArray('coord'); + $periods = []; + + foreach ($reader->nullableArray('list') ?? [] as $index => $period) { + if (!is_array($period)) { + throw HydrationException::invalidType( + self::class, + sprintf('list.%s', $index), + 'array', + $period, + ); + } + + $periods[] = Period::fromArray($period, $context); + } + + return new self( + coordinates: $coordinates === null + ? null + : Coordinates::fromArray($coordinates, $context), + periods: $periods, + ); + } + + public function coordinates(): ?Coordinates + { + return $this->coordinates; + } + + /** + * @return list + */ + public function periods(): array + { + return $this->periods; + } +} diff --git a/src/Entity/AirPollution/History/Period.php b/src/Entity/AirPollution/History/Period.php new file mode 100644 index 0000000..b3af983 --- /dev/null +++ b/src/Entity/AirPollution/History/Period.php @@ -0,0 +1,34 @@ +nullableTimestamp('dt'), + airQuality: AirQuality::fromArray($data, $context), + ); + } + + public function dateTime(): ?\DateTimeImmutable + { + return $this->dateTime; + } +} diff --git a/src/Entity/Assistant/Answer.php b/src/Entity/Assistant/Answer.php deleted file mode 100644 index 28396e7..0000000 --- a/src/Entity/Assistant/Answer.php +++ /dev/null @@ -1,40 +0,0 @@ -answer = $data['answer']; - $this->sessionId = $data['session_id']; - - if (!empty($data['data'])) { - $this->data = EntityHelper::createEntityKeyList(WeatherData::class, $data['data']); - } - } - - public function getAnswer(): string - { - return $this->answer; - } - - public function getSessionId(): string - { - return $this->sessionId; - } - - public function getData(): array - { - return $this->data; - } -} \ No newline at end of file diff --git a/src/Entity/Assistant/WeatherData.php b/src/Entity/Assistant/WeatherData.php deleted file mode 100644 index 97020ea..0000000 --- a/src/Entity/Assistant/WeatherData.php +++ /dev/null @@ -1,62 +0,0 @@ -locationName = $locationName; - $this->temperature = $data['temp']; - $this->temperatureFeelsLike = $data['feels_like']; - $this->visibility = $data['visibility'] ?? null; - $this->sunriseAt = \DateTimeImmutable::createFromFormat('U', $data['sunrise']); - $this->sunsetAt = \DateTimeImmutable::createFromFormat('U', $data['sunset']); - } - - public function getLocationName(): string - { - return $this->locationName; - } - - public function getTemperature(): float - { - return $this->temperature; - } - - public function getTemperatureFeelsLike(): float - { - return $this->temperatureFeelsLike; - } - - public function getVisibility(): ?int - { - return $this->visibility; - } - - public function getSunriseAt(): \DateTimeImmutable - { - return $this->sunriseAt; - } - - public function getSunsetAt(): \DateTimeImmutable - { - return $this->sunsetAt; - } -} \ No newline at end of file diff --git a/src/Entity/BaseWeather.php b/src/Entity/BaseWeather.php deleted file mode 100644 index a7e897f..0000000 --- a/src/Entity/BaseWeather.php +++ /dev/null @@ -1,102 +0,0 @@ -dateTime = \DateTimeImmutable::createFromFormat('U', $data['dt']); - $this->atmosphericPressure = $data['pressure']; - $this->humidity = $data['humidity']; - $this->dewPoint = $data['dew_point']; - $this->ultraVioletIndex = $data['uvi'] ?? null; - $this->cloudiness = $data['clouds']; - - $this->wind = new Wind([ - 'speed' => $data['wind_speed'], - 'deg' => $data['wind_deg'], - 'gust' => $data['wind_gust'] ?? null - ]); - - $this->conditions = EntityHelper::createEntityList(Condition::class, $data['weather']); - $this->rainVolume = $data['rain']['1h'] ?? $data['rain']['3h'] ?? $data['rain'] ?? null; - $this->snowVolume = $data['snow']['1h'] ?? $data['snow']['3h'] ?? $data['snow'] ?? null; - } - - /** - * DateTime in UTC - */ - public function getDateTime(): \DateTimeImmutable - { - return $this->dateTime; - } - - public function getAtmosphericPressure(): int - { - return $this->atmosphericPressure; - } - - public function getHumidity(): int - { - return $this->humidity; - } - - public function getDewPoint(): float - { - return $this->dewPoint; - } - - public function getUltraVioletIndex(): ?float - { - return $this->ultraVioletIndex; - } - - public function getCloudiness(): int - { - return $this->cloudiness; - } - - public function getWind(): Wind - { - return $this->wind; - } - - public function getConditions(): array - { - return $this->conditions; - } - - public function getRainVolume(): ?float - { - return $this->rainVolume; - } - - public function getSnowVolume(): ?float - { - return $this->snowVolume; - } -} \ No newline at end of file diff --git a/src/Entity/Condition.php b/src/Entity/Condition.php deleted file mode 100644 index 8f4e9d5..0000000 --- a/src/Entity/Condition.php +++ /dev/null @@ -1,92 +0,0 @@ -id = $data['id']; - $this->name = $data['main']; - $this->description = $data['description']; - $this->icon = new Icon($data); - $this->systemName = $this->findSystemName($this->id); - } - - public function getId(): int - { - return $this->id; - } - - public function getName(): string - { - return $this->name; - } - - public function getDescription(): string - { - return $this->description; - } - - public function getIcon(): Icon - { - return $this->icon; - } - - public function getSystemName(): string - { - return $this->systemName; - } - - /** - * Find group based on this table https://openweathermap.org/weather-conditions - */ - private function findSystemName(int $id): string - { - return match ($id) { - 200, 201, 202, 210, 211, 212, 221, 230, 231, 232 => self::THUNDERSTORM, - 300, 301, 302, 310, 311, 312, 313, 314, 321 => self::DRIZZLE, - 500, 501, 502, 503, 504, 511, 520, 521, 522, 531 => self::RAIN, - 600, 601, 602, 611, 612, 613, 615, 616, 620, 621, 622 => self::SNOW, - 701 => self::MIST, - 711 => self::SMOKE, - 721 => self::HAZE, - 731, 761 => self::DUST, - 741 => self::FOG, - 751 => self::SAND, - 762 => self::ASH, - 771 => self::SQUALL, - 781 => self::TORNADO, - 800 => self::CLEAR, - 801, 802, 803, 804 => self::CLOUDS, - default => self::UNDEFINED - }; - } -} \ No newline at end of file diff --git a/src/Entity/Coordinate.php b/src/Entity/Coordinate.php deleted file mode 100644 index 3156c9a..0000000 --- a/src/Entity/Coordinate.php +++ /dev/null @@ -1,26 +0,0 @@ -latitude = $data['lat']; - $this->longitude = $data['lon']; - } - - public function getLatitude(): float - { - return $this->latitude; - } - - public function getLongitude(): float - { - return $this->longitude; - } -} \ No newline at end of file diff --git a/src/Entity/Coordinates.php b/src/Entity/Coordinates.php new file mode 100644 index 0000000..b0f9262 --- /dev/null +++ b/src/Entity/Coordinates.php @@ -0,0 +1,35 @@ +nullableFloat('lat'), + longitude: $reader->nullableFloat('lon'), + ); + } + + public function latitude(): ?float + { + return $this->latitude; + } + + public function longitude(): ?float + { + return $this->longitude; + } +} diff --git a/src/Entity/Geocoding/Location.php b/src/Entity/Geocoding/Location.php new file mode 100644 index 0000000..8e245fc --- /dev/null +++ b/src/Entity/Geocoding/Location.php @@ -0,0 +1,87 @@ + $localNames + */ + private function __construct( + private readonly ?string $name, + private readonly array $localNames, + private readonly ?float $latitude, + private readonly ?float $longitude, + private readonly ?string $countryCode, + private readonly ?string $state, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $localNames = $reader->nullableArray('local_names') ?? []; + + foreach ($localNames as $language => $name) { + if (!is_string($name)) { + throw HydrationException::invalidType( + self::class, + sprintf('local_names.%s', $language), + 'string', + $name, + ); + } + } + + return new self( + name: $reader->nullableString('name'), + localNames: $localNames, + latitude: $reader->nullableFloat('lat'), + longitude: $reader->nullableFloat('lon'), + countryCode: $reader->nullableString('country'), + state: $reader->nullableString('state'), + ); + } + + public function name(): ?string + { + return $this->name; + } + + /** + * @return array + */ + public function localNames(): array + { + return $this->localNames; + } + + public function localName(string $languageCode): ?string + { + return $this->localNames[$languageCode] ?? null; + } + + public function latitude(): ?float + { + return $this->latitude; + } + + public function longitude(): ?float + { + return $this->longitude; + } + + public function countryCode(): ?string + { + return $this->countryCode; + } + + public function state(): ?string + { + return $this->state; + } +} diff --git a/src/Entity/Geocoding/PostalLocation.php b/src/Entity/Geocoding/PostalLocation.php new file mode 100644 index 0000000..e83e569 --- /dev/null +++ b/src/Entity/Geocoding/PostalLocation.php @@ -0,0 +1,55 @@ +nullableString('zip'), + name: $reader->nullableString('name'), + latitude: $reader->nullableFloat('lat'), + longitude: $reader->nullableFloat('lon'), + countryCode: $reader->nullableString('country'), + ); + } + + public function postalCode(): ?string + { + return $this->postalCode; + } + + public function name(): ?string + { + return $this->name; + } + + public function latitude(): ?float + { + return $this->latitude; + } + + public function longitude(): ?float + { + return $this->longitude; + } + + public function countryCode(): ?string + { + return $this->countryCode; + } +} diff --git a/src/Entity/Geocoding/ZipLocation.php b/src/Entity/Geocoding/ZipLocation.php deleted file mode 100644 index 5bbe563..0000000 --- a/src/Entity/Geocoding/ZipLocation.php +++ /dev/null @@ -1,48 +0,0 @@ -zipCode = $data['zip']; - $this->name = $data['name']; - $this->countryCode = $data['country']; - - $this->coordinate = new Coordinate([ - 'lat' => $data['lat'], - 'lon' => $data['lon'] - ]); - } - - public function getZipCode(): string - { - return $this->zipCode; - } - - public function getName(): string - { - return $this->name; - } - - public function getCountryCode(): string - { - return $this->countryCode; - } - - public function getCoordinate(): Coordinate - { - return $this->coordinate; - } -} \ No newline at end of file diff --git a/src/Entity/Icon.php b/src/Entity/Icon.php deleted file mode 100644 index 903399a..0000000 --- a/src/Entity/Icon.php +++ /dev/null @@ -1,26 +0,0 @@ -id = $data['icon']; - $this->url = sprintf('https://openweathermap.org/img/wn/%s@4x.png', $this->id); - } - - public function getId(): string - { - return $this->id; - } - - public function getUrl(): string - { - return $this->url; - } -} \ No newline at end of file diff --git a/src/Entity/Location.php b/src/Entity/Location.php deleted file mode 100644 index 1d5c171..0000000 --- a/src/Entity/Location.php +++ /dev/null @@ -1,133 +0,0 @@ -coordinate = new Coordinate([ - 'lat' => $data['lat'], - 'lon' => $data['lon'] - ]); - - // set no null if it is 0 - $this->id = !empty($data['id']) - ? $data['id'] - : null; - - // set to null if it is an empty string - $this->name = !empty($data['name']) - ? $data['name'] - : null; - - $this->state = $data['state'] ?? null; - - // set to null if it is an empty string - $this->countryCode = !empty($data['country']) - ? $data['country'] - : null; - - $this->localNames = $data['local_names'] ?? null; - - // set to null if it is 0 - $this->population = !empty($data['population']) - ? $data['population'] - : null; - - $this->timezone = isset($data['timezone_offset']) - ? new Timezone(['timezone_offset' => $data['timezone_offset']]) - : null; - - $this->sunriseAt = isset($data['sunrise']) - ? \DateTimeImmutable::createFromFormat('U', $data['sunrise']) - : null; - - $this->sunsetAt = isset($data['sunset']) - ? \DateTimeImmutable::createFromFormat('U', $data['sunset']) - : null; - } - - public function getCoordinate(): Coordinate - { - return $this->coordinate; - } - - public function getId(): ?int - { - return $this->id; - } - - public function getName(): ?string - { - return $this->name; - } - - public function getState(): ?string - { - return $this->state; - } - - public function getCountryCode(): ?string - { - return $this->countryCode; - } - - public function getLocalNames(): ?array - { - return $this->localNames; - } - - public function getLocalName(string $countryCode): ?string - { - $countryCode = strtolower($countryCode); - - return $this->localNames[$countryCode] ?? null; - } - - public function getPopulation(): ?int - { - return $this->population; - } - - public function getTimezone(): ?Timezone - { - return $this->timezone; - } - - /** - * Sunrise date in UTC - */ - public function getSunriseAt(): ?\DateTimeImmutable - { - return $this->sunriseAt; - } - - /** - * Sunset date in UTC - */ - public function getSunsetAt(): ?\DateTimeImmutable - { - return $this->sunsetAt; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/Alert.php b/src/Entity/OneCall/Alert.php index ff15e44..839b77d 100644 --- a/src/Entity/OneCall/Alert.php +++ b/src/Entity/OneCall/Alert.php @@ -2,57 +2,137 @@ namespace ProgrammatorDev\OpenWeatherMap\Entity\OneCall; -class Alert -{ - private string $senderName; - - private string $eventName; - - private \DateTimeImmutable $startsAt; +use ProgrammatorDev\Api\Context\Context; +use ProgrammatorDev\Api\Contract\EntityInterface; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Alert\LocalizedDescription; +use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; +use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; - private \DateTimeImmutable $endsAt; +final class Alert implements EntityInterface +{ + /** + * @param list $descriptions + * @param list $tags + */ + private function __construct( + private readonly ?string $id, + private readonly ?string $senderName, + private readonly ?string $event, + private readonly ?\DateTimeImmutable $startsAt, + private readonly ?\DateTimeImmutable $endsAt, + private readonly array $descriptions, + private readonly array $tags, + ) {} - private string $description; + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $descriptions = self::hydrateDescriptions($data, $context); - private array $tags; + return new self( + id: $reader->nullableString('id'), + senderName: $reader->nullableString('sender_name'), + event: $reader->nullableString('event'), + startsAt: $reader->nullableTimestamp('start'), + endsAt: $reader->nullableTimestamp('end'), + descriptions: $descriptions, + tags: $reader->nullableStringList('tags') ?? [], + ); + } - public function __construct(array $data) + public function id(): ?string { - $this->senderName = $data['sender_name']; - $this->eventName = $data['event']; - $this->startsAt = \DateTimeImmutable::createFromFormat('U', $data['start']); - $this->endsAt = \DateTimeImmutable::createFromFormat('U', $data['end']); - $this->description = $data['description']; - $this->tags = $data['tags']; + return $this->id; } - public function getSenderName(): string + public function senderName(): ?string { return $this->senderName; } - public function getEventName(): string + public function event(): ?string { - return $this->eventName; + return $this->event; } - public function getStartsAt(): \DateTimeImmutable + public function startsAt(): ?\DateTimeImmutable { return $this->startsAt; } - public function getEndsAt(): \DateTimeImmutable + public function endsAt(): ?\DateTimeImmutable { return $this->endsAt; } - public function getDescription(): string + /** + * @return list + */ + public function descriptions(): array { - return $this->description; + return $this->descriptions; + } + + public function description(string $languageCode): ?string + { + foreach ($this->descriptions as $description) { + if ($description->languageCode() === $languageCode) { + return $description->text(); + } + } + + return null; } - public function getTags(): array + /** + * @return list + */ + public function tags(): array { return $this->tags; } -} \ No newline at end of file + + /** + * @return list + */ + private static function hydrateDescriptions(array $data, ?Context $context): array + { + if (!array_key_exists('description', $data) || $data['description'] === null) { + return []; + } + + // The official contract documents one string, while live responses return + // localized object arrays: https://openweathermap.org/api/one-call-4 + if (is_string($data['description'])) { + return [LocalizedDescription::fromArray([ + 'description' => $data['description'], + ], $context)]; + } + + if (!is_array($data['description'])) { + throw HydrationException::invalidType( + self::class, + 'description', + 'string|array', + $data['description'], + ); + } + + $descriptions = []; + + foreach ($data['description'] as $index => $description) { + if (!is_array($description)) { + throw HydrationException::invalidType( + self::class, + sprintf('description.%s', $index), + 'array', + $description, + ); + } + + $descriptions[] = LocalizedDescription::fromArray($description, $context); + } + + return $descriptions; + } +} diff --git a/src/Entity/OneCall/Alert/LocalizedDescription.php b/src/Entity/OneCall/Alert/LocalizedDescription.php new file mode 100644 index 0000000..6d7672f --- /dev/null +++ b/src/Entity/OneCall/Alert/LocalizedDescription.php @@ -0,0 +1,35 @@ +nullableString('language'), + text: $reader->nullableString('description'), + ); + } + + public function languageCode(): ?string + { + return $this->languageCode; + } + + public function text(): ?string + { + return $this->text; + } +} diff --git a/src/Entity/OneCall/Current.php b/src/Entity/OneCall/Current.php new file mode 100644 index 0000000..c11445b --- /dev/null +++ b/src/Entity/OneCall/Current.php @@ -0,0 +1,273 @@ + $conditions + * @param list $alertIds + */ + private function __construct( + private readonly ?Coordinates $coordinates, + private readonly ?Timezone $timezone, + private readonly ?\DateTimeImmutable $dateTime, + private readonly ?\DateTimeImmutable $sunriseAt, + private readonly ?\DateTimeImmutable $sunsetAt, + private readonly ?float $temperature, + private readonly ?float $feelsLikeTemperature, + private readonly ?int $pressure, + private readonly ?int $humidity, + private readonly ?float $dewPoint, + private readonly ?float $ultravioletIndex, + private readonly ?int $visibility, + private readonly ?Wind $wind, + private readonly ?Clouds $clouds, + private readonly array $conditions, + private readonly ?Precipitation $rain, + private readonly ?Precipitation $snow, + private readonly array $alertIds, + private readonly Units $units, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + + // One Call wraps the single current observation in a one-item data list. + $observation = $reader->nullableArray('data.0') ?? []; + $conditions = []; + + foreach ($reader->nullableArray('data.0.weather') ?? [] as $index => $condition) { + if (!is_array($condition)) { + throw HydrationException::invalidType( + self::class, + sprintf('data.0.weather.%s', $index), + 'array', + $condition, + ); + } + + $conditions[] = Condition::fromArray($condition, $context); + } + + $rain = $reader->nullableArray('data.0.rain'); + $snow = $reader->nullableArray('data.0.snow'); + $hasCoordinates = array_key_exists('lat', $data) + || array_key_exists('lon', $data); + $hasTimezone = array_key_exists('timezone', $data) + || array_key_exists('timezone_offset', $data); + $hasWind = array_key_exists('wind_speed', $observation) + || array_key_exists('wind_deg', $observation) + || array_key_exists('wind_gust', $observation); + $hasClouds = array_key_exists('clouds', $observation); + + return new self( + coordinates: $hasCoordinates ? Coordinates::fromArray($data, $context) : null, + timezone: $hasTimezone ? Timezone::fromArray($data, $context) : null, + dateTime: $reader->nullableTimestamp('data.0.dt'), + sunriseAt: $reader->nullableTimestamp('data.0.sunrise'), + sunsetAt: $reader->nullableTimestamp('data.0.sunset'), + temperature: $reader->nullableFloat('data.0.temp'), + feelsLikeTemperature: $reader->nullableFloat('data.0.feels_like'), + pressure: $reader->nullableInt('data.0.pressure'), + humidity: $reader->nullableInt('data.0.humidity'), + dewPoint: $reader->nullableFloat('data.0.dew_point'), + ultravioletIndex: $reader->nullableFloat('data.0.uvi'), + visibility: $reader->nullableInt('data.0.visibility'), + wind: $hasWind + ? Wind::fromArray([ + 'speed' => $reader->nullableFloat('data.0.wind_speed'), + 'deg' => $reader->nullableInt('data.0.wind_deg'), + 'gust' => $reader->nullableFloat('data.0.wind_gust'), + ], $context) + : null, + clouds: $hasClouds + ? Clouds::fromArray([ + 'all' => $reader->nullableInt('data.0.clouds'), + ], $context) + : null, + conditions: $conditions, + rain: $rain === null ? null : Precipitation::fromArray($rain, $context), + snow: $snow === null ? null : Precipitation::fromArray($snow, $context), + alertIds: $reader->nullableStringList('data.0.alerts') ?? [], + units: UnitsResolver::fromContext($context), + ); + } + + public function coordinates(): ?Coordinates + { + return $this->coordinates; + } + + public function timezone(): ?Timezone + { + return $this->timezone; + } + + public function dateTime(): ?\DateTimeImmutable + { + return $this->dateTime; + } + + public function sunriseAt(): ?\DateTimeImmutable + { + return $this->sunriseAt; + } + + public function sunsetAt(): ?\DateTimeImmutable + { + return $this->sunsetAt; + } + + public function temperature(): ?float + { + return $this->temperature; + } + + public function temperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function temperatureWithUnit(): ?string + { + return MeasurementFormatter::format($this->temperature, $this->temperatureUnit()); + } + + public function feelsLikeTemperature(): ?float + { + return $this->feelsLikeTemperature; + } + + public function feelsLikeTemperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function feelsLikeTemperatureWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->feelsLikeTemperature, + $this->feelsLikeTemperatureUnit(), + ); + } + + public function pressure(): ?int + { + return $this->pressure; + } + + public function pressureUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function pressureWithUnit(): ?string + { + return MeasurementFormatter::format($this->pressure, $this->pressureUnit()); + } + + public function humidity(): ?int + { + return $this->humidity; + } + + public function humidityUnit(): Unit + { + return Unit::PERCENT; + } + + public function humidityWithUnit(): ?string + { + return MeasurementFormatter::format($this->humidity, $this->humidityUnit()); + } + + public function dewPoint(): ?float + { + return $this->dewPoint; + } + + public function dewPointUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function dewPointWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->dewPoint, + $this->dewPointUnit(), + ); + } + + public function ultravioletIndex(): ?float + { + return $this->ultravioletIndex; + } + + public function visibility(): ?int + { + return $this->visibility; + } + + public function visibilityUnit(): Unit + { + return Unit::METER; + } + + public function visibilityWithUnit(): ?string + { + return MeasurementFormatter::format($this->visibility, $this->visibilityUnit()); + } + + public function wind(): ?Wind + { + return $this->wind; + } + + public function clouds(): ?Clouds + { + return $this->clouds; + } + + /** + * @return list + */ + public function conditions(): array + { + return $this->conditions; + } + + public function rain(): ?Precipitation + { + return $this->rain; + } + + public function snow(): ?Precipitation + { + return $this->snow; + } + + /** + * @return list + */ + public function alertIds(): array + { + return $this->alertIds; + } +} diff --git a/src/Entity/OneCall/DayData.php b/src/Entity/OneCall/DayData.php deleted file mode 100644 index e15467b..0000000 --- a/src/Entity/OneCall/DayData.php +++ /dev/null @@ -1,98 +0,0 @@ -temperature = new Temperature($data['temp']); - $this->temperatureFeelsLike = new Temperature($data['feels_like']); - $this->precipitationProbability = round($data['pop'] * 100); - $this->summary = $data['summary']; - $this->moonPhase = new MoonPhase($data); - $this->moonriseAt = \DateTimeImmutable::createFromFormat('U', $data['moonrise']); - $this->moonsetAt = \DateTimeImmutable::createFromFormat('U', $data['moonset']); - $this->sunriseAt = \DateTimeImmutable::createFromFormat('U', $data['sunrise']); - $this->sunsetAt = \DateTimeImmutable::createFromFormat('U', $data['sunset']); - } - - public function getTemperature(): Temperature - { - return $this->temperature; - } - - public function getTemperatureFeelsLike(): Temperature - { - return $this->temperatureFeelsLike; - } - - public function getPrecipitationProbability(): int - { - return $this->precipitationProbability; - } - - public function getSummary(): string - { - return $this->summary; - } - - public function getMoonPhase(): MoonPhase - { - return $this->moonPhase; - } - - /** - * Moonrise date in UTC - */ - public function getMoonriseAt(): \DateTimeImmutable - { - return $this->moonriseAt; - } - - /** - * Moonset date in UTC - */ - public function getMoonsetAt(): \DateTimeImmutable - { - return $this->moonsetAt; - } - - /** - * Sunrise date in UTC - */ - public function getSunriseAt(): \DateTimeImmutable - { - return $this->sunriseAt; - } - - /** - * Sunset date in UTC - */ - public function getSunsetAt(): \DateTimeImmutable - { - return $this->sunsetAt; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/DayTimeline.php b/src/Entity/OneCall/DayTimeline.php new file mode 100644 index 0000000..f8615ad --- /dev/null +++ b/src/Entity/OneCall/DayTimeline.php @@ -0,0 +1,56 @@ + $page + */ + private function __construct( + private readonly TimelinePage $page, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + return new self(TimelinePage::fromArray( + data: $data, + entity: self::class, + periodClass: Period::class, + context: $context, + )); + } + + public function coordinates(): ?Coordinates + { + return $this->page->coordinates(); + } + + public function timezone(): ?Timezone + { + return $this->page->timezone(); + } + + /** + * @return list + */ + public function periods(): array + { + return $this->page->periods(); + } + + /** + * @return Pagination + */ + public function pagination(): Pagination + { + return $this->page->pagination(); + } +} diff --git a/src/Entity/OneCall/DayTimeline/FeelsLikeTemperature.php b/src/Entity/OneCall/DayTimeline/FeelsLikeTemperature.php new file mode 100644 index 0000000..4ceb62b --- /dev/null +++ b/src/Entity/OneCall/DayTimeline/FeelsLikeTemperature.php @@ -0,0 +1,100 @@ +nullableFloat('day'), + night: $reader->nullableFloat('night'), + evening: $reader->nullableFloat('eve'), + morning: $reader->nullableFloat('morn'), + units: UnitsResolver::fromContext($context), + ); + } + + public function day(): ?float + { + return $this->day; + } + + public function dayUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function dayWithUnit(): ?string + { + return $this->format($this->day); + } + + public function night(): ?float + { + return $this->night; + } + + public function nightUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function nightWithUnit(): ?string + { + return $this->format($this->night); + } + + public function evening(): ?float + { + return $this->evening; + } + + public function eveningUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function eveningWithUnit(): ?string + { + return $this->format($this->evening); + } + + public function morning(): ?float + { + return $this->morning; + } + + public function morningUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function morningWithUnit(): ?string + { + return $this->format($this->morning); + } + + private function format(?float $temperature): ?string + { + return MeasurementFormatter::format($temperature, $this->units->temperatureUnit()); + } +} diff --git a/src/Entity/OneCall/DayTimeline/Period.php b/src/Entity/OneCall/DayTimeline/Period.php new file mode 100644 index 0000000..8f28b17 --- /dev/null +++ b/src/Entity/OneCall/DayTimeline/Period.php @@ -0,0 +1,261 @@ + $conditions + * @param list $alertIds + */ + private function __construct( + private readonly ?\DateTimeImmutable $dateTime, + private readonly ?\DateTimeImmutable $sunriseAt, + private readonly ?\DateTimeImmutable $sunsetAt, + private readonly ?\DateTimeImmutable $moonriseAt, + private readonly ?\DateTimeImmutable $moonsetAt, + private readonly ?float $moonPhase, + private readonly ?Temperature $temperature, + private readonly ?FeelsLikeTemperature $feelsLikeTemperature, + private readonly ?float $pressure, + private readonly ?int $humidity, + private readonly ?float $dewPoint, + private readonly ?float $ultravioletIndex, + private readonly ?int $visibility, + private readonly ?Wind $wind, + private readonly ?Clouds $clouds, + private readonly ?float $precipitationProbability, + private readonly array $conditions, + private readonly ?float $rain, + private readonly ?float $snow, + private readonly array $alertIds, + private readonly Units $units, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $conditions = []; + + foreach ($reader->nullableArray('weather') ?? [] as $index => $condition) { + if (!is_array($condition)) { + throw HydrationException::invalidType( + self::class, + sprintf('weather.%s', $index), + 'array', + $condition, + ); + } + + $conditions[] = Condition::fromArray($condition, $context); + } + + $temperature = $reader->nullableArray('temp'); + $feelsLikeTemperature = $reader->nullableArray('feels_like'); + $hasWind = array_key_exists('wind_speed', $data) + || array_key_exists('wind_deg', $data) + || array_key_exists('wind_gust', $data); + $hasClouds = array_key_exists('clouds', $data); + + return new self( + dateTime: $reader->nullableTimestamp('dt'), + sunriseAt: $reader->nullableTimestamp('sunrise'), + sunsetAt: $reader->nullableTimestamp('sunset'), + moonriseAt: $reader->nullableTimestamp('moonrise'), + moonsetAt: $reader->nullableTimestamp('moonset'), + moonPhase: $reader->nullableFloat('moon_phase'), + temperature: $temperature === null + ? null + : Temperature::fromArray($temperature, $context), + feelsLikeTemperature: $feelsLikeTemperature === null + ? null + : FeelsLikeTemperature::fromArray($feelsLikeTemperature, $context), + pressure: $reader->nullableFloat('pressure'), + humidity: $reader->nullableInt('humidity'), + dewPoint: $reader->nullableFloat('dew_point'), + ultravioletIndex: $reader->nullableFloat('uvi'), + visibility: $reader->nullableInt('visibility'), + wind: $hasWind + ? Wind::fromArray([ + 'speed' => $reader->nullableFloat('wind_speed'), + 'deg' => $reader->nullableInt('wind_deg'), + 'gust' => $reader->nullableFloat('wind_gust'), + ], $context) + : null, + clouds: $hasClouds + ? Clouds::fromArray([ + 'all' => $reader->nullableInt('clouds'), + ], $context) + : null, + precipitationProbability: self::normalizePrecipitationProbability( + $reader->nullableFloat('pop'), + ), + conditions: $conditions, + // Live daily responses return scalar precipitation, while the field table + // still describes hourly objects: https://openweathermap.org/api/one-call-4 + rain: $reader->nullableFloat('rain'), + snow: $reader->nullableFloat('snow'), + alertIds: $reader->nullableStringList('alerts') ?? [], + units: UnitsResolver::fromContext($context), + ); + } + + public function dateTime(): ?\DateTimeImmutable + { + return $this->dateTime; + } + + public function sunriseAt(): ?\DateTimeImmutable + { + return $this->sunriseAt; + } + + public function sunsetAt(): ?\DateTimeImmutable + { + return $this->sunsetAt; + } + + public function moonriseAt(): ?\DateTimeImmutable + { + return $this->moonriseAt; + } + + public function moonsetAt(): ?\DateTimeImmutable + { + return $this->moonsetAt; + } + + public function moonPhase(): ?float + { + return $this->moonPhase; + } + + public function temperature(): ?Temperature + { + return $this->temperature; + } + + public function feelsLikeTemperature(): ?FeelsLikeTemperature + { + return $this->feelsLikeTemperature; + } + + public function pressure(): ?float + { + return $this->pressure; + } + + public function pressureUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function pressureWithUnit(): ?string + { + return MeasurementFormatter::format($this->pressure, $this->pressureUnit()); + } + + public function humidity(): ?int + { + return $this->humidity; + } + + public function humidityUnit(): Unit + { + return Unit::PERCENT; + } + + public function humidityWithUnit(): ?string + { + return MeasurementFormatter::format($this->humidity, $this->humidityUnit()); + } + + public function dewPoint(): ?float + { + return $this->dewPoint; + } + + public function dewPointUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function dewPointWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->dewPoint, + $this->dewPointUnit(), + ); + } + + public function ultravioletIndex(): ?float + { + return $this->ultravioletIndex; + } + + public function visibility(): ?int + { + return $this->visibility; + } + + public function visibilityUnit(): Unit + { + return Unit::METER; + } + + public function visibilityWithUnit(): ?string + { + return MeasurementFormatter::format($this->visibility, $this->visibilityUnit()); + } + + public function wind(): ?Wind + { + return $this->wind; + } + + public function clouds(): ?Clouds + { + return $this->clouds; + } + + /** + * @return list + */ + public function conditions(): array + { + return $this->conditions; + } + + public function rain(): ?float + { + return $this->rain; + } + + public function snow(): ?float + { + return $this->snow; + } + + /** + * @return list + */ + public function alertIds(): array + { + return $this->alertIds; + } +} diff --git a/src/Entity/OneCall/DayTimeline/Temperature.php b/src/Entity/OneCall/DayTimeline/Temperature.php new file mode 100644 index 0000000..5ff0b13 --- /dev/null +++ b/src/Entity/OneCall/DayTimeline/Temperature.php @@ -0,0 +1,134 @@ +nullableFloat('day'), + minimum: $reader->nullableFloat('min'), + maximum: $reader->nullableFloat('max'), + night: $reader->nullableFloat('night'), + evening: $reader->nullableFloat('eve'), + morning: $reader->nullableFloat('morn'), + units: UnitsResolver::fromContext($context), + ); + } + + public function day(): ?float + { + return $this->day; + } + + public function dayUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function dayWithUnit(): ?string + { + return $this->format($this->day); + } + + public function minimum(): ?float + { + return $this->minimum; + } + + public function minimumUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function minimumWithUnit(): ?string + { + return $this->format($this->minimum); + } + + public function maximum(): ?float + { + return $this->maximum; + } + + public function maximumUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function maximumWithUnit(): ?string + { + return $this->format($this->maximum); + } + + public function night(): ?float + { + return $this->night; + } + + public function nightUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function nightWithUnit(): ?string + { + return $this->format($this->night); + } + + public function evening(): ?float + { + return $this->evening; + } + + public function eveningUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function eveningWithUnit(): ?string + { + return $this->format($this->evening); + } + + public function morning(): ?float + { + return $this->morning; + } + + public function morningUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function morningWithUnit(): ?string + { + return $this->format($this->morning); + } + + private function format(?float $temperature): ?string + { + return MeasurementFormatter::format($temperature, $this->units->temperatureUnit()); + } +} diff --git a/src/Entity/OneCall/FifteenMinuteTimeline.php b/src/Entity/OneCall/FifteenMinuteTimeline.php new file mode 100644 index 0000000..8f5d6aa --- /dev/null +++ b/src/Entity/OneCall/FifteenMinuteTimeline.php @@ -0,0 +1,56 @@ + $page + */ + private function __construct( + private readonly TimelinePage $page, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + return new self(TimelinePage::fromArray( + data: $data, + entity: self::class, + periodClass: Period::class, + context: $context, + )); + } + + public function coordinates(): ?Coordinates + { + return $this->page->coordinates(); + } + + public function timezone(): ?Timezone + { + return $this->page->timezone(); + } + + /** + * @return list + */ + public function periods(): array + { + return $this->page->periods(); + } + + /** + * @return Pagination + */ + public function pagination(): Pagination + { + return $this->page->pagination(); + } +} diff --git a/src/Entity/OneCall/FifteenMinuteTimeline/Period.php b/src/Entity/OneCall/FifteenMinuteTimeline/Period.php new file mode 100644 index 0000000..0719af7 --- /dev/null +++ b/src/Entity/OneCall/FifteenMinuteTimeline/Period.php @@ -0,0 +1,7 @@ +temperature = $data['temp']; - $this->temperatureFeelsLike = $data['feels_like']; - $this->visibility = $data['visibility'] ?? null; - $this->precipitationProbability = round($data['pop'] * 100); - } - - public function getTemperature(): float - { - return $this->temperature; - } - - public function getTemperatureFeelsLike(): float - { - return $this->temperatureFeelsLike; - } - - public function getVisibility(): ?int - { - return $this->visibility; - } - - public function getPrecipitationProbability(): int - { - return $this->precipitationProbability; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/HourTimeline.php b/src/Entity/OneCall/HourTimeline.php new file mode 100644 index 0000000..63dac88 --- /dev/null +++ b/src/Entity/OneCall/HourTimeline.php @@ -0,0 +1,56 @@ + $page + */ + private function __construct( + private readonly TimelinePage $page, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + return new self(TimelinePage::fromArray( + data: $data, + entity: self::class, + periodClass: Period::class, + context: $context, + )); + } + + public function coordinates(): ?Coordinates + { + return $this->page->coordinates(); + } + + public function timezone(): ?Timezone + { + return $this->page->timezone(); + } + + /** + * @return list + */ + public function periods(): array + { + return $this->page->periods(); + } + + /** + * @return Pagination + */ + public function pagination(): Pagination + { + return $this->page->pagination(); + } +} diff --git a/src/Entity/OneCall/HourTimeline/Period.php b/src/Entity/OneCall/HourTimeline/Period.php new file mode 100644 index 0000000..7ef4feb --- /dev/null +++ b/src/Entity/OneCall/HourTimeline/Period.php @@ -0,0 +1,7 @@ +dateTime = \DateTimeImmutable::createFromFormat('U', $data['dt']); - $this->precipitation = $data['precipitation']; - } - - /** - * DateTime in UTC - */ - public function getDateTime(): \DateTimeImmutable - { - return $this->dateTime; - } - - public function getPrecipitation(): float - { - return $this->precipitation; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/MinuteTimeline.php b/src/Entity/OneCall/MinuteTimeline.php new file mode 100644 index 0000000..3b8ff35 --- /dev/null +++ b/src/Entity/OneCall/MinuteTimeline.php @@ -0,0 +1,70 @@ + $periods + */ + private function __construct( + private readonly ?Coordinates $coordinates, + private readonly ?Timezone $timezone, + private readonly array $periods, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $periods = []; + + foreach ($reader->nullableArray('data') ?? [] as $index => $period) { + if (!is_array($period)) { + throw HydrationException::invalidType( + self::class, + sprintf('data.%s', $index), + 'array', + $period, + ); + } + + $periods[] = Period::fromArray($period, $context); + } + + $hasCoordinates = array_key_exists('lat', $data) + || array_key_exists('lon', $data); + $hasTimezone = array_key_exists('timezone', $data) + || array_key_exists('timezone_offset', $data); + + return new self( + coordinates: $hasCoordinates ? Coordinates::fromArray($data, $context) : null, + timezone: $hasTimezone ? Timezone::fromArray($data, $context) : null, + periods: $periods, + ); + } + + public function coordinates(): ?Coordinates + { + return $this->coordinates; + } + + public function timezone(): ?Timezone + { + return $this->timezone; + } + + /** + * @return list + */ + public function periods(): array + { + return $this->periods; + } +} diff --git a/src/Entity/OneCall/MinuteTimeline/Period.php b/src/Entity/OneCall/MinuteTimeline/Period.php new file mode 100644 index 0000000..d9acb3d --- /dev/null +++ b/src/Entity/OneCall/MinuteTimeline/Period.php @@ -0,0 +1,59 @@ + $alertIds + */ + private function __construct( + private readonly ?\DateTimeImmutable $dateTime, + private readonly ?float $precipitation, + private readonly array $alertIds, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + return new self( + dateTime: $reader->nullableTimestamp('dt'), + precipitation: $reader->nullableFloat('precipitation'), + alertIds: $reader->nullableStringList('alerts') ?? [], + ); + } + + public function dateTime(): ?\DateTimeImmutable + { + return $this->dateTime; + } + + public function precipitation(): ?float + { + return $this->precipitation; + } + + public function precipitationUnit(): Unit + { + return Unit::MILLIMETERS_PER_HOUR; + } + + public function precipitationWithUnit(): ?string + { + return MeasurementFormatter::format($this->precipitation, $this->precipitationUnit()); + } + + /** + * @return list + */ + public function alertIds(): array + { + return $this->alertIds; + } +} diff --git a/src/Entity/OneCall/MoonPhase.php b/src/Entity/OneCall/MoonPhase.php deleted file mode 100644 index b13828b..0000000 --- a/src/Entity/OneCall/MoonPhase.php +++ /dev/null @@ -1,57 +0,0 @@ -value = $data['moon_phase']; - $this->systemName = $this->findSystemName($this->value); - $this->name = ucwords(strtolower(str_replace('_', ' ', $this->systemName))); - } - - public function getValue(): float - { - return $this->value; - } - - public function getName(): string - { - return $this->name; - } - - public function getSystemName(): string - { - return $this->systemName; - } - - private function findSystemName(float $value): string - { - return match (true) { - $value > 0 && $value < 0.25 => self::WAXING_CRESCENT, - $value === 0.25 => self::FIRST_QUARTER_MOON, - $value > 0.25 && $value < 0.5 => self::WAXING_GIBBOUS, - $value === 0.5 => self::FULL_MOON, - $value > 0.5 && $value < 0.75 => self::WANING_GIBBOUS, - $value === 0.75 => self::LAST_QUARTER_MOON, - $value > 0.75 && $value < 1 => self::WANING_CRESCENT, - default => self::NEW_MOON // 0.0 or 1.0 - }; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/Temperature.php b/src/Entity/OneCall/Temperature.php deleted file mode 100644 index 94d13f7..0000000 --- a/src/Entity/OneCall/Temperature.php +++ /dev/null @@ -1,58 +0,0 @@ -morning = $data['morn']; - $this->day = $data['day']; - $this->evening = $data['eve']; - $this->night = $data['night']; - $this->min = $data['min'] ?? null; - $this->max = $data['max'] ?? null; - } - - public function getMorning(): float - { - return $this->morning; - } - - public function getDay(): float - { - return $this->day; - } - - public function getEvening(): float - { - return $this->evening; - } - - public function getNight(): float - { - return $this->night; - } - - public function getMin(): ?float - { - return $this->min; - } - - public function getMax(): ?float - { - return $this->max; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/Timeline/Pagination.php b/src/Entity/OneCall/Timeline/Pagination.php new file mode 100644 index 0000000..4f34355 --- /dev/null +++ b/src/Entity/OneCall/Timeline/Pagination.php @@ -0,0 +1,106 @@ + $pageClass + */ + private function __construct( + private readonly string $pageClass, + private readonly ?string $previousPageUrl, + private readonly ?string $nextPageUrl, + private readonly ?Context $context, + ) {} + + /** + * @param class-string $pageClass + * @return self + */ + public static function fromArray( + array $data, + string $pageClass, + ?Context $context = null, + ): self { + $reader = PayloadReader::from($data, self::class); + $previousPageUrl = $reader->nullableString('prev'); + $nextPageUrl = $reader->nullableString('next'); + + return new self( + pageClass: $pageClass, + previousPageUrl: $previousPageUrl === null + ? null + : PaginationUrlNormalizer::normalize($previousPageUrl), + nextPageUrl: $nextPageUrl === null + ? null + : PaginationUrlNormalizer::normalize($nextPageUrl), + context: $context, + ); + } + + public function previousPageUrl(): ?string + { + return $this->previousPageUrl; + } + + public function hasPreviousPage(): bool + { + return $this->previousPageUrl !== null; + } + + public function nextPageUrl(): ?string + { + return $this->nextPageUrl; + } + + public function hasNextPage(): bool + { + return $this->nextPageUrl !== null; + } + + /** + * @return TPage|null + */ + public function nextPage(): ?EntityInterface + { + return $this->resolve($this->nextPageUrl); + } + + /** + * @return TPage|null + */ + public function previousPage(): ?EntityInterface + { + return $this->resolve($this->previousPageUrl); + } + + /** + * @return TPage|null + */ + private function resolve(?string $pageUrl): ?EntityInterface + { + if ($pageUrl === null) { + return null; + } + + if ($this->context === null) { + throw new \LogicException( + 'Pagination navigation requires a page returned by the API.', + ); + } + + /** @var TPage $page */ + $page = $this->context->resolver()->entity($pageUrl, $this->pageClass); + + return $page; + } +} diff --git a/src/Entity/OneCall/Timeline/TimelinePage.php b/src/Entity/OneCall/Timeline/TimelinePage.php new file mode 100644 index 0000000..e937725 --- /dev/null +++ b/src/Entity/OneCall/Timeline/TimelinePage.php @@ -0,0 +1,102 @@ + $periods + * @param Pagination $pagination + */ + private function __construct( + private readonly ?Coordinates $coordinates, + private readonly ?Timezone $timezone, + private readonly array $periods, + private readonly Pagination $pagination, + ) {} + + /** + * @template TPeriodClass of EntityInterface + * @template TPageClass of EntityInterface + * + * @param class-string $entity + * @param class-string $periodClass + * + * @return self + */ + public static function fromArray( + array $data, + string $entity, + string $periodClass, + ?Context $context = null, + ): self { + $reader = PayloadReader::from($data, $entity); + $periods = []; + + foreach ($reader->nullableArray('data') ?? [] as $index => $period) { + if (!is_array($period)) { + throw HydrationException::invalidType( + $entity, + sprintf('data.%s', $index), + 'array', + $period, + ); + } + + $periods[] = $periodClass::fromArray($period, $context); + } + + $hasCoordinates = array_key_exists('lat', $data) + || array_key_exists('lon', $data); + $hasTimezone = array_key_exists('timezone', $data) + || array_key_exists('timezone_offset', $data); + + return new self( + coordinates: $hasCoordinates ? Coordinates::fromArray($data, $context) : null, + timezone: $hasTimezone ? Timezone::fromArray($data, $context) : null, + periods: $periods, + pagination: Pagination::fromArray( + data: $data, + pageClass: $entity, + context: $context, + ), + ); + } + + public function coordinates(): ?Coordinates + { + return $this->coordinates; + } + + public function timezone(): ?Timezone + { + return $this->timezone; + } + + /** + * @return list + */ + public function periods(): array + { + return $this->periods; + } + + /** + * @return Pagination + */ + public function pagination(): Pagination + { + return $this->pagination; + } +} diff --git a/src/Entity/OneCall/Timeline/WeatherPeriod.php b/src/Entity/OneCall/Timeline/WeatherPeriod.php new file mode 100644 index 0000000..c4b139e --- /dev/null +++ b/src/Entity/OneCall/Timeline/WeatherPeriod.php @@ -0,0 +1,247 @@ + $conditions + * @param list $alertIds + */ + protected function __construct( + private readonly ?\DateTimeImmutable $dateTime, + private readonly ?float $temperature, + private readonly ?float $feelsLikeTemperature, + private readonly ?float $pressure, + private readonly ?int $humidity, + private readonly ?float $dewPoint, + private readonly ?float $ultravioletIndex, + private readonly ?int $visibility, + private readonly ?Wind $wind, + private readonly ?Clouds $clouds, + private readonly ?float $precipitationProbability, + private readonly array $conditions, + private readonly ?Precipitation $rain, + private readonly ?Precipitation $snow, + private readonly array $alertIds, + private readonly Units $units, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, static::class); + $conditions = []; + + foreach ($reader->nullableArray('weather') ?? [] as $index => $condition) { + if (!is_array($condition)) { + throw HydrationException::invalidType( + static::class, + sprintf('weather.%s', $index), + 'array', + $condition, + ); + } + + $conditions[] = Condition::fromArray($condition, $context); + } + + $rain = $reader->nullableArray('rain'); + $snow = $reader->nullableArray('snow'); + $hasWind = array_key_exists('wind_speed', $data) + || array_key_exists('wind_deg', $data) + || array_key_exists('wind_gust', $data); + $hasClouds = array_key_exists('clouds', $data); + + return new static( + dateTime: $reader->nullableTimestamp('dt'), + temperature: $reader->nullableFloat('temp'), + feelsLikeTemperature: $reader->nullableFloat('feels_like'), + pressure: $reader->nullableFloat('pressure'), + humidity: $reader->nullableInt('humidity'), + dewPoint: $reader->nullableFloat('dew_point'), + ultravioletIndex: $reader->nullableFloat('uvi'), + visibility: $reader->nullableInt('visibility'), + wind: $hasWind + ? Wind::fromArray([ + 'speed' => $reader->nullableFloat('wind_speed'), + 'deg' => $reader->nullableInt('wind_deg'), + 'gust' => $reader->nullableFloat('wind_gust'), + ], $context) + : null, + clouds: $hasClouds + ? Clouds::fromArray([ + 'all' => $reader->nullableInt('clouds'), + ], $context) + : null, + precipitationProbability: self::normalizePrecipitationProbability( + $reader->nullableFloat('pop'), + ), + conditions: $conditions, + rain: $rain === null ? null : Precipitation::fromArray($rain, $context), + snow: $snow === null ? null : Precipitation::fromArray($snow, $context), + alertIds: $reader->nullableStringList('alerts') ?? [], + units: UnitsResolver::fromContext($context), + ); + } + + public function dateTime(): ?\DateTimeImmutable + { + return $this->dateTime; + } + + public function temperature(): ?float + { + return $this->temperature; + } + + public function temperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function temperatureWithUnit(): ?string + { + return MeasurementFormatter::format($this->temperature, $this->temperatureUnit()); + } + + public function feelsLikeTemperature(): ?float + { + return $this->feelsLikeTemperature; + } + + public function feelsLikeTemperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function feelsLikeTemperatureWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->feelsLikeTemperature, + $this->feelsLikeTemperatureUnit(), + ); + } + + public function pressure(): ?float + { + return $this->pressure; + } + + public function pressureUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function pressureWithUnit(): ?string + { + return MeasurementFormatter::format($this->pressure, $this->pressureUnit()); + } + + public function humidity(): ?int + { + return $this->humidity; + } + + public function humidityUnit(): Unit + { + return Unit::PERCENT; + } + + public function humidityWithUnit(): ?string + { + return MeasurementFormatter::format($this->humidity, $this->humidityUnit()); + } + + public function dewPoint(): ?float + { + return $this->dewPoint; + } + + public function dewPointUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function dewPointWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->dewPoint, + $this->dewPointUnit(), + ); + } + + public function ultravioletIndex(): ?float + { + return $this->ultravioletIndex; + } + + public function visibility(): ?int + { + return $this->visibility; + } + + public function visibilityUnit(): Unit + { + return Unit::METER; + } + + public function visibilityWithUnit(): ?string + { + return MeasurementFormatter::format($this->visibility, $this->visibilityUnit()); + } + + public function wind(): ?Wind + { + return $this->wind; + } + + public function clouds(): ?Clouds + { + return $this->clouds; + } + + /** + * @return list + */ + public function conditions(): array + { + return $this->conditions; + } + + public function rain(): ?Precipitation + { + return $this->rain; + } + + public function snow(): ?Precipitation + { + return $this->snow; + } + + /** + * @return list + */ + public function alertIds(): array + { + return $this->alertIds; + } +} diff --git a/src/Entity/OneCall/Timezone.php b/src/Entity/OneCall/Timezone.php new file mode 100644 index 0000000..73886d1 --- /dev/null +++ b/src/Entity/OneCall/Timezone.php @@ -0,0 +1,35 @@ +nullableString('timezone'), + offsetSeconds: $reader->nullableInt('timezone_offset'), + ); + } + + public function identifier(): ?string + { + return $this->identifier; + } + + public function offsetSeconds(): ?int + { + return $this->offsetSeconds; + } +} diff --git a/src/Entity/OneCall/Weather.php b/src/Entity/OneCall/Weather.php deleted file mode 100644 index 09395e1..0000000 --- a/src/Entity/OneCall/Weather.php +++ /dev/null @@ -1,89 +0,0 @@ -coordinate = new Coordinate([ - 'lat' => $data['lat'], - 'lon' => $data['lon'] - ]); - - $this->timezone = new Timezone([ - 'timezone' => $data['timezone'], - 'timezone_offset' => $data['timezone_offset'] - ]); - - $this->current = new WeatherData($data['current']); - - $this->minutelyForecast = isset($data['minutely']) - ? EntityHelper::createEntityList(MinuteData::class, $data['minutely']) - : null; - - $this->hourlyForecast = EntityHelper::createEntityList(HourData::class, $data['hourly']); - $this->dailyForecast = EntityHelper::createEntityList(DayData::class, $data['daily']); - - $this->alerts = isset($data['alerts']) - ? EntityHelper::createEntityList(Alert::class, $data['alerts']) - : null; - } - - public function getCoordinate(): Coordinate - { - return $this->coordinate; - } - - public function getTimezone(): Timezone - { - return $this->timezone; - } - - public function getCurrent(): WeatherData - { - return $this->current; - } - - public function getMinutelyForecast(): ?array - { - return $this->minutelyForecast; - } - - public function getHourlyForecast(): array - { - return $this->hourlyForecast; - } - - public function getDailyForecast(): array - { - return $this->dailyForecast; - } - - public function getAlerts(): ?array - { - return $this->alerts; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/WeatherData.php b/src/Entity/OneCall/WeatherData.php deleted file mode 100644 index 0154344..0000000 --- a/src/Entity/OneCall/WeatherData.php +++ /dev/null @@ -1,66 +0,0 @@ -temperature = $data['temp']; - $this->temperatureFeelsLike = $data['feels_like']; - $this->visibility = $data['visibility'] ?? null; - - $this->sunriseAt = isset($data['sunrise']) - ? \DateTimeImmutable::createFromFormat('U', $data['sunrise']) - : null; - - $this->sunsetAt = isset($data['sunset']) - ? \DateTimeImmutable::createFromFormat('U', $data['sunset']) - : null; - } - - public function getTemperature(): float - { - return $this->temperature; - } - - public function getTemperatureFeelsLike(): float - { - return $this->temperatureFeelsLike; - } - - public function getVisibility(): ?int - { - return $this->visibility; - } - - /** - * Sunrise date in UTC - */ - public function getSunriseAt(): ?\DateTimeImmutable - { - return $this->sunriseAt; - } - - /** - * Sunset date in UTC - */ - public function getSunsetAt(): ?\DateTimeImmutable - { - return $this->sunsetAt; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/WeatherMoment.php b/src/Entity/OneCall/WeatherMoment.php deleted file mode 100644 index dc3665e..0000000 --- a/src/Entity/OneCall/WeatherMoment.php +++ /dev/null @@ -1,38 +0,0 @@ -coordinate = new Coordinate([ - 'lat' => $data['lat'], - 'lon' => $data['lon'] - ]); - - $this->timezone = new Timezone([ - 'timezone' => $data['timezone'], - 'timezone_offset' => $data['timezone_offset'] - ]); - } - - public function getCoordinate(): Coordinate - { - return $this->coordinate; - } - - public function getTimezone(): Timezone - { - return $this->timezone; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/WeatherOverview.php b/src/Entity/OneCall/WeatherOverview.php deleted file mode 100644 index 6e2fed5..0000000 --- a/src/Entity/OneCall/WeatherOverview.php +++ /dev/null @@ -1,53 +0,0 @@ -coordinate = new Coordinate($data); - - $this->timezone = new Timezone([ - 'timezone_offset' => \DateTimeImmutable::createFromFormat('P', $data['tz'])->getOffset() - ]); - - $this->dateTime = \DateTimeImmutable::createFromFormat( - 'Y-m-d H:i:s P', - sprintf('%s 00:00:00 %s', $data['date'], $data['tz']) - ); - - $this->overview = $data['weather_overview']; - } - - public function getCoordinate(): Coordinate - { - return $this->coordinate; - } - - public function getTimezone(): Timezone - { - return $this->timezone; - } - - public function getDateTime(): \DateTimeImmutable - { - return $this->dateTime; - } - - public function getOverview(): string - { - return $this->overview; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/WeatherSummary.php b/src/Entity/OneCall/WeatherSummary.php deleted file mode 100644 index 4fb3af0..0000000 --- a/src/Entity/OneCall/WeatherSummary.php +++ /dev/null @@ -1,107 +0,0 @@ -coordinate = new Coordinate($data); - - $this->timezone = new Timezone([ - 'timezone_offset' => \DateTimeImmutable::createFromFormat('P', $data['tz'])->getOffset() - ]); - - $this->dateTime = \DateTimeImmutable::createFromFormat( - 'Y-m-d H:i:s P', - sprintf('%s 00:00:00 %s', $data['date'], $data['tz']) - ); - - $this->cloudiness = \round($data['cloud_cover']['afternoon']); - $this->humidity = \round($data['humidity']['afternoon']); - $this->precipitation = $data['precipitation']['total']; - - $this->temperature = new Temperature([ - 'morn' => $data['temperature']['morning'], - 'day' => $data['temperature']['afternoon'], - 'eve' => $data['temperature']['evening'], - 'night' => $data['temperature']['night'], - 'min' => $data['temperature']['min'], - 'max' => $data['temperature']['max'] - ]); - - $this->atmosphericPressure = round($data['pressure']['afternoon']); - - $this->wind = new Wind([ - 'speed' => $data['wind']['max']['speed'], - 'deg' => round($data['wind']['max']['direction']) - ]); - } - - public function getCoordinate(): Coordinate - { - return $this->coordinate; - } - - public function getTimezone(): Timezone - { - return $this->timezone; - } - - public function getDateTime(): \DateTimeImmutable - { - return $this->dateTime; - } - - public function getCloudiness(): int - { - return $this->cloudiness; - } - - public function getHumidity(): int - { - return $this->humidity; - } - - public function getPrecipitation(): float - { - return $this->precipitation; - } - - public function getTemperature(): Temperature - { - return $this->temperature; - } - - public function getAtmosphericPressure(): int - { - return $this->atmosphericPressure; - } - - public function getWind(): Wind - { - return $this->wind; - } -} \ No newline at end of file diff --git a/src/Entity/Stations/MeasurementAggregate.php b/src/Entity/Stations/MeasurementAggregate.php new file mode 100644 index 0000000..d27f4fa --- /dev/null +++ b/src/Entity/Stations/MeasurementAggregate.php @@ -0,0 +1,108 @@ +nullableString('type'); + $temperature = $reader->nullableArray('temp'); + $humidity = $reader->nullableArray('humidity'); + $wind = $reader->nullableArray('wind'); + $pressure = $reader->nullableArray('pressure'); + $precipitation = $reader->nullableArray('precipitation'); + + if ($interval !== null) { + $interval = AggregationInterval::tryFrom($interval) + ?? throw HydrationException::invalidValue( + self::class, + 'type', + 'one of m, h, or d', + $interval, + ); + } + + return new self( + interval: $interval, + dateTime: $reader->nullableTimestamp('date'), + stationId: $reader->nullableString('station_id'), + temperature: $temperature === null + ? null + : Temperature::fromArray($temperature, $context), + humidity: $humidity === null + ? null + : Humidity::fromArray($humidity, $context), + wind: $wind === null ? null : Wind::fromArray($wind, $context), + pressure: $pressure === null + ? null + : Pressure::fromArray($pressure, $context), + precipitation: $precipitation === null + ? null + : Precipitation::fromArray($precipitation, $context), + ); + } + + public function interval(): ?AggregationInterval + { + return $this->interval; + } + + public function dateTime(): ?\DateTimeImmutable + { + return $this->dateTime; + } + + public function stationId(): ?string + { + return $this->stationId; + } + + public function temperature(): ?Temperature + { + return $this->temperature; + } + + public function humidity(): ?Humidity + { + return $this->humidity; + } + + public function wind(): ?Wind + { + return $this->wind; + } + + public function pressure(): ?Pressure + { + return $this->pressure; + } + + public function precipitation(): ?Precipitation + { + return $this->precipitation; + } +} diff --git a/src/Entity/Stations/MeasurementAggregate/Humidity.php b/src/Entity/Stations/MeasurementAggregate/Humidity.php new file mode 100644 index 0000000..9204c17 --- /dev/null +++ b/src/Entity/Stations/MeasurementAggregate/Humidity.php @@ -0,0 +1,47 @@ +nullableFloat('average'), + weight: $reader->nullableInt('weight'), + ); + } + + public function average(): ?float + { + return $this->average; + } + + public function averageUnit(): Unit + { + return Unit::PERCENT; + } + + public function averageWithUnit(): ?string + { + return MeasurementFormatter::format($this->average, $this->averageUnit()); + } + + public function weight(): ?int + { + return $this->weight; + } +} diff --git a/src/Entity/Stations/MeasurementAggregate/Precipitation.php b/src/Entity/Stations/MeasurementAggregate/Precipitation.php new file mode 100644 index 0000000..1f12539 --- /dev/null +++ b/src/Entity/Stations/MeasurementAggregate/Precipitation.php @@ -0,0 +1,62 @@ +nullableFloat('rain'), + snow: $reader->nullableFloat('snow'), + ); + } + + public function rain(): ?float + { + return $this->rain; + } + + public function rainUnit(): Unit + { + return Unit::MILLIMETER; + } + + public function rainWithUnit(): ?string + { + return $this->format($this->rain); + } + + public function snow(): ?float + { + return $this->snow; + } + + public function snowUnit(): Unit + { + return Unit::MILLIMETER; + } + + public function snowWithUnit(): ?string + { + return $this->format($this->snow); + } + + private function format(?float $precipitation): ?string + { + return MeasurementFormatter::format($precipitation, Unit::MILLIMETER); + } +} diff --git a/src/Entity/Stations/MeasurementAggregate/Pressure.php b/src/Entity/Stations/MeasurementAggregate/Pressure.php new file mode 100644 index 0000000..744eecd --- /dev/null +++ b/src/Entity/Stations/MeasurementAggregate/Pressure.php @@ -0,0 +1,86 @@ +nullableFloat('min'), + maximum: $reader->nullableFloat('max'), + average: $reader->nullableFloat('average'), + weight: $reader->nullableInt('weight'), + ); + } + + public function minimum(): ?float + { + return $this->minimum; + } + + public function minimumUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function minimumWithUnit(): ?string + { + return $this->format($this->minimum); + } + + public function maximum(): ?float + { + return $this->maximum; + } + + public function maximumUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function maximumWithUnit(): ?string + { + return $this->format($this->maximum); + } + + public function average(): ?float + { + return $this->average; + } + + public function averageUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function averageWithUnit(): ?string + { + return $this->format($this->average); + } + + public function weight(): ?int + { + return $this->weight; + } + + private function format(?float $pressure): ?string + { + return MeasurementFormatter::format($pressure, Unit::HECTOPASCAL); + } +} diff --git a/src/Entity/Stations/MeasurementAggregate/Temperature.php b/src/Entity/Stations/MeasurementAggregate/Temperature.php new file mode 100644 index 0000000..91acd6f --- /dev/null +++ b/src/Entity/Stations/MeasurementAggregate/Temperature.php @@ -0,0 +1,86 @@ +nullableFloat('min'), + maximum: $reader->nullableFloat('max'), + average: $reader->nullableFloat('average'), + weight: $reader->nullableInt('weight'), + ); + } + + public function minimum(): ?float + { + return $this->minimum; + } + + public function minimumUnit(): Unit + { + return Unit::CELSIUS; + } + + public function minimumWithUnit(): ?string + { + return $this->format($this->minimum); + } + + public function maximum(): ?float + { + return $this->maximum; + } + + public function maximumUnit(): Unit + { + return Unit::CELSIUS; + } + + public function maximumWithUnit(): ?string + { + return $this->format($this->maximum); + } + + public function average(): ?float + { + return $this->average; + } + + public function averageUnit(): Unit + { + return Unit::CELSIUS; + } + + public function averageWithUnit(): ?string + { + return $this->format($this->average); + } + + public function weight(): ?int + { + return $this->weight; + } + + private function format(?float $temperature): ?string + { + return MeasurementFormatter::format($temperature, Unit::CELSIUS); + } +} diff --git a/src/Entity/Stations/MeasurementAggregate/Wind.php b/src/Entity/Stations/MeasurementAggregate/Wind.php new file mode 100644 index 0000000..a0d2564 --- /dev/null +++ b/src/Entity/Stations/MeasurementAggregate/Wind.php @@ -0,0 +1,60 @@ +nullableFloat('deg'), + speed: $reader->nullableFloat('speed'), + ); + } + + public function direction(): ?float + { + return $this->direction; + } + + public function directionUnit(): Unit + { + return Unit::DEGREE; + } + + public function directionWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->direction, + $this->directionUnit(), + ); + } + + public function speed(): ?float + { + return $this->speed; + } + + public function speedUnit(): Unit + { + return Unit::METERS_PER_SECOND; + } + + public function speedWithUnit(): ?string + { + return MeasurementFormatter::format($this->speed, $this->speedUnit()); + } +} diff --git a/src/Entity/Stations/Station.php b/src/Entity/Stations/Station.php new file mode 100644 index 0000000..d751c4e --- /dev/null +++ b/src/Entity/Stations/Station.php @@ -0,0 +1,113 @@ +nullableString('id'); + $registrationId = $reader->nullableString('ID'); + + if ($id === null && $registrationId === null) { + throw HydrationException::invalidType( + self::class, + 'id', + 'string', + null, + ); + } + + return new self( + id: $id ?? $registrationId, + createdAt: $reader->requiredDateTime('created_at'), + updatedAt: $reader->requiredDateTime('updated_at'), + externalId: $reader->requiredString('external_id'), + name: $reader->requiredString('name'), + latitude: $reader->requiredFloat('latitude'), + longitude: $reader->requiredFloat('longitude'), + altitude: $reader->requiredFloat('altitude'), + rank: $reader->requiredInt('rank'), + userId: $reader->nullableString('user_id'), + sourceType: $reader->nullableInt('source_type'), + ); + } + + public function id(): string + { + return $this->id; + } + + public function createdAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function updatedAt(): \DateTimeImmutable + { + return $this->updatedAt; + } + + public function externalId(): string + { + return $this->externalId; + } + + public function name(): string + { + return $this->name; + } + + public function latitude(): float + { + return $this->latitude; + } + + public function longitude(): float + { + return $this->longitude; + } + + public function altitude(): float + { + return $this->altitude; + } + + public function rank(): int + { + return $this->rank; + } + + public function userId(): ?string + { + return $this->userId; + } + + public function sourceType(): ?int + { + return $this->sourceType; + } +} diff --git a/src/Entity/Timezone.php b/src/Entity/Timezone.php deleted file mode 100644 index 95c0485..0000000 --- a/src/Entity/Timezone.php +++ /dev/null @@ -1,26 +0,0 @@ -offset = $data['timezone_offset']; - $this->identifier = $data['timezone'] ?? null; - } - - public function getOffset(): int - { - return $this->offset; - } - - public function getIdentifier(): ?string - { - return $this->identifier; - } -} \ No newline at end of file diff --git a/src/Entity/Weather/Clouds.php b/src/Entity/Weather/Clouds.php new file mode 100644 index 0000000..cbf4e43 --- /dev/null +++ b/src/Entity/Weather/Clouds.php @@ -0,0 +1,40 @@ +nullableInt('all'), + ); + } + + public function coverage(): ?int + { + return $this->coverage; + } + + public function coverageUnit(): Unit + { + return Unit::PERCENT; + } + + public function coverageWithUnit(): ?string + { + return MeasurementFormatter::format($this->coverage, $this->coverageUnit()); + } +} diff --git a/src/Entity/Weather/Concern/HasPrecipitationProbability.php b/src/Entity/Weather/Concern/HasPrecipitationProbability.php new file mode 100644 index 0000000..6d37d2d --- /dev/null +++ b/src/Entity/Weather/Concern/HasPrecipitationProbability.php @@ -0,0 +1,36 @@ +precipitationProbability; + } + + public function precipitationProbabilityUnit(): Unit + { + return Unit::PERCENT; + } + + public function precipitationProbabilityWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->precipitationProbability, + $this->precipitationProbabilityUnit(), + ); + } + + private static function normalizePrecipitationProbability( + ?float $probability, + ): ?float { + // OpenWeather returns a fraction from zero to one, while the public + // getter presents the probability on the same percentage scale as + // other percentage measurements. + return $probability === null ? null : $probability * 100; + } +} diff --git a/src/Entity/Weather/Concern/HasWeatherMeasurements.php b/src/Entity/Weather/Concern/HasWeatherMeasurements.php new file mode 100644 index 0000000..d1da12b --- /dev/null +++ b/src/Entity/Weather/Concern/HasWeatherMeasurements.php @@ -0,0 +1,154 @@ +temperature; + } + + public function temperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function temperatureWithUnit(): ?string + { + return $this->formatTemperature($this->temperature); + } + + public function feelsLikeTemperature(): ?float + { + return $this->feelsLikeTemperature; + } + + public function feelsLikeTemperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function feelsLikeTemperatureWithUnit(): ?string + { + return $this->formatTemperature($this->feelsLikeTemperature); + } + + public function minimumTemperature(): ?float + { + return $this->minimumTemperature; + } + + public function minimumTemperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function minimumTemperatureWithUnit(): ?string + { + return $this->formatTemperature($this->minimumTemperature); + } + + public function maximumTemperature(): ?float + { + return $this->maximumTemperature; + } + + public function maximumTemperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function maximumTemperatureWithUnit(): ?string + { + return $this->formatTemperature($this->maximumTemperature); + } + + public function pressure(): ?int + { + return $this->pressure; + } + + public function pressureUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function pressureWithUnit(): ?string + { + return $this->formatPressure($this->pressure); + } + + public function humidity(): ?int + { + return $this->humidity; + } + + public function humidityUnit(): Unit + { + return Unit::PERCENT; + } + + public function humidityWithUnit(): ?string + { + return MeasurementFormatter::format($this->humidity, $this->humidityUnit()); + } + + public function seaLevelPressure(): ?int + { + return $this->seaLevelPressure; + } + + public function seaLevelPressureUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function seaLevelPressureWithUnit(): ?string + { + return $this->formatPressure($this->seaLevelPressure); + } + + public function groundLevelPressure(): ?int + { + return $this->groundLevelPressure; + } + + public function groundLevelPressureUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function groundLevelPressureWithUnit(): ?string + { + return $this->formatPressure($this->groundLevelPressure); + } + + public function visibility(): ?int + { + return $this->visibility; + } + + public function visibilityUnit(): Unit + { + return Unit::METER; + } + + public function visibilityWithUnit(): ?string + { + return MeasurementFormatter::format($this->visibility, $this->visibilityUnit()); + } + + private function formatTemperature(?float $temperature): ?string + { + return MeasurementFormatter::format($temperature, $this->units->temperatureUnit()); + } + + private function formatPressure(?int $pressure): ?string + { + return MeasurementFormatter::format($pressure, Unit::HECTOPASCAL); + } +} diff --git a/src/Entity/Weather/Condition.php b/src/Entity/Weather/Condition.php new file mode 100644 index 0000000..7694358 --- /dev/null +++ b/src/Entity/Weather/Condition.php @@ -0,0 +1,62 @@ +nullableInt('id'), + group: $reader->nullableString('main'), + description: $reader->nullableString('description'), + icon: $reader->nullableString('icon'), + ); + } + + public function id(): ?int + { + return $this->id; + } + + public function group(): ?string + { + return $this->group; + } + + public function description(): ?string + { + return $this->description; + } + + public function icon(): ?string + { + return $this->icon; + } + + public function iconUrl(): ?string + { + if ($this->icon === null) { + return null; + } + + return sprintf(self::ICON_URL, rawurlencode($this->icon)); + } +} diff --git a/src/Entity/Weather/Current.php b/src/Entity/Weather/Current.php new file mode 100644 index 0000000..81f7823 --- /dev/null +++ b/src/Entity/Weather/Current.php @@ -0,0 +1,168 @@ + $conditions + */ + private function __construct( + private readonly ?Coordinates $coordinates, + private readonly array $conditions, + private readonly ?float $temperature, + private readonly ?float $feelsLikeTemperature, + private readonly ?float $minimumTemperature, + private readonly ?float $maximumTemperature, + private readonly ?int $pressure, + private readonly ?int $humidity, + private readonly ?int $seaLevelPressure, + private readonly ?int $groundLevelPressure, + private readonly ?int $visibility, + private readonly ?Wind $wind, + private readonly ?Clouds $clouds, + private readonly ?Precipitation $rain, + private readonly ?Precipitation $snow, + private readonly ?\DateTimeImmutable $dateTime, + private readonly ?string $countryCode, + private readonly ?\DateTimeImmutable $sunriseAt, + private readonly ?\DateTimeImmutable $sunsetAt, + private readonly ?int $timezoneOffset, + private readonly ?int $id, + private readonly ?string $name, + private readonly Units $units, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $coordinates = $reader->nullableArray('coord'); + $conditions = []; + + foreach ($reader->nullableArray('weather') ?? [] as $index => $condition) { + if (!is_array($condition)) { + throw HydrationException::invalidType( + self::class, + sprintf('weather.%s', $index), + 'array', + $condition, + ); + } + + $conditions[] = Condition::fromArray($condition, $context); + } + + $wind = $reader->nullableArray('wind'); + $clouds = $reader->nullableArray('clouds'); + $rain = $reader->nullableArray('rain'); + $snow = $reader->nullableArray('snow'); + + return new self( + coordinates: $coordinates === null + ? null + : Coordinates::fromArray($coordinates, $context), + conditions: $conditions, + temperature: $reader->nullableFloat('main.temp'), + feelsLikeTemperature: $reader->nullableFloat('main.feels_like'), + minimumTemperature: $reader->nullableFloat('main.temp_min'), + maximumTemperature: $reader->nullableFloat('main.temp_max'), + pressure: $reader->nullableInt('main.pressure'), + humidity: $reader->nullableInt('main.humidity'), + seaLevelPressure: $reader->nullableInt('main.sea_level'), + groundLevelPressure: $reader->nullableInt('main.grnd_level'), + visibility: $reader->nullableInt('visibility'), + wind: $wind === null ? null : Wind::fromArray($wind, $context), + clouds: $clouds === null ? null : Clouds::fromArray($clouds, $context), + rain: $rain === null ? null : Precipitation::fromArray($rain, $context), + snow: $snow === null ? null : Precipitation::fromArray($snow, $context), + dateTime: $reader->nullableTimestamp('dt'), + countryCode: $reader->nullableString('sys.country'), + sunriseAt: $reader->nullableTimestamp('sys.sunrise'), + sunsetAt: $reader->nullableTimestamp('sys.sunset'), + timezoneOffset: $reader->nullableInt('timezone'), + id: $reader->nullableInt('id'), + name: $reader->nullableString('name'), + units: UnitsResolver::fromContext($context), + ); + } + + public function coordinates(): ?Coordinates + { + return $this->coordinates; + } + + /** + * @return list + */ + public function conditions(): array + { + return $this->conditions; + } + + public function wind(): ?Wind + { + return $this->wind; + } + + public function clouds(): ?Clouds + { + return $this->clouds; + } + + public function rain(): ?Precipitation + { + return $this->rain; + } + + public function snow(): ?Precipitation + { + return $this->snow; + } + + public function dateTime(): ?\DateTimeImmutable + { + return $this->dateTime; + } + + public function countryCode(): ?string + { + return $this->countryCode; + } + + public function sunriseAt(): ?\DateTimeImmutable + { + return $this->sunriseAt; + } + + public function sunsetAt(): ?\DateTimeImmutable + { + return $this->sunsetAt; + } + + public function timezoneOffset(): ?int + { + return $this->timezoneOffset; + } + + public function id(): ?int + { + return $this->id; + } + + public function name(): ?string + { + return $this->name; + } +} diff --git a/src/Entity/Weather/Current/Precipitation.php b/src/Entity/Weather/Current/Precipitation.php new file mode 100644 index 0000000..3d22281 --- /dev/null +++ b/src/Entity/Weather/Current/Precipitation.php @@ -0,0 +1,42 @@ +nullableFloat('1h'), + ); + } + + public function lastHour(): ?float + { + return $this->lastHour; + } + + public function lastHourUnit(): Unit + { + // Current Weather documents the one-hour precipitation value as mm/h. + // https://openweathermap.org/api/current + return Unit::MILLIMETERS_PER_HOUR; + } + + public function lastHourWithUnit(): ?string + { + return MeasurementFormatter::format($this->lastHour, $this->lastHourUnit()); + } +} diff --git a/src/Entity/Weather/Forecast.php b/src/Entity/Weather/Forecast.php new file mode 100644 index 0000000..5085ed3 --- /dev/null +++ b/src/Entity/Weather/Forecast.php @@ -0,0 +1,67 @@ + $periods + */ + private function __construct( + private readonly ?int $count, + private readonly array $periods, + private readonly ?City $city, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $periods = []; + + foreach ($reader->nullableArray('list') ?? [] as $index => $period) { + if (!is_array($period)) { + throw HydrationException::invalidType( + self::class, + sprintf('list.%s', $index), + 'array', + $period, + ); + } + + $periods[] = Period::fromArray($period, $context); + } + + $city = $reader->nullableArray('city'); + + return new self( + count: $reader->nullableInt('cnt'), + periods: $periods, + city: $city === null ? null : City::fromArray($city, $context), + ); + } + + public function count(): ?int + { + return $this->count; + } + + /** + * @return list + */ + public function periods(): array + { + return $this->periods; + } + + public function city(): ?City + { + return $this->city; + } +} diff --git a/src/Entity/Weather/Forecast/City.php b/src/Entity/Weather/Forecast/City.php new file mode 100644 index 0000000..ce61009 --- /dev/null +++ b/src/Entity/Weather/Forecast/City.php @@ -0,0 +1,81 @@ +nullableArray('coord'); + + return new self( + id: $reader->nullableInt('id'), + name: $reader->nullableString('name'), + coordinates: $coordinates === null + ? null + : Coordinates::fromArray($coordinates, $context), + countryCode: $reader->nullableString('country'), + population: $reader->nullableInt('population'), + timezoneOffset: $reader->nullableInt('timezone'), + sunriseAt: $reader->nullableTimestamp('sunrise'), + sunsetAt: $reader->nullableTimestamp('sunset'), + ); + } + + public function id(): ?int + { + return $this->id; + } + + public function name(): ?string + { + return $this->name; + } + + public function coordinates(): ?Coordinates + { + return $this->coordinates; + } + + public function countryCode(): ?string + { + return $this->countryCode; + } + + public function population(): ?int + { + return $this->population; + } + + public function timezoneOffset(): ?int + { + return $this->timezoneOffset; + } + + public function sunriseAt(): ?\DateTimeImmutable + { + return $this->sunriseAt; + } + + public function sunsetAt(): ?\DateTimeImmutable + { + return $this->sunsetAt; + } +} diff --git a/src/Entity/Weather/Forecast/Period.php b/src/Entity/Weather/Forecast/Period.php new file mode 100644 index 0000000..16a3055 --- /dev/null +++ b/src/Entity/Weather/Forecast/Period.php @@ -0,0 +1,168 @@ + $conditions + */ + private function __construct( + private readonly ?\DateTimeImmutable $dateTime, + private readonly ?float $temperature, + private readonly ?float $feelsLikeTemperature, + private readonly ?float $minimumTemperature, + private readonly ?float $maximumTemperature, + private readonly ?int $pressure, + private readonly ?int $humidity, + private readonly ?int $seaLevelPressure, + private readonly ?int $groundLevelPressure, + private readonly ?float $dewPoint, + private readonly array $conditions, + private readonly ?Clouds $clouds, + private readonly ?Wind $wind, + private readonly ?int $visibility, + private readonly ?float $precipitationProbability, + private readonly ?Precipitation $rain, + private readonly ?Precipitation $snow, + private readonly ?PartOfDay $partOfDay, + private readonly Units $units, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $conditions = []; + + foreach ($reader->nullableArray('weather') ?? [] as $index => $condition) { + if (!is_array($condition)) { + throw HydrationException::invalidType( + self::class, + sprintf('weather.%s', $index), + 'array', + $condition, + ); + } + + $conditions[] = Condition::fromArray($condition, $context); + } + + $clouds = $reader->nullableArray('clouds'); + $wind = $reader->nullableArray('wind'); + $rain = $reader->nullableArray('rain'); + $snow = $reader->nullableArray('snow'); + $partOfDay = $reader->nullableString('sys.pod'); + + if ($partOfDay !== null) { + $partOfDay = PartOfDay::tryFrom($partOfDay) + ?? throw HydrationException::invalidValue( + self::class, + 'sys.pod', + '"d" or "n"', + $partOfDay, + ); + } + + return new self( + dateTime: $reader->nullableTimestamp('dt'), + temperature: $reader->nullableFloat('main.temp'), + feelsLikeTemperature: $reader->nullableFloat('main.feels_like'), + minimumTemperature: $reader->nullableFloat('main.temp_min'), + maximumTemperature: $reader->nullableFloat('main.temp_max'), + pressure: $reader->nullableInt('main.pressure'), + humidity: $reader->nullableInt('main.humidity'), + seaLevelPressure: $reader->nullableInt('main.sea_level'), + groundLevelPressure: $reader->nullableInt('main.grnd_level'), + // Captured 5 Day Forecast responses include dew_point even though its field table omits it. + // The related Hourly Forecast contract documents the field and its unit behavior. + // https://openweathermap.org/forecast5 + // https://openweathermap.org/api/hourly-forecast?collection=current_forecast + dewPoint: $reader->nullableFloat('main.dew_point'), + conditions: $conditions, + clouds: $clouds === null ? null : Clouds::fromArray($clouds, $context), + wind: $wind === null ? null : Wind::fromArray($wind, $context), + visibility: $reader->nullableInt('visibility'), + precipitationProbability: self::normalizePrecipitationProbability( + $reader->nullableFloat('pop'), + ), + rain: $rain === null ? null : Precipitation::fromArray($rain, $context), + snow: $snow === null ? null : Precipitation::fromArray($snow, $context), + partOfDay: $partOfDay, + units: UnitsResolver::fromContext($context), + ); + } + + public function dateTime(): ?\DateTimeImmutable + { + return $this->dateTime; + } + + public function dewPoint(): ?float + { + return $this->dewPoint; + } + + public function dewPointUnit(): Unit + { + return $this->temperatureUnit(); + } + + public function dewPointWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->dewPoint, + $this->dewPointUnit(), + ); + } + + /** + * @return list + */ + public function conditions(): array + { + return $this->conditions; + } + + public function clouds(): ?Clouds + { + return $this->clouds; + } + + public function wind(): ?Wind + { + return $this->wind; + } + + public function rain(): ?Precipitation + { + return $this->rain; + } + + public function snow(): ?Precipitation + { + return $this->snow; + } + + public function partOfDay(): ?PartOfDay + { + return $this->partOfDay; + } +} diff --git a/src/Entity/Weather/Forecast/Precipitation.php b/src/Entity/Weather/Forecast/Precipitation.php new file mode 100644 index 0000000..9d8ba95 --- /dev/null +++ b/src/Entity/Weather/Forecast/Precipitation.php @@ -0,0 +1,45 @@ +nullableFloat('3h'), + ); + } + + public function lastThreeHours(): ?float + { + return $this->lastThreeHours; + } + + public function lastThreeHoursUnit(): Unit + { + // The forecast reports a three-hour volume in mm, unlike Current Weather's mm/h value. + // https://openweathermap.org/api/forecast5 + return Unit::MILLIMETER; + } + + public function lastThreeHoursWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->lastThreeHours, + $this->lastThreeHoursUnit(), + ); + } +} diff --git a/src/Entity/Weather/Weather.php b/src/Entity/Weather/Weather.php deleted file mode 100644 index 5e43e1b..0000000 --- a/src/Entity/Weather/Weather.php +++ /dev/null @@ -1,31 +0,0 @@ -location = new Location([ - 'lat' => $data['coord']['lat'], - 'lon' => $data['coord']['lon'], - 'id' => $data['id'] ?? null, - 'name' => $data['name'] ?? null, - 'country' => $data['sys']['country'] ?? null, - 'sunrise' => $data['sys']['sunrise'], - 'sunset' => $data['sys']['sunset'], - 'timezone_offset' => $data['timezone'] - ]); - } - - public function getLocation(): Location - { - return $this->location; - } -} \ No newline at end of file diff --git a/src/Entity/Weather/WeatherCollection.php b/src/Entity/Weather/WeatherCollection.php deleted file mode 100644 index dbb7f3d..0000000 --- a/src/Entity/Weather/WeatherCollection.php +++ /dev/null @@ -1,50 +0,0 @@ -numResults = $data['cnt']; - - $this->location = new Location([ - 'lat' => $data['city']['coord']['lat'], - 'lon' => $data['city']['coord']['lon'], - 'id' => $data['city']['id'] ?? null, - 'name' => $data['city']['name'] ?? null, - 'country' => $data['city']['country'] ?? null, - 'population' => $data['city']['population'] ?? null, - 'sunrise' => $data['city']['sunrise'], - 'sunset' => $data['city']['sunset'], - 'timezone_offset' => $data['city']['timezone'] - ]); - - $this->data = EntityHelper::createEntityList(WeatherData::class, $data['list']); - } - - public function getNumResults(): int - { - return $this->numResults; - } - - public function getLocation(): Location - { - return $this->location; - } - - public function getData(): array - { - return $this->data; - } -} \ No newline at end of file diff --git a/src/Entity/Weather/WeatherData.php b/src/Entity/Weather/WeatherData.php deleted file mode 100644 index 4b61522..0000000 --- a/src/Entity/Weather/WeatherData.php +++ /dev/null @@ -1,143 +0,0 @@ -dateTime = \DateTimeImmutable::createFromFormat('U', $data['dt']); - $this->temperature = $data['main']['temp']; - $this->temperatureFeelsLike = $data['main']['feels_like']; - $this->minTemperature = $data['main']['temp_min']; - $this->maxTemperature = $data['main']['temp_max']; - $this->humidity = $data['main']['humidity']; - $this->cloudiness = $data['clouds']['all']; - $this->visibility = $data['visibility'] ?? null; - $this->atmosphericPressure = $data['main']['pressure']; - $this->conditions = EntityHelper::createEntityList(Condition::class, $data['weather']); - $this->wind = new Wind($data['wind']); - $this->precipitationProbability = isset($data['pop']) ? round($data['pop'] * 100) : null; - $this->rainVolume = $data['rain']['1h'] ?? $data['rain']['3h'] ?? null; - $this->snowVolume = $data['snow']['1h'] ?? $data['snow']['3h'] ?? null; - } - - /** - * DateTime in UTC - */ - public function getDateTime(): \DateTimeImmutable - { - return $this->dateTime; - } - - public function getTemperature(): float - { - return $this->temperature; - } - - public function getTemperatureFeelsLike(): float - { - return $this->temperatureFeelsLike; - } - - public function getMinTemperature(): float - { - return $this->minTemperature; - } - - public function getMaxTemperature(): float - { - return $this->maxTemperature; - } - - public function getHumidity(): int - { - return $this->humidity; - } - - public function getCloudiness(): int - { - return $this->cloudiness; - } - - /** - * Visibility, meters - * Maximum value is 10000 - */ - public function getVisibility(): ?int - { - return $this->visibility; - } - - /** - * Atmospheric pressure on the sea level, hPa - */ - public function getAtmosphericPressure(): int - { - return $this->atmosphericPressure; - } - - public function getConditions(): array - { - return $this->conditions; - } - - public function getWind(): Wind - { - return $this->wind; - } - - public function getPrecipitationProbability(): ?int - { - return $this->precipitationProbability; - } - - /** - * Rain volume, mm - */ - public function getRainVolume(): ?float - { - return $this->rainVolume; - } - - /** - * Snow volume, mm - */ - public function getSnowVolume(): ?float - { - return $this->snowVolume; - } -} \ No newline at end of file diff --git a/src/Entity/Weather/Wind.php b/src/Entity/Weather/Wind.php new file mode 100644 index 0000000..addd388 --- /dev/null +++ b/src/Entity/Weather/Wind.php @@ -0,0 +1,78 @@ +nullableFloat('speed'), + direction: $reader->nullableInt('deg'), + gust: $reader->nullableFloat('gust'), + units: UnitsResolver::fromContext($context), + ); + } + + public function speed(): ?float + { + return $this->speed; + } + + public function speedUnit(): Unit + { + return $this->units->speedUnit(); + } + + public function speedWithUnit(): ?string + { + return MeasurementFormatter::format($this->speed, $this->speedUnit()); + } + + public function direction(): ?int + { + return $this->direction; + } + + public function directionUnit(): Unit + { + return Unit::DEGREE; + } + + public function directionWithUnit(): ?string + { + return MeasurementFormatter::format($this->direction, $this->directionUnit()); + } + + public function gust(): ?float + { + return $this->gust; + } + + public function gustUnit(): Unit + { + return $this->units->speedUnit(); + } + + public function gustWithUnit(): ?string + { + return MeasurementFormatter::format($this->gust, $this->gustUnit()); + } +} diff --git a/src/Entity/Wind.php b/src/Entity/Wind.php deleted file mode 100644 index 77ae282..0000000 --- a/src/Entity/Wind.php +++ /dev/null @@ -1,37 +0,0 @@ -speed = $data['speed']; - $this->direction = $data['deg']; - $this->gust = $data['gust'] ?? null; - } - - public function getSpeed(): float - { - return $this->speed; - } - - /** - * Wind direction, degrees - */ - public function getDirection(): int - { - return $this->direction; - } - - public function getGust(): ?float - { - return $this->gust; - } -} \ No newline at end of file diff --git a/src/Enum/AggregationInterval.php b/src/Enum/AggregationInterval.php new file mode 100644 index 0000000..d19c965 --- /dev/null +++ b/src/Enum/AggregationInterval.php @@ -0,0 +1,15 @@ +value; + } +} diff --git a/src/Enum/Units.php b/src/Enum/Units.php new file mode 100644 index 0000000..1bd2304 --- /dev/null +++ b/src/Enum/Units.php @@ -0,0 +1,27 @@ + Unit::KELVIN, + self::METRIC => Unit::CELSIUS, + self::IMPERIAL => Unit::FAHRENHEIT, + }; + } + + public function speedUnit(): Unit + { + return match ($this) { + self::STANDARD, self::METRIC => Unit::METERS_PER_SECOND, + self::IMPERIAL => Unit::MILES_PER_HOUR, + }; + } +} diff --git a/src/Exception/ApiErrorException.php b/src/Exception/ApiErrorException.php deleted file mode 100644 index 88209f3..0000000 --- a/src/Exception/ApiErrorException.php +++ /dev/null @@ -1,22 +0,0 @@ -parameters = $error['parameters'] ?? null; - } - - public function getParameters(): ?array - { - return $this->parameters; - } -} \ No newline at end of file diff --git a/src/Exception/ApiException.php b/src/Exception/ApiException.php new file mode 100644 index 0000000..4ecf232 --- /dev/null +++ b/src/Exception/ApiException.php @@ -0,0 +1,103 @@ +response()->data(); + $statusCode = $context->statusCode(); + + return new static( + message: self::resolveMessage( + statusCode: $statusCode, + reasonPhrase: $context->response()->raw()->getReasonPhrase(), + data: $data + ), + statusCode: $statusCode, + apiCode: self::resolveApiCode($data), + responseData: $data + ); + } + + public function statusCode(): int + { + return $this->statusCode; + } + + public function apiCode(): ?int + { + return $this->apiCode; + } + + public function responseData(): mixed + { + return $this->responseData; + } + + private static function resolveMessage( + int $statusCode, + string $reasonPhrase, + mixed $data + ): string { + if (is_array($data)) { + $message = $data['message'] ?? null; + + if (is_string($message) && trim($message) !== '') { + return $message; + } + } + + if (is_string($data) && trim($data) !== '') { + return $data; + } + + if (trim($reasonPhrase) !== '') { + return sprintf( + 'OpenWeather API request failed with HTTP %d (%s).', + $statusCode, + $reasonPhrase + ); + } + + return sprintf( + 'OpenWeather API request failed with HTTP %d.', + $statusCode + ); + } + + private static function resolveApiCode(mixed $data): ?int + { + if (!is_array($data)) { + return null; + } + + // OpenWeather APIs use both keys, and some legacy responses encode numeric codes as strings. + // Keep the public API consistently nullable-int. + foreach (['cod', 'code'] as $key) { + $code = $data[$key] ?? null; + + if (is_int($code)) { + return $code; + } + + if (is_string($code) && preg_match('/^\d+$/', $code) === 1) { + return (int) $code; + } + } + + return null; + } +} diff --git a/src/Exception/BadRequestException.php b/src/Exception/BadRequestException.php index 6f2affc..aa1b9c4 100644 --- a/src/Exception/BadRequestException.php +++ b/src/Exception/BadRequestException.php @@ -2,4 +2,4 @@ namespace ProgrammatorDev\OpenWeatherMap\Exception; -class BadRequestException extends ApiErrorException {} \ No newline at end of file +class BadRequestException extends ApiException {} diff --git a/src/Exception/HydrationException.php b/src/Exception/HydrationException.php new file mode 100644 index 0000000..987b78c --- /dev/null +++ b/src/Exception/HydrationException.php @@ -0,0 +1,36 @@ +symbol()); + } +} diff --git a/src/Helper/EntityHelper.php b/src/Helper/EntityHelper.php deleted file mode 100644 index 2993ca6..0000000 --- a/src/Helper/EntityHelper.php +++ /dev/null @@ -1,20 +0,0 @@ -getConstants(); - - // Sort by alphabetical order - // to be more intuitive when listing values for error messages - asort($constants); - - return $constants; - } -} \ No newline at end of file diff --git a/src/Hydration/OneCall/PaginationUrlNormalizer.php b/src/Hydration/OneCall/PaginationUrlNormalizer.php new file mode 100644 index 0000000..4d9bc61 --- /dev/null +++ b/src/Hydration/OneCall/PaginationUrlNormalizer.php @@ -0,0 +1,19 @@ +createUri($url); + + // Keep relative references relative so the resolver can apply the configured base URL; + // absolute references are upgraded to HTTPS without changing their query. + return (string) ($uri->getHost() === '' ? $uri : $uri->withScheme('https')); + } +} diff --git a/src/Hydration/PayloadReader.php b/src/Hydration/PayloadReader.php new file mode 100644 index 0000000..77b5bbb --- /dev/null +++ b/src/Hydration/PayloadReader.php @@ -0,0 +1,272 @@ +nullableValue( + $path, + 'string', + static fn(mixed $value): bool => is_string($value) + ); + } + + public function nullableInt(string $path): ?int + { + return $this->nullableValue( + $path, + 'int', + static fn(mixed $value): bool => is_int($value) + ); + } + + public function nullableFloat(string $path): ?float + { + // Whole JSON numbers decode as integers even when the field represents + // a measurement that this library exposes as a float. + $value = $this->nullableValue( + $path, + 'int|float', + static fn(mixed $value): bool => is_int($value) || is_float($value) + ); + + return $value === null ? null : (float) $value; + } + + public function nullableBool(string $path): ?bool + { + return $this->nullableValue( + $path, + 'bool', + static fn(mixed $value): bool => is_bool($value) + ); + } + + public function nullableArray(string $path): ?array + { + return $this->nullableValue( + $path, + 'array', + static fn(mixed $value): bool => is_array($value) + ); + } + + /** + * @return list|null + */ + public function nullableStringList(string $path): ?array + { + $values = $this->nullableArray($path); + + if ($values === null) { + return null; + } + + $strings = []; + + foreach ($values as $index => $value) { + if (!is_string($value)) { + throw HydrationException::invalidType( + $this->entity, + sprintf('%s.%s', $path, $index), + 'string', + $value + ); + } + + $strings[] = $value; + } + + return $strings; + } + + public function nullableTimestamp(string $path): ?\DateTimeImmutable + { + $timestamp = $this->nullableInt($path); + + if ($timestamp === null) { + return null; + } + + return (new \DateTimeImmutable(sprintf('@%d', $timestamp))) + ->setTimezone(new \DateTimeZone('UTC')); + } + + public function nullableDateTime(string $path): ?\DateTimeImmutable + { + $value = $this->nullableString($path); + + if ($value === null) { + return null; + } + + // Station responses use UTC ISO 8601 strings with variable + // fractional-second precision, including nanoseconds. + if (preg_match( + '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/D', + $value, + ) !== 1) { + throw HydrationException::invalidValue( + $this->entity, + $path, + 'ISO 8601 date-time string', + $value, + ); + } + + try { + $dateTime = new \DateTimeImmutable($value); + } catch (\Exception) { + throw HydrationException::invalidValue( + $this->entity, + $path, + 'ISO 8601 date-time string', + $value, + ); + } + + $errors = \DateTimeImmutable::getLastErrors(); + + if ($errors !== false + && ($errors['warning_count'] > 0 || $errors['error_count'] > 0) + ) { + throw HydrationException::invalidValue( + $this->entity, + $path, + 'ISO 8601 date-time string', + $value, + ); + } + + return $dateTime->setTimezone(new \DateTimeZone('UTC')); + } + + public function requiredString(string $path): string + { + return $this->requiredValue( + $path, + 'string', + $this->nullableString($path), + ); + } + + public function requiredInt(string $path): int + { + return $this->requiredValue( + $path, + 'int', + $this->nullableInt($path), + ); + } + + public function requiredFloat(string $path): float + { + return $this->requiredValue( + $path, + 'int|float', + $this->nullableFloat($path), + ); + } + + public function requiredDateTime(string $path): \DateTimeImmutable + { + return $this->requiredValue( + $path, + 'ISO 8601 date-time string', + $this->nullableDateTime($path), + ); + } + + private function requiredValue( + string $path, + string $expectedType, + mixed $value, + ): mixed { + if ($value === null) { + throw HydrationException::invalidType( + $this->entity, + $path, + $expectedType, + $value, + ); + } + + return $value; + } + + /** + * @param \Closure(mixed): bool $accepts + */ + private function nullableValue( + string $path, + string $expectedType, + \Closure $accepts + ): mixed { + $value = $this->value($path); + + if ($value === null) { + return null; + } + + if (!$accepts($value)) { + throw HydrationException::invalidType( + $this->entity, + $path, + $expectedType, + $value + ); + } + + return $value; + } + + private function value(string $path): mixed + { + $value = $this->payload; + $resolvedPath = []; + + // Dot-separated paths allow concise entities to expose values from + // nested API payload structures without mirroring every container. + foreach (explode('.', $path) as $segment) { + if (!is_array($value)) { + throw HydrationException::invalidType( + $this->entity, + implode('.', $resolvedPath), + 'array', + $value + ); + } + + if (!array_key_exists($segment, $value)) { + return null; + } + + $value = $value[$segment]; + $resolvedPath[] = $segment; + + if ($value === null) { + return null; + } + } + + return $value; + } +} diff --git a/src/Hydration/UnitsResolver.php b/src/Hydration/UnitsResolver.php new file mode 100644 index 0000000..d0e875e --- /dev/null +++ b/src/Hydration/UnitsResolver.php @@ -0,0 +1,30 @@ +config()->get( + OpenWeatherMap::OPTION_UNITS, + Units::METRIC, + ) ?? Units::METRIC; + + if (!$units instanceof Units) { + throw new \LogicException(sprintf( + 'The hydration context "%s" value must be an instance of %s.', + OpenWeatherMap::OPTION_UNITS, + Units::class, + )); + } + + return $units; + } +} diff --git a/src/Language/Language.php b/src/Language/Language.php deleted file mode 100644 index 9c914b9..0000000 --- a/src/Language/Language.php +++ /dev/null @@ -1,60 +0,0 @@ -optionsResolver = new OptionsResolver(); - - $this->options = $this->configureOptions($options); - $this->configureApi(); + $this->apiKey = $this->validateApiKey($apiKey); + $options = $this->validateOptions($options); + + $this->config($options, defaults: [ + self::OPTION_UNITS => Units::METRIC, + self::OPTION_LANGUAGE => Language::ENGLISH, + ]); + + $this->baseUrl(self::BASE_URL); + $this->auth()->query(self::AUTHENTICATION_KEY, $this->apiKey); + $this->responses()->custom(new PayloadDecoder()); + + $this->errors()->when(static fn (ErrorContext $context): ?ApiException => match (true) { + $context->statusCode() === 400 => BadRequestException::fromContext($context), + $context->statusCode() === 401 => UnauthorizedException::fromContext($context), + $context->statusCode() === 404 => NotFoundException::fromContext($context), + $context->statusCode() === 429 => TooManyRequestsException::fromContext($context), + $context->statusCode() >= 400 && $context->statusCode() <= 599 => UnexpectedErrorException::fromContext($context), + default => null, + }); } - public function oneCall(): OneCallResource + public function airPollution(): AirPollution { - return new OneCallResource($this); + return $this->resource(AirPollution::class); } - public function assistant(): AssistantResource + public function geocoding(): Geocoding { - return new AssistantResource($this); + return $this->resource(Geocoding::class); } - public function weather(): WeatherResource + public function maps(): Maps { - return new WeatherResource($this); + // URL generation does not send a request through the SDK authentication + // pipeline, so Maps also needs the validated key directly. + return $this->resourceWith( + Maps::class, + apiKey: $this->apiKey, + ); } - public function airPollution(): AirPollutionResource + public function oneCall(): OneCall { - return new AirPollutionResource($this); + return $this->resource(OneCall::class); } - public function geocoding(): GeocodingResource + public function stations(): Stations { - return new GeocodingResource($this); + return $this->resource(Stations::class); } - private function configureOptions(array $options): array + public function weather(): Weather { - $this->optionsResolver->setDefault('unitSystem', UnitSystem::METRIC); - $this->optionsResolver->setDefault('language', Language::ENGLISH); - - $this->optionsResolver->setAllowedTypes('unitSystem', 'string'); - $this->optionsResolver->setAllowedTypes('language', 'string'); - - $this->optionsResolver->setAllowedValues('unitSystem', UnitSystem::getOptions()); - - return $this->optionsResolver->resolve($options); + return $this->resource(Weather::class); } - private function configureApi(): void + private function validateApiKey( + #[\SensitiveParameter] string $apiKey, + ): string { - $this->setBaseUrl('https://api.openweathermap.org'); - - $this->setAuthentication(new QueryParam(['appid' => $this->apiKey])); - - $this->addQueryDefault('units', $this->options['unitSystem']); - $this->addQueryDefault('lang', $this->options['language']); - - $this->addPostRequestListener(function(PostRequestEvent $event) { - $response = $event->getResponse(); - $statusCode = $response->getStatusCode(); - - // if there was a response with an error status code - if ($statusCode >= 400) { - $error = json_decode($response->getBody()->getContents(), true); + return Assert::notBlank($apiKey, 'API key'); + } - match ($statusCode) { - 400 => throw new BadRequestException($error), - 401 => throw new UnauthorizedException($error), - 404 => throw new NotFoundException($error), - 429 => throw new TooManyRequestsException($error), - default => throw new UnexpectedErrorException($error) - }; - } - }); + private function validateOptions(array $options): array + { + $unknownOptions = array_diff( + array_keys($options), + [self::OPTION_LANGUAGE, self::OPTION_UNITS] + ); + + if ($unknownOptions !== []) { + throw new \InvalidArgumentException(sprintf( + 'Unknown OpenWeatherMap option%s: %s.', + count($unknownOptions) === 1 ? '' : 's', + implode(', ', $unknownOptions) + )); + } + + if (array_key_exists(self::OPTION_UNITS, $options)) { + $this->validateUnits($options[self::OPTION_UNITS]); + } + + if (array_key_exists(self::OPTION_LANGUAGE, $options)) { + $options[self::OPTION_LANGUAGE] = $this->validateLanguage( + $options[self::OPTION_LANGUAGE], + ); + } + + return $options; + } - $this->addResponseContentsListener(function(ResponseContentsEvent $event) { - // decode json string response into an array - $contents = $event->getContents(); - $contents = json_decode($contents, true); + private function validateUnits(mixed $units): void + { + if (!$units instanceof Units) { + throw new \InvalidArgumentException(sprintf( + 'The "%s" option must be an instance of %s.', + self::OPTION_UNITS, + Units::class + )); + } + } - $event->setContents($contents); - }); + private function validateLanguage(mixed $language): Language|string + { + if (!$language instanceof Language && !is_string($language)) { + throw new \InvalidArgumentException(sprintf( + 'The "%s" option must be an instance of %s or a string.', + self::OPTION_LANGUAGE, + Language::class + )); + } + + return is_string($language) + ? Assert::notBlank($language, sprintf('"%s" option', self::OPTION_LANGUAGE)) + : $language; } -} \ No newline at end of file +} diff --git a/src/Request/Stations/CloudLayer.php b/src/Request/Stations/CloudLayer.php new file mode 100644 index 0000000..3445a22 --- /dev/null +++ b/src/Request/Stations/CloudLayer.php @@ -0,0 +1,62 @@ +distance = Assert::nullableFiniteNumber( + $distance, + 'cloud layer distance', + ); + $this->condition = Assert::nullableNotBlank( + $condition, + 'cloud layer condition', + ); + $this->cumulus = Assert::nullableNotBlank( + $cumulus, + 'cloud layer cumulus type', + ); + + Assert::notEmpty($this->toArray(), 'cloud layer values'); + } + + public function distance(): ?float + { + return $this->distance; + } + + public function condition(): ?string + { + return $this->condition; + } + + public function cumulus(): ?string + { + return $this->cumulus; + } + + /** + * @return array + */ + public function toArray(): array + { + return array_filter([ + 'distance' => $this->distance, + 'condition' => $this->condition, + 'cumulus' => $this->cumulus, + ], static fn(mixed $value): bool => $value !== null); + } +} diff --git a/src/Request/Stations/Measurement.php b/src/Request/Stations/Measurement.php new file mode 100644 index 0000000..a92efae --- /dev/null +++ b/src/Request/Stations/Measurement.php @@ -0,0 +1,282 @@ + + */ + private readonly array $clouds; + + /** + * @var list + */ + private readonly array $weather; + + /** + * @param list $clouds + * @param list $weather + */ + public function __construct( + \DateTimeInterface $dateTime, + ?float $temperature = null, + ?float $windSpeed = null, + ?float $windGust = null, + ?int $windDirection = null, + ?float $pressure = null, + ?float $humidity = null, + ?float $rainLastHour = null, + ?float $rainLastSixHours = null, + ?float $rainLastTwentyFourHours = null, + ?float $snowLastHour = null, + ?float $snowLastSixHours = null, + ?float $snowLastTwentyFourHours = null, + ?float $dewPoint = null, + ?float $humidex = null, + ?float $heatIndex = null, + ?float $visibilityDistance = null, + ?string $visibilityPrefix = null, + array $clouds = [], + array $weather = [], + ) { + $this->dateTime = \DateTimeImmutable::createFromInterface($dateTime) + ->setTimezone(new \DateTimeZone('UTC')); + $this->temperature = Assert::nullableFiniteNumber($temperature, 'temperature'); + $this->windSpeed = Assert::nullableFiniteNumber($windSpeed, 'wind speed'); + $this->windGust = Assert::nullableFiniteNumber($windGust, 'wind gust'); + $this->windDirection = $windDirection === null + ? null + : Assert::integerBetween($windDirection, 0, 360, 'wind direction'); + $this->pressure = Assert::nullableFiniteNumber($pressure, 'pressure'); + $this->humidity = Assert::nullableFiniteNumber($humidity, 'humidity'); + $this->rainLastHour = Assert::nullableFiniteNumber( + $rainLastHour, + 'one-hour rainfall', + ); + $this->rainLastSixHours = Assert::nullableFiniteNumber( + $rainLastSixHours, + 'six-hour rainfall', + ); + $this->rainLastTwentyFourHours = Assert::nullableFiniteNumber( + $rainLastTwentyFourHours, + '24-hour rainfall', + ); + $this->snowLastHour = Assert::nullableFiniteNumber( + $snowLastHour, + 'one-hour snowfall', + ); + $this->snowLastSixHours = Assert::nullableFiniteNumber( + $snowLastSixHours, + 'six-hour snowfall', + ); + $this->snowLastTwentyFourHours = Assert::nullableFiniteNumber( + $snowLastTwentyFourHours, + '24-hour snowfall', + ); + $this->dewPoint = Assert::nullableFiniteNumber($dewPoint, 'dew point'); + $this->humidex = Assert::nullableFiniteNumber($humidex, 'humidex'); + $this->heatIndex = Assert::nullableFiniteNumber($heatIndex, 'heat index'); + $this->visibilityDistance = Assert::nullableFiniteNumber( + $visibilityDistance, + 'visibility distance', + ); + $this->visibilityPrefix = Assert::nullableNotBlank( + $visibilityPrefix, + 'visibility prefix', + ); + $this->clouds = array_values(Assert::allInstancesOf( + $clouds, + CloudLayer::class, + 'cloud layer', + )); + $this->weather = array_values(Assert::allInstancesOf( + $weather, + Weather::class, + 'weather', + )); + } + + public function dateTime(): \DateTimeImmutable + { + return $this->dateTime; + } + + public function temperature(): ?float + { + return $this->temperature; + } + + public function windSpeed(): ?float + { + return $this->windSpeed; + } + + public function windGust(): ?float + { + return $this->windGust; + } + + public function windDirection(): ?int + { + return $this->windDirection; + } + + public function pressure(): ?float + { + return $this->pressure; + } + + public function humidity(): ?float + { + return $this->humidity; + } + + public function rainLastHour(): ?float + { + return $this->rainLastHour; + } + + public function rainLastSixHours(): ?float + { + return $this->rainLastSixHours; + } + + public function rainLastTwentyFourHours(): ?float + { + return $this->rainLastTwentyFourHours; + } + + public function snowLastHour(): ?float + { + return $this->snowLastHour; + } + + public function snowLastSixHours(): ?float + { + return $this->snowLastSixHours; + } + + public function snowLastTwentyFourHours(): ?float + { + return $this->snowLastTwentyFourHours; + } + + public function dewPoint(): ?float + { + return $this->dewPoint; + } + + public function humidex(): ?float + { + return $this->humidex; + } + + public function heatIndex(): ?float + { + return $this->heatIndex; + } + + public function visibilityDistance(): ?float + { + return $this->visibilityDistance; + } + + public function visibilityPrefix(): ?string + { + return $this->visibilityPrefix; + } + + /** + * @return list + */ + public function clouds(): array + { + return $this->clouds; + } + + /** + * @return list + */ + public function weather(): array + { + return $this->weather; + } + + /** + * @return array>> + */ + public function toArray(): array + { + return array_filter([ + 'dt' => $this->dateTime->getTimestamp(), + 'temperature' => $this->temperature, + 'wind_speed' => $this->windSpeed, + 'wind_gust' => $this->windGust, + 'wind_deg' => $this->windDirection, + 'pressure' => $this->pressure, + 'humidity' => $this->humidity, + 'rain_1h' => $this->rainLastHour, + 'rain_6h' => $this->rainLastSixHours, + 'rain_24h' => $this->rainLastTwentyFourHours, + 'snow_1h' => $this->snowLastHour, + 'snow_6h' => $this->snowLastSixHours, + 'snow_24h' => $this->snowLastTwentyFourHours, + 'dew_point' => $this->dewPoint, + 'humidex' => $this->humidex, + 'heat_index' => $this->heatIndex, + 'visibility_distance' => $this->visibilityDistance, + 'visibility_prefix' => $this->visibilityPrefix, + 'clouds' => $this->clouds === [] + ? null + : array_map( + static fn(CloudLayer $cloud): array => $cloud->toArray(), + $this->clouds, + ), + 'weather' => $this->weather === [] + ? null + : array_map( + static fn(Weather $weather): array => $weather->toArray(), + $this->weather, + ), + ], static fn(mixed $value): bool => $value !== null); + } +} diff --git a/src/Request/Stations/Weather.php b/src/Request/Stations/Weather.php new file mode 100644 index 0000000..f273498 --- /dev/null +++ b/src/Request/Stations/Weather.php @@ -0,0 +1,98 @@ +precipitation = Assert::nullableNotBlank( + $precipitation, + 'weather precipitation', + ); + $this->descriptor = Assert::nullableNotBlank( + $descriptor, + 'weather descriptor', + ); + $this->intensity = Assert::nullableNotBlank( + $intensity, + 'weather intensity', + ); + $this->proximity = Assert::nullableNotBlank( + $proximity, + 'weather proximity', + ); + $this->obscuration = Assert::nullableNotBlank( + $obscuration, + 'weather obscuration', + ); + $this->other = Assert::nullableNotBlank($other, 'other weather value'); + + Assert::notEmpty($this->toArray(), 'weather values'); + } + + public function precipitation(): ?string + { + return $this->precipitation; + } + + public function descriptor(): ?string + { + return $this->descriptor; + } + + public function intensity(): ?string + { + return $this->intensity; + } + + public function proximity(): ?string + { + return $this->proximity; + } + + public function obscuration(): ?string + { + return $this->obscuration; + } + + public function other(): ?string + { + return $this->other; + } + + /** + * @return array + */ + public function toArray(): array + { + return array_filter([ + 'precipitation' => $this->precipitation, + 'descriptor' => $this->descriptor, + 'intensity' => $this->intensity, + 'proximity' => $this->proximity, + 'obscuration' => $this->obscuration, + 'other' => $this->other, + ], static fn(mixed $value): bool => $value !== null); + } +} diff --git a/src/Resource/AirPollution.php b/src/Resource/AirPollution.php new file mode 100644 index 0000000..70fdc62 --- /dev/null +++ b/src/Resource/AirPollution.php @@ -0,0 +1,80 @@ +endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + ]) + ->get('/data/2.5/air_pollution') + ->entity(Current::class); + + return $current; + } + + public function forecast(float $latitude, float $longitude): Forecast + { + $latitude = Assert::latitude($latitude); + $longitude = Assert::longitude($longitude); + + // https://openweathermap.org/api/air-pollution + /** @var Forecast $forecast */ + $forecast = $this + ->endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + ]) + ->get('/data/2.5/air_pollution/forecast') + ->entity(Forecast::class); + + return $forecast; + } + + public function history( + float $latitude, + float $longitude, + \DateTimeInterface $startAt, + \DateTimeInterface $endAt, + ): History { + $latitude = Assert::latitude($latitude); + $longitude = Assert::longitude($longitude); + Assert::chronologicalRange($startAt, $endAt); + + // A non-future end also constrains the ordered start. + // The documented minimum is left to OpenWeather because live availability differs. + $endAt = Assert::notFuture($endAt, 'end date'); + + // https://openweathermap.org/api/air-pollution + /** @var History $history */ + $history = $this + ->endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + 'start' => $startAt->getTimestamp(), + 'end' => $endAt->getTimestamp(), + ]) + ->get('/data/2.5/air_pollution/history') + ->entity(History::class); + + return $history; + } +} diff --git a/src/Resource/AirPollutionResource.php b/src/Resource/AirPollutionResource.php deleted file mode 100644 index 48980cc..0000000 --- a/src/Resource/AirPollutionResource.php +++ /dev/null @@ -1,77 +0,0 @@ -api->request( - method: Method::GET, - path: '/data/2.5/air_pollution', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - ] - ); - - return new AirPollution($data); - } - - /** - * Get access to air pollution forecast data per hour - * - * @throws ClientExceptionInterface - */ - public function getForecast(float $latitude, float $longitude): AirPollutionCollection - { - $data = $this->api->request( - method: Method::GET, - path: '/data/2.5/air_pollution/forecast', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - ] - ); - - return new AirPollutionCollection($data); - } - - /** - * Get access to historical air pollution data per hour between two dates - * - * @throws ClientExceptionInterface - */ - public function getHistory( - float $latitude, - float $longitude, - \DateTimeInterface $startDate, - \DateTimeInterface $endDate - ): AirPollutionCollection - { - $utcTimezone = new \DateTimeZone('UTC'); - - $data = $this->api->request( - method: Method::GET, - path: '/data/2.5/air_pollution/history', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - 'start' => $startDate->setTimezone($utcTimezone)->getTimestamp(), - 'end' => $endDate->setTimezone($utcTimezone)->getTimestamp() - ] - ); - - return new AirPollutionCollection($data); - } -} \ No newline at end of file diff --git a/src/Resource/AssistantResource.php b/src/Resource/AssistantResource.php deleted file mode 100644 index 7d06c16..0000000 --- a/src/Resource/AssistantResource.php +++ /dev/null @@ -1,49 +0,0 @@ -api->setAuthentication(new Header('X-Api-Key', $this->api->apiKey)); - - $data = $this->api->request( - method: Method::POST, - path: '/assistant/session', - body: json_encode(['prompt' => $prompt]) - ); - - return new Answer($data); - } - - /** - * Resume a session (continue a conversation) with the Weather AI Assistant - * - * @throws ClientExceptionInterface - */ - public function resumeSession(string $sessionId, string $prompt): Answer - { - $this->api->setAuthentication(new Header('X-Api-Key', $this->api->apiKey)); - - $data = $this->api->request( - method: Method::POST, - path: $this->api->buildPath('/assistant/session/{sessionId}', [ - 'sessionId' => $sessionId - ]), - body: json_encode(['prompt' => $prompt]) - ); - - return new Answer($data); - } -} \ No newline at end of file diff --git a/src/Resource/Concern/WithLanguage.php b/src/Resource/Concern/WithLanguage.php new file mode 100644 index 0000000..bb3d4f6 --- /dev/null +++ b/src/Resource/Concern/WithLanguage.php @@ -0,0 +1,27 @@ +withConfig([ + OpenWeatherMap::OPTION_LANGUAGE => $language, + ]); + } + + protected function resolvedLanguage(): Language|string + { + return $this->runtime->config()->get(OpenWeatherMap::OPTION_LANGUAGE); + } +} diff --git a/src/Resource/Concern/WithUnits.php b/src/Resource/Concern/WithUnits.php new file mode 100644 index 0000000..617eeeb --- /dev/null +++ b/src/Resource/Concern/WithUnits.php @@ -0,0 +1,21 @@ +withConfig([ + OpenWeatherMap::OPTION_UNITS => $units, + ]); + } + + protected function resolvedUnits(): Units + { + return $this->runtime->config()->get(OpenWeatherMap::OPTION_UNITS); + } +} diff --git a/src/Resource/Geocoding.php b/src/Resource/Geocoding.php new file mode 100644 index 0000000..3fcf6ec --- /dev/null +++ b/src/Resource/Geocoding.php @@ -0,0 +1,77 @@ + + */ + public function byName(string $name, ?int $limit = null): array + { + $name = Assert::notBlank($name, 'location name'); + + // The direct endpoint documents a maximum of five results. + // https://openweathermap.org/api/geocoding-api?collection=other + if ($limit !== null) { + $limit = Assert::integerBetween($limit, 1, 5, 'result limit'); + } + + return $this + ->endpoint() + ->queries([ + 'q' => $name, + 'limit' => $limit, + ]) + ->get('/geo/1.0/direct') + ->collection(Location::class); + } + + public function byPostalCode(string $postalCode, string $countryCode): PostalLocation + { + $postalCode = Assert::notBlank($postalCode, 'postal code'); + $countryCode = Assert::countryCode($countryCode); + + /** @var PostalLocation $location */ + $location = $this + ->endpoint() + ->query('zip', sprintf('%s,%s', $postalCode, $countryCode)) + ->get('/geo/1.0/zip') + ->entity(PostalLocation::class); + + return $location; + } + + /** + * @return list + */ + public function byCoordinates( + float $latitude, + float $longitude, + ?int $limit = null, + ): array { + $latitude = Assert::latitude($latitude); + $longitude = Assert::longitude($longitude); + + // The reverse endpoint documents no maximum result limit. + // https://openweathermap.org/api/geocoding-api?collection=other + if ($limit !== null) { + $limit = Assert::positiveInteger($limit, 'result limit'); + } + + return $this + ->endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + 'limit' => $limit, + ]) + ->get('/geo/1.0/reverse') + ->collection(Location::class); + } +} diff --git a/src/Resource/GeocodingResource.php b/src/Resource/GeocodingResource.php deleted file mode 100644 index dfdcd87..0000000 --- a/src/Resource/GeocodingResource.php +++ /dev/null @@ -1,73 +0,0 @@ -api->request( - method: Method::GET, - path: '/geo/1.0/direct', - query: [ - 'q' => $locationName, - 'limit' => $numResults - ] - ); - - return EntityHelper::createEntityList(Location::class, $data); - } - - /** - * Get geographical coordinates (latitude, longitude) by using the zip/postal code - * - * @throws ClientExceptionInterface - */ - public function getByZipCode(string $zipCode, string $countryCode): ZipLocation - { - $data = $this->api->request( - method: Method::GET, - path: '/geo/1.0/zip', - query: [ - 'zip' => \sprintf('%s,%s', $zipCode, $countryCode) - ] - ); - - return new ZipLocation($data); - } - - /** - * Get the name of the location (city name or area name) by using geographical coordinates (latitude, longitude) - * - * @return Location[] - * @throws ClientExceptionInterface - */ - public function getByCoordinate(float $latitude, float $longitude, int $numResults = self::NUM_RESULTS): array - { - $data = $this->api->request( - method: Method::GET, - path: '/geo/1.0/reverse', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - 'limit' => $numResults - ] - ); - - return EntityHelper::createEntityList(Location::class, $data); - } -} \ No newline at end of file diff --git a/src/Resource/Maps.php b/src/Resource/Maps.php new file mode 100644 index 0000000..42957de --- /dev/null +++ b/src/Resource/Maps.php @@ -0,0 +1,105 @@ +appendAuthentication( + $this->buildTileUrl($layer, $zoom, $x, $y), + ); + } + + public function tileUrlTemplate(MapLayer $layer): string + { + // XYZ clients replace these placeholders for every visible tile. + return $this->appendAuthentication(sprintf( + self::TILE_URL, + rawurlencode($layer->value), + '{z}', + '{x}', + '{y}', + )); + } + + public function tile( + MapLayer $layer, + int $zoom, + int $x, + int $y, + ): MapTile { + $response = $this + ->endpoint() + ->get($this->buildTileUrl($layer, $zoom, $x, $y)); + $contents = $response->data(); + + if (!is_string($contents)) { + throw new \UnexpectedValueException( + 'Map tile response body must contain raw image data.', + ); + } + + $contentType = $response->raw()->getHeaderLine('Content-Type'); + $mediaType = strtolower(trim(explode(';', $contentType, 2)[0])); + + if ($mediaType !== 'image/png') { + throw new \UnexpectedValueException( + 'Map tile response must use the "image/png" content type.', + ); + } + + return new MapTile($contents, $contentType); + } + + private function appendAuthentication(string $url): string + { + // URL generation does not send a request through the SDK authentication + // pipeline, so append the same query credential explicitly. + return sprintf( + '%s?%s=%s', + $url, + OpenWeatherMap::AUTHENTICATION_KEY, + rawurlencode($this->apiKey), + ); + } + + private function buildTileUrl( + MapLayer $layer, + int $zoom, + int $x, + int $y, + ): string { + $x = Assert::tileCoordinate($x, $zoom, 'X'); + $y = Assert::tileCoordinate($y, $zoom, 'Y'); + + return sprintf( + self::TILE_URL, + rawurlencode($layer->value), + $zoom, + $x, + $y, + ); + } +} diff --git a/src/Resource/OneCall.php b/src/Resource/OneCall.php new file mode 100644 index 0000000..8c4c5a3 --- /dev/null +++ b/src/Resource/OneCall.php @@ -0,0 +1,171 @@ +endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + 'units' => $this->resolvedUnits(), + 'lang' => $this->resolvedLanguage(), + ]) + ->get('/data/4.0/onecall/current') + ->entity(Current::class); + + return $current; + } + + public function minuteTimeline(float $latitude, float $longitude): MinuteTimeline + { + $latitude = Assert::latitude($latitude); + $longitude = Assert::longitude($longitude); + + // https://openweathermap.org/api/one-call-4#min + /** @var MinuteTimeline $timeline */ + $timeline = $this + ->endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + 'units' => $this->resolvedUnits(), + 'lang' => $this->resolvedLanguage(), + ]) + ->get('/data/4.0/onecall/timeline/1min') + ->entity(MinuteTimeline::class); + + return $timeline; + } + + public function fifteenMinuteTimeline( + float $latitude, + float $longitude, + ?\DateTimeInterface $startAt = null, + ?int $count = null, + ): FifteenMinuteTimeline { + $latitude = Assert::latitude($latitude); + $longitude = Assert::longitude($longitude); + + if ($count !== null) { + $count = Assert::positiveInteger($count, 'timeline count'); + } + + // https://openweathermap.org/api/one-call-4#15min + /** @var FifteenMinuteTimeline $timeline */ + $timeline = $this + ->endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + 'start' => $startAt?->getTimestamp(), + 'cnt' => $count, + 'units' => $this->resolvedUnits(), + 'lang' => $this->resolvedLanguage(), + ]) + ->get('/data/4.0/onecall/timeline/15min') + ->entity(FifteenMinuteTimeline::class); + + return $timeline; + } + + public function hourTimeline( + float $latitude, + float $longitude, + ?\DateTimeInterface $startAt = null, + ?int $count = null, + ): HourTimeline { + $latitude = Assert::latitude($latitude); + $longitude = Assert::longitude($longitude); + + if ($count !== null) { + $count = Assert::positiveInteger($count, 'timeline count'); + } + + // https://openweathermap.org/api/one-call-4#hourly + /** @var HourTimeline $timeline */ + $timeline = $this + ->endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + 'start' => $startAt?->getTimestamp(), + 'cnt' => $count, + 'units' => $this->resolvedUnits(), + 'lang' => $this->resolvedLanguage(), + ]) + ->get('/data/4.0/onecall/timeline/1h') + ->entity(HourTimeline::class); + + return $timeline; + } + + public function dayTimeline( + float $latitude, + float $longitude, + ?\DateTimeInterface $startAt = null, + ?int $count = null, + ): DayTimeline { + $latitude = Assert::latitude($latitude); + $longitude = Assert::longitude($longitude); + + if ($count !== null) { + $count = Assert::positiveInteger($count, 'timeline count'); + } + + // https://openweathermap.org/api/one-call-4#daily + /** @var DayTimeline $timeline */ + $timeline = $this + ->endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + 'start' => $startAt?->getTimestamp(), + 'cnt' => $count, + 'units' => $this->resolvedUnits(), + 'lang' => $this->resolvedLanguage(), + ]) + ->get('/data/4.0/onecall/timeline/1day') + ->entity(DayTimeline::class); + + return $timeline; + } + + public function alert(string $id): Alert + { + $id = Assert::notBlank($id, 'alert ID'); + + // https://openweathermap.org/api/one-call-4#alerts + /** @var Alert $alert */ + $alert = $this + ->endpoint() + ->get('/data/4.0/onecall/alert/{id}', [ + 'id' => $id, + ]) + ->entity(Alert::class); + + return $alert; + } +} diff --git a/src/Resource/OneCallResource.php b/src/Resource/OneCallResource.php deleted file mode 100644 index 375dede..0000000 --- a/src/Resource/OneCallResource.php +++ /dev/null @@ -1,101 +0,0 @@ -api->request( - method: Method::GET, - path: '/data/3.0/onecall', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - ] - ); - - return new Weather($data); - } - - /** - * Get access to weather data for any datetime - * - * @throws ClientExceptionInterface - */ - public function getWeatherByDate(float $latitude, float $longitude, \DateTimeInterface $dateTime): WeatherMoment - { - $utcTimezone = new \DateTimeZone('UTC'); - - $data = $this->api->request( - method: Method::GET, - path: '/data/3.0/onecall/timemachine', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - 'dt' => $dateTime->setTimezone($utcTimezone)->getTimestamp() - ] - ); - - return new WeatherMoment($data); - } - - /** - * Get access to aggregated weather data for a particular date - * - * @throws ClientExceptionInterface - */ - public function getWeatherSummaryByDate(float $latitude, float $longitude, \DateTimeInterface $date): WeatherSummary - { - $data = $this->api->request( - method: Method::GET, - path: '/data/3.0/onecall/day_summary', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - 'date' => $date->format('Y-m-d'), - 'tz' => $date->format('P') - ] - ); - - return new WeatherSummary($data); - } - - /** - * Get the weather overview with a human-readable summary for today and tomorrow's forecast, using OpenWeather AI - * - * @throws ClientExceptionInterface - */ - public function getWeatherOverviewByDate(float $latitude, float $longitude, \DateTimeInterface $date): WeatherOverview - { - $data = $this->api->request( - method: Method::GET, - path: '/data/3.0/onecall/overview', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - 'date' => $date->format('Y-m-d') - ] - ); - - return new WeatherOverview($data); - } -} \ No newline at end of file diff --git a/src/Resource/Resource.php b/src/Resource/Resource.php deleted file mode 100644 index d17aef8..0000000 --- a/src/Resource/Resource.php +++ /dev/null @@ -1,13 +0,0 @@ -endpoint() + ->json(self::stationPayload( + $externalId, + $name, + $latitude, + $longitude, + $altitude, + )) + ->post('/data/3.0/stations') + ->entity(Station::class); + + return $station; + } + + public function update( + string $id, + string $externalId, + string $name, + float $latitude, + float $longitude, + float $altitude, + ): Station { + $id = Assert::notBlank($id, 'station ID'); + + // https://openweathermap.org/api/stations + /** @var Station $station */ + $station = $this + ->endpoint() + ->json(self::stationPayload( + $externalId, + $name, + $latitude, + $longitude, + $altitude, + )) + ->put('/data/3.0/stations/{id}', [ + 'id' => $id, + ]) + ->entity(Station::class); + + return $station; + } + + /** + * @return list + */ + public function all(): array + { + // https://openweathermap.org/api/stations + return $this + ->endpoint() + ->get('/data/3.0/stations') + ->collection(Station::class); + } + + public function find(string $id): Station + { + $id = Assert::notBlank($id, 'station ID'); + + // https://openweathermap.org/api/stations + /** @var Station $station */ + $station = $this + ->endpoint() + ->get('/data/3.0/stations/{id}', [ + 'id' => $id, + ]) + ->entity(Station::class); + + return $station; + } + + public function delete(string $id): void + { + $id = Assert::notBlank($id, 'station ID'); + + // https://openweathermap.org/api/stations + $this + ->endpoint() + ->delete('/data/3.0/stations/{id}', [ + 'id' => $id, + ]); + } + + public function submitMeasurement( + string $stationId, + Measurement $measurement, + ): void + { + $this->submitMeasurements($stationId, [$measurement]); + } + + /** + * @param list $measurements + */ + public function submitMeasurements( + string $stationId, + array $measurements, + ): void + { + $stationId = Assert::notBlank($stationId, 'station ID'); + $measurements = Assert::notEmpty($measurements, 'station measurements'); + $measurements = Assert::allInstancesOf( + $measurements, + Measurement::class, + 'station measurement', + ); + $payload = []; + + foreach ($measurements as $measurement) { + $payload[] = [ + 'station_id' => $stationId, + ...$measurement->toArray(), + ]; + } + + // https://openweathermap.org/api/stations#measurement + $this + ->endpoint() + ->json($payload) + ->post('/data/3.0/measurements'); + } + + /** + * @return list + */ + public function measurements( + string $stationId, + AggregationInterval $interval, + \DateTimeInterface $startAt, + \DateTimeInterface $endAt, + int $limit, + ): array { + $stationId = Assert::notBlank($stationId, 'station ID'); + Assert::chronologicalRange($startAt, $endAt); + $limit = Assert::positiveInteger($limit, 'result limit'); + + // https://openweathermap.org/api/stations#measurement + return $this + ->endpoint() + ->queries([ + 'station_id' => $stationId, + 'type' => $interval, + 'limit' => $limit, + 'from' => $startAt->getTimestamp(), + 'to' => $endAt->getTimestamp(), + ]) + ->get('/data/3.0/measurements') + ->collection(MeasurementAggregate::class); + } + + /** + * @return array{ + * external_id: string, + * name: string, + * latitude: float, + * longitude: float, + * altitude: float + * } + */ + private static function stationPayload( + string $externalId, + string $name, + float $latitude, + float $longitude, + float $altitude, + ): array { + return [ + 'external_id' => Assert::notBlank($externalId, 'external station ID'), + 'name' => Assert::notBlank($name, 'station name'), + 'latitude' => Assert::latitude($latitude), + 'longitude' => Assert::longitude($longitude), + 'altitude' => Assert::finiteNumber($altitude, 'station altitude'), + ]; + } +} diff --git a/src/Resource/Util/CacheTrait.php b/src/Resource/Util/CacheTrait.php deleted file mode 100644 index eadfd25..0000000 --- a/src/Resource/Util/CacheTrait.php +++ /dev/null @@ -1,16 +0,0 @@ -api->getCacheBuilder()?->setTtl($ttl); - - return $clone; - } -} \ No newline at end of file diff --git a/src/Resource/Util/LanguageTrait.php b/src/Resource/Util/LanguageTrait.php deleted file mode 100644 index fecfad7..0000000 --- a/src/Resource/Util/LanguageTrait.php +++ /dev/null @@ -1,16 +0,0 @@ -api->addQueryDefault('lang', $language); - - return $clone; - } -} \ No newline at end of file diff --git a/src/Resource/Util/UnitSystemTrait.php b/src/Resource/Util/UnitSystemTrait.php deleted file mode 100644 index 168a220..0000000 --- a/src/Resource/Util/UnitSystemTrait.php +++ /dev/null @@ -1,16 +0,0 @@ -api->addQueryDefault('units', $unitSystem); - - return $clone; - } -} \ No newline at end of file diff --git a/src/Resource/Weather.php b/src/Resource/Weather.php new file mode 100644 index 0000000..6d733f5 --- /dev/null +++ b/src/Resource/Weather.php @@ -0,0 +1,66 @@ +endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + 'units' => $this->resolvedUnits(), + 'lang' => $this->resolvedLanguage(), + ]) + ->get('/data/2.5/weather') + ->entity(Current::class); + + return $weather; + } + + public function forecast( + float $latitude, + float $longitude, + ?int $count = null, + ): Forecast { + $latitude = Assert::latitude($latitude); + $longitude = Assert::longitude($longitude); + + if ($count !== null) { + $count = Assert::positiveInteger($count, 'forecast count'); + } + + // https://openweathermap.org/api/forecast5 + /** @var Forecast $forecast */ + $forecast = $this + ->endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + 'cnt' => $count, + 'units' => $this->resolvedUnits(), + 'lang' => $this->resolvedLanguage(), + ]) + ->get('/data/2.5/forecast') + ->entity(Forecast::class); + + return $forecast; + } +} diff --git a/src/Resource/WeatherResource.php b/src/Resource/WeatherResource.php deleted file mode 100644 index 96a5b06..0000000 --- a/src/Resource/WeatherResource.php +++ /dev/null @@ -1,57 +0,0 @@ -api->request( - method: Method::GET, - path: '/data/2.5/weather', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - ] - ); - - return new Weather($data); - } - - /** - * Get access to 5-day weather forecast data with 3-hour steps - * - * @throws ClientExceptionInterface - */ - public function getForecast(float $latitude, float $longitude, int $numResults = self::NUM_RESULTS): WeatherCollection - { - $data = $this->api->request( - method: Method::GET, - path: '/data/2.5/forecast', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - 'cnt' => $numResults - ] - ); - - return new WeatherCollection($data); - } -} \ No newline at end of file diff --git a/src/Response/MapTile.php b/src/Response/MapTile.php new file mode 100644 index 0000000..08d0305 --- /dev/null +++ b/src/Response/MapTile.php @@ -0,0 +1,21 @@ +contents; + } + + public function contentType(): string + { + return $this->contentType; + } +} diff --git a/src/Response/PayloadDecoder.php b/src/Response/PayloadDecoder.php new file mode 100644 index 0000000..a71dd49 --- /dev/null +++ b/src/Response/PayloadDecoder.php @@ -0,0 +1,46 @@ +getBody()->rewind(); + $contents = $response->getBody()->getContents(); + + if ($contents === '') { + return null; + } + + try { + return json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + $contentType = strtolower($response->getHeaderLine('Content-Type')); + + // Images are successful binary payloads, while non-JSON error + // bodies still need to reach the HTTP error mapper unchanged. + if ( + $response->getStatusCode() >= 400 + || str_starts_with($contentType, 'image/') + ) { + return $contents; + } + + // A successful non-image response is expected to be valid JSON; + // preserve the previous strict failure instead of hiding corruption. + throw $exception; + } + } +} diff --git a/src/Test/AbstractTest.php b/src/Test/AbstractTest.php deleted file mode 100644 index 66c6a8b..0000000 --- a/src/Test/AbstractTest.php +++ /dev/null @@ -1,27 +0,0 @@ -mockClient = new Client(); - - $this->api = new OpenWeatherMap(self::API_KEY); - $this->api->setClientBuilder(new ClientBuilder($this->mockClient)); - } -} \ No newline at end of file diff --git a/src/Test/MockResponse.php b/src/Test/MockResponse.php deleted file mode 100644 index ffcd0c3..0000000 --- a/src/Test/MockResponse.php +++ /dev/null @@ -1,27 +0,0 @@ -mockClient->addResponse(new Response( - status: 200, - body: $responseBody - )); - - $response = $this->api->$resource()->$method(...$args); - $this->assertContainsOnlyInstancesOf($responseClass, $response); - } - - abstract public static function provideCollectionResponseData(): \Generator; -} \ No newline at end of file diff --git a/src/Test/Util/TestItemResponseTrait.php b/src/Test/Util/TestItemResponseTrait.php deleted file mode 100644 index c12d7e0..0000000 --- a/src/Test/Util/TestItemResponseTrait.php +++ /dev/null @@ -1,29 +0,0 @@ -mockClient->addResponse(new Response( - status: 200, - body: $responseBody - )); - - $response = $this->api->$resource()->$method(...$args); - $this->assertInstanceOf($responseClass, $response); - } - - abstract public static function provideItemResponseData(): \Generator; -} \ No newline at end of file diff --git a/src/UnitSystem/Fahrenheit.php b/src/UnitSystem/Fahrenheit.php deleted file mode 100644 index 441642a..0000000 --- a/src/UnitSystem/Fahrenheit.php +++ /dev/null @@ -1,16 +0,0 @@ - $values + * + * @return non-empty-array + */ + public static function notEmpty(array $values, string $name): array + { + if ($values === []) { + throw new \InvalidArgumentException(sprintf( + 'The %s must not be empty.', + $name, + )); + } + + return $values; + } + + /** + * @template T of object + * + * @param class-string $class + * + * @return T + */ + public static function isInstanceOf( + mixed $value, + string $class, + string $name, + ): object { + if (!$value instanceof $class) { + throw new \InvalidArgumentException(sprintf( + 'The %s must be an instance of %s.', + $name, + $class, + )); + } + + return $value; + } + + /** + * @template T of object + * + * @param array $values + * @param class-string $class + * + * @return array + */ + public static function allInstancesOf( + array $values, + string $class, + string $name, + ): array { + $instances = []; + + foreach ($values as $index => $value) { + $instances[$index] = self::isInstanceOf( + $value, + $class, + sprintf('%s at index %s', $name, $index), + ); + } + + return $instances; + } + + public static function notBlank(string $value, string $name): string + { + $value = trim($value); + + if ($value === '') { + throw new \InvalidArgumentException(sprintf( + 'The %s must be a non-empty string.', + $name, + )); + } + + return $value; + } + + public static function nullableNotBlank( + ?string $value, + string $name, + ): ?string { + return $value === null + ? null + : self::notBlank($value, $name); + } + + public static function latitude(float $latitude): float + { + if (!is_finite($latitude) || $latitude < -90 || $latitude > 90) { + throw new \InvalidArgumentException( + 'Latitude must be a finite number between -90 and 90.', + ); + } + + return $latitude; + } + + public static function longitude(float $longitude): float + { + if (!is_finite($longitude) || $longitude < -180 || $longitude > 180) { + throw new \InvalidArgumentException( + 'Longitude must be a finite number between -180 and 180.', + ); + } + + return $longitude; + } + + public static function finiteNumber(float $value, string $name): float + { + if (!is_finite($value)) { + throw new \InvalidArgumentException(sprintf( + 'The %s must be a finite number.', + $name, + )); + } + + return $value; + } + + public static function nullableFiniteNumber( + ?float $value, + string $name, + ): ?float { + return $value === null + ? null + : self::finiteNumber($value, $name); + } + + public static function countryCode(string $countryCode): string + { + $countryCode = strtoupper(trim($countryCode)); + + if (preg_match('/^[A-Z]{2}$/D', $countryCode) !== 1) { + throw new \InvalidArgumentException( + 'The country code must contain exactly two ASCII letters.', + ); + } + + return $countryCode; + } + + public static function chronologicalRange( + \DateTimeInterface $start, + \DateTimeInterface $end, + ): void { + if ($end->getTimestamp() < $start->getTimestamp()) { + throw new \InvalidArgumentException( + 'The end date must be after or equal to the start date.', + ); + } + } + + public static function notFuture( + \DateTimeInterface $value, + string $name, + ): \DateTimeInterface { + if ($value->getTimestamp() > time()) { + throw new \InvalidArgumentException(sprintf( + 'The %s must not be in the future.', + $name, + )); + } + + return $value; + } + + public static function positiveInteger(int $value, string $name): int + { + if ($value < 1) { + throw new \InvalidArgumentException(sprintf( + 'The %s must be at least 1.', + $name, + )); + } + + return $value; + } + + public static function nonNegativeInteger(int $value, string $name): int + { + if ($value < 0) { + throw new \InvalidArgumentException(sprintf( + 'The %s must be zero or greater.', + $name, + )); + } + + return $value; + } + + public static function integerBetween( + int $value, + int $minimum, + int $maximum, + string $name, + ): int { + if ($value < $minimum || $value > $maximum) { + throw new \InvalidArgumentException(sprintf( + 'The %s must be between %d and %d.', + $name, + $minimum, + $maximum, + )); + } + + return $value; + } + + public static function tileCoordinate( + int $coordinate, + int $zoom, + string $axis, + ): int { + $zoom = self::nonNegativeInteger($zoom, 'tile zoom level'); + + // Each zoom level doubles the number of tiles along both axes, making + // the valid zero-based coordinate range 0 through 2^zoom - 1. + $maximum = (2 ** $zoom) - 1; + + if ($coordinate < 0 || $coordinate > $maximum) { + if ($maximum === 0) { + throw new \InvalidArgumentException(sprintf( + 'At zoom level 0, the tile %s coordinate must be 0.', + strtoupper($axis), + )); + } + + throw new \InvalidArgumentException(sprintf( + 'At zoom level %d, the tile %s coordinate must be between 0 and %.0f.', + $zoom, + strtoupper($axis), + $maximum, + )); + } + + return $coordinate; + } +} diff --git a/tests/Fixtures/README.md b/tests/Fixtures/README.md new file mode 100644 index 0000000..72971e9 --- /dev/null +++ b/tests/Fixtures/README.md @@ -0,0 +1,96 @@ +# Response Fixtures + +Automated tests use committed response fixtures derived from representative +real OpenWeather responses. These are usually JSON, but binary APIs retain +their original response format. Tests must never make live OpenWeather +requests. + +## Naming + +Store fixtures by product, endpoint, and scenario: + +```text +tests/Fixtures///.json +tests/Fixtures///.meta.json +``` + +Name top-level product folders after the corresponding public resource, using +kebab case where required, such as `maps`, `stations`, `air-pollution`, and +`one-call`. When a resource and endpoint are the same concept, operation names +may be used directly as scenarios instead of adding a redundant folder. + +For example: + +```text +tests/Fixtures/geocoding/direct/success.json +tests/Fixtures/geocoding/direct/success.meta.json +``` + +Use stable endpoint and scenario names such as `success`, `empty`, +`missing-optional-fields`, or `invalid-request`. Do not include a captured +location name in a filename because the returned name may change or be absent. +Use the actual body format as the fixture extension, such as `.png` for a map +tile, even when the response advertises an incorrect content type. Use +`.empty` for a successful response with a zero-byte body. + +## Metadata + +Every response fixture must have a sidecar with the same basename. A captured +response sidecar follows this shape: + +```json +{ + "provenance": "captured", + "product": "Geocoding API", + "endpoint": "Direct geocoding", + "apiVersion": "1.0", + "capturedAt": "2026-07-31T12:00:00Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/geo/1.0/direct", + "query": { + "q": "Lisbon,PT", + "limit": 5 + } + }, + "sanitization": [] +} +``` + +Record non-secret request parameters, including coordinates when applicable. +Never include an API key. Synthetic fixtures use `"provenance": "synthetic"` +and describe why they were created in a `notes` field. + +For binary fixtures, also record the response content type, body format, byte +length, and SHA-256 hash. Record stable format metadata such as image dimensions +when it is useful for validation. + +## Sanitization + +Keep captured payloads as close to the real response as possible. Record every +redaction or replacement in `sanitization`, including its JSON path and the +action performed: + +```json +{ + "path": "$.station.id", + "action": "replaced private identifier with station-example" +} +``` + +- Never record API keys or authentication headers in request metadata. Because + they are excluded at the capture boundary, do not list them as sanitization. +- Remove API keys from response URLs and pagination links, and record those + response changes in `sanitization`. +- Replace identifiers, names, and coordinates belonging to persistent or + user-owned stations. +- Generated identifiers and deliberately public metadata for a temporary + fixture station may remain unchanged after its deletion is verified. +- Public test locations and coordinates may remain unchanged. +- Do not change ordinary weather or geocoding values merely to make assertions + easier. + +Capture responses manually outside PHPUnit and CI. Once committed, treat a +fixture as immutable; add a new scenario or capture rather than silently +rewriting its provenance. diff --git a/tests/Fixtures/air-pollution/current/good.json b/tests/Fixtures/air-pollution/current/good.json new file mode 100644 index 0000000..274e279 --- /dev/null +++ b/tests/Fixtures/air-pollution/current/good.json @@ -0,0 +1,24 @@ +{ + "coord": { + "lon": 151.2073, + "lat": -33.8679 + }, + "list": [ + { + "main": { + "aqi": 1 + }, + "components": { + "co": 96.56, + "no": 0.01, + "no2": 7.17, + "o3": 29.69, + "so2": 1.03, + "pm2_5": 5.89, + "pm10": 7.62, + "nh3": 0.65 + }, + "dt": 1785616883 + } + ] +} diff --git a/tests/Fixtures/air-pollution/current/good.meta.json b/tests/Fixtures/air-pollution/current/good.meta.json new file mode 100644 index 0000000..9ca7384 --- /dev/null +++ b/tests/Fixtures/air-pollution/current/good.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Air Pollution API", + "endpoint": "Current air pollution by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T20:49:47Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/air_pollution", + "query": { + "lat": -33.8688, + "lon": 151.2093 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/air-pollution/current/invalid-coordinates.json b/tests/Fixtures/air-pollution/current/invalid-coordinates.json new file mode 100644 index 0000000..e8a1d66 --- /dev/null +++ b/tests/Fixtures/air-pollution/current/invalid-coordinates.json @@ -0,0 +1,4 @@ +{ + "cod": "400", + "message": "wrong latitude" +} diff --git a/tests/Fixtures/air-pollution/current/invalid-coordinates.meta.json b/tests/Fixtures/air-pollution/current/invalid-coordinates.meta.json new file mode 100644 index 0000000..7adba4e --- /dev/null +++ b/tests/Fixtures/air-pollution/current/invalid-coordinates.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Air Pollution API", + "endpoint": "Current air pollution by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T20:49:47Z", + "httpStatus": 400, + "request": { + "method": "GET", + "path": "/data/2.5/air_pollution", + "query": { + "lat": 91, + "lon": 0 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/air-pollution/current/moderate.json b/tests/Fixtures/air-pollution/current/moderate.json new file mode 100644 index 0000000..52c5fe9 --- /dev/null +++ b/tests/Fixtures/air-pollution/current/moderate.json @@ -0,0 +1,24 @@ +{ + "coord": { + "lon": 77.209, + "lat": 28.6139 + }, + "list": [ + { + "main": { + "aqi": 3 + }, + "components": { + "co": 383.18, + "no": 0, + "no2": 8.65, + "o3": 40.98, + "so2": 1.11, + "pm2_5": 32.69, + "pm10": 35.31, + "nh3": 3 + }, + "dt": 1785616886 + } + ] +} diff --git a/tests/Fixtures/air-pollution/current/moderate.meta.json b/tests/Fixtures/air-pollution/current/moderate.meta.json new file mode 100644 index 0000000..fb4795a --- /dev/null +++ b/tests/Fixtures/air-pollution/current/moderate.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Air Pollution API", + "endpoint": "Current air pollution by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T20:49:47Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/air_pollution", + "query": { + "lat": 28.6139, + "lon": 77.209 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/air-pollution/current/poor.json b/tests/Fixtures/air-pollution/current/poor.json new file mode 100644 index 0000000..ee5fcce --- /dev/null +++ b/tests/Fixtures/air-pollution/current/poor.json @@ -0,0 +1,24 @@ +{ + "coord": { + "lon": 116.4074, + "lat": 39.9042 + }, + "list": [ + { + "main": { + "aqi": 4 + }, + "components": { + "co": 293.74, + "no": 0, + "no2": 8.33, + "o3": 28.05, + "so2": 6.92, + "pm2_5": 52.28, + "pm10": 57.21, + "nh3": 5.63 + }, + "dt": 1785616886 + } + ] +} diff --git a/tests/Fixtures/air-pollution/current/poor.meta.json b/tests/Fixtures/air-pollution/current/poor.meta.json new file mode 100644 index 0000000..55a0525 --- /dev/null +++ b/tests/Fixtures/air-pollution/current/poor.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Air Pollution API", + "endpoint": "Current air pollution by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T20:49:47Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/air_pollution", + "query": { + "lat": 39.9042, + "lon": 116.4074 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/air-pollution/forecast/good-to-moderate.json b/tests/Fixtures/air-pollution/forecast/good-to-moderate.json new file mode 100644 index 0000000..b26169a --- /dev/null +++ b/tests/Fixtures/air-pollution/forecast/good-to-moderate.json @@ -0,0 +1,1544 @@ +{ + "coord": { + "lon": 77.209, + "lat": 28.6139 + }, + "list": [ + { + "main": { + "aqi": 3 + }, + "components": { + "co": 414.72, + "no": 0, + "no2": 9.7, + "o3": 43.64, + "so2": 1.39, + "pm2_5": 36.74, + "pm10": 39.79, + "nh3": 3.44 + }, + "dt": 1785614400 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 383.18, + "no": 0, + "no2": 8.65, + "o3": 40.98, + "so2": 1.11, + "pm2_5": 32.69, + "pm10": 35.31, + "nh3": 3 + }, + "dt": 1785618000 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 358.11, + "no": 0, + "no2": 7.98, + "o3": 37.86, + "so2": 0.91, + "pm2_5": 29.46, + "pm10": 31.84, + "nh3": 2.6 + }, + "dt": 1785621600 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 337.8, + "no": 0, + "no2": 7.63, + "o3": 34.72, + "so2": 0.8, + "pm2_5": 26.77, + "pm10": 29.1, + "nh3": 2.24 + }, + "dt": 1785625200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 315.49, + "no": 0, + "no2": 7.36, + "o3": 32.97, + "so2": 0.81, + "pm2_5": 23.83, + "pm10": 26.17, + "nh3": 1.96 + }, + "dt": 1785628800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 284.77, + "no": 0.01, + "no2": 6.73, + "o3": 33.56, + "so2": 0.96, + "pm2_5": 19.95, + "pm10": 22.36, + "nh3": 1.72 + }, + "dt": 1785632400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 269.92, + "no": 0.2, + "no2": 6.78, + "o3": 32.36, + "so2": 1.08, + "pm2_5": 17.4, + "pm10": 20.02, + "nh3": 1.7 + }, + "dt": 1785636000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 253.09, + "no": 0.7, + "no2": 6.23, + "o3": 33.68, + "so2": 1.12, + "pm2_5": 15.14, + "pm10": 17.83, + "nh3": 1.71 + }, + "dt": 1785639600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 229.19, + "no": 0.66, + "no2": 5.48, + "o3": 37.93, + "so2": 1.06, + "pm2_5": 12.79, + "pm10": 15.08, + "nh3": 1.53 + }, + "dt": 1785643200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 215.36, + "no": 0.46, + "no2": 5.19, + "o3": 41.08, + "so2": 1.01, + "pm2_5": 11.81, + "pm10": 13.77, + "nh3": 1.45 + }, + "dt": 1785646800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 206.76, + "no": 0.28, + "no2": 5.03, + "o3": 43.17, + "so2": 0.99, + "pm2_5": 11.69, + "pm10": 13.42, + "nh3": 1.41 + }, + "dt": 1785650400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 203.05, + "no": 0.3, + "no2": 4.51, + "o3": 46.57, + "so2": 0.97, + "pm2_5": 11.53, + "pm10": 12.92, + "nh3": 1.26 + }, + "dt": 1785654000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 202.19, + "no": 0.37, + "no2": 3.94, + "o3": 50.34, + "so2": 0.9, + "pm2_5": 12.31, + "pm10": 13.48, + "nh3": 1.2 + }, + "dt": 1785657600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 202.5, + "no": 0.42, + "no2": 3.41, + "o3": 55.02, + "so2": 0.85, + "pm2_5": 14.16, + "pm10": 15.25, + "nh3": 1.17 + }, + "dt": 1785661200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 203.65, + "no": 0.37, + "no2": 3.3, + "o3": 59.26, + "so2": 0.8, + "pm2_5": 16.34, + "pm10": 17.48, + "nh3": 1.26 + }, + "dt": 1785664800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 209.3, + "no": 0.28, + "no2": 4.59, + "o3": 55.88, + "so2": 0.82, + "pm2_5": 15.59, + "pm10": 16.9, + "nh3": 1.54 + }, + "dt": 1785668400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 217.75, + "no": 0.21, + "no2": 6.46, + "o3": 50.18, + "so2": 0.9, + "pm2_5": 13.94, + "pm10": 15.45, + "nh3": 1.89 + }, + "dt": 1785672000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 229.18, + "no": 0.1, + "no2": 8.18, + "o3": 44.7, + "so2": 1.08, + "pm2_5": 12.83, + "pm10": 14.65, + "nh3": 2.19 + }, + "dt": 1785675600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 240.85, + "no": 0.01, + "no2": 9.15, + "o3": 39.89, + "so2": 1.24, + "pm2_5": 12.69, + "pm10": 14.86, + "nh3": 2.19 + }, + "dt": 1785679200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 248.77, + "no": 0, + "no2": 9.26, + "o3": 37.48, + "so2": 1.29, + "pm2_5": 12.81, + "pm10": 15.1, + "nh3": 1.9 + }, + "dt": 1785682800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 254.79, + "no": 0, + "no2": 9.23, + "o3": 35.38, + "so2": 1.27, + "pm2_5": 13.09, + "pm10": 15.44, + "nh3": 1.79 + }, + "dt": 1785686400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 268.97, + "no": 0, + "no2": 9.74, + "o3": 32.92, + "so2": 1.32, + "pm2_5": 13.84, + "pm10": 16.38, + "nh3": 1.9 + }, + "dt": 1785690000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 281.66, + "no": 0, + "no2": 10.28, + "o3": 30.68, + "so2": 1.31, + "pm2_5": 14.61, + "pm10": 17.11, + "nh3": 1.94 + }, + "dt": 1785693600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 289.55, + "no": 0, + "no2": 10.5, + "o3": 28.55, + "so2": 1.21, + "pm2_5": 15.22, + "pm10": 17.57, + "nh3": 1.97 + }, + "dt": 1785697200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 301.37, + "no": 0, + "no2": 10.63, + "o3": 26.34, + "so2": 1.19, + "pm2_5": 15.94, + "pm10": 18.39, + "nh3": 2.01 + }, + "dt": 1785700800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 315.9, + "no": 0, + "no2": 10.78, + "o3": 23.67, + "so2": 1.16, + "pm2_5": 16.57, + "pm10": 19.09, + "nh3": 2.04 + }, + "dt": 1785704400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 327.05, + "no": 0.01, + "no2": 10.9, + "o3": 21.09, + "so2": 1.12, + "pm2_5": 17.01, + "pm10": 19.79, + "nh3": 2.13 + }, + "dt": 1785708000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 334.83, + "no": 0.01, + "no2": 10.96, + "o3": 18.98, + "so2": 1.11, + "pm2_5": 17.33, + "pm10": 20.54, + "nh3": 2.26 + }, + "dt": 1785711600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 338.9, + "no": 0.01, + "no2": 11.04, + "o3": 17.29, + "so2": 1.14, + "pm2_5": 17.71, + "pm10": 21.36, + "nh3": 2.37 + }, + "dt": 1785715200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 340.99, + "no": 0.03, + "no2": 11.22, + "o3": 16.21, + "so2": 1.26, + "pm2_5": 18.27, + "pm10": 22.64, + "nh3": 2.42 + }, + "dt": 1785718800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 346.45, + "no": 0.33, + "no2": 11.62, + "o3": 15.52, + "so2": 1.51, + "pm2_5": 19.19, + "pm10": 24.67, + "nh3": 2.53 + }, + "dt": 1785722400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 347.53, + "no": 0.67, + "no2": 11.85, + "o3": 16.22, + "so2": 1.71, + "pm2_5": 20.17, + "pm10": 26.42, + "nh3": 2.71 + }, + "dt": 1785726000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 314.32, + "no": 0.52, + "no2": 9.19, + "o3": 26.43, + "so2": 1.57, + "pm2_5": 18.47, + "pm10": 23.65, + "nh3": 2.15 + }, + "dt": 1785729600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 302.16, + "no": 0.35, + "no2": 7.91, + "o3": 32.41, + "so2": 1.36, + "pm2_5": 16.47, + "pm10": 20.59, + "nh3": 1.81 + }, + "dt": 1785733200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 302.72, + "no": 0.25, + "no2": 7.5, + "o3": 35.08, + "so2": 1.23, + "pm2_5": 15.17, + "pm10": 18.56, + "nh3": 1.69 + }, + "dt": 1785736800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 319.6, + "no": 0.41, + "no2": 7.76, + "o3": 35.92, + "so2": 1.24, + "pm2_5": 15.07, + "pm10": 18.32, + "nh3": 1.93 + }, + "dt": 1785740400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 351.58, + "no": 0.81, + "no2": 8.64, + "o3": 35.56, + "so2": 1.35, + "pm2_5": 16.95, + "pm10": 20.59, + "nh3": 2.38 + }, + "dt": 1785744000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 383.68, + "no": 1.16, + "no2": 9.31, + "o3": 38.03, + "so2": 1.45, + "pm2_5": 19.68, + "pm10": 23.64, + "nh3": 2.77 + }, + "dt": 1785747600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 408.65, + "no": 1.18, + "no2": 10.2, + "o3": 40.35, + "so2": 1.48, + "pm2_5": 22.25, + "pm10": 26.38, + "nh3": 3.11 + }, + "dt": 1785751200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 429.38, + "no": 0.89, + "no2": 11.54, + "o3": 40.31, + "so2": 1.47, + "pm2_5": 23.19, + "pm10": 27.31, + "nh3": 3.11 + }, + "dt": 1785754800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 443.07, + "no": 0.63, + "no2": 12.82, + "o3": 40.24, + "so2": 1.46, + "pm2_5": 22.28, + "pm10": 26.17, + "nh3": 3.06 + }, + "dt": 1785758400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 445.87, + "no": 0.08, + "no2": 13.2, + "o3": 41.25, + "so2": 1.47, + "pm2_5": 20.22, + "pm10": 23.78, + "nh3": 2.9 + }, + "dt": 1785762000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 456.54, + "no": 0, + "no2": 13.08, + "o3": 39.68, + "so2": 1.52, + "pm2_5": 19.12, + "pm10": 22.82, + "nh3": 2.75 + }, + "dt": 1785765600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 463.56, + "no": 0, + "no2": 12.67, + "o3": 38.36, + "so2": 1.51, + "pm2_5": 18.55, + "pm10": 22.35, + "nh3": 2.53 + }, + "dt": 1785769200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 473.87, + "no": 0, + "no2": 12.88, + "o3": 34.53, + "so2": 1.51, + "pm2_5": 18.68, + "pm10": 22.76, + "nh3": 2.46 + }, + "dt": 1785772800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 496.24, + "no": 0, + "no2": 14, + "o3": 28.9, + "so2": 1.55, + "pm2_5": 19.4, + "pm10": 24.06, + "nh3": 2.54 + }, + "dt": 1785776400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 506.84, + "no": 0.01, + "no2": 14.67, + "o3": 24.55, + "so2": 1.45, + "pm2_5": 19.46, + "pm10": 24, + "nh3": 2.52 + }, + "dt": 1785780000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 502.31, + "no": 0.02, + "no2": 14.61, + "o3": 21.27, + "so2": 1.22, + "pm2_5": 18.9, + "pm10": 22.9, + "nh3": 2.53 + }, + "dt": 1785783600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 495.13, + "no": 0.03, + "no2": 14.4, + "o3": 17.94, + "so2": 1.09, + "pm2_5": 18.67, + "pm10": 22.53, + "nh3": 2.78 + }, + "dt": 1785787200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 487.96, + "no": 0.06, + "no2": 14.19, + "o3": 14.95, + "so2": 1.02, + "pm2_5": 18.61, + "pm10": 22.45, + "nh3": 3.1 + }, + "dt": 1785790800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 478.84, + "no": 0.09, + "no2": 13.97, + "o3": 12.85, + "so2": 0.99, + "pm2_5": 18.56, + "pm10": 22.44, + "nh3": 3.25 + }, + "dt": 1785794400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 461.7, + "no": 0.09, + "no2": 13.52, + "o3": 12.02, + "so2": 0.98, + "pm2_5": 18.16, + "pm10": 22.01, + "nh3": 2.96 + }, + "dt": 1785798000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 438.14, + "no": 0.09, + "no2": 13.15, + "o3": 11.92, + "so2": 1.01, + "pm2_5": 17.45, + "pm10": 21.28, + "nh3": 2.62 + }, + "dt": 1785801600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 419.78, + "no": 0.15, + "no2": 13.14, + "o3": 11.6, + "so2": 1.13, + "pm2_5": 16.76, + "pm10": 21, + "nh3": 2.43 + }, + "dt": 1785805200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 418.78, + "no": 0.68, + "no2": 13.64, + "o3": 10.79, + "so2": 1.36, + "pm2_5": 16.52, + "pm10": 21.57, + "nh3": 2.44 + }, + "dt": 1785808800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 413.06, + "no": 1.84, + "no2": 13.37, + "o3": 12.33, + "so2": 1.53, + "pm2_5": 16.06, + "pm10": 21.6, + "nh3": 2.45 + }, + "dt": 1785812400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 317.79, + "no": 0.99, + "no2": 9.27, + "o3": 27.36, + "so2": 1.27, + "pm2_5": 11.94, + "pm10": 15.79, + "nh3": 1.65 + }, + "dt": 1785816000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 277.21, + "no": 0.67, + "no2": 7.47, + "o3": 36.02, + "so2": 1.06, + "pm2_5": 10.25, + "pm10": 13.5, + "nh3": 1.46 + }, + "dt": 1785819600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 258.41, + "no": 0.51, + "no2": 6.75, + "o3": 41.19, + "so2": 0.96, + "pm2_5": 10.37, + "pm10": 13.4, + "nh3": 1.46 + }, + "dt": 1785823200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 247.49, + "no": 0.42, + "no2": 6.31, + "o3": 45.76, + "so2": 0.92, + "pm2_5": 11.55, + "pm10": 14.47, + "nh3": 1.49 + }, + "dt": 1785826800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 242.4, + "no": 0.49, + "no2": 5.96, + "o3": 49.94, + "so2": 0.91, + "pm2_5": 13.43, + "pm10": 16.31, + "nh3": 1.57 + }, + "dt": 1785830400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 243.17, + "no": 0.63, + "no2": 5.42, + "o3": 55.69, + "so2": 0.91, + "pm2_5": 15.93, + "pm10": 18.81, + "nh3": 1.64 + }, + "dt": 1785834000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 246.74, + "no": 0.15, + "no2": 6.14, + "o3": 58.38, + "so2": 0.91, + "pm2_5": 18.01, + "pm10": 20.89, + "nh3": 1.71 + }, + "dt": 1785837600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 248.79, + "no": 0.04, + "no2": 7.13, + "o3": 55.46, + "so2": 0.93, + "pm2_5": 17.78, + "pm10": 20.62, + "nh3": 1.76 + }, + "dt": 1785841200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 250, + "no": 0.01, + "no2": 8.22, + "o3": 52.03, + "so2": 0.98, + "pm2_5": 17.39, + "pm10": 20.21, + "nh3": 1.78 + }, + "dt": 1785844800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 263.43, + "no": 0, + "no2": 10.06, + "o3": 46.41, + "so2": 1.12, + "pm2_5": 17.47, + "pm10": 20.56, + "nh3": 1.93 + }, + "dt": 1785848400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 286.48, + "no": 0, + "no2": 12, + "o3": 40, + "so2": 1.28, + "pm2_5": 18.12, + "pm10": 21.89, + "nh3": 2.1 + }, + "dt": 1785852000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 304.11, + "no": 0, + "no2": 12.88, + "o3": 35.54, + "so2": 1.32, + "pm2_5": 18.63, + "pm10": 22.96, + "nh3": 2.12 + }, + "dt": 1785855600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 314.58, + "no": 0, + "no2": 13.09, + "o3": 31.81, + "so2": 1.29, + "pm2_5": 18.76, + "pm10": 23.42, + "nh3": 2.2 + }, + "dt": 1785859200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 328.77, + "no": 0.01, + "no2": 13.48, + "o3": 28.25, + "so2": 1.29, + "pm2_5": 19.02, + "pm10": 24.01, + "nh3": 2.46 + }, + "dt": 1785862800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 339.11, + "no": 0.01, + "no2": 13.88, + "o3": 25.09, + "so2": 1.23, + "pm2_5": 19.11, + "pm10": 23.83, + "nh3": 2.8 + }, + "dt": 1785866400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 334.15, + "no": 0.01, + "no2": 13.43, + "o3": 23.18, + "so2": 1.07, + "pm2_5": 18.73, + "pm10": 22.96, + "nh3": 2.87 + }, + "dt": 1785870000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 321.33, + "no": 0.01, + "no2": 12.15, + "o3": 22.78, + "so2": 0.93, + "pm2_5": 18.05, + "pm10": 21.81, + "nh3": 2.45 + }, + "dt": 1785873600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 305.47, + "no": 0, + "no2": 10.83, + "o3": 23.29, + "so2": 0.83, + "pm2_5": 17.02, + "pm10": 20.31, + "nh3": 2.04 + }, + "dt": 1785877200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 284.79, + "no": 0, + "no2": 9.38, + "o3": 25.61, + "so2": 0.76, + "pm2_5": 15.39, + "pm10": 18.25, + "nh3": 1.7 + }, + "dt": 1785880800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 273.46, + "no": 0, + "no2": 8.59, + "o3": 26.68, + "so2": 0.68, + "pm2_5": 14.18, + "pm10": 16.87, + "nh3": 1.6 + }, + "dt": 1785884400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 266.85, + "no": 0, + "no2": 8.22, + "o3": 27.23, + "so2": 0.65, + "pm2_5": 13.3, + "pm10": 16, + "nh3": 1.58 + }, + "dt": 1785888000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 267.75, + "no": 0.01, + "no2": 8.29, + "o3": 27.13, + "so2": 0.68, + "pm2_5": 12.62, + "pm10": 15.32, + "nh3": 1.55 + }, + "dt": 1785891600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 274.14, + "no": 0.09, + "no2": 8.84, + "o3": 26.69, + "so2": 0.74, + "pm2_5": 12.01, + "pm10": 14.87, + "nh3": 1.51 + }, + "dt": 1785895200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 274.1, + "no": 0.36, + "no2": 9.2, + "o3": 27.16, + "so2": 0.74, + "pm2_5": 11.25, + "pm10": 14.19, + "nh3": 1.45 + }, + "dt": 1785898800 + }, + { + "main": { + "aqi": 1 + }, + "components": { + "co": 252.99, + "no": 0.51, + "no2": 7.7, + "o3": 33.38, + "so2": 0.65, + "pm2_5": 9.89, + "pm10": 12.59, + "nh3": 1.22 + }, + "dt": 1785902400 + }, + { + "main": { + "aqi": 1 + }, + "components": { + "co": 237.22, + "no": 0.54, + "no2": 6.48, + "o3": 39.51, + "so2": 0.6, + "pm2_5": 9.06, + "pm10": 11.65, + "nh3": 1.12 + }, + "dt": 1785906000 + }, + { + "main": { + "aqi": 1 + }, + "components": { + "co": 234.28, + "no": 0.51, + "no2": 5.68, + "o3": 44.66, + "so2": 0.55, + "pm2_5": 9.26, + "pm10": 11.8, + "nh3": 1.08 + }, + "dt": 1785909600 + }, + { + "main": { + "aqi": 1 + }, + "components": { + "co": 237.43, + "no": 0.38, + "no2": 4.6, + "o3": 53.32, + "so2": 0.48, + "pm2_5": 9.97, + "pm10": 12.48, + "nh3": 1.02 + }, + "dt": 1785913200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 242.75, + "no": 0.25, + "no2": 4.32, + "o3": 58.49, + "so2": 0.47, + "pm2_5": 10.64, + "pm10": 13.14, + "nh3": 1.07 + }, + "dt": 1785916800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 251.7, + "no": 0.2, + "no2": 4.55, + "o3": 60.56, + "so2": 0.5, + "pm2_5": 11.3, + "pm10": 13.84, + "nh3": 1.19 + }, + "dt": 1785920400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 264.74, + "no": 0.18, + "no2": 5.23, + "o3": 61.29, + "so2": 0.58, + "pm2_5": 12.22, + "pm10": 14.92, + "nh3": 1.37 + }, + "dt": 1785924000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 272.16, + "no": 0.14, + "no2": 6.42, + "o3": 58.75, + "so2": 0.67, + "pm2_5": 13.07, + "pm10": 15.98, + "nh3": 1.59 + }, + "dt": 1785927600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 275.11, + "no": 0.1, + "no2": 7.77, + "o3": 55.35, + "so2": 0.74, + "pm2_5": 13.39, + "pm10": 16.47, + "nh3": 1.73 + }, + "dt": 1785931200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 294.23, + "no": 0.05, + "no2": 10.6, + "o3": 47.41, + "so2": 0.93, + "pm2_5": 13.88, + "pm10": 17.6, + "nh3": 2.15 + }, + "dt": 1785934800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 325.96, + "no": 0, + "no2": 13.4, + "o3": 38.5, + "so2": 1.21, + "pm2_5": 14.88, + "pm10": 19.85, + "nh3": 2.55 + }, + "dt": 1785938400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 347.45, + "no": 0.01, + "no2": 14.64, + "o3": 33.06, + "so2": 1.39, + "pm2_5": 15.82, + "pm10": 21.92, + "nh3": 2.7 + }, + "dt": 1785942000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 360.46, + "no": 0.01, + "no2": 15.1, + "o3": 28.64, + "so2": 1.47, + "pm2_5": 16.49, + "pm10": 22.88, + "nh3": 2.75 + }, + "dt": 1785945600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 369.78, + "no": 0.02, + "no2": 15.35, + "o3": 25.02, + "so2": 1.46, + "pm2_5": 17.04, + "pm10": 23.41, + "nh3": 2.67 + }, + "dt": 1785949200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 366.32, + "no": 0.02, + "no2": 15.12, + "o3": 22.69, + "so2": 1.28, + "pm2_5": 17.07, + "pm10": 22.9, + "nh3": 2.43 + }, + "dt": 1785952800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 346.04, + "no": 0.01, + "no2": 14, + "o3": 22.35, + "so2": 1, + "pm2_5": 16.44, + "pm10": 21.34, + "nh3": 2.08 + }, + "dt": 1785956400 + } + ] +} diff --git a/tests/Fixtures/air-pollution/forecast/good-to-moderate.meta.json b/tests/Fixtures/air-pollution/forecast/good-to-moderate.meta.json new file mode 100644 index 0000000..1e9b9d5 --- /dev/null +++ b/tests/Fixtures/air-pollution/forecast/good-to-moderate.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Air Pollution API", + "endpoint": "Air pollution forecast by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T20:55:10Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/air_pollution/forecast", + "query": { + "lat": 28.6139, + "lon": 77.209 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/air-pollution/forecast/moderate-to-very-poor.json b/tests/Fixtures/air-pollution/forecast/moderate-to-very-poor.json new file mode 100644 index 0000000..bec2f2b --- /dev/null +++ b/tests/Fixtures/air-pollution/forecast/moderate-to-very-poor.json @@ -0,0 +1,1544 @@ +{ + "coord": { + "lon": 116.4074, + "lat": 39.9042 + }, + "list": [ + { + "main": { + "aqi": 4 + }, + "components": { + "co": 302.89, + "no": 0, + "no2": 7.98, + "o3": 33.59, + "so2": 7.19, + "pm2_5": 54.55, + "pm10": 59.46, + "nh3": 5.55 + }, + "dt": 1785614400 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 293.74, + "no": 0, + "no2": 8.33, + "o3": 28.05, + "so2": 6.92, + "pm2_5": 52.28, + "pm10": 57.21, + "nh3": 5.63 + }, + "dt": 1785618000 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 284.17, + "no": 0.01, + "no2": 8.82, + "o3": 23.17, + "so2": 7.08, + "pm2_5": 49.53, + "pm10": 54.55, + "nh3": 5.8 + }, + "dt": 1785621600 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 281.33, + "no": 0.73, + "no2": 8.9, + "o3": 19.8, + "so2": 7.66, + "pm2_5": 47.72, + "pm10": 53.19, + "nh3": 6.22 + }, + "dt": 1785625200 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 284.56, + "no": 2.58, + "no2": 8.19, + "o3": 22.17, + "so2": 9.12, + "pm2_5": 47.42, + "pm10": 53.43, + "nh3": 6.74 + }, + "dt": 1785628800 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 285.33, + "no": 2.83, + "no2": 8.98, + "o3": 33.39, + "so2": 12.59, + "pm2_5": 49.88, + "pm10": 55.79, + "nh3": 6.35 + }, + "dt": 1785632400 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 283.81, + "no": 2.07, + "no2": 9.42, + "o3": 45, + "so2": 13.17, + "pm2_5": 54.05, + "pm10": 59.76, + "nh3": 5.94 + }, + "dt": 1785636000 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 280.23, + "no": 1.15, + "no2": 9.5, + "o3": 56.29, + "so2": 13.15, + "pm2_5": 59.12, + "pm10": 64.55, + "nh3": 5.83 + }, + "dt": 1785639600 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 272.6, + "no": 0.61, + "no2": 7.66, + "o3": 77.2, + "so2": 11.35, + "pm2_5": 62.55, + "pm10": 66.76, + "nh3": 4.54 + }, + "dt": 1785643200 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 262.99, + "no": 0.44, + "no2": 5.24, + "o3": 98.39, + "so2": 9.15, + "pm2_5": 61.95, + "pm10": 65.11, + "nh3": 3.67 + }, + "dt": 1785646800 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 253.23, + "no": 0.33, + "no2": 3.84, + "o3": 114.84, + "so2": 7.9, + "pm2_5": 61.26, + "pm10": 63.91, + "nh3": 3.55 + }, + "dt": 1785650400 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 247.13, + "no": 0.25, + "no2": 3.41, + "o3": 125.86, + "so2": 7.3, + "pm2_5": 59.97, + "pm10": 62.33, + "nh3": 3.7 + }, + "dt": 1785654000 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 241.92, + "no": 0.17, + "no2": 3.82, + "o3": 127.12, + "so2": 7.05, + "pm2_5": 57.59, + "pm10": 59.75, + "nh3": 4.03 + }, + "dt": 1785657600 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 237.55, + "no": 0.08, + "no2": 4.65, + "o3": 125.14, + "so2": 6.83, + "pm2_5": 54.38, + "pm10": 56.3, + "nh3": 4.27 + }, + "dt": 1785661200 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 237.2, + "no": 0.04, + "no2": 6.35, + "o3": 114.12, + "so2": 6.86, + "pm2_5": 52.33, + "pm10": 54.33, + "nh3": 5.2 + }, + "dt": 1785664800 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 248.69, + "no": 0.01, + "no2": 8.44, + "o3": 97.85, + "so2": 7.47, + "pm2_5": 53.6, + "pm10": 56.26, + "nh3": 6.54 + }, + "dt": 1785668400 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 266.28, + "no": 0, + "no2": 9.67, + "o3": 84.9, + "so2": 8.26, + "pm2_5": 56.99, + "pm10": 60.48, + "nh3": 7.29 + }, + "dt": 1785672000 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 284.48, + "no": 0, + "no2": 10.7, + "o3": 70.32, + "so2": 8.34, + "pm2_5": 60.47, + "pm10": 65.04, + "nh3": 8.4 + }, + "dt": 1785675600 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 301.38, + "no": 0, + "no2": 11.6, + "o3": 57.09, + "so2": 7.91, + "pm2_5": 63.2, + "pm10": 68.99, + "nh3": 10.01 + }, + "dt": 1785679200 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 307.9, + "no": 0, + "no2": 12.11, + "o3": 50.81, + "so2": 7.67, + "pm2_5": 65.34, + "pm10": 71.78, + "nh3": 11.68 + }, + "dt": 1785682800 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 300.83, + "no": 0, + "no2": 11.86, + "o3": 44.55, + "so2": 7.44, + "pm2_5": 64.97, + "pm10": 71.08, + "nh3": 12.36 + }, + "dt": 1785686400 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 297.95, + "no": 0, + "no2": 11.3, + "o3": 35.35, + "so2": 7.04, + "pm2_5": 64.2, + "pm10": 69.75, + "nh3": 11.17 + }, + "dt": 1785690000 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 298.39, + "no": 0, + "no2": 10.82, + "o3": 28.76, + "so2": 6.87, + "pm2_5": 63.84, + "pm10": 68.81, + "nh3": 10.09 + }, + "dt": 1785693600 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 299.87, + "no": 0.01, + "no2": 10.56, + "o3": 24.09, + "so2": 6.45, + "pm2_5": 64.5, + "pm10": 69.13, + "nh3": 9.53 + }, + "dt": 1785697200 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 301.34, + "no": 0.01, + "no2": 10.33, + "o3": 20.77, + "so2": 6.03, + "pm2_5": 65.72, + "pm10": 70.27, + "nh3": 8.74 + }, + "dt": 1785700800 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 300.89, + "no": 0.02, + "no2": 10.26, + "o3": 18.36, + "so2": 5.93, + "pm2_5": 66.14, + "pm10": 70.65, + "nh3": 8.11 + }, + "dt": 1785704400 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 299.25, + "no": 0.06, + "no2": 10.39, + "o3": 15.91, + "so2": 6.08, + "pm2_5": 65.29, + "pm10": 70, + "nh3": 7.64 + }, + "dt": 1785708000 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 308.51, + "no": 0.83, + "no2": 10.62, + "o3": 13.54, + "so2": 6.85, + "pm2_5": 64.19, + "pm10": 70.01, + "nh3": 7.74 + }, + "dt": 1785711600 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 325.42, + "no": 3.01, + "no2": 10.78, + "o3": 15.25, + "so2": 8.72, + "pm2_5": 64.6, + "pm10": 71.71, + "nh3": 8.04 + }, + "dt": 1785715200 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 305.88, + "no": 2.54, + "no2": 11.94, + "o3": 35.27, + "so2": 15.29, + "pm2_5": 65.3, + "pm10": 71.88, + "nh3": 6.57 + }, + "dt": 1785718800 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 293.63, + "no": 2.42, + "no2": 10.5, + "o3": 51.76, + "so2": 16.72, + "pm2_5": 67.97, + "pm10": 74.41, + "nh3": 6.07 + }, + "dt": 1785722400 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 283.54, + "no": 1.58, + "no2": 7.98, + "o3": 78.24, + "so2": 16.4, + "pm2_5": 77.33, + "pm10": 83.54, + "nh3": 5.9 + }, + "dt": 1785726000 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 234.36, + "no": 0.44, + "no2": 3.35, + "o3": 115.06, + "so2": 10.67, + "pm2_5": 70.06, + "pm10": 74.05, + "nh3": 3.51 + }, + "dt": 1785729600 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 198.65, + "no": 0.25, + "no2": 2, + "o3": 120.19, + "so2": 7.07, + "pm2_5": 51.6, + "pm10": 54.15, + "nh3": 2.44 + }, + "dt": 1785733200 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 181.13, + "no": 0.23, + "no2": 1.81, + "o3": 118.24, + "so2": 5.85, + "pm2_5": 41.78, + "pm10": 43.8, + "nh3": 2.27 + }, + "dt": 1785736800 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 176.49, + "no": 0.28, + "no2": 2.35, + "o3": 113.79, + "so2": 6.11, + "pm2_5": 37.74, + "pm10": 39.7, + "nh3": 2.85 + }, + "dt": 1785740400 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 183.3, + "no": 0.37, + "no2": 3.72, + "o3": 106.53, + "so2": 7.48, + "pm2_5": 39.28, + "pm10": 41.59, + "nh3": 4.07 + }, + "dt": 1785744000 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 194.93, + "no": 0.38, + "no2": 5.84, + "o3": 97.5, + "so2": 8.93, + "pm2_5": 42.16, + "pm10": 44.81, + "nh3": 5.02 + }, + "dt": 1785747600 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 216.01, + "no": 0.28, + "no2": 9.26, + "o3": 77.45, + "so2": 10.7, + "pm2_5": 45.31, + "pm10": 48.53, + "nh3": 6.51 + }, + "dt": 1785751200 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 245.98, + "no": 0.07, + "no2": 12.41, + "o3": 58.99, + "so2": 12.97, + "pm2_5": 49.19, + "pm10": 53.47, + "nh3": 7.46 + }, + "dt": 1785754800 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 276.18, + "no": 0, + "no2": 14.2, + "o3": 49.45, + "so2": 15.51, + "pm2_5": 53.5, + "pm10": 58.93, + "nh3": 7.6 + }, + "dt": 1785758400 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 293.46, + "no": 0.01, + "no2": 15.05, + "o3": 42.49, + "so2": 14.95, + "pm2_5": 56.18, + "pm10": 62.31, + "nh3": 8.01 + }, + "dt": 1785762000 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 310.36, + "no": 0.01, + "no2": 15.47, + "o3": 35.57, + "so2": 13.42, + "pm2_5": 58.5, + "pm10": 65.33, + "nh3": 9.11 + }, + "dt": 1785765600 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 319.91, + "no": 0.02, + "no2": 15.57, + "o3": 29.66, + "so2": 12.19, + "pm2_5": 60.47, + "pm10": 67.5, + "nh3": 10.33 + }, + "dt": 1785769200 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 316.39, + "no": 0.02, + "no2": 15.05, + "o3": 24.13, + "so2": 11.25, + "pm2_5": 60.91, + "pm10": 67.7, + "nh3": 11.94 + }, + "dt": 1785772800 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 312.43, + "no": 0.02, + "no2": 14.02, + "o3": 18.6, + "so2": 10.43, + "pm2_5": 60.92, + "pm10": 67.51, + "nh3": 13.5 + }, + "dt": 1785776400 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 310.23, + "no": 0.04, + "no2": 12.91, + "o3": 14.48, + "so2": 9.81, + "pm2_5": 61.38, + "pm10": 67.44, + "nh3": 14.74 + }, + "dt": 1785780000 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 310.41, + "no": 0.06, + "no2": 12.26, + "o3": 11.9, + "so2": 9.11, + "pm2_5": 63.17, + "pm10": 68.58, + "nh3": 16.29 + }, + "dt": 1785783600 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 312.26, + "no": 0.08, + "no2": 12.19, + "o3": 10.65, + "so2": 9.07, + "pm2_5": 66.09, + "pm10": 71.38, + "nh3": 18.93 + }, + "dt": 1785787200 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 318, + "no": 0.12, + "no2": 12.67, + "o3": 9.66, + "so2": 9.44, + "pm2_5": 69.01, + "pm10": 74.33, + "nh3": 22.26 + }, + "dt": 1785790800 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 329.06, + "no": 0.25, + "no2": 13.34, + "o3": 8.04, + "so2": 9.52, + "pm2_5": 71.4, + "pm10": 76.98, + "nh3": 22.94 + }, + "dt": 1785794400 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 354.89, + "no": 1.97, + "no2": 12.62, + "o3": 7.29, + "so2": 9.26, + "pm2_5": 73.49, + "pm10": 80.47, + "nh3": 16.69 + }, + "dt": 1785798000 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 377.82, + "no": 5.13, + "no2": 11.6, + "o3": 11.56, + "so2": 10.68, + "pm2_5": 76.03, + "pm10": 84.07, + "nh3": 11.88 + }, + "dt": 1785801600 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 356.73, + "no": 3.59, + "no2": 13.64, + "o3": 34.96, + "so2": 19.46, + "pm2_5": 84.24, + "pm10": 91.23, + "nh3": 6.44 + }, + "dt": 1785805200 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 346.86, + "no": 2.9, + "no2": 12.32, + "o3": 54.59, + "so2": 21.5, + "pm2_5": 86.71, + "pm10": 93.98, + "nh3": 6.04 + }, + "dt": 1785808800 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 341.58, + "no": 1.63, + "no2": 9.71, + "o3": 87.34, + "so2": 22.24, + "pm2_5": 100.49, + "pm10": 108.09, + "nh3": 5.37 + }, + "dt": 1785812400 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 308.82, + "no": 0.58, + "no2": 5.28, + "o3": 132.35, + "so2": 17.71, + "pm2_5": 105.91, + "pm10": 112.13, + "nh3": 1.81 + }, + "dt": 1785816000 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 282.82, + "no": 0.34, + "no2": 3.52, + "o3": 154.41, + "so2": 13.95, + "pm2_5": 98.47, + "pm10": 103.56, + "nh3": 0.59 + }, + "dt": 1785819600 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 269.37, + "no": 0.28, + "no2": 3.04, + "o3": 168.97, + "so2": 12.46, + "pm2_5": 94.36, + "pm10": 99.05, + "nh3": 0.63 + }, + "dt": 1785823200 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 264.55, + "no": 0.27, + "no2": 3.12, + "o3": 180.55, + "so2": 11.83, + "pm2_5": 92.63, + "pm10": 97.24, + "nh3": 1.16 + }, + "dt": 1785826800 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 264, + "no": 0.26, + "no2": 3.69, + "o3": 183.79, + "so2": 11.96, + "pm2_5": 93.76, + "pm10": 98.61, + "nh3": 2.16 + }, + "dt": 1785830400 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 266.54, + "no": 0.25, + "no2": 4.76, + "o3": 180.71, + "so2": 12.37, + "pm2_5": 94.38, + "pm10": 99.53, + "nh3": 3.17 + }, + "dt": 1785834000 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 288.66, + "no": 0.3, + "no2": 9.39, + "o3": 135.4, + "so2": 11.08, + "pm2_5": 95.57, + "pm10": 102.06, + "nh3": 7.54 + }, + "dt": 1785837600 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 328.96, + "no": 0.12, + "no2": 14.67, + "o3": 86.48, + "so2": 10.01, + "pm2_5": 97.71, + "pm10": 106.81, + "nh3": 13.2 + }, + "dt": 1785841200 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 367.54, + "no": 0, + "no2": 17.26, + "o3": 62.63, + "so2": 10.43, + "pm2_5": 99.66, + "pm10": 111.59, + "nh3": 17.67 + }, + "dt": 1785844800 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 396.31, + "no": 0, + "no2": 19.18, + "o3": 50.3, + "so2": 11.39, + "pm2_5": 101.22, + "pm10": 115.54, + "nh3": 22.72 + }, + "dt": 1785848400 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 421.8, + "no": 0, + "no2": 20.31, + "o3": 39.56, + "so2": 11.93, + "pm2_5": 103.51, + "pm10": 119.79, + "nh3": 26.68 + }, + "dt": 1785852000 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 439.43, + "no": 0, + "no2": 21.42, + "o3": 29.58, + "so2": 11.88, + "pm2_5": 106.38, + "pm10": 124, + "nh3": 30.06 + }, + "dt": 1785855600 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 435.81, + "no": 0.02, + "no2": 21.18, + "o3": 20.7, + "so2": 10.45, + "pm2_5": 107.03, + "pm10": 124.72, + "nh3": 30.38 + }, + "dt": 1785859200 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 427.53, + "no": 0.06, + "no2": 19.02, + "o3": 13.39, + "so2": 7.8, + "pm2_5": 106.09, + "pm10": 123.65, + "nh3": 24.07 + }, + "dt": 1785862800 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 416.43, + "no": 0.13, + "no2": 16.32, + "o3": 8.45, + "so2": 5.46, + "pm2_5": 104.57, + "pm10": 121.92, + "nh3": 17.1 + }, + "dt": 1785866400 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 403.82, + "no": 0.27, + "no2": 14.13, + "o3": 5.13, + "so2": 4.27, + "pm2_5": 102.76, + "pm10": 119.12, + "nh3": 14.79 + }, + "dt": 1785870000 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 395.65, + "no": 0.49, + "no2": 13.42, + "o3": 3.27, + "so2": 4.63, + "pm2_5": 101.69, + "pm10": 117.43, + "nh3": 18.73 + }, + "dt": 1785873600 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 391.66, + "no": 0.88, + "no2": 13.88, + "o3": 2.31, + "so2": 5.76, + "pm2_5": 100.66, + "pm10": 114.94, + "nh3": 24.62 + }, + "dt": 1785877200 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 390.88, + "no": 1.7, + "no2": 14.62, + "o3": 2.24, + "so2": 7.31, + "pm2_5": 98.88, + "pm10": 112.39, + "nh3": 30.24 + }, + "dt": 1785880800 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 407.64, + "no": 5.21, + "no2": 12.31, + "o3": 3.47, + "so2": 8.37, + "pm2_5": 98.44, + "pm10": 113.19, + "nh3": 29.44 + }, + "dt": 1785884400 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 436.21, + "no": 10.71, + "no2": 8.97, + "o3": 6.08, + "so2": 8.39, + "pm2_5": 98.47, + "pm10": 114.79, + "nh3": 19.72 + }, + "dt": 1785888000 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 412.25, + "no": 4.23, + "no2": 13.45, + "o3": 42.44, + "so2": 20.39, + "pm2_5": 112.02, + "pm10": 125.26, + "nh3": 8.28 + }, + "dt": 1785891600 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 404.56, + "no": 2.36, + "no2": 11.31, + "o3": 78.75, + "so2": 23.06, + "pm2_5": 120.26, + "pm10": 132.85, + "nh3": 7.2 + }, + "dt": 1785895200 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 399.34, + "no": 1.05, + "no2": 7.55, + "o3": 122.82, + "so2": 22.77, + "pm2_5": 138.24, + "pm10": 150.56, + "nh3": 6.62 + }, + "dt": 1785898800 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 294.85, + "no": 0.29, + "no2": 3.6, + "o3": 148.75, + "so2": 17.1, + "pm2_5": 104.48, + "pm10": 111.39, + "nh3": 0.64 + }, + "dt": 1785902400 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 250.21, + "no": 0.25, + "no2": 2.77, + "o3": 149.1, + "so2": 12.79, + "pm2_5": 84.71, + "pm10": 89.76, + "nh3": 0.01 + }, + "dt": 1785906000 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 238.28, + "no": 0.26, + "no2": 2.65, + "o3": 153.69, + "so2": 11.77, + "pm2_5": 82.2, + "pm10": 86.96, + "nh3": 0.06 + }, + "dt": 1785909600 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 248.15, + "no": 0.5, + "no2": 4.57, + "o3": 134.15, + "so2": 10.71, + "pm2_5": 84.68, + "pm10": 90.3, + "nh3": 3.87 + }, + "dt": 1785913200 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 271.92, + "no": 0.9, + "no2": 8.22, + "o3": 105.94, + "so2": 10.05, + "pm2_5": 88.07, + "pm10": 95.32, + "nh3": 9.37 + }, + "dt": 1785916800 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 297.89, + "no": 1.13, + "no2": 13.83, + "o3": 87.04, + "so2": 10.62, + "pm2_5": 89.17, + "pm10": 97.87, + "nh3": 14.29 + }, + "dt": 1785920400 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 328.46, + "no": 0.94, + "no2": 21.09, + "o3": 72, + "so2": 11.95, + "pm2_5": 89.85, + "pm10": 100.17, + "nh3": 20.21 + }, + "dt": 1785924000 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 360.21, + "no": 0.26, + "no2": 25.75, + "o3": 54.59, + "so2": 12.73, + "pm2_5": 90.77, + "pm10": 103.16, + "nh3": 25.1 + }, + "dt": 1785927600 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 387.66, + "no": 0, + "no2": 26.94, + "o3": 41.65, + "so2": 13.29, + "pm2_5": 91.14, + "pm10": 105.68, + "nh3": 28.61 + }, + "dt": 1785931200 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 404.88, + "no": 0.01, + "no2": 26.53, + "o3": 32.5, + "so2": 13.14, + "pm2_5": 91.55, + "pm10": 107.67, + "nh3": 29.42 + }, + "dt": 1785934800 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 420.06, + "no": 0.03, + "no2": 24.95, + "o3": 24.41, + "so2": 11.96, + "pm2_5": 93.24, + "pm10": 110.57, + "nh3": 26.59 + }, + "dt": 1785938400 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 426.65, + "no": 0.12, + "no2": 23.11, + "o3": 18.02, + "so2": 10.01, + "pm2_5": 94.22, + "pm10": 111.85, + "nh3": 21.38 + }, + "dt": 1785942000 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 411.58, + "no": 0.17, + "no2": 20.63, + "o3": 14.97, + "so2": 8.03, + "pm2_5": 93.64, + "pm10": 110.17, + "nh3": 17.66 + }, + "dt": 1785945600 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 398.35, + "no": 0.16, + "no2": 18.38, + "o3": 14.86, + "so2": 7.44, + "pm2_5": 94.94, + "pm10": 110.43, + "nh3": 18.13 + }, + "dt": 1785949200 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 393.08, + "no": 0.17, + "no2": 16.81, + "o3": 15.1, + "so2": 7.48, + "pm2_5": 96.36, + "pm10": 111.25, + "nh3": 19.75 + }, + "dt": 1785952800 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 391.16, + "no": 0.2, + "no2": 15.99, + "o3": 14.77, + "so2": 7.75, + "pm2_5": 96.46, + "pm10": 110.87, + "nh3": 22.18 + }, + "dt": 1785956400 + } + ] +} diff --git a/tests/Fixtures/air-pollution/forecast/moderate-to-very-poor.meta.json b/tests/Fixtures/air-pollution/forecast/moderate-to-very-poor.meta.json new file mode 100644 index 0000000..0045547 --- /dev/null +++ b/tests/Fixtures/air-pollution/forecast/moderate-to-very-poor.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Air Pollution API", + "endpoint": "Air pollution forecast by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T20:55:10Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/air_pollution/forecast", + "query": { + "lat": 39.9042, + "lon": 116.4074 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/air-pollution/history/empty.json b/tests/Fixtures/air-pollution/history/empty.json new file mode 100644 index 0000000..b06c61e --- /dev/null +++ b/tests/Fixtures/air-pollution/history/empty.json @@ -0,0 +1,7 @@ +{ + "coord": { + "lon": -9.1393, + "lat": 38.7223 + }, + "list": [] +} diff --git a/tests/Fixtures/air-pollution/history/empty.meta.json b/tests/Fixtures/air-pollution/history/empty.meta.json new file mode 100644 index 0000000..3e9228b --- /dev/null +++ b/tests/Fixtures/air-pollution/history/empty.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "Air Pollution API", + "endpoint": "Historical air pollution by coordinates and time range", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T20:59:08Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/air_pollution/history", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "start": 1604188800, + "end": 1604192400 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/air-pollution/history/invalid-range.json b/tests/Fixtures/air-pollution/history/invalid-range.json new file mode 100644 index 0000000..26b66d4 --- /dev/null +++ b/tests/Fixtures/air-pollution/history/invalid-range.json @@ -0,0 +1,4 @@ +{ + "cod": "400", + "message": "end must be after start" +} diff --git a/tests/Fixtures/air-pollution/history/invalid-range.meta.json b/tests/Fixtures/air-pollution/history/invalid-range.meta.json new file mode 100644 index 0000000..b63618e --- /dev/null +++ b/tests/Fixtures/air-pollution/history/invalid-range.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "Air Pollution API", + "endpoint": "Historical air pollution by coordinates and time range", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T20:59:08Z", + "httpStatus": 400, + "request": { + "method": "GET", + "path": "/data/2.5/air_pollution/history", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "start": 1782950400, + "end": 1782864000 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/air-pollution/history/success.json b/tests/Fixtures/air-pollution/history/success.json new file mode 100644 index 0000000..c859165 --- /dev/null +++ b/tests/Fixtures/air-pollution/history/success.json @@ -0,0 +1,408 @@ +{ + "coord": { + "lon": -9.1393, + "lat": 38.7223 + }, + "list": [ + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.71, + "no": 0, + "no2": 2.91, + "o3": 86.35, + "so2": 2.17, + "pm2_5": 6.86, + "pm10": 26.12, + "nh3": 0 + }, + "dt": 1782864000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.52, + "no": 0, + "no2": 3.03, + "o3": 82.88, + "so2": 2.25, + "pm2_5": 6.78, + "pm10": 26.39, + "nh3": 0 + }, + "dt": 1782867600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.34, + "no": 0, + "no2": 3.25, + "o3": 79.36, + "so2": 2.37, + "pm2_5": 6.94, + "pm10": 27.23, + "nh3": 0 + }, + "dt": 1782871200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.1, + "no": 0, + "no2": 3.51, + "o3": 75.64, + "so2": 2.48, + "pm2_5": 7.18, + "pm10": 28.01, + "nh3": 0 + }, + "dt": 1782874800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.09, + "no": 0, + "no2": 3.71, + "o3": 72.78, + "so2": 2.55, + "pm2_5": 7.33, + "pm10": 28.29, + "nh3": 0 + }, + "dt": 1782878400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.21, + "no": 0, + "no2": 3.8, + "o3": 70.88, + "so2": 2.58, + "pm2_5": 7.31, + "pm10": 27.88, + "nh3": 0.02 + }, + "dt": 1782882000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.51, + "no": 0, + "no2": 3.88, + "o3": 69.79, + "so2": 2.59, + "pm2_5": 7.2, + "pm10": 27.15, + "nh3": 0.03 + }, + "dt": 1782885600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 81.02, + "no": 0.19, + "no2": 3.99, + "o3": 69.57, + "so2": 2.61, + "pm2_5": 7.1, + "pm10": 26.32, + "nh3": 0.05 + }, + "dt": 1782889200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 81.33, + "no": 0.57, + "no2": 3.64, + "o3": 70.29, + "so2": 2.64, + "pm2_5": 7.03, + "pm10": 25.61, + "nh3": 0.05 + }, + "dt": 1782892800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 81.46, + "no": 0.77, + "no2": 3.15, + "o3": 72.33, + "so2": 2.62, + "pm2_5": 7.02, + "pm10": 25.35, + "nh3": 0.05 + }, + "dt": 1782896400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 81.2, + "no": 0.74, + "no2": 2.6, + "o3": 76.11, + "so2": 2.59, + "pm2_5": 7.17, + "pm10": 25.56, + "nh3": 0.03 + }, + "dt": 1782900000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 81.11, + "no": 0.57, + "no2": 2.02, + "o3": 82.11, + "so2": 2.55, + "pm2_5": 7.62, + "pm10": 26.57, + "nh3": 0.01 + }, + "dt": 1782903600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 81.05, + "no": 0.36, + "no2": 1.44, + "o3": 89.19, + "so2": 2.42, + "pm2_5": 8.15, + "pm10": 27.71, + "nh3": 0 + }, + "dt": 1782907200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.88, + "no": 0.24, + "no2": 1.05, + "o3": 94.79, + "so2": 2.25, + "pm2_5": 8.43, + "pm10": 28.4, + "nh3": 0 + }, + "dt": 1782910800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.37, + "no": 0.19, + "no2": 0.91, + "o3": 98.15, + "so2": 2.13, + "pm2_5": 8.6, + "pm10": 28.86, + "nh3": 0 + }, + "dt": 1782914400 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 79.91, + "no": 0.18, + "no2": 0.9, + "o3": 100.05, + "so2": 2.09, + "pm2_5": 8.79, + "pm10": 29.55, + "nh3": 0 + }, + "dt": 1782918000 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 79.64, + "no": 0.18, + "no2": 0.95, + "o3": 101.09, + "so2": 2.07, + "pm2_5": 8.99, + "pm10": 30.53, + "nh3": 0 + }, + "dt": 1782921600 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 79.65, + "no": 0.19, + "no2": 1.1, + "o3": 101.16, + "so2": 2.06, + "pm2_5": 9.1, + "pm10": 31.25, + "nh3": 0 + }, + "dt": 1782925200 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 79.59, + "no": 0.19, + "no2": 1.41, + "o3": 100.75, + "so2": 2.11, + "pm2_5": 9.14, + "pm10": 31.42, + "nh3": 0 + }, + "dt": 1782928800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 79.95, + "no": 0.14, + "no2": 1.97, + "o3": 99.3, + "so2": 2.2, + "pm2_5": 9.08, + "pm10": 31.06, + "nh3": 0 + }, + "dt": 1782932400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.1, + "no": 0.03, + "no2": 2.69, + "o3": 96.52, + "so2": 2.28, + "pm2_5": 8.95, + "pm10": 30.38, + "nh3": 0 + }, + "dt": 1782936000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.25, + "no": 0, + "no2": 3.09, + "o3": 93.3, + "so2": 2.36, + "pm2_5": 8.85, + "pm10": 29.74, + "nh3": 0.01 + }, + "dt": 1782939600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.42, + "no": 0, + "no2": 3.53, + "o3": 89.97, + "so2": 2.48, + "pm2_5": 8.81, + "pm10": 29.23, + "nh3": 0.02 + }, + "dt": 1782943200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.6, + "no": 0, + "no2": 4.02, + "o3": 86.33, + "so2": 2.6, + "pm2_5": 8.77, + "pm10": 28.67, + "nh3": 0.02 + }, + "dt": 1782946800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.92, + "no": 0, + "no2": 4.47, + "o3": 82.81, + "so2": 2.71, + "pm2_5": 8.73, + "pm10": 28.1, + "nh3": 0.02 + }, + "dt": 1782950400 + } + ] +} diff --git a/tests/Fixtures/air-pollution/history/success.meta.json b/tests/Fixtures/air-pollution/history/success.meta.json new file mode 100644 index 0000000..21a3722 --- /dev/null +++ b/tests/Fixtures/air-pollution/history/success.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "Air Pollution API", + "endpoint": "Historical air pollution by coordinates and time range", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T20:59:08Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/air_pollution/history", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "start": 1782864000, + "end": 1782950400 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/geocoding/direct/empty.json b/tests/Fixtures/geocoding/direct/empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/Fixtures/geocoding/direct/empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/Fixtures/geocoding/direct/empty.meta.json b/tests/Fixtures/geocoding/direct/empty.meta.json new file mode 100644 index 0000000..fc5f096 --- /dev/null +++ b/tests/Fixtures/geocoding/direct/empty.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Geocoding API", + "endpoint": "Direct geocoding", + "apiVersion": "1.0", + "capturedAt": "2026-07-31T17:19:23Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/geo/1.0/direct", + "query": { + "q": "ThisPlaceShouldNotExistOpenWeatherFixture987654321", + "limit": 5 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/geocoding/direct/missing-query.json b/tests/Fixtures/geocoding/direct/missing-query.json new file mode 100644 index 0000000..31e836f --- /dev/null +++ b/tests/Fixtures/geocoding/direct/missing-query.json @@ -0,0 +1 @@ +{"cod":"400","message":"Nothing to geocode"} diff --git a/tests/Fixtures/geocoding/direct/missing-query.meta.json b/tests/Fixtures/geocoding/direct/missing-query.meta.json new file mode 100644 index 0000000..54e02d2 --- /dev/null +++ b/tests/Fixtures/geocoding/direct/missing-query.meta.json @@ -0,0 +1,16 @@ +{ + "provenance": "captured", + "product": "Geocoding API", + "endpoint": "Direct geocoding", + "apiVersion": "1.0", + "capturedAt": "2026-07-31T17:19:23Z", + "httpStatus": 400, + "request": { + "method": "GET", + "path": "/geo/1.0/direct", + "query": { + "limit": 5 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/geocoding/direct/success.json b/tests/Fixtures/geocoding/direct/success.json new file mode 100644 index 0000000..f0a32eb --- /dev/null +++ b/tests/Fixtures/geocoding/direct/success.json @@ -0,0 +1 @@ +[{"name":"Springfield","local_names":{"ta":"ஸ்பிரிங்ஃபீல்ட்","uk":"Спрингфілд","en":"Springfield","lt":"Springfildas","pl":"Springfield","ru":"Спрингфилд"},"lat":39.7990175,"lon":-89.6439575,"country":"US","state":"Illinois"},{"name":"Springfield","local_names":{"en":"Springfield","ru":"Спрингфилд"},"lat":42.1018764,"lon":-72.5886727,"country":"US","state":"Massachusetts"},{"name":"Springfield","local_names":{"en":"Springfield","lt":"Springfildas","ru":"Спрингфилд","uk":"Спрингфілд"},"lat":37.1968298,"lon":-93.2946576,"country":"US","state":"Missouri"},{"name":"Springfield","local_names":{"en":"Springfield","lt":"Springfildas"},"lat":39.9234046,"lon":-83.810138,"country":"US","state":"Ohio"},{"name":"Springfield","local_names":{"ja":"スプリングフィールド","en":"Springfield"},"lat":44.0462362,"lon":-123.0220289,"country":"US","state":"Oregon"}] diff --git a/tests/Fixtures/geocoding/direct/success.meta.json b/tests/Fixtures/geocoding/direct/success.meta.json new file mode 100644 index 0000000..5e2ec63 --- /dev/null +++ b/tests/Fixtures/geocoding/direct/success.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Geocoding API", + "endpoint": "Direct geocoding", + "apiVersion": "1.0", + "capturedAt": "2026-07-31T17:20:54Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/geo/1.0/direct", + "query": { + "q": "Springfield,US", + "limit": 5 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/geocoding/reverse/invalid-coordinates.json b/tests/Fixtures/geocoding/reverse/invalid-coordinates.json new file mode 100644 index 0000000..a53e941 --- /dev/null +++ b/tests/Fixtures/geocoding/reverse/invalid-coordinates.json @@ -0,0 +1 @@ +{"cod":"400","message":"wrong latitude"} diff --git a/tests/Fixtures/geocoding/reverse/invalid-coordinates.meta.json b/tests/Fixtures/geocoding/reverse/invalid-coordinates.meta.json new file mode 100644 index 0000000..790fb28 --- /dev/null +++ b/tests/Fixtures/geocoding/reverse/invalid-coordinates.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Geocoding API", + "endpoint": "Reverse geocoding", + "apiVersion": "1.0", + "capturedAt": "2026-07-31T17:19:23Z", + "httpStatus": 400, + "request": { + "method": "GET", + "path": "/geo/1.0/reverse", + "query": { + "lat": 91, + "lon": 0 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/geocoding/reverse/success.json b/tests/Fixtures/geocoding/reverse/success.json new file mode 100644 index 0000000..0664d40 --- /dev/null +++ b/tests/Fixtures/geocoding/reverse/success.json @@ -0,0 +1 @@ +[{"name":"New York County","local_names":{"be":"Нью-Ёрк","hi":"न्यूयॊर्क्","uk":"Нью-Йорк","zh":"纽约/紐約","pl":"Nowy Jork","he":"ניו יורק","vi":"New York","kn":"ನ್ಯೂಯೊರ್ಕ್","de":"New York","pt":"Nova Iorque","eo":"Novjorko","it":"New York","fr":"New York","cy":"Efrog Newydd","ca":"Nova York","ko":"뉴욕","oc":"Nòva York","en":"New York","ja":"ニューヨーク","es":"Nueva York","is":"Nýja Jórvík","te":"న్యూయొర్క్","fa":"نیویورک","ru":"Нью-Йорк","ar":"نيويورك","el":"Νέα Υόρκη","gl":"Nova York","cs":"New York"},"lat":40.7127281,"lon":-74.0060152,"country":"US","state":"New York"}] diff --git a/tests/Fixtures/geocoding/reverse/success.meta.json b/tests/Fixtures/geocoding/reverse/success.meta.json new file mode 100644 index 0000000..5eccaf9 --- /dev/null +++ b/tests/Fixtures/geocoding/reverse/success.meta.json @@ -0,0 +1,18 @@ +{ + "provenance": "captured", + "product": "Geocoding API", + "endpoint": "Reverse geocoding", + "apiVersion": "1.0", + "capturedAt": "2026-07-31T17:20:54Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/geo/1.0/reverse", + "query": { + "lat": 40.7128, + "lon": -74.006, + "limit": 5 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/geocoding/zip/success.json b/tests/Fixtures/geocoding/zip/success.json new file mode 100644 index 0000000..018d83f --- /dev/null +++ b/tests/Fixtures/geocoding/zip/success.json @@ -0,0 +1 @@ +{"zip":"1000-001","name":"Lisbon","lat":38.7167,"lon":-9.1333,"country":"PT"} diff --git a/tests/Fixtures/geocoding/zip/success.meta.json b/tests/Fixtures/geocoding/zip/success.meta.json new file mode 100644 index 0000000..c2d2fc0 --- /dev/null +++ b/tests/Fixtures/geocoding/zip/success.meta.json @@ -0,0 +1,16 @@ +{ + "provenance": "captured", + "product": "Geocoding API", + "endpoint": "ZIP/postcode geocoding", + "apiVersion": "1.0", + "capturedAt": "2026-07-31T17:19:23Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/geo/1.0/zip", + "query": { + "zip": "1000-001,PT" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/maps/tile/clouds-new.meta.json b/tests/Fixtures/maps/tile/clouds-new.meta.json new file mode 100644 index 0000000..158dfc9 --- /dev/null +++ b/tests/Fixtures/maps/tile/clouds-new.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Maps API", + "endpoint": "Map tile", + "apiVersion": "1.0", + "capturedAt": "2026-08-08T10:22:04Z", + "httpStatus": 200, + "contentType": "image/png", + "bodyFormat": "png", + "bodyBytes": 80024, + "sha256": "b3f24ece986ec16dd20e5d11c7ab7f4fbe63f8e301f29f33d29e70e010406eef", + "image": { + "width": 256, + "height": 256 + }, + "request": { + "method": "GET", + "path": "/map/clouds_new/1/1/1.png", + "query": {} + }, + "sanitization": [] +} diff --git a/tests/Fixtures/maps/tile/clouds-new.png b/tests/Fixtures/maps/tile/clouds-new.png new file mode 100644 index 0000000..06d6526 Binary files /dev/null and b/tests/Fixtures/maps/tile/clouds-new.png differ diff --git a/tests/Fixtures/maps/tile/missing-key.json b/tests/Fixtures/maps/tile/missing-key.json new file mode 100644 index 0000000..c3f109a --- /dev/null +++ b/tests/Fixtures/maps/tile/missing-key.json @@ -0,0 +1 @@ +{"cod":401, "message": "Invalid API key. Please see http://openweathermap.org/faq#error401 for more info."} \ No newline at end of file diff --git a/tests/Fixtures/maps/tile/missing-key.meta.json b/tests/Fixtures/maps/tile/missing-key.meta.json new file mode 100644 index 0000000..3d5366f --- /dev/null +++ b/tests/Fixtures/maps/tile/missing-key.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "Weather Maps API", + "endpoint": "Map tile", + "apiVersion": "1.0", + "capturedAt": "2026-08-08T10:22:04Z", + "httpStatus": 401, + "contentType": "image/png", + "bodyFormat": "json", + "bodyBytes": 107, + "sha256": "cdee0b4f0401915d83300301514984fef2ddfb91245c52358326f9e11f8707d9", + "request": { + "method": "GET", + "path": "/map/clouds_new/1/1/1.png", + "query": {} + }, + "sanitization": [], + "notes": "OpenWeather labeled this JSON error body as image/png." +} diff --git a/tests/Fixtures/maps/tile/precipitation-new.meta.json b/tests/Fixtures/maps/tile/precipitation-new.meta.json new file mode 100644 index 0000000..5924a43 --- /dev/null +++ b/tests/Fixtures/maps/tile/precipitation-new.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Maps API", + "endpoint": "Map tile", + "apiVersion": "1.0", + "capturedAt": "2026-08-08T10:22:04Z", + "httpStatus": 200, + "contentType": "image/png", + "bodyFormat": "png", + "bodyBytes": 81096, + "sha256": "c082371b48f55d29df23b5527666a5b88285ce0da52b8c542724600c782f62a4", + "image": { + "width": 256, + "height": 256 + }, + "request": { + "method": "GET", + "path": "/map/precipitation_new/1/1/1.png", + "query": {} + }, + "sanitization": [] +} diff --git a/tests/Fixtures/maps/tile/precipitation-new.png b/tests/Fixtures/maps/tile/precipitation-new.png new file mode 100644 index 0000000..35444be Binary files /dev/null and b/tests/Fixtures/maps/tile/precipitation-new.png differ diff --git a/tests/Fixtures/maps/tile/pressure-new.meta.json b/tests/Fixtures/maps/tile/pressure-new.meta.json new file mode 100644 index 0000000..adaf02f --- /dev/null +++ b/tests/Fixtures/maps/tile/pressure-new.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Maps API", + "endpoint": "Map tile", + "apiVersion": "1.0", + "capturedAt": "2026-08-08T10:22:04Z", + "httpStatus": 200, + "contentType": "image/png", + "bodyFormat": "png", + "bodyBytes": 66276, + "sha256": "b5310fd2b200e551ee3d9943e5b45ad6b71907bf8fa5f12de5060b6217ac8b55", + "image": { + "width": 256, + "height": 256 + }, + "request": { + "method": "GET", + "path": "/map/pressure_new/1/1/1.png", + "query": {} + }, + "sanitization": [] +} diff --git a/tests/Fixtures/maps/tile/pressure-new.png b/tests/Fixtures/maps/tile/pressure-new.png new file mode 100644 index 0000000..cedf8ae Binary files /dev/null and b/tests/Fixtures/maps/tile/pressure-new.png differ diff --git a/tests/Fixtures/maps/tile/temp-new.meta.json b/tests/Fixtures/maps/tile/temp-new.meta.json new file mode 100644 index 0000000..c18c598 --- /dev/null +++ b/tests/Fixtures/maps/tile/temp-new.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Maps API", + "endpoint": "Map tile", + "apiVersion": "1.0", + "capturedAt": "2026-08-08T10:22:04Z", + "httpStatus": 200, + "contentType": "image/png", + "bodyFormat": "png", + "bodyBytes": 57338, + "sha256": "aec0f69761e259099ac09d9b69a1156ec50233fd4ac67ba667e792c860e878ed", + "image": { + "width": 256, + "height": 256 + }, + "request": { + "method": "GET", + "path": "/map/temp_new/1/1/1.png", + "query": {} + }, + "sanitization": [] +} diff --git a/tests/Fixtures/maps/tile/temp-new.png b/tests/Fixtures/maps/tile/temp-new.png new file mode 100644 index 0000000..71ed4b6 Binary files /dev/null and b/tests/Fixtures/maps/tile/temp-new.png differ diff --git a/tests/Fixtures/maps/tile/unknown-layer.json b/tests/Fixtures/maps/tile/unknown-layer.json new file mode 100644 index 0000000..649427d --- /dev/null +++ b/tests/Fixtures/maps/tile/unknown-layer.json @@ -0,0 +1 @@ +{"message":"Unexpected layer name."} \ No newline at end of file diff --git a/tests/Fixtures/maps/tile/unknown-layer.meta.json b/tests/Fixtures/maps/tile/unknown-layer.meta.json new file mode 100644 index 0000000..81e2a84 --- /dev/null +++ b/tests/Fixtures/maps/tile/unknown-layer.meta.json @@ -0,0 +1,18 @@ +{ + "provenance": "captured", + "product": "Weather Maps API", + "endpoint": "Map tile", + "apiVersion": "1.0", + "capturedAt": "2026-08-08T10:22:04Z", + "httpStatus": 400, + "contentType": "application/json; charset=utf-8", + "bodyFormat": "json", + "bodyBytes": 36, + "sha256": "1a8d7022d57616fa8ab74ce761ba40b8ef3c85dc5580ca5b0a488f682a1a4b0c", + "request": { + "method": "GET", + "path": "/map/unknown_layer/1/1/1.png", + "query": {} + }, + "sanitization": [] +} diff --git a/tests/Fixtures/maps/tile/wind-new.meta.json b/tests/Fixtures/maps/tile/wind-new.meta.json new file mode 100644 index 0000000..5804456 --- /dev/null +++ b/tests/Fixtures/maps/tile/wind-new.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Maps API", + "endpoint": "Map tile", + "apiVersion": "1.0", + "capturedAt": "2026-08-08T10:22:04Z", + "httpStatus": 200, + "contentType": "image/png", + "bodyFormat": "png", + "bodyBytes": 113454, + "sha256": "b96ef794cc6a079d8e5daeb854f34c4801cc8bcd67c14319415ff6ac8c9d3da6", + "image": { + "width": 256, + "height": 256 + }, + "request": { + "method": "GET", + "path": "/map/wind_new/1/1/1.png", + "query": {} + }, + "sanitization": [] +} diff --git a/tests/Fixtures/maps/tile/wind-new.png b/tests/Fixtures/maps/tile/wind-new.png new file mode 100644 index 0000000..f7703ca Binary files /dev/null and b/tests/Fixtures/maps/tile/wind-new.png differ diff --git a/tests/Fixtures/one-call/alert/chile-rain.json b/tests/Fixtures/one-call/alert/chile-rain.json new file mode 100644 index 0000000..462d085 --- /dev/null +++ b/tests/Fixtures/one-call/alert/chile-rain.json @@ -0,0 +1,16 @@ +{ + "id": "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0", + "sender_name": "Dirección Meteorológica de Chile", + "event": "", + "start": 1785578400, + "end": 1785708000, + "description": [ + { + "language": "es-CL", + "description": "Precipitaciones Normales a Moderadas en zonas de las regiones de La Araucanía, Los Ríos y Los Lagos" + } + ], + "tags": [ + "Rain" + ] +} diff --git a/tests/Fixtures/one-call/alert/chile-rain.meta.json b/tests/Fixtures/one-call/alert/chile-rain.meta.json new file mode 100644 index 0000000..42857ff --- /dev/null +++ b/tests/Fixtures/one-call/alert/chile-rain.meta.json @@ -0,0 +1,14 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "Detailed weather alert by ID", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:44:45Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/alert/urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0", + "query": {} + }, + "sanitization": [] +} diff --git a/tests/Fixtures/one-call/alert/houston-air-quality.json b/tests/Fixtures/one-call/alert/houston-air-quality.json new file mode 100644 index 0000000..921d3e3 --- /dev/null +++ b/tests/Fixtures/one-call/alert/houston-air-quality.json @@ -0,0 +1,16 @@ +{ + "id": "urn:oid:2.49.0.1.840.0.ecea2e4e89531a971e82aa9bfb2483dd830a4738.001.1:7038934a0b3cdfbb142576a95acc4a0a", + "sender_name": "NWS Houston/Galveston TX", + "event": "", + "start": 1785614160, + "end": 1785715200, + "description": [ + { + "language": "en-US", + "description": "AQAHGX\n\nThe Texas Commission on Environmental Quality (TCEQ) has issued an\nOzone Action Day for the Houston, Galveston, and Brazoria area for\nSunday, August 2, 2026.\n\nAtmospheric conditions are expected to be favorable for producing\nhigh levels of ozone pollution in the Houston, Galveston, and\nsurrounding areas on Sunday. You can help prevent ozone pollution by\nsharing a ride, walking, riding a bicycle, taking your lunch to work,\navoiding drive through lanes, conserving energy and keeping your vehicle\nproperly tuned.\n\nFor more information on ozone:\nOzone: The Facts www.tceq.texas.gov/airquality/monops/ozonefacts.html\nEPA AirNow: www.airnow.gov/?city=Houston&state=TX&country=USA\nTake Care of Texas:\nwww.takecareoftexas.org/conservation-tips/keep-our-air-clean" + } + ], + "tags": [ + "Air quality" + ] +} diff --git a/tests/Fixtures/one-call/alert/houston-air-quality.meta.json b/tests/Fixtures/one-call/alert/houston-air-quality.meta.json new file mode 100644 index 0000000..f7bd129 --- /dev/null +++ b/tests/Fixtures/one-call/alert/houston-air-quality.meta.json @@ -0,0 +1,14 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "Detailed weather alert by ID", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:44:45Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/alert/urn:oid:2.49.0.1.840.0.ecea2e4e89531a971e82aa9bfb2483dd830a4738.001.1:7038934a0b3cdfbb142576a95acc4a0a", + "query": {} + }, + "sanitization": [] +} diff --git a/tests/Fixtures/one-call/alert/phoenix-extreme-heat.json b/tests/Fixtures/one-call/alert/phoenix-extreme-heat.json new file mode 100644 index 0000000..4f2afd8 --- /dev/null +++ b/tests/Fixtures/one-call/alert/phoenix-extreme-heat.json @@ -0,0 +1,16 @@ +{ + "id": "urn:oid:2.49.0.1.840.0.cd17291384fae80fe8e7d9bdf80b7daacd96d2b2.003.1:42df637e7a40cbc59c30cd956a8a638b", + "sender_name": "NWS Phoenix AZ", + "event": "", + "start": 1785653280, + "end": 1785898800, + "description": [ + { + "language": "en-US", + "description": "* WHAT...Dangerously hot and humid conditions. Afternoon temperatures\n109 to 116. Major to Extreme Heat Risk.\n\n* WHERE...The Phoenix metropolitan area.\n\n* WHEN...Until 8 PM MST Tuesday.\n\n* IMPACTS...Increase in heat related illnesses, including heat\ncramps, heat exhaustion, and heat stroke. Heat stroke can lead to\ndeath.\n\n* ADDITIONAL DETAILS...In Maricopa County, Call 2-1-1 to find a free\ncooling center, transportation, water and more.\nhttps://www.maricopa.gov/heat" + } + ], + "tags": [ + "Extreme high temperature" + ] +} diff --git a/tests/Fixtures/one-call/alert/phoenix-extreme-heat.meta.json b/tests/Fixtures/one-call/alert/phoenix-extreme-heat.meta.json new file mode 100644 index 0000000..aaa42ba --- /dev/null +++ b/tests/Fixtures/one-call/alert/phoenix-extreme-heat.meta.json @@ -0,0 +1,14 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "Detailed weather alert by ID", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:44:45Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/alert/urn:oid:2.49.0.1.840.0.cd17291384fae80fe8e7d9bdf80b7daacd96d2b2.003.1:42df637e7a40cbc59c30cd956a8a638b", + "query": {} + }, + "sanitization": [] +} diff --git a/tests/Fixtures/one-call/alert/tokyo-thunderstorm.json b/tests/Fixtures/one-call/alert/tokyo-thunderstorm.json new file mode 100644 index 0000000..3235f20 --- /dev/null +++ b/tests/Fixtures/one-call/alert/tokyo-thunderstorm.json @@ -0,0 +1,16 @@ +{ + "id": "VPWW54_JPTK_020757_02_202608020757550_001_55787:14:1311500:0:0:bd980f52b5594645c6a36b4016fdd359", + "sender_name": "JMA", + "event": "", + "start": 1785661201, + "end": 1785682800, + "description": [ + { + "language": "ja-JP", + "description": "突風\n突風\nひょう" + } + ], + "tags": [ + "Thunderstorm" + ] +} diff --git a/tests/Fixtures/one-call/alert/tokyo-thunderstorm.meta.json b/tests/Fixtures/one-call/alert/tokyo-thunderstorm.meta.json new file mode 100644 index 0000000..cb311de --- /dev/null +++ b/tests/Fixtures/one-call/alert/tokyo-thunderstorm.meta.json @@ -0,0 +1,14 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "Detailed weather alert by ID", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:44:45Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/alert/VPWW54_JPTK_020757_02_202608020757550_001_55787:14:1311500:0:0:bd980f52b5594645c6a36b4016fdd359", + "query": {} + }, + "sanitization": [] +} diff --git a/tests/Fixtures/one-call/current/rain.json b/tests/Fixtures/one-call/current/rain.json new file mode 100644 index 0000000..6d4d03a --- /dev/null +++ b/tests/Fixtures/one-call/current/rain.json @@ -0,0 +1,35 @@ +{ + "lat": 14.5995, + "lon": 120.9842, + "timezone": "Asia/Manila", + "timezone_offset": 28800, + "data": [ + { + "dt": 1785668580, + "sunrise": 1785620365, + "sunset": 1785666311, + "temp": 26.42, + "feels_like": 26.42, + "pressure": 1006, + "humidity": 92, + "dew_point": 25.01, + "uvi": 0, + "clouds": 100, + "visibility": 7458, + "wind_speed": 1.79, + "wind_deg": 272, + "wind_gust": 2.68, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10n" + } + ], + "rain": { + "1h": 0.13 + } + } + ] +} diff --git a/tests/Fixtures/one-call/current/rain.meta.json b/tests/Fixtures/one-call/current/rain.meta.json new file mode 100644 index 0000000..fe105b3 --- /dev/null +++ b/tests/Fixtures/one-call/current/rain.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "Current weather by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:03:13Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/current", + "query": { + "lat": 14.5995, + "lon": 120.9842, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/one-call/current/snow.json b/tests/Fixtures/one-call/current/snow.json new file mode 100644 index 0000000..4d1e999 --- /dev/null +++ b/tests/Fixtures/one-call/current/snow.json @@ -0,0 +1,38 @@ +{ + "lat": -38.4, + "lon": -71.58, + "timezone": "America/Santiago", + "timezone_offset": -14400, + "data": [ + { + "dt": 1785668585, + "sunrise": 1785671200, + "sunset": 1785707904, + "temp": -2.31, + "feels_like": -6.94, + "pressure": 1012, + "humidity": 100, + "dew_point": -2.31, + "uvi": 0, + "clouds": 100, + "visibility": 33, + "wind_speed": 3.77, + "wind_deg": 306, + "wind_gust": 14.85, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "snow": { + "1h": 4.86 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + } + ] +} diff --git a/tests/Fixtures/one-call/current/snow.meta.json b/tests/Fixtures/one-call/current/snow.meta.json new file mode 100644 index 0000000..167c66e --- /dev/null +++ b/tests/Fixtures/one-call/current/snow.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "Current weather by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:03:13Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/current", + "query": { + "lat": -38.4, + "lon": -71.58, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/one-call/current/success.json b/tests/Fixtures/one-call/current/success.json new file mode 100644 index 0000000..d71d102 --- /dev/null +++ b/tests/Fixtures/one-call/current/success.json @@ -0,0 +1,32 @@ +{ + "lat": 38.7223, + "lon": -9.1393, + "timezone": "Europe/Lisbon", + "timezone_offset": 3600, + "data": [ + { + "dt": 1785668004, + "sunrise": 1785649113, + "sunset": 1785700020, + "temp": 24.34, + "feels_like": 24.68, + "pressure": 1016, + "humidity": 71, + "dew_point": 18.75, + "uvi": 7.53, + "clouds": 40, + "visibility": 10000, + "wind_speed": 2.24, + "wind_deg": 293, + "wind_gust": 5.36, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + } + ] +} diff --git a/tests/Fixtures/one-call/current/success.meta.json b/tests/Fixtures/one-call/current/success.meta.json new file mode 100644 index 0000000..dcece59 --- /dev/null +++ b/tests/Fixtures/one-call/current/success.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "Current weather by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T10:53:24Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/current", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/one-call/fifteen-minute/pagination.json b/tests/Fixtures/one-call/fifteen-minute/pagination.json new file mode 100644 index 0000000..cb4b42c --- /dev/null +++ b/tests/Fixtures/one-call/fifteen-minute/pagination.json @@ -0,0 +1,1110 @@ +{ + "lat": 38.7223, + "lon": -9.1393, + "timezone": "Europe/Lisbon", + "timezone_offset": 3600, + "data": [ + { + "dt": 1785715200, + "temp": 21.57, + "feels_like": 21.69, + "pressure": 1014, + "humidity": 73, + "dew_point": 15.78, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.84, + "wind_deg": 308, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785716100, + "temp": 21.52, + "feels_like": 21.64, + "pressure": 1014, + "humidity": 73, + "dew_point": 15.81, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.71, + "wind_deg": 311, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785717000, + "temp": 21.47, + "feels_like": 21.59, + "pressure": 1014, + "humidity": 73, + "dew_point": 15.83, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.58, + "wind_deg": 315, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785717900, + "temp": 21.41, + "feels_like": 21.53, + "pressure": 1014, + "humidity": 73, + "dew_point": 15.86, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.44, + "wind_deg": 319, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785718800, + "temp": 21.36, + "feels_like": 21.48, + "pressure": 1014, + "humidity": 74, + "dew_point": 15.88, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.31, + "wind_deg": 323, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785719700, + "temp": 21.28, + "feels_like": 21.41, + "pressure": 1014, + "humidity": 74, + "dew_point": 15.97, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.32, + "wind_deg": 321, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785720600, + "temp": 21.2, + "feels_like": 21.34, + "pressure": 1014, + "humidity": 75, + "dew_point": 16.06, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.32, + "wind_deg": 320, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785721500, + "temp": 21.11, + "feels_like": 21.27, + "pressure": 1014, + "humidity": 76, + "dew_point": 16.16, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.33, + "wind_deg": 319, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785722400, + "temp": 21.03, + "feels_like": 21.2, + "pressure": 1014, + "humidity": 77, + "dew_point": 16.25, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.33, + "wind_deg": 318, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785723300, + "temp": 20.94, + "feels_like": 21.12, + "pressure": 1014, + "humidity": 77, + "dew_point": 16.3, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.19, + "wind_deg": 313, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785724200, + "temp": 20.85, + "feels_like": 21.03, + "pressure": 1014, + "humidity": 78, + "dew_point": 16.35, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.05, + "wind_deg": 308, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785725100, + "temp": 20.76, + "feels_like": 20.95, + "pressure": 1014, + "humidity": 78, + "dew_point": 16.4, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 1.91, + "wind_deg": 303, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785726000, + "temp": 20.67, + "feels_like": 20.86, + "pressure": 1014, + "humidity": 79, + "dew_point": 16.45, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 1.77, + "wind_deg": 298, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785726900, + "temp": 20.58, + "feels_like": 20.76, + "pressure": 1014, + "humidity": 79, + "dew_point": 16.47, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 1.63, + "wind_deg": 297, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785727800, + "temp": 20.48, + "feels_like": 20.66, + "pressure": 1014, + "humidity": 79, + "dew_point": 16.5, + "uvi": 0, + "clouds": 98, + "visibility": 10000, + "wind_speed": 1.48, + "wind_deg": 296, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785728700, + "temp": 20.38, + "feels_like": 20.56, + "pressure": 1014, + "humidity": 79, + "dew_point": 16.52, + "uvi": 0, + "clouds": 97, + "visibility": 10000, + "wind_speed": 1.34, + "wind_deg": 295, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785729600, + "temp": 20.29, + "feels_like": 20.46, + "pressure": 1014, + "humidity": 80, + "dew_point": 16.54, + "uvi": 0, + "clouds": 97, + "visibility": 10000, + "wind_speed": 1.19, + "wind_deg": 295, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785730500, + "temp": 20.33, + "feels_like": 20.51, + "pressure": 1014, + "humidity": 80, + "dew_point": 16.57, + "uvi": 0, + "clouds": 96, + "visibility": 10000, + "wind_speed": 1.21, + "wind_deg": 293, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785731400, + "temp": 20.36, + "feels_like": 20.55, + "pressure": 1014, + "humidity": 80, + "dew_point": 16.6, + "uvi": 0, + "clouds": 95, + "visibility": 10000, + "wind_speed": 1.22, + "wind_deg": 291, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785732300, + "temp": 20.39, + "feels_like": 20.6, + "pressure": 1014, + "humidity": 80, + "dew_point": 16.63, + "uvi": 0, + "clouds": 94, + "visibility": 10000, + "wind_speed": 1.23, + "wind_deg": 289, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785733200, + "temp": 20.43, + "feels_like": 20.64, + "pressure": 1014, + "humidity": 81, + "dew_point": 16.66, + "uvi": 0, + "clouds": 93, + "visibility": 10000, + "wind_speed": 1.25, + "wind_deg": 287, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785734100, + "temp": 20.64, + "feels_like": 20.87, + "pressure": 1014, + "humidity": 81, + "dew_point": 16.67, + "uvi": 0, + "clouds": 93, + "visibility": 10000, + "wind_speed": 1.29, + "wind_deg": 278, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785735000, + "temp": 20.85, + "feels_like": 21.1, + "pressure": 1014, + "humidity": 81, + "dew_point": 16.67, + "uvi": 0, + "clouds": 93, + "visibility": 10000, + "wind_speed": 1.33, + "wind_deg": 270, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785735900, + "temp": 21.05, + "feels_like": 21.33, + "pressure": 1014, + "humidity": 81, + "dew_point": 16.68, + "uvi": 0, + "clouds": 93, + "visibility": 10000, + "wind_speed": 1.36, + "wind_deg": 262, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785736800, + "temp": 21.26, + "feels_like": 21.56, + "pressure": 1014, + "humidity": 81, + "dew_point": 16.68, + "uvi": 0, + "clouds": 94, + "visibility": 10000, + "wind_speed": 1.4, + "wind_deg": 254, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785737700, + "temp": 21.46, + "feels_like": 21.76, + "pressure": 1014, + "humidity": 80, + "dew_point": 16.67, + "uvi": 0.12, + "clouds": 95, + "visibility": 10000, + "wind_speed": 1.49, + "wind_deg": 253, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785738600, + "temp": 21.66, + "feels_like": 21.96, + "pressure": 1014, + "humidity": 79, + "dew_point": 16.65, + "uvi": 0.23, + "clouds": 97, + "visibility": 10000, + "wind_speed": 1.59, + "wind_deg": 253, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785739500, + "temp": 21.85, + "feels_like": 22.15, + "pressure": 1014, + "humidity": 78, + "dew_point": 16.64, + "uvi": 0.35, + "clouds": 98, + "visibility": 10000, + "wind_speed": 1.68, + "wind_deg": 252, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785740400, + "temp": 22.05, + "feels_like": 22.35, + "pressure": 1014, + "humidity": 78, + "dew_point": 16.63, + "uvi": 0.46, + "clouds": 100, + "visibility": 10000, + "wind_speed": 1.77, + "wind_deg": 252, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785741300, + "temp": 22.2, + "feels_like": 22.48, + "pressure": 1014.25, + "humidity": 76, + "dew_point": 16.59, + "uvi": 0.72, + "clouds": 88, + "visibility": 10000, + "wind_speed": 1.93, + "wind_deg": 252, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785742200, + "temp": 22.35, + "feels_like": 22.6, + "pressure": 1014.5, + "humidity": 75, + "dew_point": 16.56, + "uvi": 0.98, + "clouds": 76, + "visibility": 10000, + "wind_speed": 2.09, + "wind_deg": 252, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785743100, + "temp": 22.5, + "feels_like": 22.73, + "pressure": 1014.75, + "humidity": 73, + "dew_point": 16.52, + "uvi": 1.24, + "clouds": 64, + "visibility": 10000, + "wind_speed": 2.24, + "wind_deg": 252, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785744000, + "temp": 22.65, + "feels_like": 22.85, + "pressure": 1015, + "humidity": 72, + "dew_point": 16.48, + "uvi": 1.5, + "clouds": 52, + "visibility": 10000, + "wind_speed": 2.4, + "wind_deg": 252, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785744900, + "temp": 22.8, + "feels_like": 22.98, + "pressure": 1015, + "humidity": 70, + "dew_point": 16.48, + "uvi": 1.94, + "clouds": 47, + "visibility": 10000, + "wind_speed": 2.51, + "wind_deg": 250, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785745800, + "temp": 22.95, + "feels_like": 23.12, + "pressure": 1015, + "humidity": 69, + "dew_point": 16.47, + "uvi": 2.38, + "clouds": 43, + "visibility": 10000, + "wind_speed": 2.63, + "wind_deg": 248, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785746700, + "temp": 23.1, + "feels_like": 23.25, + "pressure": 1015, + "humidity": 68, + "dew_point": 16.47, + "uvi": 2.82, + "clouds": 39, + "visibility": 10000, + "wind_speed": 2.75, + "wind_deg": 246, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785747600, + "temp": 23.25, + "feels_like": 23.38, + "pressure": 1015, + "humidity": 67, + "dew_point": 16.46, + "uvi": 3.26, + "clouds": 35, + "visibility": 10000, + "wind_speed": 2.86, + "wind_deg": 245, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785748500, + "temp": 23.27, + "feels_like": 23.38, + "pressure": 1015, + "humidity": 66, + "dew_point": 16.47, + "uvi": 3.8, + "clouds": 32, + "visibility": 10000, + "wind_speed": 3.01, + "wind_deg": 244, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785749400, + "temp": 23.3, + "feels_like": 23.37, + "pressure": 1015, + "humidity": 65, + "dew_point": 16.47, + "uvi": 4.34, + "clouds": 30, + "visibility": 10000, + "wind_speed": 3.16, + "wind_deg": 243, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785750300, + "temp": 23.32, + "feels_like": 23.37, + "pressure": 1015, + "humidity": 64, + "dew_point": 16.48, + "uvi": 4.88, + "clouds": 28, + "visibility": 10000, + "wind_speed": 3.31, + "wind_deg": 242, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785751200, + "temp": 23.34, + "feels_like": 23.37, + "pressure": 1015, + "humidity": 63, + "dew_point": 16.48, + "uvi": 5.42, + "clouds": 26, + "visibility": 10000, + "wind_speed": 3.46, + "wind_deg": 242, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785752100, + "temp": 23.56, + "feels_like": 23.59, + "pressure": 1015, + "humidity": 62, + "dew_point": 16.52, + "uvi": 5.94, + "clouds": 24, + "visibility": 10000, + "wind_speed": 3.6, + "wind_deg": 241, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785753000, + "temp": 23.77, + "feels_like": 23.81, + "pressure": 1015, + "humidity": 61, + "dew_point": 16.57, + "uvi": 6.47, + "clouds": 23, + "visibility": 10000, + "wind_speed": 3.74, + "wind_deg": 241, + "pop": 0, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ] + }, + { + "dt": 1785753900, + "temp": 23.99, + "feels_like": 24.02, + "pressure": 1015, + "humidity": 60, + "dew_point": 16.61, + "uvi": 6.99, + "clouds": 22, + "visibility": 10000, + "wind_speed": 3.87, + "wind_deg": 240, + "pop": 0, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ] + }, + { + "dt": 1785754800, + "temp": 24.2, + "feels_like": 24.24, + "pressure": 1015, + "humidity": 60, + "dew_point": 16.65, + "uvi": 7.51, + "clouds": 21, + "visibility": 10000, + "wind_speed": 4.01, + "wind_deg": 240, + "pop": 0, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ] + }, + { + "dt": 1785755700, + "temp": 24.49, + "feels_like": 24.56, + "pressure": 1015, + "humidity": 60, + "dew_point": 16.78, + "uvi": 7.8, + "clouds": 20, + "visibility": 10000, + "wind_speed": 4.22, + "wind_deg": 240, + "pop": 0, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ] + }, + { + "dt": 1785756600, + "temp": 24.77, + "feels_like": 24.87, + "pressure": 1015, + "humidity": 60, + "dew_point": 16.91, + "uvi": 8.09, + "clouds": 20, + "visibility": 10000, + "wind_speed": 4.43, + "wind_deg": 241, + "pop": 0, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ] + }, + { + "dt": 1785757500, + "temp": 25.06, + "feels_like": 25.18, + "pressure": 1015, + "humidity": 60, + "dew_point": 17.03, + "uvi": 8.38, + "clouds": 20, + "visibility": 10000, + "wind_speed": 4.63, + "wind_deg": 242, + "pop": 0, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ] + }, + { + "dt": 1785758400, + "temp": 25.34, + "feels_like": 25.5, + "pressure": 1015, + "humidity": 60, + "dew_point": 17.16, + "uvi": 8.67, + "clouds": 20, + "visibility": 10000, + "wind_speed": 4.84, + "wind_deg": 243, + "pop": 0, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ] + }, + { + "dt": 1785759300, + "temp": 25.53, + "feels_like": 25.65, + "pressure": 1015, + "humidity": 60, + "dew_point": 17.3, + "uvi": 8.47, + "clouds": 22, + "visibility": 10000, + "wind_speed": 4.94, + "wind_deg": 243, + "pop": 0, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ] + } + ], + "prev": "http://api.openweathermap.org/data/4.0/onecall/timeline/15min?cnt=50&lat=38.7223&lon=-9.1393&start=1785670200&appid={API key}&units=metric&lang=en", + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/15min?cnt=50&lat=38.7223&lon=-9.1393&start=1785760200&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/fifteen-minute/pagination.meta.json b/tests/Fixtures/one-call/fifteen-minute/pagination.meta.json new file mode 100644 index 0000000..396cc10 --- /dev/null +++ b/tests/Fixtures/one-call/fifteen-minute/pagination.meta.json @@ -0,0 +1,30 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "15-minute timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:25:45Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/15min", + "query": { + "cnt": 50, + "lat": 38.7223, + "lon": -9.1393, + "start": 1785715200, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.prev", + "action": "replaced appid query value with {API key}" + }, + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/fifteen-minute/rain.json b/tests/Fixtures/one-call/fifteen-minute/rain.json new file mode 100644 index 0000000..60d4b91 --- /dev/null +++ b/tests/Fixtures/one-call/fifteen-minute/rain.json @@ -0,0 +1,1109 @@ +{ + "lat": 14.5995, + "lon": 120.9842, + "timezone": "Asia/Manila", + "timezone_offset": 28800, + "data": [ + { + "dt": 1785670200, + "temp": 26.7, + "feels_like": 28.55, + "pressure": 1007, + "humidity": 92, + "dew_point": 25.29, + "uvi": 0, + "clouds": 100, + "visibility": 8729, + "wind_speed": 1.98, + "wind_deg": 270, + "pop": 0.9, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785671100, + "temp": 26.6, + "feels_like": 27.53, + "pressure": 1007, + "humidity": 92, + "dew_point": 25.19, + "uvi": 0, + "clouds": 100, + "visibility": 9364, + "wind_speed": 2.07, + "wind_deg": 269, + "pop": 0.85, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785672000, + "temp": 26.51, + "feels_like": 26.51, + "pressure": 1007, + "humidity": 92, + "dew_point": 25.1, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.16, + "wind_deg": 268, + "pop": 0.8, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785672900, + "temp": 26.4, + "feels_like": 26.4, + "pressure": 1007, + "humidity": 91, + "dew_point": 24.9, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.24, + "wind_deg": 276, + "pop": 0.8, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785673800, + "temp": 26.3, + "feels_like": 26.3, + "pressure": 1007, + "humidity": 91, + "dew_point": 24.71, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.33, + "wind_deg": 284, + "pop": 0.8, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785674700, + "temp": 26.19, + "feels_like": 26.19, + "pressure": 1007, + "humidity": 90, + "dew_point": 24.51, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.41, + "wind_deg": 292, + "pop": 0.8, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785675600, + "temp": 26.08, + "feels_like": 26.08, + "pressure": 1007, + "humidity": 90, + "dew_point": 24.31, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.49, + "wind_deg": 301, + "pop": 0.8, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785676500, + "temp": 25.97, + "feels_like": 26.19, + "pressure": 1007.25, + "humidity": 89, + "dew_point": 24.06, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.46, + "wind_deg": 305, + "pop": 0.8, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785677400, + "temp": 25.86, + "feels_like": 26.3, + "pressure": 1007.5, + "humidity": 88, + "dew_point": 23.81, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.43, + "wind_deg": 309, + "pop": 0.79, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785678300, + "temp": 25.74, + "feels_like": 26.41, + "pressure": 1007.75, + "humidity": 87, + "dew_point": 23.55, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.4, + "wind_deg": 313, + "pop": 0.79, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785679200, + "temp": 25.63, + "feels_like": 26.52, + "pressure": 1008, + "humidity": 87, + "dew_point": 23.3, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.37, + "wind_deg": 318, + "pop": 0.78, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785680100, + "temp": 25.59, + "feels_like": 26.47, + "pressure": 1008, + "humidity": 86, + "dew_point": 23.22, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.31, + "wind_deg": 321, + "pop": 0.79, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785681000, + "temp": 25.55, + "feels_like": 26.42, + "pressure": 1008, + "humidity": 86, + "dew_point": 23.13, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.26, + "wind_deg": 325, + "pop": 0.8, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785681900, + "temp": 25.51, + "feels_like": 26.37, + "pressure": 1008, + "humidity": 86, + "dew_point": 23.05, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.2, + "wind_deg": 328, + "pop": 0.81, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785682800, + "temp": 25.47, + "feels_like": 26.32, + "pressure": 1008, + "humidity": 86, + "dew_point": 22.96, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.14, + "wind_deg": 332, + "pop": 0.82, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785683700, + "temp": 25.33, + "feels_like": 26.18, + "pressure": 1008, + "humidity": 86, + "dew_point": 22.96, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.31, + "wind_deg": 328, + "pop": 0.87, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785684600, + "temp": 25.2, + "feels_like": 26.05, + "pressure": 1008, + "humidity": 87, + "dew_point": 22.96, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.48, + "wind_deg": 325, + "pop": 0.91, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785685500, + "temp": 25.06, + "feels_like": 25.91, + "pressure": 1008, + "humidity": 87, + "dew_point": 22.96, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.65, + "wind_deg": 321, + "pop": 0.96, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785686400, + "temp": 24.92, + "feels_like": 25.77, + "pressure": 1008, + "humidity": 88, + "dew_point": 22.96, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.82, + "wind_deg": 318, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785687300, + "temp": 24.68, + "feels_like": 25.51, + "pressure": 1007.75, + "humidity": 88, + "dew_point": 22.99, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.85, + "wind_deg": 310, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785688200, + "temp": 24.44, + "feels_like": 25.24, + "pressure": 1007.5, + "humidity": 88, + "dew_point": 23.02, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.87, + "wind_deg": 303, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785689100, + "temp": 24.2, + "feels_like": 24.98, + "pressure": 1007.25, + "humidity": 88, + "dew_point": 23.04, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.9, + "wind_deg": 295, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785690000, + "temp": 23.96, + "feels_like": 24.71, + "pressure": 1007, + "humidity": 88, + "dew_point": 23.07, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.92, + "wind_deg": 288, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785690900, + "temp": 23.93, + "feels_like": 24.67, + "pressure": 1006.75, + "humidity": 87, + "dew_point": 22.99, + "uvi": 0, + "clouds": 100, + "visibility": 9787, + "wind_speed": 2.91, + "wind_deg": 283, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785691800, + "temp": 23.89, + "feels_like": 24.62, + "pressure": 1006.5, + "humidity": 87, + "dew_point": 22.92, + "uvi": 0, + "clouds": 100, + "visibility": 9574, + "wind_speed": 2.9, + "wind_deg": 279, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785692700, + "temp": 23.86, + "feels_like": 24.58, + "pressure": 1006.25, + "humidity": 87, + "dew_point": 22.84, + "uvi": 0, + "clouds": 100, + "visibility": 9361, + "wind_speed": 2.89, + "wind_deg": 274, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785693600, + "temp": 23.82, + "feels_like": 24.53, + "pressure": 1006, + "humidity": 87, + "dew_point": 22.76, + "uvi": 0, + "clouds": 100, + "visibility": 9149, + "wind_speed": 2.88, + "wind_deg": 270, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785694500, + "temp": 23.81, + "feels_like": 24.53, + "pressure": 1006, + "humidity": 87, + "dew_point": 22.8, + "uvi": 0, + "clouds": 100, + "visibility": 9361, + "wind_speed": 2.82, + "wind_deg": 267, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785695400, + "temp": 23.79, + "feels_like": 24.53, + "pressure": 1006, + "humidity": 88, + "dew_point": 22.85, + "uvi": 0, + "clouds": 100, + "visibility": 9574, + "wind_speed": 2.76, + "wind_deg": 264, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785696300, + "temp": 23.78, + "feels_like": 24.52, + "pressure": 1006, + "humidity": 88, + "dew_point": 22.89, + "uvi": 0, + "clouds": 100, + "visibility": 9787, + "wind_speed": 2.69, + "wind_deg": 261, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785697200, + "temp": 23.76, + "feels_like": 24.52, + "pressure": 1006, + "humidity": 89, + "dew_point": 22.93, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.63, + "wind_deg": 258, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785698100, + "temp": 23.85, + "feels_like": 24.61, + "pressure": 1006, + "humidity": 89, + "dew_point": 22.93, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.76, + "wind_deg": 255, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785699000, + "temp": 23.93, + "feels_like": 24.71, + "pressure": 1006, + "humidity": 89, + "dew_point": 22.93, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.88, + "wind_deg": 252, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785699900, + "temp": 24.02, + "feels_like": 24.8, + "pressure": 1006, + "humidity": 89, + "dew_point": 22.93, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.01, + "wind_deg": 249, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785700800, + "temp": 24.1, + "feels_like": 24.89, + "pressure": 1006, + "humidity": 89, + "dew_point": 22.93, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.13, + "wind_deg": 247, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785701700, + "temp": 24.19, + "feels_like": 24.98, + "pressure": 1006, + "humidity": 88, + "dew_point": 22.97, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.36, + "wind_deg": 246, + "pop": 0.95, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785702600, + "temp": 24.28, + "feels_like": 25.08, + "pressure": 1006, + "humidity": 88, + "dew_point": 23.01, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.59, + "wind_deg": 246, + "pop": 0.9, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785703500, + "temp": 24.37, + "feels_like": 25.17, + "pressure": 1006, + "humidity": 88, + "dew_point": 23.06, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.81, + "wind_deg": 246, + "pop": 0.85, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785704400, + "temp": 24.46, + "feels_like": 25.26, + "pressure": 1006, + "humidity": 88, + "dew_point": 23.1, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.04, + "wind_deg": 246, + "pop": 0.8, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785705300, + "temp": 24.43, + "feels_like": 25.24, + "pressure": 1006.25, + "humidity": 88, + "dew_point": 23.11, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.61, + "wind_deg": 245, + "pop": 0.85, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785706200, + "temp": 24.41, + "feels_like": 25.21, + "pressure": 1006.5, + "humidity": 88, + "dew_point": 23.12, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.17, + "wind_deg": 244, + "pop": 0.9, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + { + "dt": 1785707100, + "temp": 24.38, + "feels_like": 25.19, + "pressure": 1006.75, + "humidity": 88, + "dew_point": 23.14, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.74, + "wind_deg": 243, + "pop": 0.95, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + { + "dt": 1785708000, + "temp": 24.35, + "feels_like": 25.16, + "pressure": 1007, + "humidity": 89, + "dew_point": 23.15, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.3, + "wind_deg": 242, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + { + "dt": 1785708900, + "temp": 24.33, + "feels_like": 25.14, + "pressure": 1007, + "humidity": 89, + "dew_point": 23.16, + "uvi": 0.02, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.46, + "wind_deg": 239, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + { + "dt": 1785709800, + "temp": 24.3, + "feels_like": 25.12, + "pressure": 1007, + "humidity": 89, + "dew_point": 23.17, + "uvi": 0.04, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.62, + "wind_deg": 237, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + { + "dt": 1785710700, + "temp": 24.27, + "feels_like": 25.1, + "pressure": 1007, + "humidity": 89, + "dew_point": 23.17, + "uvi": 0.06, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.78, + "wind_deg": 235, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + { + "dt": 1785711600, + "temp": 24.25, + "feels_like": 25.08, + "pressure": 1007, + "humidity": 90, + "dew_point": 23.18, + "uvi": 0.08, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.94, + "wind_deg": 233, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + { + "dt": 1785712500, + "temp": 24.5, + "feels_like": 25.35, + "pressure": 1007, + "humidity": 90, + "dew_point": 23.27, + "uvi": 0.14, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.94, + "wind_deg": 232, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + { + "dt": 1785713400, + "temp": 24.74, + "feels_like": 25.62, + "pressure": 1007, + "humidity": 90, + "dew_point": 23.37, + "uvi": 0.2, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.93, + "wind_deg": 232, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + { + "dt": 1785714300, + "temp": 24.99, + "feels_like": 25.89, + "pressure": 1007, + "humidity": 90, + "dew_point": 23.46, + "uvi": 0.26, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.93, + "wind_deg": 231, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + } + ], + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/15min?cnt=50&lat=14.5995&lon=120.9842&start=1785715200&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/fifteen-minute/rain.meta.json b/tests/Fixtures/one-call/fifteen-minute/rain.meta.json new file mode 100644 index 0000000..408f5fc --- /dev/null +++ b/tests/Fixtures/one-call/fifteen-minute/rain.meta.json @@ -0,0 +1,24 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "15-minute timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:25:06Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/15min", + "query": { + "lat": 14.5995, + "lon": 120.9842, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/fifteen-minute/snow-alerts.json b/tests/Fixtures/one-call/fifteen-minute/snow-alerts.json new file mode 100644 index 0000000..cb1b8f2 --- /dev/null +++ b/tests/Fixtures/one-call/fifteen-minute/snow-alerts.json @@ -0,0 +1,1259 @@ +{ + "lat": -38.4, + "lon": -71.58, + "timezone": "America/Santiago", + "timezone_offset": -14400, + "data": [ + { + "dt": 1785670200, + "temp": -2.3, + "feels_like": -6.89, + "pressure": 1012, + "humidity": 100, + "dew_point": -2.3, + "uvi": 0, + "clouds": 100, + "visibility": 79, + "wind_speed": 3.72, + "wind_deg": 305, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671100, + "temp": -2.3, + "feels_like": -6.87, + "pressure": 1012, + "humidity": 100, + "dew_point": -2.3, + "uvi": 0, + "clouds": 100, + "visibility": 102, + "wind_speed": 3.69, + "wind_deg": 305, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672000, + "temp": -2.3, + "feels_like": -6.84, + "pressure": 1012, + "humidity": 100, + "dew_point": -2.3, + "uvi": 0, + "clouds": 100, + "visibility": 126, + "wind_speed": 3.66, + "wind_deg": 305, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672900, + "temp": -2.31, + "feels_like": -6.8, + "pressure": 1012.25, + "humidity": 100, + "dew_point": -2.31, + "uvi": 0.01, + "clouds": 100, + "visibility": 94, + "wind_speed": 3.59, + "wind_deg": 304, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785673800, + "temp": -2.32, + "feels_like": -6.76, + "pressure": 1012.5, + "humidity": 100, + "dew_point": -2.32, + "uvi": 0.02, + "clouds": 100, + "visibility": 63, + "wind_speed": 3.53, + "wind_deg": 303, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785674700, + "temp": -2.34, + "feels_like": -6.72, + "pressure": 1012.75, + "humidity": 100, + "dew_point": -2.34, + "uvi": 0.03, + "clouds": 100, + "visibility": 31, + "wind_speed": 3.46, + "wind_deg": 302, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785675600, + "temp": -2.35, + "feels_like": -6.68, + "pressure": 1013, + "humidity": 100, + "dew_point": -2.35, + "uvi": 0.04, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.39, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785676500, + "temp": -2.34, + "feels_like": -6.64, + "pressure": 1013.25, + "humidity": 100, + "dew_point": -2.34, + "uvi": 0.06, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.36, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785677400, + "temp": -2.33, + "feels_like": -6.6, + "pressure": 1013.5, + "humidity": 100, + "dew_point": -2.33, + "uvi": 0.08, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.33, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785678300, + "temp": -2.32, + "feels_like": -6.56, + "pressure": 1013.75, + "humidity": 100, + "dew_point": -2.32, + "uvi": 0.1, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.3, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785679200, + "temp": -2.31, + "feels_like": -6.52, + "pressure": 1014, + "humidity": 100, + "dew_point": -2.31, + "uvi": 0.12, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.27, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785680100, + "temp": -2.32, + "feels_like": -6.51, + "pressure": 1014, + "humidity": 100, + "dew_point": -2.32, + "uvi": 0.16, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.24, + "wind_deg": 300, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785681000, + "temp": -2.34, + "feels_like": -6.51, + "pressure": 1014, + "humidity": 100, + "dew_point": -2.34, + "uvi": 0.19, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.22, + "wind_deg": 300, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785681900, + "temp": -2.36, + "feels_like": -6.5, + "pressure": 1014, + "humidity": 100, + "dew_point": -2.36, + "uvi": 0.23, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.19, + "wind_deg": 300, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785682800, + "temp": -2.37, + "feels_like": -6.5, + "pressure": 1014, + "humidity": 100, + "dew_point": -2.37, + "uvi": 0.26, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.16, + "wind_deg": 300, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785683700, + "temp": -2.35, + "feels_like": -6.48, + "pressure": 1014.25, + "humidity": 100, + "dew_point": -1.8, + "uvi": 0.29, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.17, + "wind_deg": 299, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785684600, + "temp": -2.33, + "feels_like": -6.46, + "pressure": 1014.5, + "humidity": 100, + "dew_point": -1.23, + "uvi": 0.32, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.17, + "wind_deg": 299, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785685500, + "temp": -2.31, + "feels_like": -6.44, + "pressure": 1014.75, + "humidity": 100, + "dew_point": -0.65, + "uvi": 0.34, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.18, + "wind_deg": 299, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785686400, + "temp": -2.29, + "feels_like": -6.42, + "pressure": 1015, + "humidity": 100, + "dew_point": -0.08, + "uvi": 0.37, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.18, + "wind_deg": 299, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785687300, + "temp": -2.23, + "feels_like": -6.4, + "pressure": 1015, + "humidity": 100, + "dew_point": -0.02, + "uvi": 0.39, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.23, + "wind_deg": 300, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785688200, + "temp": -2.18, + "feels_like": -6.38, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.04, + "uvi": 0.4, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.29, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785689100, + "temp": -2.13, + "feels_like": -6.36, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.09, + "uvi": 0.42, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.34, + "wind_deg": 302, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785690000, + "temp": -2.07, + "feels_like": -6.34, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.15, + "uvi": 0.43, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.39, + "wind_deg": 303, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785690900, + "temp": -2.03, + "feels_like": -6.31, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.21, + "uvi": 0.39, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.42, + "wind_deg": 302, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785691800, + "temp": -1.99, + "feels_like": -6.28, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.27, + "uvi": 0.34, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.44, + "wind_deg": 302, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785692700, + "temp": -1.94, + "feels_like": -6.25, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.33, + "uvi": 0.3, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.47, + "wind_deg": 302, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785693600, + "temp": -1.9, + "feels_like": -6.22, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.39, + "uvi": 0.25, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.49, + "wind_deg": 302, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785694500, + "temp": -1.84, + "feels_like": -6.1, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.43, + "uvi": 0.22, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.44, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785695400, + "temp": -1.77, + "feels_like": -5.98, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.48, + "uvi": 0.18, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.39, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785696300, + "temp": -1.71, + "feels_like": -5.86, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.52, + "uvi": 0.14, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.33, + "wind_deg": 300, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785697200, + "temp": -1.65, + "feels_like": -5.74, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.56, + "uvi": 0.11, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.28, + "wind_deg": 300, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785698100, + "temp": -1.71, + "feels_like": -5.68, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.5, + "uvi": 0.1, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.14, + "wind_deg": 300, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785699000, + "temp": -1.77, + "feels_like": -5.61, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.45, + "uvi": 0.08, + "clouds": 100, + "visibility": 0, + "wind_speed": 3, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785699900, + "temp": -1.83, + "feels_like": -5.55, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.39, + "uvi": 0.07, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.86, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785700800, + "temp": -1.89, + "feels_like": -5.49, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.33, + "uvi": 0.05, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.72, + "wind_deg": 302, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785701700, + "temp": -1.92, + "feels_like": -5.52, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.28, + "uvi": 0.04, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.71, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785702600, + "temp": -1.96, + "feels_like": -5.56, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.23, + "uvi": 0.04, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.71, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785703500, + "temp": -2, + "feels_like": -5.59, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.18, + "uvi": 0.03, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.7, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785704400, + "temp": -2.03, + "feels_like": -5.63, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.13, + "uvi": 0.02, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.69, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785705300, + "temp": -2.08, + "feels_like": -5.63, + "pressure": 1015.25, + "humidity": 100, + "dew_point": 0.11, + "uvi": 0.02, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.63, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785706200, + "temp": -2.14, + "feels_like": -5.63, + "pressure": 1015.5, + "humidity": 100, + "dew_point": 0.1, + "uvi": 0.01, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.57, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785707100, + "temp": -2.19, + "feels_like": -5.62, + "pressure": 1015.75, + "humidity": 100, + "dew_point": 0.08, + "uvi": 0.01, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.5, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785708000, + "temp": -2.25, + "feels_like": -5.62, + "pressure": 1016, + "humidity": 100, + "dew_point": 0.06, + "uvi": 0, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.44, + "wind_deg": 302, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785708900, + "temp": -2.26, + "feels_like": -5.57, + "pressure": 1016.25, + "humidity": 100, + "dew_point": 0.04, + "uvi": 0, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.39, + "wind_deg": 294, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785709800, + "temp": -2.26, + "feels_like": -5.51, + "pressure": 1016.5, + "humidity": 100, + "dew_point": 0.01, + "uvi": 0, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.34, + "wind_deg": 287, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785710700, + "temp": -2.27, + "feels_like": -5.46, + "pressure": 1016.75, + "humidity": 100, + "dew_point": -0.01, + "uvi": 0, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.28, + "wind_deg": 279, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785711600, + "temp": -2.28, + "feels_like": -5.41, + "pressure": 1017, + "humidity": 100, + "dew_point": -0.04, + "uvi": 0, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.23, + "wind_deg": 272, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785712500, + "temp": -2.29, + "feels_like": -5.4, + "pressure": 1017, + "humidity": 100, + "dew_point": -0.05, + "uvi": 0, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.22, + "wind_deg": 279, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785713400, + "temp": -2.29, + "feels_like": -5.4, + "pressure": 1017, + "humidity": 100, + "dew_point": -0.06, + "uvi": 0, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.21, + "wind_deg": 286, + "pop": 1, + "weather": [ + { + "id": 602, + "main": "Snow", + "description": "heavy snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785714300, + "temp": -2.3, + "feels_like": -5.39, + "pressure": 1017, + "humidity": 100, + "dew_point": -0.08, + "uvi": 0, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.2, + "wind_deg": 293, + "pop": 1, + "weather": [ + { + "id": 602, + "main": "Snow", + "description": "heavy snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + } + ], + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/15min?cnt=50&lat=-38.4000&lon=-71.5800&start=1785715200&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/fifteen-minute/snow-alerts.meta.json b/tests/Fixtures/one-call/fifteen-minute/snow-alerts.meta.json new file mode 100644 index 0000000..becea9b --- /dev/null +++ b/tests/Fixtures/one-call/fifteen-minute/snow-alerts.meta.json @@ -0,0 +1,24 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "15-minute timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:24:55Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/15min", + "query": { + "lat": -38.4, + "lon": -71.58, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/fifteen-minute/success.json b/tests/Fixtures/one-call/fifteen-minute/success.json new file mode 100644 index 0000000..bea4e05 --- /dev/null +++ b/tests/Fixtures/one-call/fifteen-minute/success.json @@ -0,0 +1,1109 @@ +{ + "lat": 38.7223, + "lon": -9.1393, + "timezone": "Europe/Lisbon", + "timezone_offset": 3600, + "data": [ + { + "dt": 1785670200, + "temp": 25.19, + "feels_like": 25.49, + "pressure": 1016, + "humidity": 66, + "dew_point": 18.37, + "uvi": 8.05, + "clouds": 41, + "visibility": 10000, + "wind_speed": 3.92, + "wind_deg": 314, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785671100, + "temp": 25.25, + "feels_like": 25.51, + "pressure": 1016, + "humidity": 64, + "dew_point": 18.06, + "uvi": 8.31, + "clouds": 41, + "visibility": 10000, + "wind_speed": 3.94, + "wind_deg": 313, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785672000, + "temp": 25.3, + "feels_like": 25.53, + "pressure": 1016, + "humidity": 63, + "dew_point": 17.75, + "uvi": 8.57, + "clouds": 42, + "visibility": 10000, + "wind_speed": 3.96, + "wind_deg": 312, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785672900, + "temp": 25.47, + "feels_like": 25.64, + "pressure": 1016, + "humidity": 61, + "dew_point": 17.51, + "uvi": 8.5, + "clouds": 47, + "visibility": 10000, + "wind_speed": 4.24, + "wind_deg": 310, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785673800, + "temp": 25.63, + "feels_like": 25.75, + "pressure": 1016, + "humidity": 60, + "dew_point": 17.27, + "uvi": 8.43, + "clouds": 53, + "visibility": 10000, + "wind_speed": 4.52, + "wind_deg": 309, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785674700, + "temp": 25.8, + "feels_like": 25.85, + "pressure": 1016, + "humidity": 58, + "dew_point": 17.03, + "uvi": 8.35, + "clouds": 58, + "visibility": 10000, + "wind_speed": 4.79, + "wind_deg": 307, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785675600, + "temp": 25.96, + "feels_like": 25.96, + "pressure": 1016, + "humidity": 57, + "dew_point": 16.79, + "uvi": 8.28, + "clouds": 64, + "visibility": 10000, + "wind_speed": 5.07, + "wind_deg": 306, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785676500, + "temp": 26.06, + "feels_like": 26.06, + "pressure": 1015.75, + "humidity": 55, + "dew_point": 16.45, + "uvi": 8.06, + "clouds": 67, + "visibility": 10000, + "wind_speed": 5.21, + "wind_deg": 306, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785677400, + "temp": 26.16, + "feels_like": 26.16, + "pressure": 1015.5, + "humidity": 54, + "dew_point": 16.1, + "uvi": 7.85, + "clouds": 70, + "visibility": 10000, + "wind_speed": 5.35, + "wind_deg": 306, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785678300, + "temp": 26.25, + "feels_like": 26.25, + "pressure": 1015.25, + "humidity": 52, + "dew_point": 15.75, + "uvi": 7.63, + "clouds": 73, + "visibility": 10000, + "wind_speed": 5.48, + "wind_deg": 306, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785679200, + "temp": 26.35, + "feels_like": 26.35, + "pressure": 1015, + "humidity": 51, + "dew_point": 15.41, + "uvi": 7.41, + "clouds": 76, + "visibility": 10000, + "wind_speed": 5.62, + "wind_deg": 307, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785680100, + "temp": 26.31, + "feels_like": 26.31, + "pressure": 1014.75, + "humidity": 49, + "dew_point": 14.89, + "uvi": 6.91, + "clouds": 79, + "visibility": 10000, + "wind_speed": 5.65, + "wind_deg": 307, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785681000, + "temp": 26.27, + "feels_like": 26.27, + "pressure": 1014.5, + "humidity": 48, + "dew_point": 14.37, + "uvi": 6.41, + "clouds": 82, + "visibility": 10000, + "wind_speed": 5.68, + "wind_deg": 308, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785681900, + "temp": 26.23, + "feels_like": 26.23, + "pressure": 1014.25, + "humidity": 46, + "dew_point": 13.85, + "uvi": 5.9, + "clouds": 85, + "visibility": 10000, + "wind_speed": 5.71, + "wind_deg": 309, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785682800, + "temp": 26.19, + "feels_like": 26.19, + "pressure": 1014, + "humidity": 45, + "dew_point": 13.33, + "uvi": 5.4, + "clouds": 88, + "visibility": 10000, + "wind_speed": 5.74, + "wind_deg": 310, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785683700, + "temp": 26.13, + "feels_like": 26.13, + "pressure": 1014, + "humidity": 44, + "dew_point": 13.02, + "uvi": 4.88, + "clouds": 91, + "visibility": 10000, + "wind_speed": 5.73, + "wind_deg": 310, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785684600, + "temp": 26.08, + "feels_like": 26.08, + "pressure": 1014, + "humidity": 43, + "dew_point": 12.71, + "uvi": 4.36, + "clouds": 94, + "visibility": 10000, + "wind_speed": 5.73, + "wind_deg": 310, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785685500, + "temp": 26.03, + "feels_like": 26.03, + "pressure": 1014, + "humidity": 42, + "dew_point": 12.4, + "uvi": 3.83, + "clouds": 97, + "visibility": 10000, + "wind_speed": 5.72, + "wind_deg": 310, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785686400, + "temp": 25.97, + "feels_like": 25.97, + "pressure": 1014, + "humidity": 42, + "dew_point": 12.09, + "uvi": 3.31, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.71, + "wind_deg": 311, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785687300, + "temp": 25.61, + "feels_like": 25.53, + "pressure": 1014, + "humidity": 42, + "dew_point": 12.22, + "uvi": 2.87, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.68, + "wind_deg": 312, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785688200, + "temp": 25.25, + "feels_like": 25.09, + "pressure": 1014, + "humidity": 43, + "dew_point": 12.34, + "uvi": 2.44, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.65, + "wind_deg": 313, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785689100, + "temp": 24.88, + "feels_like": 24.64, + "pressure": 1014, + "humidity": 44, + "dew_point": 12.47, + "uvi": 2, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.61, + "wind_deg": 314, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785690000, + "temp": 24.52, + "feels_like": 24.2, + "pressure": 1014, + "humidity": 45, + "dew_point": 12.59, + "uvi": 1.56, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.58, + "wind_deg": 315, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785690900, + "temp": 24.41, + "feels_like": 24.1, + "pressure": 1014, + "humidity": 46, + "dew_point": 12.72, + "uvi": 1.31, + "clouds": 99, + "visibility": 10000, + "wind_speed": 5.49, + "wind_deg": 315, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785691800, + "temp": 24.3, + "feels_like": 24, + "pressure": 1014, + "humidity": 47, + "dew_point": 12.85, + "uvi": 1.05, + "clouds": 99, + "visibility": 10000, + "wind_speed": 5.4, + "wind_deg": 315, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785692700, + "temp": 24.18, + "feels_like": 23.91, + "pressure": 1014, + "humidity": 48, + "dew_point": 12.98, + "uvi": 0.8, + "clouds": 99, + "visibility": 10000, + "wind_speed": 5.3, + "wind_deg": 315, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785693600, + "temp": 24.07, + "feels_like": 23.81, + "pressure": 1014, + "humidity": 49, + "dew_point": 13.11, + "uvi": 0.54, + "clouds": 99, + "visibility": 10000, + "wind_speed": 5.21, + "wind_deg": 315, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785694500, + "temp": 23.85, + "feels_like": 23.62, + "pressure": 1014, + "humidity": 51, + "dew_point": 13.33, + "uvi": 0.44, + "clouds": 99, + "visibility": 10000, + "wind_speed": 5.1, + "wind_deg": 315, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785695400, + "temp": 23.64, + "feels_like": 23.43, + "pressure": 1014, + "humidity": 53, + "dew_point": 13.55, + "uvi": 0.33, + "clouds": 99, + "visibility": 10000, + "wind_speed": 4.98, + "wind_deg": 316, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785696300, + "temp": 23.42, + "feels_like": 23.25, + "pressure": 1014, + "humidity": 55, + "dew_point": 13.76, + "uvi": 0.22, + "clouds": 99, + "visibility": 10000, + "wind_speed": 4.87, + "wind_deg": 316, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785697200, + "temp": 23.2, + "feels_like": 23.06, + "pressure": 1014, + "humidity": 57, + "dew_point": 13.98, + "uvi": 0.12, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.75, + "wind_deg": 317, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785698100, + "temp": 23.04, + "feels_like": 22.93, + "pressure": 1014, + "humidity": 59, + "dew_point": 14.17, + "uvi": 0.09, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.58, + "wind_deg": 317, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785699000, + "temp": 22.87, + "feels_like": 22.81, + "pressure": 1014, + "humidity": 61, + "dew_point": 14.36, + "uvi": 0.06, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.41, + "wind_deg": 318, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785699900, + "temp": 22.71, + "feels_like": 22.68, + "pressure": 1014, + "humidity": 63, + "dew_point": 14.54, + "uvi": 0.03, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.24, + "wind_deg": 318, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785700800, + "temp": 22.54, + "feels_like": 22.55, + "pressure": 1014, + "humidity": 65, + "dew_point": 14.73, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.07, + "wind_deg": 319, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785701700, + "temp": 22.5, + "feels_like": 22.53, + "pressure": 1014.25, + "humidity": 66, + "dew_point": 14.89, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.07, + "wind_deg": 318, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785702600, + "temp": 22.45, + "feels_like": 22.51, + "pressure": 1014.5, + "humidity": 67, + "dew_point": 15.05, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.06, + "wind_deg": 317, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785703500, + "temp": 22.41, + "feels_like": 22.5, + "pressure": 1014.75, + "humidity": 68, + "dew_point": 15.2, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.06, + "wind_deg": 316, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785704400, + "temp": 22.36, + "feels_like": 22.48, + "pressure": 1015, + "humidity": 70, + "dew_point": 15.36, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.05, + "wind_deg": 315, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785705300, + "temp": 22.37, + "feels_like": 22.49, + "pressure": 1015, + "humidity": 70, + "dew_point": 15.41, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.91, + "wind_deg": 314, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785706200, + "temp": 22.37, + "feels_like": 22.5, + "pressure": 1015, + "humidity": 70, + "dew_point": 15.46, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.76, + "wind_deg": 313, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785707100, + "temp": 22.37, + "feels_like": 22.52, + "pressure": 1015, + "humidity": 70, + "dew_point": 15.5, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.62, + "wind_deg": 312, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785708000, + "temp": 22.38, + "feels_like": 22.53, + "pressure": 1015, + "humidity": 71, + "dew_point": 15.55, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.48, + "wind_deg": 312, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785708900, + "temp": 22.33, + "feels_like": 22.49, + "pressure": 1015, + "humidity": 71, + "dew_point": 15.62, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.5, + "wind_deg": 311, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785709800, + "temp": 22.28, + "feels_like": 22.45, + "pressure": 1015, + "humidity": 72, + "dew_point": 15.68, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.51, + "wind_deg": 311, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785710700, + "temp": 22.23, + "feels_like": 22.4, + "pressure": 1015, + "humidity": 72, + "dew_point": 15.75, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.53, + "wind_deg": 311, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785711600, + "temp": 22.18, + "feels_like": 22.36, + "pressure": 1015, + "humidity": 73, + "dew_point": 15.81, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.54, + "wind_deg": 311, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785712500, + "temp": 22.03, + "feels_like": 22.19, + "pressure": 1014.75, + "humidity": 73, + "dew_point": 15.8, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.37, + "wind_deg": 310, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785713400, + "temp": 21.87, + "feels_like": 22.02, + "pressure": 1014.5, + "humidity": 73, + "dew_point": 15.8, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.19, + "wind_deg": 309, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785714300, + "temp": 21.72, + "feels_like": 21.86, + "pressure": 1014.25, + "humidity": 73, + "dew_point": 15.79, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.01, + "wind_deg": 308, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + } + ], + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/15min?cnt=50&lat=38.7223&lon=-9.1393&start=1785715200&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/fifteen-minute/success.meta.json b/tests/Fixtures/one-call/fifteen-minute/success.meta.json new file mode 100644 index 0000000..a6e3667 --- /dev/null +++ b/tests/Fixtures/one-call/fifteen-minute/success.meta.json @@ -0,0 +1,24 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "15-minute timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:24:40Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/15min", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/one-day/history.json b/tests/Fixtures/one-call/one-day/history.json new file mode 100644 index 0000000..5b400ea --- /dev/null +++ b/tests/Fixtures/one-call/one-day/history.json @@ -0,0 +1,379 @@ +{ + "lat": 38.7223, + "lon": -9.1393, + "timezone": "Europe/Lisbon", + "timezone_offset": 3600, + "data": [ + { + "dt": 1785456000, + "sunrise": 1785476207, + "sunset": 1785527338, + "moonrise": 1785531300, + "moonset": 1785482220, + "moon_phase": 0.56, + "temp": { + "day": 24.68, + "min": 18.34, + "max": 27.68, + "night": 19.91, + "eve": 26.98, + "morn": 18.38 + }, + "feels_like": { + "day": 24.68, + "night": 19.91, + "eve": 26.98, + "morn": 18.38 + }, + "pressure": 1019, + "humidity": 69, + "wind_speed": 4.92, + "wind_deg": 5, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 1, + "uvi": 0 + }, + { + "dt": 1785542400, + "sunrise": 1785562660, + "sunset": 1785613679, + "moonrise": 1785619080, + "moonset": 1785572460, + "moon_phase": 0.59, + "temp": { + "day": 26.04, + "min": 18.96, + "max": 30.07, + "night": 20.82, + "eve": 27.39, + "morn": 18.97 + }, + "feels_like": { + "day": 26.04, + "night": 20.82, + "eve": 27.39, + "morn": 18.97 + }, + "pressure": 1015, + "humidity": 70, + "wind_speed": 6.26, + "wind_deg": 317, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ], + "clouds": 38, + "uvi": 0 + }, + { + "dt": 1785628800, + "sunrise": 1785649113, + "sunset": 1785700020, + "moonrise": 1785706800, + "moonset": 1785662640, + "moon_phase": 0.62, + "temp": { + "day": 25.53, + "min": 18.73, + "max": 26.99, + "night": 19.4, + "eve": 24.82, + "morn": 18.74 + }, + "feels_like": { + "day": 25.53, + "night": 19.4, + "eve": 24.82, + "morn": 18.74 + }, + "pressure": 1015.44, + "humidity": 48, + "wind_speed": 6.17, + "wind_deg": 307, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ], + "clouds": 41, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1785715200, + "sunrise": 1785735567, + "sunset": 1785786359, + "moonrise": 1785794640, + "moonset": 1785753000, + "moon_phase": 0.66, + "temp": { + "day": 24.47, + "min": 19.72, + "max": 25.34, + "night": 20.48, + "eve": 24.34, + "morn": 19.72 + }, + "feels_like": { + "day": 24.47, + "night": 20.48, + "eve": 24.34, + "morn": 19.72 + }, + "pressure": 1015.12, + "humidity": 62, + "wind_speed": 5.87, + "wind_deg": 246, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ], + "clouds": 22, + "pop": 0, + "rain": 0.09, + "uvi": 0 + }, + { + "dt": 1785801600, + "sunrise": 1785822021, + "sunset": 1785872696, + "moonrise": 1785882660, + "moonset": 1785843420, + "moon_phase": 0.69, + "temp": { + "day": 25.65, + "min": 19.98, + "max": 26.79, + "night": 20.69, + "eve": 25.3, + "morn": 19.98 + }, + "feels_like": { + "day": 25.65, + "night": 20.69, + "eve": 25.3, + "morn": 19.98 + }, + "pressure": 1018.73, + "humidity": 52, + "wind_speed": 6.12, + "wind_deg": 287, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 3, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1785888000, + "sunrise": 1785908475, + "sunset": 1785959032, + "moonrise": 0, + "moonset": 1785934020, + "moon_phase": 0.73, + "temp": { + "day": 26.19, + "min": 19.81, + "max": 27.07, + "night": 20.81, + "eve": 25.09, + "morn": 19.95 + }, + "feels_like": { + "day": 26.19, + "night": 20.81, + "eve": 25.09, + "morn": 19.95 + }, + "pressure": 1020.18, + "humidity": 51, + "wind_speed": 8.17, + "wind_deg": 341, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 1, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1785974400, + "sunrise": 1785994929, + "sunset": 1786045367, + "moonrise": 1785970920, + "moonset": 1786024800, + "moon_phase": 0.75, + "temp": { + "day": 26.03, + "min": 18.89, + "max": 27.46, + "night": 19.54, + "eve": 25.3, + "morn": 18.97 + }, + "feels_like": { + "day": 26.03, + "night": 19.54, + "eve": 25.3, + "morn": 18.97 + }, + "pressure": 1019.85, + "humidity": 42, + "wind_speed": 8.77, + "wind_deg": 340, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1786060800, + "sunrise": 1786081384, + "sunset": 1786131700, + "moonrise": 1786059600, + "moonset": 1786115640, + "moon_phase": 0.8, + "temp": { + "day": 26.16, + "min": 18.76, + "max": 27.59, + "night": 19.59, + "eve": 25.13, + "morn": 18.81 + }, + "feels_like": { + "day": 26.16, + "night": 19.59, + "eve": 25.13, + "morn": 18.81 + }, + "pressure": 1016.4, + "humidity": 44, + "wind_speed": 7.45, + "wind_deg": 331, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1786147200, + "sunrise": 1786167838, + "sunset": 1786218032, + "moonrise": 1786148880, + "moonset": 1786206360, + "moon_phase": 0.84, + "temp": { + "day": 25.26, + "min": 18.6, + "max": 26.53, + "night": 19.45, + "eve": 24.46, + "morn": 18.66 + }, + "feels_like": { + "day": 25.26, + "night": 19.45, + "eve": 24.46, + "morn": 18.66 + }, + "pressure": 1016.06, + "humidity": 48, + "wind_speed": 7.13, + "wind_deg": 327, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1786233600, + "sunrise": 1786254293, + "sunset": 1786304363, + "moonrise": 1786238880, + "moonset": 1786296480, + "moon_phase": 0.87, + "temp": { + "day": 23.74, + "min": 18.37, + "max": 25.29, + "night": 19.5, + "eve": 23.55, + "morn": 18.46 + }, + "feels_like": { + "day": 23.74, + "night": 19.5, + "eve": 23.55, + "morn": 18.46 + }, + "pressure": 1017.37, + "humidity": 56, + "wind_speed": 7.94, + "wind_deg": 339, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 0 + } + ], + "prev": "http://api.openweathermap.org/data/4.0/onecall/timeline/1day?cnt=10&lat=38.7223&lon=-9.1393&start=1784592000&appid={API key}&units=metric&lang=en", + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/1day?cnt=10&lat=38.7223&lon=-9.1393&start=1786320000&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/one-day/history.meta.json b/tests/Fixtures/one-call/one-day/history.meta.json new file mode 100644 index 0000000..2da7798 --- /dev/null +++ b/tests/Fixtures/one-call/one-day/history.meta.json @@ -0,0 +1,29 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-day timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:35:19Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1day", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "start": 1785456000, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.prev", + "action": "replaced appid query value with {API key}" + }, + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/one-day/rain.json b/tests/Fixtures/one-call/one-day/rain.json new file mode 100644 index 0000000..c40d066 --- /dev/null +++ b/tests/Fixtures/one-call/one-day/rain.json @@ -0,0 +1,390 @@ +{ + "lat": 14.5995, + "lon": 120.9842, + "timezone": "Asia/Manila", + "timezone_offset": 28800, + "data": [ + { + "dt": 1785628800, + "sunrise": 1785620365, + "sunset": 1785666311, + "moonrise": 1785675120, + "moonset": 1785630420, + "moon_phase": 0.61, + "temp": { + "day": 28.37, + "min": 25.42, + "max": 29.32, + "night": 26.91, + "eve": 25.49, + "morn": 26.06 + }, + "feels_like": { + "day": 28.37, + "night": 26.91, + "eve": 25.49, + "morn": 26.06 + }, + "pressure": 1006.71, + "humidity": 79, + "wind_speed": 3.69, + "wind_deg": 359, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "clouds": 99, + "pop": 1, + "rain": 17.92, + "uvi": 0 + }, + { + "dt": 1785715200, + "sunrise": 1785706779, + "sunset": 1785752690, + "moonrise": 1785763800, + "moonset": 1785719880, + "moon_phase": 0.65, + "temp": { + "day": 25.11, + "min": 24.31, + "max": 25.54, + "night": 24.99, + "eve": 24.87, + "morn": 24.75 + }, + "feels_like": { + "day": 25.11, + "night": 24.99, + "eve": 24.87, + "morn": 24.75 + }, + "pressure": 1007.73, + "humidity": 90, + "wind_speed": 8.27, + "wind_deg": 242, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "clouds": 100, + "pop": 1, + "rain": 30.77, + "uvi": 0 + }, + { + "dt": 1785801600, + "sunrise": 1785793194, + "sunset": 1785839067, + "moonrise": 1785852540, + "moonset": 1785809340, + "moon_phase": 0.68, + "temp": { + "day": 27.95, + "min": 25.25, + "max": 28.02, + "night": 25.31, + "eve": 26.29, + "morn": 25.98 + }, + "feels_like": { + "day": 27.95, + "night": 25.31, + "eve": 26.29, + "morn": 25.98 + }, + "pressure": 1007.03, + "humidity": 74, + "wind_speed": 10.11, + "wind_deg": 248, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "clouds": 100, + "pop": 0.96, + "rain": 11.54, + "uvi": 0 + }, + { + "dt": 1785888000, + "sunrise": 1785879608, + "sunset": 1785925444, + "moonrise": 1785941520, + "moonset": 1785899040, + "moon_phase": 0.72, + "temp": { + "day": 27.57, + "min": 25.75, + "max": 27.57, + "night": 26.81, + "eve": 26.76, + "morn": 25.75 + }, + "feels_like": { + "day": 27.57, + "night": 26.81, + "eve": 26.76, + "morn": 25.75 + }, + "pressure": 1005.81, + "humidity": 82, + "wind_speed": 9.36, + "wind_deg": 244, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "clouds": 100, + "pop": 1, + "rain": 25.92, + "uvi": 0 + }, + { + "dt": 1785974400, + "sunrise": 1785966021, + "sunset": 1786011820, + "moonrise": 1786030860, + "moonset": 1785988920, + "moon_phase": 0.75, + "temp": { + "day": 27.53, + "min": 25.75, + "max": 28.08, + "night": 26.9, + "eve": 26.4, + "morn": 26.75 + }, + "feels_like": { + "day": 27.53, + "night": 26.9, + "eve": 26.4, + "morn": 26.75 + }, + "pressure": 1003.96, + "humidity": 86, + "wind_speed": 8.72, + "wind_deg": 244, + "weather": [ + { + "id": 502, + "main": "Rain", + "description": "heavy intensity rain", + "icon": "10d" + } + ], + "clouds": 99, + "pop": 1, + "rain": 57.96, + "uvi": 0 + }, + { + "dt": 1786060800, + "sunrise": 1786052434, + "sunset": 1786098195, + "moonrise": 0, + "moonset": 1786079040, + "moon_phase": 0.79, + "temp": { + "day": 29.02, + "min": 25.97, + "max": 29.07, + "night": 26.15, + "eve": 27.38, + "morn": 26.45 + }, + "feels_like": { + "day": 29.02, + "night": 26.15, + "eve": 27.38, + "morn": 26.45 + }, + "pressure": 1003.18, + "humidity": 80, + "wind_speed": 9.03, + "wind_deg": 254, + "weather": [ + { + "id": 502, + "main": "Rain", + "description": "heavy intensity rain", + "icon": "10d" + } + ], + "clouds": 78, + "pop": 0.95, + "rain": 95.22, + "uvi": 0 + }, + { + "dt": 1786147200, + "sunrise": 1786138847, + "sunset": 1786184569, + "moonrise": 1786120500, + "moonset": 1786169340, + "moon_phase": 0.83, + "temp": { + "day": 28.27, + "min": 26.62, + "max": 28.59, + "night": 26.68, + "eve": 27.29, + "morn": 27.41 + }, + "feels_like": { + "day": 28.27, + "night": 26.68, + "eve": 27.29, + "morn": 27.41 + }, + "pressure": 1004.12, + "humidity": 83, + "wind_speed": 10.8, + "wind_deg": 254, + "weather": [ + { + "id": 502, + "main": "Rain", + "description": "heavy intensity rain", + "icon": "10d" + } + ], + "clouds": 100, + "pop": 1, + "rain": 141.21, + "uvi": 0 + }, + { + "dt": 1786233600, + "sunrise": 1786225260, + "sunset": 1786270942, + "moonrise": 1786210620, + "moonset": 1786259700, + "moon_phase": 0.86, + "temp": { + "day": 27.18, + "min": 27.02, + "max": 27.52, + "night": 27.07, + "eve": 27.19, + "morn": 27.27 + }, + "feels_like": { + "day": 27.18, + "night": 27.07, + "eve": 27.19, + "morn": 27.27 + }, + "pressure": 1002.91, + "humidity": 89, + "wind_speed": 11.31, + "wind_deg": 244, + "weather": [ + { + "id": 503, + "main": "Rain", + "description": "very heavy rain", + "icon": "10d" + } + ], + "clouds": 100, + "pop": 1, + "rain": 269.27, + "uvi": 0 + }, + { + "dt": 1786320000, + "sunrise": 1786311672, + "sunset": 1786357315, + "moonrise": 1786300920, + "moonset": 1786349820, + "moon_phase": 0.9, + "temp": { + "day": 27.97, + "min": 27.41, + "max": 28.57, + "night": 27.46, + "eve": 27.54, + "morn": 27.53 + }, + "feels_like": { + "day": 27.97, + "night": 27.46, + "eve": 27.54, + "morn": 27.53 + }, + "pressure": 1001.69, + "humidity": 86, + "wind_speed": 11.38, + "wind_deg": 235, + "weather": [ + { + "id": 502, + "main": "Rain", + "description": "heavy intensity rain", + "icon": "10d" + } + ], + "clouds": 100, + "pop": 1, + "rain": 149.4, + "uvi": 0 + }, + { + "dt": 1786406400, + "sunrise": 1786398084, + "sunset": 1786443686, + "moonrise": 1786391400, + "moonset": 1786439640, + "moon_phase": 0.94, + "temp": { + "day": 28.5, + "min": 27.17, + "max": 28.58, + "night": 27.33, + "eve": 27.56, + "morn": 27.37 + }, + "feels_like": { + "day": 28.5, + "night": 27.33, + "eve": 27.56, + "morn": 27.37 + }, + "pressure": 1001.12, + "humidity": 85, + "wind_speed": 11.08, + "wind_deg": 239, + "weather": [ + { + "id": 502, + "main": "Rain", + "description": "heavy intensity rain", + "icon": "10d" + } + ], + "clouds": 99, + "pop": 1, + "rain": 130.08, + "uvi": 0 + } + ], + "prev": "http://api.openweathermap.org/data/4.0/onecall/timeline/1day?cnt=10&lat=14.5995&lon=120.9842&start=1784764800&appid={API key}&units=metric&lang=en", + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/1day?cnt=10&lat=14.5995&lon=120.9842&start=1786492800&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/one-day/rain.meta.json b/tests/Fixtures/one-call/one-day/rain.meta.json new file mode 100644 index 0000000..78e358e --- /dev/null +++ b/tests/Fixtures/one-call/one-day/rain.meta.json @@ -0,0 +1,28 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-day timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:34:55Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1day", + "query": { + "lat": 14.5995, + "lon": 120.9842, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.prev", + "action": "replaced appid query value with {API key}" + }, + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/one-day/snow.json b/tests/Fixtures/one-call/one-day/snow.json new file mode 100644 index 0000000..4f88ba3 --- /dev/null +++ b/tests/Fixtures/one-call/one-day/snow.json @@ -0,0 +1,390 @@ +{ + "lat": -38.4, + "lon": -71.58, + "timezone": "America/Santiago", + "timezone_offset": -14400, + "data": [ + { + "dt": 1785628800, + "sunrise": 1785671200, + "sunset": 1785707904, + "moonrise": 1785633300, + "moonset": 1785590340, + "moon_phase": 0.6, + "temp": { + "day": -2.28, + "min": -2.9, + "max": -1.84, + "night": -2.51, + "eve": -2.35, + "morn": -2.17 + }, + "feels_like": { + "day": -2.28, + "night": -2.51, + "eve": -2.35, + "morn": -2.17 + }, + "pressure": 1015.3, + "humidity": 99, + "wind_speed": 3.91, + "wind_deg": 316, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "clouds": 100, + "pop": 1, + "snow": 60.98, + "uvi": 0 + }, + { + "dt": 1785715200, + "sunrise": 1785757540, + "sunset": 1785794355, + "moonrise": 1785723540, + "moonset": 1785678120, + "moon_phase": 0.63, + "temp": { + "day": -2.09, + "min": -3.01, + "max": -0.99, + "night": -2.93, + "eve": -2.13, + "morn": -2.86 + }, + "feels_like": { + "day": -2.09, + "night": -2.93, + "eve": -2.13, + "morn": -2.86 + }, + "pressure": 1022.42, + "humidity": 99, + "wind_speed": 1.96, + "wind_deg": 305, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "clouds": 100, + "pop": 1, + "snow": 22.08, + "uvi": 0 + }, + { + "dt": 1785801600, + "sunrise": 1785843880, + "sunset": 1785880807, + "moonrise": 1785813960, + "moonset": 1785765900, + "moon_phase": 0.67, + "temp": { + "day": -0.5, + "min": -2.78, + "max": 0.14, + "night": -2.06, + "eve": -1.52, + "morn": -1.68 + }, + "feels_like": { + "day": -0.5, + "night": -2.06, + "eve": -1.52, + "morn": -1.68 + }, + "pressure": 1020.64, + "humidity": 96, + "wind_speed": 2, + "wind_deg": 283, + "weather": [ + { + "id": 600, + "main": "Snow", + "description": "light snow", + "icon": "13d" + } + ], + "clouds": 99, + "pop": 0.36, + "snow": 1.01, + "uvi": 0 + }, + { + "dt": 1785888000, + "sunrise": 1785930218, + "sunset": 1785967259, + "moonrise": 0, + "moonset": 1785853800, + "moon_phase": 0.7, + "temp": { + "day": 0.01, + "min": -2.87, + "max": 0.52, + "night": -2.66, + "eve": -1.02, + "morn": -2.23 + }, + "feels_like": { + "day": 0.01, + "night": -2.66, + "eve": -1.02, + "morn": -2.23 + }, + "pressure": 1014.13, + "humidity": 95, + "wind_speed": 2.1, + "wind_deg": 337, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "clouds": 100, + "pop": 0.72, + "snow": 4.9, + "uvi": 0 + }, + { + "dt": 1785974400, + "sunrise": 1786016554, + "sunset": 1786053711, + "moonrise": 1785904440, + "moonset": 1785942000, + "moon_phase": 0.75, + "temp": { + "day": -3.34, + "min": -5.41, + "max": -0.38, + "night": -0.78, + "eve": -4.84, + "morn": -2.7 + }, + "feels_like": { + "day": -3.34, + "night": -0.78, + "eve": -4.84, + "morn": -2.7 + }, + "pressure": 1009.7, + "humidity": 98, + "wind_speed": 5.53, + "wind_deg": 280, + "weather": [ + { + "id": 602, + "main": "Snow", + "description": "heavy snow", + "icon": "13d" + } + ], + "clouds": 100, + "pop": 1, + "snow": 73.78, + "uvi": 0 + }, + { + "dt": 1786060800, + "sunrise": 1786102890, + "sunset": 1786140164, + "moonrise": 1785995220, + "moonset": 1786030560, + "moon_phase": 0.77, + "temp": { + "day": -6, + "min": -7.83, + "max": -4.7, + "night": -4.87, + "eve": -7.47, + "morn": -4.82 + }, + "feels_like": { + "day": -6, + "night": -4.87, + "eve": -7.47, + "morn": -4.82 + }, + "pressure": 1013.16, + "humidity": 97, + "wind_speed": 7.06, + "wind_deg": 265, + "weather": [ + { + "id": 602, + "main": "Snow", + "description": "heavy snow", + "icon": "13d" + } + ], + "clouds": 100, + "pop": 1, + "snow": 257.03, + "uvi": 0 + }, + { + "dt": 1786147200, + "sunrise": 1786189224, + "sunset": 1786226616, + "moonrise": 1786086060, + "moonset": 1786119600, + "moon_phase": 0.81, + "temp": { + "day": -5.83, + "min": -7.18, + "max": -3.82, + "night": -7.18, + "eve": -4.63, + "morn": -6.95 + }, + "feels_like": { + "day": -5.83, + "night": -7.18, + "eve": -4.63, + "morn": -6.95 + }, + "pressure": 1021.05, + "humidity": 98, + "wind_speed": 5.9, + "wind_deg": 290, + "weather": [ + { + "id": 602, + "main": "Snow", + "description": "heavy snow", + "icon": "13d" + } + ], + "clouds": 100, + "pop": 1, + "snow": 237.96, + "uvi": 0 + }, + { + "dt": 1786233600, + "sunrise": 1786275556, + "sunset": 1786313069, + "moonrise": 1786176780, + "moonset": 1786209420, + "moon_phase": 0.84, + "temp": { + "day": -4.05, + "min": -4.67, + "max": -3.24, + "night": -3.92, + "eve": -3.28, + "morn": -4.55 + }, + "feels_like": { + "day": -4.05, + "night": -3.92, + "eve": -3.28, + "morn": -4.55 + }, + "pressure": 1021.17, + "humidity": 99, + "wind_speed": 4.62, + "wind_deg": 292, + "weather": [ + { + "id": 602, + "main": "Snow", + "description": "heavy snow", + "icon": "13d" + } + ], + "clouds": 100, + "pop": 1, + "snow": 271.81, + "uvi": 0 + }, + { + "dt": 1786320000, + "sunrise": 1786361888, + "sunset": 1786399521, + "moonrise": 1786267140, + "moonset": 1786299840, + "moon_phase": 0.88, + "temp": { + "day": -3.12, + "min": -7.68, + "max": -3.07, + "night": -3.43, + "eve": -4.67, + "morn": -3.66 + }, + "feels_like": { + "day": -3.12, + "night": -3.43, + "eve": -4.67, + "morn": -3.66 + }, + "pressure": 1025.25, + "humidity": 99, + "wind_speed": 2.64, + "wind_deg": 286, + "weather": [ + { + "id": 602, + "main": "Snow", + "description": "heavy snow", + "icon": "13d" + } + ], + "clouds": 100, + "pop": 0.97, + "snow": 50.39, + "uvi": 0 + }, + { + "dt": 1786406400, + "sunrise": 1786448218, + "sunset": 1786485974, + "moonrise": 1786356900, + "moonset": 1786390800, + "moon_phase": 0.92, + "temp": { + "day": -3.15, + "min": -7.31, + "max": -3.14, + "night": -7.31, + "eve": -3.42, + "morn": -5.8 + }, + "feels_like": { + "day": -3.15, + "night": -7.31, + "eve": -3.42, + "morn": -5.8 + }, + "pressure": 1021.44, + "humidity": 99, + "wind_speed": 1.67, + "wind_deg": 335, + "weather": [ + { + "id": 602, + "main": "Snow", + "description": "heavy snow", + "icon": "13d" + } + ], + "clouds": 100, + "pop": 1, + "snow": 55.84, + "uvi": 0 + } + ], + "prev": "http://api.openweathermap.org/data/4.0/onecall/timeline/1day?cnt=10&lat=-38.4000&lon=-71.5800&start=1784764800&appid={API key}&units=metric&lang=en", + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/1day?cnt=10&lat=-38.4000&lon=-71.5800&start=1786492800&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/one-day/snow.meta.json b/tests/Fixtures/one-call/one-day/snow.meta.json new file mode 100644 index 0000000..417f39f --- /dev/null +++ b/tests/Fixtures/one-call/one-day/snow.meta.json @@ -0,0 +1,28 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-day timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:35:06Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1day", + "query": { + "lat": -38.4, + "lon": -71.58, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.prev", + "action": "replaced appid query value with {API key}" + }, + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/one-day/success.json b/tests/Fixtures/one-call/one-day/success.json new file mode 100644 index 0000000..4859af5 --- /dev/null +++ b/tests/Fixtures/one-call/one-day/success.json @@ -0,0 +1,381 @@ +{ + "lat": 38.7223, + "lon": -9.1393, + "timezone": "Europe/Lisbon", + "timezone_offset": 3600, + "data": [ + { + "dt": 1785628800, + "sunrise": 1785649113, + "sunset": 1785700020, + "moonrise": 1785706800, + "moonset": 1785662640, + "moon_phase": 0.62, + "temp": { + "day": 25.53, + "min": 18.73, + "max": 26.99, + "night": 19.4, + "eve": 24.82, + "morn": 18.74 + }, + "feels_like": { + "day": 25.53, + "night": 19.4, + "eve": 24.82, + "morn": 18.74 + }, + "pressure": 1015.44, + "humidity": 48, + "wind_speed": 6.17, + "wind_deg": 307, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ], + "clouds": 41, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1785715200, + "sunrise": 1785735567, + "sunset": 1785786359, + "moonrise": 1785794640, + "moonset": 1785753000, + "moon_phase": 0.66, + "temp": { + "day": 24.47, + "min": 19.72, + "max": 25.34, + "night": 20.48, + "eve": 24.34, + "morn": 19.72 + }, + "feels_like": { + "day": 24.47, + "night": 20.48, + "eve": 24.34, + "morn": 19.72 + }, + "pressure": 1015.12, + "humidity": 62, + "wind_speed": 5.87, + "wind_deg": 246, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ], + "clouds": 22, + "pop": 0, + "rain": 0.09, + "uvi": 0 + }, + { + "dt": 1785801600, + "sunrise": 1785822021, + "sunset": 1785872696, + "moonrise": 1785882660, + "moonset": 1785843420, + "moon_phase": 0.69, + "temp": { + "day": 25.65, + "min": 19.98, + "max": 26.79, + "night": 20.69, + "eve": 25.3, + "morn": 19.98 + }, + "feels_like": { + "day": 25.65, + "night": 20.69, + "eve": 25.3, + "morn": 19.98 + }, + "pressure": 1018.73, + "humidity": 52, + "wind_speed": 6.12, + "wind_deg": 287, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 3, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1785888000, + "sunrise": 1785908475, + "sunset": 1785959032, + "moonrise": 0, + "moonset": 1785934020, + "moon_phase": 0.73, + "temp": { + "day": 26.19, + "min": 19.81, + "max": 27.07, + "night": 20.81, + "eve": 25.09, + "morn": 19.95 + }, + "feels_like": { + "day": 26.19, + "night": 20.81, + "eve": 25.09, + "morn": 19.95 + }, + "pressure": 1020.18, + "humidity": 51, + "wind_speed": 8.17, + "wind_deg": 341, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 1, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1785974400, + "sunrise": 1785994929, + "sunset": 1786045367, + "moonrise": 1785970920, + "moonset": 1786024800, + "moon_phase": 0.75, + "temp": { + "day": 26.03, + "min": 18.89, + "max": 27.46, + "night": 19.54, + "eve": 25.3, + "morn": 18.97 + }, + "feels_like": { + "day": 26.03, + "night": 19.54, + "eve": 25.3, + "morn": 18.97 + }, + "pressure": 1019.85, + "humidity": 42, + "wind_speed": 8.77, + "wind_deg": 340, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1786060800, + "sunrise": 1786081384, + "sunset": 1786131700, + "moonrise": 1786059600, + "moonset": 1786115640, + "moon_phase": 0.8, + "temp": { + "day": 26.16, + "min": 18.76, + "max": 27.59, + "night": 19.59, + "eve": 25.13, + "morn": 18.81 + }, + "feels_like": { + "day": 26.16, + "night": 19.59, + "eve": 25.13, + "morn": 18.81 + }, + "pressure": 1016.4, + "humidity": 44, + "wind_speed": 7.45, + "wind_deg": 331, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1786147200, + "sunrise": 1786167838, + "sunset": 1786218032, + "moonrise": 1786148880, + "moonset": 1786206360, + "moon_phase": 0.84, + "temp": { + "day": 25.26, + "min": 18.6, + "max": 26.53, + "night": 19.45, + "eve": 24.46, + "morn": 18.66 + }, + "feels_like": { + "day": 25.26, + "night": 19.45, + "eve": 24.46, + "morn": 18.66 + }, + "pressure": 1016.06, + "humidity": 48, + "wind_speed": 7.13, + "wind_deg": 327, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1786233600, + "sunrise": 1786254293, + "sunset": 1786304363, + "moonrise": 1786238880, + "moonset": 1786296480, + "moon_phase": 0.87, + "temp": { + "day": 23.74, + "min": 18.37, + "max": 25.29, + "night": 19.5, + "eve": 23.55, + "morn": 18.46 + }, + "feels_like": { + "day": 23.74, + "night": 19.5, + "eve": 23.55, + "morn": 18.46 + }, + "pressure": 1017.37, + "humidity": 56, + "wind_speed": 7.94, + "wind_deg": 339, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1786320000, + "sunrise": 1786340747, + "sunset": 1786390693, + "moonrise": 1786329540, + "moonset": 1786386060, + "moon_phase": 0.91, + "temp": { + "day": 24.6, + "min": 17.73, + "max": 26.46, + "night": 18.78, + "eve": 24.15, + "morn": 17.82 + }, + "feels_like": { + "day": 24.6, + "night": 18.78, + "eve": 24.15, + "morn": 17.82 + }, + "pressure": 1016.51, + "humidity": 49, + "wind_speed": 7.39, + "wind_deg": 322, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1786406400, + "sunrise": 1786427202, + "sunset": 1786477021, + "moonrise": 1786420560, + "moonset": 1786474920, + "moon_phase": 0.95, + "temp": { + "day": 24.33, + "min": 18.08, + "max": 27.43, + "night": 19.15, + "eve": 25.5, + "morn": 18.24 + }, + "feels_like": { + "day": 24.33, + "night": 19.15, + "eve": 25.5, + "morn": 18.24 + }, + "pressure": 1016.12, + "humidity": 55, + "wind_speed": 4.77, + "wind_deg": 242, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ], + "clouds": 32, + "pop": 0, + "uvi": 0 + } + ], + "prev": "http://api.openweathermap.org/data/4.0/onecall/timeline/1day?cnt=10&lat=38.7223&lon=-9.1393&start=1784764800&appid={API key}&units=metric&lang=en", + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/1day?cnt=10&lat=38.7223&lon=-9.1393&start=1786492800&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/one-day/success.meta.json b/tests/Fixtures/one-call/one-day/success.meta.json new file mode 100644 index 0000000..ed9b991 --- /dev/null +++ b/tests/Fixtures/one-call/one-day/success.meta.json @@ -0,0 +1,28 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-day timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:34:29Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1day", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.prev", + "action": "replaced appid query value with {API key}" + }, + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/one-hour/history.json b/tests/Fixtures/one-call/one-hour/history.json new file mode 100644 index 0000000..5da7b44 --- /dev/null +++ b/tests/Fixtures/one-call/one-hour/history.json @@ -0,0 +1,450 @@ +{ + "lat": 38.7223, + "lon": -9.1393, + "timezone": "Europe/Lisbon", + "timezone_offset": 3600, + "data": [ + { + "dt": 1785495600, + "temp": 24.19, + "feels_like": 24.31, + "pressure": 1018, + "humidity": 63, + "dew_point": 16.71, + "uvi": 7.52, + "clouds": 5, + "visibility": 10000, + "wind_speed": 1.34, + "wind_deg": 303, + "wind_gust": 4.47, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + { + "dt": 1785499200, + "temp": 26.42, + "feels_like": 26.42, + "pressure": 1018, + "humidity": 56, + "dew_point": 16.94, + "uvi": 8.79, + "clouds": 5, + "visibility": 10000, + "wind_speed": 1.34, + "wind_deg": 86, + "wind_gust": 3.58, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + { + "dt": 1785502800, + "temp": 26.97, + "feels_like": 27.51, + "pressure": 1018, + "humidity": 52, + "dew_point": 16.28, + "uvi": 8.82, + "clouds": 5, + "visibility": 10000, + "wind_speed": 3.13, + "wind_deg": 1, + "wind_gust": 4.47, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + { + "dt": 1785506400, + "temp": 28.08, + "feels_like": 28.73, + "pressure": 1016, + "humidity": 52, + "dew_point": 17.31, + "uvi": 7.76, + "clouds": 5, + "visibility": 10000, + "wind_speed": 4.02, + "wind_deg": 329, + "wind_gust": 7.6, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + { + "dt": 1785510000, + "temp": 28.21, + "feels_like": 29.2, + "pressure": 1016, + "humidity": 55, + "dew_point": 18.32, + "uvi": 5.84, + "clouds": 5, + "visibility": 10000, + "wind_speed": 3.13, + "wind_deg": 312, + "wind_gust": 6.26, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + { + "dt": 1785513600, + "temp": 27.66, + "feels_like": 28.5, + "pressure": 1016, + "humidity": 55, + "dew_point": 17.81, + "uvi": 3.65, + "clouds": 5, + "visibility": 10000, + "wind_speed": 4.02, + "wind_deg": 344, + "wind_gust": 6.71, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + { + "dt": 1785517200, + "temp": 27.03, + "feels_like": 27.98, + "pressure": 1015, + "humidity": 58, + "dew_point": 18.06, + "uvi": 1.83, + "clouds": 5, + "visibility": 10000, + "wind_speed": 3.13, + "wind_deg": 345, + "wind_gust": 7.6, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + { + "dt": 1785520800, + "temp": 25.88, + "feels_like": 26.17, + "pressure": 1015, + "humidity": 63, + "dew_point": 18.3, + "uvi": 0.67, + "clouds": 5, + "visibility": 10000, + "wind_speed": 4.47, + "wind_deg": 190, + "wind_gust": 6.71, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + { + "dt": 1785524400, + "temp": 24.21, + "feels_like": 24.41, + "pressure": 1015, + "humidity": 66, + "dew_point": 17.46, + "uvi": 0.14, + "clouds": 5, + "visibility": 10000, + "wind_speed": 4.02, + "wind_deg": 190, + "wind_gust": 6.71, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + { + "dt": 1785528000, + "temp": 23.07, + "feels_like": 23.21, + "pressure": 1016, + "humidity": 68, + "dew_point": 16.85, + "uvi": 0, + "clouds": 4, + "visibility": 10000, + "wind_speed": 4.02, + "wind_deg": 335, + "wind_gust": 7.15, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785531600, + "temp": 21.96, + "feels_like": 22.07, + "pressure": 1016, + "humidity": 71, + "dew_point": 16.47, + "uvi": 0, + "clouds": 4, + "visibility": 10000, + "wind_speed": 4.92, + "wind_deg": 5, + "wind_gust": 8.05, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785535200, + "temp": 21.44, + "feels_like": 21.55, + "pressure": 1016, + "humidity": 73, + "dew_point": 16.4, + "uvi": 0, + "clouds": 4, + "visibility": 10000, + "wind_speed": 2.68, + "wind_deg": 336, + "wind_gust": 5.81, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785538800, + "temp": 20.86, + "feels_like": 20.99, + "pressure": 1016, + "humidity": 76, + "dew_point": 16.48, + "uvi": 0, + "clouds": 5, + "visibility": 10000, + "wind_speed": 2.24, + "wind_deg": 318, + "wind_gust": 4.47, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785542400, + "temp": 20.31, + "feels_like": 20.41, + "pressure": 1016, + "humidity": 77, + "dew_point": 16.15, + "uvi": 0, + "clouds": 5, + "visibility": 10000, + "wind_speed": 1.79, + "wind_deg": 334, + "wind_gust": 4.02, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785546000, + "temp": 19.75, + "feels_like": 19.84, + "pressure": 1016, + "humidity": 79, + "dew_point": 16.01, + "uvi": 0, + "clouds": 5, + "visibility": 10000, + "wind_speed": 1.34, + "wind_deg": 5, + "wind_gust": 4.02, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785549600, + "temp": 19.75, + "feels_like": 19.9, + "pressure": 1015, + "humidity": 81, + "dew_point": 16.4, + "uvi": 0, + "clouds": 2, + "visibility": 10000, + "wind_speed": 1.34, + "wind_deg": 337, + "wind_gust": 4.47, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785553200, + "temp": 19.19, + "feels_like": 19.33, + "pressure": 1015, + "humidity": 83, + "dew_point": 16.23, + "uvi": 0, + "clouds": 2, + "visibility": 10000, + "wind_speed": 3.13, + "wind_deg": 297, + "wind_gust": 5.36, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785556800, + "temp": 19.19, + "feels_like": 19.33, + "pressure": 1015, + "humidity": 83, + "dew_point": 16.23, + "uvi": 0, + "clouds": 2, + "visibility": 10000, + "wind_speed": 3.13, + "wind_deg": 328, + "wind_gust": 4.92, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785560400, + "temp": 18.64, + "feels_like": 18.75, + "pressure": 1015, + "humidity": 84, + "dew_point": 15.88, + "uvi": 0, + "clouds": 0, + "visibility": 10000, + "wind_speed": 2.68, + "wind_deg": 308, + "wind_gust": 4.92, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785564000, + "temp": 19.19, + "feels_like": 19.36, + "pressure": 1015, + "humidity": 84, + "dew_point": 16.42, + "uvi": 0, + "clouds": 0, + "visibility": 10000, + "wind_speed": 2.68, + "wind_deg": 322, + "wind_gust": 5.36, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + } + ], + "prev": "http://api.openweathermap.org/data/4.0/onecall/timeline/1h?cnt=20&lat=38.7223&lon=-9.1393&start=1785423600&appid={API key}&units=metric&lang=en", + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/1h?cnt=20&lat=38.7223&lon=-9.1393&start=1785567600&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/one-hour/history.meta.json b/tests/Fixtures/one-call/one-hour/history.meta.json new file mode 100644 index 0000000..23494b2 --- /dev/null +++ b/tests/Fixtures/one-call/one-hour/history.meta.json @@ -0,0 +1,29 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-hour timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:31:32Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1h", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "start": 1785495600, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.prev", + "action": "replaced appid query value with {API key}" + }, + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/one-hour/rain.json b/tests/Fixtures/one-call/one-hour/rain.json new file mode 100644 index 0000000..115436b --- /dev/null +++ b/tests/Fixtures/one-call/one-hour/rain.json @@ -0,0 +1,515 @@ +{ + "lat": 14.5995, + "lon": 120.9842, + "timezone": "Asia/Manila", + "timezone_offset": 28800, + "data": [ + { + "dt": 1785668400, + "temp": 26.19, + "feels_like": 26.19, + "pressure": 1007, + "humidity": 93, + "dew_point": 24.97, + "uvi": 0, + "clouds": 100, + "visibility": 7458, + "wind_speed": 1.79, + "wind_deg": 272, + "wind_gust": 2.68, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ], + "pop": 1, + "rain": { + "1h": 1.72 + } + }, + { + "dt": 1785672000, + "temp": 26.42, + "feels_like": 26.42, + "pressure": 1007, + "humidity": 93, + "dew_point": 25.2, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.16, + "wind_deg": 268, + "wind_gust": 3.24, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0.8 + }, + { + "dt": 1785675600, + "temp": 26.11, + "feels_like": 26.11, + "pressure": 1007, + "humidity": 92, + "dew_point": 24.71, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.49, + "wind_deg": 301, + "wind_gust": 4.7, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0.8 + }, + { + "dt": 1785679200, + "temp": 25.77, + "feels_like": 26.73, + "pressure": 1007, + "humidity": 89, + "dew_point": 23.82, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.37, + "wind_deg": 318, + "wind_gust": 3.77, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0.78 + }, + { + "dt": 1785682800, + "temp": 25.64, + "feels_like": 26.53, + "pressure": 1008, + "humidity": 87, + "dew_point": 23.31, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.14, + "wind_deg": 332, + "wind_gust": 4.65, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0.82 + }, + { + "dt": 1785686400, + "temp": 25.22, + "feels_like": 26.1, + "pressure": 1008, + "humidity": 88, + "dew_point": 23.09, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.82, + "wind_deg": 318, + "wind_gust": 4.91, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ], + "pop": 1, + "rain": { + "1h": 1.32 + } + }, + { + "dt": 1785690000, + "temp": 23.96, + "feels_like": 24.71, + "pressure": 1007, + "humidity": 88, + "dew_point": 23.07, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.92, + "wind_deg": 288, + "wind_gust": 5.72, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ], + "pop": 1, + "rain": { + "1h": 1.09 + } + }, + { + "dt": 1785693600, + "temp": 23.82, + "feels_like": 24.53, + "pressure": 1006, + "humidity": 87, + "dew_point": 22.76, + "uvi": 0, + "clouds": 100, + "visibility": 9149, + "wind_speed": 2.88, + "wind_deg": 270, + "wind_gust": 5.81, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ], + "pop": 1, + "rain": { + "1h": 1.58 + } + }, + { + "dt": 1785697200, + "temp": 23.76, + "feels_like": 24.52, + "pressure": 1006, + "humidity": 89, + "dew_point": 22.93, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.63, + "wind_deg": 258, + "wind_gust": 4.7, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ], + "pop": 1, + "rain": { + "1h": 1.99 + } + }, + { + "dt": 1785700800, + "temp": 24.1, + "feels_like": 24.89, + "pressure": 1006, + "humidity": 89, + "dew_point": 22.93, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.13, + "wind_deg": 247, + "wind_gust": 5.13, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10n" + } + ], + "pop": 1, + "rain": { + "1h": 0.57 + } + }, + { + "dt": 1785704400, + "temp": 24.46, + "feels_like": 25.26, + "pressure": 1006, + "humidity": 88, + "dew_point": 23.1, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.04, + "wind_deg": 246, + "wind_gust": 6.52, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0.8 + }, + { + "dt": 1785708000, + "temp": 24.35, + "feels_like": 25.16, + "pressure": 1007, + "humidity": 89, + "dew_point": 23.15, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.3, + "wind_deg": 242, + "wind_gust": 7.95, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ], + "pop": 1, + "rain": { + "1h": 0.26 + } + }, + { + "dt": 1785711600, + "temp": 24.25, + "feels_like": 25.08, + "pressure": 1007, + "humidity": 90, + "dew_point": 23.18, + "uvi": 0.08, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.94, + "wind_deg": 233, + "wind_gust": 8.22, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ], + "pop": 1, + "rain": { + "1h": 0.66 + } + }, + { + "dt": 1785715200, + "temp": 25.23, + "feels_like": 26.16, + "pressure": 1007, + "humidity": 90, + "dew_point": 23.55, + "uvi": 0.32, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.92, + "wind_deg": 231, + "wind_gust": 8.26, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ], + "pop": 1, + "rain": { + "1h": 0.67 + } + }, + { + "dt": 1785718800, + "temp": 25.52, + "feels_like": 26.45, + "pressure": 1007, + "humidity": 89, + "dew_point": 23.77, + "uvi": 0.7, + "clouds": 100, + "visibility": 9530, + "wind_speed": 5.28, + "wind_deg": 253, + "wind_gust": 6.85, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "pop": 1, + "rain": { + "1h": 1.57 + } + }, + { + "dt": 1785722400, + "temp": 25.51, + "feels_like": 26.44, + "pressure": 1008, + "humidity": 89, + "dew_point": 23.6, + "uvi": 1.23, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.09, + "wind_deg": 258, + "wind_gust": 7.3, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "pop": 1, + "rain": { + "1h": 1.74 + } + }, + { + "dt": 1785726000, + "temp": 25.59, + "feels_like": 26.48, + "pressure": 1008, + "humidity": 87, + "dew_point": 23.43, + "uvi": 1.71, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.73, + "wind_deg": 253, + "wind_gust": 7.83, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "pop": 1, + "rain": { + "1h": 1.6 + } + }, + { + "dt": 1785729600, + "temp": 25.43, + "feels_like": 26.35, + "pressure": 1008, + "humidity": 89, + "dew_point": 23.62, + "uvi": 2.39, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.95, + "wind_deg": 249, + "wind_gust": 7.85, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "pop": 1, + "rain": { + "1h": 1.12 + } + }, + { + "dt": 1785733200, + "temp": 25.2, + "feels_like": 26.18, + "pressure": 1007, + "humidity": 92, + "dew_point": 23.86, + "uvi": 1.39, + "clouds": 100, + "visibility": 8050, + "wind_speed": 6.88, + "wind_deg": 242, + "wind_gust": 7.87, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "pop": 1, + "rain": { + "1h": 1.28 + } + }, + { + "dt": 1785736800, + "temp": 25.14, + "feels_like": 26.11, + "pressure": 1007, + "humidity": 92, + "dew_point": 23.89, + "uvi": 1.2, + "clouds": 100, + "visibility": 5699, + "wind_speed": 8.25, + "wind_deg": 236, + "wind_gust": 10.35, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "pop": 1, + "rain": { + "1h": 2.96 + } + } + ], + "prev": "http://api.openweathermap.org/data/4.0/onecall/timeline/1h?cnt=20&lat=14.5995&lon=120.9842&start=1785596400&appid={API key}&units=metric&lang=en", + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/1h?cnt=20&lat=14.5995&lon=120.9842&start=1785740400&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/one-hour/rain.meta.json b/tests/Fixtures/one-call/one-hour/rain.meta.json new file mode 100644 index 0000000..7af6593 --- /dev/null +++ b/tests/Fixtures/one-call/one-hour/rain.meta.json @@ -0,0 +1,28 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-hour timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:31:15Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1h", + "query": { + "lat": 14.5995, + "lon": 120.9842, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.prev", + "action": "replaced appid query value with {API key}" + }, + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/one-hour/snow-alerts.json b/tests/Fixtures/one-call/one-hour/snow-alerts.json new file mode 100644 index 0000000..262b08a --- /dev/null +++ b/tests/Fixtures/one-call/one-hour/snow-alerts.json @@ -0,0 +1,572 @@ +{ + "lat": -38.4, + "lon": -71.58, + "timezone": "America/Santiago", + "timezone_offset": -14400, + "data": [ + { + "dt": 1785668400, + "temp": -2.25, + "feels_like": -6.87, + "pressure": 1013, + "humidity": 100, + "dew_point": -2.25, + "uvi": 0, + "clouds": 100, + "visibility": 33, + "wind_speed": 3.77, + "wind_deg": 306, + "wind_gust": 14.85, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 2.54 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672000, + "temp": -2.24, + "feels_like": -6.77, + "pressure": 1013, + "humidity": 100, + "dew_point": -2.24, + "uvi": 0, + "clouds": 100, + "visibility": 126, + "wind_speed": 3.66, + "wind_deg": 305, + "wind_gust": 13.45, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 3.65 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785675600, + "temp": -2.27, + "feels_like": -6.58, + "pressure": 1013, + "humidity": 100, + "dew_point": -2.27, + "uvi": 0.04, + "clouds": 100, + "wind_speed": 3.39, + "wind_deg": 301, + "wind_gust": 13.12, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 2.57 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785679200, + "temp": -2.27, + "feels_like": -6.48, + "pressure": 1014, + "humidity": 100, + "dew_point": -2.27, + "uvi": 0.12, + "clouds": 100, + "wind_speed": 3.27, + "wind_deg": 301, + "wind_gust": 12.86, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 2.75 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785682800, + "temp": -2.32, + "feels_like": -6.44, + "pressure": 1014, + "humidity": 100, + "dew_point": -2.32, + "uvi": 0.26, + "clouds": 100, + "wind_speed": 3.16, + "wind_deg": 300, + "wind_gust": 12.96, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 2.5 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785686400, + "temp": -2.28, + "feels_like": -6.41, + "pressure": 1015, + "humidity": 100, + "dew_point": -2.28, + "uvi": 0.37, + "clouds": 100, + "wind_speed": 3.18, + "wind_deg": 299, + "wind_gust": 13.04, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 2.45 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785690000, + "temp": -2.07, + "feels_like": -6.34, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.15, + "uvi": 0.43, + "clouds": 100, + "wind_speed": 3.39, + "wind_deg": 303, + "wind_gust": 13.5, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 2.22 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785693600, + "temp": -1.9, + "feels_like": -6.22, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.39, + "uvi": 0.25, + "clouds": 100, + "wind_speed": 3.49, + "wind_deg": 302, + "wind_gust": 14.65, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 2.21 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785697200, + "temp": -1.65, + "feels_like": -5.74, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.56, + "uvi": 0.11, + "clouds": 100, + "wind_speed": 3.28, + "wind_deg": 300, + "wind_gust": 14.22, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 2.48 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785700800, + "temp": -1.89, + "feels_like": -5.49, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.33, + "uvi": 0.05, + "clouds": 100, + "wind_speed": 2.72, + "wind_deg": 302, + "wind_gust": 9.86, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 3.41 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785704400, + "temp": -2.03, + "feels_like": -5.63, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.13, + "uvi": 0.02, + "clouds": 100, + "wind_speed": 2.69, + "wind_deg": 301, + "wind_gust": 7.87, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 4.32 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785708000, + "temp": -2.25, + "feels_like": -5.62, + "pressure": 1016, + "humidity": 100, + "dew_point": 0.06, + "uvi": 0, + "clouds": 100, + "wind_speed": 2.44, + "wind_deg": 302, + "wind_gust": 6.6, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 4.65 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785711600, + "temp": -2.28, + "feels_like": -5.41, + "pressure": 1017, + "humidity": 100, + "dew_point": -0.04, + "uvi": 0, + "clouds": 100, + "wind_speed": 2.23, + "wind_deg": 272, + "wind_gust": 5.66, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 4.07 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785715200, + "temp": -2.31, + "feels_like": -5.39, + "pressure": 1017, + "humidity": 100, + "dew_point": -0.09, + "uvi": 0, + "clouds": 100, + "wind_speed": 2.19, + "wind_deg": 300, + "wind_gust": 5.37, + "weather": [ + { + "id": 602, + "main": "Snow", + "description": "heavy snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 5.06 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785718800, + "temp": -2.52, + "feels_like": -5.4, + "pressure": 1018, + "humidity": 100, + "dew_point": -0.25, + "uvi": 0, + "clouds": 100, + "wind_speed": 2.01, + "wind_deg": 296, + "wind_gust": 5.22, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 4.49 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785722400, + "temp": -2.62, + "feels_like": -5.15, + "pressure": 1018, + "humidity": 100, + "dew_point": -0.48, + "uvi": 0, + "clouds": 100, + "wind_speed": 1.75, + "wind_deg": 308, + "wind_gust": 4.18, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 3.25 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785726000, + "temp": -2.86, + "feels_like": -5.17, + "pressure": 1018, + "humidity": 100, + "dew_point": -0.66, + "uvi": 0, + "clouds": 100, + "wind_speed": 1.59, + "wind_deg": 296, + "wind_gust": 4.16, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 2.58 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785729600, + "temp": -2.88, + "feels_like": -5.02, + "pressure": 1018, + "humidity": 100, + "dew_point": -0.69, + "uvi": 0, + "clouds": 100, + "wind_speed": 1.49, + "wind_deg": 310, + "wind_gust": 4.08, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 2.86 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785733200, + "temp": -2.84, + "feels_like": -5.13, + "pressure": 1018, + "humidity": 100, + "dew_point": -0.69, + "uvi": 0, + "clouds": 100, + "wind_speed": 1.58, + "wind_deg": 310, + "wind_gust": 4.56, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 2.99 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785736800, + "temp": -2.89, + "feels_like": -5.47, + "pressure": 1019, + "humidity": 100, + "dew_point": -0.73, + "uvi": 0, + "clouds": 100, + "wind_speed": 1.76, + "wind_deg": 306, + "wind_gust": 4.73, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 2.99 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + } + ], + "prev": "http://api.openweathermap.org/data/4.0/onecall/timeline/1h?cnt=20&lat=-38.4000&lon=-71.5800&start=1785596400&appid={API key}&units=metric&lang=en", + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/1h?cnt=20&lat=-38.4000&lon=-71.5800&start=1785740400&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/one-hour/snow-alerts.meta.json b/tests/Fixtures/one-call/one-hour/snow-alerts.meta.json new file mode 100644 index 0000000..819787d --- /dev/null +++ b/tests/Fixtures/one-call/one-hour/snow-alerts.meta.json @@ -0,0 +1,28 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-hour timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:31:08Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1h", + "query": { + "lat": -38.4, + "lon": -71.58, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.prev", + "action": "replaced appid query value with {API key}" + }, + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/one-hour/success.json b/tests/Fixtures/one-call/one-hour/success.json new file mode 100644 index 0000000..f031103 --- /dev/null +++ b/tests/Fixtures/one-call/one-hour/success.json @@ -0,0 +1,470 @@ +{ + "lat": 38.7223, + "lon": -9.1393, + "timezone": "Europe/Lisbon", + "timezone_offset": 3600, + "data": [ + { + "dt": 1785668400, + "temp": 25.05, + "feels_like": 25.23, + "pressure": 1015, + "humidity": 62, + "dew_point": 17.27, + "uvi": 7.53, + "clouds": 48, + "visibility": 10000, + "wind_speed": 3.87, + "wind_deg": 317, + "wind_gust": 4.47, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ], + "pop": 0 + }, + { + "dt": 1785672000, + "temp": 25.05, + "feels_like": 25.33, + "pressure": 1015, + "humidity": 66, + "dew_point": 18.26, + "uvi": 8.57, + "clouds": 50, + "visibility": 10000, + "wind_speed": 3.96, + "wind_deg": 312, + "wind_gust": 4.13, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ], + "pop": 0 + }, + { + "dt": 1785675600, + "temp": 25.49, + "feels_like": 25.66, + "pressure": 1015, + "humidity": 60, + "dew_point": 17.16, + "uvi": 8.28, + "clouds": 60, + "visibility": 10000, + "wind_speed": 5.07, + "wind_deg": 306, + "wind_gust": 4.89, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1785679200, + "temp": 25.91, + "feels_like": 25.99, + "pressure": 1015, + "humidity": 55, + "dew_point": 16.18, + "uvi": 7.41, + "clouds": 70, + "visibility": 10000, + "wind_speed": 5.62, + "wind_deg": 307, + "wind_gust": 5.67, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1785682800, + "temp": 25.9, + "feels_like": 25.85, + "pressure": 1014, + "humidity": 50, + "dew_point": 14.69, + "uvi": 5.4, + "clouds": 80, + "visibility": 10000, + "wind_speed": 5.74, + "wind_deg": 310, + "wind_gust": 6.15, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1785686400, + "temp": 25.79, + "feels_like": 25.63, + "pressure": 1014, + "humidity": 46, + "dew_point": 13.3, + "uvi": 3.31, + "clouds": 90, + "visibility": 10000, + "wind_speed": 5.71, + "wind_deg": 311, + "wind_gust": 6.63, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1785690000, + "temp": 24.52, + "feels_like": 24.2, + "pressure": 1014, + "humidity": 45, + "dew_point": 12.59, + "uvi": 1.56, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.58, + "wind_deg": 315, + "wind_gust": 7.14, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1785693600, + "temp": 24.07, + "feels_like": 23.81, + "pressure": 1014, + "humidity": 49, + "dew_point": 13.11, + "uvi": 0.54, + "clouds": 99, + "visibility": 10000, + "wind_speed": 5.21, + "wind_deg": 315, + "wind_gust": 7.12, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1785697200, + "temp": 23.2, + "feels_like": 23.06, + "pressure": 1014, + "humidity": 57, + "dew_point": 13.98, + "uvi": 0.12, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.75, + "wind_deg": 317, + "wind_gust": 7.22, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1785700800, + "temp": 22.54, + "feels_like": 22.55, + "pressure": 1014, + "humidity": 65, + "dew_point": 14.73, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.07, + "wind_deg": 319, + "wind_gust": 7.05, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785704400, + "temp": 22.36, + "feels_like": 22.48, + "pressure": 1015, + "humidity": 70, + "dew_point": 15.36, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.05, + "wind_deg": 315, + "wind_gust": 7.17, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785708000, + "temp": 22.38, + "feels_like": 22.53, + "pressure": 1015, + "humidity": 71, + "dew_point": 15.55, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.48, + "wind_deg": 312, + "wind_gust": 6.32, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785711600, + "temp": 22.18, + "feels_like": 22.36, + "pressure": 1015, + "humidity": 73, + "dew_point": 15.81, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.54, + "wind_deg": 311, + "wind_gust": 6.86, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785715200, + "temp": 21.57, + "feels_like": 21.69, + "pressure": 1014, + "humidity": 73, + "dew_point": 15.78, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.84, + "wind_deg": 308, + "wind_gust": 5.34, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785718800, + "temp": 21.36, + "feels_like": 21.48, + "pressure": 1014, + "humidity": 74, + "dew_point": 15.88, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.31, + "wind_deg": 323, + "wind_gust": 4.42, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785722400, + "temp": 21.03, + "feels_like": 21.2, + "pressure": 1014, + "humidity": 77, + "dew_point": 16.25, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.33, + "wind_deg": 318, + "wind_gust": 4.84, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785726000, + "temp": 20.67, + "feels_like": 20.86, + "pressure": 1014, + "humidity": 79, + "dew_point": 16.45, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 1.77, + "wind_deg": 298, + "wind_gust": 3.61, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785729600, + "temp": 20.29, + "feels_like": 20.46, + "pressure": 1014, + "humidity": 80, + "dew_point": 16.54, + "uvi": 0, + "clouds": 97, + "visibility": 10000, + "wind_speed": 1.19, + "wind_deg": 295, + "wind_gust": 2.42, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785733200, + "temp": 20.43, + "feels_like": 20.64, + "pressure": 1014, + "humidity": 81, + "dew_point": 16.66, + "uvi": 0, + "clouds": 93, + "visibility": 10000, + "wind_speed": 1.25, + "wind_deg": 287, + "wind_gust": 2.28, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785736800, + "temp": 21.26, + "feels_like": 21.56, + "pressure": 1014, + "humidity": 81, + "dew_point": 16.68, + "uvi": 0, + "clouds": 94, + "visibility": 10000, + "wind_speed": 1.4, + "wind_deg": 254, + "wind_gust": 2.25, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "pop": 0 + } + ], + "prev": "http://api.openweathermap.org/data/4.0/onecall/timeline/1h?cnt=20&lat=38.7223&lon=-9.1393&start=1785596400&appid={API key}&units=metric&lang=en", + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/1h?cnt=20&lat=38.7223&lon=-9.1393&start=1785740400&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/one-hour/success.meta.json b/tests/Fixtures/one-call/one-hour/success.meta.json new file mode 100644 index 0000000..32719cd --- /dev/null +++ b/tests/Fixtures/one-call/one-hour/success.meta.json @@ -0,0 +1,28 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-hour timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:31:00Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1h", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.prev", + "action": "replaced appid query value with {API key}" + }, + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/one-minute/precipitation-alerts.json b/tests/Fixtures/one-call/one-minute/precipitation-alerts.json new file mode 100644 index 0000000..2b6c572 --- /dev/null +++ b/tests/Fixtures/one-call/one-minute/precipitation-alerts.json @@ -0,0 +1,428 @@ +{ + "lat": -38.4, + "lon": -71.58, + "timezone": "America/Santiago", + "timezone_offset": -14400, + "data": [ + { + "dt": 1785669780, + "precipitation": 5.9626, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785669840, + "precipitation": 5.7888, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785669900, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785669960, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670020, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670080, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670140, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670200, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670260, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670320, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670380, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670440, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670500, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670560, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670620, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670680, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670740, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670800, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670860, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670920, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670980, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671040, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671100, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671160, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671220, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671280, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671340, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671400, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671460, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671520, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671580, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671640, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671700, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671760, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671820, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671880, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671940, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672000, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672060, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672120, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672180, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672240, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672300, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672360, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672420, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672480, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672540, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672600, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672660, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672720, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672780, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672840, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672900, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672960, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785673020, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785673080, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785673140, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785673200, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785673260, + "precipitation": 5.4644, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785673320, + "precipitation": 5.3138, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + } + ] +} diff --git a/tests/Fixtures/one-call/one-minute/precipitation-alerts.meta.json b/tests/Fixtures/one-call/one-minute/precipitation-alerts.meta.json new file mode 100644 index 0000000..8b00749 --- /dev/null +++ b/tests/Fixtures/one-call/one-minute/precipitation-alerts.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-minute timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:22:14Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1min", + "query": { + "lat": -38.4, + "lon": -71.58, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/one-call/one-minute/success.json b/tests/Fixtures/one-call/one-minute/success.json new file mode 100644 index 0000000..2c7ceac --- /dev/null +++ b/tests/Fixtures/one-call/one-minute/success.json @@ -0,0 +1,248 @@ +{ + "lat": 38.7223, + "lon": -9.1393, + "timezone": "Europe/Lisbon", + "timezone_offset": 3600, + "data": [ + { + "dt": 1785669780, + "precipitation": 0 + }, + { + "dt": 1785669840, + "precipitation": 0 + }, + { + "dt": 1785669900, + "precipitation": 0 + }, + { + "dt": 1785669960, + "precipitation": 0 + }, + { + "dt": 1785670020, + "precipitation": 0 + }, + { + "dt": 1785670080, + "precipitation": 0 + }, + { + "dt": 1785670140, + "precipitation": 0 + }, + { + "dt": 1785670200, + "precipitation": 0 + }, + { + "dt": 1785670260, + "precipitation": 0 + }, + { + "dt": 1785670320, + "precipitation": 0 + }, + { + "dt": 1785670380, + "precipitation": 0 + }, + { + "dt": 1785670440, + "precipitation": 0 + }, + { + "dt": 1785670500, + "precipitation": 0 + }, + { + "dt": 1785670560, + "precipitation": 0 + }, + { + "dt": 1785670620, + "precipitation": 0 + }, + { + "dt": 1785670680, + "precipitation": 0 + }, + { + "dt": 1785670740, + "precipitation": 0 + }, + { + "dt": 1785670800, + "precipitation": 0 + }, + { + "dt": 1785670860, + "precipitation": 0 + }, + { + "dt": 1785670920, + "precipitation": 0 + }, + { + "dt": 1785670980, + "precipitation": 0 + }, + { + "dt": 1785671040, + "precipitation": 0 + }, + { + "dt": 1785671100, + "precipitation": 0 + }, + { + "dt": 1785671160, + "precipitation": 0 + }, + { + "dt": 1785671220, + "precipitation": 0 + }, + { + "dt": 1785671280, + "precipitation": 0 + }, + { + "dt": 1785671340, + "precipitation": 0 + }, + { + "dt": 1785671400, + "precipitation": 0 + }, + { + "dt": 1785671460, + "precipitation": 0 + }, + { + "dt": 1785671520, + "precipitation": 0 + }, + { + "dt": 1785671580, + "precipitation": 0 + }, + { + "dt": 1785671640, + "precipitation": 0 + }, + { + "dt": 1785671700, + "precipitation": 0 + }, + { + "dt": 1785671760, + "precipitation": 0 + }, + { + "dt": 1785671820, + "precipitation": 0 + }, + { + "dt": 1785671880, + "precipitation": 0 + }, + { + "dt": 1785671940, + "precipitation": 0 + }, + { + "dt": 1785672000, + "precipitation": 0 + }, + { + "dt": 1785672060, + "precipitation": 0 + }, + { + "dt": 1785672120, + "precipitation": 0 + }, + { + "dt": 1785672180, + "precipitation": 0 + }, + { + "dt": 1785672240, + "precipitation": 0 + }, + { + "dt": 1785672300, + "precipitation": 0 + }, + { + "dt": 1785672360, + "precipitation": 0 + }, + { + "dt": 1785672420, + "precipitation": 0 + }, + { + "dt": 1785672480, + "precipitation": 0 + }, + { + "dt": 1785672540, + "precipitation": 0 + }, + { + "dt": 1785672600, + "precipitation": 0 + }, + { + "dt": 1785672660, + "precipitation": 0 + }, + { + "dt": 1785672720, + "precipitation": 0 + }, + { + "dt": 1785672780, + "precipitation": 0 + }, + { + "dt": 1785672840, + "precipitation": 0 + }, + { + "dt": 1785672900, + "precipitation": 0 + }, + { + "dt": 1785672960, + "precipitation": 0 + }, + { + "dt": 1785673020, + "precipitation": 0 + }, + { + "dt": 1785673080, + "precipitation": 0 + }, + { + "dt": 1785673140, + "precipitation": 0 + }, + { + "dt": 1785673200, + "precipitation": 0 + }, + { + "dt": 1785673260, + "precipitation": 0 + }, + { + "dt": 1785673320, + "precipitation": 0 + } + ] +} diff --git a/tests/Fixtures/one-call/one-minute/success.meta.json b/tests/Fixtures/one-call/one-minute/success.meta.json new file mode 100644 index 0000000..cef3e55 --- /dev/null +++ b/tests/Fixtures/one-call/one-minute/success.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-minute timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:22:06Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1min", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/stations/delete.empty b/tests/Fixtures/stations/delete.empty new file mode 100644 index 0000000..e69de29 diff --git a/tests/Fixtures/stations/delete.meta.json b/tests/Fixtures/stations/delete.meta.json new file mode 100644 index 0000000..481bec7 --- /dev/null +++ b/tests/Fixtures/stations/delete.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Delete station", + "apiVersion": "3.0", + "capturedAt": "2026-08-08T20:33:09Z", + "httpStatus": 204, + "bodyFormat": "empty", + "bodyBytes": 0, + "request": { + "method": "DELETE", + "path": "/data/3.0/stations/6a779284adde3b0001343e02", + "query": {} + }, + "sanitization": [], + "notes": "The generated identifier belongs to the deleted temporary fixture station and is unchanged. The successful response had no body or Content-Type header, and a following list request confirmed the account returned from one station to zero." +} diff --git a/tests/Fixtures/stations/list.json b/tests/Fixtures/stations/list.json new file mode 100644 index 0000000..b3836e8 --- /dev/null +++ b/tests/Fixtures/stations/list.json @@ -0,0 +1 @@ +[{"id":"6a779284adde3b0001343e02","created_at":"2026-08-08T20:33:08.107Z","updated_at":"2026-08-08T20:33:08.107Z","external_id":"openweathermap-php-api-fixture","name":"OpenWeatherMap PHP API Fixture","longitude":-9.1393,"latitude":38.7223,"altitude":100,"rank":10}] diff --git a/tests/Fixtures/stations/list.meta.json b/tests/Fixtures/stations/list.meta.json new file mode 100644 index 0000000..b356b4e --- /dev/null +++ b/tests/Fixtures/stations/list.meta.json @@ -0,0 +1,16 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "List stations", + "apiVersion": "3.0", + "capturedAt": "2026-08-08T20:33:09Z", + "httpStatus": 200, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "GET", + "path": "/data/3.0/stations", + "query": {} + }, + "sanitization": [], + "notes": "The captured account contained only the deliberately public temporary fixture station. Its generated identifier is unchanged, and the station was deleted before the batch completed." +} diff --git a/tests/Fixtures/stations/measurements/aggregate-day-empty.json b/tests/Fixtures/stations/measurements/aggregate-day-empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-day-empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/Fixtures/stations/measurements/aggregate-day-empty.meta.json b/tests/Fixtures/stations/measurements/aggregate-day-empty.meta.json new file mode 100644 index 0000000..8bdd456 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-day-empty.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Aggregate station measurements", + "apiVersion": "3.0", + "capturedAt": "2026-08-08T23:13:27Z", + "httpStatus": 200, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "GET", + "path": "/data/3.0/measurements", + "query": { + "station_id": "6a77b80aadde3b0001343e08", + "type": "d", + "limit": 10, + "from": 1786143599, + "to": 1786230780 + } + }, + "sanitization": [], + "notes": "The API returned an empty array after accepting measurements in completed time buckets. The temporary station and its measurements were deleted before the batch completed." +} diff --git a/tests/Fixtures/stations/measurements/aggregate-day-success.json b/tests/Fixtures/stations/measurements/aggregate-day-success.json new file mode 100644 index 0000000..7818f20 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-day-success.json @@ -0,0 +1,59 @@ +[ + { + "type": "d", + "date": 1786233600, + "station_id": "6a77ba36adde3b0001343e09", + "temp": { + "max": 21.5, + "min": 19.5, + "average": 20.5, + "weight": 3 + }, + "humidity": { + "average": 64, + "weight": 3 + }, + "wind": { + "deg": 203.4, + "speed": 3.08 + }, + "pressure": { + "min": 1012, + "max": 1014, + "average": 1013, + "weight": 3 + }, + "precipitation": { + "rain": 0.6 + } + }, + { + "type": "d", + "date": 1786320000, + "station_id": "6a77ba36adde3b0001343e09", + "temp": { + "max": 16.7, + "min": 16.7, + "average": 16.7, + "weight": 1 + }, + "humidity": { + "average": 72, + "weight": 1 + }, + "wind": { + "deg": 230, + "speed": 4.8 + }, + "pressure": { + "min": 1014.2, + "max": 1014.2, + "average": 1014.2, + "weight": 1 + }, + "precipitation": { + "rain": 3.4, + "snow": 0.5 + } + } +] diff --git a/tests/Fixtures/stations/measurements/aggregate-day-success.meta.json b/tests/Fixtures/stations/measurements/aggregate-day-success.meta.json new file mode 100644 index 0000000..26eb74f --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-day-success.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Aggregate station measurements", + "apiVersion": "3.0", + "capturedAt": "2026-08-14T07:30:22Z", + "httpStatus": 200, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "GET", + "path": "/data/3.0/measurements", + "query": { + "station_id": "6a77ba36adde3b0001343e09", + "type": "d", + "limit": 100, + "from": 1786231349, + "to": 1786692622 + } + }, + "sanitization": [], + "notes": "The first aggregate appeared between 23 hours 43 minutes and 31 hours 43 minutes after its three source measurements were accepted. The second aggregate appeared approximately 30 hours after its fully populated source measurement was accepted. Its decimal pressure and rain and snow totals are preserved exactly; submitted METAR structures are absent from the aggregate response. The retained station was deleted after capture, and a following list request confirmed that no stations remained." +} diff --git a/tests/Fixtures/stations/measurements/aggregate-hour-empty.json b/tests/Fixtures/stations/measurements/aggregate-hour-empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-hour-empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/Fixtures/stations/measurements/aggregate-hour-empty.meta.json b/tests/Fixtures/stations/measurements/aggregate-hour-empty.meta.json new file mode 100644 index 0000000..be8b416 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-hour-empty.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Aggregate station measurements", + "apiVersion": "3.0", + "capturedAt": "2026-08-08T23:13:27Z", + "httpStatus": 200, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "GET", + "path": "/data/3.0/measurements", + "query": { + "station_id": "6a77b80aadde3b0001343e08", + "type": "h", + "limit": 10, + "from": 1786143599, + "to": 1786230780 + } + }, + "sanitization": [], + "notes": "The API returned an empty array after accepting measurements in completed time buckets. The temporary station and its measurements were deleted before the batch completed." +} diff --git a/tests/Fixtures/stations/measurements/aggregate-hour-success.json b/tests/Fixtures/stations/measurements/aggregate-hour-success.json new file mode 100644 index 0000000..c068e45 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-hour-success.json @@ -0,0 +1,59 @@ +[ + { + "type": "h", + "date": 1786233600, + "station_id": "6a77ba36adde3b0001343e09", + "temp": { + "max": 21.5, + "min": 19.5, + "average": 20.5, + "weight": 3 + }, + "humidity": { + "average": 64, + "weight": 3 + }, + "wind": { + "deg": 203.4, + "speed": 3.08 + }, + "pressure": { + "min": 1012, + "max": 1014, + "average": 1013, + "weight": 3 + }, + "precipitation": { + "rain": 0.6 + } + }, + { + "type": "h", + "date": 1786291200, + "station_id": "6a77ba36adde3b0001343e09", + "temp": { + "max": 16.7, + "min": 16.7, + "average": 16.7, + "weight": 1 + }, + "humidity": { + "average": 72, + "weight": 1 + }, + "wind": { + "deg": 230, + "speed": 4.8 + }, + "pressure": { + "min": 1014.2, + "max": 1014.2, + "average": 1014.2, + "weight": 1 + }, + "precipitation": { + "rain": 0.6, + "snow": 0.1 + } + } +] diff --git a/tests/Fixtures/stations/measurements/aggregate-hour-success.meta.json b/tests/Fixtures/stations/measurements/aggregate-hour-success.meta.json new file mode 100644 index 0000000..0c7feb0 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-hour-success.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Aggregate station measurements", + "apiVersion": "3.0", + "capturedAt": "2026-08-14T07:30:22Z", + "httpStatus": 200, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "GET", + "path": "/data/3.0/measurements", + "query": { + "station_id": "6a77ba36adde3b0001343e09", + "type": "h", + "limit": 100, + "from": 1786231349, + "to": 1786692622 + } + }, + "sanitization": [], + "notes": "The first aggregate appeared between 23 hours 43 minutes and 31 hours 43 minutes after its three source measurements were accepted. The second aggregate appeared approximately 30 hours after its fully populated source measurement was accepted. Its decimal pressure and rain and snow totals are preserved exactly; submitted METAR structures are absent from the aggregate response. The retained station was deleted after capture, and a following list request confirmed that no stations remained." +} diff --git a/tests/Fixtures/stations/measurements/aggregate-minute-empty.json b/tests/Fixtures/stations/measurements/aggregate-minute-empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-minute-empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/Fixtures/stations/measurements/aggregate-minute-empty.meta.json b/tests/Fixtures/stations/measurements/aggregate-minute-empty.meta.json new file mode 100644 index 0000000..1a7d164 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-minute-empty.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Aggregate station measurements", + "apiVersion": "3.0", + "capturedAt": "2026-08-08T23:13:27Z", + "httpStatus": 200, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "GET", + "path": "/data/3.0/measurements", + "query": { + "station_id": "6a77b80aadde3b0001343e08", + "type": "m", + "limit": 10, + "from": 1786143599, + "to": 1786230780 + } + }, + "sanitization": [], + "notes": "The API returned an empty array after accepting three measurements with HTTP 204. Five polls covered completed minute, hour, and day buckets before the temporary station was deleted." +} diff --git a/tests/Fixtures/stations/measurements/aggregate-minute-success.json b/tests/Fixtures/stations/measurements/aggregate-minute-success.json new file mode 100644 index 0000000..4852ebf --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-minute-success.json @@ -0,0 +1,54 @@ +[ + { + "type": "m", + "date": 1786231380, + "station_id": "6a77ba36adde3b0001343e09", + "temp": { + "max": 21.5, + "min": 19.5, + "average": 20.5, + "weight": 3 + }, + "humidity": { + "average": 64, + "weight": 3 + }, + "wind": { + "deg": 203.4, + "speed": 3.08 + }, + "pressure": { + "min": 1012, + "max": 1014, + "average": 1013, + "weight": 3 + }, + "precipitation": {} + }, + { + "type": "m", + "date": 1786290900, + "station_id": "6a77ba36adde3b0001343e09", + "temp": { + "max": 16.7, + "min": 16.7, + "average": 16.7, + "weight": 1 + }, + "humidity": { + "average": 72, + "weight": 1 + }, + "wind": { + "deg": 230, + "speed": 4.8 + }, + "pressure": { + "min": 1014.2, + "max": 1014.2, + "average": 1014.2, + "weight": 1 + }, + "precipitation": {} + } +] diff --git a/tests/Fixtures/stations/measurements/aggregate-minute-success.meta.json b/tests/Fixtures/stations/measurements/aggregate-minute-success.meta.json new file mode 100644 index 0000000..2a6c8ea --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-minute-success.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Aggregate station measurements", + "apiVersion": "3.0", + "capturedAt": "2026-08-14T07:30:22Z", + "httpStatus": 200, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "GET", + "path": "/data/3.0/measurements", + "query": { + "station_id": "6a77ba36adde3b0001343e09", + "type": "m", + "limit": 100, + "from": 1786231349, + "to": 1786692622 + } + }, + "sanitization": [], + "notes": "The first aggregate appeared between 23 hours 43 minutes and 31 hours 43 minutes after its three source measurements were accepted. The second aggregate appeared approximately 30 hours after its fully populated source measurement was accepted. Both empty precipitation objects are preserved exactly. The retained station was deleted after capture, and a following list request confirmed that no stations remained." +} diff --git a/tests/Fixtures/stations/measurements/submit.empty b/tests/Fixtures/stations/measurements/submit.empty new file mode 100644 index 0000000..e69de29 diff --git a/tests/Fixtures/stations/measurements/submit.meta.json b/tests/Fixtures/stations/measurements/submit.meta.json new file mode 100644 index 0000000..03e6961 --- /dev/null +++ b/tests/Fixtures/stations/measurements/submit.meta.json @@ -0,0 +1,52 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Submit station measurements", + "apiVersion": "3.0", + "capturedAt": "2026-08-08T23:13:27Z", + "httpStatus": 204, + "bodyFormat": "empty", + "bodyBytes": 0, + "request": { + "method": "POST", + "path": "/data/3.0/measurements", + "query": {}, + "body": [ + { + "station_id": "6a77b80aadde3b0001343e08", + "dt": 1786143600, + "temperature": 19.5, + "wind_speed": 2.4, + "wind_gust": 4.1, + "wind_deg": 180, + "pressure": 1012, + "humidity": 68, + "rain_1h": 0.2 + }, + { + "station_id": "6a77b80aadde3b0001343e08", + "dt": 1786228200, + "temperature": 20.5, + "wind_speed": 3.2, + "wind_gust": 5.3, + "wind_deg": 200, + "pressure": 1013, + "humidity": 64, + "rain_1h": 0.4 + }, + { + "station_id": "6a77b80aadde3b0001343e08", + "dt": 1786230720, + "temperature": 21.5, + "wind_speed": 4.0, + "wind_gust": 6.5, + "wind_deg": 220, + "pressure": 1014, + "humidity": 60, + "rain_1h": 0.6 + } + ] + }, + "sanitization": [], + "notes": "The generated identifier belongs to a deliberately public temporary fixture station. Submission returned no body or Content-Type header. The station and its measurements were deleted before the batch completed." +} diff --git a/tests/Fixtures/stations/register.json b/tests/Fixtures/stations/register.json new file mode 100644 index 0000000..246520c --- /dev/null +++ b/tests/Fixtures/stations/register.json @@ -0,0 +1 @@ +{"ID":"6a779284adde3b0001343e02","updated_at":"2026-08-08T20:33:08.107598533Z","created_at":"2026-08-08T20:33:08.107598359Z","user_id":"user-fixture","external_id":"openweathermap-php-api-fixture","name":"OpenWeatherMap PHP API Fixture","latitude":38.7223,"longitude":-9.1393,"altitude":100,"rank":10,"source_type":5} diff --git a/tests/Fixtures/stations/register.meta.json b/tests/Fixtures/stations/register.meta.json new file mode 100644 index 0000000..8a6ab77 --- /dev/null +++ b/tests/Fixtures/stations/register.meta.json @@ -0,0 +1,28 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Register station", + "apiVersion": "3.0", + "capturedAt": "2026-08-08T20:33:09Z", + "httpStatus": 201, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "POST", + "path": "/data/3.0/stations", + "query": {}, + "body": { + "external_id": "openweathermap-php-api-fixture", + "name": "OpenWeatherMap PHP API Fixture", + "latitude": 38.7223, + "longitude": -9.1393, + "altitude": 100 + } + }, + "sanitization": [ + { + "path": "$.user_id", + "action": "replaced private account identifier with user-fixture" + } + ], + "notes": "The deliberately public fixture metadata and generated station identifier are unchanged. The temporary station was deleted and the account returned from one station to zero." +} diff --git a/tests/Fixtures/stations/retrieve.json b/tests/Fixtures/stations/retrieve.json new file mode 100644 index 0000000..664f49c --- /dev/null +++ b/tests/Fixtures/stations/retrieve.json @@ -0,0 +1 @@ +{"id":"6a779284adde3b0001343e02","created_at":"2026-08-08T20:33:08.107Z","updated_at":"2026-08-08T20:33:08.107Z","external_id":"openweathermap-php-api-fixture","name":"OpenWeatherMap PHP API Fixture","longitude":-9.1393,"latitude":38.7223,"altitude":100,"rank":10} diff --git a/tests/Fixtures/stations/retrieve.meta.json b/tests/Fixtures/stations/retrieve.meta.json new file mode 100644 index 0000000..584e559 --- /dev/null +++ b/tests/Fixtures/stations/retrieve.meta.json @@ -0,0 +1,16 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Retrieve station", + "apiVersion": "3.0", + "capturedAt": "2026-08-08T20:33:09Z", + "httpStatus": 200, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "GET", + "path": "/data/3.0/stations/6a779284adde3b0001343e02", + "query": {} + }, + "sanitization": [], + "notes": "The deliberately public fixture metadata and generated station identifier are unchanged. The temporary station was deleted before the batch completed." +} diff --git a/tests/Fixtures/stations/update.json b/tests/Fixtures/stations/update.json new file mode 100644 index 0000000..3a5701c --- /dev/null +++ b/tests/Fixtures/stations/update.json @@ -0,0 +1 @@ +{"id":"6a779284adde3b0001343e02","created_at":"2026-08-08T20:33:08.107Z","updated_at":"2026-08-08T20:33:09.01059313Z","external_id":"openweathermap-php-api-fixture-updated","name":"Updated OpenWeatherMap PHP API Fixture","longitude":-9.14,"latitude":38.72,"altitude":110,"rank":0} diff --git a/tests/Fixtures/stations/update.meta.json b/tests/Fixtures/stations/update.meta.json new file mode 100644 index 0000000..57055bb --- /dev/null +++ b/tests/Fixtures/stations/update.meta.json @@ -0,0 +1,23 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Update station", + "apiVersion": "3.0", + "capturedAt": "2026-08-08T20:33:09Z", + "httpStatus": 200, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "PUT", + "path": "/data/3.0/stations/6a779284adde3b0001343e02", + "query": {}, + "body": { + "external_id": "openweathermap-php-api-fixture-updated", + "name": "Updated OpenWeatherMap PHP API Fixture", + "latitude": 38.72, + "longitude": -9.14, + "altitude": 110 + } + }, + "sanitization": [], + "notes": "The deliberately public fixture metadata and generated station identifier are unchanged. The live response changed rank from 10 at registration to 0 after update, and the station was then deleted." +} diff --git a/tests/Fixtures/weather/current/invalid-coordinates.json b/tests/Fixtures/weather/current/invalid-coordinates.json new file mode 100644 index 0000000..a53e941 --- /dev/null +++ b/tests/Fixtures/weather/current/invalid-coordinates.json @@ -0,0 +1 @@ +{"cod":"400","message":"wrong latitude"} diff --git a/tests/Fixtures/weather/current/invalid-coordinates.meta.json b/tests/Fixtures/weather/current/invalid-coordinates.meta.json new file mode 100644 index 0000000..6b270f9 --- /dev/null +++ b/tests/Fixtures/weather/current/invalid-coordinates.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "Current Weather API", + "endpoint": "Current weather by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T08:50:07Z", + "httpStatus": 400, + "request": { + "method": "GET", + "path": "/data/2.5/weather", + "query": { + "lat": 91, + "lon": 0, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/weather/current/rain.json b/tests/Fixtures/weather/current/rain.json new file mode 100644 index 0000000..4b00710 --- /dev/null +++ b/tests/Fixtures/weather/current/rain.json @@ -0,0 +1 @@ +{"coord":{"lon":120.9842,"lat":14.5995},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10d"}],"base":"stations","main":{"temp":29.5,"feels_like":36.42,"temp_min":26.73,"temp_max":29.51,"pressure":1007,"humidity":81,"sea_level":1007,"grnd_level":1010},"visibility":9420,"wind":{"speed":1.98,"deg":255,"gust":2.31},"rain":{"1h":2.47},"clouds":{"all":100},"dt":1785574377,"sys":{"type":2,"id":2008256,"country":"PH","sunrise":1785533950,"sunset":1785579932},"timezone":28800,"id":1692184,"name":"Quiapo District","cod":200} diff --git a/tests/Fixtures/weather/current/rain.meta.json b/tests/Fixtures/weather/current/rain.meta.json new file mode 100644 index 0000000..edc36cf --- /dev/null +++ b/tests/Fixtures/weather/current/rain.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "Current Weather API", + "endpoint": "Current weather by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T08:53:44Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/weather", + "query": { + "lat": 14.5995, + "lon": 120.9842, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/weather/current/snow.json b/tests/Fixtures/weather/current/snow.json new file mode 100644 index 0000000..27a8957 --- /dev/null +++ b/tests/Fixtures/weather/current/snow.json @@ -0,0 +1 @@ +{"coord":{"lon":-71.58,"lat":-38.4},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"base":"stations","main":{"temp":-1.44,"feels_like":-6.29,"temp_min":-1.44,"temp_max":-1.44,"pressure":1014,"humidity":99,"sea_level":1014,"grnd_level":858},"wind":{"speed":4.33,"deg":312,"gust":14.6},"snow":{"1h":1.37},"clouds":{"all":100},"dt":1785574532,"sys":{"country":"CL","sunrise":1785584858,"sunset":1785621452},"timezone":-14400,"id":3892935,"name":"Curacautín","cod":200} diff --git a/tests/Fixtures/weather/current/snow.meta.json b/tests/Fixtures/weather/current/snow.meta.json new file mode 100644 index 0000000..2203a55 --- /dev/null +++ b/tests/Fixtures/weather/current/snow.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "Current Weather API", + "endpoint": "Current weather by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T08:55:55Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/weather", + "query": { + "lat": -38.4, + "lon": -71.58, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/weather/current/success.json b/tests/Fixtures/weather/current/success.json new file mode 100644 index 0000000..b2b538e --- /dev/null +++ b/tests/Fixtures/weather/current/success.json @@ -0,0 +1 @@ +{"coord":{"lon":-9.1393,"lat":38.7223},"weather":[{"id":802,"main":"Clouds","description":"scattered clouds","icon":"03d"}],"base":"stations","main":{"temp":22.55,"feels_like":22.87,"temp_min":21.48,"temp_max":23.94,"pressure":1016,"humidity":77,"sea_level":1016,"grnd_level":1006},"visibility":10000,"wind":{"speed":4.47,"deg":190,"gust":7.6},"clouds":{"all":48},"dt":1785573885,"sys":{"type":2,"id":2016751,"country":"PT","sunrise":1785562660,"sunset":1785613679},"timezone":3600,"id":8012502,"name":"Socorro","cod":200} diff --git a/tests/Fixtures/weather/current/success.meta.json b/tests/Fixtures/weather/current/success.meta.json new file mode 100644 index 0000000..60bc6c8 --- /dev/null +++ b/tests/Fixtures/weather/current/success.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "Current Weather API", + "endpoint": "Current weather by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T08:49:58Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/weather", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/weather/forecast/invalid-coordinates.json b/tests/Fixtures/weather/forecast/invalid-coordinates.json new file mode 100644 index 0000000..a53e941 --- /dev/null +++ b/tests/Fixtures/weather/forecast/invalid-coordinates.json @@ -0,0 +1 @@ +{"cod":"400","message":"wrong latitude"} diff --git a/tests/Fixtures/weather/forecast/invalid-coordinates.meta.json b/tests/Fixtures/weather/forecast/invalid-coordinates.meta.json new file mode 100644 index 0000000..ce9a5dc --- /dev/null +++ b/tests/Fixtures/weather/forecast/invalid-coordinates.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "5 Day / 3 Hour Forecast API", + "endpoint": "5 day / 3 hour forecast by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T09:00:36Z", + "httpStatus": 400, + "request": { + "method": "GET", + "path": "/data/2.5/forecast", + "query": { + "lat": 91, + "lon": 0, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/weather/forecast/rain.json b/tests/Fixtures/weather/forecast/rain.json new file mode 100644 index 0000000..49275c4 --- /dev/null +++ b/tests/Fixtures/weather/forecast/rain.json @@ -0,0 +1 @@ +{"cod":"200","message":0,"cnt":40,"list":[{"dt":1785574800,"main":{"temp":29.51,"feels_like":36.45,"temp_min":28.55,"temp_max":29.51,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":81,"temp_kf":0.96,"dew_point":25.9},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":1.98,"deg":255,"gust":2.31},"visibility":9420,"pop":1,"rain":{"3h":5.49},"sys":{"pod":"d"},"dt_txt":"2026-08-01 09:00:00"},{"dt":1785585600,"main":{"temp":29.19,"feels_like":35.28,"temp_min":28.54,"temp_max":29.19,"pressure":1008,"sea_level":1008,"grnd_level":1012,"humidity":80,"temp_kf":0.65,"dew_point":25.38},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10n"}],"clouds":{"all":99},"wind":{"speed":0.88,"deg":219,"gust":1.63},"visibility":10000,"pop":1,"rain":{"3h":3.16},"sys":{"pod":"n"},"dt_txt":"2026-08-01 12:00:00"},{"dt":1785596400,"main":{"temp":28.16,"feels_like":32.5,"temp_min":27.48,"temp_max":28.16,"pressure":1008,"sea_level":1008,"grnd_level":1012,"humidity":80,"temp_kf":0.68,"dew_point":24.38},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":98},"wind":{"speed":1.46,"deg":15,"gust":1.52},"visibility":10000,"pop":0.94,"rain":{"3h":1.61},"sys":{"pod":"n"},"dt_txt":"2026-08-01 15:00:00"},{"dt":1785607200,"main":{"temp":27.1,"feels_like":30.17,"temp_min":27.1,"temp_max":27.1,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":82,"temp_kf":0,"dew_point":23.93},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":97},"wind":{"speed":2.48,"deg":9,"gust":3.43},"visibility":10000,"pop":0.9,"rain":{"3h":0.3},"sys":{"pod":"n"},"dt_txt":"2026-08-01 18:00:00"},{"dt":1785618000,"main":{"temp":26.2,"feels_like":26.2,"temp_min":26.2,"temp_max":26.2,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":86,"temp_kf":0,"dew_point":23.84},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"clouds":{"all":100},"wind":{"speed":3.95,"deg":6,"gust":6.39},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-01 21:00:00"},{"dt":1785628800,"main":{"temp":26.9,"feels_like":29.88,"temp_min":26.9,"temp_max":26.9,"pressure":1008,"sea_level":1008,"grnd_level":1011,"humidity":84,"temp_kf":0,"dew_point":24.04},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"clouds":{"all":100},"wind":{"speed":3.25,"deg":347,"gust":5.04},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-02 00:00:00"},{"dt":1785639600,"main":{"temp":28.02,"feels_like":31.84,"temp_min":28.02,"temp_max":28.02,"pressure":1008,"sea_level":1008,"grnd_level":1011,"humidity":78,"temp_kf":0,"dew_point":23.92},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"clouds":{"all":100},"wind":{"speed":1.99,"deg":316,"gust":2.93},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-02 03:00:00"},{"dt":1785650400,"main":{"temp":28.23,"feels_like":32.03,"temp_min":28.23,"temp_max":28.23,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":76,"temp_kf":0,"dew_point":23.88},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"clouds":{"all":100},"wind":{"speed":0.46,"deg":216,"gust":0.62},"visibility":10000,"pop":0.04,"sys":{"pod":"d"},"dt_txt":"2026-08-02 06:00:00"},{"dt":1785661200,"main":{"temp":28.24,"feels_like":32.21,"temp_min":28.24,"temp_max":28.24,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":77,"temp_kf":0,"dew_point":23.98},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":2.32,"deg":206,"gust":2.92},"visibility":10000,"pop":1,"rain":{"3h":2.62},"sys":{"pod":"d"},"dt_txt":"2026-08-02 09:00:00"},{"dt":1785672000,"main":{"temp":26.45,"feels_like":26.45,"temp_min":26.45,"temp_max":26.45,"pressure":1008,"sea_level":1008,"grnd_level":1011,"humidity":87,"temp_kf":0,"dew_point":24.29},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":3.52,"deg":309,"gust":4.23},"visibility":5934,"pop":1,"rain":{"3h":6.62},"sys":{"pod":"n"},"dt_txt":"2026-08-02 12:00:00"},{"dt":1785682800,"main":{"temp":25.29,"feels_like":26.3,"temp_min":25.29,"temp_max":25.29,"pressure":1009,"sea_level":1009,"grnd_level":1012,"humidity":93,"temp_kf":0,"dew_point":24.19},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":2.98,"deg":294,"gust":3.22},"visibility":4067,"pop":1,"rain":{"3h":9.02},"sys":{"pod":"n"},"dt_txt":"2026-08-02 15:00:00"},{"dt":1785693600,"main":{"temp":24.91,"feels_like":25.81,"temp_min":24.91,"temp_max":24.91,"pressure":1008,"sea_level":1008,"grnd_level":1011,"humidity":90,"temp_kf":0,"dew_point":23.23},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":3.24,"deg":340,"gust":6.23},"visibility":10000,"pop":1,"rain":{"3h":4.96},"sys":{"pod":"n"},"dt_txt":"2026-08-02 18:00:00"},{"dt":1785704400,"main":{"temp":24.75,"feels_like":25.63,"temp_min":24.75,"temp_max":24.75,"pressure":1008,"sea_level":1008,"grnd_level":1011,"humidity":90,"temp_kf":0,"dew_point":23.07},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":3.28,"deg":5,"gust":4.31},"visibility":10000,"pop":0.82,"rain":{"3h":0.58},"sys":{"pod":"n"},"dt_txt":"2026-08-02 21:00:00"},{"dt":1785715200,"main":{"temp":24.86,"feels_like":25.67,"temp_min":24.86,"temp_max":24.86,"pressure":1009,"sea_level":1009,"grnd_level":1012,"humidity":87,"temp_kf":0,"dew_point":22.65},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":3.53,"deg":13,"gust":4.43},"visibility":10000,"pop":0.65,"rain":{"3h":0.23},"sys":{"pod":"d"},"dt_txt":"2026-08-03 00:00:00"},{"dt":1785726000,"main":{"temp":25.79,"feels_like":26.54,"temp_min":25.79,"temp_max":25.79,"pressure":1009,"sea_level":1009,"grnd_level":1012,"humidity":81,"temp_kf":0,"dew_point":22.38},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"clouds":{"all":99},"wind":{"speed":3.01,"deg":358,"gust":2.96},"visibility":10000,"pop":0.03,"sys":{"pod":"d"},"dt_txt":"2026-08-03 03:00:00"},{"dt":1785736800,"main":{"temp":26.66,"feels_like":26.66,"temp_min":26.66,"temp_max":26.66,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":79,"temp_kf":0,"dew_point":22.91},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":99},"wind":{"speed":2.07,"deg":310,"gust":2.42},"visibility":10000,"pop":0.29,"rain":{"3h":0.22},"sys":{"pod":"d"},"dt_txt":"2026-08-03 06:00:00"},{"dt":1785747600,"main":{"temp":27.32,"feels_like":30.13,"temp_min":27.32,"temp_max":27.32,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":77,"temp_kf":0,"dew_point":23.07},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":99},"wind":{"speed":1.8,"deg":252,"gust":2.15},"visibility":10000,"pop":0.9,"rain":{"3h":1.34},"sys":{"pod":"d"},"dt_txt":"2026-08-03 09:00:00"},{"dt":1785758400,"main":{"temp":26.76,"feels_like":29.46,"temp_min":26.76,"temp_max":26.76,"pressure":1009,"sea_level":1009,"grnd_level":1012,"humidity":83,"temp_kf":0,"dew_point":23.88},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":1.98,"deg":295,"gust":2.87},"visibility":10000,"pop":1,"rain":{"3h":2.08},"sys":{"pod":"n"},"dt_txt":"2026-08-03 12:00:00"},{"dt":1785769200,"main":{"temp":25.7,"feels_like":26.62,"temp_min":25.7,"temp_max":25.7,"pressure":1009,"sea_level":1009,"grnd_level":1012,"humidity":88,"temp_kf":0,"dew_point":23.67},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":4.14,"deg":336,"gust":5.28},"visibility":10000,"pop":1,"rain":{"3h":2.44},"sys":{"pod":"n"},"dt_txt":"2026-08-03 15:00:00"},{"dt":1785780000,"main":{"temp":25.5,"feels_like":26.4,"temp_min":25.5,"temp_max":25.5,"pressure":1008,"sea_level":1008,"grnd_level":1011,"humidity":88,"temp_kf":0,"dew_point":23.53},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":2.69,"deg":286,"gust":4.55},"visibility":10000,"pop":1,"rain":{"3h":2.03},"sys":{"pod":"n"},"dt_txt":"2026-08-03 18:00:00"},{"dt":1785790800,"main":{"temp":26.07,"feels_like":26.07,"temp_min":26.07,"temp_max":26.07,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":82,"temp_kf":0,"dew_point":22.98},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":3.74,"deg":281,"gust":5.41},"visibility":10000,"pop":1,"rain":{"3h":1.27},"sys":{"pod":"n"},"dt_txt":"2026-08-03 21:00:00"},{"dt":1785801600,"main":{"temp":25.79,"feels_like":26.62,"temp_min":25.79,"temp_max":25.79,"pressure":1009,"sea_level":1009,"grnd_level":1012,"humidity":84,"temp_kf":0,"dew_point":23.14},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":4.28,"deg":272,"gust":6.28},"visibility":10000,"pop":0.99,"rain":{"3h":0.87},"sys":{"pod":"d"},"dt_txt":"2026-08-04 00:00:00"},{"dt":1785812400,"main":{"temp":27.26,"feels_like":29.9,"temp_min":27.26,"temp_max":27.26,"pressure":1009,"sea_level":1009,"grnd_level":1012,"humidity":76,"temp_kf":0,"dew_point":22.94},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":5.14,"deg":270,"gust":6.22},"visibility":10000,"pop":0.95,"rain":{"3h":1.19},"sys":{"pod":"d"},"dt_txt":"2026-08-04 03:00:00"},{"dt":1785823200,"main":{"temp":26.99,"feels_like":29.26,"temp_min":26.99,"temp_max":26.99,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":75,"temp_kf":0,"dew_point":22.29},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":4.91,"deg":274,"gust":6.25},"visibility":10000,"pop":0.87,"rain":{"3h":0.11},"sys":{"pod":"d"},"dt_txt":"2026-08-04 06:00:00"},{"dt":1785834000,"main":{"temp":26.89,"feels_like":29.24,"temp_min":26.89,"temp_max":26.89,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":77,"temp_kf":0,"dew_point":22.66},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"clouds":{"all":100},"wind":{"speed":4.05,"deg":267,"gust":5.47},"visibility":10000,"pop":0.02,"sys":{"pod":"d"},"dt_txt":"2026-08-04 09:00:00"},{"dt":1785844800,"main":{"temp":26.6,"feels_like":26.6,"temp_min":26.6,"temp_max":26.6,"pressure":1008,"sea_level":1008,"grnd_level":1011,"humidity":80,"temp_kf":0,"dew_point":23.17},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":2.77,"deg":262,"gust":3.49},"visibility":10000,"pop":0.67,"rain":{"3h":0.75},"sys":{"pod":"n"},"dt_txt":"2026-08-04 12:00:00"},{"dt":1785855600,"main":{"temp":26.64,"feels_like":26.64,"temp_min":26.64,"temp_max":26.64,"pressure":1008,"sea_level":1008,"grnd_level":1011,"humidity":80,"temp_kf":0,"dew_point":23.19},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":3.21,"deg":277,"gust":4.39},"visibility":10000,"pop":0.99,"rain":{"3h":1.27},"sys":{"pod":"n"},"dt_txt":"2026-08-04 15:00:00"},{"dt":1785866400,"main":{"temp":26.04,"feels_like":26.04,"temp_min":26.04,"temp_max":26.04,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":82,"temp_kf":0,"dew_point":22.89},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":3.82,"deg":274,"gust":5.61},"visibility":10000,"pop":1,"rain":{"3h":1.51},"sys":{"pod":"n"},"dt_txt":"2026-08-04 18:00:00"},{"dt":1785877200,"main":{"temp":25.81,"feels_like":26.67,"temp_min":25.81,"temp_max":25.81,"pressure":1006,"sea_level":1006,"grnd_level":1009,"humidity":85,"temp_kf":0,"dew_point":23.27},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":4,"deg":249,"gust":5.3},"visibility":10000,"pop":1,"rain":{"3h":1.78},"sys":{"pod":"n"},"dt_txt":"2026-08-04 21:00:00"},{"dt":1785888000,"main":{"temp":26.48,"feels_like":26.48,"temp_min":26.48,"temp_max":26.48,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":85,"temp_kf":0,"dew_point":23.89},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":4.82,"deg":248,"gust":6.15},"visibility":10000,"pop":1,"rain":{"3h":1.75},"sys":{"pod":"d"},"dt_txt":"2026-08-05 00:00:00"},{"dt":1785898800,"main":{"temp":25.99,"feels_like":25.99,"temp_min":25.99,"temp_max":25.99,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":90,"temp_kf":0,"dew_point":24.34},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":5.26,"deg":232,"gust":7.64},"visibility":8381,"pop":1,"rain":{"3h":3.59},"sys":{"pod":"d"},"dt_txt":"2026-08-05 03:00:00"},{"dt":1785909600,"main":{"temp":26.15,"feels_like":26.15,"temp_min":26.15,"temp_max":26.15,"pressure":1005,"sea_level":1005,"grnd_level":1008,"humidity":87,"temp_kf":0,"dew_point":24.05},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":5.54,"deg":251,"gust":7.2},"visibility":6415,"pop":1,"rain":{"3h":2.74},"sys":{"pod":"d"},"dt_txt":"2026-08-05 06:00:00"},{"dt":1785920400,"main":{"temp":25.75,"feels_like":26.7,"temp_min":25.75,"temp_max":25.75,"pressure":1005,"sea_level":1005,"grnd_level":1008,"humidity":89,"temp_kf":0,"dew_point":23.98},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":6.14,"deg":244,"gust":9.24},"visibility":5191,"pop":1,"rain":{"3h":8.95},"sys":{"pod":"d"},"dt_txt":"2026-08-05 09:00:00"},{"dt":1785931200,"main":{"temp":26.54,"feels_like":26.54,"temp_min":26.54,"temp_max":26.54,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":82,"temp_kf":0,"dew_point":23.32},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":5.86,"deg":245,"gust":8.39},"visibility":10000,"pop":1,"rain":{"3h":1.36},"sys":{"pod":"n"},"dt_txt":"2026-08-05 12:00:00"},{"dt":1785942000,"main":{"temp":26.75,"feels_like":29.27,"temp_min":26.75,"temp_max":26.75,"pressure":1006,"sea_level":1006,"grnd_level":1009,"humidity":81,"temp_kf":0,"dew_point":23.39},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"clouds":{"all":100},"wind":{"speed":5.72,"deg":244,"gust":7.93},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-05 15:00:00"},{"dt":1785952800,"main":{"temp":26.5,"feels_like":26.5,"temp_min":26.5,"temp_max":26.5,"pressure":1004,"sea_level":1004,"grnd_level":1007,"humidity":85,"temp_kf":0,"dew_point":23.81},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":5.46,"deg":256,"gust":7.23},"visibility":10000,"pop":0.86,"rain":{"3h":0.94},"sys":{"pod":"n"},"dt_txt":"2026-08-05 18:00:00"},{"dt":1785963600,"main":{"temp":26.06,"feels_like":26.06,"temp_min":26.06,"temp_max":26.06,"pressure":1004,"sea_level":1004,"grnd_level":1007,"humidity":91,"temp_kf":0,"dew_point":24.53},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":4.44,"deg":274,"gust":6.56},"visibility":10000,"pop":1,"rain":{"3h":4.21},"sys":{"pod":"n"},"dt_txt":"2026-08-05 21:00:00"},{"dt":1785974400,"main":{"temp":27.32,"feels_like":30.69,"temp_min":27.32,"temp_max":27.32,"pressure":1005,"sea_level":1005,"grnd_level":1007,"humidity":82,"temp_kf":0,"dew_point":24.18},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":5.3,"deg":248,"gust":7.86},"visibility":10000,"pop":1,"rain":{"3h":0.7},"sys":{"pod":"d"},"dt_txt":"2026-08-06 00:00:00"},{"dt":1785985200,"main":{"temp":28.65,"feels_like":33.59,"temp_min":28.65,"temp_max":28.65,"pressure":1004,"sea_level":1004,"grnd_level":1007,"humidity":79,"temp_kf":0,"dew_point":24.84},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":7.76,"deg":240,"gust":10.35},"visibility":10000,"pop":1,"rain":{"3h":1.41},"sys":{"pod":"d"},"dt_txt":"2026-08-06 03:00:00"},{"dt":1785996000,"main":{"temp":28.93,"feels_like":33.92,"temp_min":28.93,"temp_max":28.93,"pressure":1003,"sea_level":1003,"grnd_level":1006,"humidity":77,"temp_kf":0,"dew_point":24.61},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":8.75,"deg":240,"gust":12.22},"visibility":10000,"pop":1,"rain":{"3h":3.99},"sys":{"pod":"d"},"dt_txt":"2026-08-06 06:00:00"}],"city":{"id":1701668,"name":"Manila","coord":{"lat":14.5995,"lon":120.9842},"country":"PH","population":15000,"timezone":28800,"sunrise":1785533950,"sunset":1785579932}} diff --git a/tests/Fixtures/weather/forecast/rain.meta.json b/tests/Fixtures/weather/forecast/rain.meta.json new file mode 100644 index 0000000..f7e0891 --- /dev/null +++ b/tests/Fixtures/weather/forecast/rain.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "5 Day / 3 Hour Forecast API", + "endpoint": "5 day / 3 hour forecast by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T09:02:25Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/forecast", + "query": { + "lat": 14.5995, + "lon": 120.9842, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/weather/forecast/snow.json b/tests/Fixtures/weather/forecast/snow.json new file mode 100644 index 0000000..2b8f485 --- /dev/null +++ b/tests/Fixtures/weather/forecast/snow.json @@ -0,0 +1 @@ +{"cod":"200","message":0,"cnt":40,"list":[{"dt":1785574800,"main":{"temp":-1.44,"feels_like":-6.29,"temp_min":-1.44,"temp_max":-1.44,"pressure":1014,"sea_level":1014,"grnd_level":858,"humidity":99,"temp_kf":0,"dew_point":-1.56},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":4.33,"deg":312,"gust":14.6},"pop":1,"snow":{"3h":3},"sys":{"pod":"n"},"dt_txt":"2026-08-01 09:00:00"},{"dt":1785585600,"main":{"temp":-1.69,"feels_like":-5.32,"temp_min":-2.2,"temp_max":-1.69,"pressure":1014,"sea_level":1014,"grnd_level":856,"humidity":100,"temp_kf":0.51,"dew_point":-1.69},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":2.78,"deg":315,"gust":10.81},"pop":1,"snow":{"3h":6.3},"sys":{"pod":"d"},"dt_txt":"2026-08-01 12:00:00"},{"dt":1785596400,"main":{"temp":-1.69,"feels_like":-5.41,"temp_min":-1.81,"temp_max":-1.69,"pressure":1012,"sea_level":1012,"grnd_level":855,"humidity":100,"temp_kf":0.12,"dew_point":-1.69},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":2.87,"deg":335,"gust":13.57},"pop":1,"snow":{"3h":5.45},"sys":{"pod":"d"},"dt_txt":"2026-08-01 15:00:00"},{"dt":1785607200,"main":{"temp":-1.97,"feels_like":-6.76,"temp_min":-1.97,"temp_max":-1.97,"pressure":1009,"sea_level":1009,"grnd_level":853,"humidity":99,"temp_kf":0,"dew_point":0.09},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":4.08,"deg":328,"gust":15.97},"pop":1,"snow":{"3h":5.1},"sys":{"pod":"d"},"dt_txt":"2026-08-01 18:00:00"},{"dt":1785618000,"main":{"temp":-2.69,"feels_like":-6.21,"temp_min":-2.69,"temp_max":-2.69,"pressure":1007,"sea_level":1007,"grnd_level":851,"humidity":99,"temp_kf":0,"dew_point":-0.6},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":2.5,"deg":356,"gust":8.75},"visibility":401,"pop":1,"snow":{"3h":8.51},"sys":{"pod":"d"},"dt_txt":"2026-08-01 21:00:00"},{"dt":1785628800,"main":{"temp":-2.87,"feels_like":-5.79,"temp_min":-2.87,"temp_max":-2.87,"pressure":1007,"sea_level":1007,"grnd_level":851,"humidity":100,"temp_kf":0,"dew_point":-0.66},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.99,"deg":313,"gust":7.6},"visibility":193,"pop":1,"snow":{"3h":9.97},"sys":{"pod":"n"},"dt_txt":"2026-08-02 00:00:00"},{"dt":1785639600,"main":{"temp":-1.83,"feels_like":-7.01,"temp_min":-1.83,"temp_max":-1.83,"pressure":1008,"sea_level":1008,"grnd_level":852,"humidity":98,"temp_kf":0,"dew_point":0.16},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":4.69,"deg":326,"gust":18.12},"visibility":1150,"pop":1,"snow":{"3h":12.53},"sys":{"pod":"n"},"dt_txt":"2026-08-02 03:00:00"},{"dt":1785650400,"main":{"temp":-2.42,"feels_like":-7.2,"temp_min":-2.42,"temp_max":-2.42,"pressure":1010,"sea_level":1010,"grnd_level":854,"humidity":96,"temp_kf":0,"dew_point":-0.8},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":3.92,"deg":317,"gust":15.2},"visibility":7229,"pop":1,"snow":{"3h":2.37},"sys":{"pod":"n"},"dt_txt":"2026-08-02 06:00:00"},{"dt":1785661200,"main":{"temp":-1.94,"feels_like":-6.92,"temp_min":-1.94,"temp_max":-1.94,"pressure":1011,"sea_level":1011,"grnd_level":854,"humidity":100,"temp_kf":0,"dew_point":0.3},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":4.35,"deg":304,"gust":15.5},"pop":1,"snow":{"3h":4.45},"sys":{"pod":"n"},"dt_txt":"2026-08-02 09:00:00"},{"dt":1785672000,"main":{"temp":-2.02,"feels_like":-6.63,"temp_min":-2.02,"temp_max":-2.02,"pressure":1013,"sea_level":1013,"grnd_level":856,"humidity":100,"temp_kf":0,"dew_point":0.14},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":3.82,"deg":304,"gust":14.04},"pop":1,"snow":{"3h":1.57},"sys":{"pod":"d"},"dt_txt":"2026-08-02 12:00:00"},{"dt":1785682800,"main":{"temp":-2.02,"feels_like":-6.49,"temp_min":-2.02,"temp_max":-2.02,"pressure":1015,"sea_level":1015,"grnd_level":858,"humidity":100,"temp_kf":0,"dew_point":0.21},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":3.64,"deg":298,"gust":13.56},"pop":1,"snow":{"3h":2.8},"sys":{"pod":"d"},"dt_txt":"2026-08-02 15:00:00"},{"dt":1785693600,"main":{"temp":-1.63,"feels_like":-5.34,"temp_min":-1.63,"temp_max":-1.63,"pressure":1014,"sea_level":1014,"grnd_level":857,"humidity":100,"temp_kf":0,"dew_point":0.6},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":2.88,"deg":299,"gust":13.04},"pop":1,"snow":{"3h":3.27},"sys":{"pod":"d"},"dt_txt":"2026-08-02 18:00:00"},{"dt":1785704400,"main":{"temp":-2.21,"feels_like":-5.6,"temp_min":-2.21,"temp_max":-2.21,"pressure":1016,"sea_level":1016,"grnd_level":858,"humidity":100,"temp_kf":0,"dew_point":0.03},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":2.46,"deg":291,"gust":6.52},"pop":1,"snow":{"3h":12.13},"sys":{"pod":"d"},"dt_txt":"2026-08-02 21:00:00"},{"dt":1785715200,"main":{"temp":-3.21,"feels_like":-5.34,"temp_min":-3.21,"temp_max":-3.21,"pressure":1017,"sea_level":1017,"grnd_level":859,"humidity":100,"temp_kf":0,"dew_point":-1.03},"weather":[{"id":602,"main":"Snow","description":"heavy snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.46,"deg":318,"gust":2.85},"pop":1,"snow":{"3h":16.09},"sys":{"pod":"n"},"dt_txt":"2026-08-03 00:00:00"},{"dt":1785726000,"main":{"temp":-3.52,"feels_like":-5.55,"temp_min":-3.52,"temp_max":-3.52,"pressure":1018,"sea_level":1018,"grnd_level":859,"humidity":100,"temp_kf":0,"dew_point":-1.3},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.38,"deg":342,"gust":3.84},"pop":1,"snow":{"3h":7.04},"sys":{"pod":"n"},"dt_txt":"2026-08-03 03:00:00"},{"dt":1785736800,"main":{"temp":-3.18,"feels_like":-5.68,"temp_min":-3.18,"temp_max":-3.18,"pressure":1018,"sea_level":1018,"grnd_level":860,"humidity":100,"temp_kf":0,"dew_point":-1.04},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.68,"deg":276,"gust":4.02},"pop":1,"snow":{"3h":7.42},"sys":{"pod":"n"},"dt_txt":"2026-08-03 06:00:00"},{"dt":1785747600,"main":{"temp":-3.1,"feels_like":-5.01,"temp_min":-3.1,"temp_max":-3.1,"pressure":1019,"sea_level":1019,"grnd_level":860,"humidity":100,"temp_kf":0,"dew_point":-0.87},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.35,"deg":278,"gust":2.55},"pop":1,"snow":{"3h":5.76},"sys":{"pod":"n"},"dt_txt":"2026-08-03 09:00:00"},{"dt":1785758400,"main":{"temp":-2.51,"feels_like":-2.51,"temp_min":-2.51,"temp_max":-2.51,"pressure":1021,"sea_level":1021,"grnd_level":862,"humidity":100,"temp_kf":0,"dew_point":-0.31},"weather":[{"id":600,"main":"Snow","description":"light snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":0.96,"deg":263,"gust":1.44},"pop":1,"snow":{"3h":0.59},"sys":{"pod":"d"},"dt_txt":"2026-08-03 12:00:00"},{"dt":1785769200,"main":{"temp":-1.68,"feels_like":-1.68,"temp_min":-1.68,"temp_max":-1.68,"pressure":1021,"sea_level":1021,"grnd_level":864,"humidity":99,"temp_kf":0,"dew_point":0.41},"weather":[{"id":600,"main":"Snow","description":"light snow","icon":"13d"}],"clouds":{"all":98},"wind":{"speed":0.46,"deg":234,"gust":0.77},"visibility":395,"pop":0.39,"snow":{"3h":0.18},"sys":{"pod":"d"},"dt_txt":"2026-08-03 15:00:00"},{"dt":1785780000,"main":{"temp":-1.77,"feels_like":-1.77,"temp_min":-1.77,"temp_max":-1.77,"pressure":1021,"sea_level":1021,"grnd_level":864,"humidity":101,"temp_kf":0,"dew_point":0.54},"weather":[{"id":600,"main":"Snow","description":"light snow","icon":"13d"}],"clouds":{"all":99},"wind":{"speed":0.68,"deg":317,"gust":1.08},"pop":0.41,"snow":{"3h":0.34},"sys":{"pod":"d"},"dt_txt":"2026-08-03 18:00:00"},{"dt":1785790800,"main":{"temp":-1.57,"feels_like":-1.57,"temp_min":-1.57,"temp_max":-1.57,"pressure":1021,"sea_level":1021,"grnd_level":864,"humidity":100,"temp_kf":0,"dew_point":0.6},"weather":[{"id":600,"main":"Snow","description":"light snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":0.43,"deg":269,"gust":0.6},"pop":0.2,"snow":{"3h":0.12},"sys":{"pod":"d"},"dt_txt":"2026-08-03 21:00:00"},{"dt":1785801600,"main":{"temp":-2.24,"feels_like":-2.24,"temp_min":-2.24,"temp_max":-2.24,"pressure":1022,"sea_level":1022,"grnd_level":864,"humidity":100,"temp_kf":0,"dew_point":-0.03},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"clouds":{"all":100},"wind":{"speed":0.6,"deg":60,"gust":1.07},"visibility":348,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-04 00:00:00"},{"dt":1785812400,"main":{"temp":-2.43,"feels_like":-2.43,"temp_min":-2.43,"temp_max":-2.43,"pressure":1022,"sea_level":1022,"grnd_level":864,"humidity":100,"temp_kf":0,"dew_point":-0.26},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"clouds":{"all":100},"wind":{"speed":0.83,"deg":40,"gust":1.13},"visibility":239,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-04 03:00:00"},{"dt":1785823200,"main":{"temp":-2.14,"feels_like":-2.14,"temp_min":-2.14,"temp_max":-2.14,"pressure":1021,"sea_level":1021,"grnd_level":863,"humidity":100,"temp_kf":0,"dew_point":0.07},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"clouds":{"all":100},"wind":{"speed":0.92,"deg":280,"gust":0.99},"visibility":96,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-04 06:00:00"},{"dt":1785834000,"main":{"temp":-1.99,"feels_like":-1.99,"temp_min":-1.99,"temp_max":-1.99,"pressure":1020,"sea_level":1020,"grnd_level":862,"humidity":100,"temp_kf":0,"dew_point":0.2},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"clouds":{"all":100},"wind":{"speed":1.33,"deg":272,"gust":1.23},"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-04 09:00:00"},{"dt":1785844800,"main":{"temp":-1.99,"feels_like":-1.99,"temp_min":-1.99,"temp_max":-1.99,"pressure":1021,"sea_level":1021,"grnd_level":863,"humidity":100,"temp_kf":0,"dew_point":0.23},"weather":[{"id":600,"main":"Snow","description":"light snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":0.84,"deg":279,"gust":1.15},"pop":0.2,"snow":{"3h":0.1},"sys":{"pod":"d"},"dt_txt":"2026-08-04 12:00:00"},{"dt":1785855600,"main":{"temp":-0.87,"feels_like":-2.7,"temp_min":-0.87,"temp_max":-0.87,"pressure":1019,"sea_level":1019,"grnd_level":863,"humidity":99,"temp_kf":0,"dew_point":1.25},"weather":[{"id":600,"main":"Snow","description":"light snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":1.47,"deg":298,"gust":2.38},"pop":0.2,"snow":{"3h":0.19},"sys":{"pod":"d"},"dt_txt":"2026-08-04 15:00:00"},{"dt":1785866400,"main":{"temp":-0.21,"feels_like":-2.99,"temp_min":-0.21,"temp_max":-0.21,"pressure":1018,"sea_level":1018,"grnd_level":862,"humidity":98,"temp_kf":0,"dew_point":1.71},"weather":[{"id":600,"main":"Snow","description":"light snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":2.24,"deg":292,"gust":4.18},"visibility":20,"pop":0.32,"snow":{"3h":0.26},"sys":{"pod":"d"},"dt_txt":"2026-08-04 18:00:00"},{"dt":1785877200,"main":{"temp":-1.61,"feels_like":-4.57,"temp_min":-1.61,"temp_max":-1.61,"pressure":1019,"sea_level":1019,"grnd_level":862,"humidity":98,"temp_kf":0,"dew_point":0.38},"weather":[{"id":600,"main":"Snow","description":"light snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":2.19,"deg":310,"gust":5.53},"visibility":1042,"pop":1,"snow":{"3h":0.53},"sys":{"pod":"d"},"dt_txt":"2026-08-04 21:00:00"},{"dt":1785888000,"main":{"temp":-2.46,"feels_like":-4.53,"temp_min":-2.46,"temp_max":-2.46,"pressure":1020,"sea_level":1020,"grnd_level":862,"humidity":100,"temp_kf":0,"dew_point":-0.22},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.48,"deg":296,"gust":3.18},"pop":1,"snow":{"3h":3.29},"sys":{"pod":"n"},"dt_txt":"2026-08-05 00:00:00"},{"dt":1785898800,"main":{"temp":-2.74,"feels_like":-2.74,"temp_min":-2.74,"temp_max":-2.74,"pressure":1019,"sea_level":1019,"grnd_level":861,"humidity":100,"temp_kf":0,"dew_point":-0.52},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.12,"deg":16,"gust":3.29},"pop":1,"snow":{"3h":7.21},"sys":{"pod":"n"},"dt_txt":"2026-08-05 03:00:00"},{"dt":1785909600,"main":{"temp":-2.28,"feels_like":-2.28,"temp_min":-2.28,"temp_max":-2.28,"pressure":1016,"sea_level":1016,"grnd_level":859,"humidity":100,"temp_kf":0,"dew_point":-0.1},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.02,"deg":37,"gust":2.9},"visibility":155,"pop":1,"snow":{"3h":6.52},"sys":{"pod":"n"},"dt_txt":"2026-08-05 06:00:00"},{"dt":1785920400,"main":{"temp":-1.2,"feels_like":-3.06,"temp_min":-1.2,"temp_max":-1.2,"pressure":1013,"sea_level":1013,"grnd_level":857,"humidity":100,"temp_kf":0,"dew_point":1.04},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.46,"deg":344,"gust":2.46},"pop":1,"snow":{"3h":5.89},"sys":{"pod":"n"},"dt_txt":"2026-08-05 09:00:00"},{"dt":1785931200,"main":{"temp":-0.99,"feels_like":-4.02,"temp_min":-0.99,"temp_max":-0.99,"pressure":1012,"sea_level":1012,"grnd_level":857,"humidity":100,"temp_kf":0,"dew_point":1.18},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":2.34,"deg":337,"gust":5.99},"visibility":89,"pop":1,"snow":{"3h":7.81},"sys":{"pod":"d"},"dt_txt":"2026-08-05 12:00:00"},{"dt":1785942000,"main":{"temp":-0.17,"feels_like":-3.05,"temp_min":-0.17,"temp_max":-0.17,"pressure":1011,"sea_level":1011,"grnd_level":856,"humidity":100,"temp_kf":0,"dew_point":1.97},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":2.34,"deg":320,"gust":5.78},"pop":1,"snow":{"3h":8.38},"sys":{"pod":"d"},"dt_txt":"2026-08-05 15:00:00"},{"dt":1785952800,"main":{"temp":0.16,"feels_like":-3.06,"temp_min":0.16,"temp_max":0.16,"pressure":1008,"sea_level":1008,"grnd_level":854,"humidity":99,"temp_kf":0,"dew_point":2.25},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":2.73,"deg":322,"gust":6.81},"visibility":154,"pop":1,"rain":{"3h":11.1},"sys":{"pod":"d"},"dt_txt":"2026-08-05 18:00:00"},{"dt":1785963600,"main":{"temp":1.25,"feels_like":-1.79,"temp_min":1.25,"temp_max":1.25,"pressure":1005,"sea_level":1005,"grnd_level":852,"humidity":99,"temp_kf":0,"dew_point":3.31},"weather":[{"id":502,"main":"Rain","description":"heavy intensity rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":2.76,"deg":329,"gust":6.98},"visibility":138,"pop":1,"rain":{"3h":12.91},"sys":{"pod":"d"},"dt_txt":"2026-08-05 21:00:00"},{"dt":1785974400,"main":{"temp":0.8,"feels_like":-0.58,"temp_min":0.8,"temp_max":0.8,"pressure":1006,"sea_level":1006,"grnd_level":853,"humidity":99,"temp_kf":0,"dew_point":3.03},"weather":[{"id":502,"main":"Rain","description":"heavy intensity rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":1.34,"deg":352,"gust":5.43},"visibility":151,"pop":1,"rain":{"3h":17.87},"sys":{"pod":"n"},"dt_txt":"2026-08-06 00:00:00"},{"dt":1785985200,"main":{"temp":-1.61,"feels_like":-3.49,"temp_min":-1.61,"temp_max":-1.61,"pressure":1009,"sea_level":1009,"grnd_level":853,"humidity":100,"temp_kf":0,"dew_point":0.62},"weather":[{"id":602,"main":"Snow","description":"heavy snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.44,"deg":62,"gust":3.78},"visibility":185,"pop":1,"snow":{"3h":22.11},"sys":{"pod":"n"},"dt_txt":"2026-08-06 03:00:00"},{"dt":1785996000,"main":{"temp":-2.43,"feels_like":-4.46,"temp_min":-2.43,"temp_max":-2.43,"pressure":1008,"sea_level":1008,"grnd_level":852,"humidity":100,"temp_kf":0,"dew_point":-0.25},"weather":[{"id":602,"main":"Snow","description":"heavy snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.46,"deg":121,"gust":3.17},"visibility":223,"pop":1,"snow":{"3h":15.87},"sys":{"pod":"n"},"dt_txt":"2026-08-06 06:00:00"}],"city":{"id":3892935,"name":"Curacautín","coord":{"lat":-38.4,"lon":-71.58},"country":"CL","population":0,"timezone":-14400,"sunrise":1785584858,"sunset":1785621452}} diff --git a/tests/Fixtures/weather/forecast/snow.meta.json b/tests/Fixtures/weather/forecast/snow.meta.json new file mode 100644 index 0000000..c7fc28d --- /dev/null +++ b/tests/Fixtures/weather/forecast/snow.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "5 Day / 3 Hour Forecast API", + "endpoint": "5 day / 3 hour forecast by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T09:00:36Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/forecast", + "query": { + "lat": -38.4, + "lon": -71.58, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/weather/forecast/success.json b/tests/Fixtures/weather/forecast/success.json new file mode 100644 index 0000000..7340994 --- /dev/null +++ b/tests/Fixtures/weather/forecast/success.json @@ -0,0 +1 @@ +{"cod":"200","message":0,"cnt":40,"list":[{"dt":1785574800,"main":{"temp":22.54,"feels_like":22.83,"temp_min":22.54,"temp_max":23.52,"pressure":1016,"sea_level":1016,"grnd_level":1006,"humidity":76,"temp_kf":-0.98,"dew_point":18.1},"weather":[{"id":802,"main":"Clouds","description":"scattered clouds","icon":"03d"}],"clouds":{"all":48},"wind":{"speed":3.36,"deg":349,"gust":4.91},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-01 09:00:00"},{"dt":1785585600,"main":{"temp":24.26,"feels_like":24.46,"temp_min":24.26,"temp_max":27.71,"pressure":1016,"sea_level":1016,"grnd_level":1005,"humidity":66,"temp_kf":-3.45,"dew_point":17.51},"weather":[{"id":802,"main":"Clouds","description":"scattered clouds","icon":"03d"}],"clouds":{"all":44},"wind":{"speed":3.74,"deg":320,"gust":3.14},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-01 12:00:00"},{"dt":1785596400,"main":{"temp":26.61,"feels_like":26.61,"temp_min":26.61,"temp_max":28.64,"pressure":1015,"sea_level":1015,"grnd_level":1004,"humidity":50,"temp_kf":-2.03,"dew_point":15.34},"weather":[{"id":802,"main":"Clouds","description":"scattered clouds","icon":"03d"}],"clouds":{"all":27},"wind":{"speed":6.12,"deg":315,"gust":5.97},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-01 15:00:00"},{"dt":1785607200,"main":{"temp":22.82,"feels_like":22.67,"temp_min":22.82,"temp_max":22.82,"pressure":1014,"sea_level":1014,"grnd_level":1005,"humidity":58,"temp_kf":0,"dew_point":15.43},"weather":[{"id":801,"main":"Clouds","description":"few clouds","icon":"02d"}],"clouds":{"all":13},"wind":{"speed":7.17,"deg":323,"gust":10.24},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-01 18:00:00"},{"dt":1785618000,"main":{"temp":21.69,"feels_like":22.03,"temp_min":21.69,"temp_max":21.69,"pressure":1015,"sea_level":1015,"grnd_level":1005,"humidity":81,"temp_kf":0,"dew_point":17.25},"weather":[{"id":801,"main":"Clouds","description":"few clouds","icon":"02n"}],"clouds":{"all":17},"wind":{"speed":4.91,"deg":334,"gust":10.61},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-01 21:00:00"},{"dt":1785628800,"main":{"temp":20.92,"feels_like":21.26,"temp_min":20.92,"temp_max":20.92,"pressure":1015,"sea_level":1015,"grnd_level":1005,"humidity":84,"temp_kf":0,"dew_point":17.02},"weather":[{"id":801,"main":"Clouds","description":"few clouds","icon":"02n"}],"clouds":{"all":18},"wind":{"speed":3.01,"deg":331,"gust":7.2},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-02 00:00:00"},{"dt":1785639600,"main":{"temp":20.66,"feels_like":21.05,"temp_min":20.66,"temp_max":20.66,"pressure":1014,"sea_level":1014,"grnd_level":1005,"humidity":87,"temp_kf":0,"dew_point":17.11},"weather":[{"id":801,"main":"Clouds","description":"few clouds","icon":"02n"}],"clouds":{"all":19},"wind":{"speed":3.42,"deg":321,"gust":7.58},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-02 03:00:00"},{"dt":1785650400,"main":{"temp":20.92,"feels_like":21.34,"temp_min":20.92,"temp_max":20.92,"pressure":1015,"sea_level":1015,"grnd_level":1005,"humidity":87,"temp_kf":0,"dew_point":16.92},"weather":[{"id":801,"main":"Clouds","description":"few clouds","icon":"02d"}],"clouds":{"all":19},"wind":{"speed":3.27,"deg":323,"gust":7.66},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-02 06:00:00"},{"dt":1785661200,"main":{"temp":23.67,"feels_like":23.71,"temp_min":23.67,"temp_max":23.67,"pressure":1016,"sea_level":1016,"grnd_level":1006,"humidity":62,"temp_kf":0,"dew_point":15.46},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":9},"wind":{"speed":4.2,"deg":324,"gust":5.87},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-02 09:00:00"},{"dt":1785672000,"main":{"temp":25.42,"feels_like":25.14,"temp_min":25.42,"temp_max":25.42,"pressure":1016,"sea_level":1016,"grnd_level":1006,"humidity":43,"temp_kf":0,"dew_point":12.93},"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04d"}],"clouds":{"all":51},"wind":{"speed":4.95,"deg":312,"gust":5.46},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-02 12:00:00"},{"dt":1785682800,"main":{"temp":26.33,"feels_like":26.33,"temp_min":26.33,"temp_max":26.33,"pressure":1015,"sea_level":1015,"grnd_level":1005,"humidity":41,"temp_kf":0,"dew_point":12.46},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"clouds":{"all":100},"wind":{"speed":5.9,"deg":309,"gust":6.73},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-02 15:00:00"},{"dt":1785693600,"main":{"temp":22.93,"feels_like":22.66,"temp_min":22.93,"temp_max":22.93,"pressure":1014,"sea_level":1014,"grnd_level":1005,"humidity":53,"temp_kf":0,"dew_point":13.82},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"clouds":{"all":99},"wind":{"speed":5.3,"deg":311,"gust":7.14},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-02 18:00:00"},{"dt":1785704400,"main":{"temp":23.24,"feels_like":23.47,"temp_min":23.24,"temp_max":23.24,"pressure":1015,"sea_level":1015,"grnd_level":1006,"humidity":71,"temp_kf":0,"dew_point":15.58},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"clouds":{"all":100},"wind":{"speed":3.67,"deg":317,"gust":6.84},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-02 21:00:00"},{"dt":1785715200,"main":{"temp":22.27,"feels_like":22.51,"temp_min":22.27,"temp_max":22.27,"pressure":1015,"sea_level":1015,"grnd_level":1005,"humidity":75,"temp_kf":0,"dew_point":16.08},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"clouds":{"all":96},"wind":{"speed":2.77,"deg":316,"gust":5.57},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-03 00:00:00"},{"dt":1785726000,"main":{"temp":20.65,"feels_like":20.78,"temp_min":20.65,"temp_max":20.65,"pressure":1014,"sea_level":1014,"grnd_level":1005,"humidity":77,"temp_kf":0,"dew_point":16.47},"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04n"}],"clouds":{"all":78},"wind":{"speed":1.97,"deg":292,"gust":3.94},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-03 03:00:00"},{"dt":1785736800,"main":{"temp":20.15,"feels_like":20.34,"temp_min":20.15,"temp_max":20.15,"pressure":1014,"sea_level":1014,"grnd_level":1005,"humidity":81,"temp_kf":0,"dew_point":16.83},"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04d"}],"clouds":{"all":67},"wind":{"speed":1.13,"deg":280,"gust":2.07},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-03 06:00:00"},{"dt":1785747600,"main":{"temp":23.24,"feels_like":23.37,"temp_min":23.24,"temp_max":23.24,"pressure":1015,"sea_level":1015,"grnd_level":1005,"humidity":67,"temp_kf":0,"dew_point":16.74},"weather":[{"id":801,"main":"Clouds","description":"few clouds","icon":"02d"}],"clouds":{"all":19},"wind":{"speed":2.74,"deg":240,"gust":3.26},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-03 09:00:00"},{"dt":1785758400,"main":{"temp":25.53,"feels_like":25.68,"temp_min":25.53,"temp_max":25.53,"pressure":1015,"sea_level":1015,"grnd_level":1005,"humidity":59,"temp_kf":0,"dew_point":17.06},"weather":[{"id":801,"main":"Clouds","description":"few clouds","icon":"02d"}],"clouds":{"all":11},"wind":{"speed":4.93,"deg":236,"gust":5.28},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-03 12:00:00"},{"dt":1785769200,"main":{"temp":25.43,"feels_like":25.75,"temp_min":25.43,"temp_max":25.43,"pressure":1015,"sea_level":1015,"grnd_level":1005,"humidity":66,"temp_kf":0,"dew_point":18.66},"weather":[{"id":802,"main":"Clouds","description":"scattered clouds","icon":"03d"}],"clouds":{"all":36},"wind":{"speed":5.79,"deg":246,"gust":6.97},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-03 15:00:00"},{"dt":1785780000,"main":{"temp":24.21,"feels_like":24.59,"temp_min":24.21,"temp_max":24.21,"pressure":1015,"sea_level":1015,"grnd_level":1005,"humidity":73,"temp_kf":0,"dew_point":19.08},"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04d"}],"clouds":{"all":57},"wind":{"speed":4.79,"deg":263,"gust":6.7},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-03 18:00:00"},{"dt":1785790800,"main":{"temp":21.81,"feels_like":22.06,"temp_min":21.81,"temp_max":21.81,"pressure":1016,"sea_level":1016,"grnd_level":1007,"humidity":77,"temp_kf":0,"dew_point":17.43},"weather":[{"id":801,"main":"Clouds","description":"few clouds","icon":"02n"}],"clouds":{"all":11},"wind":{"speed":3.28,"deg":310,"gust":5.34},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-03 21:00:00"},{"dt":1785801600,"main":{"temp":20.74,"feels_like":20.98,"temp_min":20.74,"temp_max":20.74,"pressure":1016,"sea_level":1016,"grnd_level":1007,"humidity":81,"temp_kf":0,"dew_point":17.26},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01n"}],"clouds":{"all":5},"wind":{"speed":2.56,"deg":318,"gust":5.22},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-04 00:00:00"},{"dt":1785812400,"main":{"temp":20.2,"feels_like":20.47,"temp_min":20.2,"temp_max":20.2,"pressure":1016,"sea_level":1016,"grnd_level":1006,"humidity":84,"temp_kf":0,"dew_point":17.33},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01n"}],"clouds":{"all":0},"wind":{"speed":1.55,"deg":307,"gust":3.14},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-04 03:00:00"},{"dt":1785823200,"main":{"temp":19.93,"feels_like":20.17,"temp_min":19.93,"temp_max":19.93,"pressure":1017,"sea_level":1017,"grnd_level":1007,"humidity":84,"temp_kf":0,"dew_point":17.11},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":0},"wind":{"speed":0.61,"deg":296,"gust":1.12},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-04 06:00:00"},{"dt":1785834000,"main":{"temp":23.72,"feels_like":23.74,"temp_min":23.72,"temp_max":23.72,"pressure":1018,"sea_level":1018,"grnd_level":1008,"humidity":61,"temp_kf":0,"dew_point":15.76},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":0},"wind":{"speed":2.13,"deg":286,"gust":2.95},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-04 09:00:00"},{"dt":1785844800,"main":{"temp":26.74,"feels_like":27.07,"temp_min":26.74,"temp_max":26.74,"pressure":1018,"sea_level":1018,"grnd_level":1008,"humidity":48,"temp_kf":0,"dew_point":14.92},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":0},"wind":{"speed":4.25,"deg":271,"gust":4.23},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-04 12:00:00"},{"dt":1785855600,"main":{"temp":26.92,"feels_like":27.17,"temp_min":26.92,"temp_max":26.92,"pressure":1017,"sea_level":1017,"grnd_level":1008,"humidity":47,"temp_kf":0,"dew_point":14.69},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":0},"wind":{"speed":5.5,"deg":290,"gust":5.52},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-04 15:00:00"},{"dt":1785866400,"main":{"temp":24.7,"feels_like":24.77,"temp_min":24.7,"temp_max":24.7,"pressure":1018,"sea_level":1018,"grnd_level":1008,"humidity":59,"temp_kf":0,"dew_point":16.13},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":0},"wind":{"speed":5.33,"deg":312,"gust":6.75},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-04 18:00:00"},{"dt":1785877200,"main":{"temp":21.85,"feels_like":22.15,"temp_min":21.85,"temp_max":21.85,"pressure":1019,"sea_level":1019,"grnd_level":1010,"humidity":79,"temp_kf":0,"dew_point":17.96},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01n"}],"clouds":{"all":0},"wind":{"speed":4.24,"deg":325,"gust":7.25},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-04 21:00:00"},{"dt":1785888000,"main":{"temp":21.08,"feels_like":21.46,"temp_min":21.08,"temp_max":21.08,"pressure":1019,"sea_level":1019,"grnd_level":1010,"humidity":85,"temp_kf":0,"dew_point":18.36},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01n"}],"clouds":{"all":1},"wind":{"speed":3.37,"deg":326,"gust":6.71},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-05 00:00:00"},{"dt":1785898800,"main":{"temp":20.46,"feels_like":20.83,"temp_min":20.46,"temp_max":20.46,"pressure":1018,"sea_level":1018,"grnd_level":1009,"humidity":87,"temp_kf":0,"dew_point":18.16},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01n"}],"clouds":{"all":3},"wind":{"speed":2.55,"deg":329,"gust":5.63},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-05 03:00:00"},{"dt":1785909600,"main":{"temp":20,"feels_like":20.41,"temp_min":20,"temp_max":20,"pressure":1019,"sea_level":1019,"grnd_level":1009,"humidity":90,"temp_kf":0,"dew_point":18.31},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":4},"wind":{"speed":2.36,"deg":334,"gust":5.81},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-05 06:00:00"},{"dt":1785920400,"main":{"temp":23.82,"feels_like":23.93,"temp_min":23.82,"temp_max":23.82,"pressure":1019,"sea_level":1019,"grnd_level":1010,"humidity":64,"temp_kf":0,"dew_point":16.55},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":5},"wind":{"speed":3.68,"deg":337,"gust":5.02},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-05 09:00:00"},{"dt":1785931200,"main":{"temp":27.54,"feels_like":27.8,"temp_min":27.54,"temp_max":27.54,"pressure":1019,"sea_level":1019,"grnd_level":1010,"humidity":48,"temp_kf":0,"dew_point":15.71},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":3},"wind":{"speed":5.41,"deg":320,"gust":5.21},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-05 12:00:00"},{"dt":1785942000,"main":{"temp":27.37,"feels_like":27.64,"temp_min":27.37,"temp_max":27.37,"pressure":1019,"sea_level":1019,"grnd_level":1009,"humidity":48,"temp_kf":0,"dew_point":15.47},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":4},"wind":{"speed":6.9,"deg":320,"gust":7.18},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-05 15:00:00"},{"dt":1785952800,"main":{"temp":24.92,"feels_like":24.85,"temp_min":24.92,"temp_max":24.92,"pressure":1019,"sea_level":1019,"grnd_level":1010,"humidity":53,"temp_kf":0,"dew_point":14.97},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":2},"wind":{"speed":6.63,"deg":335,"gust":8.72},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-05 18:00:00"},{"dt":1785963600,"main":{"temp":21.25,"feels_like":21.26,"temp_min":21.25,"temp_max":21.25,"pressure":1021,"sea_level":1021,"grnd_level":1011,"humidity":70,"temp_kf":0,"dew_point":15.41},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01n"}],"clouds":{"all":0},"wind":{"speed":4.88,"deg":347,"gust":10.43},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-05 21:00:00"},{"dt":1785974400,"main":{"temp":20.03,"feels_like":19.99,"temp_min":20.03,"temp_max":20.03,"pressure":1021,"sea_level":1021,"grnd_level":1011,"humidity":73,"temp_kf":0,"dew_point":14.83},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01n"}],"clouds":{"all":0},"wind":{"speed":3.29,"deg":346,"gust":8.51},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-06 00:00:00"},{"dt":1785985200,"main":{"temp":19.3,"feels_like":19.32,"temp_min":19.3,"temp_max":19.3,"pressure":1020,"sea_level":1020,"grnd_level":1010,"humidity":78,"temp_kf":0,"dew_point":15.15},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01n"}],"clouds":{"all":0},"wind":{"speed":2.92,"deg":338,"gust":9.28},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-06 03:00:00"},{"dt":1785996000,"main":{"temp":19.21,"feels_like":19.2,"temp_min":19.21,"temp_max":19.21,"pressure":1020,"sea_level":1020,"grnd_level":1011,"humidity":77,"temp_kf":0,"dew_point":15.13},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":0},"wind":{"speed":3.21,"deg":341,"gust":9.84},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-06 06:00:00"}],"city":{"id":6458923,"name":"Lisbon Municipality","coord":{"lat":38.7223,"lon":-9.1393},"country":"PT","population":0,"timezone":3600,"sunrise":1785562660,"sunset":1785613679}} diff --git a/tests/Fixtures/weather/forecast/success.meta.json b/tests/Fixtures/weather/forecast/success.meta.json new file mode 100644 index 0000000..1e0b97d --- /dev/null +++ b/tests/Fixtures/weather/forecast/success.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "5 Day / 3 Hour Forecast API", + "endpoint": "5 day / 3 hour forecast by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T09:02:25Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/forecast", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Integration/AirPollutionResourceTest.php b/tests/Integration/AirPollutionResourceTest.php deleted file mode 100644 index ec52e26..0000000 --- a/tests/Integration/AirPollutionResourceTest.php +++ /dev/null @@ -1,39 +0,0 @@ - [ - AirPollution::class, - MockResponse::AIR_POLLUTION_CURRENT, - 'airPollution', - 'getCurrent', - [50, 50] - ]; - yield 'get forecast' => [ - AirPollutionCollection::class, - MockResponse::AIR_POLLUTION_FORECAST, - 'airPollution', - 'getForecast', - [50, 50] - ]; - yield 'get history' => [ - AirPollutionCollection::class, - MockResponse::AIR_POLLUTION_HISTORY, - 'airPollution', - 'getHistory', - [50, 50, new \DateTime('-1 day'), new \DateTime('now')] - ]; - } -} \ No newline at end of file diff --git a/tests/Integration/AssistantResourceTest.php b/tests/Integration/AssistantResourceTest.php deleted file mode 100644 index 162c3d6..0000000 --- a/tests/Integration/AssistantResourceTest.php +++ /dev/null @@ -1,31 +0,0 @@ - [ - Answer::class, - MockResponse::ASSISTANT_START_SESSION, - 'assistant', - 'startSession', - ['prompt'] - ]; - yield 'resume session' => [ - Answer::class, - MockResponse::ASSISTANT_RESUME_SESSION, - 'assistant', - 'resumeSession', - ['session-id', 'prompt'] - ]; - } -} \ No newline at end of file diff --git a/tests/Integration/CacheTraitTest.php b/tests/Integration/CacheTraitTest.php deleted file mode 100644 index 9e8cc73..0000000 --- a/tests/Integration/CacheTraitTest.php +++ /dev/null @@ -1,40 +0,0 @@ -createMock(CacheItemPoolInterface::class); - $cacheBuilder = new CacheBuilder($pool); - - $this->api->setCacheBuilder($cacheBuilder); - - $this->resource = new class($this->api) extends Resource { - use CacheTrait; - - public function getCacheTtl(): ?int - { - return $this->api->getCacheBuilder()?->getTtl(); - } - }; - } - - public function testMethods(): void - { - $this->assertSame(60, $this->resource->getCacheTtl()); - $this->assertSame(600, $this->resource->withCacheTtl(600)->getCacheTtl()); - $this->assertSame(60, $this->resource->getCacheTtl()); // back to default value - } -} \ No newline at end of file diff --git a/tests/Integration/GeocodingResourceTest.php b/tests/Integration/GeocodingResourceTest.php deleted file mode 100644 index 7f8dfe0..0000000 --- a/tests/Integration/GeocodingResourceTest.php +++ /dev/null @@ -1,45 +0,0 @@ - [ - ZipLocation::class, - MockResponse::GEOCODING_ZIP, - 'geocoding', - 'getByZipCode', - ['1000-001', 'pt'] - ]; - } - - public static function provideCollectionResponseData(): \Generator - { - yield 'get by location name' => [ - Location::class, - MockResponse::GEOCODING_DIRECT, - 'geocoding', - 'getByLocationName', - ['test'] - ]; - yield 'get by coordinate' => [ - Location::class, - MockResponse::GEOCODING_REVERSE, - 'geocoding', - 'getByCoordinate', - [50, 50] - ]; - } -} \ No newline at end of file diff --git a/tests/Integration/LanguageTraitTest.php b/tests/Integration/LanguageTraitTest.php deleted file mode 100644 index 31cf054..0000000 --- a/tests/Integration/LanguageTraitTest.php +++ /dev/null @@ -1,33 +0,0 @@ -resource = new class($this->api) extends Resource { - use LanguageTrait; - - public function getLanguage(): string - { - return $this->api->getQueryDefault('lang'); - } - }; - } - - public function testMethods(): void - { - $this->assertSame('en', $this->resource->getLanguage()); - $this->assertSame('pt', $this->resource->withLanguage('pt')->getLanguage()); - $this->assertSame('en', $this->resource->getLanguage()); // back to default value - } -} \ No newline at end of file diff --git a/tests/Integration/OneCallResourceTest.php b/tests/Integration/OneCallResourceTest.php deleted file mode 100644 index 2528545..0000000 --- a/tests/Integration/OneCallResourceTest.php +++ /dev/null @@ -1,48 +0,0 @@ - [ - Weather::class, - MockResponse::ONE_CALL_WEATHER, - 'oneCall', - 'getWeather', - [50, 50] - ]; - yield 'get weather by date' => [ - WeatherMoment::class, - MockResponse::ONE_CALL_TIMEMACHINE, - 'oneCall', - 'getWeatherByDate', - [50, 50, new \DateTime()] - ]; - yield 'get weather summary by date' => [ - WeatherSummary::class, - MockResponse::ONE_CALL_DAY_SUMMARY, - 'oneCall', - 'getWeatherSummaryByDate', - [50, 50, new \DateTime()] - ]; - yield 'get weather overview by date' => [ - WeatherOverview::class, - MockResponse::ONE_CALL_OVERVIEW, - 'oneCall', - 'getWeatherOverviewByDate', - [50, 50, new \DateTime()] - ]; - } -} \ No newline at end of file diff --git a/tests/Integration/OpenWeatherMapTest.php b/tests/Integration/OpenWeatherMapTest.php deleted file mode 100644 index 76f4bca..0000000 --- a/tests/Integration/OpenWeatherMapTest.php +++ /dev/null @@ -1,20 +0,0 @@ -assertInstanceOf(OneCallResource::class, $this->api->oneCall()); - $this->assertInstanceOf(WeatherResource::class, $this->api->weather()); - $this->assertInstanceOf(AirPollutionResource::class, $this->api->airPollution()); - $this->assertInstanceOf(GeocodingResource::class, $this->api->geocoding()); - } -} \ No newline at end of file diff --git a/tests/Integration/ResourceTest.php b/tests/Integration/ResourceTest.php deleted file mode 100644 index 7638911..0000000 --- a/tests/Integration/ResourceTest.php +++ /dev/null @@ -1,56 +0,0 @@ -resource = new class($this->api) extends Resource { - public function request(): void - { - $this->api->request( - method: Method::GET, - path: '/test' - ); - } - }; - } - - #[DataProvider(methodName: 'provideApiErrorData')] - public function testApiError(int $statusCode, string $exception): void - { - $this->mockClient->addResponse(new Response( - status: $statusCode, - body: MockResponse::API_ERROR - )); - - $this->expectException($exception); - $this->resource->request(); - } - - public static function provideApiErrorData(): \Generator - { - yield 'bad request' => [400, BadRequestException::class]; - yield 'unauthorized' => [401, UnauthorizedException::class]; - yield 'not found' => [404, NotFoundException::class]; - yield 'too many requests' => [429, TooManyRequestsException::class]; - yield 'unexpected error' => [500, UnexpectedErrorException::class]; - } -} \ No newline at end of file diff --git a/tests/Integration/UnitSystemTraitTest.php b/tests/Integration/UnitSystemTraitTest.php deleted file mode 100644 index 11e5919..0000000 --- a/tests/Integration/UnitSystemTraitTest.php +++ /dev/null @@ -1,33 +0,0 @@ -resource = new class($this->api) extends Resource { - use UnitSystemTrait; - - public function getUnitSystem(): string - { - return $this->api->getQueryDefault('units'); - } - }; - } - - public function testMethods(): void - { - $this->assertSame('metric', $this->resource->getUnitSystem()); - $this->assertSame('imperial', $this->resource->withUnitSystem('imperial')->getUnitSystem()); - $this->assertSame('metric', $this->resource->getUnitSystem()); // back to default value - } -} \ No newline at end of file diff --git a/tests/Integration/WeatherResourceTest.php b/tests/Integration/WeatherResourceTest.php deleted file mode 100644 index 579aa01..0000000 --- a/tests/Integration/WeatherResourceTest.php +++ /dev/null @@ -1,32 +0,0 @@ - [ - Weather::class, - MockResponse::WEATHER_CURRENT, - 'weather', - 'getCurrent', - [50, 50] - ]; - yield 'get forecast' => [ - WeatherCollection::class, - MockResponse::WEATHER_FORECAST, - 'weather', - 'getForecast', - [50, 50] - ]; - } -} \ No newline at end of file diff --git a/tests/Support/ApiTestCase.php b/tests/Support/ApiTestCase.php new file mode 100644 index 0000000..84180de --- /dev/null +++ b/tests/Support/ApiTestCase.php @@ -0,0 +1,43 @@ +client = new Client(); + $this->api = new OpenWeatherMap('api-key'); + $this->api->setup()->client($this->client); + } + + protected function respondWithFixture( + string $path, + int $status = 200, + ): void + { + $this->client->addResponse(new Response( + status: $status, + body: Fixture::contents($path), + )); + } + + protected function query(RequestInterface $request): array + { + parse_str($request->getUri()->getQuery(), $query); + + return $query; + } +} diff --git a/tests/Support/Fixture.php b/tests/Support/Fixture.php new file mode 100644 index 0000000..66cb250 --- /dev/null +++ b/tests/Support/Fixture.php @@ -0,0 +1,33 @@ + [ - 'lat' => 50, - 'lon' => 50 - ], - 'list' => [ - [ - 'dt' => 1715279409, - 'main' => [ - 'aqi' => 1 - ], - 'components' => [ - 'co' => 100, - 'no' => 0, - 'no2' => 1, - 'o3' => 100, - 'so2' => 1, - 'pm2_5' => 1, - 'pm10' => 1, - 'nh3' => 1 - ] - ] - ] - ]); - - $this->assertSame(1, $entity->getNumResults()); - $this->assertInstanceOf(Coordinate::class, $entity->getCoordinate()); - $this->assertContainsOnlyInstancesOf(AirPollutionData::class, $entity->getData()); - } -} \ No newline at end of file diff --git a/tests/Unit/AirPollution/AirPollutionDataTest.php b/tests/Unit/AirPollution/AirPollutionDataTest.php deleted file mode 100644 index 68da780..0000000 --- a/tests/Unit/AirPollution/AirPollutionDataTest.php +++ /dev/null @@ -1,41 +0,0 @@ - 1715279409, - 'main' => [ - 'aqi' => 1 - ], - 'components' => [ - 'co' => 100, - 'no' => 0, - 'no2' => 1, - 'o3' => 100, - 'so2' => 1, - 'pm2_5' => 1, - 'pm10' => 1, - 'nh3' => 1 - ] - ]); - - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertInstanceOf(AirQuality::class, $entity->getAirQuality()); - $this->assertSame(100.0, $entity->getCarbonMonoxide()); - $this->assertSame(0.0, $entity->getNitrogenMonoxide()); - $this->assertSame(1.0, $entity->getNitrogenDioxide()); - $this->assertSame(100.0, $entity->getOzone()); - $this->assertSame(1.0, $entity->getSulphurDioxide()); - $this->assertSame(1.0, $entity->getFineParticulateMatter()); - $this->assertSame(1.0, $entity->getCoarseParticulateMatter()); - $this->assertSame(1.0, $entity->getAmmonia()); - } -} \ No newline at end of file diff --git a/tests/Unit/AirPollution/AirPollutionTest.php b/tests/Unit/AirPollution/AirPollutionTest.php deleted file mode 100644 index 76902f7..0000000 --- a/tests/Unit/AirPollution/AirPollutionTest.php +++ /dev/null @@ -1,51 +0,0 @@ - [ - 'lat' => 50, - 'lon' => 50 - ], - 'list' => [ - [ - 'dt' => 1715279409, - 'main' => [ - 'aqi' => 1 - ], - 'components' => [ - 'co' => 100, - 'no' => 0, - 'no2' => 1, - 'o3' => 100, - 'so2' => 1, - 'pm2_5' => 1, - 'pm10' => 1, - 'nh3' => 1 - ] - ] - ] - ]); - - $this->assertInstanceOf(Coordinate::class, $entity->getCoordinate()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertInstanceOf(AirQuality::class, $entity->getAirQuality()); - $this->assertSame(100.0, $entity->getCarbonMonoxide()); - $this->assertSame(0.0, $entity->getNitrogenMonoxide()); - $this->assertSame(1.0, $entity->getNitrogenDioxide()); - $this->assertSame(100.0, $entity->getOzone()); - $this->assertSame(1.0, $entity->getSulphurDioxide()); - $this->assertSame(1.0, $entity->getFineParticulateMatter()); - $this->assertSame(1.0, $entity->getCoarseParticulateMatter()); - $this->assertSame(1.0, $entity->getAmmonia()); - } -} \ No newline at end of file diff --git a/tests/Unit/AirPollution/AirQualityTest.php b/tests/Unit/AirPollution/AirQualityTest.php deleted file mode 100644 index cae7f29..0000000 --- a/tests/Unit/AirPollution/AirQualityTest.php +++ /dev/null @@ -1,19 +0,0 @@ - 1 - ]); - - $this->assertSame(1, $entity->getIndex()); - $this->assertSame('Good', $entity->getQualitativeName()); - } -} \ No newline at end of file diff --git a/tests/Unit/Assistant/AnswerTest.php b/tests/Unit/Assistant/AnswerTest.php deleted file mode 100644 index 980c527..0000000 --- a/tests/Unit/Assistant/AnswerTest.php +++ /dev/null @@ -1,48 +0,0 @@ - 'Answer text', - 'data' => [ - 'location' => [ - 'clouds' => 100, - 'dew_point' => 10, - 'dt' => 1762622549, - 'feels_like' => 10, - 'humidity' => 10, - 'pressure' => 1000, - 'sunrise' => 1762602543, - 'sunset' => 1762638067, - 'temp' => 10, - 'uvi' => 1, - 'visibility' => 10000, - 'weather' => [ - [ - 'description' => 'description', - 'icon' => '01d', - 'id' => 200, - 'main' => 'name' - ] - ], - 'wind_deg' => 10, - 'wind_speed' => 10, - 'wind_gust' => 10, - ] - ], - 'session_id' => '777f049a-c96b-423f-baef-ca34ff725fe9' - ]); - - $this->assertSame('Answer text', $entity->getAnswer()); - $this->assertSame('777f049a-c96b-423f-baef-ca34ff725fe9', $entity->getSessionId()); - $this->assertContainsOnlyInstancesOf(WeatherData::class, $entity->getData()); - } -} \ No newline at end of file diff --git a/tests/Unit/Assistant/WeatherDataTest.php b/tests/Unit/Assistant/WeatherDataTest.php deleted file mode 100644 index 080b3aa..0000000 --- a/tests/Unit/Assistant/WeatherDataTest.php +++ /dev/null @@ -1,54 +0,0 @@ - 100, - 'dew_point' => 10, - 'dt' => 1762622549, - 'feels_like' => 10, - 'humidity' => 10, - 'pressure' => 1000, - 'sunrise' => 1762602543, - 'sunset' => 1762638067, - 'temp' => 10, - 'uvi' => 1, - 'visibility' => 10000, - 'weather' => [ - [ - 'description' => 'description', - 'icon' => '01d', - 'id' => 200, - 'main' => 'name' - ] - ], - 'wind_deg' => 10, - 'wind_speed' => 10, - 'wind_gust' => 10, - ]); - - $this->assertSame('locationName', $entity->getLocationName()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame(1000, $entity->getAtmosphericPressure()); - $this->assertSame(10, $entity->getHumidity()); - $this->assertSame(10.0, $entity->getDewPoint()); - $this->assertSame(1.0, $entity->getUltraVioletIndex()); - $this->assertSame(100, $entity->getCloudiness()); - $this->assertInstanceOf(Wind::class, $entity->getWind()); - $this->assertContainsOnlyInstancesOf(Condition::class, $entity->getConditions()); - $this->assertSame(10.0, $entity->getTemperature()); - $this->assertSame(10.0, $entity->getTemperatureFeelsLike()); - $this->assertSame(10000, $entity->getVisibility()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunriseAt()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunsetAt()); - } -} \ No newline at end of file diff --git a/tests/Unit/ConditionTest.php b/tests/Unit/ConditionTest.php deleted file mode 100644 index 2aa2c7f..0000000 --- a/tests/Unit/ConditionTest.php +++ /dev/null @@ -1,26 +0,0 @@ - 200, - 'main' => 'name', - 'description' => 'description', - 'icon' => '01d' - ]); - - $this->assertSame(200, $entity->getId()); - $this->assertSame('name', $entity->getName()); - $this->assertSame('description', $entity->getDescription()); - $this->assertInstanceOf(Icon::class, $entity->getIcon()); - $this->assertSame('THUNDERSTORM', $entity->getSystemName()); - } -} \ No newline at end of file diff --git a/tests/Unit/CoordinateTest.php b/tests/Unit/CoordinateTest.php deleted file mode 100644 index 8ef62b9..0000000 --- a/tests/Unit/CoordinateTest.php +++ /dev/null @@ -1,20 +0,0 @@ - 50, - 'lon' => 50 - ]); - - $this->assertSame(50.0, $entity->getLatitude()); - $this->assertSame(50.0, $entity->getLongitude()); - } -} \ No newline at end of file diff --git a/tests/Unit/Entity/AirPollution/AirQualityTest.php b/tests/Unit/Entity/AirPollution/AirQualityTest.php new file mode 100644 index 0000000..b2f83e3 --- /dev/null +++ b/tests/Unit/Entity/AirPollution/AirQualityTest.php @@ -0,0 +1,124 @@ +airQualityIndex()); + + $components = $airQuality->components(); + + self::assertSame(96.56, $components?->carbonMonoxide()); + self::assertSame('96.56 µg/m³', $components?->carbonMonoxideWithUnit()); + self::assertSame(0.01, $components?->nitrogenMonoxide()); + self::assertSame('0.01 µg/m³', $components?->nitrogenMonoxideWithUnit()); + self::assertSame(7.17, $components?->nitrogenDioxide()); + self::assertSame('7.17 µg/m³', $components?->nitrogenDioxideWithUnit()); + self::assertSame(29.69, $components?->ozone()); + self::assertSame('29.69 µg/m³', $components?->ozoneWithUnit()); + self::assertSame(1.03, $components?->sulphurDioxide()); + self::assertSame('1.03 µg/m³', $components?->sulphurDioxideWithUnit()); + self::assertSame(5.89, $components?->fineParticulateMatter()); + self::assertSame('5.89 µg/m³', $components?->fineParticulateMatterWithUnit()); + self::assertSame(7.62, $components?->coarseParticulateMatter()); + self::assertSame('7.62 µg/m³', $components?->coarseParticulateMatterWithUnit()); + self::assertSame(0.65, $components?->ammonia()); + self::assertSame('0.65 µg/m³', $components?->ammoniaWithUnit()); + self::assertSame( + Unit::MICROGRAMS_PER_CUBIC_METER, + $components?->fineParticulateMatterUnit(), + ); + } + + #[DataProvider('airQualityIndexes')] + public function testHydratesEveryDocumentedAirQualityIndex( + int $value, + AirQualityIndex $expected, + ): void { + $airQuality = AirQuality::fromArray([ + 'main' => ['aqi' => $value], + ]); + + self::assertSame($expected, $airQuality->airQualityIndex()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = AirQuality::fromArray([]); + + self::assertNull($missing->airQualityIndex()); + self::assertNull($missing->components()); + + $airQuality = AirQuality::fromArray([ + 'main' => ['aqi' => null, 'unknown' => true], + 'components' => [ + 'co' => null, + 'unknown' => new \stdClass(), + ], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($airQuality->airQualityIndex()); + self::assertNull($airQuality->components()?->carbonMonoxide()); + self::assertNull($airQuality->components()?->nitrogenMonoxide()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + AirQuality::fromArray($data); + } + + public static function airQualityIndexes(): iterable + { + yield 'good' => [1, AirQualityIndex::GOOD]; + yield 'fair' => [2, AirQualityIndex::FAIR]; + yield 'moderate' => [3, AirQualityIndex::MODERATE]; + yield 'poor' => [4, AirQualityIndex::POOR]; + yield 'very poor' => [5, AirQualityIndex::VERY_POOR]; + } + + public static function invalidFields(): iterable + { + yield 'main' => [ + ['main' => 'invalid'], + '"main" expected array, string received.', + ]; + yield 'AQI type' => [ + ['main' => ['aqi' => '1']], + '"main.aqi" expected int, string received.', + ]; + yield 'AQI float' => [ + ['main' => ['aqi' => 1.0]], + '"main.aqi" expected int, float received.', + ]; + yield 'unsupported AQI' => [ + ['main' => ['aqi' => 6]], + '"main.aqi" expected an integer from 1 through 5, "6" received.', + ]; + yield 'components' => [ + ['components' => 'invalid'], + '"components" expected array, string received.', + ]; + yield 'component concentration' => [ + ['components' => ['co' => '96.56']], + '"co" expected int|float, string received.', + ]; + } +} diff --git a/tests/Unit/Entity/AirPollution/CurrentTest.php b/tests/Unit/Entity/AirPollution/CurrentTest.php new file mode 100644 index 0000000..a43c406 --- /dev/null +++ b/tests/Unit/Entity/AirPollution/CurrentTest.php @@ -0,0 +1,96 @@ +coordinates()?->latitude()); + self::assertSame(151.2073, $current->coordinates()?->longitude()); + self::assertSame(1785616883, $current->dateTime()?->getTimestamp()); + self::assertSame('UTC', $current->dateTime()?->getTimezone()->getName()); + self::assertSame(AirQualityIndex::GOOD, $current->airQualityIndex()); + + self::assertSame(96.56, $current->components()?->carbonMonoxide()); + self::assertSame('96.56 µg/m³', $current->components()?->carbonMonoxideWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Current::fromArray([]); + + self::assertNull($missing->coordinates()); + self::assertNull($missing->dateTime()); + self::assertNull($missing->airQualityIndex()); + self::assertNull($missing->components()); + + $current = Current::fromArray([ + 'coord' => ['lat' => null, 'unknown' => true], + 'list' => [[ + 'dt' => null, + 'main' => ['aqi' => null, 'unknown' => true], + 'components' => [ + 'co' => null, + 'unknown' => new \stdClass(), + ], + 'unknown' => new \stdClass(), + ]], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($current->coordinates()?->latitude()); + self::assertNull($current->coordinates()?->longitude()); + self::assertNull($current->dateTime()); + self::assertNull($current->airQualityIndex()); + self::assertNull($current->components()?->carbonMonoxide()); + self::assertNull($current->components()?->nitrogenMonoxide()); + + self::assertNull(Current::fromArray(['list' => null])->dateTime()); + self::assertNull(Current::fromArray(['list' => []])->dateTime()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Current::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'coordinates' => [ + ['coord' => 'invalid'], + '"coord" expected array, string received.', + ]; + yield 'latitude' => [ + ['coord' => ['lat' => '-33.8']], + '"lat" expected int|float, string received.', + ]; + yield 'list' => [ + ['list' => 'invalid'], + '"list" expected array, string received.', + ]; + yield 'list member' => [ + ['list' => ['invalid']], + '"list.0" expected array, string received.', + ]; + yield 'date and time' => [ + ['list' => [['dt' => '1785616883']]], + '"list.0.dt" expected int, string received.', + ]; + } +} diff --git a/tests/Unit/Entity/AirPollution/Forecast/PeriodTest.php b/tests/Unit/Entity/AirPollution/Forecast/PeriodTest.php new file mode 100644 index 0000000..8555fdd --- /dev/null +++ b/tests/Unit/Entity/AirPollution/Forecast/PeriodTest.php @@ -0,0 +1,57 @@ +dateTime()?->getTimestamp()); + self::assertSame('UTC', $period->dateTime()?->getTimezone()->getName()); + self::assertSame(AirQualityIndex::MODERATE, $period->airQualityIndex()); + self::assertSame(414.72, $period->components()?->carbonMonoxide()); + self::assertSame(36.74, $period->components()?->fineParticulateMatter()); + self::assertSame('36.74 µg/m³', $period->components()?->fineParticulateMatterWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Period::fromArray([]); + + self::assertNull($missing->dateTime()); + self::assertNull($missing->airQualityIndex()); + self::assertNull($missing->components()); + + $period = Period::fromArray([ + 'dt' => null, + 'main' => ['aqi' => null, 'unknown' => true], + 'components' => [ + 'co' => null, + 'unknown' => new \stdClass(), + ], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($period->dateTime()); + self::assertNull($period->airQualityIndex()); + self::assertNull($period->components()?->carbonMonoxide()); + self::assertNull($period->components()?->nitrogenDioxide()); + } + + public function testRejectsInvalidForecastTime(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage('"dt" expected int, string received.'); + + Period::fromArray(['dt' => '1785614400']); + } +} diff --git a/tests/Unit/Entity/AirPollution/ForecastTest.php b/tests/Unit/Entity/AirPollution/ForecastTest.php new file mode 100644 index 0000000..f3bf127 --- /dev/null +++ b/tests/Unit/Entity/AirPollution/ForecastTest.php @@ -0,0 +1,116 @@ +coordinates()?->latitude()); + self::assertSame($longitude, $forecast->coordinates()?->longitude()); + self::assertCount(96, $forecast->periods()); + self::assertContainsOnlyInstancesOf(Period::class, $forecast->periods()); + self::assertSame(1785614400, $forecast->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame(1785956400, $forecast->periods()[95]->dateTime()?->getTimestamp()); + + $actualAirQualityIndexes = array_values(array_unique(array_map( + static fn (Period $period): ?int => $period->airQualityIndex()?->value, + $forecast->periods(), + ))); + sort($actualAirQualityIndexes); + + self::assertSame($airQualityIndexes, $actualAirQualityIndexes); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Forecast::fromArray([]); + + self::assertNull($missing->coordinates()); + self::assertSame([], $missing->periods()); + + $forecast = Forecast::fromArray([ + 'coord' => [ + 'lat' => null, + 'unknown' => new \stdClass(), + ], + 'list' => [ + [], + ['dt' => null, 'unknown' => new \stdClass()], + ], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($forecast->coordinates()?->latitude()); + self::assertNull($forecast->coordinates()?->longitude()); + self::assertCount(2, $forecast->periods()); + self::assertNull($forecast->periods()[0]->dateTime()); + self::assertNull($forecast->periods()[1]->airQualityIndex()); + + self::assertSame([], Forecast::fromArray(['list' => null])->periods()); + self::assertNull(Forecast::fromArray(['coord' => null])->coordinates()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Forecast::fromArray($data); + } + + public static function capturedForecasts(): iterable + { + yield 'good to moderate' => [ + 'air-pollution/forecast/good-to-moderate.json', + 28.6139, + 77.209, + [1, 2, 3], + ]; + yield 'moderate to very poor' => [ + 'air-pollution/forecast/moderate-to-very-poor.json', + 39.9042, + 116.4074, + [3, 4, 5], + ]; + } + + public static function invalidFields(): iterable + { + yield 'coordinates' => [ + ['coord' => 'invalid'], + '"coord" expected array, string received.', + ]; + yield 'latitude' => [ + ['coord' => ['lat' => '28.6']], + '"lat" expected int|float, string received.', + ]; + yield 'periods' => [ + ['list' => 'invalid'], + '"list" expected array, string received.', + ]; + yield 'period member' => [ + ['list' => ['invalid']], + '"list.0" expected array, string received.', + ]; + yield 'period field' => [ + ['list' => [['main' => 'invalid']]], + '"main" expected array, string received.', + ]; + } +} diff --git a/tests/Unit/Entity/AirPollution/History/PeriodTest.php b/tests/Unit/Entity/AirPollution/History/PeriodTest.php new file mode 100644 index 0000000..8f80554 --- /dev/null +++ b/tests/Unit/Entity/AirPollution/History/PeriodTest.php @@ -0,0 +1,57 @@ +dateTime()?->getTimestamp()); + self::assertSame('UTC', $period->dateTime()?->getTimezone()->getName()); + self::assertSame(AirQualityIndex::FAIR, $period->airQualityIndex()); + self::assertSame(80.71, $period->components()?->carbonMonoxide()); + self::assertSame(6.86, $period->components()?->fineParticulateMatter()); + self::assertSame('6.86 µg/m³', $period->components()?->fineParticulateMatterWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Period::fromArray([]); + + self::assertNull($missing->dateTime()); + self::assertNull($missing->airQualityIndex()); + self::assertNull($missing->components()); + + $period = Period::fromArray([ + 'dt' => null, + 'main' => ['aqi' => null, 'unknown' => true], + 'components' => [ + 'co' => null, + 'unknown' => new \stdClass(), + ], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($period->dateTime()); + self::assertNull($period->airQualityIndex()); + self::assertNull($period->components()?->carbonMonoxide()); + self::assertNull($period->components()?->nitrogenDioxide()); + } + + public function testRejectsInvalidObservationTime(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage('"dt" expected int, string received.'); + + Period::fromArray(['dt' => '1782864000']); + } +} diff --git a/tests/Unit/Entity/AirPollution/HistoryTest.php b/tests/Unit/Entity/AirPollution/HistoryTest.php new file mode 100644 index 0000000..c3e559c --- /dev/null +++ b/tests/Unit/Entity/AirPollution/HistoryTest.php @@ -0,0 +1,103 @@ +coordinates()?->latitude()); + self::assertSame(-9.1393, $history->coordinates()?->longitude()); + self::assertCount(25, $history->periods()); + self::assertContainsOnlyInstancesOf(Period::class, $history->periods()); + self::assertSame(1782864000, $history->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame(1782950400, $history->periods()[24]->dateTime()?->getTimestamp()); + self::assertSame(AirQualityIndex::FAIR, $history->periods()[0]->airQualityIndex()); + self::assertSame(80.71, $history->periods()[0]->components()?->carbonMonoxide()); + } + + public function testHydratesCapturedEmptyHistory(): void + { + $history = History::fromArray( + Fixture::json('air-pollution/history/empty.json'), + ); + + self::assertSame(38.7223, $history->coordinates()?->latitude()); + self::assertSame(-9.1393, $history->coordinates()?->longitude()); + self::assertSame([], $history->periods()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = History::fromArray([]); + + self::assertNull($missing->coordinates()); + self::assertSame([], $missing->periods()); + + $history = History::fromArray([ + 'coord' => [ + 'lat' => null, + 'unknown' => new \stdClass(), + ], + 'list' => [ + [], + ['dt' => null, 'unknown' => new \stdClass()], + ], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($history->coordinates()?->latitude()); + self::assertNull($history->coordinates()?->longitude()); + self::assertCount(2, $history->periods()); + self::assertNull($history->periods()[0]->dateTime()); + self::assertNull($history->periods()[1]->airQualityIndex()); + + self::assertSame([], History::fromArray(['list' => null])->periods()); + self::assertNull(History::fromArray(['coord' => null])->coordinates()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + History::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'coordinates' => [ + ['coord' => 'invalid'], + '"coord" expected array, string received.', + ]; + yield 'latitude' => [ + ['coord' => ['lat' => '38.7']], + '"lat" expected int|float, string received.', + ]; + yield 'periods' => [ + ['list' => 'invalid'], + '"list" expected array, string received.', + ]; + yield 'period member' => [ + ['list' => ['invalid']], + '"list.0" expected array, string received.', + ]; + yield 'period field' => [ + ['list' => [['main' => 'invalid']]], + '"main" expected array, string received.', + ]; + } +} diff --git a/tests/Unit/Entity/Geocoding/LocationTest.php b/tests/Unit/Entity/Geocoding/LocationTest.php new file mode 100644 index 0000000..e7ea4e4 --- /dev/null +++ b/tests/Unit/Entity/Geocoding/LocationTest.php @@ -0,0 +1,88 @@ +name()); + self::assertSame('Springfield', $location->localName('en')); + self::assertSame('ஸ்பிரிங்ஃபீல்ட்', $location->localName('ta')); + self::assertNull($location->localName('pt')); + self::assertSame(6, count($location->localNames())); + self::assertSame(39.7990175, $location->latitude()); + self::assertSame(-89.6439575, $location->longitude()); + self::assertSame('US', $location->countryCode()); + self::assertSame('Illinois', $location->state()); + } + + public function testHydratesCapturedReverseLocation(): void + { + $data = Fixture::json('geocoding/reverse/success.json'); + $location = Location::fromArray($data[0]); + + self::assertSame('New York County', $location->name()); + self::assertSame('Nova Iorque', $location->localName('pt')); + self::assertSame(40.7127281, $location->latitude()); + self::assertSame(-74.0060152, $location->longitude()); + self::assertSame('US', $location->countryCode()); + self::assertSame('New York', $location->state()); + } + + public function testToleratesMissingNullAndUnknownFields(): void + { + $location = Location::fromArray([ + 'name' => null, + 'local_names' => null, + 'lat' => null, + 'lon' => -9.1, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($location->name()); + self::assertSame([], $location->localNames()); + self::assertNull($location->latitude()); + self::assertSame(-9.1, $location->longitude()); + self::assertNull($location->countryCode()); + self::assertNull($location->state()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFieldTypes( + array $data, + string $path, + string $expectedType, + string $receivedType, + ): void { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected %s, %s received.', + $path, + $expectedType, + $receivedType, + )); + + Location::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'name' => [['name' => 1], 'name', 'string', 'int']; + yield 'local names' => [['local_names' => 'English'], 'local_names', 'array', 'string']; + yield 'local name' => [['local_names' => ['en' => 1]], 'local_names.en', 'string', 'int']; + yield 'latitude' => [['lat' => '39.7'], 'lat', 'int|float', 'string']; + yield 'longitude' => [['lon' => '-89.6'], 'lon', 'int|float', 'string']; + yield 'country' => [['country' => 1], 'country', 'string', 'int']; + yield 'state' => [['state' => 1], 'state', 'string', 'int']; + } +} diff --git a/tests/Unit/Entity/Geocoding/PostalLocationTest.php b/tests/Unit/Entity/Geocoding/PostalLocationTest.php new file mode 100644 index 0000000..ca29506 --- /dev/null +++ b/tests/Unit/Entity/Geocoding/PostalLocationTest.php @@ -0,0 +1,68 @@ +postalCode()); + self::assertSame('Lisbon', $location->name()); + self::assertSame(38.7167, $location->latitude()); + self::assertSame(-9.1333, $location->longitude()); + self::assertSame('PT', $location->countryCode()); + } + + public function testToleratesMissingNullAndUnknownFields(): void + { + $location = PostalLocation::fromArray([ + 'zip' => null, + 'name' => null, + 'lat' => null, + 'lon' => -9.1, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($location->postalCode()); + self::assertNull($location->name()); + self::assertNull($location->latitude()); + self::assertSame(-9.1, $location->longitude()); + self::assertNull($location->countryCode()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFieldTypes( + array $data, + string $path, + string $expectedType, + string $receivedType, + ): void { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected %s, %s received.', + $path, + $expectedType, + $receivedType, + )); + + PostalLocation::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'postal code' => [['zip' => 1000], 'zip', 'string', 'int']; + yield 'name' => [['name' => 1], 'name', 'string', 'int']; + yield 'latitude' => [['lat' => '38.7'], 'lat', 'int|float', 'string']; + yield 'longitude' => [['lon' => '-9.1'], 'lon', 'int|float', 'string']; + yield 'country' => [['country' => 1], 'country', 'string', 'int']; + } +} diff --git a/tests/Unit/Entity/OneCall/Alert/LocalizedDescriptionTest.php b/tests/Unit/Entity/OneCall/Alert/LocalizedDescriptionTest.php new file mode 100644 index 0000000..a6e93bf --- /dev/null +++ b/tests/Unit/Entity/OneCall/Alert/LocalizedDescriptionTest.php @@ -0,0 +1,60 @@ + 'es-CL', + 'description' => 'Precipitaciones moderadas', + ]); + + self::assertSame('es-CL', $description->languageCode()); + self::assertSame('Precipitaciones moderadas', $description->text()); + } + + public function testToleratesMissingNullUnknownAndOpenLanguageValues(): void + { + $missing = LocalizedDescription::fromArray([]); + + self::assertNull($missing->languageCode()); + self::assertNull($missing->text()); + + $description = LocalizedDescription::fromArray([ + 'language' => 'x-agency-local', + 'description' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertSame('x-agency-local', $description->languageCode()); + self::assertNull($description->text()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + LocalizedDescription::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'language' => [ + ['language' => 1], + '"language" expected string, int received.', + ]; + yield 'text' => [ + ['description' => []], + '"description" expected string, array received.', + ]; + } +} diff --git a/tests/Unit/Entity/OneCall/AlertTest.php b/tests/Unit/Entity/OneCall/AlertTest.php new file mode 100644 index 0000000..6e30937 --- /dev/null +++ b/tests/Unit/Entity/OneCall/AlertTest.php @@ -0,0 +1,199 @@ +id(), + ); + self::assertSame('Dirección Meteorológica de Chile', $alert->senderName()); + self::assertSame('', $alert->event()); + self::assertSame(1785578400, $alert->startsAt()?->getTimestamp()); + self::assertSame('UTC', $alert->startsAt()?->getTimezone()->getName()); + self::assertSame(1785708000, $alert->endsAt()?->getTimestamp()); + self::assertCount(1, $alert->descriptions()); + self::assertContainsOnlyInstancesOf( + LocalizedDescription::class, + $alert->descriptions(), + ); + self::assertSame('es-CL', $alert->descriptions()[0]->languageCode()); + self::assertSame( + 'Precipitaciones Normales a Moderadas en zonas de las regiones de ' + .'La Araucanía, Los Ríos y Los Lagos', + $alert->descriptions()[0]->text(), + ); + self::assertSame( + $alert->descriptions()[0]->text(), + $alert->description('es-CL'), + ); + self::assertNull($alert->description('en-US')); + self::assertSame(['Rain'], $alert->tags()); + } + + #[DataProvider('capturedAlerts')] + public function testHydratesOtherCapturedLocalizedAlerts( + string $fixture, + string $senderName, + string $language, + string $tag, + ): void { + $alert = Alert::fromArray(Fixture::json($fixture)); + + self::assertSame($senderName, $alert->senderName()); + self::assertSame('', $alert->event()); + self::assertSame($language, $alert->descriptions()[0]->languageCode()); + self::assertNotSame('', $alert->descriptions()[0]->text()); + self::assertSame([$tag], $alert->tags()); + } + + public function testReturnsFirstDescriptionMatchingLanguage(): void + { + $alert = Alert::fromArray([ + 'description' => [ + ['language' => 'en-US', 'description' => 'First description'], + ['language' => 'pt-PT', 'description' => 'Descrição'], + ['language' => 'en-US', 'description' => 'Second description'], + ], + ]); + + self::assertSame('First description', $alert->description('en-US')); + self::assertSame('Descrição', $alert->description('pt-PT')); + self::assertNull($alert->description('es-ES')); + } + + public static function capturedAlerts(): iterable + { + yield 'Houston air quality' => [ + 'one-call/alert/houston-air-quality.json', + 'NWS Houston/Galveston TX', + 'en-US', + 'Air quality', + ]; + yield 'Phoenix extreme heat' => [ + 'one-call/alert/phoenix-extreme-heat.json', + 'NWS Phoenix AZ', + 'en-US', + 'Extreme high temperature', + ]; + yield 'Tokyo thunderstorm' => [ + 'one-call/alert/tokyo-thunderstorm.json', + 'JMA', + 'ja-JP', + 'Thunderstorm', + ]; + } + + public function testNormalizesDocumentedStringDescription(): void + { + $alert = Alert::fromArray([ + 'description' => 'Documented alert description', + ]); + + self::assertCount(1, $alert->descriptions()); + self::assertNull($alert->descriptions()[0]->languageCode()); + self::assertSame('Documented alert description', $alert->descriptions()[0]->text()); + self::assertNull($alert->description('en-US')); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Alert::fromArray([]); + + self::assertNull($missing->id()); + self::assertNull($missing->senderName()); + self::assertNull($missing->event()); + self::assertNull($missing->startsAt()); + self::assertNull($missing->endsAt()); + self::assertSame([], $missing->descriptions()); + self::assertSame([], $missing->tags()); + + $alert = Alert::fromArray([ + 'id' => null, + 'sender_name' => null, + 'event' => '', + 'start' => null, + 'description' => [ + ['language' => null, 'description' => null, 'unknown' => true], + ], + 'tags' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($alert->id()); + self::assertSame('', $alert->event()); + self::assertCount(1, $alert->descriptions()); + self::assertNull($alert->descriptions()[0]->languageCode()); + self::assertNull($alert->descriptions()[0]->text()); + self::assertSame([], $alert->tags()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Alert::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'ID' => [ + ['id' => 1], + '"id" expected string, int received.', + ]; + yield 'sender name' => [ + ['sender_name' => []], + '"sender_name" expected string, array received.', + ]; + yield 'event' => [ + ['event' => 1], + '"event" expected string, int received.', + ]; + yield 'start' => [ + ['start' => '1785578400'], + '"start" expected int, string received.', + ]; + yield 'end' => [ + ['end' => 1785708000.5], + '"end" expected int, float received.', + ]; + yield 'description shape' => [ + ['description' => 1], + '"description" expected string|array, int received.', + ]; + yield 'description member' => [ + ['description' => ['invalid']], + '"description.0" expected array, string received.', + ]; + yield 'description language' => [ + ['description' => [['language' => 1]]], + '"language" expected string, int received.', + ]; + yield 'description text' => [ + ['description' => [['description' => []]]], + '"description" expected string, array received.', + ]; + yield 'tags' => [ + ['tags' => 'Rain'], + '"tags" expected array, string received.', + ]; + yield 'tag member' => [ + ['tags' => [1]], + '"tags.0" expected string, int received.', + ]; + } +} diff --git a/tests/Unit/Entity/OneCall/CurrentTest.php b/tests/Unit/Entity/OneCall/CurrentTest.php new file mode 100644 index 0000000..ce5001a --- /dev/null +++ b/tests/Unit/Entity/OneCall/CurrentTest.php @@ -0,0 +1,250 @@ +coordinates()?->latitude()); + self::assertSame(-9.1393, $current->coordinates()?->longitude()); + self::assertSame('Europe/Lisbon', $current->timezone()?->identifier()); + self::assertSame(3600, $current->timezone()?->offsetSeconds()); + self::assertSame(1785668004, $current->dateTime()?->getTimestamp()); + self::assertSame('UTC', $current->dateTime()?->getTimezone()->getName()); + self::assertSame(1785649113, $current->sunriseAt()?->getTimestamp()); + self::assertSame(1785700020, $current->sunsetAt()?->getTimestamp()); + + self::assertSame(24.34, $current->temperature()); + self::assertSame(Unit::CELSIUS, $current->temperatureUnit()); + self::assertSame('24.34 °C', $current->temperatureWithUnit()); + self::assertSame(24.68, $current->feelsLikeTemperature()); + self::assertSame('24.68 °C', $current->feelsLikeTemperatureWithUnit()); + self::assertSame(1016, $current->pressure()); + self::assertSame(Unit::HECTOPASCAL, $current->pressureUnit()); + self::assertSame('1016 hPa', $current->pressureWithUnit()); + self::assertSame(71, $current->humidity()); + self::assertSame(Unit::PERCENT, $current->humidityUnit()); + self::assertSame('71 %', $current->humidityWithUnit()); + self::assertSame(18.75, $current->dewPoint()); + self::assertSame(Unit::CELSIUS, $current->dewPointUnit()); + self::assertSame('18.75 °C', $current->dewPointWithUnit()); + self::assertSame(7.53, $current->ultravioletIndex()); + self::assertSame(40, $current->clouds()?->coverage()); + self::assertSame(Unit::PERCENT, $current->clouds()?->coverageUnit()); + self::assertSame('40 %', $current->clouds()?->coverageWithUnit()); + self::assertSame(10000, $current->visibility()); + self::assertSame(Unit::METER, $current->visibilityUnit()); + self::assertSame('10000 m', $current->visibilityWithUnit()); + self::assertSame(2.24, $current->wind()?->speed()); + self::assertSame(Unit::METERS_PER_SECOND, $current->wind()?->speedUnit()); + self::assertSame('2.24 m/s', $current->wind()?->speedWithUnit()); + self::assertSame(293, $current->wind()?->direction()); + self::assertSame(Unit::DEGREE, $current->wind()?->directionUnit()); + self::assertSame('293 °', $current->wind()?->directionWithUnit()); + self::assertSame(5.36, $current->wind()?->gust()); + self::assertSame(Unit::METERS_PER_SECOND, $current->wind()?->gustUnit()); + self::assertSame('5.36 m/s', $current->wind()?->gustWithUnit()); + + self::assertCount(1, $current->conditions()); + self::assertSame(802, $current->conditions()[0]->id()); + self::assertSame('Clouds', $current->conditions()[0]->group()); + self::assertSame('scattered clouds', $current->conditions()[0]->description()); + self::assertSame('03d', $current->conditions()[0]->icon()); + self::assertSame( + 'https://openweathermap.org/img/wn/03d@2x.png', + $current->conditions()[0]->iconUrl(), + ); + self::assertNull($current->rain()); + self::assertNull($current->snow()); + self::assertSame([], $current->alertIds()); + } + + public function testHydratesConditionalRain(): void + { + $current = Current::fromArray( + Fixture::json('one-call/current/rain.json'), + ); + + self::assertSame('Rain', $current->conditions()[0]->group()); + self::assertSame(0.13, $current->rain()?->lastHour()); + self::assertSame(Unit::MILLIMETERS_PER_HOUR, $current->rain()?->lastHourUnit()); + self::assertSame('0.13 mm/h', $current->rain()?->lastHourWithUnit()); + self::assertNull($current->snow()); + self::assertSame([], $current->alertIds()); + } + + public function testHydratesConditionalSnowAndAlertIds(): void + { + $current = Current::fromArray( + Fixture::json('one-call/current/snow.json'), + ); + + self::assertSame('Snow', $current->conditions()[0]->group()); + self::assertSame(4.86, $current->snow()?->lastHour()); + self::assertSame('4.86 mm/h', $current->snow()?->lastHourWithUnit()); + self::assertNull($current->rain()); + self::assertSame([ + 'urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0', + ], $current->alertIds()); + } + + public function testRetainsUnitsFromHydrationContext(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + + $current = Current::fromArray([ + 'data' => [[ + 'temp' => 72.5, + 'feels_like' => 71, + 'dew_point' => 60, + 'wind_speed' => 10, + 'wind_gust' => 15, + ]], + ], $context); + + self::assertSame(Unit::FAHRENHEIT, $current->temperatureUnit()); + self::assertSame('72.5 °F', $current->temperatureWithUnit()); + self::assertSame('71 °F', $current->feelsLikeTemperatureWithUnit()); + self::assertSame('60 °F', $current->dewPointWithUnit()); + self::assertSame(Unit::MILES_PER_HOUR, $current->wind()?->speedUnit()); + self::assertSame('10 mph', $current->wind()?->speedWithUnit()); + self::assertSame('15 mph', $current->wind()?->gustWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Current::fromArray([]); + + self::assertNull($missing->coordinates()); + self::assertNull($missing->timezone()); + self::assertNull($missing->dateTime()); + self::assertNull($missing->temperature()); + self::assertNull($missing->temperatureWithUnit()); + self::assertNull($missing->dewPoint()); + self::assertNull($missing->ultravioletIndex()); + self::assertNull($missing->wind()); + self::assertNull($missing->clouds()); + self::assertSame([], $missing->conditions()); + self::assertNull($missing->rain()); + self::assertNull($missing->snow()); + self::assertSame([], $missing->alertIds()); + + $current = Current::fromArray([ + 'lat' => null, + 'timezone' => null, + 'data' => [[ + 'dt' => null, + 'temp' => null, + 'weather' => [['icon' => null, 'unknown' => true]], + 'rain' => ['1h' => null, 'unknown' => true], + 'snow' => null, + 'alerts' => null, + 'unknown' => new \stdClass(), + ]], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($current->coordinates()?->latitude()); + self::assertNull($current->coordinates()?->longitude()); + self::assertNull($current->timezone()?->identifier()); + self::assertNull($current->timezone()?->offsetSeconds()); + self::assertCount(1, $current->conditions()); + self::assertNull($current->conditions()[0]->icon()); + self::assertNull($current->temperature()); + self::assertNull($current->wind()); + self::assertNull($current->clouds()); + self::assertNull($current->rain()?->lastHour()); + self::assertNull($current->snow()); + self::assertSame([], $current->alertIds()); + + self::assertNull(Current::fromArray(['data' => null])->dateTime()); + self::assertNull(Current::fromArray(['data' => []])->dateTime()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Current::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'latitude' => [ + ['lat' => '38.7'], + '"lat" expected int|float, string received.', + ]; + yield 'timezone' => [ + ['timezone' => 1], + '"timezone" expected string, int received.', + ]; + yield 'data' => [ + ['data' => 'invalid'], + '"data" expected array, string received.', + ]; + yield 'data member' => [ + ['data' => ['invalid']], + '"data.0" expected array, string received.', + ]; + yield 'observation time' => [ + ['data' => [['dt' => '1785668004']]], + '"data.0.dt" expected int, string received.', + ]; + yield 'temperature' => [ + ['data' => [['temp' => '24.34']]], + '"data.0.temp" expected int|float, string received.', + ]; + yield 'pressure' => [ + ['data' => [['pressure' => 1016.5]]], + '"data.0.pressure" expected int, float received.', + ]; + yield 'wind speed' => [ + ['data' => [['wind_speed' => '2.24']]], + '"data.0.wind_speed" expected int|float, string received.', + ]; + yield 'cloud coverage' => [ + ['data' => [['clouds' => 40.5]]], + '"data.0.clouds" expected int, float received.', + ]; + yield 'conditions' => [ + ['data' => [['weather' => 'Clouds']]], + '"data.0.weather" expected array, string received.', + ]; + yield 'condition member' => [ + ['data' => [['weather' => ['Clouds']]]], + '"data.0.weather.0" expected array, string received.', + ]; + yield 'rain' => [ + ['data' => [['rain' => 'invalid']]], + '"data.0.rain" expected array, string received.', + ]; + yield 'alert IDs' => [ + ['data' => [['alerts' => 'invalid']]], + '"data.0.alerts" expected array, string received.', + ]; + yield 'alert ID member' => [ + ['data' => [['alerts' => [123]]]], + '"data.0.alerts.0" expected string, int received.', + ]; + } +} diff --git a/tests/Unit/Entity/OneCall/DayTimeline/FeelsLikeTemperatureTest.php b/tests/Unit/Entity/OneCall/DayTimeline/FeelsLikeTemperatureTest.php new file mode 100644 index 0000000..ed2d034 --- /dev/null +++ b/tests/Unit/Entity/OneCall/DayTimeline/FeelsLikeTemperatureTest.php @@ -0,0 +1,86 @@ + 25.53, + 'night' => 19.4, + 'eve' => 24.82, + 'morn' => 18.74, + ]); + + self::assertSame(25.53, $temperature->day()); + self::assertSame(Unit::CELSIUS, $temperature->dayUnit()); + self::assertSame('25.53 °C', $temperature->dayWithUnit()); + self::assertSame(19.4, $temperature->night()); + self::assertSame('19.4 °C', $temperature->nightWithUnit()); + self::assertSame(24.82, $temperature->evening()); + self::assertSame('24.82 °C', $temperature->eveningWithUnit()); + self::assertSame(18.74, $temperature->morning()); + self::assertSame('18.74 °C', $temperature->morningWithUnit()); + } + + public function testRetainsUnitsFromHydrationContext(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + + $temperature = FeelsLikeTemperature::fromArray(['night' => 68], $context); + + self::assertSame(Unit::FAHRENHEIT, $temperature->nightUnit()); + self::assertSame('68 °F', $temperature->nightWithUnit()); + } + + public function testToleratesMissingNullAndUnknownFields(): void + { + $missing = FeelsLikeTemperature::fromArray([]); + + self::assertNull($missing->day()); + self::assertNull($missing->night()); + self::assertNull($missing->evening()); + self::assertNull($missing->morning()); + + $temperature = FeelsLikeTemperature::fromArray([ + 'day' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($temperature->day()); + self::assertNull($temperature->dayWithUnit()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(string $field): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected int|float, string received.', + $field, + )); + + FeelsLikeTemperature::fromArray([$field => 'invalid']); + } + + public static function invalidFields(): iterable + { + yield 'day' => ['day']; + yield 'night' => ['night']; + yield 'evening' => ['eve']; + yield 'morning' => ['morn']; + } +} diff --git a/tests/Unit/Entity/OneCall/DayTimeline/PeriodTest.php b/tests/Unit/Entity/OneCall/DayTimeline/PeriodTest.php new file mode 100644 index 0000000..64b5a74 --- /dev/null +++ b/tests/Unit/Entity/OneCall/DayTimeline/PeriodTest.php @@ -0,0 +1,241 @@ +dateTime()?->getTimestamp()); + self::assertSame('UTC', $period->dateTime()?->getTimezone()->getName()); + self::assertSame(1785649113, $period->sunriseAt()?->getTimestamp()); + self::assertSame(1785700020, $period->sunsetAt()?->getTimestamp()); + self::assertSame(1785706800, $period->moonriseAt()?->getTimestamp()); + self::assertSame(1785662640, $period->moonsetAt()?->getTimestamp()); + self::assertSame(0.62, $period->moonPhase()); + + self::assertSame(25.53, $period->temperature()?->day()); + self::assertSame(18.73, $period->temperature()?->minimum()); + self::assertSame(26.99, $period->temperature()?->maximum()); + self::assertSame(19.4, $period->temperature()?->night()); + self::assertSame(24.82, $period->temperature()?->evening()); + self::assertSame(18.74, $period->temperature()?->morning()); + self::assertSame(25.53, $period->feelsLikeTemperature()?->day()); + self::assertSame(19.4, $period->feelsLikeTemperature()?->night()); + + self::assertSame(1015.44, $period->pressure()); + self::assertSame(Unit::HECTOPASCAL, $period->pressureUnit()); + self::assertSame('1015.44 hPa', $period->pressureWithUnit()); + self::assertSame(48, $period->humidity()); + self::assertNull($period->dewPoint()); + self::assertSame(0.0, $period->ultravioletIndex()); + self::assertNull($period->visibility()); + self::assertSame(6.17, $period->wind()?->speed()); + self::assertSame(307, $period->wind()?->direction()); + self::assertNull($period->wind()?->gust()); + self::assertSame(41, $period->clouds()?->coverage()); + self::assertSame(0.0, $period->precipitationProbability()); + self::assertSame(Unit::PERCENT, $period->precipitationProbabilityUnit()); + self::assertSame('0 %', $period->precipitationProbabilityWithUnit()); + self::assertSame('Clouds', $period->conditions()[0]->group()); + self::assertNull($period->rain()); + self::assertNull($period->snow()); + self::assertSame([], $period->alertIds()); + } + + public function testHydratesCapturedScalarRainAndSnow(): void + { + $lightRain = self::fromFixture('one-call/one-day/success.json', 1); + $rain = self::fromFixture('one-call/one-day/rain.json'); + $snow = self::fromFixture('one-call/one-day/snow.json'); + + self::assertSame(0.09, $lightRain->rain()); + self::assertSame('Rain', $rain->conditions()[0]->group()); + self::assertSame(17.92, $rain->rain()); + self::assertNull($rain->snow()); + self::assertSame('Snow', $snow->conditions()[0]->group()); + self::assertSame(60.98, $snow->snow()); + self::assertNull($snow->rain()); + } + + public function testHydratesHistoricalAndForecastPeriodsWithoutClassification(): void + { + $historical = self::fromFixture('one-call/one-day/history.json'); + $forecast = self::fromFixture('one-call/one-day/history.json', 2); + + self::assertSame(1785456000, $historical->dateTime()?->getTimestamp()); + self::assertNull($historical->precipitationProbability()); + self::assertNull($historical->precipitationProbabilityWithUnit()); + self::assertSame(1785628800, $forecast->dateTime()?->getTimestamp()); + self::assertSame(0.0, $forecast->precipitationProbability()); + } + + public function testHydratesDocumentedFieldsAbsentFromCapturedPeriods(): void + { + $period = Period::fromArray([ + 'dew_point' => 16.5, + 'visibility' => 10000, + 'wind_gust' => 8.2, + 'alerts' => ['alert-id'], + ]); + + self::assertSame(16.5, $period->dewPoint()); + self::assertSame('16.5 °C', $period->dewPointWithUnit()); + self::assertSame(10000, $period->visibility()); + self::assertSame('10000 m', $period->visibilityWithUnit()); + self::assertSame(8.2, $period->wind()?->gust()); + self::assertSame(['alert-id'], $period->alertIds()); + } + + public function testRetainsUnitsFromHydrationContext(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + + $period = Period::fromArray([ + 'temp' => ['day' => 72.5], + 'feels_like' => ['day' => 71], + 'dew_point' => 60, + 'wind_speed' => 10, + ], $context); + + self::assertSame(Unit::FAHRENHEIT, $period->temperature()?->dayUnit()); + self::assertSame('72.5 °F', $period->temperature()?->dayWithUnit()); + self::assertSame('71 °F', $period->feelsLikeTemperature()?->dayWithUnit()); + self::assertSame('60 °F', $period->dewPointWithUnit()); + self::assertSame(Unit::MILES_PER_HOUR, $period->wind()?->speedUnit()); + self::assertSame('10 mph', $period->wind()?->speedWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Period::fromArray([]); + + self::assertNull($missing->dateTime()); + self::assertNull($missing->temperature()); + self::assertNull($missing->feelsLikeTemperature()); + self::assertNull($missing->wind()); + self::assertNull($missing->clouds()); + self::assertSame([], $missing->conditions()); + self::assertNull($missing->rain()); + self::assertNull($missing->snow()); + self::assertSame([], $missing->alertIds()); + + $period = Period::fromArray([ + 'dt' => null, + 'temp' => ['day' => null, 'unknown' => true], + 'feels_like' => null, + 'weather' => [['icon' => null, 'unknown' => true]], + 'clouds' => null, + 'wind_speed' => null, + 'rain' => null, + 'snow' => null, + 'alerts' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($period->dateTime()); + self::assertNull($period->temperature()?->day()); + self::assertNull($period->feelsLikeTemperature()); + self::assertCount(1, $period->conditions()); + self::assertNull($period->conditions()[0]->icon()); + self::assertNull($period->clouds()?->coverage()); + self::assertNull($period->wind()?->speed()); + self::assertNull($period->rain()); + self::assertSame([], $period->alertIds()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Period::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'date time' => [ + ['dt' => '1785628800'], + '"dt" expected int, string received.', + ]; + yield 'sunrise' => [ + ['sunrise' => '1785649113'], + '"sunrise" expected int, string received.', + ]; + yield 'moon phase' => [ + ['moon_phase' => '0.62'], + '"moon_phase" expected int|float, string received.', + ]; + yield 'temperature object' => [ + ['temp' => 25.53], + '"temp" expected array, float received.', + ]; + yield 'temperature field' => [ + ['temp' => ['day' => '25.53']], + '"day" expected int|float, string received.', + ]; + yield 'feels-like object' => [ + ['feels_like' => 25.53], + '"feels_like" expected array, float received.', + ]; + yield 'pressure' => [ + ['pressure' => '1015.44'], + '"pressure" expected int|float, string received.', + ]; + yield 'humidity' => [ + ['humidity' => 48.5], + '"humidity" expected int, float received.', + ]; + yield 'visibility' => [ + ['visibility' => 10000.5], + '"visibility" expected int, float received.', + ]; + yield 'wind speed' => [ + ['wind_speed' => '6.17'], + '"wind_speed" expected int|float, string received.', + ]; + yield 'cloud coverage' => [ + ['clouds' => 41.5], + '"clouds" expected int, float received.', + ]; + yield 'conditions' => [ + ['weather' => 'Clouds'], + '"weather" expected array, string received.', + ]; + yield 'condition member' => [ + ['weather' => ['Clouds']], + '"weather.0" expected array, string received.', + ]; + yield 'scalar rain' => [ + ['rain' => ['1h' => 1.5]], + '"rain" expected int|float, array received.', + ]; + yield 'alert ID member' => [ + ['alerts' => [123]], + '"alerts.0" expected string, int received.', + ]; + } + + private static function fromFixture(string $path, int $index = 0): Period + { + $response = Fixture::json($path); + + return Period::fromArray($response['data'][$index]); + } +} diff --git a/tests/Unit/Entity/OneCall/DayTimeline/TemperatureTest.php b/tests/Unit/Entity/OneCall/DayTimeline/TemperatureTest.php new file mode 100644 index 0000000..70519f9 --- /dev/null +++ b/tests/Unit/Entity/OneCall/DayTimeline/TemperatureTest.php @@ -0,0 +1,96 @@ + 25.53, + 'min' => 18.73, + 'max' => 26.99, + 'night' => 19.4, + 'eve' => 24.82, + 'morn' => 18.74, + ]); + + self::assertSame(25.53, $temperature->day()); + self::assertSame(Unit::CELSIUS, $temperature->dayUnit()); + self::assertSame('25.53 °C', $temperature->dayWithUnit()); + self::assertSame(18.73, $temperature->minimum()); + self::assertSame('18.73 °C', $temperature->minimumWithUnit()); + self::assertSame(26.99, $temperature->maximum()); + self::assertSame('26.99 °C', $temperature->maximumWithUnit()); + self::assertSame(19.4, $temperature->night()); + self::assertSame('19.4 °C', $temperature->nightWithUnit()); + self::assertSame(24.82, $temperature->evening()); + self::assertSame('24.82 °C', $temperature->eveningWithUnit()); + self::assertSame(18.74, $temperature->morning()); + self::assertSame('18.74 °C', $temperature->morningWithUnit()); + } + + public function testRetainsUnitsFromHydrationContext(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + + $temperature = Temperature::fromArray(['day' => 72.5], $context); + + self::assertSame(Unit::FAHRENHEIT, $temperature->dayUnit()); + self::assertSame('72.5 °F', $temperature->dayWithUnit()); + } + + public function testToleratesMissingNullAndUnknownFields(): void + { + $missing = Temperature::fromArray([]); + + self::assertNull($missing->day()); + self::assertNull($missing->minimum()); + self::assertNull($missing->maximum()); + self::assertNull($missing->night()); + self::assertNull($missing->evening()); + self::assertNull($missing->morning()); + + $temperature = Temperature::fromArray([ + 'day' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($temperature->day()); + self::assertNull($temperature->dayWithUnit()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(string $field): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected int|float, string received.', + $field, + )); + + Temperature::fromArray([$field => 'invalid']); + } + + public static function invalidFields(): iterable + { + yield 'day' => ['day']; + yield 'minimum' => ['min']; + yield 'maximum' => ['max']; + yield 'night' => ['night']; + yield 'evening' => ['eve']; + yield 'morning' => ['morn']; + } +} diff --git a/tests/Unit/Entity/OneCall/DayTimelineTest.php b/tests/Unit/Entity/OneCall/DayTimelineTest.php new file mode 100644 index 0000000..3b1b2a8 --- /dev/null +++ b/tests/Unit/Entity/OneCall/DayTimelineTest.php @@ -0,0 +1,130 @@ +coordinates()?->latitude()); + self::assertSame(-9.1393, $timeline->coordinates()?->longitude()); + self::assertSame('Europe/Lisbon', $timeline->timezone()?->identifier()); + self::assertSame(3600, $timeline->timezone()?->offsetSeconds()); + self::assertCount(10, $timeline->periods()); + self::assertContainsOnlyInstancesOf(Period::class, $timeline->periods()); + self::assertInstanceOf(Pagination::class, $timeline->pagination()); + self::assertSame(1785628800, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame(1786406400, $timeline->periods()[9]->dateTime()?->getTimestamp()); + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/1day?' + .'cnt=10&lat=38.7223&lon=-9.1393&start=1784764800' + .'&appid=%7BAPI%20key%7D&units=metric&lang=en', + $timeline->pagination()->previousPageUrl(), + ); + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/1day?' + .'cnt=10&lat=38.7223&lon=-9.1393&start=1786492800' + .'&appid=%7BAPI%20key%7D&units=metric&lang=en', + $timeline->pagination()->nextPageUrl(), + ); + } + + public function testHydratesCapturedMixedHistoricalAndForecastTimeline(): void + { + $timeline = DayTimeline::fromArray( + Fixture::json('one-call/one-day/history.json'), + ); + + self::assertCount(10, $timeline->periods()); + self::assertSame(1785456000, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertNull($timeline->periods()[0]->precipitationProbability()); + self::assertSame(1785628800, $timeline->periods()[2]->dateTime()?->getTimestamp()); + self::assertSame(0.0, $timeline->periods()[2]->precipitationProbability()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = DayTimeline::fromArray([]); + + self::assertNull($missing->coordinates()); + self::assertNull($missing->timezone()); + self::assertSame([], $missing->periods()); + self::assertNull($missing->pagination()->previousPageUrl()); + self::assertNull($missing->pagination()->nextPageUrl()); + self::assertNull($missing->pagination()->previousPage()); + self::assertNull($missing->pagination()->nextPage()); + + $timeline = DayTimeline::fromArray([ + 'lat' => null, + 'timezone_offset' => null, + 'data' => [ + [], + ['dt' => null, 'unknown' => new \stdClass()], + ], + 'prev' => null, + 'next' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($timeline->coordinates()?->latitude()); + self::assertNull($timeline->coordinates()?->longitude()); + self::assertNull($timeline->timezone()?->identifier()); + self::assertNull($timeline->timezone()?->offsetSeconds()); + self::assertCount(2, $timeline->periods()); + self::assertNull($timeline->periods()[0]->dateTime()); + self::assertNull($timeline->periods()[1]->temperature()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + DayTimeline::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'latitude' => [ + ['lat' => '38.7'], + '"lat" expected int|float, string received.', + ]; + yield 'timezone' => [ + ['timezone' => 1], + '"timezone" expected string, int received.', + ]; + yield 'periods' => [ + ['data' => 'invalid'], + '"data" expected array, string received.', + ]; + yield 'period member' => [ + ['data' => ['invalid']], + '"data.0" expected array, string received.', + ]; + yield 'period field' => [ + ['data' => [['temp' => 'invalid']]], + '"temp" expected array, string received.', + ]; + yield 'previous page URL type' => [ + ['prev' => 1], + '"prev" expected string, int received.', + ]; + yield 'next page URL type' => [ + ['next' => []], + '"next" expected string, array received.', + ]; + } +} diff --git a/tests/Unit/Entity/OneCall/FifteenMinuteTimeline/PeriodTest.php b/tests/Unit/Entity/OneCall/FifteenMinuteTimeline/PeriodTest.php new file mode 100644 index 0000000..4b21caf --- /dev/null +++ b/tests/Unit/Entity/OneCall/FifteenMinuteTimeline/PeriodTest.php @@ -0,0 +1,230 @@ +dateTime()?->getTimestamp()); + self::assertSame('UTC', $period->dateTime()?->getTimezone()->getName()); + self::assertSame(26.06, $period->temperature()); + self::assertSame(Unit::CELSIUS, $period->temperatureUnit()); + self::assertSame('26.06 °C', $period->temperatureWithUnit()); + self::assertSame(26.06, $period->feelsLikeTemperature()); + self::assertSame('26.06 °C', $period->feelsLikeTemperatureWithUnit()); + self::assertSame(1015.75, $period->pressure()); + self::assertSame(Unit::HECTOPASCAL, $period->pressureUnit()); + self::assertSame('1015.75 hPa', $period->pressureWithUnit()); + self::assertSame(55, $period->humidity()); + self::assertSame(Unit::PERCENT, $period->humidityUnit()); + self::assertSame('55 %', $period->humidityWithUnit()); + self::assertSame(16.45, $period->dewPoint()); + self::assertSame(Unit::CELSIUS, $period->dewPointUnit()); + self::assertSame('16.45 °C', $period->dewPointWithUnit()); + self::assertSame(8.06, $period->ultravioletIndex()); + self::assertSame(10000, $period->visibility()); + self::assertSame(Unit::METER, $period->visibilityUnit()); + self::assertSame('10000 m', $period->visibilityWithUnit()); + self::assertSame(5.21, $period->wind()?->speed()); + self::assertSame('5.21 m/s', $period->wind()?->speedWithUnit()); + self::assertSame(306, $period->wind()?->direction()); + self::assertNull($period->wind()?->gust()); + self::assertSame(67, $period->clouds()?->coverage()); + self::assertSame(0.0, $period->precipitationProbability()); + self::assertSame(Unit::PERCENT, $period->precipitationProbabilityUnit()); + self::assertSame('0 %', $period->precipitationProbabilityWithUnit()); + self::assertSame('Clouds', $period->conditions()[0]->group()); + self::assertNull($period->rain()); + self::assertNull($period->snow()); + self::assertSame([], $period->alertIds()); + } + + public function testHydratesCapturedRainConditionWithoutAmount(): void + { + $period = self::fromFixture('one-call/fifteen-minute/rain.json', 16); + + self::assertSame('Rain', $period->conditions()[0]->group()); + self::assertSame(91.0, $period->precipitationProbability()); + self::assertSame('91 %', $period->precipitationProbabilityWithUnit()); + self::assertNull($period->rain()); + self::assertNull($period->snow()); + } + + public function testHydratesCapturedSnowConditionAndAlertIdsWithoutAmount(): void + { + $period = self::fromFixture('one-call/fifteen-minute/snow-alerts.json'); + + self::assertSame('Snow', $period->conditions()[0]->group()); + self::assertSame(100.0, $period->precipitationProbability()); + self::assertNull($period->rain()); + self::assertNull($period->snow()); + self::assertSame([ + 'urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0', + ], $period->alertIds()); + } + + public function testHydratesDocumentedConditionalPrecipitation(): void + { + $period = Period::fromArray([ + 'rain' => ['1h' => 1.25], + 'snow' => ['1h' => 0.5], + ]); + + self::assertSame(1.25, $period->rain()?->lastHour()); + self::assertSame(Unit::MILLIMETERS_PER_HOUR, $period->rain()?->lastHourUnit()); + self::assertSame('1.25 mm/h', $period->rain()?->lastHourWithUnit()); + self::assertSame(0.5, $period->snow()?->lastHour()); + self::assertSame('0.5 mm/h', $period->snow()?->lastHourWithUnit()); + } + + public function testRetainsUnitsFromHydrationContext(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + + $period = Period::fromArray([ + 'temp' => 72.5, + 'feels_like' => 71, + 'dew_point' => 60, + 'wind_speed' => 10, + 'wind_gust' => 15, + ], $context); + + self::assertSame(Unit::FAHRENHEIT, $period->temperatureUnit()); + self::assertSame('72.5 °F', $period->temperatureWithUnit()); + self::assertSame('71 °F', $period->feelsLikeTemperatureWithUnit()); + self::assertSame('60 °F', $period->dewPointWithUnit()); + self::assertSame(Unit::MILES_PER_HOUR, $period->wind()?->speedUnit()); + self::assertSame('10 mph', $period->wind()?->speedWithUnit()); + self::assertSame('15 mph', $period->wind()?->gustWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Period::fromArray([]); + + self::assertNull($missing->dateTime()); + self::assertNull($missing->temperature()); + self::assertNull($missing->temperatureWithUnit()); + self::assertNull($missing->pressure()); + self::assertNull($missing->wind()); + self::assertNull($missing->clouds()); + self::assertSame([], $missing->conditions()); + self::assertNull($missing->rain()); + self::assertNull($missing->snow()); + self::assertSame([], $missing->alertIds()); + + $period = Period::fromArray([ + 'dt' => null, + 'temp' => null, + 'weather' => [['icon' => null, 'unknown' => true]], + 'clouds' => null, + 'wind_speed' => null, + 'rain' => ['1h' => null, 'unknown' => true], + 'snow' => null, + 'alerts' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($period->dateTime()); + self::assertNull($period->temperature()); + self::assertCount(1, $period->conditions()); + self::assertNull($period->conditions()[0]->icon()); + self::assertNull($period->clouds()?->coverage()); + self::assertNull($period->wind()?->speed()); + self::assertNull($period->rain()?->lastHour()); + self::assertNull($period->snow()); + self::assertSame([], $period->alertIds()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Period::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'forecast time' => [ + ['dt' => '1785676500'], + '"dt" expected int, string received.', + ]; + yield 'temperature' => [ + ['temp' => '26.06'], + '"temp" expected int|float, string received.', + ]; + yield 'pressure' => [ + ['pressure' => '1015.75'], + '"pressure" expected int|float, string received.', + ]; + yield 'humidity' => [ + ['humidity' => 55.5], + '"humidity" expected int, float received.', + ]; + yield 'visibility' => [ + ['visibility' => 10000.5], + '"visibility" expected int, float received.', + ]; + yield 'wind speed' => [ + ['wind_speed' => '5.21'], + '"wind_speed" expected int|float, string received.', + ]; + yield 'cloud coverage' => [ + ['clouds' => 67.5], + '"clouds" expected int, float received.', + ]; + yield 'precipitation probability' => [ + ['pop' => '0.5'], + '"pop" expected int|float, string received.', + ]; + yield 'conditions' => [ + ['weather' => 'Clouds'], + '"weather" expected array, string received.', + ]; + yield 'condition member' => [ + ['weather' => ['Clouds']], + '"weather.0" expected array, string received.', + ]; + yield 'rain' => [ + ['rain' => 'invalid'], + '"rain" expected array, string received.', + ]; + yield 'rain amount' => [ + ['rain' => ['1h' => '1.25']], + '"1h" expected int|float, string received.', + ]; + yield 'alert IDs' => [ + ['alerts' => 'invalid'], + '"alerts" expected array, string received.', + ]; + yield 'alert ID member' => [ + ['alerts' => [123]], + '"alerts.0" expected string, int received.', + ]; + } + + private static function fromFixture(string $path, int $index = 0): Period + { + $response = Fixture::json($path); + + return Period::fromArray($response['data'][$index]); + } +} diff --git a/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php b/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php new file mode 100644 index 0000000..b43416f --- /dev/null +++ b/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php @@ -0,0 +1,132 @@ +coordinates()?->latitude()); + self::assertSame(-9.1393, $timeline->coordinates()?->longitude()); + self::assertSame('Europe/Lisbon', $timeline->timezone()?->identifier()); + self::assertSame(3600, $timeline->timezone()?->offsetSeconds()); + self::assertCount(50, $timeline->periods()); + self::assertContainsOnlyInstancesOf(Period::class, $timeline->periods()); + self::assertInstanceOf(Pagination::class, $timeline->pagination()); + self::assertSame(1785670200, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame(1785714300, $timeline->periods()[49]->dateTime()?->getTimestamp()); + self::assertNull($timeline->pagination()->previousPageUrl()); + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/15min?' + .'cnt=50&lat=38.7223&lon=-9.1393&start=1785715200' + .'&appid=%7BAPI%20key%7D&units=metric&lang=en', + $timeline->pagination()->nextPageUrl(), + ); + } + + public function testHydratesCapturedBidirectionalPagination(): void + { + $timeline = FifteenMinuteTimeline::fromArray( + Fixture::json('one-call/fifteen-minute/pagination.json'), + ); + + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/15min?' + .'cnt=50&lat=38.7223&lon=-9.1393&start=1785670200' + .'&appid=%7BAPI%20key%7D&units=metric&lang=en', + $timeline->pagination()->previousPageUrl(), + ); + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/15min?' + .'cnt=50&lat=38.7223&lon=-9.1393&start=1785760200' + .'&appid=%7BAPI%20key%7D&units=metric&lang=en', + $timeline->pagination()->nextPageUrl(), + ); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = FifteenMinuteTimeline::fromArray([]); + + self::assertNull($missing->coordinates()); + self::assertNull($missing->timezone()); + self::assertSame([], $missing->periods()); + self::assertNull($missing->pagination()->previousPageUrl()); + self::assertNull($missing->pagination()->nextPageUrl()); + self::assertNull($missing->pagination()->previousPage()); + self::assertNull($missing->pagination()->nextPage()); + + $timeline = FifteenMinuteTimeline::fromArray([ + 'lat' => null, + 'timezone_offset' => null, + 'data' => [ + [], + ['dt' => null, 'unknown' => new \stdClass()], + ], + 'prev' => null, + 'next' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($timeline->coordinates()?->latitude()); + self::assertNull($timeline->coordinates()?->longitude()); + self::assertNull($timeline->timezone()?->identifier()); + self::assertNull($timeline->timezone()?->offsetSeconds()); + self::assertCount(2, $timeline->periods()); + self::assertNull($timeline->periods()[0]->dateTime()); + self::assertNull($timeline->periods()[1]->temperature()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + FifteenMinuteTimeline::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'latitude' => [ + ['lat' => '38.7'], + '"lat" expected int|float, string received.', + ]; + yield 'timezone' => [ + ['timezone' => 1], + '"timezone" expected string, int received.', + ]; + yield 'periods' => [ + ['data' => 'invalid'], + '"data" expected array, string received.', + ]; + yield 'period member' => [ + ['data' => ['invalid']], + '"data.0" expected array, string received.', + ]; + yield 'period field' => [ + ['data' => [['pressure' => '1015.75']]], + '"pressure" expected int|float, string received.', + ]; + yield 'previous page URL type' => [ + ['prev' => 1], + '"prev" expected string, int received.', + ]; + yield 'next page URL type' => [ + ['next' => []], + '"next" expected string, array received.', + ]; + } +} diff --git a/tests/Unit/Entity/OneCall/HourTimeline/PeriodTest.php b/tests/Unit/Entity/OneCall/HourTimeline/PeriodTest.php new file mode 100644 index 0000000..33813cf --- /dev/null +++ b/tests/Unit/Entity/OneCall/HourTimeline/PeriodTest.php @@ -0,0 +1,173 @@ +dateTime()?->getTimestamp()); + self::assertSame('UTC', $period->dateTime()?->getTimezone()->getName()); + self::assertSame(25.05, $period->temperature()); + self::assertSame(Unit::CELSIUS, $period->temperatureUnit()); + self::assertSame('25.05 °C', $period->temperatureWithUnit()); + self::assertSame(25.23, $period->feelsLikeTemperature()); + self::assertSame(1015.0, $period->pressure()); + self::assertSame('1015 hPa', $period->pressureWithUnit()); + self::assertSame(62, $period->humidity()); + self::assertSame(17.27, $period->dewPoint()); + self::assertSame(7.53, $period->ultravioletIndex()); + self::assertSame(10000, $period->visibility()); + self::assertSame(3.87, $period->wind()?->speed()); + self::assertSame(317, $period->wind()?->direction()); + self::assertSame(4.47, $period->wind()?->gust()); + self::assertSame(48, $period->clouds()?->coverage()); + self::assertSame(0.0, $period->precipitationProbability()); + self::assertSame(Unit::PERCENT, $period->precipitationProbabilityUnit()); + self::assertSame('0 %', $period->precipitationProbabilityWithUnit()); + self::assertSame('Clouds', $period->conditions()[0]->group()); + self::assertNull($period->rain()); + self::assertNull($period->snow()); + self::assertSame([], $period->alertIds()); + } + + public function testHydratesCapturedRain(): void + { + $period = self::fromFixture('one-call/one-hour/rain.json'); + + self::assertSame('Rain', $period->conditions()[0]->group()); + self::assertSame(100.0, $period->precipitationProbability()); + self::assertSame('100 %', $period->precipitationProbabilityWithUnit()); + self::assertSame(1.72, $period->rain()?->lastHour()); + self::assertSame(Unit::MILLIMETERS_PER_HOUR, $period->rain()?->lastHourUnit()); + self::assertSame('1.72 mm/h', $period->rain()?->lastHourWithUnit()); + self::assertNull($period->snow()); + } + + public function testHydratesCapturedSnowAndAlerts(): void + { + $period = self::fromFixture('one-call/one-hour/snow-alerts.json'); + $periodWithoutVisibility = self::fromFixture( + 'one-call/one-hour/snow-alerts.json', + 19, + ); + + self::assertSame('Snow', $period->conditions()[0]->group()); + self::assertSame(100.0, $period->precipitationProbability()); + self::assertSame(2.54, $period->snow()?->lastHour()); + self::assertSame('2.54 mm/h', $period->snow()?->lastHourWithUnit()); + self::assertNull($period->rain()); + self::assertSame([ + 'urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0', + ], $period->alertIds()); + self::assertNull($periodWithoutVisibility->visibility()); + } + + public function testRetainsUnitsFromHydrationContext(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + + $period = Period::fromArray([ + 'temp' => 72.5, + 'feels_like' => 71, + 'dew_point' => 60, + 'wind_speed' => 10, + 'wind_gust' => 15, + ], $context); + + self::assertSame(Unit::FAHRENHEIT, $period->temperatureUnit()); + self::assertSame('72.5 °F', $period->temperatureWithUnit()); + self::assertSame('71 °F', $period->feelsLikeTemperatureWithUnit()); + self::assertSame('60 °F', $period->dewPointWithUnit()); + self::assertSame(Unit::MILES_PER_HOUR, $period->wind()?->speedUnit()); + self::assertSame('10 mph', $period->wind()?->speedWithUnit()); + self::assertSame('15 mph', $period->wind()?->gustWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Period::fromArray([]); + + self::assertNull($missing->dateTime()); + self::assertNull($missing->temperature()); + self::assertNull($missing->wind()); + self::assertNull($missing->clouds()); + self::assertSame([], $missing->conditions()); + self::assertNull($missing->rain()); + self::assertNull($missing->snow()); + self::assertSame([], $missing->alertIds()); + + $period = Period::fromArray([ + 'dt' => null, + 'temp' => null, + 'weather' => [['icon' => null, 'unknown' => true]], + 'clouds' => null, + 'wind_speed' => null, + 'rain' => ['1h' => null, 'unknown' => true], + 'snow' => null, + 'alerts' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($period->dateTime()); + self::assertNull($period->temperature()); + self::assertCount(1, $period->conditions()); + self::assertNull($period->conditions()[0]->icon()); + self::assertNull($period->clouds()?->coverage()); + self::assertNull($period->wind()?->speed()); + self::assertNull($period->rain()?->lastHour()); + self::assertNull($period->snow()); + self::assertSame([], $period->alertIds()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Period::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'date time' => [ + ['dt' => '1785668400'], + '"dt" expected int, string received.', + ]; + yield 'temperature' => [ + ['temp' => '25.05'], + '"temp" expected int|float, string received.', + ]; + yield 'rain amount' => [ + ['rain' => ['1h' => '1.72']], + '"1h" expected int|float, string received.', + ]; + yield 'alert ID member' => [ + ['alerts' => [123]], + '"alerts.0" expected string, int received.', + ]; + } + + private static function fromFixture(string $path, int $index = 0): Period + { + $response = Fixture::json($path); + + return Period::fromArray($response['data'][$index]); + } +} diff --git a/tests/Unit/Entity/OneCall/HourTimelineTest.php b/tests/Unit/Entity/OneCall/HourTimelineTest.php new file mode 100644 index 0000000..c89bd39 --- /dev/null +++ b/tests/Unit/Entity/OneCall/HourTimelineTest.php @@ -0,0 +1,144 @@ +coordinates()?->latitude()); + self::assertSame(-9.1393, $timeline->coordinates()?->longitude()); + self::assertSame('Europe/Lisbon', $timeline->timezone()?->identifier()); + self::assertSame(3600, $timeline->timezone()?->offsetSeconds()); + self::assertCount(20, $timeline->periods()); + self::assertContainsOnlyInstancesOf(Period::class, $timeline->periods()); + self::assertInstanceOf(Pagination::class, $timeline->pagination()); + self::assertSame(1785668400, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame(1785736800, $timeline->periods()[19]->dateTime()?->getTimestamp()); + self::assertTrue($timeline->pagination()->hasPreviousPage()); + self::assertTrue($timeline->pagination()->hasNextPage()); + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/1h?' + .'cnt=20&lat=38.7223&lon=-9.1393&start=1785596400' + .'&appid=%7BAPI%20key%7D&units=metric&lang=en', + $timeline->pagination()->previousPageUrl(), + ); + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/1h?' + .'cnt=20&lat=38.7223&lon=-9.1393&start=1785740400' + .'&appid=%7BAPI%20key%7D&units=metric&lang=en', + $timeline->pagination()->nextPageUrl(), + ); + } + + public function testHydratesCapturedHistoricalTimeline(): void + { + $timeline = HourTimeline::fromArray( + Fixture::json('one-call/one-hour/history.json'), + ); + + self::assertCount(20, $timeline->periods()); + self::assertSame(1785495600, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame(1785564000, $timeline->periods()[19]->dateTime()?->getTimestamp()); + self::assertNull($timeline->periods()[0]->precipitationProbability()); + } + + public function testHydratesWithConfigurationContextWithoutResolver(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + $timeline = HourTimeline::fromArray([ + 'data' => [['temp' => 72.5]], + 'next' => '/data/4.0/onecall/timeline/1h?start=1785740400', + ], $context); + + self::assertSame(Unit::FAHRENHEIT, $timeline->periods()[0]->temperatureUnit()); + self::assertTrue($timeline->pagination()->hasNextPage()); + self::assertSame( + '/data/4.0/onecall/timeline/1h?start=1785740400', + $timeline->pagination()->nextPageUrl(), + ); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = HourTimeline::fromArray([]); + + self::assertNull($missing->coordinates()); + self::assertNull($missing->timezone()); + self::assertSame([], $missing->periods()); + self::assertNull($missing->pagination()->previousPageUrl()); + self::assertNull($missing->pagination()->nextPageUrl()); + self::assertFalse($missing->pagination()->hasPreviousPage()); + self::assertFalse($missing->pagination()->hasNextPage()); + self::assertNull($missing->pagination()->previousPage()); + self::assertNull($missing->pagination()->nextPage()); + + $timeline = HourTimeline::fromArray([ + 'lat' => null, + 'timezone_offset' => null, + 'data' => [ + [], + ['dt' => null, 'unknown' => new \stdClass()], + ], + 'prev' => null, + 'next' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($timeline->coordinates()?->latitude()); + self::assertNull($timeline->coordinates()?->longitude()); + self::assertNull($timeline->timezone()?->identifier()); + self::assertNull($timeline->timezone()?->offsetSeconds()); + self::assertCount(2, $timeline->periods()); + self::assertNull($timeline->periods()[0]->dateTime()); + self::assertNull($timeline->periods()[1]->temperature()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + HourTimeline::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'periods' => [ + ['data' => 'invalid'], + '"data" expected array, string received.', + ]; + yield 'period member' => [ + ['data' => ['invalid']], + '"data.0" expected array, string received.', + ]; + yield 'period field' => [ + ['data' => [['temp' => '25.05']]], + '"temp" expected int|float, string received.', + ]; + yield 'previous page URL type' => [ + ['prev' => 1], + '"prev" expected string, int received.', + ]; + } +} diff --git a/tests/Unit/Entity/OneCall/MinuteTimeline/PeriodTest.php b/tests/Unit/Entity/OneCall/MinuteTimeline/PeriodTest.php new file mode 100644 index 0000000..5dbcb7f --- /dev/null +++ b/tests/Unit/Entity/OneCall/MinuteTimeline/PeriodTest.php @@ -0,0 +1,93 @@ +dateTime()?->getTimestamp()); + self::assertSame('UTC', $period->dateTime()?->getTimezone()->getName()); + self::assertSame(0.0, $period->precipitation()); + self::assertSame(Unit::MILLIMETERS_PER_HOUR, $period->precipitationUnit()); + self::assertSame('0 mm/h', $period->precipitationWithUnit()); + self::assertSame([], $period->alertIds()); + } + + public function testHydratesCapturedPrecipitationAndAlertIds(): void + { + $period = self::fromFixture('one-call/one-minute/precipitation-alerts.json'); + + self::assertSame(5.9626, $period->precipitation()); + self::assertSame('5.9626 mm/h', $period->precipitationWithUnit()); + self::assertSame([ + 'urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0', + ], $period->alertIds()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Period::fromArray([]); + + self::assertNull($missing->dateTime()); + self::assertNull($missing->precipitation()); + self::assertNull($missing->precipitationWithUnit()); + self::assertSame([], $missing->alertIds()); + + $period = Period::fromArray([ + 'dt' => null, + 'precipitation' => null, + 'alerts' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($period->dateTime()); + self::assertNull($period->precipitation()); + self::assertSame([], $period->alertIds()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Period::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'forecast time' => [ + ['dt' => '1785669780'], + '"dt" expected int, string received.', + ]; + yield 'precipitation' => [ + ['precipitation' => '5.9626'], + '"precipitation" expected int|float, string received.', + ]; + yield 'alert IDs' => [ + ['alerts' => 'invalid'], + '"alerts" expected array, string received.', + ]; + yield 'alert ID member' => [ + ['alerts' => [123]], + '"alerts.0" expected string, int received.', + ]; + } + + private static function fromFixture(string $path): Period + { + $response = Fixture::json($path); + + return Period::fromArray($response['data'][0]); + } +} diff --git a/tests/Unit/Entity/OneCall/MinuteTimelineTest.php b/tests/Unit/Entity/OneCall/MinuteTimelineTest.php new file mode 100644 index 0000000..d718440 --- /dev/null +++ b/tests/Unit/Entity/OneCall/MinuteTimelineTest.php @@ -0,0 +1,118 @@ +coordinates()?->latitude()); + self::assertSame($longitude, $timeline->coordinates()?->longitude()); + self::assertSame($timezone, $timeline->timezone()?->identifier()); + self::assertSame($timezoneOffset, $timeline->timezone()?->offsetSeconds()); + self::assertCount(60, $timeline->periods()); + self::assertContainsOnlyInstancesOf(Period::class, $timeline->periods()); + self::assertSame(1785669780, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame(1785673320, $timeline->periods()[59]->dateTime()?->getTimestamp()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = MinuteTimeline::fromArray([]); + + self::assertNull($missing->coordinates()); + self::assertNull($missing->timezone()); + self::assertSame([], $missing->periods()); + + $timeline = MinuteTimeline::fromArray([ + 'lat' => null, + 'timezone' => null, + 'timezone_offset' => null, + 'data' => [ + [], + ['dt' => null, 'unknown' => new \stdClass()], + ], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($timeline->coordinates()?->latitude()); + self::assertNull($timeline->coordinates()?->longitude()); + self::assertNull($timeline->timezone()?->identifier()); + self::assertNull($timeline->timezone()?->offsetSeconds()); + self::assertCount(2, $timeline->periods()); + self::assertNull($timeline->periods()[0]->dateTime()); + self::assertNull($timeline->periods()[1]->precipitation()); + + self::assertSame([], MinuteTimeline::fromArray(['data' => null])->periods()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + MinuteTimeline::fromArray($data); + } + + public static function capturedTimelines(): iterable + { + yield 'dry' => [ + 'one-call/one-minute/success.json', + 38.7223, + -9.1393, + 'Europe/Lisbon', + 3600, + ]; + yield 'precipitation with alerts' => [ + 'one-call/one-minute/precipitation-alerts.json', + -38.4, + -71.58, + 'America/Santiago', + -14400, + ]; + } + + public static function invalidFields(): iterable + { + yield 'latitude' => [ + ['lat' => '38.7'], + '"lat" expected int|float, string received.', + ]; + yield 'timezone' => [ + ['timezone' => 1], + '"timezone" expected string, int received.', + ]; + yield 'timezone offset' => [ + ['timezone_offset' => '3600'], + '"timezone_offset" expected int, string received.', + ]; + yield 'periods' => [ + ['data' => 'invalid'], + '"data" expected array, string received.', + ]; + yield 'period member' => [ + ['data' => ['invalid']], + '"data.0" expected array, string received.', + ]; + yield 'period field' => [ + ['data' => [['precipitation' => '0.5']]], + '"precipitation" expected int|float, string received.', + ]; + } +} diff --git a/tests/Unit/Entity/OneCall/TimezoneTest.php b/tests/Unit/Entity/OneCall/TimezoneTest.php new file mode 100644 index 0000000..5d382ca --- /dev/null +++ b/tests/Unit/Entity/OneCall/TimezoneTest.php @@ -0,0 +1,58 @@ +identifier()); + self::assertSame(3600, $timezone->offsetSeconds()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Timezone::fromArray([]); + + self::assertNull($missing->identifier()); + self::assertNull($missing->offsetSeconds()); + + $timezone = Timezone::fromArray([ + 'timezone' => null, + 'timezone_offset' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($timezone->identifier()); + self::assertNull($timezone->offsetSeconds()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Timezone::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'identifier' => [ + ['timezone' => 1], + '"timezone" expected string, int received.', + ]; + yield 'offset' => [ + ['timezone_offset' => '3600'], + '"timezone_offset" expected int, string received.', + ]; + } +} diff --git a/tests/Unit/Entity/Stations/MeasurementAggregateTest.php b/tests/Unit/Entity/Stations/MeasurementAggregateTest.php new file mode 100644 index 0000000..1c1fd9a --- /dev/null +++ b/tests/Unit/Entity/Stations/MeasurementAggregateTest.php @@ -0,0 +1,162 @@ +interval()); + self::assertSame(1786231380, $aggregate->dateTime()?->getTimestamp()); + self::assertSame('UTC', $aggregate->dateTime()?->getTimezone()->getName()); + self::assertSame('6a77ba36adde3b0001343e09', $aggregate->stationId()); + + self::assertSame(19.5, $aggregate->temperature()?->minimum()); + self::assertSame(Unit::CELSIUS, $aggregate->temperature()?->minimumUnit()); + self::assertSame('19.5 °C', $aggregate->temperature()?->minimumWithUnit()); + self::assertSame(21.5, $aggregate->temperature()?->maximum()); + self::assertSame('21.5 °C', $aggregate->temperature()?->maximumWithUnit()); + self::assertSame(20.5, $aggregate->temperature()?->average()); + self::assertSame('20.5 °C', $aggregate->temperature()?->averageWithUnit()); + self::assertSame(3, $aggregate->temperature()?->weight()); + + self::assertSame(64.0, $aggregate->humidity()?->average()); + self::assertSame(Unit::PERCENT, $aggregate->humidity()?->averageUnit()); + self::assertSame('64 %', $aggregate->humidity()?->averageWithUnit()); + self::assertSame(3, $aggregate->humidity()?->weight()); + + self::assertSame(203.4, $aggregate->wind()?->direction()); + self::assertSame(Unit::DEGREE, $aggregate->wind()?->directionUnit()); + self::assertSame('203.4 °', $aggregate->wind()?->directionWithUnit()); + self::assertSame(3.08, $aggregate->wind()?->speed()); + self::assertSame(Unit::METERS_PER_SECOND, $aggregate->wind()?->speedUnit()); + self::assertSame('3.08 m/s', $aggregate->wind()?->speedWithUnit()); + + self::assertSame(1012.0, $aggregate->pressure()?->minimum()); + self::assertSame(Unit::HECTOPASCAL, $aggregate->pressure()?->minimumUnit()); + self::assertSame('1012 hPa', $aggregate->pressure()?->minimumWithUnit()); + self::assertSame(1014.0, $aggregate->pressure()?->maximum()); + self::assertSame('1014 hPa', $aggregate->pressure()?->maximumWithUnit()); + self::assertSame(1013.0, $aggregate->pressure()?->average()); + self::assertSame('1013 hPa', $aggregate->pressure()?->averageWithUnit()); + self::assertSame(3, $aggregate->pressure()?->weight()); + + self::assertNotNull($aggregate->precipitation()); + self::assertNull($aggregate->precipitation()?->rain()); + self::assertNull($aggregate->precipitation()?->rainWithUnit()); + self::assertNull($aggregate->precipitation()?->snow()); + } + + public function testHydratesCapturedHourAndDayAggregates(): void + { + $hours = Fixture::json('stations/measurements/aggregate-hour-success.json'); + $days = Fixture::json('stations/measurements/aggregate-day-success.json'); + $hour = MeasurementAggregate::fromArray($hours[0]); + $day = MeasurementAggregate::fromArray($days[0]); + $populatedHour = MeasurementAggregate::fromArray($hours[1]); + $populatedDay = MeasurementAggregate::fromArray($days[1]); + + self::assertSame(AggregationInterval::HOUR, $hour->interval()); + self::assertSame(AggregationInterval::DAY, $day->interval()); + self::assertSame(1786233600, $hour->dateTime()?->getTimestamp()); + self::assertSame(1786233600, $day->dateTime()?->getTimestamp()); + self::assertSame(0.6, $hour->precipitation()?->rain()); + self::assertSame(Unit::MILLIMETER, $hour->precipitation()?->rainUnit()); + self::assertSame('0.6 mm', $hour->precipitation()?->rainWithUnit()); + self::assertSame(0.6, $day->precipitation()?->rain()); + self::assertSame(1014.2, $populatedHour->pressure()?->average()); + self::assertSame(0.1, $populatedHour->precipitation()?->snow()); + self::assertSame('0.1 mm', $populatedHour->precipitation()?->snowWithUnit()); + self::assertSame(3.4, $populatedDay->precipitation()?->rain()); + self::assertSame(0.5, $populatedDay->precipitation()?->snow()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = MeasurementAggregate::fromArray([]); + + self::assertNull($missing->interval()); + self::assertNull($missing->dateTime()); + self::assertNull($missing->stationId()); + self::assertNull($missing->temperature()); + self::assertNull($missing->humidity()); + self::assertNull($missing->wind()); + self::assertNull($missing->pressure()); + self::assertNull($missing->precipitation()); + + $partial = MeasurementAggregate::fromArray([ + 'type' => null, + 'date' => null, + 'station_id' => null, + 'temp' => ['average' => null, 'unknown' => new \stdClass()], + 'humidity' => [], + 'wind' => ['speed' => null], + 'pressure' => [], + 'precipitation' => ['rain' => null, 'snow' => 1.2], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($partial->temperature()?->average()); + self::assertNull($partial->humidity()?->average()); + self::assertNull($partial->wind()?->speed()); + self::assertNull($partial->pressure()?->average()); + self::assertNull($partial->precipitation()?->rain()); + self::assertSame(1.2, $partial->precipitation()?->snow()); + self::assertSame(Unit::MILLIMETER, $partial->precipitation()?->snowUnit()); + self::assertSame('1.2 mm', $partial->precipitation()?->snowWithUnit()); + } + + public function testRejectsAnUnknownAggregationInterval(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '"type" expected one of m, h, or d, "week" received.', + ); + + MeasurementAggregate::fromArray(['type' => 'week']); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields( + array $data, + string $path, + string $expectedType, + string $receivedType, + ): void { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected %s, %s received.', + $path, + $expectedType, + $receivedType, + )); + + MeasurementAggregate::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'interval' => [['type' => 1], 'type', 'string', 'int']; + yield 'date' => [['date' => '1786231380'], 'date', 'int', 'string']; + yield 'station identifier' => [['station_id' => 1], 'station_id', 'string', 'int']; + yield 'temperature container' => [['temp' => 'invalid'], 'temp', 'array', 'string']; + yield 'temperature minimum' => [['temp' => ['min' => '19.5']], 'min', 'int|float', 'string']; + yield 'temperature weight' => [['temp' => ['weight' => 3.0]], 'weight', 'int', 'float']; + yield 'humidity average' => [['humidity' => ['average' => '64']], 'average', 'int|float', 'string']; + yield 'wind direction' => [['wind' => ['deg' => '203.4']], 'deg', 'int|float', 'string']; + yield 'pressure average' => [['pressure' => ['average' => '1013']], 'average', 'int|float', 'string']; + yield 'precipitation rain' => [['precipitation' => ['rain' => '0.6']], 'rain', 'int|float', 'string']; + } +} diff --git a/tests/Unit/Entity/Stations/StationTest.php b/tests/Unit/Entity/Stations/StationTest.php new file mode 100644 index 0000000..1cce26c --- /dev/null +++ b/tests/Unit/Entity/Stations/StationTest.php @@ -0,0 +1,137 @@ +id()); + self::assertSame('2026-08-08T20:33:08+00:00', $station->createdAt()->format(\DateTimeInterface::ATOM)); + self::assertSame('107000', $station->createdAt()->format('u')); + self::assertSame('UTC', $station->createdAt()->getTimezone()->getName()); + self::assertSame('2026-08-08T20:33:08+00:00', $station->updatedAt()->format(\DateTimeInterface::ATOM)); + self::assertSame('107000', $station->updatedAt()->format('u')); + self::assertSame('openweathermap-php-api-fixture', $station->externalId()); + self::assertSame('OpenWeatherMap PHP API Fixture', $station->name()); + self::assertSame(38.7223, $station->latitude()); + self::assertSame(-9.1393, $station->longitude()); + self::assertSame(100.0, $station->altitude()); + self::assertSame(10, $station->rank()); + self::assertNull($station->userId()); + self::assertNull($station->sourceType()); + } + + public function testHydratesCapturedRegistrationFields(): void + { + $station = Station::fromArray(Fixture::json('stations/register.json')); + + self::assertSame('6a779284adde3b0001343e02', $station->id()); + self::assertSame('user-fixture', $station->userId()); + self::assertSame(5, $station->sourceType()); + self::assertSame('107598', $station->createdAt()->format('u')); + self::assertSame('107598', $station->updatedAt()->format('u')); + } + + public function testToleratesNullAndUnknownOptionalFields(): void + { + $data = Fixture::json('stations/retrieve.json'); + $data = array_replace($data, [ + 'user_id' => null, + 'source_type' => null, + 'unknown' => new \stdClass(), + ]); + $station = Station::fromArray($data); + + self::assertNull($station->userId()); + self::assertNull($station->sourceType()); + } + + #[DataProvider('requiredFields')] + public function testRejectsMissingRequiredFields( + string $field, + string $path, + string $expectedType, + ): void { + $data = Fixture::json('stations/retrieve.json'); + unset($data[$field]); + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected %s, null received.', + $path, + $expectedType, + )); + + Station::fromArray($data); + } + + #[DataProvider('requiredFields')] + public function testRejectsNullRequiredFields( + string $field, + string $path, + string $expectedType, + ): void { + $data = Fixture::json('stations/retrieve.json'); + $data[$field] = null; + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected %s, null received.', + $path, + $expectedType, + )); + + Station::fromArray($data); + } + + public static function requiredFields(): iterable + { + yield 'identifier' => ['id', 'id', 'string']; + yield 'created date' => ['created_at', 'created_at', 'ISO 8601 date-time string']; + yield 'updated date' => ['updated_at', 'updated_at', 'ISO 8601 date-time string']; + yield 'external identifier' => ['external_id', 'external_id', 'string']; + yield 'name' => ['name', 'name', 'string']; + yield 'latitude' => ['latitude', 'latitude', 'int|float']; + yield 'longitude' => ['longitude', 'longitude', 'int|float']; + yield 'altitude' => ['altitude', 'altitude', 'int|float']; + yield 'rank' => ['rank', 'rank', 'int']; + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Station::fromArray(array_replace( + Fixture::json('stations/retrieve.json'), + $data, + )); + } + + public static function invalidFields(): iterable + { + yield 'identifier' => [['id' => 1], '"id" expected string, int received.']; + yield 'registration identifier' => [['ID' => 1], '"ID" expected string, int received.']; + yield 'created date type' => [['created_at' => 1], '"created_at" expected string, int received.']; + yield 'created date value' => [['created_at' => 'tomorrow'], '"created_at" expected ISO 8601 date-time string, "tomorrow" received.']; + yield 'updated date' => [['updated_at' => 1], '"updated_at" expected string, int received.']; + yield 'external identifier' => [['external_id' => 1], '"external_id" expected string, int received.']; + yield 'name' => [['name' => 1], '"name" expected string, int received.']; + yield 'latitude' => [['latitude' => '38.7'], '"latitude" expected int|float, string received.']; + yield 'longitude' => [['longitude' => '-9.1'], '"longitude" expected int|float, string received.']; + yield 'altitude' => [['altitude' => '100'], '"altitude" expected int|float, string received.']; + yield 'rank' => [['rank' => '10'], '"rank" expected int, string received.']; + yield 'user identifier' => [['user_id' => 1], '"user_id" expected string, int received.']; + yield 'source type' => [['source_type' => '5'], '"source_type" expected int, string received.']; + } +} diff --git a/tests/Unit/Entity/Weather/CurrentTest.php b/tests/Unit/Entity/Weather/CurrentTest.php new file mode 100644 index 0000000..5a246ef --- /dev/null +++ b/tests/Unit/Entity/Weather/CurrentTest.php @@ -0,0 +1,201 @@ +coordinates()?->latitude()); + self::assertSame(-9.1393, $weather->coordinates()?->longitude()); + self::assertSame(22.55, $weather->temperature()); + self::assertSame(Unit::CELSIUS, $weather->temperatureUnit()); + self::assertSame('22.55 °C', $weather->temperatureWithUnit()); + self::assertSame(22.87, $weather->feelsLikeTemperature()); + self::assertSame('22.87 °C', $weather->feelsLikeTemperatureWithUnit()); + self::assertSame(21.48, $weather->minimumTemperature()); + self::assertSame('21.48 °C', $weather->minimumTemperatureWithUnit()); + self::assertSame(23.94, $weather->maximumTemperature()); + self::assertSame('23.94 °C', $weather->maximumTemperatureWithUnit()); + self::assertSame(1016, $weather->pressure()); + self::assertSame('1016 hPa', $weather->pressureWithUnit()); + self::assertSame(77, $weather->humidity()); + self::assertSame('77 %', $weather->humidityWithUnit()); + self::assertSame(1016, $weather->seaLevelPressure()); + self::assertSame('1016 hPa', $weather->seaLevelPressureWithUnit()); + self::assertSame(1006, $weather->groundLevelPressure()); + self::assertSame('1006 hPa', $weather->groundLevelPressureWithUnit()); + self::assertSame(10000, $weather->visibility()); + self::assertSame('10000 m', $weather->visibilityWithUnit()); + + self::assertCount(1, $weather->conditions()); + self::assertSame(802, $weather->conditions()[0]->id()); + self::assertSame('Clouds', $weather->conditions()[0]->group()); + self::assertSame('scattered clouds', $weather->conditions()[0]->description()); + self::assertSame('03d', $weather->conditions()[0]->icon()); + self::assertSame( + 'https://openweathermap.org/img/wn/03d@2x.png', + $weather->conditions()[0]->iconUrl(), + ); + + self::assertSame(4.47, $weather->wind()?->speed()); + self::assertSame(Unit::METERS_PER_SECOND, $weather->wind()?->speedUnit()); + self::assertSame('4.47 m/s', $weather->wind()?->speedWithUnit()); + self::assertSame(190, $weather->wind()?->direction()); + self::assertSame('190 °', $weather->wind()?->directionWithUnit()); + self::assertSame(7.6, $weather->wind()?->gust()); + self::assertSame('7.6 m/s', $weather->wind()?->gustWithUnit()); + + self::assertSame(48, $weather->clouds()?->coverage()); + self::assertSame(Unit::PERCENT, $weather->clouds()?->coverageUnit()); + self::assertSame('48 %', $weather->clouds()?->coverageWithUnit()); + self::assertNull($weather->rain()); + self::assertNull($weather->snow()); + + self::assertSame(1785573885, $weather->dateTime()?->getTimestamp()); + self::assertSame('UTC', $weather->dateTime()?->getTimezone()->getName()); + self::assertSame('PT', $weather->countryCode()); + self::assertSame(1785562660, $weather->sunriseAt()?->getTimestamp()); + self::assertSame(1785613679, $weather->sunsetAt()?->getTimestamp()); + self::assertSame(3600, $weather->timezoneOffset()); + self::assertSame(8012502, $weather->id()); + self::assertSame('Socorro', $weather->name()); + } + + public function testHydratesConditionalRain(): void + { + $weather = Current::fromArray( + Fixture::json('weather/current/rain.json'), + ); + + self::assertSame('Rain', $weather->conditions()[0]->group()); + self::assertSame(2.47, $weather->rain()?->lastHour()); + self::assertSame(Unit::MILLIMETERS_PER_HOUR, $weather->rain()?->lastHourUnit()); + self::assertSame('2.47 mm/h', $weather->rain()?->lastHourWithUnit()); + self::assertNull($weather->snow()); + } + + public function testHydratesConditionalSnowAndMissingFields(): void + { + $weather = Current::fromArray( + Fixture::json('weather/current/snow.json'), + ); + + self::assertSame('Snow', $weather->conditions()[0]->group()); + self::assertSame(1.37, $weather->snow()?->lastHour()); + self::assertSame('1.37 mm/h', $weather->snow()?->lastHourWithUnit()); + self::assertNull($weather->rain()); + self::assertNull($weather->visibility()); + self::assertNull($weather->visibilityWithUnit()); + } + + public function testRetainsUnitsFromHydrationContext(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + + $weather = Current::fromArray([ + 'main' => ['temp' => 72.5], + 'wind' => ['speed' => 10, 'gust' => 15], + ], $context); + + self::assertSame(Unit::FAHRENHEIT, $weather->temperatureUnit()); + self::assertSame('72.5 °F', $weather->temperatureWithUnit()); + self::assertSame(Unit::MILES_PER_HOUR, $weather->wind()?->speedUnit()); + self::assertSame('10 mph', $weather->wind()?->speedWithUnit()); + self::assertSame('15 mph', $weather->wind()?->gustWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + self::assertNull(Current::fromArray([])->coordinates()); + self::assertSame([], Current::fromArray(['weather' => null])->conditions()); + + $weather = Current::fromArray([ + 'coord' => ['lat' => null, 'unknown' => true], + 'weather' => [['icon' => null]], + 'main' => ['temp' => null], + 'base' => new \stdClass(), + 'wind' => null, + 'clouds' => ['all' => null], + 'rain' => ['1h' => null, 'unknown' => true], + 'sys' => [ + 'type' => new \stdClass(), + 'id' => new \stdClass(), + ], + 'cod' => new \stdClass(), + 'unknown' => new \stdClass(), + ]); + + self::assertNull($weather->coordinates()?->latitude()); + self::assertNull($weather->coordinates()?->longitude()); + self::assertCount(1, $weather->conditions()); + self::assertNull($weather->conditions()[0]->icon()); + self::assertNull($weather->conditions()[0]->iconUrl()); + self::assertNull($weather->temperature()); + self::assertSame(Unit::CELSIUS, $weather->temperatureUnit()); + self::assertNull($weather->temperatureWithUnit()); + self::assertNull($weather->feelsLikeTemperature()); + self::assertNull($weather->pressure()); + self::assertNull($weather->wind()); + self::assertNull($weather->clouds()?->coverage()); + self::assertNull($weather->clouds()?->coverageWithUnit()); + self::assertNull($weather->rain()?->lastHour()); + self::assertNull($weather->rain()?->lastHourWithUnit()); + self::assertNull($weather->snow()); + self::assertNull($weather->dateTime()); + self::assertNull($weather->countryCode()); + self::assertNull($weather->sunriseAt()); + self::assertNull($weather->name()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFieldTypes( + array $data, + string $path, + string $expectedType, + string $receivedType, + ): void { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected %s, %s received.', + $path, + $expectedType, + $receivedType, + )); + + Current::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'coordinates' => [['coord' => 'invalid'], 'coord', 'array', 'string']; + yield 'latitude' => [['coord' => ['lat' => '38.7']], 'lat', 'int|float', 'string']; + yield 'conditions' => [['weather' => 'Clouds'], 'weather', 'array', 'string']; + yield 'condition member' => [['weather' => ['Clouds']], 'weather.0', 'array', 'string']; + yield 'condition id' => [['weather' => [['id' => '802']]], 'id', 'int', 'string']; + yield 'measurements' => [['main' => 'invalid'], 'main', 'array', 'string']; + yield 'temperature' => [['main' => ['temp' => '22.5']], 'main.temp', 'int|float', 'string']; + yield 'wind speed' => [['wind' => ['speed' => '4.5']], 'speed', 'int|float', 'string']; + yield 'cloud coverage' => [['clouds' => ['all' => 48.5]], 'all', 'int', 'float']; + yield 'rain' => [['rain' => ['1h' => '2.5']], '1h', 'int|float', 'string']; + yield 'observation time' => [['dt' => '1785573885'], 'dt', 'int', 'string']; + yield 'country' => [['sys' => ['country' => 1]], 'sys.country', 'string', 'int']; + } +} diff --git a/tests/Unit/Entity/Weather/Forecast/PeriodTest.php b/tests/Unit/Entity/Weather/Forecast/PeriodTest.php new file mode 100644 index 0000000..486cbf0 --- /dev/null +++ b/tests/Unit/Entity/Weather/Forecast/PeriodTest.php @@ -0,0 +1,197 @@ +dateTime()?->getTimestamp()); + self::assertSame('UTC', $period->dateTime()?->getTimezone()->getName()); + self::assertSame(22.54, $period->temperature()); + self::assertSame(Unit::CELSIUS, $period->temperatureUnit()); + self::assertSame('22.54 °C', $period->temperatureWithUnit()); + self::assertSame(22.83, $period->feelsLikeTemperature()); + self::assertSame(22.54, $period->minimumTemperature()); + self::assertSame(23.52, $period->maximumTemperature()); + self::assertSame(1016, $period->pressure()); + self::assertSame('1016 hPa', $period->pressureWithUnit()); + self::assertSame(76, $period->humidity()); + self::assertSame('76 %', $period->humidityWithUnit()); + self::assertSame(1016, $period->seaLevelPressure()); + self::assertSame(1006, $period->groundLevelPressure()); + self::assertSame(18.1, $period->dewPoint()); + self::assertSame(Unit::CELSIUS, $period->dewPointUnit()); + self::assertSame('18.1 °C', $period->dewPointWithUnit()); + self::assertSame(10000, $period->visibility()); + self::assertSame('10000 m', $period->visibilityWithUnit()); + + self::assertCount(1, $period->conditions()); + self::assertSame('Clouds', $period->conditions()[0]->group()); + self::assertSame(48, $period->clouds()?->coverage()); + self::assertSame(3.36, $period->wind()?->speed()); + self::assertSame('3.36 m/s', $period->wind()?->speedWithUnit()); + self::assertSame(349, $period->wind()?->direction()); + self::assertSame(4.91, $period->wind()?->gust()); + + self::assertSame(0.0, $period->precipitationProbability()); + self::assertSame(Unit::PERCENT, $period->precipitationProbabilityUnit()); + self::assertSame('0 %', $period->precipitationProbabilityWithUnit()); + self::assertNull($period->rain()); + self::assertNull($period->snow()); + self::assertSame(PartOfDay::DAY, $period->partOfDay()); + } + + public function testHydratesConditionalRain(): void + { + $period = self::fromFixture('weather/forecast/rain.json'); + + self::assertSame('Rain', $period->conditions()[0]->group()); + self::assertSame(100.0, $period->precipitationProbability()); + self::assertSame('100 %', $period->precipitationProbabilityWithUnit()); + self::assertSame(5.49, $period->rain()?->lastThreeHours()); + self::assertSame(Unit::MILLIMETER, $period->rain()?->lastThreeHoursUnit()); + self::assertSame('5.49 mm', $period->rain()?->lastThreeHoursWithUnit()); + self::assertNull($period->snow()); + } + + public function testHydratesConditionalSnowAndMissingVisibility(): void + { + $period = self::fromFixture('weather/forecast/snow.json'); + + self::assertSame('Snow', $period->conditions()[0]->group()); + self::assertSame(3.0, $period->snow()?->lastThreeHours()); + self::assertSame('3 mm', $period->snow()?->lastThreeHoursWithUnit()); + self::assertNull($period->rain()); + self::assertNull($period->visibility()); + self::assertNull($period->visibilityWithUnit()); + self::assertSame(PartOfDay::NIGHT, $period->partOfDay()); + } + + public function testRetainsUnitsFromHydrationContext(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + + $period = Period::fromArray([ + 'main' => [ + 'temp' => 72.5, + 'dew_point' => 60.25, + ], + 'wind' => ['speed' => 10], + ], $context); + + self::assertSame(Unit::FAHRENHEIT, $period->temperatureUnit()); + self::assertSame('72.5 °F', $period->temperatureWithUnit()); + self::assertSame(Unit::MILES_PER_HOUR, $period->wind()?->speedUnit()); + self::assertSame('10 mph', $period->wind()?->speedWithUnit()); + self::assertSame('60.25 °F', $period->dewPointWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + self::assertSame([], Period::fromArray(['weather' => null])->conditions()); + + $period = Period::fromArray([ + 'dt' => null, + 'main' => [ + 'temp' => null, + 'dew_point' => null, + ], + 'weather' => [['icon' => null]], + 'clouds' => ['all' => null], + 'wind' => null, + 'rain' => ['3h' => null, 'unknown' => true], + 'snow' => null, + 'sys' => null, + 'dt_txt' => new \stdClass(), + 'unknown' => new \stdClass(), + ]); + + self::assertNull($period->dateTime()); + self::assertNull($period->temperature()); + self::assertNull($period->temperatureWithUnit()); + self::assertNull($period->feelsLikeTemperature()); + self::assertNull($period->pressure()); + self::assertNull($period->dewPoint()); + self::assertNull($period->dewPointWithUnit()); + self::assertCount(1, $period->conditions()); + self::assertNull($period->conditions()[0]->icon()); + self::assertNull($period->clouds()?->coverage()); + self::assertNull($period->wind()); + self::assertNull($period->visibility()); + self::assertNull($period->precipitationProbability()); + self::assertNull($period->precipitationProbabilityWithUnit()); + self::assertNull($period->rain()?->lastThreeHours()); + self::assertNull($period->rain()?->lastThreeHoursWithUnit()); + self::assertNull($period->snow()); + self::assertNull($period->partOfDay()); + } + + public function testRejectsUnknownPartOfDay(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '"sys.pod" expected "d" or "n", "x" received.', + ); + + Period::fromArray(['sys' => ['pod' => 'x']]); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFieldTypes( + array $data, + string $path, + string $expectedType, + string $receivedType, + ): void { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected %s, %s received.', + $path, + $expectedType, + $receivedType, + )); + + Period::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'forecast time' => [['dt' => '1785574800'], 'dt', 'int', 'string']; + yield 'measurements' => [['main' => 'invalid'], 'main', 'array', 'string']; + yield 'temperature' => [['main' => ['temp' => '22.5']], 'main.temp', 'int|float', 'string']; + yield 'dew point' => [['main' => ['dew_point' => '18.1']], 'main.dew_point', 'int|float', 'string']; + yield 'conditions' => [['weather' => 'Clouds'], 'weather', 'array', 'string']; + yield 'condition member' => [['weather' => ['Clouds']], 'weather.0', 'array', 'string']; + yield 'clouds' => [['clouds' => ['all' => 48.5]], 'all', 'int', 'float']; + yield 'wind' => [['wind' => ['speed' => '3.5']], 'speed', 'int|float', 'string']; + yield 'visibility' => [['visibility' => 10000.5], 'visibility', 'int', 'float']; + yield 'precipitation probability' => [['pop' => '1'], 'pop', 'int|float', 'string']; + yield 'rain' => [['rain' => ['3h' => '5.49']], '3h', 'int|float', 'string']; + yield 'system' => [['sys' => 'invalid'], 'sys', 'array', 'string']; + yield 'part of day' => [['sys' => ['pod' => 1]], 'sys.pod', 'string', 'int']; + } + + private static function fromFixture(string $path): Period + { + $response = Fixture::json($path); + + return Period::fromArray($response['list'][0]); + } +} diff --git a/tests/Unit/Entity/Weather/ForecastTest.php b/tests/Unit/Entity/Weather/ForecastTest.php new file mode 100644 index 0000000..cb8c8af --- /dev/null +++ b/tests/Unit/Entity/Weather/ForecastTest.php @@ -0,0 +1,129 @@ +count()); + self::assertCount(40, $forecast->periods()); + self::assertSame(1785574800, $forecast->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame(22.54, $forecast->periods()[0]->temperature()); + + $city = $forecast->city(); + + self::assertSame(6458923, $city?->id()); + self::assertSame('Lisbon Municipality', $city?->name()); + self::assertSame(38.7223, $city?->coordinates()?->latitude()); + self::assertSame(-9.1393, $city?->coordinates()?->longitude()); + self::assertSame('PT', $city?->countryCode()); + self::assertSame(0, $city?->population()); + self::assertSame(3600, $city?->timezoneOffset()); + self::assertSame(1785562660, $city?->sunriseAt()?->getTimestamp()); + self::assertSame('UTC', $city?->sunriseAt()?->getTimezone()->getName()); + self::assertSame(1785613679, $city?->sunsetAt()?->getTimestamp()); + self::assertSame('UTC', $city?->sunsetAt()?->getTimezone()->getName()); + } + + public function testPropagatesHydrationContextToPeriods(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + + $forecast = Forecast::fromArray([ + 'list' => [ + ['main' => ['temp' => 72.5]], + ], + ], $context); + + self::assertSame(Unit::FAHRENHEIT, $forecast->periods()[0]->temperatureUnit()); + self::assertSame('72.5 °F', $forecast->periods()[0]->temperatureWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + self::assertNull(Forecast::fromArray(['city' => null])->city()); + self::assertNull(Forecast::fromArray(['city' => []])->city()?->coordinates()); + + $forecast = Forecast::fromArray([ + 'cod' => new \stdClass(), + 'message' => new \stdClass(), + 'cnt' => null, + 'list' => null, + 'city' => [ + 'id' => null, + 'coord' => [ + 'lat' => null, + 'unknown' => true, + ], + 'sunrise' => null, + 'unknown' => new \stdClass(), + ], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($forecast->count()); + self::assertSame([], $forecast->periods()); + self::assertNull($forecast->city()?->id()); + self::assertNull($forecast->city()?->name()); + self::assertNull($forecast->city()?->coordinates()?->latitude()); + self::assertNull($forecast->city()?->coordinates()?->longitude()); + self::assertNull($forecast->city()?->countryCode()); + self::assertNull($forecast->city()?->population()); + self::assertNull($forecast->city()?->timezoneOffset()); + self::assertNull($forecast->city()?->sunriseAt()); + self::assertNull($forecast->city()?->sunsetAt()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFieldTypes( + array $data, + string $path, + string $expectedType, + string $receivedType, + ): void { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected %s, %s received.', + $path, + $expectedType, + $receivedType, + )); + + Forecast::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'count' => [['cnt' => 40.0], 'cnt', 'int', 'float']; + yield 'periods' => [['list' => 'invalid'], 'list', 'array', 'string']; + yield 'period member' => [['list' => ['invalid']], 'list.0', 'array', 'string']; + yield 'city' => [['city' => 'invalid'], 'city', 'array', 'string']; + yield 'city id' => [['city' => ['id' => '1']], 'id', 'int', 'string']; + yield 'city name' => [['city' => ['name' => 1]], 'name', 'string', 'int']; + yield 'coordinates' => [['city' => ['coord' => 'invalid']], 'coord', 'array', 'string']; + yield 'latitude' => [['city' => ['coord' => ['lat' => '38.7']]], 'lat', 'int|float', 'string']; + yield 'country' => [['city' => ['country' => 1]], 'country', 'string', 'int']; + yield 'population' => [['city' => ['population' => 1.5]], 'population', 'int', 'float']; + yield 'timezone' => [['city' => ['timezone' => '3600']], 'timezone', 'int', 'string']; + yield 'sunrise' => [['city' => ['sunrise' => '1785562660']], 'sunrise', 'int', 'string']; + yield 'sunset' => [['city' => ['sunset' => 1785613679.5]], 'sunset', 'int', 'float']; + } +} diff --git a/tests/Unit/Enum/LanguageTest.php b/tests/Unit/Enum/LanguageTest.php new file mode 100644 index 0000000..6bdb47e --- /dev/null +++ b/tests/Unit/Enum/LanguageTest.php @@ -0,0 +1,73 @@ +name] = $language->value; + } + + self::assertSame([ + 'AFRIKAANS' => 'af', + 'ALBANIAN' => 'sq', + 'ARABIC' => 'ar', + 'AZERBAIJANI' => 'az', + 'BASQUE' => 'eu', + 'BELARUSIAN' => 'be', + 'BULGARIAN' => 'bg', + 'CATALAN' => 'ca', + 'CHINESE_SIMPLIFIED' => 'zh_cn', + 'CHINESE_TRADITIONAL' => 'zh_tw', + 'CROATIAN' => 'hr', + 'CZECH' => 'cz', + 'DANISH' => 'da', + 'DUTCH' => 'nl', + 'ENGLISH' => 'en', + 'FINNISH' => 'fi', + 'FRENCH' => 'fr', + 'GALICIAN' => 'gl', + 'GERMAN' => 'de', + 'GREEK' => 'el', + 'HEBREW' => 'he', + 'HINDI' => 'hi', + 'HUNGARIAN' => 'hu', + 'ICELANDIC' => 'is', + 'INDONESIAN' => 'id', + 'ITALIAN' => 'it', + 'JAPANESE' => 'ja', + 'KOREAN' => 'kr', + 'KURMANJI' => 'ku', + 'LATVIAN' => 'la', + 'LITHUANIAN' => 'lt', + 'MACEDONIAN' => 'mk', + 'NORWEGIAN' => 'no', + 'PERSIAN_FARSI' => 'fa', + 'POLISH' => 'pl', + 'PORTUGUESE' => 'pt', + 'PORTUGUESE_BRAZIL' => 'pt_br', + 'ROMANIAN' => 'ro', + 'RUSSIAN' => 'ru', + 'SERBIAN' => 'sr', + 'SLOVAK' => 'sk', + 'SLOVENIAN' => 'sl', + 'SPANISH' => 'es', + 'SPANISH_SP' => 'sp', + 'SWEDISH' => 'sv', + 'SWEDISH_SE' => 'se', + 'THAI' => 'th', + 'TURKISH' => 'tr', + 'UKRAINIAN' => 'uk', + 'UKRAINIAN_UA' => 'ua', + 'VIETNAMESE' => 'vi', + 'ZULU' => 'zu', + ], $actual); + } +} diff --git a/tests/Unit/Enum/MapLayerTest.php b/tests/Unit/Enum/MapLayerTest.php new file mode 100644 index 0000000..70ea290 --- /dev/null +++ b/tests/Unit/Enum/MapLayerTest.php @@ -0,0 +1,23 @@ + 'clouds_new', + 'PRECIPITATION' => 'precipitation_new', + 'PRESSURE' => 'pressure_new', + 'WIND' => 'wind_new', + 'TEMPERATURE' => 'temp_new', + ], array_combine( + array_column(MapLayer::cases(), 'name'), + array_column(MapLayer::cases(), 'value'), + )); + } +} diff --git a/tests/Unit/Enum/UnitsTest.php b/tests/Unit/Enum/UnitsTest.php new file mode 100644 index 0000000..1c40081 --- /dev/null +++ b/tests/Unit/Enum/UnitsTest.php @@ -0,0 +1,73 @@ +temperatureUnit()); + self::assertSame($expected[1], $units->speedUnit()); + } + + /** + * @return iterable + */ + public static function unitMappings(): iterable + { + yield 'standard' => [ + Units::STANDARD, + [Unit::KELVIN, Unit::METERS_PER_SECOND], + ]; + yield 'metric' => [ + Units::METRIC, + [Unit::CELSIUS, Unit::METERS_PER_SECOND], + ]; + yield 'imperial' => [ + Units::IMPERIAL, + [Unit::FAHRENHEIT, Unit::MILES_PER_HOUR], + ]; + } + + public function testItUsesOpenWeatherQueryValues(): void + { + self::assertSame('standard', Units::STANDARD->value); + self::assertSame('metric', Units::METRIC->value); + self::assertSame('imperial', Units::IMPERIAL->value); + } + + public function testMeasurementUnitsExposeDisplaySymbols(): void + { + self::assertSame([ + 'KELVIN' => 'K', + 'CELSIUS' => '°C', + 'FAHRENHEIT' => '°F', + 'METERS_PER_SECOND' => 'm/s', + 'MILES_PER_HOUR' => 'mph', + 'HECTOPASCAL' => 'hPa', + 'PERCENT' => '%', + 'METER' => 'm', + 'DEGREE' => '°', + 'MILLIMETER' => 'mm', + 'MILLIMETERS_PER_HOUR' => 'mm/h', + 'MICROGRAMS_PER_CUBIC_METER' => 'µg/m³', + ], array_combine( + array_column(Unit::cases(), 'name'), + array_map( + static fn (Unit $unit): string => $unit->symbol(), + Unit::cases(), + ), + )); + } +} diff --git a/tests/Unit/Exception/HydrationExceptionTest.php b/tests/Unit/Exception/HydrationExceptionTest.php new file mode 100644 index 0000000..cca8865 --- /dev/null +++ b/tests/Unit/Exception/HydrationExceptionTest.php @@ -0,0 +1,24 @@ +getMessage() + ); + } +} diff --git a/tests/Unit/Formatting/MeasurementFormatterTest.php b/tests/Unit/Formatting/MeasurementFormatterTest.php new file mode 100644 index 0000000..94f0ff0 --- /dev/null +++ b/tests/Unit/Formatting/MeasurementFormatterTest.php @@ -0,0 +1,38 @@ + + */ + public static function measurements(): iterable + { + yield 'integer-valued float' => [30.0, Unit::CELSIUS, '30 °C']; + yield 'decimal float' => [5.2, Unit::METERS_PER_SECOND, '5.2 m/s']; + yield 'integer' => [1013, Unit::HECTOPASCAL, '1013 hPa']; + yield 'negative value' => [-2.75, Unit::CELSIUS, '-2.75 °C']; + yield 'zero' => [0, Unit::PERCENT, '0 %']; + yield 'full precision' => [5.2345678901234, Unit::MILLIMETER, '5.2345678901234 mm']; + } + + public function testItPreservesAnUnavailableMeasurement(): void + { + self::assertNull(MeasurementFormatter::format(null, Unit::CELSIUS)); + } +} diff --git a/tests/Unit/Geocoding/ZipLocationTest.php b/tests/Unit/Geocoding/ZipLocationTest.php deleted file mode 100644 index f335f7f..0000000 --- a/tests/Unit/Geocoding/ZipLocationTest.php +++ /dev/null @@ -1,26 +0,0 @@ - '1234-567', - 'name' => 'name', - 'country' => 'PT', - 'lat' => 50, - 'lon' => 50 - ]); - - $this->assertSame('1234-567', $entity->getZipCode()); - $this->assertSame('name', $entity->getName()); - $this->assertSame('PT', $entity->getCountryCode()); - $this->assertInstanceOf(Coordinate::class, $entity->getCoordinate()); - } -} \ No newline at end of file diff --git a/tests/Unit/Hydration/PayloadReaderTest.php b/tests/Unit/Hydration/PayloadReaderTest.php new file mode 100644 index 0000000..b10b795 --- /dev/null +++ b/tests/Unit/Hydration/PayloadReaderTest.php @@ -0,0 +1,183 @@ + 'Lisbon', + 'timezone' => 3600, + 'temperature' => 20, + 'cloudiness' => 12.5, + 'daylight' => true, + 'rain' => ['1h' => 0.4], + 'alerts' => ['alert-1', 'alert-2'], + 'created_at' => '2026-08-08T20:33:08.107Z', + ], 'Weather'); + + self::assertSame('Lisbon', $reader->nullableString('name')); + self::assertSame('Lisbon', $reader->requiredString('name')); + self::assertSame(3600, $reader->nullableInt('timezone')); + self::assertSame(3600, $reader->requiredInt('timezone')); + self::assertSame(20.0, $reader->nullableFloat('temperature')); + self::assertSame(20.0, $reader->requiredFloat('temperature')); + self::assertSame(12.5, $reader->nullableFloat('cloudiness')); + self::assertTrue($reader->nullableBool('daylight')); + self::assertSame(['1h' => 0.4], $reader->nullableArray('rain')); + self::assertSame( + ['alert-1', 'alert-2'], + $reader->nullableStringList('alerts') + ); + self::assertSame( + '2026-08-08T20:33:08+00:00', + $reader->nullableDateTime('created_at')?->format(\DateTimeInterface::ATOM), + ); + self::assertSame( + '2026-08-08T20:33:08+00:00', + $reader->requiredDateTime('created_at')->format(\DateTimeInterface::ATOM), + ); + } + + public function testMissingAndNullValuesAreTolerated(): void + { + $reader = PayloadReader::from(['name' => null], 'Weather'); + + self::assertNull($reader->nullableString('missing')); + self::assertNull($reader->nullableString('name')); + self::assertNull($reader->nullableStringList('alerts')); + self::assertNull($reader->nullableTimestamp('observed_at')); + self::assertNull($reader->nullableDateTime('created_at')); + } + + public function testUnknownFieldsAreIgnored(): void + { + $reader = PayloadReader::from([ + 'name' => 'Lisbon', + 'undocumented' => new \stdClass(), + ], 'Weather'); + + self::assertSame('Lisbon', $reader->nullableString('name')); + } + + public function testItReadsNestedFieldPaths(): void + { + $reader = PayloadReader::from([ + 'main' => [ + 'temp' => 30, + ], + ], 'Weather'); + + self::assertSame(30.0, $reader->nullableFloat('main.temp')); + } + + public function testItRequiresIntermediateValuesToBeArrays(): void + { + $reader = PayloadReader::from(['main' => 'unexpected'], 'Weather'); + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + 'Cannot hydrate Weather: "main" expected array, string received.' + ); + + $reader->nullableFloat('main.temp'); + } + + public function testItHydratesTimestampsAsImmutableUtcValues(): void + { + $reader = PayloadReader::from(['observed_at' => 1700000000], 'Weather'); + + $timestamp = $reader->nullableTimestamp('observed_at'); + + self::assertInstanceOf(\DateTimeImmutable::class, $timestamp); + self::assertSame('UTC', $timestamp->getTimezone()->getName()); + self::assertSame('2023-11-14T22:13:20+00:00', $timestamp->format(\DateTimeInterface::ATOM)); + } + + #[DataProvider('invalidDateTimeProvider')] + public function testItRejectsMalformedDateTimeStrings(string $value): void + { + $reader = PayloadReader::from(['created_at' => $value], 'Weather'); + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + 'Cannot hydrate Weather: "created_at" expected ISO 8601 date-time string, "%s" received.', + $value, + )); + + $reader->nullableDateTime('created_at'); + } + + public static function invalidDateTimeProvider(): iterable + { + yield 'relative value' => ['tomorrow']; + yield 'invalid calendar date' => ['2026-02-31T12:00:00Z']; + } + + #[DataProvider('missingRequiredValueProvider')] + public function testItRejectsMissingRequiredValues( + string $method, + string $expectedType, + ): void { + $reader = PayloadReader::from([], 'Weather'); + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + 'Cannot hydrate Weather: "value" expected %s, null received.', + $expectedType, + )); + + $reader->{$method}('value'); + } + + public static function missingRequiredValueProvider(): iterable + { + yield 'string' => ['requiredString', 'string']; + yield 'integer' => ['requiredInt', 'int']; + yield 'float' => ['requiredFloat', 'int|float']; + yield 'date and time' => ['requiredDateTime', 'ISO 8601 date-time string']; + } + + #[DataProvider('invalidValueProvider')] + public function testItRejectsKnownFieldsWithInvalidTypes( + string $method, + mixed $value, + string $expectedType, + string $receivedType + ): void { + $reader = PayloadReader::from([ + 'main' => [ + 'value' => $value, + ], + ], 'Weather'); + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + 'Cannot hydrate Weather: "main.value" expected %s, %s received.', + $expectedType, + $receivedType + )); + + $reader->{$method}('main.value'); + } + + /** + * @return iterable + */ + public static function invalidValueProvider(): iterable + { + yield 'string' => ['nullableString', 1, 'string', 'int']; + yield 'integer' => ['nullableInt', 1.5, 'int', 'float']; + yield 'float' => ['nullableFloat', '20.5', 'int|float', 'string']; + yield 'boolean' => ['nullableBool', 1, 'bool', 'int']; + yield 'array' => ['nullableArray', new \stdClass(), 'array', 'stdClass']; + yield 'timestamp' => ['nullableTimestamp', '1700000000', 'int', 'string']; + yield 'date and time' => ['nullableDateTime', 1700000000, 'string', 'int']; + } +} diff --git a/tests/Unit/IconTest.php b/tests/Unit/IconTest.php deleted file mode 100644 index 7d0d984..0000000 --- a/tests/Unit/IconTest.php +++ /dev/null @@ -1,19 +0,0 @@ - '01d' - ]); - - $this->assertSame('01d', $entity->getId()); - $this->assertSame('https://openweathermap.org/img/wn/01d@4x.png', $entity->getUrl()); - } -} \ No newline at end of file diff --git a/tests/Unit/LocationTest.php b/tests/Unit/LocationTest.php deleted file mode 100644 index fd1bae2..0000000 --- a/tests/Unit/LocationTest.php +++ /dev/null @@ -1,45 +0,0 @@ - 50, - 'lon' => 50, - 'id' => 100, - 'name' => 'name', - 'state' => 'state', - 'country' => 'PT', - 'local_names' => [ - 'en' => 'local name' - ], - 'population' => 100, - 'timezone_offset' => 0, - 'sunrise' => 1661834187, - 'sunset' => 1661882248 - ]); - - $this->assertInstanceOf(Coordinate::class, $entity->getCoordinate()); - $this->assertSame(100, $entity->getId()); - $this->assertSame('name', $entity->getName()); - $this->assertSame('state', $entity->getState()); - $this->assertSame('PT', $entity->getCountryCode()); - - $this->assertSame(['en' => 'local name'], $entity->getLocalNames()); - $this->assertSame('local name', $entity->getLocalName('en')); - $this->assertSame(null, $entity->getLocalName('pt')); - - $this->assertSame(100, $entity->getPopulation()); - $this->assertInstanceOf(Timezone::class, $entity->getTimezone()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunriseAt()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunsetAt()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/AlertTest.php b/tests/Unit/OneCall/AlertTest.php deleted file mode 100644 index d143863..0000000 --- a/tests/Unit/OneCall/AlertTest.php +++ /dev/null @@ -1,28 +0,0 @@ - 'sender name', - 'event' => 'event name', - 'start' => 1715561801, - 'end' => 1715616968, - 'description' => 'description', - 'tags' => ['tag1', 'tag2'] - ]); - - $this->assertSame('sender name', $entity->getSenderName()); - $this->assertSame('event name', $entity->getEventName()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getStartsAt()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getEndsAt()); - $this->assertSame('description', $entity->getDescription()); - $this->assertSame(['tag1', 'tag2'], $entity->getTags()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/DayDataTest.php b/tests/Unit/OneCall/DayDataTest.php deleted file mode 100644 index 9b381b4..0000000 --- a/tests/Unit/OneCall/DayDataTest.php +++ /dev/null @@ -1,79 +0,0 @@ - 1715561801, - 'pressure' => 1000, - 'humidity' => 10, - 'dew_point' => 10, - 'uvi' => 1, - 'clouds' => 10, - 'wind_speed' => 10, - 'wind_deg' => 10, - 'wind_gust' => 10, - 'weather' => [ - [ - 'id' => 200, - 'main' => 'name', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'rain' => 1, - 'snow' => 1, - 'temp' => [ - 'morn' => 10, - 'day' => 15, - 'eve' => 15, - 'night' => 10, - 'min' => 10, - 'max' => 15, - ], - 'feels_like' => [ - 'morn' => 10, - 'day' => 15, - 'eve' => 15, - 'night' => 10 - ], - 'pop' => 1, - 'summary' => 'summary', - 'moon_phase' => 1, - 'moonrise' => 1715561801, - 'moonset' => 1715616968, - 'sunrise' => 1715561801, - 'sunset' => 1715616968 - ]); - - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame(1000, $entity->getAtmosphericPressure()); - $this->assertSame(10, $entity->getHumidity()); - $this->assertSame(10.0, $entity->getDewPoint()); - $this->assertSame(1.0, $entity->getUltraVioletIndex()); - $this->assertSame(10, $entity->getCloudiness()); - $this->assertInstanceOf(Wind::class, $entity->getWind()); - $this->assertContainsOnlyInstancesOf(Condition::class, $entity->getConditions()); - $this->assertSame(1.0, $entity->getRainVolume()); - $this->assertSame(1.0, $entity->getSnowVolume()); - $this->assertInstanceOf(Temperature::class, $entity->getTemperature()); - $this->assertInstanceOf(Temperature::class, $entity->getTemperatureFeelsLike()); - $this->assertSame(100, $entity->getPrecipitationProbability()); - $this->assertSame('summary', $entity->getSummary()); - $this->assertInstanceOf(MoonPhase::class, $entity->getMoonPhase()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getMoonriseAt()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getMoonsetAt()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunriseAt()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunsetAt()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/HourDataTest.php b/tests/Unit/OneCall/HourDataTest.php deleted file mode 100644 index aaef7dc..0000000 --- a/tests/Unit/OneCall/HourDataTest.php +++ /dev/null @@ -1,55 +0,0 @@ - 1715561801, - 'pressure' => 1000, - 'humidity' => 10, - 'dew_point' => 10, - 'uvi' => 1, - 'clouds' => 10, - 'wind_speed' => 10, - 'wind_deg' => 10, - 'wind_gust' => 10, - 'weather' => [ - [ - 'id' => 200, - 'main' => 'name', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'rain' => 1, - 'snow' => 1, - 'temp' => 10, - 'feels_like' => 10, - 'visibility' => 10000, - 'pop' => 1 - ]); - - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame(1000, $entity->getAtmosphericPressure()); - $this->assertSame(10, $entity->getHumidity()); - $this->assertSame(10.0, $entity->getDewPoint()); - $this->assertSame(1.0, $entity->getUltraVioletIndex()); - $this->assertSame(10, $entity->getCloudiness()); - $this->assertInstanceOf(Wind::class, $entity->getWind()); - $this->assertContainsOnlyInstancesOf(Condition::class, $entity->getConditions()); - $this->assertSame(1.0, $entity->getRainVolume()); - $this->assertSame(1.0, $entity->getSnowVolume()); - $this->assertSame(10.0, $entity->getTemperature()); - $this->assertSame(10.0, $entity->getTemperatureFeelsLike()); - $this->assertSame(10000, $entity->getVisibility()); - $this->assertSame(100, $entity->getPrecipitationProbability()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/MinuteDataTest.php b/tests/Unit/OneCall/MinuteDataTest.php deleted file mode 100644 index 81f1e85..0000000 --- a/tests/Unit/OneCall/MinuteDataTest.php +++ /dev/null @@ -1,20 +0,0 @@ - 1715561801, - 'precipitation' => 1, - ]); - - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame(1.0, $entity->getPrecipitation()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/MoonPhaseTest.php b/tests/Unit/OneCall/MoonPhaseTest.php deleted file mode 100644 index e4b1094..0000000 --- a/tests/Unit/OneCall/MoonPhaseTest.php +++ /dev/null @@ -1,20 +0,0 @@ - 1 - ]); - - $this->assertSame(1.0, $entity->getValue()); - $this->assertSame('New Moon', $entity->getName()); - $this->assertSame('NEW_MOON', $entity->getSystemName()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/TemperatureTest.php b/tests/Unit/OneCall/TemperatureTest.php deleted file mode 100644 index 5e5bdd5..0000000 --- a/tests/Unit/OneCall/TemperatureTest.php +++ /dev/null @@ -1,28 +0,0 @@ - 10, - 'day' => 15, - 'eve' => 15, - 'night' => 10, - 'min' => 10, - 'max' => 15, - ]); - - $this->assertSame(10.0, $entity->getMorning()); - $this->assertSame(15.0, $entity->getDay()); - $this->assertSame(15.0, $entity->getEvening()); - $this->assertSame(10.0, $entity->getNight()); - $this->assertSame(10.0, $entity->getMin()); - $this->assertSame(15.0, $entity->getMax()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/WeatherDataTest.php b/tests/Unit/OneCall/WeatherDataTest.php deleted file mode 100644 index 9d040f2..0000000 --- a/tests/Unit/OneCall/WeatherDataTest.php +++ /dev/null @@ -1,57 +0,0 @@ - 1715561801, - 'pressure' => 1000, - 'humidity' => 10, - 'dew_point' => 10, - 'uvi' => 1, - 'clouds' => 10, - 'wind_speed' => 10, - 'wind_deg' => 10, - 'wind_gust' => 10, - 'weather' => [ - [ - 'id' => 200, - 'main' => 'name', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'rain' => 1, - 'snow' => 1, - 'temp' => 10, - 'feels_like' => 10, - 'visibility' => 10000, - 'sunrise' => 1715561801, - 'sunset' => 1715616968 - ]); - - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame(1000, $entity->getAtmosphericPressure()); - $this->assertSame(10, $entity->getHumidity()); - $this->assertSame(10.0, $entity->getDewPoint()); - $this->assertSame(1.0, $entity->getUltraVioletIndex()); - $this->assertSame(10, $entity->getCloudiness()); - $this->assertInstanceOf(Wind::class, $entity->getWind()); - $this->assertContainsOnlyInstancesOf(Condition::class, $entity->getConditions()); - $this->assertSame(1.0, $entity->getRainVolume()); - $this->assertSame(1.0, $entity->getSnowVolume()); - $this->assertSame(10.0, $entity->getTemperature()); - $this->assertSame(10.0, $entity->getTemperatureFeelsLike()); - $this->assertSame(10000, $entity->getVisibility()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunriseAt()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunsetAt()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/WeatherMomentTest.php b/tests/Unit/OneCall/WeatherMomentTest.php deleted file mode 100644 index 19e6f27..0000000 --- a/tests/Unit/OneCall/WeatherMomentTest.php +++ /dev/null @@ -1,69 +0,0 @@ - 50, - 'lon' => 50, - 'timezone' => 'UTC', - 'timezone_offset' => 0, - 'data' => [ - [ - 'dt' => 1715561801, - 'pressure' => 1000, - 'humidity' => 10, - 'dew_point' => 10, - 'uvi' => 1, - 'clouds' => 10, - 'wind_speed' => 10, - 'wind_deg' => 10, - 'wind_gust' => 10, - 'weather' => [ - [ - 'id' => 200, - 'main' => 'name', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'rain' => 1, - 'snow' => 1, - 'temp' => 10, - 'feels_like' => 10, - 'visibility' => 10000, - 'sunrise' => 1715561801, - 'sunset' => 1715616968 - ] - ] - ]); - - $this->assertInstanceOf(Coordinate::class, $entity->getCoordinate()); - $this->assertInstanceOf(Timezone::class, $entity->getTimezone()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame(1000, $entity->getAtmosphericPressure()); - $this->assertSame(10, $entity->getHumidity()); - $this->assertSame(10.0, $entity->getDewPoint()); - $this->assertSame(1.0, $entity->getUltraVioletIndex()); - $this->assertSame(10, $entity->getCloudiness()); - $this->assertInstanceOf(Wind::class, $entity->getWind()); - $this->assertContainsOnlyInstancesOf(Condition::class, $entity->getConditions()); - $this->assertSame(1.0, $entity->getRainVolume()); - $this->assertSame(1.0, $entity->getSnowVolume()); - $this->assertSame(10.0, $entity->getTemperature()); - $this->assertSame(10.0, $entity->getTemperatureFeelsLike()); - $this->assertSame(10000, $entity->getVisibility()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunriseAt()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunsetAt()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/WeatherOverviewTest.php b/tests/Unit/OneCall/WeatherOverviewTest.php deleted file mode 100644 index cae347e..0000000 --- a/tests/Unit/OneCall/WeatherOverviewTest.php +++ /dev/null @@ -1,27 +0,0 @@ - 50, - 'lon' => 50, - 'tz' => '+00:00', - 'date' => '2025-01-01', - 'weather_overview' => 'Weather overview text' - ]); - - $this->assertInstanceOf(Coordinate::class, $entity->getCoordinate()); - $this->assertInstanceOf(Timezone::class, $entity->getTimezone()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame('Weather overview text', $entity->getOverview()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/WeatherSummaryTest.php b/tests/Unit/OneCall/WeatherSummaryTest.php deleted file mode 100644 index 642474c..0000000 --- a/tests/Unit/OneCall/WeatherSummaryTest.php +++ /dev/null @@ -1,59 +0,0 @@ - 50, - 'lon' => 50, - 'tz' => '+00:00', - 'date' => '2024-01-01', - 'cloud_cover' => [ - 'afternoon' => 10 - ], - 'humidity' => [ - 'afternoon' => 10 - ], - 'precipitation' => [ - 'total' => 1 - ], - 'temperature' => [ - 'morning' => 10, - 'afternoon' => 15, - 'evening' => 15, - 'night' => 10, - 'min' => 10, - 'max' => 15 - ], - 'pressure' => [ - 'afternoon' => 1000 - ], - 'wind' => [ - 'max' => [ - 'speed' => 10, - 'direction' => 10 - ] - ] - ]); - - $this->assertInstanceOf(Coordinate::class, $entity->getCoordinate()); - $this->assertInstanceOf(Timezone::class, $entity->getTimezone()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame(10, $entity->getCloudiness()); - $this->assertSame(10, $entity->getHumidity()); - $this->assertSame(1.0, $entity->getPrecipitation()); - $this->assertInstanceOf(Temperature::class, $entity->getTemperature()); - $this->assertSame(1000, $entity->getAtmosphericPressure()); - $this->assertInstanceOf(Wind::class, $entity->getWind()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/WeatherTest.php b/tests/Unit/OneCall/WeatherTest.php deleted file mode 100644 index 27174ae..0000000 --- a/tests/Unit/OneCall/WeatherTest.php +++ /dev/null @@ -1,147 +0,0 @@ - 50, - 'lon' => 50, - 'timezone' => 'UTC', - 'timezone_offset' => 0, - 'current' => [ - 'dt' => 1715561801, - 'pressure' => 1000, - 'humidity' => 10, - 'dew_point' => 10, - 'uvi' => 1, - 'clouds' => 10, - 'wind_speed' => 10, - 'wind_deg' => 10, - 'wind_gust' => 10, - 'weather' => [ - [ - 'id' => 200, - 'main' => 'name', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'rain' => 1, - 'snow' => 1, - 'temp' => 10, - 'feels_like' => 10, - 'visibility' => 10000, - 'sunrise' => 1715561801, - 'sunset' => 1715616968 - ], - 'minutely' => [ - [ - 'dt' => 1715561801, - 'precipitation' => 1 - ] - ], - 'hourly' => [ - [ - 'dt' => 1715561801, - 'pressure' => 1000, - 'humidity' => 10, - 'dew_point' => 10, - 'uvi' => 1, - 'clouds' => 10, - 'wind_speed' => 10, - 'wind_deg' => 10, - 'wind_gust' => 10, - 'weather' => [ - [ - 'id' => 200, - 'main' => 'name', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'rain' => 1, - 'snow' => 1, - 'temp' => 10, - 'feels_like' => 10, - 'visibility' => 10000, - 'pop' => 1 - ] - ], - 'daily' => [ - [ - 'dt' => 1715561801, - 'pressure' => 1000, - 'humidity' => 10, - 'dew_point' => 10, - 'uvi' => 1, - 'clouds' => 10, - 'wind_speed' => 10, - 'wind_deg' => 10, - 'wind_gust' => 10, - 'weather' => [ - [ - 'id' => 200, - 'main' => 'name', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'rain' => 1, - 'snow' => 1, - 'temp' => [ - 'morn' => 10, - 'day' => 15, - 'eve' => 15, - 'night' => 10, - 'min' => 10, - 'max' => 15, - ], - 'feels_like' => [ - 'morn' => 10, - 'day' => 15, - 'eve' => 15, - 'night' => 10 - ], - 'pop' => 1, - 'summary' => 'summary', - 'moon_phase' => 1, - 'moonrise' => 1715561801, - 'moonset' => 1715616968, - 'sunrise' => 1715561801, - 'sunset' => 1715616968 - ] - ], - 'alerts' => [ - [ - 'sender_name' => 'sender name', - 'event' => 'event name', - 'start' => 1715561801, - 'end' => 1715616968, - 'description' => 'description', - 'tags' => ['tag1', 'tag2'] - ] - ] - ]); - - $this->assertInstanceOf(Coordinate::class, $entity->getCoordinate()); - $this->assertInstanceOf(Timezone::class, $entity->getTimezone()); - $this->assertInstanceOf(WeatherData::class, $entity->getCurrent()); - $this->assertContainsOnlyInstancesOf(MinuteData::class, $entity->getMinutelyForecast()); - $this->assertContainsOnlyInstancesOf(HourData::class, $entity->getHourlyForecast()); - $this->assertContainsOnlyInstancesOf(DayData::class, $entity->getDailyForecast()); - $this->assertContainsOnlyInstancesOf(Alert::class, $entity->getAlerts()); - } -} \ No newline at end of file diff --git a/tests/Unit/OpenWeatherMapTest.php b/tests/Unit/OpenWeatherMapTest.php new file mode 100644 index 0000000..4cee864 --- /dev/null +++ b/tests/Unit/OpenWeatherMapTest.php @@ -0,0 +1,273 @@ +config()->get(OpenWeatherMap::OPTION_UNITS)); + self::assertSame(Language::ENGLISH, $api->config()->get(OpenWeatherMap::OPTION_LANGUAGE)); + } + + public function testMarksApiKeyParametersAsSensitive(): void + { + $constructor = new \ReflectionMethod(OpenWeatherMap::class, '__construct'); + $validator = new \ReflectionMethod(OpenWeatherMap::class, 'validateApiKey'); + + self::assertCount( + 1, + $constructor->getParameters()[0]->getAttributes(\SensitiveParameter::class), + ); + self::assertCount( + 1, + $validator->getParameters()[0]->getAttributes(\SensitiveParameter::class), + ); + } + + public function testAcceptsConfiguredUnitsAndKnownLanguage(): void + { + $api = new OpenWeatherMap('api-key', [ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + OpenWeatherMap::OPTION_LANGUAGE => Language::PORTUGUESE, + ]); + + self::assertSame(Units::IMPERIAL, $api->config()->get(OpenWeatherMap::OPTION_UNITS)); + self::assertSame(Language::PORTUGUESE, $api->config()->get(OpenWeatherMap::OPTION_LANGUAGE)); + } + + public function testAcceptsAnArbitraryNonEmptyLanguageCode(): void + { + $api = new OpenWeatherMap('api-key', [ + OpenWeatherMap::OPTION_LANGUAGE => ' future_language ', + ]); + + self::assertSame('future_language', $api->config()->get(OpenWeatherMap::OPTION_LANGUAGE)); + } + + public function testConfiguresBaseUrlQueryAuthenticationAndJsonPayloadDecoding(): void + { + $client = new Client(); + $client->addResponse(new Response(body: '{"ok":true}')); + + $api = new OpenWeatherMap(' secret '); + $api->setup()->client($client); + + $response = $api->send(Method::GET, '/data/2.5/weather'); + $request = $client->getLastRequest(); + + parse_str($request->getUri()->getQuery(), $query); + + self::assertSame('https://api.openweathermap.org/data/2.5/weather', sprintf( + '%s://%s%s', + $request->getUri()->getScheme(), + $request->getUri()->getHost(), + $request->getUri()->getPath() + )); + self::assertSame(['appid' => 'secret'], $query); + self::assertSame(['ok' => true], $response->data()); + } + + public function testReturnsMapImagesAsRawResponseData(): void + { + $contents = Fixture::contents('maps/tile/clouds-new.png'); + $client = new Client(); + $client->addResponse(new Response( + headers: ['Content-Type' => 'image/png'], + body: $contents, + )); + + $api = new OpenWeatherMap('api-key'); + $api->setup()->client($client); + + $response = $api->send( + Method::GET, + 'https://tile.openweathermap.org/map/clouds_new/1/1/1.png', + ); + + self::assertSame($contents, $response->data()); + } + + public function testDecodesMislabeledJsonBeforeMappingTheHttpError(): void + { + $data = Fixture::json('maps/tile/missing-key.json'); + $client = new Client(); + $client->addResponse(new Response( + status: 401, + headers: ['Content-Type' => 'image/png'], + body: Fixture::contents('maps/tile/missing-key.json'), + )); + + $api = new OpenWeatherMap('api-key'); + $api->setup()->client($client); + + try { + $api->send( + Method::GET, + 'https://tile.openweathermap.org/map/clouds_new/1/1/1.png', + ); + } catch (UnauthorizedException $exception) { + self::assertSame($data['message'], $exception->getMessage()); + self::assertSame(401, $exception->statusCode()); + self::assertSame(401, $exception->apiCode()); + self::assertSame($data, $exception->responseData()); + + return; + } + + self::fail(sprintf('Expected %s to be thrown.', UnauthorizedException::class)); + } + + #[DataProvider('httpErrors')] + public function testMapsHttpErrorsToApiException( + int $statusCode, + array $data, + string $expectedClass, + ?int $expectedApiCode, + string $expectedMessage + ): void { + $client = new Client(); + $client->addResponse(new Response( + status: $statusCode, + body: json_encode($data, JSON_THROW_ON_ERROR) + )); + + $api = new OpenWeatherMap('api-key'); + $api->setup()->client($client); + + try { + $api->send(Method::GET, '/data/2.5/weather'); + } catch (ApiException $exception) { + self::assertSame($expectedClass, $exception::class); + self::assertSame($expectedMessage, $exception->getMessage()); + self::assertSame($statusCode, $exception->statusCode()); + self::assertSame($expectedApiCode, $exception->apiCode()); + self::assertSame($data, $exception->responseData()); + + return; + } + + self::fail(sprintf('Expected %s to be thrown.', ApiException::class)); + } + + /** + * @return iterable, + * class-string, + * int|null, + * string + * }> + */ + public static function httpErrors(): iterable + { + yield 'bad request using code' => [ + 400, + ['code' => 400000, 'message' => 'Invalid parameter format'], + BadRequestException::class, + 400000, + 'Invalid parameter format', + ]; + yield 'unauthorized using numeric string cod' => [ + 401, + ['cod' => '401', 'message' => 'Invalid API key'], + UnauthorizedException::class, + 401, + 'Invalid API key', + ]; + yield 'not found' => [ + 404, + ['cod' => 404, 'message' => 'Data not found'], + NotFoundException::class, + 404, + 'Data not found', + ]; + yield 'unmapped client error' => [ + 418, + ['code' => 'unexpected', 'message' => 'Unexpected client error'], + UnexpectedErrorException::class, + null, + 'Unexpected client error', + ]; + yield 'too many requests' => [ + 429, + ['cod' => 429, 'message' => 'Too many requests'], + TooManyRequestsException::class, + 429, + 'Too many requests', + ]; + yield 'unexpected error' => [ + 503, + ['code' => 503, 'message' => 'Unexpected error'], + UnexpectedErrorException::class, + 503, + 'Unexpected error', + ]; + } + + public function testFallsBackToTheHttpErrorWhenPayloadHasNoMessage(): void + { + $client = new Client(); + $client->addResponse(new Response( + status: 503, + headers: ['Content-Type' => 'application/json'], + body: '{"unavailable":true}' + )); + + $api = new OpenWeatherMap('api-key'); + $api->setup()->client($client); + + try { + $api->send(Method::GET, '/data/2.5/weather'); + } catch (ApiException $exception) { + self::assertInstanceOf(UnexpectedErrorException::class, $exception); + self::assertSame( + 'OpenWeather API request failed with HTTP 503 (Service Unavailable).', + $exception->getMessage() + ); + self::assertSame(503, $exception->statusCode()); + self::assertNull($exception->apiCode()); + self::assertSame(['unavailable' => true], $exception->responseData()); + + return; + } + + self::fail(sprintf('Expected %s to be thrown.', ApiException::class)); + } + + #[DataProvider('invalidConfigurationProvider')] + public function testRejectsInvalidConfiguration(string $apiKey, array $options, string $message): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + new OpenWeatherMap($apiKey, $options); + } + + public static function invalidConfigurationProvider(): iterable + { + yield 'blank API key' => [' ', [], 'The API key must be a non-empty string.']; + yield 'unknown option' => ['api-key', ['unsupported' => true], 'Unknown OpenWeatherMap option: unsupported.']; + yield 'invalid units' => ['api-key', [OpenWeatherMap::OPTION_UNITS => 'metric'], 'The "units" option must be an instance of']; + yield 'invalid language type' => ['api-key', [OpenWeatherMap::OPTION_LANGUAGE => 123], 'The "language" option must be an instance of']; + yield 'blank language' => ['api-key', [OpenWeatherMap::OPTION_LANGUAGE => ' '], 'The "language" option must be a non-empty string.']; + } +} diff --git a/tests/Unit/Request/Stations/CloudLayerTest.php b/tests/Unit/Request/Stations/CloudLayerTest.php new file mode 100644 index 0000000..540d235 --- /dev/null +++ b/tests/Unit/Request/Stations/CloudLayerTest.php @@ -0,0 +1,84 @@ +distance()); + self::assertSame('BKN', $cloud->condition()); + self::assertSame('CB', $cloud->cumulus()); + self::assertSame([ + 'distance' => 1200.0, + 'condition' => 'BKN', + 'cumulus' => 'CB', + ], $cloud->toArray()); + } + + public function testOmitsUnavailableCloudLayerValues(): void + { + $cloud = new CloudLayer(condition: 'NSC'); + + self::assertNull($cloud->distance()); + self::assertSame('NSC', $cloud->condition()); + self::assertNull($cloud->cumulus()); + self::assertSame(['condition' => 'NSC'], $cloud->toArray()); + } + + public function testRejectsACloudLayerWithoutValues(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The cloud layer values must not be empty.', + ); + + new CloudLayer(); + } + + public function testRejectsANonFiniteDistance(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The cloud layer distance must be a finite number.', + ); + + new CloudLayer(distance: INF); + } + + #[DataProvider('blankStringValues')] + public function testRejectsABlankStringValue( + ?string $condition, + ?string $cumulus, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + new CloudLayer(condition: $condition, cumulus: $cumulus); + } + + public static function blankStringValues(): iterable + { + yield 'condition' => [ + ' ', + null, + 'The cloud layer condition must be a non-empty string.', + ]; + yield 'cumulus' => [ + null, + ' ', + 'The cloud layer cumulus type must be a non-empty string.', + ]; + } +} diff --git a/tests/Unit/Request/Stations/MeasurementTest.php b/tests/Unit/Request/Stations/MeasurementTest.php new file mode 100644 index 0000000..2d8da79 --- /dev/null +++ b/tests/Unit/Request/Stations/MeasurementTest.php @@ -0,0 +1,204 @@ +dateTime()->getTimezone()->getName()); + self::assertSame(19.5, $measurement->temperature()); + self::assertSame(2.4, $measurement->windSpeed()); + self::assertSame(4.1, $measurement->windGust()); + self::assertSame(180, $measurement->windDirection()); + self::assertSame(1012.0, $measurement->pressure()); + self::assertSame(68.0, $measurement->humidity()); + self::assertSame(0.2, $measurement->rainLastHour()); + self::assertSame(0.4, $measurement->rainLastSixHours()); + self::assertSame(0.6, $measurement->rainLastTwentyFourHours()); + self::assertSame(0.0, $measurement->snowLastHour()); + self::assertSame(0.1, $measurement->snowLastSixHours()); + self::assertSame(0.3, $measurement->snowLastTwentyFourHours()); + self::assertSame(12.5, $measurement->dewPoint()); + self::assertSame(20.1, $measurement->humidex()); + self::assertSame(19.8, $measurement->heatIndex()); + self::assertSame(10.0, $measurement->visibilityDistance()); + self::assertSame('N', $measurement->visibilityPrefix()); + self::assertSame([ + 'dt' => 1786224150, + 'temperature' => 19.5, + 'wind_speed' => 2.4, + 'wind_gust' => 4.1, + 'wind_deg' => 180, + 'pressure' => 1012.0, + 'humidity' => 68.0, + 'rain_1h' => 0.2, + 'rain_6h' => 0.4, + 'rain_24h' => 0.6, + 'snow_1h' => 0.0, + 'snow_6h' => 0.1, + 'snow_24h' => 0.3, + 'dew_point' => 12.5, + 'humidex' => 20.1, + 'heat_index' => 19.8, + 'visibility_distance' => 10.0, + 'visibility_prefix' => 'N', + ], $measurement->toArray()); + } + + public function testOmitsUnavailableScalarMeasurements(): void + { + $measurement = new Measurement( + dateTime: new \DateTimeImmutable('@1786231350'), + ); + + self::assertSame([ + 'dt' => 1786231350, + ], $measurement->toArray()); + } + + public function testMapsCloudLayers(): void + { + $clouds = [ + new CloudLayer(condition: 'SCT', distance: 800), + new CloudLayer(condition: 'BKN', distance: 1200, cumulus: 'CB'), + ]; + $measurement = new Measurement( + dateTime: new \DateTimeImmutable('@1786231350'), + clouds: $clouds, + ); + + self::assertSame($clouds, $measurement->clouds()); + self::assertSame([ + 'dt' => 1786231350, + 'clouds' => [ + ['distance' => 800.0, 'condition' => 'SCT'], + [ + 'distance' => 1200.0, + 'condition' => 'BKN', + 'cumulus' => 'CB', + ], + ], + ], $measurement->toArray()); + } + + public function testRejectsAnInvalidCloudLayer(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The cloud layer at index 0 must be an instance of', + ); + + new Measurement( + dateTime: new \DateTimeImmutable(), + clouds: ['invalid'], + ); + } + + public function testMapsWeather(): void + { + $weather = [ + new Weather(precipitation: 'RA', intensity: '-'), + new Weather(obscuration: 'FG'), + ]; + $measurement = new Measurement( + dateTime: new \DateTimeImmutable('@1786231350'), + weather: $weather, + ); + + self::assertSame($weather, $measurement->weather()); + self::assertSame([ + 'dt' => 1786231350, + 'weather' => [ + ['precipitation' => 'RA', 'intensity' => '-'], + ['obscuration' => 'FG'], + ], + ], $measurement->toArray()); + } + + public function testRejectsInvalidWeather(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The weather at index 0 must be an instance of', + ); + + new Measurement( + dateTime: new \DateTimeImmutable(), + weather: ['invalid'], + ); + } + + #[DataProvider('invalidWindDirections')] + public function testRejectsAnInvalidWindDirection(int $windDirection): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The wind direction must be between 0 and 360.', + ); + + new Measurement( + new \DateTimeImmutable(), + windDirection: $windDirection, + ); + } + + public static function invalidWindDirections(): iterable + { + yield 'below zero' => [-1]; + yield 'above 360' => [361]; + } + + public function testRejectsANonFiniteScalarMeasurement(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The temperature must be a finite number.', + ); + + new Measurement( + new \DateTimeImmutable(), + temperature: INF, + ); + } + + public function testRejectsABlankVisibilityPrefix(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The visibility prefix must be a non-empty string.', + ); + + new Measurement( + new \DateTimeImmutable(), + visibilityPrefix: ' ', + ); + } +} diff --git a/tests/Unit/Request/Stations/WeatherTest.php b/tests/Unit/Request/Stations/WeatherTest.php new file mode 100644 index 0000000..32080c6 --- /dev/null +++ b/tests/Unit/Request/Stations/WeatherTest.php @@ -0,0 +1,64 @@ +precipitation()); + self::assertSame('SH', $weather->descriptor()); + self::assertSame('-', $weather->intensity()); + self::assertSame('VC', $weather->proximity()); + self::assertSame('BR', $weather->obscuration()); + self::assertSame('SQ', $weather->other()); + self::assertSame([ + 'precipitation' => 'RA', + 'descriptor' => 'SH', + 'intensity' => '-', + 'proximity' => 'VC', + 'obscuration' => 'BR', + 'other' => 'SQ', + ], $weather->toArray()); + } + + public function testOmitsUnavailableWeatherValues(): void + { + $weather = new Weather(precipitation: 'SN'); + + self::assertNull($weather->descriptor()); + self::assertSame(['precipitation' => 'SN'], $weather->toArray()); + } + + public function testRejectsWeatherWithoutValues(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The weather values must not be empty.', + ); + + new Weather(); + } + + public function testRejectsABlankWeatherValue(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The weather precipitation must be a non-empty string.', + ); + + new Weather(precipitation: ' '); + } +} diff --git a/tests/Unit/Resource/AirPollutionTest.php b/tests/Unit/Resource/AirPollutionTest.php new file mode 100644 index 0000000..2a653a0 --- /dev/null +++ b/tests/Unit/Resource/AirPollutionTest.php @@ -0,0 +1,194 @@ +respondWithFixture('air-pollution/current/good.json'); + + $current = $this->api->airPollution()->current( + latitude: -33.8688, + longitude: 151.2093, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(Current::class, $current); + self::assertSame(AirQualityIndex::GOOD, $current->airQualityIndex()); + self::assertSame(-33.8679, $current->coordinates()?->latitude()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/2.5/air_pollution', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '-33.8688', + 'lon' => '151.2093', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testGetsAirPollutionForecastByCoordinates(): void + { + $this->respondWithFixture('air-pollution/forecast/good-to-moderate.json'); + + $forecast = $this->api->airPollution()->forecast( + latitude: 28.6139, + longitude: 77.209, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(Forecast::class, $forecast); + self::assertCount(96, $forecast->periods()); + self::assertSame(1785614400, $forecast->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame('GET', $request->getMethod()); + self::assertSame( + '/data/2.5/air_pollution/forecast', + $request->getUri()->getPath(), + ); + self::assertSame([ + 'lat' => '28.6139', + 'lon' => '77.209', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testGetsHistoricalAirPollutionByCoordinatesAndDateRange(): void + { + $this->respondWithFixture('air-pollution/history/success.json'); + + $history = $this->api->airPollution()->history( + latitude: 38.7223, + longitude: -9.1393, + startAt: new \DateTimeImmutable('@1782864000'), + endAt: new \DateTimeImmutable('@1782950400'), + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(History::class, $history); + self::assertCount(25, $history->periods()); + self::assertSame(1782864000, $history->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame('GET', $request->getMethod()); + self::assertSame( + '/data/2.5/air_pollution/history', + $request->getUri()->getPath(), + ); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1782864000', + 'end' => '1782950400', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testHistoryAllowsEqualRangeBoundaries(): void + { + $this->respondWithFixture('air-pollution/history/empty.json'); + + $boundary = new \DateTimeImmutable('@1604188800'); + + $this->api->airPollution()->history( + latitude: 38.7223, + longitude: -9.1393, + startAt: $boundary, + endAt: $boundary, + ); + + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1604188800', + 'end' => '1604188800', + 'appid' => 'api-key', + ], $this->query($this->client->getLastRequest())); + } + + public function testHistoryRejectsReversedRange(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The end date must be after or equal to the start date.', + ); + + $this->api->airPollution()->history( + latitude: 38.7223, + longitude: -9.1393, + startAt: new \DateTimeImmutable('@1782950400'), + endAt: new \DateTimeImmutable('@1782864000'), + ); + } + + public function testHistoryRejectsFutureEnd(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The end date must not be in the future.'); + + $this->api->airPollution()->history( + latitude: 38.7223, + longitude: -9.1393, + startAt: new \DateTimeImmutable('@0'), + endAt: new \DateTimeImmutable(sprintf('@%d', time() + 60)), + ); + } + + #[DataProvider('invalidCoordinates')] + public function testCurrentRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->airPollution()->current($latitude, $longitude); + } + + #[DataProvider('invalidCoordinates')] + public function testForecastRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->airPollution()->forecast($latitude, $longitude); + } + + #[DataProvider('invalidCoordinates')] + public function testHistoryRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->airPollution()->history( + $latitude, + $longitude, + new \DateTimeImmutable('@1604188800'), + new \DateTimeImmutable('@1604192400'), + ); + } + + public static function invalidCoordinates(): iterable + { + yield 'invalid latitude' => [ + 90.0001, + 0, + 'Latitude must be a finite number between -90 and 90.', + ]; + yield 'invalid longitude' => [ + 0, + 180.0001, + 'Longitude must be a finite number between -180 and 180.', + ]; + } +} diff --git a/tests/Unit/Resource/Concern/FluentConfigurationTest.php b/tests/Unit/Resource/Concern/FluentConfigurationTest.php new file mode 100644 index 0000000..7cca079 --- /dev/null +++ b/tests/Unit/Resource/Concern/FluentConfigurationTest.php @@ -0,0 +1,104 @@ +resource = (new TestableOpenWeatherMap('api-key'))->configurableResource(); + } + + public function testConfigurationIsImmutableAndChainable(): void + { + $configured = $this->resource + ->withUnits(Units::IMPERIAL) + ->withLanguage(Language::PORTUGUESE); + + self::assertNotSame($this->resource, $configured); + self::assertSame(Units::METRIC, $this->resource->resolvedUnitsValue()); + self::assertSame(Language::ENGLISH, $this->resource->resolvedLanguageValue()); + self::assertSame(Units::IMPERIAL, $configured->resolvedUnitsValue()); + self::assertSame(Language::PORTUGUESE, $configured->resolvedLanguageValue()); + } + + public function testLaterOverridesDoNotMutateEarlierClones(): void + { + $metric = $this->resource->withUnits(Units::METRIC); + $imperial = $metric->withUnits(Units::IMPERIAL); + + self::assertSame(Units::METRIC, $metric->resolvedUnitsValue()); + self::assertSame(Units::IMPERIAL, $imperial->resolvedUnitsValue()); + } + + public function testItAcceptsAnArbitraryLanguageCode(): void + { + $configured = $this->resource->withLanguage(' future_language '); + + self::assertSame('future_language', $configured->resolvedLanguageValue()); + } + + public function testItRejectsABlankLanguageCode(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The language must be a non-empty string.'); + + $this->resource->withLanguage(' '); + } + + public function testItResolvesApiConfigurationWithoutOverrides(): void + { + $resource = (new TestableOpenWeatherMap('api-key', [ + OpenWeatherMap::OPTION_UNITS => Units::STANDARD, + OpenWeatherMap::OPTION_LANGUAGE => Language::PORTUGUESE, + ]))->configurableResource(); + + self::assertSame(Units::STANDARD, $resource->resolvedUnitsValue()); + self::assertSame(Language::PORTUGUESE, $resource->resolvedLanguageValue()); + } + + public function testOverridesTakePrecedenceWhenResolvingConfiguration(): void + { + $configured = $this->resource + ->withUnits(Units::IMPERIAL) + ->withLanguage(Language::PORTUGUESE); + + self::assertSame(Units::IMPERIAL, $configured->resolvedUnitsValue()); + self::assertSame(Language::PORTUGUESE, $configured->resolvedLanguageValue()); + } +} + +final class TestableOpenWeatherMap extends OpenWeatherMap +{ + public function configurableResource(): ConfigurableResource + { + /** @var ConfigurableResource */ + return $this->resource(ConfigurableResource::class); + } +} + +final class ConfigurableResource extends Resource +{ + use WithLanguage; + use WithUnits; + + public function resolvedLanguageValue(): Language|string + { + return $this->resolvedLanguage(); + } + + public function resolvedUnitsValue(): Units + { + return $this->resolvedUnits(); + } +} diff --git a/tests/Unit/Resource/GeocodingTest.php b/tests/Unit/Resource/GeocodingTest.php new file mode 100644 index 0000000..18c111b --- /dev/null +++ b/tests/Unit/Resource/GeocodingTest.php @@ -0,0 +1,201 @@ +respondWithFixture('geocoding/direct/success.json'); + + $locations = $this->api->geocoding()->byName(' Springfield,US ', limit: 5); + $request = $this->client->getLastRequest(); + + self::assertCount(5, $locations); + self::assertContainsOnlyInstancesOf(Location::class, $locations); + self::assertSame('Illinois', $locations[0]->state()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/geo/1.0/direct', $request->getUri()->getPath()); + self::assertSame([ + 'q' => 'Springfield,US', + 'limit' => '5', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testAllowsAnOmittedLimitAndHydratesAnEmptyResult(): void + { + $this->respondWithFixture('geocoding/direct/empty.json'); + + $locations = $this->api->geocoding()->byName('Unknown location'); + $request = $this->client->getLastRequest(); + + self::assertSame([], $locations); + self::assertSame([ + 'q' => 'Unknown location', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testLooksUpALocationByPostalCode(): void + { + $this->respondWithFixture('geocoding/zip/success.json'); + + $location = $this->api->geocoding()->byPostalCode(' 1000-001 ', 'pt'); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(PostalLocation::class, $location); + self::assertSame('1000-001', $location->postalCode()); + self::assertSame('Lisbon', $location->name()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/geo/1.0/zip', $request->getUri()->getPath()); + self::assertSame([ + 'zip' => '1000-001,PT', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testLooksUpLocationsByCoordinates(): void + { + $this->respondWithFixture('geocoding/reverse/success.json'); + + $locations = $this->api->geocoding()->byCoordinates( + latitude: 40.7128, + longitude: -74.006, + limit: 5, + ); + $request = $this->client->getLastRequest(); + + self::assertCount(1, $locations); + self::assertContainsOnlyInstancesOf(Location::class, $locations); + self::assertSame('New York County', $locations[0]->name()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/geo/1.0/reverse', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '40.7128', + 'lon' => '-74.006', + 'limit' => '5', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testAllowsAnOmittedReverseLimit(): void + { + $this->respondWithFixture('geocoding/reverse/success.json'); + + $this->api->geocoding()->byCoordinates( + latitude: 40.7128, + longitude: -74.006, + ); + $request = $this->client->getLastRequest(); + + self::assertSame([ + 'lat' => '40.7128', + 'lon' => '-74.006', + 'appid' => 'api-key', + ], $this->query($request)); + } + + #[DataProvider('invalidNameArguments')] + public function testRejectsInvalidNameArguments( + string $name, + ?int $limit, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->geocoding()->byName($name, $limit); + } + + public static function invalidNameArguments(): iterable + { + yield 'blank name' => [' ', null, 'The location name must be a non-empty string.']; + yield 'limit below minimum' => ['Lisbon', 0, 'The result limit must be between 1 and 5.']; + yield 'limit above maximum' => ['Lisbon', 6, 'The result limit must be between 1 and 5.']; + } + + #[DataProvider('invalidPostalCodeArguments')] + public function testRejectsInvalidPostalCodeArguments( + string $postalCode, + string $countryCode, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->geocoding() + ->byPostalCode($postalCode, $countryCode); + } + + public static function invalidPostalCodeArguments(): iterable + { + yield 'blank postal code' => [ + ' ', + 'PT', + 'The postal code must be a non-empty string.', + ]; + yield 'blank country code' => [ + '1000-001', + ' ', + 'The country code must contain exactly two ASCII letters.', + ]; + yield 'short country code' => [ + '1000-001', + 'P', + 'The country code must contain exactly two ASCII letters.', + ]; + yield 'non-letter country code' => [ + '1000-001', + 'P1', + 'The country code must contain exactly two ASCII letters.', + ]; + } + + public function testRejectsAReverseLimitBelowOne(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The result limit must be at least 1.'); + + $this->api->geocoding() + ->byCoordinates( + latitude: 40.7128, + longitude: -74.006, + limit: 0, + ); + } + + #[DataProvider('invalidCoordinates')] + public function testRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->geocoding()->byCoordinates($latitude, $longitude); + } + + public static function invalidCoordinates(): iterable + { + $latitudeMessage = 'Latitude must be a finite number between -90 and 90.'; + $longitudeMessage = 'Longitude must be a finite number between -180 and 180.'; + + yield 'latitude below minimum' => [-90.0001, 0, $latitudeMessage]; + yield 'latitude above maximum' => [90.0001, 0, $latitudeMessage]; + yield 'latitude is negative infinity' => [-INF, 0, $latitudeMessage]; + yield 'latitude is positive infinity' => [INF, 0, $latitudeMessage]; + yield 'latitude is not a number' => [NAN, 0, $latitudeMessage]; + yield 'longitude below minimum' => [0, -180.0001, $longitudeMessage]; + yield 'longitude above maximum' => [0, 180.0001, $longitudeMessage]; + yield 'longitude is negative infinity' => [0, -INF, $longitudeMessage]; + yield 'longitude is positive infinity' => [0, INF, $longitudeMessage]; + yield 'longitude is not a number' => [0, NAN, $longitudeMessage]; + } +} diff --git a/tests/Unit/Resource/MapsTest.php b/tests/Unit/Resource/MapsTest.php new file mode 100644 index 0000000..76d5a20 --- /dev/null +++ b/tests/Unit/Resource/MapsTest.php @@ -0,0 +1,206 @@ +getParameters()[1]->getAttributes(\SensitiveParameter::class), + ); + } + + public function testGeneratesAnAuthenticatedTileUrlWithoutSendingARequest(): void + { + $api = new OpenWeatherMap('api key&value'); + $api->setup()->client($this->client); + + $url = $api->maps()->tileUrl( + layer: MapLayer::PRECIPITATION, + zoom: 6, + x: 31, + y: 20, + ); + $expected = 'https://tile.openweathermap.org/map/precipitation_new/6/31/20.png' + . '?appid=api%20key%26value'; + + self::assertSame($expected, $url); + self::assertSame([], $this->client->getRequests()); + } + + public function testValidatesGeneratedTileUrlAddresses(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'At zoom level 1, the tile X coordinate must be between 0 and 1.', + ); + + $this->api->maps()->tileUrl(MapLayer::CLOUDS, 1, 2, 0); + } + + public function testGeneratesAnAuthenticatedTileUrlTemplateWithoutSendingARequest(): void + { + $template = $this->api->maps()->tileUrlTemplate(MapLayer::WIND); + + self::assertSame( + 'https://tile.openweathermap.org/map/wind_new/{z}/{x}/{y}.png?appid=api-key', + $template, + ); + self::assertSame([], $this->client->getRequests()); + } + + #[DataProvider('layers')] + public function testGetsWeatherMapTiles( + MapLayer $layer, + string $path, + string $fixture, + ): void { + $contents = Fixture::contents($fixture); + $this->client->addResponse(new Response( + headers: ['Content-Type' => 'image/png'], + body: $contents, + )); + + $tile = $this->api->maps()->tile( + layer: $layer, + zoom: 1, + x: 1, + y: 1, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(MapTile::class, $tile); + self::assertSame($contents, $tile->contents()); + self::assertSame('image/png', $tile->contentType()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('https', $request->getUri()->getScheme()); + self::assertSame('tile.openweathermap.org', $request->getUri()->getHost()); + self::assertSame($path, $request->getUri()->getPath()); + self::assertSame(['appid' => 'api-key'], $this->query($request)); + } + + /** + * @return iterable + */ + public static function layers(): iterable + { + yield 'clouds' => [ + MapLayer::CLOUDS, + '/map/clouds_new/1/1/1.png', + 'maps/tile/clouds-new.png', + ]; + yield 'precipitation' => [ + MapLayer::PRECIPITATION, + '/map/precipitation_new/1/1/1.png', + 'maps/tile/precipitation-new.png', + ]; + yield 'pressure' => [ + MapLayer::PRESSURE, + '/map/pressure_new/1/1/1.png', + 'maps/tile/pressure-new.png', + ]; + yield 'wind' => [ + MapLayer::WIND, + '/map/wind_new/1/1/1.png', + 'maps/tile/wind-new.png', + ]; + yield 'temperature' => [ + MapLayer::TEMPERATURE, + '/map/temp_new/1/1/1.png', + 'maps/tile/temp-new.png', + ]; + } + + #[DataProvider('invalidAddresses')] + public function testRejectsInvalidTileAddresses( + int $zoom, + int $x, + int $y, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->maps()->tile(MapLayer::CLOUDS, $zoom, $x, $y); + } + + /** + * @return iterable + */ + public static function invalidAddresses(): iterable + { + yield 'negative zoom' => [ + -1, + 0, + 0, + 'The tile zoom level must be zero or greater.', + ]; + yield 'negative X' => [ + 9, + -1, + 0, + 'At zoom level 9, the tile X coordinate must be between 0 and 511.', + ]; + yield 'X above maximum' => [ + 9, + 512, + 0, + 'At zoom level 9, the tile X coordinate must be between 0 and 511.', + ]; + yield 'Y above maximum' => [ + 9, + 0, + 512, + 'At zoom level 9, the tile Y coordinate must be between 0 and 511.', + ]; + yield 'negative Y' => [ + 9, + 0, + -1, + 'At zoom level 9, the tile Y coordinate must be between 0 and 511.', + ]; + } + + public function testRejectsDecodedResponseData(): void + { + $this->client->addResponse(new Response( + headers: ['Content-Type' => 'image/png'], + body: '{}', + )); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage( + 'Map tile response body must contain raw image data.', + ); + + $this->api->maps()->tile(MapLayer::CLOUDS, 1, 1, 1); + } + + public function testRejectsUnexpectedImageContentTypes(): void + { + $this->client->addResponse(new Response( + headers: ['Content-Type' => 'image/jpeg'], + body: 'image bytes', + )); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage( + 'Map tile response must use the "image/png" content type.', + ); + + $this->api->maps()->tile(MapLayer::CLOUDS, 1, 1, 1); + } +} diff --git a/tests/Unit/Resource/OneCallTest.php b/tests/Unit/Resource/OneCallTest.php new file mode 100644 index 0000000..60eb6bd --- /dev/null +++ b/tests/Unit/Resource/OneCallTest.php @@ -0,0 +1,682 @@ +respondWithFixture('one-call/alert/chile-rain.json'); + $id = 'urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:' + .'f1076d7511a15522d5a6e41917020bc0'; + + $alert = $this->api->oneCall()->alert($id); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(Alert::class, $alert); + self::assertSame($id, $alert->id()); + self::assertSame('Dirección Meteorológica de Chile', $alert->senderName()); + self::assertSame('GET', $request->getMethod()); + self::assertSame( + '/data/4.0/onecall/alert/'.rawurlencode($id), + $request->getUri()->getPath(), + ); + self::assertSame(['appid' => 'api-key'], $this->query($request)); + } + + public function testEncodesOpaqueAlertIdAsOnePathSegment(): void + { + $this->client->addResponse(new Response(body: '{}')); + + $this->api->oneCall()->alert('agency:alert/segment'); + $request = $this->client->getLastRequest(); + + self::assertSame( + '/data/4.0/onecall/alert/agency%3Aalert%2Fsegment', + $request->getUri()->getPath(), + ); + } + + public function testRejectsBlankAlertId(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The alert ID must be a non-empty string.'); + + $this->api->oneCall()->alert(' '); + } + + public function testGetsCurrentWeatherByCoordinates(): void + { + $this->respondWithFixture('one-call/current/success.json'); + + $current = $this->api->oneCall()->current( + latitude: 38.7223, + longitude: -9.1393, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(Current::class, $current); + self::assertSame(24.34, $current->temperature()); + self::assertSame(Unit::CELSIUS, $current->temperatureUnit()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/4.0/onecall/current', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testFluentOverridesAreRequestLocal(): void + { + $this->client->addResponse(new Response( + body: '{"data":[{"temp":72.5}]}', + )); + $this->respondWithFixture('one-call/current/success.json'); + + $oneCall = $this->api->oneCall(); + $overridden = $oneCall + ->withUnits(Units::IMPERIAL) + ->withLanguage('pt'); + + $imperial = $overridden->current(38.7223, -9.1393); + $imperialRequest = $this->client->getLastRequest(); + $metric = $oneCall->current(38.7223, -9.1393); + $metricRequest = $this->client->getLastRequest(); + + self::assertSame(Unit::FAHRENHEIT, $imperial->temperatureUnit()); + self::assertSame('72.5 °F', $imperial->temperatureWithUnit()); + self::assertSame(Unit::CELSIUS, $metric->temperatureUnit()); + self::assertSame('imperial', $this->query($imperialRequest)['units']); + self::assertSame('pt', $this->query($imperialRequest)['lang']); + self::assertSame('metric', $this->query($metricRequest)['units']); + self::assertSame('en', $this->query($metricRequest)['lang']); + } + + public function testGetsMinuteTimelineByCoordinates(): void + { + $this->respondWithFixture('one-call/one-minute/success.json'); + + $timeline = $this->api->oneCall()->minuteTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(MinuteTimeline::class, $timeline); + self::assertCount(60, $timeline->periods()); + self::assertSame(1785669780, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/4.0/onecall/timeline/1min', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testMinuteTimelineAcceptsFluentConfiguration(): void + { + $this->respondWithFixture('one-call/one-minute/precipitation-alerts.json'); + + $timeline = $this->api + ->oneCall() + ->withUnits(Units::IMPERIAL) + ->withLanguage('pt') + ->minuteTimeline(-38.4, -71.58); + $request = $this->client->getLastRequest(); + + self::assertSame(Unit::MILLIMETERS_PER_HOUR, $timeline->periods()[0]->precipitationUnit()); + self::assertSame([ + 'lat' => '-38.4', + 'lon' => '-71.58', + 'units' => 'imperial', + 'lang' => 'pt', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testGetsFifteenMinuteTimelineByCoordinates(): void + { + $this->respondWithFixture('one-call/fifteen-minute/success.json'); + + $timeline = $this->api->oneCall()->fifteenMinuteTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(FifteenMinuteTimeline::class, $timeline); + self::assertCount(50, $timeline->periods()); + self::assertSame(1785670200, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertNull($timeline->pagination()->previousPageUrl()); + self::assertStringContainsString( + 'appid=', + $timeline->pagination()->nextPageUrl() ?? '', + ); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/4.0/onecall/timeline/15min', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testGetsFifteenMinuteTimelineFromStart(): void + { + $this->respondWithFixture('one-call/fifteen-minute/pagination.json'); + + $timeline = $this->api->oneCall()->fifteenMinuteTimeline( + latitude: 38.7223, + longitude: -9.1393, + startAt: new \DateTimeImmutable('@1785715200'), + count: 50, + ); + $request = $this->client->getLastRequest(); + + self::assertSame(1785715200, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1785715200', + 'cnt' => '50', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testGetsNextFifteenMinuteTimelinePage(): void + { + $this->respondWithFixture('one-call/fifteen-minute/success.json'); + $this->client->addResponse(new Response( + body: '{"data":[{"dt":1785715200}]}', + )); + + $timeline = $this->api->oneCall()->fifteenMinuteTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + + self::assertCount(1, $this->client->getRequests()); + + $nextPage = $timeline->pagination()->nextPage(); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(FifteenMinuteTimeline::class, $nextPage); + self::assertSame(1785715200, $nextPage->periods()[0]->dateTime()?->getTimestamp()); + self::assertCount(2, $this->client->getRequests()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('https', $request->getUri()->getScheme()); + self::assertSame('/data/4.0/onecall/timeline/15min', $request->getUri()->getPath()); + self::assertSame([ + 'cnt' => '50', + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1785715200', + 'appid' => 'api-key', + 'units' => 'metric', + 'lang' => 'en', + ], $this->query($request)); + } + + public function testGetsPreviousFifteenMinuteTimelinePage(): void + { + $this->respondWithFixture('one-call/fifteen-minute/pagination.json'); + $this->client->addResponse(new Response( + body: '{"data":[{"dt":1785670200}]}', + )); + + $timeline = $this->api->oneCall()->fifteenMinuteTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + + self::assertCount(1, $this->client->getRequests()); + + $previousPage = $timeline->pagination()->previousPage(); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(FifteenMinuteTimeline::class, $previousPage); + self::assertSame(1785670200, $previousPage->periods()[0]->dateTime()?->getTimestamp()); + self::assertCount(2, $this->client->getRequests()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('https', $request->getUri()->getScheme()); + self::assertSame('/data/4.0/onecall/timeline/15min', $request->getUri()->getPath()); + self::assertSame([ + 'cnt' => '50', + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1785670200', + 'appid' => 'api-key', + 'units' => 'metric', + 'lang' => 'en', + ], $this->query($request)); + } + + public function testFifteenMinuteTimelineAcceptsFluentConfiguration(): void + { + $this->client->addResponse(new Response( + body: '{"data":[{"temp":72.5}]}', + )); + + $timeline = $this->api + ->oneCall() + ->withUnits(Units::IMPERIAL) + ->withLanguage('pt') + ->fifteenMinuteTimeline(38.7223, -9.1393, count: 3); + $request = $this->client->getLastRequest(); + + self::assertSame(Unit::FAHRENHEIT, $timeline->periods()[0]->temperatureUnit()); + self::assertSame('72.5 °F', $timeline->periods()[0]->temperatureWithUnit()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'cnt' => '3', + 'units' => 'imperial', + 'lang' => 'pt', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testGetsHourTimelineByCoordinates(): void + { + $this->respondWithFixture('one-call/one-hour/success.json'); + + $timeline = $this->api->oneCall()->hourTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(HourTimeline::class, $timeline); + self::assertCount(20, $timeline->periods()); + self::assertSame(1785668400, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertStringContainsString( + 'appid=', + $timeline->pagination()->previousPageUrl() ?? '', + ); + self::assertStringContainsString( + 'appid=', + $timeline->pagination()->nextPageUrl() ?? '', + ); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/4.0/onecall/timeline/1h', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testGetsHourTimelineFromStart(): void + { + $this->respondWithFixture('one-call/one-hour/history.json'); + + $timeline = $this->api->oneCall()->hourTimeline( + latitude: 38.7223, + longitude: -9.1393, + startAt: new \DateTimeImmutable('@1785495600'), + ); + $request = $this->client->getLastRequest(); + + self::assertSame(1785495600, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1785495600', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testGetsNextHourTimelinePage(): void + { + $this->respondWithFixture('one-call/one-hour/success.json'); + $this->client->addResponse(new Response( + body: '{"data":[{"dt":1785740400}]}', + )); + + $timeline = $this->api->oneCall()->hourTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + + self::assertCount(1, $this->client->getRequests()); + + $nextPage = $timeline->pagination()->nextPage(); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(HourTimeline::class, $nextPage); + self::assertSame(1785740400, $nextPage->periods()[0]->dateTime()?->getTimestamp()); + self::assertCount(2, $this->client->getRequests()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('https', $request->getUri()->getScheme()); + self::assertSame('/data/4.0/onecall/timeline/1h', $request->getUri()->getPath()); + self::assertSame([ + 'cnt' => '20', + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1785740400', + 'appid' => 'api-key', + 'units' => 'metric', + 'lang' => 'en', + ], $this->query($request)); + } + + public function testGetsPreviousHourTimelinePage(): void + { + $this->respondWithFixture('one-call/one-hour/success.json'); + $this->client->addResponse(new Response( + body: '{"data":[{"dt":1785596400}]}', + )); + + $timeline = $this->api->oneCall()->hourTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + + self::assertCount(1, $this->client->getRequests()); + + $previousPage = $timeline->pagination()->previousPage(); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(HourTimeline::class, $previousPage); + self::assertSame(1785596400, $previousPage->periods()[0]->dateTime()?->getTimestamp()); + self::assertCount(2, $this->client->getRequests()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('https', $request->getUri()->getScheme()); + self::assertSame('/data/4.0/onecall/timeline/1h', $request->getUri()->getPath()); + self::assertSame([ + 'cnt' => '20', + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1785596400', + 'appid' => 'api-key', + 'units' => 'metric', + 'lang' => 'en', + ], $this->query($request)); + } + + public function testHourTimelineAcceptsFluentConfiguration(): void + { + $this->client->addResponse(new Response( + body: '{"data":[{"temp":72.5}]}', + )); + + $timeline = $this->api + ->oneCall() + ->withUnits(Units::IMPERIAL) + ->withLanguage('pt') + ->hourTimeline(38.7223, -9.1393, count: 3); + $request = $this->client->getLastRequest(); + + self::assertSame(Unit::FAHRENHEIT, $timeline->periods()[0]->temperatureUnit()); + self::assertSame('72.5 °F', $timeline->periods()[0]->temperatureWithUnit()); + self::assertSame('3', $this->query($request)['cnt']); + self::assertSame('imperial', $this->query($request)['units']); + self::assertSame('pt', $this->query($request)['lang']); + } + + public function testGetsDayTimelineByCoordinates(): void + { + $this->respondWithFixture('one-call/one-day/success.json'); + + $timeline = $this->api->oneCall()->dayTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(DayTimeline::class, $timeline); + self::assertCount(10, $timeline->periods()); + self::assertSame(1785628800, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertStringContainsString( + 'appid=', + $timeline->pagination()->previousPageUrl() ?? '', + ); + self::assertStringContainsString( + 'appid=', + $timeline->pagination()->nextPageUrl() ?? '', + ); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/4.0/onecall/timeline/1day', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testGetsDayTimelineFromStart(): void + { + $this->respondWithFixture('one-call/one-day/history.json'); + + $timeline = $this->api->oneCall()->dayTimeline( + latitude: 38.7223, + longitude: -9.1393, + startAt: new \DateTimeImmutable('@1785456000'), + ); + $request = $this->client->getLastRequest(); + + self::assertSame(1785456000, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1785456000', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testGetsNextDayTimelinePage(): void + { + $this->respondWithFixture('one-call/one-day/success.json'); + $this->client->addResponse(new Response( + body: '{"data":[{"dt":1786492800}]}', + )); + + $timeline = $this->api->oneCall()->dayTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + + self::assertCount(1, $this->client->getRequests()); + + $nextPage = $timeline->pagination()->nextPage(); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(DayTimeline::class, $nextPage); + self::assertSame(1786492800, $nextPage->periods()[0]->dateTime()?->getTimestamp()); + self::assertCount(2, $this->client->getRequests()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('https', $request->getUri()->getScheme()); + self::assertSame('/data/4.0/onecall/timeline/1day', $request->getUri()->getPath()); + self::assertSame([ + 'cnt' => '10', + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1786492800', + 'appid' => 'api-key', + 'units' => 'metric', + 'lang' => 'en', + ], $this->query($request)); + } + + public function testGetsPreviousDayTimelinePage(): void + { + $this->respondWithFixture('one-call/one-day/success.json'); + $this->client->addResponse(new Response( + body: '{"data":[{"dt":1784764800}]}', + )); + + $timeline = $this->api->oneCall()->dayTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + + self::assertCount(1, $this->client->getRequests()); + + $previousPage = $timeline->pagination()->previousPage(); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(DayTimeline::class, $previousPage); + self::assertSame(1784764800, $previousPage->periods()[0]->dateTime()?->getTimestamp()); + self::assertCount(2, $this->client->getRequests()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('https', $request->getUri()->getScheme()); + self::assertSame('/data/4.0/onecall/timeline/1day', $request->getUri()->getPath()); + self::assertSame([ + 'cnt' => '10', + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1784764800', + 'appid' => 'api-key', + 'units' => 'metric', + 'lang' => 'en', + ], $this->query($request)); + } + + public function testDayTimelineAcceptsFluentConfigurationAndCount(): void + { + $this->client->addResponse(new Response( + body: '{"data":[{"temp":{"day":72.5}}]}', + )); + + $timeline = $this->api + ->oneCall() + ->withUnits(Units::IMPERIAL) + ->withLanguage('pt') + ->dayTimeline(38.7223, -9.1393, count: 3); + $request = $this->client->getLastRequest(); + + self::assertSame(Unit::FAHRENHEIT, $timeline->periods()[0]->temperature()?->dayUnit()); + self::assertSame('72.5 °F', $timeline->periods()[0]->temperature()?->dayWithUnit()); + self::assertSame('3', $this->query($request)['cnt']); + self::assertSame('imperial', $this->query($request)['units']); + self::assertSame('pt', $this->query($request)['lang']); + } + + #[DataProvider('invalidCoordinates')] + public function testRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->oneCall()->current($latitude, $longitude); + } + + #[DataProvider('invalidCoordinates')] + public function testMinuteTimelineRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->oneCall()->minuteTimeline($latitude, $longitude); + } + + #[DataProvider('invalidCoordinates')] + public function testFifteenMinuteTimelineRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->oneCall()->fifteenMinuteTimeline($latitude, $longitude); + } + + #[DataProvider('invalidCoordinates')] + public function testHourTimelineRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->oneCall()->hourTimeline($latitude, $longitude); + } + + #[DataProvider('invalidCoordinates')] + public function testDayTimelineRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->oneCall()->dayTimeline($latitude, $longitude); + } + + public function testFifteenMinuteTimelineRejectsInvalidCount(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The timeline count must be at least 1.'); + + $this->api->oneCall()->fifteenMinuteTimeline(38.7223, -9.1393, count: 0); + } + + public function testHourTimelineRejectsInvalidCount(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The timeline count must be at least 1.'); + + $this->api->oneCall()->hourTimeline(38.7223, -9.1393, count: 0); + } + + public function testDayTimelineRejectsInvalidCount(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The timeline count must be at least 1.'); + + $this->api->oneCall()->dayTimeline(38.7223, -9.1393, count: 0); + } + + public static function invalidCoordinates(): iterable + { + yield 'invalid latitude' => [ + 90.0001, + 0, + 'Latitude must be a finite number between -90 and 90.', + ]; + yield 'invalid longitude' => [ + 0, + 180.0001, + 'Longitude must be a finite number between -180 and 180.', + ]; + } +} diff --git a/tests/Unit/Resource/StationsTest.php b/tests/Unit/Resource/StationsTest.php new file mode 100644 index 0000000..f3aca54 --- /dev/null +++ b/tests/Unit/Resource/StationsTest.php @@ -0,0 +1,469 @@ +respondWithFixture('stations/register.json', status: 201); + + $station = $this->api->stations()->create( + externalId: ' openweathermap-php-api-fixture ', + name: ' OpenWeatherMap PHP API Fixture ', + latitude: 38.7223, + longitude: -9.1393, + altitude: 100, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(Station::class, $station); + self::assertSame('6a779284adde3b0001343e02', $station->id()); + self::assertSame('user-fixture', $station->userId()); + self::assertSame(5, $station->sourceType()); + self::assertSame('POST', $request->getMethod()); + self::assertSame('/data/3.0/stations', $request->getUri()->getPath()); + self::assertSame(['appid' => 'api-key'], $this->query($request)); + self::assertSame('application/json', $request->getHeaderLine('Content-Type')); + self::assertSame([ + 'external_id' => 'openweathermap-php-api-fixture', + 'name' => 'OpenWeatherMap PHP API Fixture', + 'latitude' => 38.7223, + 'longitude' => -9.1393, + 'altitude' => 100, + ], json_decode((string) $request->getBody(), true, 512, JSON_THROW_ON_ERROR)); + } + + public function testListsStations(): void + { + $this->respondWithFixture('stations/list.json'); + + $stations = $this->api->stations()->all(); + $request = $this->client->getLastRequest(); + + self::assertCount(1, $stations); + self::assertContainsOnlyInstancesOf(Station::class, $stations); + self::assertSame('6a779284adde3b0001343e02', $stations[0]->id()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/3.0/stations', $request->getUri()->getPath()); + self::assertSame(['appid' => 'api-key'], $this->query($request)); + } + + public function testUpdatesAStation(): void + { + $this->respondWithFixture('stations/update.json'); + + $station = $this->api->stations()->update( + id: ' 6a779284adde3b0001343e02 ', + externalId: ' openweathermap-php-api-fixture-updated ', + name: ' Updated OpenWeatherMap PHP API Fixture ', + latitude: 38.72, + longitude: -9.14, + altitude: 110, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(Station::class, $station); + self::assertSame('openweathermap-php-api-fixture-updated', $station->externalId()); + self::assertSame('PUT', $request->getMethod()); + self::assertSame( + '/data/3.0/stations/6a779284adde3b0001343e02', + $request->getUri()->getPath(), + ); + self::assertSame(['appid' => 'api-key'], $this->query($request)); + self::assertSame('application/json', $request->getHeaderLine('Content-Type')); + self::assertSame([ + 'external_id' => 'openweathermap-php-api-fixture-updated', + 'name' => 'Updated OpenWeatherMap PHP API Fixture', + 'latitude' => 38.72, + 'longitude' => -9.14, + 'altitude' => 110, + ], json_decode((string) $request->getBody(), true, 512, JSON_THROW_ON_ERROR)); + } + + public function testFindsAStation(): void + { + $this->respondWithFixture('stations/retrieve.json'); + + $station = $this->api->stations()->find(' 6a779284adde3b0001343e02 '); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(Station::class, $station); + self::assertSame('OpenWeatherMap PHP API Fixture', $station->name()); + self::assertSame('GET', $request->getMethod()); + self::assertSame( + '/data/3.0/stations/6a779284adde3b0001343e02', + $request->getUri()->getPath(), + ); + self::assertSame(['appid' => 'api-key'], $this->query($request)); + } + + public function testRejectsABlankStationIdentifier(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The station ID must be a non-empty string.', + ); + + $this->api->stations()->find(' '); + } + + public function testRejectsABlankStationIdentifierWhenUpdating(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The station ID must be a non-empty string.', + ); + + $this->api->stations()->update( + id: ' ', + externalId: 'station', + name: 'Station', + latitude: 0, + longitude: 0, + altitude: 0, + ); + } + + public function testDeletesAStation(): void + { + $this->respondWithFixture('stations/delete.empty', status: 204); + + $this->api->stations()->delete(' 6a779284adde3b0001343e02 '); + $request = $this->client->getLastRequest(); + + self::assertSame('DELETE', $request->getMethod()); + self::assertSame( + '/data/3.0/stations/6a779284adde3b0001343e02', + $request->getUri()->getPath(), + ); + self::assertSame(['appid' => 'api-key'], $this->query($request)); + self::assertSame('', (string) $request->getBody()); + } + + public function testRejectsABlankStationIdentifierWhenDeleting(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The station ID must be a non-empty string.', + ); + + $this->api->stations()->delete(' '); + } + + public function testSubmitsAMeasurement(): void + { + $this->respondWithFixture('stations/measurements/submit.empty', status: 204); + + $this->api->stations()->submitMeasurement( + 'station-id', + new Measurement( + dateTime: new \DateTimeImmutable('@1786231350'), + temperature: 19.5, + clouds: [new CloudLayer(condition: 'NSC')], + weather: [new Weather(precipitation: 'RA', intensity: '-')], + ), + ); + $request = $this->client->getLastRequest(); + + self::assertSame('POST', $request->getMethod()); + self::assertSame('/data/3.0/measurements', $request->getUri()->getPath()); + self::assertSame(['appid' => 'api-key'], $this->query($request)); + self::assertSame('application/json', $request->getHeaderLine('Content-Type')); + self::assertSame([[ + 'station_id' => 'station-id', + 'dt' => 1786231350, + 'temperature' => 19.5, + 'clouds' => [['condition' => 'NSC']], + 'weather' => [['precipitation' => 'RA', 'intensity' => '-']], + ]], json_decode((string) $request->getBody(), true, 512, JSON_THROW_ON_ERROR)); + } + + public function testSubmitsMultipleMeasurements(): void + { + $this->respondWithFixture('stations/measurements/submit.empty', status: 204); + + $measurements = [ + new Measurement( + dateTime: new \DateTimeImmutable('@1786143600'), + temperature: 19.5, + windSpeed: 2.4, + windGust: 4.1, + windDirection: 180, + pressure: 1012, + humidity: 68, + rainLastHour: 0.2, + ), + new Measurement( + dateTime: new \DateTimeImmutable('@1786228200'), + temperature: 20.5, + windSpeed: 3.2, + windGust: 5.3, + windDirection: 200, + pressure: 1013, + humidity: 64, + rainLastHour: 0.4, + ), + new Measurement( + dateTime: new \DateTimeImmutable('@1786230720'), + temperature: 21.5, + windSpeed: 4, + windGust: 6.5, + windDirection: 220, + pressure: 1014, + humidity: 60, + rainLastHour: 0.6, + ), + ]; + + $this->api->stations()->submitMeasurements( + '6a77b80aadde3b0001343e08', + $measurements, + ); + $request = $this->client->getLastRequest(); + + self::assertSame( + [ + [ + 'station_id' => '6a77b80aadde3b0001343e08', + 'dt' => 1786143600, + 'temperature' => 19.5, + 'wind_speed' => 2.4, + 'wind_gust' => 4.1, + 'wind_deg' => 180, + 'pressure' => 1012, + 'humidity' => 68, + 'rain_1h' => 0.2, + ], + [ + 'station_id' => '6a77b80aadde3b0001343e08', + 'dt' => 1786228200, + 'temperature' => 20.5, + 'wind_speed' => 3.2, + 'wind_gust' => 5.3, + 'wind_deg' => 200, + 'pressure' => 1013, + 'humidity' => 64, + 'rain_1h' => 0.4, + ], + [ + 'station_id' => '6a77b80aadde3b0001343e08', + 'dt' => 1786230720, + 'temperature' => 21.5, + 'wind_speed' => 4, + 'wind_gust' => 6.5, + 'wind_deg' => 220, + 'pressure' => 1014, + 'humidity' => 60, + 'rain_1h' => 0.6, + ], + ], + json_decode((string) $request->getBody(), true, 512, JSON_THROW_ON_ERROR), + ); + } + + public function testRejectsAnEmptyMeasurementBatch(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The station measurements must not be empty.', + ); + + $this->api->stations()->submitMeasurements('station-id', []); + } + + public function testRejectsABlankMeasurementStationIdentifier(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The station ID must be a non-empty string.', + ); + + $this->api->stations()->submitMeasurement( + ' ', + new Measurement(new \DateTimeImmutable()), + ); + } + + public function testRejectsAnInvalidMeasurementBatchItem(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The station measurement at index 1 must be an instance of', + ); + + $this->api->stations()->submitMeasurements('station-id', [ + new Measurement(new \DateTimeImmutable()), + 'invalid', + ]); + } + + public function testAggregatesMeasurements(): void + { + $this->respondWithFixture( + 'stations/measurements/aggregate-hour-success.json', + ); + + $aggregates = $this->api->stations()->measurements( + stationId: ' 6a77ba36adde3b0001343e09 ', + interval: AggregationInterval::HOUR, + startAt: new \DateTimeImmutable('@1786231349'), + endAt: new \DateTimeImmutable('@1786692622'), + limit: 100, + ); + $request = $this->client->getLastRequest(); + + self::assertCount(2, $aggregates); + self::assertContainsOnlyInstancesOf(MeasurementAggregate::class, $aggregates); + self::assertSame(AggregationInterval::HOUR, $aggregates[0]->interval()); + self::assertSame(20.5, $aggregates[0]->temperature()?->average()); + self::assertSame(0.6, $aggregates[0]->precipitation()?->rain()); + self::assertSame(0.1, $aggregates[1]->precipitation()?->snow()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/3.0/measurements', $request->getUri()->getPath()); + self::assertSame([ + 'station_id' => '6a77ba36adde3b0001343e09', + 'type' => 'h', + 'limit' => '100', + 'from' => '1786231349', + 'to' => '1786692622', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testReturnsAnEmptyMeasurementAggregateCollection(): void + { + $this->respondWithFixture( + 'stations/measurements/aggregate-minute-empty.json', + ); + + $aggregates = $this->api->stations()->measurements( + stationId: 'station-id', + interval: AggregationInterval::MINUTE, + startAt: new \DateTimeImmutable('@1786143599'), + endAt: new \DateTimeImmutable('@1786230780'), + limit: 10, + ); + + self::assertSame([], $aggregates); + } + + #[DataProvider('invalidAggregationArguments')] + public function testRejectsInvalidAggregationArguments( + string $stationId, + \DateTimeInterface $startAt, + \DateTimeInterface $endAt, + int $limit, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->stations()->measurements( + $stationId, + AggregationInterval::HOUR, + $startAt, + $endAt, + $limit, + ); + } + + public static function invalidAggregationArguments(): iterable + { + yield 'blank station identifier' => [ + ' ', + new \DateTimeImmutable('@100'), + new \DateTimeImmutable('@200'), + 1, + 'The station ID must be a non-empty string.', + ]; + yield 'reversed date range' => [ + 'station-id', + new \DateTimeImmutable('@200'), + new \DateTimeImmutable('@100'), + 1, + 'The end date must be after or equal to the start date.', + ]; + yield 'non-positive result limit' => [ + 'station-id', + new \DateTimeImmutable('@100'), + new \DateTimeImmutable('@200'), + 0, + 'The result limit must be at least 1.', + ]; + } + + #[DataProvider('invalidCreationArguments')] + public function testRejectsInvalidCreationArguments( + string $externalId, + string $name, + float $latitude, + float $longitude, + float $altitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->stations()->create( + $externalId, + $name, + $latitude, + $longitude, + $altitude, + ); + } + + public static function invalidCreationArguments(): iterable + { + yield 'blank external identifier' => [ + ' ', + 'Station', + 0, + 0, + 0, + 'The external station ID must be a non-empty string.', + ]; + yield 'blank name' => [ + 'station', + ' ', + 0, + 0, + 0, + 'The station name must be a non-empty string.', + ]; + yield 'invalid latitude' => [ + 'station', + 'Station', + 90.0001, + 0, + 0, + 'Latitude must be a finite number between -90 and 90.', + ]; + yield 'invalid longitude' => [ + 'station', + 'Station', + 0, + 180.0001, + 0, + 'Longitude must be a finite number between -180 and 180.', + ]; + yield 'non-finite altitude' => [ + 'station', + 'Station', + 0, + 0, + INF, + 'The station altitude must be a finite number.', + ]; + } +} diff --git a/tests/Unit/Resource/WeatherTest.php b/tests/Unit/Resource/WeatherTest.php new file mode 100644 index 0000000..893e367 --- /dev/null +++ b/tests/Unit/Resource/WeatherTest.php @@ -0,0 +1,187 @@ +respondWithFixture('weather/current/success.json'); + + $weather = $this->api->weather()->current( + latitude: 38.7223, + longitude: -9.1393, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(Current::class, $weather); + self::assertSame('Socorro', $weather->name()); + self::assertSame(Unit::CELSIUS, $weather->temperatureUnit()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/2.5/weather', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testUsesApiConfigurationForTheRequestAndEntity(): void + { + $this->api = new OpenWeatherMap('api-key', [ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + OpenWeatherMap::OPTION_LANGUAGE => Language::PORTUGUESE, + ]); + $this->api->setup()->client($this->client); + $this->client->addResponse(new Response( + body: '{"main":{"temp":72.5},"wind":{"speed":10}}', + )); + + $weather = $this->api->weather()->current(38.7223, -9.1393); + $request = $this->client->getLastRequest(); + + self::assertSame(Unit::FAHRENHEIT, $weather->temperatureUnit()); + self::assertSame('72.5 °F', $weather->temperatureWithUnit()); + self::assertSame(Unit::MILES_PER_HOUR, $weather->wind()?->speedUnit()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'units' => 'imperial', + 'lang' => 'pt', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testFluentOverridesAreRequestLocal(): void + { + $this->client->addResponse(new Response( + body: '{"main":{"temp":72.5}}', + )); + $this->respondWithFixture('weather/current/success.json'); + + $weather = $this->api->weather(); + $overridden = $weather + ->withUnits(Units::IMPERIAL) + ->withLanguage('pt'); + + $imperial = $overridden->current(38.7223, -9.1393); + $imperialRequest = $this->client->getLastRequest(); + $metric = $weather->current(38.7223, -9.1393); + $metricRequest = $this->client->getLastRequest(); + + self::assertSame(Unit::FAHRENHEIT, $imperial->temperatureUnit()); + self::assertSame(Unit::CELSIUS, $metric->temperatureUnit()); + self::assertSame('imperial', $this->query($imperialRequest)['units']); + self::assertSame('pt', $this->query($imperialRequest)['lang']); + self::assertSame('metric', $this->query($metricRequest)['units']); + self::assertSame('en', $this->query($metricRequest)['lang']); + } + + public function testGetsForecastByCoordinates(): void + { + $this->respondWithFixture('weather/forecast/success.json'); + + $forecast = $this->api->weather()->forecast( + latitude: 38.7223, + longitude: -9.1393, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(Forecast::class, $forecast); + self::assertSame(40, $forecast->count()); + self::assertCount(40, $forecast->periods()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/2.5/forecast', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testGetsLimitedForecastWithFluentConfiguration(): void + { + $this->client->addResponse(new Response( + body: '{"cnt":1,"list":[{"main":{"temp":72.5}}]}', + )); + + $forecast = $this->api + ->weather() + ->withUnits(Units::IMPERIAL) + ->withLanguage('pt') + ->forecast(38.7223, -9.1393, count: 1); + $request = $this->client->getLastRequest(); + + self::assertSame(1, $forecast->count()); + self::assertSame(Unit::FAHRENHEIT, $forecast->periods()[0]->temperatureUnit()); + self::assertSame('72.5 °F', $forecast->periods()[0]->temperatureWithUnit()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'cnt' => '1', + 'units' => 'imperial', + 'lang' => 'pt', + 'appid' => 'api-key', + ], $this->query($request)); + } + + #[DataProvider('invalidCoordinates')] + public function testCurrentRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->weather()->current($latitude, $longitude); + } + + #[DataProvider('invalidCoordinates')] + public function testForecastRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->weather()->forecast($latitude, $longitude); + } + + public function testForecastRejectsInvalidCount(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The forecast count must be at least 1.'); + + $this->api->weather()->forecast(38.7223, -9.1393, count: 0); + } + + public static function invalidCoordinates(): iterable + { + yield 'invalid latitude' => [ + 90.0001, + 0, + 'Latitude must be a finite number between -90 and 90.', + ]; + yield 'invalid longitude' => [ + 0, + 180.0001, + 'Longitude must be a finite number between -180 and 180.', + ]; + } +} diff --git a/tests/Unit/Response/PayloadDecoderTest.php b/tests/Unit/Response/PayloadDecoderTest.php new file mode 100644 index 0000000..4d42e6c --- /dev/null +++ b/tests/Unit/Response/PayloadDecoderTest.php @@ -0,0 +1,77 @@ +decoder = new PayloadDecoder(); + } + + public function testItDecodesJsonRegardlessOfTheContentType(): void + { + $response = new Response( + status: 401, + headers: ['Content-Type' => 'image/png'], + body: Fixture::contents('maps/tile/missing-key.json'), + ); + + self::assertSame( + Fixture::json('maps/tile/missing-key.json'), + ($this->decoder)($response), + ); + } + + public function testItReturnsBinaryBodiesUnchanged(): void + { + $contents = Fixture::contents('maps/tile/clouds-new.png'); + $response = new Response( + headers: ['Content-Type' => 'image/png'], + body: $contents, + ); + + self::assertSame($contents, ($this->decoder)($response)); + } + + public function testItReturnsPlainTextBodiesUnchanged(): void + { + $response = new Response(status: 503, body: 'Service unavailable'); + + self::assertSame('Service unavailable', ($this->decoder)($response)); + } + + public function testItRejectsMalformedSuccessfulJson(): void + { + $response = new Response( + headers: ['Content-Type' => 'application/json'], + body: '{"incomplete":', + ); + + $this->expectException(\JsonException::class); + + ($this->decoder)($response); + } + + public function testItReturnsNullForAnEmptyBody(): void + { + self::assertNull(($this->decoder)(new Response())); + } + + public function testItRewindsTheResponseBodyBeforeReading(): void + { + $response = new Response(body: '{"ok":true}'); + $response->getBody()->getContents(); + + self::assertSame(['ok' => true], ($this->decoder)($response)); + } +} diff --git a/tests/Unit/TimezoneTest.php b/tests/Unit/TimezoneTest.php deleted file mode 100644 index 84bc444..0000000 --- a/tests/Unit/TimezoneTest.php +++ /dev/null @@ -1,20 +0,0 @@ - 'UTC', - 'timezone_offset' => 0 - ]); - - $this->assertSame('UTC', $entity->getIdentifier()); - $this->assertSame(0, $entity->getOffset()); - } -} \ No newline at end of file diff --git a/tests/Unit/Validation/AssertTest.php b/tests/Unit/Validation/AssertTest.php new file mode 100644 index 0000000..f95e235 --- /dev/null +++ b/tests/Unit/Validation/AssertTest.php @@ -0,0 +1,207 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The values must not be empty.'); + + Assert::notEmpty([], 'values'); + } + + public function testItAcceptsAnInstanceOfAClass(): void + { + $value = new \stdClass(); + + self::assertSame( + $value, + Assert::isInstanceOf($value, \stdClass::class, 'value'), + ); + } + + public function testItRejectsAValueThatIsNotAnInstanceOfAClass(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The value must be an instance of stdClass.', + ); + + Assert::isInstanceOf('value', \stdClass::class, 'value'); + } + + public function testItPreservesANullableNonBlankString(): void + { + self::assertNull(Assert::nullableNotBlank(null, 'value')); + self::assertSame('value', Assert::nullableNotBlank(' value ', 'value')); + } + + public function testItAcceptsAnArrayContainingOnlyInstancesOfAClass(): void + { + $values = [new \stdClass(), new \stdClass()]; + + self::assertSame( + $values, + Assert::allInstancesOf($values, \stdClass::class, 'value'), + ); + } + + public function testItRejectsAnArrayContainingAnotherType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The value at index 1 must be an instance of stdClass.', + ); + + Assert::allInstancesOf( + [new \stdClass(), 'value'], + \stdClass::class, + 'value', + ); + } + + #[DataProvider('finiteNumbers')] + public function testItAcceptsFiniteNumbers(float $value): void + { + self::assertSame($value, Assert::finiteNumber($value, 'value')); + } + + public static function finiteNumbers(): iterable + { + yield 'negative' => [-10.5]; + yield 'zero' => [0.0]; + yield 'positive' => [10.5]; + } + + #[DataProvider('nonFiniteNumbers')] + public function testItRejectsNonFiniteNumbers(float $value): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The value must be a finite number.'); + + Assert::finiteNumber($value, 'value'); + } + + public static function nonFiniteNumbers(): iterable + { + yield 'negative infinity' => [-INF]; + yield 'positive infinity' => [INF]; + yield 'not a number' => [NAN]; + } + + public function testItPreservesANullableFiniteNumber(): void + { + self::assertNull(Assert::nullableFiniteNumber(null, 'value')); + self::assertSame(10.5, Assert::nullableFiniteNumber(10.5, 'value')); + } + + #[DataProvider('nonNegativeIntegers')] + public function testItAcceptsNonNegativeIntegers(int $value): void + { + self::assertSame( + $value, + Assert::nonNegativeInteger($value, 'value'), + ); + } + + /** + * @return iterable + */ + public static function nonNegativeIntegers(): iterable + { + yield 'zero' => [0]; + yield 'positive integer' => [9]; + } + + public function testItRejectsNegativeIntegers(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The value must be zero or greater.'); + + Assert::nonNegativeInteger(-1, 'value'); + } + + #[DataProvider('validTileCoordinates')] + public function testItAcceptsCoordinatesWithinTheZoomRange( + int $coordinate, + int $zoom, + ): void { + self::assertSame( + $coordinate, + Assert::tileCoordinate($coordinate, $zoom, 'x'), + ); + } + + /** + * @return iterable + */ + public static function validTileCoordinates(): iterable + { + yield 'zoom zero origin' => [0, 0]; + yield 'zoom nine origin' => [0, 9]; + yield 'zoom nine maximum' => [511, 9]; + yield 'zoom twenty center' => [524288, 20]; + yield 'zoom twenty maximum' => [1048575, 20]; + } + + #[DataProvider('invalidTileCoordinates')] + public function testItRejectsCoordinatesOutsideTheZoomRange( + int $coordinate, + int $zoom, + string $axis, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + Assert::tileCoordinate($coordinate, $zoom, $axis); + } + + /** + * @return iterable + */ + public static function invalidTileCoordinates(): iterable + { + yield 'negative X' => [ + -1, + 9, + 'x', + 'At zoom level 9, the tile X coordinate must be between 0 and 511.', + ]; + yield 'X above maximum' => [ + 512, + 9, + 'x', + 'At zoom level 9, the tile X coordinate must be between 0 and 511.', + ]; + yield 'Y above zoom zero maximum' => [ + 1, + 0, + 'y', + 'At zoom level 0, the tile Y coordinate must be 0.', + ]; + } + + public function testTileCoordinateRejectsANegativeZoom(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The tile zoom level must be zero or greater.', + ); + + Assert::tileCoordinate(0, -1, 'x'); + } +} diff --git a/tests/Unit/Weather/WeatherCollectionTest.php b/tests/Unit/Weather/WeatherCollectionTest.php deleted file mode 100644 index 0aefbe5..0000000 --- a/tests/Unit/Weather/WeatherCollectionTest.php +++ /dev/null @@ -1,72 +0,0 @@ - 1, - 'city' => [ - 'id' => 100, - 'name' => 'name', - 'coord' => [ - 'lat' => 50, - 'lon' => 50 - ], - 'country' => 'PT', - 'population' => 100, - 'timezone' => 0, - 'sunrise' => 1715130253, - 'sunset' => 1715184525 - ], - 'list' => [ - [ - 'dt' => 1715187406, - 'weather' => [ - [ - 'id' => 200, - 'main' => 'main', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'main' => [ - 'temp' => 20, - 'feels_like' => 20, - 'temp_min' => 15, - 'temp_max' => 25, - 'pressure' => 1010, - 'humidity' => 50 - ], - 'visibility' => 10000, - 'wind' => [ - 'speed' => 100, - 'deg' => 100, - 'gust' => 100 - ], - 'clouds' => [ - 'all' => 100 - ], - 'pop' => 1, - 'rain' => [ - '3h' => 10 - ], - 'snow' => [ - '3h' => 10 - ] - ] - ] - ]); - - $this->assertSame(1, $entity->getNumResults()); - $this->assertInstanceOf(Location::class, $entity->getLocation()); - $this->assertContainsOnlyInstancesOf(WeatherData::class, $entity->getData()); - } -} \ No newline at end of file diff --git a/tests/Unit/Weather/WeatherDataTest.php b/tests/Unit/Weather/WeatherDataTest.php deleted file mode 100644 index 5bedf1f..0000000 --- a/tests/Unit/Weather/WeatherDataTest.php +++ /dev/null @@ -1,65 +0,0 @@ - [ - [ - 'id' => 200, - 'main' => 'main', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'main' => [ - 'temp' => 20, - 'feels_like' => 20, - 'temp_min' => 15, - 'temp_max' => 25, - 'pressure' => 1010, - 'humidity' => 50 - ], - 'visibility' => 10000, - 'wind' => [ - 'speed' => 100, - 'deg' => 100, - 'gust' => 100 - ], - 'clouds' => [ - 'all' => 100 - ], - 'pop' => 1, - 'rain' => [ - '1h' => 10 - ], - 'snow' => [ - '1h' => 10 - ], - 'dt' => 1715187406 - ]); - - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame(20.0, $entity->getTemperature()); - $this->assertSame(20.0, $entity->getTemperatureFeelsLike()); - $this->assertSame(15.0, $entity->getMinTemperature()); - $this->assertSame(25.0, $entity->getMaxTemperature()); - $this->assertSame(50, $entity->getHumidity()); - $this->assertSame(100, $entity->getCloudiness()); - $this->assertSame(10000, $entity->getVisibility()); - $this->assertSame(1010, $entity->getAtmosphericPressure()); - $this->assertContainsOnlyInstancesOf(Condition::class, $entity->getConditions()); - $this->assertInstanceOf(Wind::class, $entity->getWind()); - $this->assertSame(100, $entity->getPrecipitationProbability()); - $this->assertSame(10.0, $entity->getRainVolume()); - $this->assertSame(10.0, $entity->getSnowVolume()); - } -} \ No newline at end of file diff --git a/tests/Unit/Weather/WeatherTest.php b/tests/Unit/Weather/WeatherTest.php deleted file mode 100644 index b7ebec9..0000000 --- a/tests/Unit/Weather/WeatherTest.php +++ /dev/null @@ -1,79 +0,0 @@ - [ - 'lat' => 50, - 'lon' => 50 - ], - 'id' => 100, - 'name' => 'name', - 'sys' => [ - 'country' => 'PT', - 'sunrise' => 1715130253, - 'sunset' => 1715184525 - ], - 'timezone' => 0, - 'weather' => [ - [ - 'id' => 200, - 'main' => 'main', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'main' => [ - 'temp' => 20, - 'feels_like' => 20, - 'temp_min' => 15, - 'temp_max' => 25, - 'pressure' => 1010, - 'humidity' => 50 - ], - 'visibility' => 10000, - 'wind' => [ - 'speed' => 100, - 'deg' => 100, - 'gust' => 100 - ], - 'clouds' => [ - 'all' => 100 - ], - 'pop' => 1, - 'rain' => [ - '1h' => 10 - ], - 'snow' => [ - '1h' => 10 - ], - 'dt' => 1715187406 - ]); - - $this->assertInstanceOf(Location::class, $entity->getLocation()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame(20.0, $entity->getTemperature()); - $this->assertSame(20.0, $entity->getTemperatureFeelsLike()); - $this->assertSame(15.0, $entity->getMinTemperature()); - $this->assertSame(25.0, $entity->getMaxTemperature()); - $this->assertSame(50, $entity->getHumidity()); - $this->assertSame(100, $entity->getCloudiness()); - $this->assertSame(10000, $entity->getVisibility()); - $this->assertSame(1010, $entity->getAtmosphericPressure()); - $this->assertContainsOnlyInstancesOf(Condition::class, $entity->getConditions()); - $this->assertInstanceOf(Wind::class, $entity->getWind()); - $this->assertSame(100, $entity->getPrecipitationProbability()); - $this->assertSame(10.0, $entity->getRainVolume()); - $this->assertSame(10.0, $entity->getSnowVolume()); - } -} \ No newline at end of file diff --git a/tests/Unit/WindTest.php b/tests/Unit/WindTest.php deleted file mode 100644 index 6a487ec..0000000 --- a/tests/Unit/WindTest.php +++ /dev/null @@ -1,22 +0,0 @@ - 100, - 'deg' => 100, - 'gust' => 100 - ]); - - $this->assertSame(100.0, $entity->getSpeed()); - $this->assertSame(100, $entity->getDirection()); - $this->assertSame(100.0, $entity->getGust()); - } -} \ No newline at end of file