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),