diff --git a/.env b/.env index f7a36f1..5dd84cf 100644 --- a/.env +++ b/.env @@ -84,6 +84,13 @@ SFTP_XML_EXPORT_USER= SFTP_XML_EXPORT_PASSWORD= SFTP_XML_EXPORT_ROOT= +# Machine to machine access to the MyE&P API. This is a separate confidential client +# from the login one below, so it can be rotated without locking anyone out. The base +# url is the host root: both the token endpoint and the api paths hang off it. +MYEP_API_BASE_URL=https://my.ep-reisen.de/ +MYEP_API_CLIENT_ID= +MYEP_API_CLIENT_SECRET= + MYEP_OAUTH2_CLIENT_ID= MYEP_OAUTH2_CLIENT_SECRET= MYEP_OAUTH2_URL_AUTHORIZE=https://my.ep-reisen.de/authorize diff --git a/assets/images/icons.svg b/assets/images/icons.svg index ddb1040..4c64bab 100644 --- a/assets/images/icons.svg +++ b/assets/images/icons.svg @@ -198,11 +198,11 @@ - + - - - + + + diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 72722c1..ef8942d 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -28,6 +28,22 @@ framework: router: default_uri: '%env(APP_BASE_URI)%' + # Every call to the sibling MyE&P application, both the token request and the API + # itself. Paths are given relative to the base uri, so without a leading slash. + http_client: + scoped_clients: + myep.client: + base_uri: '%env(MYEP_API_BASE_URL)%' + timeout: 10 + max_duration: 30 + headers: + Accept: application/json + retry_failed: + max_retries: 2 + # 401 is deliberately absent: it needs the cached token dropped + # before the retry, which App\MyEp\Api\ApiClient does itself. + http_codes: [423, 425, 429, 500, 502, 503, 504] + when@test: framework: test: true diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml index cdce850..f8ce77a 100644 --- a/config/packages/monolog.yaml +++ b/config/packages/monolog.yaml @@ -3,6 +3,7 @@ monolog: - deprecation - bpn - saas + - myep - audit - api @@ -13,7 +14,7 @@ monolog: handler: exceptions excluded_http_codes: [404, 405] buffer_size: 50 - channels: ["!bpn", "!saas", "!audit", "!api"] + channels: ["!bpn", "!saas", "!myep", "!audit", "!api"] exceptions: type: rotating_file path: "%kernel.logs_dir%/framework.%kernel.environment%.log" @@ -31,6 +32,12 @@ monolog: path: '%kernel.logs_dir%/saas.%kernel.environment%.log' max_files: 5 level: info + myep: + channels: ["myep"] + type: rotating_file + path: '%kernel.logs_dir%/myep.%kernel.environment%.log' + max_files: 5 + level: info audit: channels: [ "audit" ] type: rotating_file @@ -52,7 +59,7 @@ monolog: console: type: console process_psr_3_messages: false - channels: ["!event", "!bpn", "!saas", "!api", "!doctrine", "!console"] + channels: ["!event", "!bpn", "!saas", "!myep", "!api", "!doctrine", "!console"] when@dev: monolog: @@ -89,7 +96,7 @@ when@prod: handler: exceptions excluded_http_codes: [404, 405] buffer_size: 50 - channels: ["!bpn", "!saas", "!audit", "!api"] + channels: ["!bpn", "!saas", "!myep", "!audit", "!api"] exceptions: type: rotating_file path: "%kernel.logs_dir%/framework.%kernel.environment%.log" @@ -107,6 +114,12 @@ when@prod: path: '%kernel.logs_dir%/saas.%kernel.environment%.log' max_files: 5 level: info + myep: + channels: ["myep"] + type: rotating_file + path: '%kernel.logs_dir%/myep.%kernel.environment%.log' + max_files: 5 + level: info audit: channels: [ "audit" ] type: rotating_file @@ -126,4 +139,4 @@ when@prod: console: type: console process_psr_3_messages: false - channels: ["!event", "!doctrine", "!bpn", "!saas", "!audit", "!api"] + channels: ["!event", "!doctrine", "!bpn", "!saas", "!myep", "!audit", "!api"] diff --git a/config/packages/zenstruck_schedule.yaml b/config/packages/zenstruck_schedule.yaml index ea2d9fd..5c0b652 100644 --- a/config/packages/zenstruck_schedule.yaml +++ b/config/packages/zenstruck_schedule.yaml @@ -15,8 +15,10 @@ zenstruck_schedule: when@prod: zenstruck_schedule: tasks: + # 03:20 rather than 03:00: MyE&P mirrors from BusProNet hourly overnight, on the + # hour, so an import at 03:00 can read a half written export. - task: app:bpn-import - frequency: '0 3 * * *' + frequency: '20 3 * * *' description: "Imports dates, products and hotels from BusProNet" - task: app:cron @@ -46,7 +48,7 @@ when@staging: tasks: - task: app:bpn-import - frequency: '30 3 * * *' + frequency: '50 3 * * *' description: "Imports dates, products and hotels from BusProNet" - task: app:cron diff --git a/config/services.yaml b/config/services.yaml index d4d6096..eeb5517 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -217,6 +217,31 @@ services: # new starter is picked up the same day. $ttl: 21600 + App\MyEp\Api\AccessTokenProvider: + arguments: + $logger: '@monolog.logger.myep' + $cache: '@cache.app' + $options: + client_id: '%env(MYEP_API_CLIENT_ID)%' + client_secret: '%env(MYEP_API_CLIENT_SECRET)%' + + App\MyEp\Api\ApiClient: + arguments: + $logger: '@monolog.logger.myep' + + App\MyEp\Api\ResponseMapper: + arguments: + $logger: '@monolog.logger.myep' + + App\MyEp\Cache\StaleWhileErrorCache: + arguments: + $logger: '@monolog.logger.myep' + $cache: '@cache.app' + + App\MyEp\Import\TravelExportClient: + arguments: + $logger: '@monolog.logger.myep' + App\Twig\AppRuntime: arguments: $intlExtension: '@twig.extension.intl' diff --git a/migrations/Version20260813120000.php b/migrations/Version20260813120000.php new file mode 100644 index 0000000..b9ef624 --- /dev/null +++ b/migrations/Version20260813120000.php @@ -0,0 +1,49 @@ + 'at', + 'd' => 'de', + 'f' => 'fr', + 'i' => 'it', + ]; + + public function getDescription(): string + { + return 'Convert destination.country to lowercase ISO-3166-1 alpha-2'; + } + + public function up(Schema $schema): void + { + foreach (self::TO_ISO as $legacy => $iso) { + $this->addSql('UPDATE destination SET country = ? WHERE country = ?', [$iso, $legacy]); + } + + // ch and nl are already ISO; empty strings were never a country + $this->addSql("UPDATE destination SET country = NULL WHERE country = ''"); + } + + public function down(Schema $schema): void + { + foreach (self::TO_ISO as $legacy => $iso) { + $this->addSql('UPDATE destination SET country = ? WHERE country = ?', [$legacy, $iso]); + } + } +} diff --git a/src/BusProNet/DataProvider/CountryDataProvider.php b/src/BusProNet/DataProvider/CountryDataProvider.php index ee43460..f174264 100644 --- a/src/BusProNet/DataProvider/CountryDataProvider.php +++ b/src/BusProNet/DataProvider/CountryDataProvider.php @@ -2,46 +2,39 @@ namespace App\BusProNet\DataProvider; -use App\BusProNet\ApiClient; -use App\BusProNet\ApiClientException; use App\BusProNet\Model\Country; -use App\BusProNet\Model\NotificationResponse; -use App\BusProNet\ResponseParserException; -use Psr\Log\LoggerInterface; -use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; +use App\MyEp\Api\ApiClient; +use App\MyEp\Api\ResponseMapper; +use App\MyEp\Cache\StaleWhileErrorCache; +/** + * Country base data, read from MyE&P rather than from BusProNet directly. + * + * Keyed by the raw BusProNet token, not by an ISO code: the token is what a hotel's + * country and a teamer's nationality join against, and that vocabulary stays raw. + */ class CountryDataProvider { + private const CACHE_KEY = 'myep_countries'; + private const TTL = 3600; + public function __construct( private readonly ApiClient $apiClient, - private readonly CacheInterface $cache, - private readonly LoggerInterface $logger, + private readonly ResponseMapper $responseMapper, + private readonly StaleWhileErrorCache $cache, ) { } + /** + * @return array keyed by token + */ public function getAll(): array { - try { - $countries = $this->cache->get('bpn_countries', function (ItemInterface $item) { - $item->expiresAfter(3600); - - $response = $this->apiClient->getBaseData(ApiClient::TYPE_BASE_DATA_COUNTRIES); - - if ($response instanceof NotificationResponse) { - $this->logger->error('Unable to fetch country base data from BusProNet. Code: '.$response->getCode().', Message: '.$response->getMessage()); - - throw new ApiClientException('Unable to fetch country base data from BusProNet'); - } - - return $response->getItems(); - }); - } catch (ApiClientException|ResponseParserException $e) { - $this->logger->error('Unable to fetch country base data from BusProNet: '.$e->getMessage()); - $countries = []; - } - - return $countries; + return $this->cache->get( + self::CACHE_KEY, + self::TTL, + fn (): array => $this->responseMapper->mapCountries($this->apiClient->get('api/countries')), + ); } public function get(?string $token): ?Country diff --git a/src/BusProNet/DataProvider/HotelDataProvider.php b/src/BusProNet/DataProvider/HotelDataProvider.php index 6a33096..65faf49 100644 --- a/src/BusProNet/DataProvider/HotelDataProvider.php +++ b/src/BusProNet/DataProvider/HotelDataProvider.php @@ -2,47 +2,40 @@ namespace App\BusProNet\DataProvider; -use App\BusProNet\ApiClient; -use App\BusProNet\ApiClientException; use App\BusProNet\Model\Hotel; -use App\BusProNet\Model\NotificationResponse; -use App\BusProNet\ResponseParserException; -use Psr\Cache\InvalidArgumentException; -use Psr\Log\LoggerInterface; -use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; +use App\MyEp\Api\ApiClient; +use App\MyEp\Api\ResponseMapper; +use App\MyEp\Cache\StaleWhileErrorCache; +/** + * Hotel base data, read from MyE&P rather than from BusProNet directly. + * + * The models and the public interface are unchanged from when this spoke the BusProNet + * socket protocol: every caller keys on the BusProNet idbuspro, which is exactly what + * MyE&P reports as the hotel id. + */ class HotelDataProvider { + private const CACHE_KEY = 'myep_hotels'; + private const TTL = 3600; + public function __construct( private readonly ApiClient $apiClient, - private readonly CacheInterface $cache, - private readonly LoggerInterface $logger, + private readonly ResponseMapper $responseMapper, + private readonly StaleWhileErrorCache $cache, ) { } + /** + * @return array keyed by busProId + */ public function getAll(): array { - try { - $hotels = $this->cache->get('bpn_hotels', function (ItemInterface $item) { - $item->expiresAfter(3600); - - $response = $this->apiClient->getBaseData(ApiClient::TYPE_BASE_DATA_HOTELS); - - if ($response instanceof NotificationResponse) { - $this->logger->error('Unable to fetch hotel base data from BusProNet. Code: '.$response->getCode().', Message: '.$response->getMessage()); - - throw new ApiClientException('Unable to fetch hotel base data from BusProNet'); - } - - return $response->getItems(); - }); - } catch (ApiClientException|InvalidArgumentException|ResponseParserException $e) { - $this->logger->error('Unable to fetch hotel base data from BusProNet: '.$e->getMessage()); - $hotels = []; - } - - return $hotels; + return $this->cache->get( + self::CACHE_KEY, + self::TTL, + fn (): array => $this->responseMapper->mapHotels($this->apiClient->get('api/hotels')), + ); } public function get(int $busProId): ?Hotel @@ -50,12 +43,21 @@ class HotelDataProvider return $this->getAll()[$busProId] ?? null; } + /** + * @return list + */ public function findByCode(string $code): array { $hotels = []; foreach ($this->getAll() as $hotel) { - if (str_starts_with($hotel->getCode(), $code) || str_ends_with($hotel->getCode(), $code)) { + $hotelCode = $hotel->getCode(); + + if (null === $hotelCode) { + continue; + } + + if (str_starts_with($hotelCode, $code) || str_ends_with($hotelCode, $code)) { $hotels[] = $hotel; } } diff --git a/src/BusProNet/DataProvider/PickupDataProvider.php b/src/BusProNet/DataProvider/PickupDataProvider.php index 8ed4d92..d003f7a 100644 --- a/src/BusProNet/DataProvider/PickupDataProvider.php +++ b/src/BusProNet/DataProvider/PickupDataProvider.php @@ -2,46 +2,39 @@ namespace App\BusProNet\DataProvider; -use App\BusProNet\ApiClient; -use App\BusProNet\ApiClientException; -use App\BusProNet\Model\NotificationResponse; use App\BusProNet\Model\Pickup; -use App\BusProNet\ResponseParserException; -use Psr\Log\LoggerInterface; -use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; +use App\MyEp\Api\ApiClient; +use App\MyEp\Api\ResponseMapper; +use App\MyEp\Cache\StaleWhileErrorCache; +/** + * Boarding point base data, read from MyE&P rather than from BusProNet directly. + * + * These are the stops themselves, without a departure time: a time belongs to a stop on + * a given travel date and is carried by the destination's pickups instead. + */ class PickupDataProvider { + private const CACHE_KEY = 'myep_pickups'; + private const TTL = 3600; + public function __construct( private readonly ApiClient $apiClient, - private readonly CacheInterface $cache, - private readonly LoggerInterface $logger, + private readonly ResponseMapper $responseMapper, + private readonly StaleWhileErrorCache $cache, ) { } + /** + * @return array keyed by busProId + */ public function getAll(): array { - try { - $pickups = $this->cache->get('bpn_pickups', function (ItemInterface $item) { - $item->expiresAfter(3600); - - $response = $this->apiClient->getBaseData(ApiClient::TYPE_BASE_DATA_PICKUPS); - - if ($response instanceof NotificationResponse) { - $this->logger->error('Unable to fetch pickup base data from BusProNet. Code: '.$response->getCode().', Message: '.$response->getMessage()); - - throw new ApiClientException('Unable to fetch pickup base data from BusProNet'); - } - - return $response->getItems(); - }); - } catch (ApiClientException|ResponseParserException $e) { - $this->logger->error('Unable to fetch pickup base data from BusProNet: '.$e->getMessage()); - $pickups = []; - } - - return $pickups; + return $this->cache->get( + self::CACHE_KEY, + self::TTL, + fn (): array => $this->responseMapper->mapPickups($this->apiClient->get('api/pickups')), + ); } public function get(int $busProId): ?Pickup diff --git a/src/BusProNet/Model/Hotel.php b/src/BusProNet/Model/Hotel.php index 79e131e..7457ab0 100644 --- a/src/BusProNet/Model/Hotel.php +++ b/src/BusProNet/Model/Hotel.php @@ -12,7 +12,17 @@ class Hotel private ?string $street = null; private ?string $phone = null; private ?string $type = null; + /** + * The raw BusProNet abbreviation, which is not a standard: the same country turns + * up as D, DEU, A, AT or AUT. It joins against the country base data by token. + */ private ?string $country = null; + + /** + * The same country as lowercase ISO-3166-1 alpha-2, resolved by MyE&P. Null when + * the source carries no country or none can be determined. + */ + private ?string $countryCode = null; private ?string $costUnit = null; public function getId(): ?int @@ -123,6 +133,18 @@ class Hotel return $this; } + public function getCountryCode(): ?string + { + return $this->countryCode; + } + + public function setCountryCode(?string $countryCode): static + { + $this->countryCode = $countryCode; + + return $this; + } + public function getCostUnit(): ?string { return $this->costUnit; diff --git a/src/Form/ApplicationFilterType.php b/src/Form/ApplicationFilterType.php index 871167b..aece7db 100644 --- a/src/Form/ApplicationFilterType.php +++ b/src/Form/ApplicationFilterType.php @@ -63,10 +63,10 @@ class ApplicationFilterType extends AbstractType 'required' => false, 'placeholder' => 'nicht filtern', 'choices' => [ - 'Österreich' => 'a', + 'Österreich' => 'at', 'Schweiz' => 'ch', - 'Frankreich' => 'f', - 'Italien' => 'i', + 'Frankreich' => 'fr', + 'Italien' => 'it', ], ]) ->add('apply', SubmitType::class, [ diff --git a/src/Form/AssignmentFilterType.php b/src/Form/AssignmentFilterType.php index e96bffe..c1dd566 100644 --- a/src/Form/AssignmentFilterType.php +++ b/src/Form/AssignmentFilterType.php @@ -67,10 +67,10 @@ class AssignmentFilterType extends AbstractType 'required' => false, 'placeholder' => 'nicht filtern', 'choices' => [ - 'Österreich' => 'a', + 'Österreich' => 'at', 'Schweiz' => 'ch', - 'Frankreich' => 'f', - 'Italien' => 'i', + 'Frankreich' => 'fr', + 'Italien' => 'it', ], ]) ->add('apply', SubmitType::class, [ diff --git a/src/Form/DestinationType.php b/src/Form/DestinationType.php index 287c560..ad8f37a 100644 --- a/src/Form/DestinationType.php +++ b/src/Form/DestinationType.php @@ -41,11 +41,11 @@ class DestinationType extends AbstractType ->add('country', ChoiceType::class, [ 'label' => 'Land', 'choices' => [ - 'Österreich' => 'a', + 'Österreich' => 'at', 'Schweiz' => 'ch', - 'Frankreich' => 'f', - 'Italien' => 'i', - 'Deutschland' => 'd', + 'Frankreich' => 'fr', + 'Italien' => 'it', + 'Deutschland' => 'de', 'Niederlande' => 'nl', ], ]) diff --git a/src/MyEp/Api/AccessTokenProvider.php b/src/MyEp/Api/AccessTokenProvider.php new file mode 100644 index 0000000..a09fb6f --- /dev/null +++ b/src/MyEp/Api/AccessTokenProvider.php @@ -0,0 +1,139 @@ +config = $this->resolveOptions($options); + } + + /** + * @throws ApiException + */ + public function getToken(): string + { + try { + $item = $this->cache->getItem(self::CACHE_KEY); + } catch (InvalidArgumentException $e) { + throw new ApiException('Unable to read the cached MyE&P access token', 0, $e); + } + + if ($item->isHit()) { + return $item->get(); + } + + [$token, $lifetime] = $this->requestToken(); + + $item->set($token); + $item->expiresAfter($lifetime); + $this->cache->save($item); + + return $token; + } + + /** + * Drops the cached token so the next call fetches a fresh one. Used when the API + * rejects a token the cache still considered valid. + */ + public function invalidate(): void + { + try { + $this->cache->deleteItem(self::CACHE_KEY); + } catch (InvalidArgumentException) { + // the key is a constant, so it cannot be malformed; nothing to recover from + } + } + + /** + * @return array{0: string, 1: int} the token and the seconds it may be cached for + * + * @throws ApiException + */ + private function requestToken(): array + { + try { + $response = $this->myepClient->request('POST', $this->config['token_path'], [ + 'body' => [ + 'grant_type' => 'client_credentials', + 'client_id' => $this->config['client_id'], + 'client_secret' => $this->config['client_secret'], + 'scope' => $this->config['scope'], + ], + ]); + + $statusCode = $response->getStatusCode(); + $payload = $response->toArray(false); + } catch (TransportException $e) { + $this->logger->error('Unable to obtain a MyE&P access token', [ + 'client_id' => $this->config['client_id'], + 'exception' => $e, + ]); + + throw new ApiException('Unable to obtain a MyE&P access token', 0, $e); + } + + $token = $payload['access_token'] ?? null; + + if (200 !== $statusCode || false === is_string($token) || '' === $token) { + $this->logger->error('MyE&P rejected the access token request', [ + 'client_id' => $this->config['client_id'], + 'status_code' => $statusCode, + 'error' => $payload['error'] ?? null, + ]); + + throw new ApiException('MyE&P rejected the access token request with status '.$statusCode); + } + + $expiresIn = (int) ($payload['expires_in'] ?? self::DEFAULT_LIFETIME); + + return [$token, max(self::MINIMUM_LIFETIME, $expiresIn - self::EXPIRY_MARGIN)]; + } + + private function resolveOptions(array $options): array + { + $optionsResolver = new OptionsResolver(); + $optionsResolver->setRequired(['client_id', 'client_secret']); + $optionsResolver->setDefaults([ + 'token_path' => 'token', + 'scope' => 'api', + ]); + + return $optionsResolver->resolve($options); + } +} diff --git a/src/MyEp/Api/ApiClient.php b/src/MyEp/Api/ApiClient.php new file mode 100644 index 0000000..8e998c9 --- /dev/null +++ b/src/MyEp/Api/ApiClient.php @@ -0,0 +1,105 @@ +send($path, $query); + + if (Response::HTTP_UNAUTHORIZED === $this->statusOf($response, $path)) { + // tokens live ten minutes, so one expiring mid-run is routine rather than a fault + $this->tokens->invalidate(); + $response = $this->send($path, $query); + } + + $statusCode = $this->statusOf($response, $path); + + if (Response::HTTP_FORBIDDEN === $statusCode) { + $this->logger->error('MyE&P denied a request for lack of scope', ['path' => $path]); + + throw new ApiException(sprintf('MyE&P denied "%s": the API client lacks the required scope', $path)); + } + + if ($statusCode < 200 || $statusCode >= 300) { + $this->logger->error('MyE&P returned an unexpected status', [ + 'path' => $path, + 'status_code' => $statusCode, + ]); + + throw new ApiException(sprintf('MyE&P returned status %d for "%s"', $statusCode, $path)); + } + + return $this->decode($response, $path); + } + + private function send(string $path, array $query): ResponseInterface + { + try { + return $this->myepClient->request('GET', $path, [ + 'auth_bearer' => $this->tokens->getToken(), + 'query' => $query, + ]); + } catch (TransportException $e) { + $this->logger->error('Request to MyE&P failed', ['path' => $path, 'exception' => $e]); + + throw new ApiException(sprintf('Request to MyE&P endpoint "%s" failed', $path), 0, $e); + } + } + + private function statusOf(ResponseInterface $response, string $path): int + { + try { + return $response->getStatusCode(); + } catch (TransportException $e) { + $this->logger->error('Request to MyE&P failed', ['path' => $path, 'exception' => $e]); + + throw new ApiException(sprintf('Request to MyE&P endpoint "%s" failed', $path), 0, $e); + } + } + + private function decode(ResponseInterface $response, string $path): array + { + try { + $payload = $response->toArray(false); + } catch (TransportException $e) { + $this->logger->error('Unreadable response from MyE&P', ['path' => $path, 'exception' => $e]); + + throw new ApiException(sprintf('Unreadable response from MyE&P endpoint "%s"', $path), 0, $e); + } + + // some MyE&P endpoints report upstream failures in the body while still + // answering with 200, so the payload decides, not the status code + if (isset($payload['error'])) { + $this->logger->error('MyE&P reported an error', [ + 'path' => $path, + 'error' => $payload['error'], + ]); + + throw new ApiException(sprintf('MyE&P reported an error for "%s": %s', $path, (string) $payload['error'])); + } + + return $payload; + } +} diff --git a/src/MyEp/Api/ApiException.php b/src/MyEp/Api/ApiException.php new file mode 100644 index 0000000..7c649fc --- /dev/null +++ b/src/MyEp/Api/ApiException.php @@ -0,0 +1,7 @@ + keyed by busProId + */ + public function mapHotels(array $rows): array + { + $hotels = []; + + foreach ($rows as $row) { + if (null === $busProId = $this->readBusProId($row, 'hotel')) { + continue; + } + + $hotel = new Hotel(); + $hotel + ->setId($busProId) + ->setBusProId($busProId) + ->setCode($this->readString($row, 'code')) + ->setName($this->readString($row, 'name')) + ->setCity($this->readString($row, 'city')) + ->setStreet($this->readString($row, 'street')) + ->setPhone($this->readString($row, 'phone')) + ->setCountry($this->readString($row, 'country')) + ->setCountryCode($this->readCountryCode($row)) + ->setCostUnit($this->readString($row, 'costUnit')) + ; + + $hotels[$busProId] = $hotel; + } + + return $hotels; + } + + /** + * @return array keyed by busProId + */ + public function mapPickups(array $rows): array + { + $pickups = []; + + foreach ($rows as $row) { + if (null === $busProId = $this->readBusProId($row, 'pickup')) { + continue; + } + + $pickup = new Pickup(); + $pickup + ->setId($busProId) + ->setBusProId($busProId) + ->setCode($this->readString($row, 'code')) + ->setCity($this->readString($row, 'city')) + ->setStreet($this->readString($row, 'street')) + ; + + // the time is a property of a boarding stop on a date, never of the stop itself + $pickups[$busProId] = $pickup; + } + + return $pickups; + } + + /** + * @return array keyed by token + */ + public function mapCountries(array $rows): array + { + $countries = []; + + foreach ($rows as $row) { + $token = is_array($row) ? $this->readString($row, 'token') : null; + + if (null === $token || '' === $token) { + $this->logger->warning('Skipped a MyE&P country without a token', ['row' => $row]); + continue; + } + + $country = new Country(); + $country + ->setId(isset($row['id']) ? (int) $row['id'] : null) + ->setName($this->readString($row, 'name')) + ->setToken($token) + ->setNationality($this->readString($row, 'nationality')) + ; + + $countries[$token] = $country; + } + + return $countries; + } + + private function readCountryCode(array $row): ?string + { + $countryCode = CountryCode::normalize($row['countryCode'] ?? null); + + if (null === $countryCode && null !== ($row['country'] ?? null)) { + $this->logger->warning('MyE&P reported a hotel country it could not map to ISO', [ + 'id' => $row['id'] ?? null, + 'country' => $row['country'], + 'countryCode' => $row['countryCode'] ?? null, + ]); + } + + return $countryCode; + } + + private function readBusProId(mixed $row, string $type): ?int + { + $busProId = is_array($row) ? (int) ($row['id'] ?? 0) : 0; + + if (0 === $busProId) { + $this->logger->warning(sprintf('Skipped a MyE&P %s without an id', $type), ['row' => $row]); + + return null; + } + + return $busProId; + } + + private function readString(array $row, string $key): ?string + { + $value = $row[$key] ?? null; + + return null === $value ? null : (string) $value; + } +} diff --git a/src/MyEp/Cache/StaleWhileErrorCache.php b/src/MyEp/Cache/StaleWhileErrorCache.php new file mode 100644 index 0000000..b175a50 --- /dev/null +++ b/src/MyEp/Cache/StaleWhileErrorCache.php @@ -0,0 +1,120 @@ +cache->getItem($key); + } catch (InvalidArgumentException $e) { + $this->logger->error('Unusable MyE&P cache key', ['key' => $key, 'exception' => $e]); + + return $this->produceUncached($key, $producer); + } + + if ($fresh->isHit()) { + return $fresh->get(); + } + + try { + $value = $producer(); + } catch (\Throwable $e) { + return $this->serveStale($key, $fresh, $e); + } + + $fresh->set($value); + $fresh->expiresAfter($ttl); + $this->cache->save($fresh); + + $this->saveLastGood($key, $value); + + return $value; + } + + private function serveStale(string $key, CacheItemInterface $fresh, \Throwable $e): array + { + try { + $lastGood = $this->cache->getItem($key.self::LAST_GOOD_SUFFIX); + } catch (InvalidArgumentException) { + $lastGood = null; + } + + if (null === $lastGood || false === $lastGood->isHit()) { + $this->logger->error('MyE&P request failed and no previous data is cached', [ + 'key' => $key, + 'exception' => $e, + ]); + + return []; + } + + $this->logger->error('MyE&P request failed, serving the last known data', [ + 'key' => $key, + 'exception' => $e, + ]); + + $stale = $lastGood->get(); + + $fresh->set($stale); + $fresh->expiresAfter(self::RETRY_DELAY); + $this->cache->save($fresh); + + return $stale; + } + + private function saveLastGood(string $key, array $value): void + { + try { + $lastGood = $this->cache->getItem($key.self::LAST_GOOD_SUFFIX); + } catch (InvalidArgumentException) { + return; + } + + $lastGood->set($value); + $lastGood->expiresAfter($this->staleLifetime); + $this->cache->save($lastGood); + } + + private function produceUncached(string $key, callable $producer): array + { + try { + return $producer(); + } catch (\Throwable $e) { + $this->logger->error('MyE&P request failed', ['key' => $key, 'exception' => $e]); + + return []; + } + } +} diff --git a/src/MyEp/Import/DestinationRow.php b/src/MyEp/Import/DestinationRow.php new file mode 100644 index 0000000..decc44e --- /dev/null +++ b/src/MyEp/Import/DestinationRow.php @@ -0,0 +1,155 @@ + $this->code, + 'date_from' => $this->dateFrom->format('Y-m-d'), + 'date_to' => $this->dateTo->format('Y-m-d'), + 'product' => $this->product, + 'hotel' => $this->hotelName, + 'hotel_code' => $this->hotelCode, + 'pickups' => json_encode($this->pickups), + 'cost_unit' => $this->costUnit, + 'country' => $this->country, + 'updated_at' => $now->format('Y-m-d H:i:s'), + ]; + } + + public function toInsertPayload(\DateTimeImmutable $now): array + { + return [ + 'code' => $this->code, + 'bus_pro_id' => $this->busProId, + 'date_from' => $this->dateFrom->format('Y-m-d'), + 'date_to' => $this->dateTo->format('Y-m-d'), + 'product' => $this->product, + 'hotel' => $this->hotelName, + 'hotel_code' => $this->hotelCode, + 'hotel_bus_pro_id' => $this->hotelBusProId, + 'pickups' => json_encode($this->pickups), + 'cost_unit' => $this->costUnit, + 'country' => $this->country, + 'created_at' => $now->format('Y-m-d H:i:s'), + ]; + } + + /** + * @throws InvalidRowException + */ + private static function readDate(array $data, string $key): \DateTimeImmutable + { + $value = $data[$key] ?? null; + + if (false === is_string($value) || false === $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value)) { + throw new InvalidRowException(sprintf('Travel export row with an unreadable %s', $key)); + } + + return $date; + } + + /** + * The shape here is what the destination table stores and what + * App\BusProNet\Model\Pickup::fromArray() reads back. + */ + private static function readPickups(mixed $pickups): array + { + if (false === is_array($pickups)) { + return []; + } + + $mapped = []; + + foreach ($pickups as $pickup) { + if (false === is_array($pickup)) { + continue; + } + + $busProId = (int) ($pickup['busProId'] ?? 0); + + if (0 === $busProId) { + continue; + } + + $mapped[] = [ + 'busProId' => $busProId, + 'city' => self::readNullableString($pickup, 'city'), + 'street' => self::readNullableString($pickup, 'street'), + 'time' => self::readNullableString($pickup, 'time'), + ]; + } + + return $mapped; + } + + private static function readNullableString(array $data, string $key): ?string + { + $value = $data[$key] ?? null; + + return null === $value ? null : (string) $value; + } +} diff --git a/src/MyEp/Import/InvalidRowException.php b/src/MyEp/Import/InvalidRowException.php new file mode 100644 index 0000000..d285dfc --- /dev/null +++ b/src/MyEp/Import/InvalidRowException.php @@ -0,0 +1,11 @@ +apiClient->get(self::PATH, [ + 'page' => $page, + 'perPage' => self::PER_PAGE, + ]); + + $data = $payload['data'] ?? null; + + if (false === is_array($data)) { + throw new ApiException('The MyE&P travel export did not contain a data list'); + } + + foreach ($data as $item) { + if (false === is_array($item)) { + continue; + } + + try { + $rows[] = DestinationRow::fromArray($item); + } catch (InvalidRowException $e) { + $this->logger->warning('Skipped an unusable travel export row', [ + 'reason' => $e->getMessage(), + 'row' => $item, + ]); + } + } + + $totalPages = max(1, (int) ($payload['meta']['totalPages'] ?? 1)); + ++$page; + } while ($page <= $totalPages && $page <= self::MAX_PAGES && [] !== $data); + + if ($totalPages > self::MAX_PAGES) { + $this->logger->error('The MyE&P travel export reported more pages than are read', [ + 'total_pages' => $totalPages, + 'max_pages' => self::MAX_PAGES, + ]); + } + + return $rows; + } +} diff --git a/src/Security/MyEpAuthenticator.php b/src/Security/MyEpAuthenticator.php index 8d139e8..990b372 100644 --- a/src/Security/MyEpAuthenticator.php +++ b/src/Security/MyEpAuthenticator.php @@ -12,7 +12,6 @@ use App\Security\OAuth2\MyEpClient; use Doctrine\ORM\EntityManagerInterface; use Flagception\Manager\FeatureManagerInterface; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; -use Psr\Http\Client\ClientExceptionInterface; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; @@ -26,6 +25,8 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\Util\TargetPathTrait; +use Symfony\Contracts\HttpClient\Exception\ExceptionInterface as HttpExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; class MyEpAuthenticator extends AbstractAuthenticator { @@ -46,6 +47,7 @@ class MyEpAuthenticator extends AbstractAuthenticator public function __construct( private readonly MyEpClient $client, + private readonly HttpClientInterface $myepClient, private readonly UserDataHandler $userDataHandler, private readonly EntityManagerInterface $entityManager, private readonly UrlGeneratorInterface $urlGenerator, @@ -84,25 +86,22 @@ class MyEpAuthenticator extends AbstractAuthenticator throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich'); } + // A plain bearer request over the shared MyE&P client, so it inherits its + // timeouts and transport retries. It deliberately does not go through + // App\MyEp\Api\ApiClient: this token was just minted for this one request, so + // there is no cached token to renew on a 401, and every failure here ends in + // the same message anyway. Logging stays on the audit channel, unlike the + // machine to machine traffic - a login attempt belongs in the audit trail. try { - $provider = $this->client->getProvider(); - $url = $provider->getResourceOwnerDetailsUrl($accessToken); - $userinfoRequest = $provider->getAuthenticatedRequest('GET', $url, $accessToken, [ - 'headers' => [ - 'Accept' => 'application/json', - 'Content-Type' => 'application/json', - ], + $response = $this->myepClient->request('GET', 'api/userinfo', [ + 'auth_bearer' => $accessToken->getToken(), + 'headers' => ['Accept' => 'application/json'], + ]); + $userinfo = $response->toArray(); + } catch (HttpExceptionInterface $e) { + $this->logger->error('Login via MyE&P failed due to unexpected userinfo response', [ + 'exception' => $e, ]); - $response = $provider->getHttpClient()->sendRequest($userinfoRequest); - } catch (ClientExceptionInterface $e) { - $this->logger->error('Login via MyE&P failed due to unexpected userinfo response'); - throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich'); - } - - $userinfo = json_decode($response->getBody(), true); - - if (false === is_array($userinfo)) { - $this->logger->error('Login via MyE&P failed due to unreadable userinfo payload'); throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich'); } diff --git a/src/Service/Common/AbstractFilterHandler.php b/src/Service/Common/AbstractFilterHandler.php index 27cb986..7cafaa7 100644 --- a/src/Service/Common/AbstractFilterHandler.php +++ b/src/Service/Common/AbstractFilterHandler.php @@ -61,6 +61,22 @@ abstract class AbstractFilterHandler abstract protected function saveFilterSettings(AbstractFilterDto $filterDto): void; + /** + * Country filters used to be stored as the raw BusProNet abbreviations, which the + * destination table has since moved off. A session written before that switch would + * otherwise keep filtering for a value no destination carries any more. + */ + protected function toIsoCountry(?string $country): ?string + { + return match ($country) { + 'a' => 'at', + 'd' => 'de', + 'f' => 'fr', + 'i' => 'it', + default => $country, + }; + } + protected function getSession(): SessionInterface { return $this->requestStack->getSession(); diff --git a/src/Service/Common/ApplicationFilterHandler.php b/src/Service/Common/ApplicationFilterHandler.php index e9900bb..ec85eca 100644 --- a/src/Service/Common/ApplicationFilterHandler.php +++ b/src/Service/Common/ApplicationFilterHandler.php @@ -49,7 +49,7 @@ class ApplicationFilterHandler extends AbstractFilterHandler $filterDto->setIncludePast((bool) $data['include_past']); } if (isset($data['country'])) { - $filterDto->setCountry($data['country']); + $filterDto->setCountry($this->toIsoCountry($data['country'])); } if (isset($data['teamer_name'])) { $filterDto->setTeamerName($data['teamer_name']); diff --git a/src/Service/Common/AssignmentFilterHandler.php b/src/Service/Common/AssignmentFilterHandler.php index 4c5d9c2..19572f8 100644 --- a/src/Service/Common/AssignmentFilterHandler.php +++ b/src/Service/Common/AssignmentFilterHandler.php @@ -49,7 +49,7 @@ class AssignmentFilterHandler extends AbstractFilterHandler $filterDto->setIncludePast((bool) $data['include_past']); } if (isset($data['country'])) { - $filterDto->setCountry($data['country']); + $filterDto->setCountry($this->toIsoCountry($data['country'])); } if (isset($data['teamer_name'])) { $filterDto->setTeamerName($data['teamer_name']); diff --git a/tests/BusProNet/DataProvider/CountryDataProviderTest.php b/tests/BusProNet/DataProvider/CountryDataProviderTest.php new file mode 100644 index 0000000..24141d9 --- /dev/null +++ b/tests/BusProNet/DataProvider/CountryDataProviderTest.php @@ -0,0 +1,94 @@ +provider([$this->countriesResponse()]); + + $countries = $provider->getAll(); + + // the token, not an ISO code: this is the vocabulary a hotel's country and a + // teamer's nationality join against + $this->assertEquals(['D', 'A', 'CH'], array_keys($countries)); + $this->assertEquals('Deutschland', $countries['D']->getName()); + $this->assertEquals('deutsch', $countries['D']->getNationality()); + } + + public function testGetResolvesByTokenAndToleratesNull(): void + { + $provider = $this->provider([$this->countriesResponse()]); + + $this->assertEquals('Österreich', $provider->get('A')->getName()); + $this->assertNull($provider->get('XX')); + $this->assertNull($provider->get(null)); + } + + public function testACountryWithoutATokenIsSkipped(): void + { + $provider = $this->provider([new MockResponse(json_encode([ + ['id' => 1, 'name' => 'Ohne Token'], + ['id' => 2, 'name' => 'Schweiz', 'token' => 'CH'], + ]))]); + + $this->assertEquals(['CH'], array_keys($provider->getAll())); + } + + public function testAFailingRequestServesTheLastKnownCountries(): void + { + $pool = new ArrayAdapter(); + $this->provider([$this->countriesResponse()], $pool)->getAll(); + + $pool->deleteItem('myep_countries'); + $failing = $this->provider([new MockResponse('', ['http_code' => 503])], $pool); + + $this->assertCount(3, $failing->getAll()); + } + + private function countriesResponse(): MockResponse + { + return new MockResponse(json_encode([ + ['id' => 1, 'name' => 'Deutschland', 'token' => 'D', 'nationality' => 'deutsch'], + ['id' => 2, 'name' => 'Österreich', 'token' => 'A', 'nationality' => 'österreichisch'], + ['id' => 3, 'name' => 'Schweiz', 'token' => 'CH', 'nationality' => 'schweizerisch'], + ])); + } + + /** + * @param list $responses + */ + private function provider(array $responses, ?ArrayAdapter $pool = null): CountryDataProvider + { + $pool ??= new ArrayAdapter(); + $logger = new NullLogger(); + + $httpClient = new MockHttpClient(array_merge( + [new MockResponse(json_encode(['access_token' => 'token', 'expires_in' => 600]))], + $responses, + )); + + $tokens = new AccessTokenProvider($httpClient, new ArrayAdapter(), $logger, [ + 'client_id' => 'id', + 'client_secret' => 'secret', + ]); + + return new CountryDataProvider( + new ApiClient($httpClient, $tokens, $logger), + new ResponseMapper($logger), + new StaleWhileErrorCache($pool, $logger), + ); + } +} diff --git a/tests/BusProNet/DataProvider/HotelDataProviderTest.php b/tests/BusProNet/DataProvider/HotelDataProviderTest.php new file mode 100644 index 0000000..49e200b --- /dev/null +++ b/tests/BusProNet/DataProvider/HotelDataProviderTest.php @@ -0,0 +1,131 @@ +provider([$this->hotelsResponse()]); + + $hotels = $provider->getAll(); + + $this->assertEquals([163959, 11223], array_keys($hotels)); + $this->assertEquals('Sportclub Jenatsch', $hotels[163959]->getName()); + // MyE&P reports the BusProNet idbuspro as the id, so both properties carry it + $this->assertEquals(163959, $hotels[163959]->getId()); + $this->assertEquals(163959, $hotels[163959]->getBusProId()); + $this->assertEquals('4711', $hotels[163959]->getCostUnit()); + $this->assertEquals('CH', $hotels[163959]->getCountry()); + $this->assertEquals('ch', $hotels[163959]->getCountryCode()); + } + + public function testAnUnmappableCountryCodeBecomesNull(): void + { + $provider = $this->provider([$this->hotelsResponse()]); + + // "AUT" is what MyE&P could not map; it must not reach the two character column + $this->assertNull($provider->getAll()[11223]->getCountryCode()); + $this->assertEquals('AUT', $provider->getAll()[11223]->getCountry()); + } + + public function testAFailingRequestServesTheLastKnownHotels(): void + { + $pool = new ArrayAdapter(); + $provider = $this->provider([$this->hotelsResponse()], $pool); + + $this->assertCount(2, $provider->getAll()); + + // the fresh key is gone but the last known good one is not, which is the whole + // point: an outage must not empty the destination form's hotel list + $pool->deleteItem('myep_hotels'); + $failing = $this->provider([new MockResponse('', ['http_code' => 503])], $pool); + + $this->assertCount(2, $failing->getAll()); + } + + public function testAFailingRequestWithoutAnyCachedDataYieldsNothing(): void + { + $provider = $this->provider([new MockResponse('', ['http_code' => 503])]); + + $this->assertEquals([], $provider->getAll()); + } + + public function testFindByCodeMatchesOnEitherEnd(): void + { + $provider = $this->provider([$this->hotelsResponse()]); + + $this->assertCount(1, $provider->findByCode('LPJ')); + $this->assertCount(1, $provider->findByCode('GR')); + $this->assertCount(0, $provider->findByCode('XXX')); + } + + public function testAHotelWithoutACodeIsSkippedByFindByCode(): void + { + $provider = $this->provider([new MockResponse(json_encode([ + ['id' => 1, 'name' => 'Ohne Code'], + ]))]); + + $this->assertEquals([], $provider->findByCode('SSL')); + } + + private function hotelsResponse(): MockResponse + { + return new MockResponse(json_encode([ + [ + 'id' => 163959, + 'code' => 'LPJGR', + 'name' => 'Sportclub Jenatsch', + 'city' => 'Lenzerheide', + 'street' => 'Voa Principala 1', + 'phone' => '+41 81 000 00 00', + 'costUnit' => '4711', + 'country' => 'CH', + 'countryCode' => 'ch', + ], + [ + 'id' => 11223, + 'code' => 'SSLAT', + 'name' => 'Haus Sonnenschein', + 'country' => 'AUT', + 'countryCode' => 'AUT', + ], + ])); + } + + /** + * @param list $responses + */ + private function provider(array $responses, ?ArrayAdapter $pool = null): HotelDataProvider + { + $pool ??= new ArrayAdapter(); + $logger = new NullLogger(); + + $httpClient = new MockHttpClient(array_merge( + [new MockResponse(json_encode(['access_token' => 'token', 'expires_in' => 600]))], + $responses, + )); + + $tokens = new AccessTokenProvider($httpClient, new ArrayAdapter(), $logger, [ + 'client_id' => 'id', + 'client_secret' => 'secret', + ]); + + return new HotelDataProvider( + new ApiClient($httpClient, $tokens, $logger), + new ResponseMapper($logger), + new StaleWhileErrorCache($pool, $logger), + ); + } +} diff --git a/tests/BusProNet/DataProvider/PickupDataProviderTest.php b/tests/BusProNet/DataProvider/PickupDataProviderTest.php new file mode 100644 index 0000000..51b059d --- /dev/null +++ b/tests/BusProNet/DataProvider/PickupDataProviderTest.php @@ -0,0 +1,91 @@ +provider([$this->pickupsResponse()]); + + $pickups = $provider->getAll(); + + $this->assertEquals([4, 27], array_keys($pickups)); + $this->assertEquals('Karlsruhe', $pickups[4]->getCity()); + $this->assertEquals('A5 - Autohof Bruchsal', $pickups[4]->getStreet()); + $this->assertEquals(4, $pickups[4]->getBusProId()); + } + + public function testGetResolvesASingleBoardingPoint(): void + { + $provider = $this->provider([$this->pickupsResponse()]); + + $this->assertEquals('Karlsruhe', $provider->get(4)->getCity()); + $this->assertNull($provider->get(999)); + } + + public function testAPickupWithoutAnIdIsSkipped(): void + { + $provider = $this->provider([new MockResponse(json_encode([ + ['city' => 'Nirgendwo'], + ['id' => 27, 'city' => 'Stuttgart'], + ]))]); + + $this->assertEquals([27], array_keys($provider->getAll())); + } + + public function testAFailingRequestServesTheLastKnownPickups(): void + { + $pool = new ArrayAdapter(); + $this->provider([$this->pickupsResponse()], $pool)->getAll(); + + $pool->deleteItem('myep_pickups'); + $failing = $this->provider([new MockResponse('', ['http_code' => 503])], $pool); + + $this->assertCount(2, $failing->getAll()); + } + + private function pickupsResponse(): MockResponse + { + return new MockResponse(json_encode([ + ['id' => 4, 'code' => '298', 'city' => 'Karlsruhe', 'street' => 'A5 - Autohof Bruchsal'], + ['id' => 27, 'code' => '301', 'city' => 'Stuttgart', 'street' => 'Flughafen'], + ])); + } + + /** + * @param list $responses + */ + private function provider(array $responses, ?ArrayAdapter $pool = null): PickupDataProvider + { + $pool ??= new ArrayAdapter(); + $logger = new NullLogger(); + + $httpClient = new MockHttpClient(array_merge( + [new MockResponse(json_encode(['access_token' => 'token', 'expires_in' => 600]))], + $responses, + )); + + $tokens = new AccessTokenProvider($httpClient, new ArrayAdapter(), $logger, [ + 'client_id' => 'id', + 'client_secret' => 'secret', + ]); + + return new PickupDataProvider( + new ApiClient($httpClient, $tokens, $logger), + new ResponseMapper($logger), + new StaleWhileErrorCache($pool, $logger), + ); + } +} diff --git a/tests/MyEp/Api/AccessTokenProviderTest.php b/tests/MyEp/Api/AccessTokenProviderTest.php new file mode 100644 index 0000000..26edd07 --- /dev/null +++ b/tests/MyEp/Api/AccessTokenProviderTest.php @@ -0,0 +1,142 @@ +tokenResponse('abc123')]); + + $this->assertEquals('abc123', $this->getProviderInstance($httpClient)->getToken()); + } + + public function testTheClientCredentialsGrantIsRequested(): void + { + $body = null; + $httpClient = new MockHttpClient(function (string $method, string $url, array $options) use (&$body) { + $this->assertEquals('POST', $method); + $this->assertEquals('https://myep.example/token', $url); + $body = $options['body']; + + return $this->tokenResponse('abc123'); + }); + + $this->getProviderInstance($httpClient)->getToken(); + + $this->assertStringContainsString('grant_type=client_credentials', $body); + $this->assertStringContainsString('scope=api', $body); + $this->assertStringContainsString('client_secret=secret', $body); + } + + public function testTheTokenIsCachedRatherThanRequestedPerCall(): void + { + $httpClient = new MockHttpClient([$this->tokenResponse('abc123')]); + $cache = new ArrayAdapter(); + $provider = $this->getProviderInstance($httpClient, $cache); + + $provider->getToken(); + $provider->getToken(); + + $this->assertEquals(1, $httpClient->getRequestsCount()); + $this->assertTrue($cache->getItem(self::CACHE_KEY)->isHit()); + } + + public function testACachedTokenIsUsedWithoutAskingForANewOne(): void + { + $cache = new ArrayAdapter(); + $item = $cache->getItem(self::CACHE_KEY); + $item->set('cached-token'); + $cache->save($item); + + $httpClient = new MockHttpClient([]); + + $this->assertEquals('cached-token', $this->getProviderInstance($httpClient, $cache)->getToken()); + $this->assertEquals(0, $httpClient->getRequestsCount()); + } + + public function testAnInvalidatedTokenIsRequestedAgain(): void + { + $httpClient = new MockHttpClient([$this->tokenResponse('abc123'), $this->tokenResponse('def456')]); + $provider = $this->getProviderInstance($httpClient); + + $provider->getToken(); + $provider->invalidate(); + + $this->assertEquals('def456', $provider->getToken()); + $this->assertEquals(2, $httpClient->getRequestsCount()); + } + + public function testARejectedRequestFails(): void + { + $httpClient = new MockHttpClient([ + new MockResponse('{"error":"invalid_client"}', ['http_code' => 401]), + ]); + + $this->expectException(ApiException::class); + + $this->getProviderInstance($httpClient)->getToken(); + } + + public function testAResponseWithoutATokenFails(): void + { + $httpClient = new MockHttpClient([new MockResponse('{"expires_in":600}')]); + + $this->expectException(ApiException::class); + + $this->getProviderInstance($httpClient)->getToken(); + } + + public function testAnUnreachableTokenEndpointFails(): void + { + $httpClient = new MockHttpClient([new MockResponse('', ['error' => 'connection refused'])]); + + $this->expectException(ApiException::class); + + $this->getProviderInstance($httpClient)->getToken(); + } + + public function testAnUnparsableResponseFails(): void + { + $httpClient = new MockHttpClient([new MockResponse('gateway timeout')]); + + $this->expectException(ApiException::class); + + $this->getProviderInstance($httpClient)->getToken(); + } + + private function tokenResponse(string $token, int $expiresIn = 600): MockResponse + { + return new MockResponse(json_encode([ + 'access_token' => $token, + 'token_type' => 'Bearer', + 'expires_in' => $expiresIn, + ])); + } + + private function getProviderInstance(MockHttpClient $httpClient, ?CacheItemPoolInterface $cache = null): AccessTokenProvider + { + return new AccessTokenProvider( + $httpClient, + $cache ?? new ArrayAdapter(), + new NullLogger(), + [ + 'client_id' => 'team-app', + 'client_secret' => 'secret', + 'token_path' => 'https://myep.example/token', + 'scope' => 'api', + ], + ); + } +} diff --git a/tests/MyEp/Api/ApiClientTest.php b/tests/MyEp/Api/ApiClientTest.php new file mode 100644 index 0000000..398857f --- /dev/null +++ b/tests/MyEp/Api/ApiClientTest.php @@ -0,0 +1,166 @@ +mockClient([new MockResponse('[{"id":1}]')]); + + $this->assertEquals([['id' => 1]], $this->getClientInstance($httpClient)->get('api/hotels')); + } + + public function testTheTokenIsSentAsABearer(): void + { + $authorization = null; + $httpClient = $this->mockClient(function (string $method, string $url, array $options) use (&$authorization) { + $this->assertEquals('GET', $method); + $authorization = $this->headerOf($options, 'authorization'); + + return new MockResponse('[]'); + }); + + $this->getClientInstance($httpClient)->get('api/hotels'); + + $this->assertEquals('Authorization: Bearer token-1', $authorization); + } + + public function testQueryParametersArePassedOn(): void + { + $requestedUrl = null; + $httpClient = $this->mockClient(function (string $method, string $url) use (&$requestedUrl) { + $requestedUrl = $url; + + return new MockResponse('[]'); + }); + + $this->getClientInstance($httpClient)->get('api/travels/export', ['page' => 2, 'perPage' => 500]); + + $this->assertStringContainsString('page=2', $requestedUrl); + $this->assertStringContainsString('perPage=500', $requestedUrl); + } + + public function testARejectedTokenIsRenewedOnce(): void + { + $httpClient = $this->mockClient([ + new MockResponse('{"message":"Unauthorized"}', ['http_code' => 401]), + new MockResponse('[{"id":1}]'), + ]); + + $result = $this->getClientInstance($httpClient)->get('api/hotels'); + + $this->assertEquals([['id' => 1]], $result); + $this->assertEquals(2, $httpClient->getRequestsCount()); + } + + public function testARepeatedlyRejectedTokenFailsWithoutLooping(): void + { + $httpClient = $this->mockClient([ + new MockResponse('{"message":"Unauthorized"}', ['http_code' => 401]), + new MockResponse('{"message":"Unauthorized"}', ['http_code' => 401]), + ]); + + try { + $this->getClientInstance($httpClient)->get('api/hotels'); + $this->fail('Expected an ApiException'); + } catch (ApiException) { + } + + $this->assertEquals(2, $httpClient->getRequestsCount()); + } + + public function testAMissingScopeFails(): void + { + $httpClient = $this->mockClient([new MockResponse('{"message":"Forbidden"}', ['http_code' => 403])]); + + $this->expectException(ApiException::class); + $this->expectExceptionMessageMatches('/scope/'); + + $this->getClientInstance($httpClient)->get('api/hotels'); + } + + public function testAServerErrorFails(): void + { + $httpClient = $this->mockClient([new MockResponse('', ['http_code' => 500])]); + + $this->expectException(ApiException::class); + + $this->getClientInstance($httpClient)->get('api/hotels'); + } + + public function testAnErrorReportedWithStatusOkFails(): void + { + // some MyE&P endpoints answer 200 with an error body, so the payload has to decide + $httpClient = $this->mockClient([ + new MockResponse('{"error":"upstream unavailable","type":"api_error"}'), + ]); + + $this->expectException(ApiException::class); + + $this->getClientInstance($httpClient)->get('api/products'); + } + + public function testAnUnreadableBodyFails(): void + { + $httpClient = $this->mockClient([new MockResponse('not json')]); + + $this->expectException(ApiException::class); + + $this->getClientInstance($httpClient)->get('api/hotels'); + } + + public function testAnUnreachableApiFails(): void + { + $httpClient = $this->mockClient([new MockResponse('', ['error' => 'connection refused'])]); + + $this->expectException(ApiException::class); + + $this->getClientInstance($httpClient)->get('api/hotels'); + } + + private function mockClient(array|callable $responses): MockHttpClient + { + return new MockHttpClient($responses, 'https://myep.example/'); + } + + private function headerOf(array $options, string $name): ?string + { + foreach ($options['headers'] ?? [] as $header) { + if (str_starts_with(strtolower($header), $name.':')) { + return $header; + } + } + + return null; + } + + private function getClientInstance(MockHttpClient $httpClient): ApiClient + { + $tokenClient = new MockHttpClient(function () { + static $count = 0; + + return new MockResponse(json_encode([ + 'access_token' => 'token-'.++$count, + 'expires_in' => 600, + ])); + }); + + $tokens = new AccessTokenProvider($tokenClient, new ArrayAdapter(), new NullLogger(), [ + 'client_id' => 'team-app', + 'client_secret' => 'secret', + 'token_path' => 'https://myep.example/token', + ]); + + return new ApiClient($httpClient, $tokens, new NullLogger()); + } +} diff --git a/tests/MyEp/Api/ResponseMapperTest.php b/tests/MyEp/Api/ResponseMapperTest.php new file mode 100644 index 0000000..bae5270 --- /dev/null +++ b/tests/MyEp/Api/ResponseMapperTest.php @@ -0,0 +1,167 @@ +getMapperInstance()->mapHotels([ + ['id' => 163959, 'code' => 'LPJGR', 'name' => 'Sportclub Jenatsch'], + ['id' => 202003, 'code' => 'SRR', 'name' => 'Berggasthof Ranalt'], + ]); + + $this->assertEquals([163959, 202003], array_keys($hotels)); + $this->assertInstanceOf(Hotel::class, $hotels[163959]); + $this->assertEquals(163959, $hotels[163959]->getBusProId()); + $this->assertEquals('LPJGR', $hotels[163959]->getCode()); + $this->assertEquals('Sportclub Jenatsch', $hotels[163959]->getName()); + } + + public function testHotelFieldsAreMapped(): void + { + $hotels = $this->getMapperInstance()->mapHotels([[ + 'id' => 163959, + 'code' => 'LPJGR', + 'name' => 'Sportclub Jenatsch', + 'city' => 'Lenzerheide', + 'street' => 'Voa Principala 1', + 'phone' => '+41 81 000000', + 'country' => 'CH', + 'countryCode' => 'ch', + 'costUnit' => '4711', + ]]); + + $hotel = $hotels[163959]; + + $this->assertEquals('Lenzerheide', $hotel->getCity()); + $this->assertEquals('Voa Principala 1', $hotel->getStreet()); + $this->assertEquals('+41 81 000000', $hotel->getPhone()); + $this->assertEquals('4711', $hotel->getCostUnit()); + } + + public function testTheRawCountryTokenIsKeptAsReported(): void + { + // the raw token is not a standard, it is the join key into the country base data + $hotels = $this->getMapperInstance()->mapHotels([['id' => 1, 'country' => 'AUT']]); + + $this->assertEquals('AUT', $hotels[1]->getCountry()); + } + + public function testTheCountryCodeIsLowercasedIso(): void + { + // this is what reaches the two character column and the flag icons + $hotels = $this->getMapperInstance()->mapHotels([['id' => 1, 'country' => 'AUT', 'countryCode' => 'AT']]); + + $this->assertEquals('at', $hotels[1]->getCountryCode()); + } + + public function testAnUnresolvedCountryCodeIsNull(): void + { + $hotels = $this->getMapperInstance()->mapHotels([ + ['id' => 1, 'country' => 'XYZ'], + ['id' => 2, 'country' => 'SLO', 'countryCode' => null], + ['id' => 3, 'country' => 'D', 'countryCode' => 'deu'], + ]); + + $this->assertNull($hotels[1]->getCountryCode()); + $this->assertNull($hotels[2]->getCountryCode()); + $this->assertNull($hotels[3]->getCountryCode(), 'a three letter code does not fit the column'); + } + + public function testOptionalHotelFieldsMayBeAbsent(): void + { + $hotels = $this->getMapperInstance()->mapHotels([['id' => 1, 'code' => 'AAA']]); + + $this->assertNull($hotels[1]->getCountryCode()); + $this->assertNull($hotels[1]->getCostUnit()); + $this->assertNull($hotels[1]->getType()); + $this->assertNull($hotels[1]->getPhone()); + } + + public function testHotelsWithoutAnIdAreSkipped(): void + { + $hotels = $this->getMapperInstance()->mapHotels([ + ['code' => 'AAA'], + ['id' => 0, 'code' => 'BBB'], + ['id' => 7, 'code' => 'CCC'], + ]); + + $this->assertEquals([7], array_keys($hotels)); + } + + public function testPickupsAreKeyedByTheirBusProId(): void + { + $pickups = $this->getMapperInstance()->mapPickups([ + ['id' => 4, 'code' => '298', 'city' => 'Karlsruhe', 'street' => 'A5 - Autohof Bruchsal'], + ]); + + $this->assertEquals([4], array_keys($pickups)); + $this->assertInstanceOf(Pickup::class, $pickups[4]); + $this->assertEquals(4, $pickups[4]->getBusProId()); + $this->assertEquals(4, $pickups[4]->getId()); + $this->assertEquals('298', $pickups[4]->getCode()); + $this->assertEquals('Karlsruhe', $pickups[4]->getCity()); + $this->assertEquals('A5 - Autohof Bruchsal', $pickups[4]->getStreet()); + } + + public function testAPickupCarriesNoTimeOfItsOwn(): void + { + // a departure time belongs to a boarding stop on a date, not to the stop itself + $pickups = $this->getMapperInstance()->mapPickups([['id' => 4, 'time' => '23:59']]); + + $this->assertNull($pickups[4]->getTime()); + } + + public function testPickupsWithoutAnIdAreSkipped(): void + { + $pickups = $this->getMapperInstance()->mapPickups([['city' => 'Karlsruhe'], ['id' => 4]]); + + $this->assertEquals([4], array_keys($pickups)); + } + + public function testCountriesAreKeyedByTheirToken(): void + { + $countries = $this->getMapperInstance()->mapCountries([ + ['id' => 1, 'name' => 'Schweiz', 'token' => 'CH', 'nationality' => 'schweizerisch'], + ]); + + $this->assertEquals(['CH'], array_keys($countries)); + $this->assertInstanceOf(Country::class, $countries['CH']); + $this->assertEquals(1, $countries['CH']->getId()); + $this->assertEquals('Schweiz', $countries['CH']->getName()); + $this->assertEquals('CH', $countries['CH']->getToken()); + $this->assertEquals('schweizerisch', $countries['CH']->getNationality()); + } + + public function testCountriesWithoutATokenAreSkipped(): void + { + $countries = $this->getMapperInstance()->mapCountries([ + ['id' => 1, 'name' => 'Schweiz'], + ['id' => 2, 'name' => 'Österreich', 'token' => 'A'], + ]); + + $this->assertEquals(['A'], array_keys($countries)); + } + + public function testAnEmptyListMapsToAnEmptyResult(): void + { + $mapper = $this->getMapperInstance(); + + $this->assertEquals([], $mapper->mapHotels([])); + $this->assertEquals([], $mapper->mapPickups([])); + $this->assertEquals([], $mapper->mapCountries([])); + } + + private function getMapperInstance(): ResponseMapper + { + return new ResponseMapper(new NullLogger()); + } +} diff --git a/tests/MyEp/Cache/StaleWhileErrorCacheTest.php b/tests/MyEp/Cache/StaleWhileErrorCacheTest.php new file mode 100644 index 0000000..baf7d18 --- /dev/null +++ b/tests/MyEp/Cache/StaleWhileErrorCacheTest.php @@ -0,0 +1,97 @@ +get('myep.hotels.v1', 3600, fn () => ['a' => 1]); + + $this->assertEquals(['a' => 1], $value); + $this->assertTrue($pool->getItem('myep.hotels.v1')->isHit()); + $this->assertTrue($pool->getItem('myep.hotels.v1.last_good')->isHit()); + } + + public function testACachedValueIsServedWithoutProducingAgain(): void + { + $pool = new ArrayAdapter(); + $cache = new StaleWhileErrorCache($pool, new NullLogger()); + $calls = 0; + + $producer = function () use (&$calls) { + ++$calls; + + return ['a' => 1]; + }; + + $cache->get('myep.hotels.v1', 3600, $producer); + $value = $cache->get('myep.hotels.v1', 3600, $producer); + + $this->assertEquals(['a' => 1], $value); + $this->assertEquals(1, $calls); + } + + public function testTheLastKnownValueIsServedWhenProducingFails(): void + { + $pool = new ArrayAdapter(); + $cache = new StaleWhileErrorCache($pool, new NullLogger()); + + $cache->get('myep.hotels.v1', 3600, fn () => ['a' => 1]); + $pool->deleteItem('myep.hotels.v1'); + + $value = $cache->get('myep.hotels.v1', 3600, fn () => throw new \RuntimeException('upstream is down')); + + $this->assertEquals(['a' => 1], $value); + } + + public function testAServedStaleValueSuppressesFurtherAttempts(): void + { + $pool = new ArrayAdapter(); + $cache = new StaleWhileErrorCache($pool, new NullLogger()); + $calls = 0; + + $failing = function () use (&$calls) { + ++$calls; + + throw new \RuntimeException('upstream is down'); + }; + + $cache->get('myep.hotels.v1', 3600, fn () => ['a' => 1]); + $pool->deleteItem('myep.hotels.v1'); + + $cache->get('myep.hotels.v1', 3600, $failing); + $cache->get('myep.hotels.v1', 3600, $failing); + + $this->assertEquals(1, $calls); + } + + public function testAFailureWithoutAnyCachedValueIsEmpty(): void + { + $cache = new StaleWhileErrorCache(new ArrayAdapter(), new NullLogger()); + + $value = $cache->get('myep.hotels.v1', 3600, fn () => throw new \RuntimeException('upstream is down')); + + $this->assertEquals([], $value); + } + + public function testEachKeyKeepsItsOwnValue(): void + { + $pool = new ArrayAdapter(); + $cache = new StaleWhileErrorCache($pool, new NullLogger()); + + $cache->get('myep.hotels.v1', 3600, fn () => ['hotel']); + $cache->get('myep.pickups.v1', 3600, fn () => ['pickup']); + + $this->assertEquals(['hotel'], $cache->get('myep.hotels.v1', 3600, fn () => ['other'])); + $this->assertEquals(['pickup'], $cache->get('myep.pickups.v1', 3600, fn () => ['other'])); + } +} diff --git a/tests/MyEp/Import/DestinationRowTest.php b/tests/MyEp/Import/DestinationRowTest.php new file mode 100644 index 0000000..016ad30 --- /dev/null +++ b/tests/MyEp/Import/DestinationRowTest.php @@ -0,0 +1,180 @@ +getRowData()); + + $this->assertEquals(11886, $row->busProId); + $this->assertEquals('LPJPGJ040426', $row->code); + $this->assertEquals('Lenzerheide - Sportclub Jenatsch', $row->product); + $this->assertEquals('2026-04-04', $row->dateFrom->format('Y-m-d')); + $this->assertEquals('2026-04-11', $row->dateTo->format('Y-m-d')); + $this->assertEquals(163959, $row->hotelBusProId); + $this->assertEquals('LPJGR', $row->hotelCode); + $this->assertEquals('Sportclub Jenatsch', $row->hotelName); + $this->assertEquals('4711', $row->costUnit); + $this->assertEquals('ch', $row->country); + } + + public function testTheIsoCountryCodeIsStoredRatherThanTheRawToken(): void + { + $row = DestinationRow::fromArray($this->getRowData([ + 'hotel' => ['busProId' => 1, 'country' => 'AUT', 'countryCode' => 'AT'], + ])); + + $this->assertEquals('at', $row->country); + } + + public function testAnUnresolvedCountryCodeIsStoredAsNull(): void + { + $row = DestinationRow::fromArray($this->getRowData([ + 'hotel' => ['busProId' => 1, 'country' => 'SLO'], + ])); + + $this->assertNull($row->country); + } + + public function testTheDatesAreReadAsWholeDays(): void + { + $row = DestinationRow::fromArray($this->getRowData()); + + $this->assertEquals('00:00:00', $row->dateFrom->format('H:i:s')); + $this->assertEquals('00:00:00', $row->dateTo->format('H:i:s')); + } + + public function testPickupsKeepTheShapeTheDestinationTableStores(): void + { + $row = DestinationRow::fromArray($this->getRowData()); + + $this->assertEquals([[ + 'busProId' => 4, + 'city' => 'Karlsruhe', + 'street' => 'A5 - Autohof Bruchsal', + 'time' => '23:59', + ]], $row->pickups); + } + + public function testStoredPickupsCanBeReadBackAsModels(): void + { + $row = DestinationRow::fromArray($this->getRowData()); + + $pickup = Pickup::fromArray(json_decode(json_encode($row->pickups), true)[0]); + + $this->assertEquals(4, $pickup->getId()); + $this->assertEquals('Karlsruhe', $pickup->getCity()); + $this->assertEquals('23:59', $pickup->getTime()); + } + + public function testPickupsWithoutAnIdAreDropped(): void + { + $row = DestinationRow::fromArray($this->getRowData(['pickups' => [ + ['city' => 'Karlsruhe'], + ['busProId' => 0, 'city' => 'Köln'], + ['busProId' => 27, 'city' => 'Bonn', 'street' => 'ZOB', 'time' => '02:15'], + ]])); + + $this->assertCount(1, $row->pickups); + $this->assertEquals(27, $row->pickups[0]['busProId']); + } + + public function testARowWithoutPickupsIsAccepted(): void + { + $row = DestinationRow::fromArray($this->getRowData(['pickups' => []])); + + $this->assertEquals([], $row->pickups); + } + + public function testARowWithoutAHotelIsRejected(): void + { + $this->expectException(InvalidRowException::class); + + DestinationRow::fromArray($this->getRowData(['hotel' => null])); + } + + public function testARowWithoutIdsIsRejected(): void + { + $this->expectException(InvalidRowException::class); + + DestinationRow::fromArray($this->getRowData(['busProId' => 0])); + } + + public function testARowWithAnUnreadableDateIsRejected(): void + { + $this->expectException(InvalidRowException::class); + + DestinationRow::fromArray($this->getRowData(['dateFrom' => '04.04.2026'])); + } + + public function testTheInsertPayloadCarriesEveryColumn(): void + { + $row = DestinationRow::fromArray($this->getRowData()); + + $payload = $row->toInsertPayload(new \DateTimeImmutable('2026-08-13 03:00:00')); + + $this->assertEquals([ + 'code' => 'LPJPGJ040426', + 'bus_pro_id' => 11886, + 'date_from' => '2026-04-04', + 'date_to' => '2026-04-11', + 'product' => 'Lenzerheide - Sportclub Jenatsch', + 'hotel' => 'Sportclub Jenatsch', + 'hotel_code' => 'LPJGR', + 'hotel_bus_pro_id' => 163959, + 'pickups' => '[{"busProId":4,"city":"Karlsruhe","street":"A5 - Autohof Bruchsal","time":"23:59"}]', + 'cost_unit' => '4711', + 'country' => 'ch', + 'created_at' => '2026-08-13 03:00:00', + ], $payload); + } + + public function testTheUpdatePayloadRefreshesTheHotelName(): void + { + // a renamed house has to reach existing rows, or it drops out of the house matching + $row = DestinationRow::fromArray($this->getRowData()); + + $payload = $row->toUpdatePayload(new \DateTimeImmutable('2026-08-13 03:00:00')); + + $this->assertEquals('Sportclub Jenatsch', $payload['hotel']); + $this->assertEquals('LPJGR', $payload['hotel_code']); + $this->assertEquals('2026-08-13 03:00:00', $payload['updated_at']); + $this->assertArrayNotHasKey('created_at', $payload); + $this->assertArrayNotHasKey('bus_pro_id', $payload); + $this->assertArrayNotHasKey('hotel_bus_pro_id', $payload); + } + + private function getRowData(array $overrides = []): array + { + return array_merge([ + 'busProId' => 11886, + 'code' => 'LPJPGJ040426', + 'product' => 'Lenzerheide - Sportclub Jenatsch', + 'dateFrom' => '2026-04-04', + 'dateTo' => '2026-04-11', + 'hotel' => [ + 'busProId' => 163959, + 'code' => 'LPJGR', + 'name' => 'Sportclub Jenatsch', + 'costUnit' => '4711', + 'country' => 'CH', + 'countryCode' => 'ch', + ], + 'pickups' => [[ + 'busProId' => 4, + 'code' => '298', + 'city' => 'Karlsruhe', + 'street' => 'A5 - Autohof Bruchsal', + 'time' => '23:59', + 'datetime' => '2026-04-04T23:59:00+02:00', + ]], + ], $overrides); + } +} diff --git a/tests/MyEp/Import/TravelExportClientTest.php b/tests/MyEp/Import/TravelExportClientTest.php new file mode 100644 index 0000000..8e86cad --- /dev/null +++ b/tests/MyEp/Import/TravelExportClientTest.php @@ -0,0 +1,138 @@ +mockClient([ + $this->page([$this->row(1, 100), $this->row(2, 200)], 1, 2), + $this->page([$this->row(3, 300)], 2, 2), + ]); + + $rows = $this->getClientInstance($httpClient)->fetchAll(); + + $this->assertCount(3, $rows); + $this->assertEquals([1, 2, 3], array_map(fn ($row) => $row->busProId, $rows)); + $this->assertEquals(2, $httpClient->getRequestsCount()); + } + + public function testASinglePageIsNotFollowedByAnother(): void + { + $httpClient = $this->mockClient([$this->page([$this->row(1, 100)], 1, 1)]); + + $this->getClientInstance($httpClient)->fetchAll(); + + $this->assertEquals(1, $httpClient->getRequestsCount()); + } + + public function testTheRequestedPageIsPassedOn(): void + { + $urls = []; + $httpClient = new MockHttpClient(function (string $method, string $url) use (&$urls) { + $urls[] = $url; + + return $this->page([$this->row(count($urls), 100)], count($urls), 2); + }, 'https://myep.example/'); + + $this->getClientInstance($httpClient)->fetchAll(); + + $this->assertStringContainsString('page=1', $urls[0]); + $this->assertStringContainsString('page=2', $urls[1]); + $this->assertStringContainsString('perPage=500', $urls[0]); + } + + public function testAnUnusableRowIsSkippedRatherThanFatal(): void + { + $httpClient = $this->mockClient([ + $this->page([ + ['busProId' => 1], + $this->row(2, 200), + ], 1, 1), + ]); + + $rows = $this->getClientInstance($httpClient)->fetchAll(); + + $this->assertCount(1, $rows); + $this->assertEquals(2, $rows[0]->busProId); + } + + public function testAnEmptyExportYieldsNoRows(): void + { + $httpClient = $this->mockClient([$this->page([], 1, 1)]); + + $this->assertEquals([], $this->getClientInstance($httpClient)->fetchAll()); + } + + public function testAPayloadWithoutADataListFails(): void + { + $httpClient = $this->mockClient([new MockResponse('{"meta":{"totalPages":1}}')]); + + $this->expectException(ApiException::class); + + $this->getClientInstance($httpClient)->fetchAll(); + } + + private function row(int $busProId, int $hotelBusProId): array + { + return [ + 'busProId' => $busProId, + 'code' => 'CODE'.$busProId, + 'product' => 'Produkt '.$busProId, + 'dateFrom' => '2026-04-04', + 'dateTo' => '2026-04-11', + 'hotel' => [ + 'busProId' => $hotelBusProId, + 'code' => 'AAA', + 'name' => 'Haus', + 'costUnit' => null, + 'country' => 'ch', + ], + 'pickups' => [], + ]; + } + + private function page(array $rows, int $page, int $totalPages): MockResponse + { + return new MockResponse(json_encode([ + 'data' => $rows, + 'meta' => [ + 'page' => $page, + 'perPage' => 500, + 'total' => count($rows), + 'totalPages' => $totalPages, + ], + ])); + } + + private function mockClient(array $responses): MockHttpClient + { + return new MockHttpClient($responses, 'https://myep.example/'); + } + + private function getClientInstance(MockHttpClient $httpClient): TravelExportClient + { + $tokenClient = new MockHttpClient( + fn () => new MockResponse('{"access_token":"token","expires_in":600}'), + 'https://myep.example/', + ); + + $tokens = new AccessTokenProvider($tokenClient, new ArrayAdapter(), new NullLogger(), [ + 'client_id' => 'team-app', + 'client_secret' => 'secret', + ]); + + return new TravelExportClient(new ApiClient($httpClient, $tokens, new NullLogger()), new NullLogger()); + } +} diff --git a/tests/Security/MyEpAuthenticatorTest.php b/tests/Security/MyEpAuthenticatorTest.php index e9bd774..b164b9c 100644 --- a/tests/Security/MyEpAuthenticatorTest.php +++ b/tests/Security/MyEpAuthenticatorTest.php @@ -12,14 +12,12 @@ use App\Security\OAuth2\MyEpClient; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Flagception\Manager\FeatureManagerInterface; -use GuzzleHttp\Psr7\Request as Psr7Request; -use GuzzleHttp\Psr7\Response as Psr7Response; -use League\OAuth2\Client\Provider\AbstractProvider; use League\OAuth2\Client\Token\AccessToken; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Psr\Http\Client\ClientInterface; use Psr\Log\LoggerInterface; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; @@ -346,6 +344,38 @@ class MyEpAuthenticatorTest extends TestCase $this->assertTrue($authenticator->supports($request)); } + public function testARejectedUserinfoRequestRefusesTheLogin(): void + { + $httpClient = new MockHttpClient( + new MockResponse('{"message":"Unauthorized"}', ['http_code' => 401]), + 'https://my.example.com/', + ); + $authenticator = $this->createAuthenticator($this->createUserinfo(['ROLE_TEAMER']), true, $httpClient); + + $request = new Request(query: ['code' => 'code', 'state' => 'state']); + $request->setSession(new Session(new MockArraySessionStorage())); + + $this->expectException(CustomUserMessageAuthenticationException::class); + + $authenticator->authenticate($request); + } + + public function testAnUnreadableUserinfoPayloadRefusesTheLogin(): void + { + $httpClient = new MockHttpClient( + new MockResponse('gateway timeout'), + 'https://my.example.com/', + ); + $authenticator = $this->createAuthenticator($this->createUserinfo(['ROLE_TEAMER']), true, $httpClient); + + $request = new Request(query: ['code' => 'code', 'state' => 'state']); + $request->setSession(new Session(new MockArraySessionStorage())); + + $this->expectException(CustomUserMessageAuthenticationException::class); + + $authenticator->authenticate($request); + } + /** * @param string[] $roles */ @@ -385,31 +415,25 @@ class MyEpAuthenticatorTest extends TestCase return $user; } - private function createAuthenticator(array $userinfo, bool $featureActive = true): MyEpAuthenticator + private function userinfoClient(array $userinfo): MockHttpClient { - $httpClient = $this->createMock(ClientInterface::class); - $httpClient - ->method('sendRequest') - ->willReturn(new Psr7Response(200, [], json_encode($userinfo))) - ; - - $provider = $this->createMock(AbstractProvider::class); - $provider->method('getResourceOwnerDetailsUrl')->willReturn('https://my.example.com/api/userinfo'); - $provider - ->method('getAuthenticatedRequest') - ->willReturn(new Psr7Request('GET', 'https://my.example.com/api/userinfo')) - ; - $provider->method('getHttpClient')->willReturn($httpClient); + return new MockHttpClient( + new MockResponse(json_encode($userinfo)), + 'https://my.example.com/', + ); + } + private function createAuthenticator(array $userinfo, bool $featureActive = true, ?MockHttpClient $httpClient = null): MyEpAuthenticator + { $client = $this->createMock(MyEpClient::class); $client->method('fetchAccessToken')->willReturn(new AccessToken(['access_token' => 'token'])); - $client->method('getProvider')->willReturn($provider); $featureManager = $this->createMock(FeatureManagerInterface::class); $featureManager->method('isActive')->willReturn($featureActive); return new MyEpAuthenticator( $client, + $httpClient ?? $this->userinfoClient($userinfo), new UserDataHandler($this->entityManager, $this->createMock(LoggerInterface::class)), $this->entityManager, $this->createMock(UrlGeneratorInterface::class),