feat: read BusProNet base data from the MyE&P API
This commit is contained in:
1 parent
1505f9b50b
commit
2e9328fc51
37 files changed
+2302
-148
No files matched your search
@@ -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
|
||||
|
||||
@@ -198,11 +198,11 @@
|
||||
<path d="M24 0C10.7 0 0 10.7 0 24S10.7 48 24 48h8V67c0 40.3 16 79 44.5 107.5L158.1 256 76.5 337.5C48 366 32 404.7 32 445v19H24c-13.3 0-24 10.7-24 24s10.7 24 24 24H360c13.3 0 24-10.7 24-24s-10.7-24-24-24h-8V445c0-40.3-16-79-44.5-107.5L225.9 256l81.5-81.5C336 146 352 107.3 352 67V48h8c13.3 0 24-10.7 24-24s-10.7-24-24-24H24zM192 289.9l81.5 81.5C293 391 304 417.4 304 445v19H80V445c0-27.6 11-54 30.5-73.5L192 289.9zm0-67.9l-81.5-81.5C91 121 80 94.6 80 67V48H304V67c0 27.6-11 54-30.5 73.5L192 222.1z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-car" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="64" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="192" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="88" y1="176" x2="168" y2="176" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M216,176h24a8,8,0,0,0,8-8V128a8,8,0,0,0-8-8H208L162.34,74.34A8,8,0,0,0,156.69,72H44.28a8,8,0,0,0-6.65,3.56L8,120v48a8,8,0,0,0,8,8H40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="208" y1="120" x2="8" y2="120" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-flag-a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#fff" d="M640 480H0V0h640z"/><path fill="#ed2939" d="M640 480H0V320h640zm0-319.9H0V.1h640z"/></g></symbol>
|
||||
<symbol id="icon-flag-at" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#fff" d="M640 480H0V0h640z"/><path fill="#ed2939" d="M640 480H0V320h640zm0-319.9H0V.1h640z"/></g></symbol>
|
||||
<symbol id="icon-flag-ch" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#d52b1e" d="M0 0h640v480H0z"/><g fill="#fff"><path d="M170 195h300v90H170z"/><path d="M275 90h90v300h-90z"/></g></g></symbol>
|
||||
<symbol id="icon-flag-d" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><path fill="#ffce00" d="M0 320h640v160H0z"/><path d="M0 0h640v160H0z"/><path fill="#d00" d="M0 160h640v160H0z"/></symbol>
|
||||
<symbol id="icon-flag-f" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#00267f" d="M0 0h213.3v480H0z"/><path fill="#f31830" d="M426.7 0H640v480H426.7z"/></g></symbol>
|
||||
<symbol id="icon-flag-i" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#009246" d="M0 0h213.3v480H0z"/><path fill="#ce2b37" d="M426.7 0H640v480H426.7z"/></g></symbol>
|
||||
<symbol id="icon-flag-de" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><path fill="#ffce00" d="M0 320h640v160H0z"/><path d="M0 0h640v160H0z"/><path fill="#d00" d="M0 160h640v160H0z"/></symbol>
|
||||
<symbol id="icon-flag-fr" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#00267f" d="M0 0h213.3v480H0z"/><path fill="#f31830" d="M426.7 0H640v480H426.7z"/></g></symbol>
|
||||
<symbol id="icon-flag-it" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#009246" d="M0 0h213.3v480H0z"/><path fill="#ce2b37" d="M426.7 0H640v480H426.7z"/></g></symbol>
|
||||
<symbol id="icon-flag-nl" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#21468b" d="M0 320h640v160H0z"/><path fill="#fff" d="M0 160h640v160H0z"/><path fill="#ae1c28" d="M0 0h640v160H0z"/></g></symbol>
|
||||
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
|
||||
<symbol id="icon-spinner" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">
|
||||
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Moves destination.country from the raw BusProNet abbreviations to lowercase ISO-2.
|
||||
*
|
||||
* The column used to hold whatever BusProNet reported as hotel/land, lowercased: a,
|
||||
* ch, d, f, i, nl. That set is not a standard - the same country appears as A, AT and
|
||||
* AUT upstream - so the import now stores the ISO code MyE&P resolves instead, and the
|
||||
* existing rows are converted to match. The flag icons and the country filters use the
|
||||
* same vocabulary and are switched with it.
|
||||
*/
|
||||
final class Version20260813120000 extends AbstractMigration
|
||||
{
|
||||
private const TO_ISO = [
|
||||
'a' => '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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, Country> 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
|
||||
|
||||
@@ -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<int, Hotel> 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<Hotel>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int, Pickup> 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
])
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\MyEp\Api;
|
||||
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface as TransportException;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* Obtains and caches the client_credentials access token for the MyE&P API.
|
||||
*
|
||||
* MyE&P issues short-lived tokens (ten minutes), so a token is kept in the shared
|
||||
* cache until shortly before it expires rather than requested per call.
|
||||
*/
|
||||
class AccessTokenProvider
|
||||
{
|
||||
private const CACHE_KEY = 'myep.api.access_token';
|
||||
|
||||
/**
|
||||
* Assumed token lifetime when the response omits expires_in.
|
||||
*/
|
||||
private const DEFAULT_LIFETIME = 600;
|
||||
|
||||
/**
|
||||
* Dropped from every lifetime so a token is never handed out in the seconds
|
||||
* before it expires.
|
||||
*/
|
||||
private const EXPIRY_MARGIN = 60;
|
||||
|
||||
private const MINIMUM_LIFETIME = 30;
|
||||
|
||||
private array $config;
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $myepClient,
|
||||
private readonly CacheItemPoolInterface $cache,
|
||||
private readonly LoggerInterface $logger,
|
||||
array $options,
|
||||
) {
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\MyEp\Api;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface as TransportException;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Reads the MyE&P JSON API. Every response is validated here so that callers only
|
||||
* ever see a decoded payload or an ApiException.
|
||||
*/
|
||||
class ApiClient
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $myepClient,
|
||||
private readonly AccessTokenProvider $tokens,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiException
|
||||
*/
|
||||
public function get(string $path, array $query = []): array
|
||||
{
|
||||
$response = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\MyEp\Api;
|
||||
|
||||
class ApiException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\MyEp\Api;
|
||||
|
||||
/**
|
||||
* Normalizes the country code MyE&P resolves for a hotel.
|
||||
*
|
||||
* MyE&P maps the raw BusProNet abbreviations (D, A, AUT, DEU, …) to ISO-3166-1
|
||||
* alpha-2 and reports the result as countryCode. Anything that is not a two letter
|
||||
* code is dropped rather than stored: the value goes into a two character column and
|
||||
* addresses a flag icon, so a half-mapped token is worse than no country at all.
|
||||
*/
|
||||
final class CountryCode
|
||||
{
|
||||
public static function normalize(mixed $value): ?string
|
||||
{
|
||||
if (false === is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$code = strtolower(trim($value));
|
||||
|
||||
return 1 === preg_match('/^[a-z]{2}$/', $code) ? $code : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\MyEp\Api;
|
||||
|
||||
use App\BusProNet\Model\Country;
|
||||
use App\BusProNet\Model\Hotel;
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Turns MyE&P list responses into the BusProNet models the application already works
|
||||
* with, keyed the way the data providers expose them.
|
||||
*
|
||||
* The id MyE&P reports for hotels and pickups is the BusProNet idbuspro, so it fills
|
||||
* both the id and the busProId of the models.
|
||||
*/
|
||||
class ResponseMapper
|
||||
{
|
||||
public function __construct(private readonly LoggerInterface $logger)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Hotel> 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<int, Pickup> 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<string, Country> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\MyEp\Cache;
|
||||
|
||||
use Psr\Cache\CacheItemInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Caches a value and keeps the last successful one around far longer, so a MyE&P
|
||||
* outage degrades to slightly stale master data instead of empty dropdowns.
|
||||
*
|
||||
* Symfony's cache contract cannot express this: it discards the item when the
|
||||
* callback throws, which is exactly the case that needs the previous value.
|
||||
*/
|
||||
class StaleWhileErrorCache
|
||||
{
|
||||
private const LAST_GOOD_SUFFIX = '.last_good';
|
||||
|
||||
/**
|
||||
* How long a served stale value suppresses further upstream attempts. Without it
|
||||
* every call during an outage would try again, since the fresh key stays empty.
|
||||
*/
|
||||
private const RETRY_DELAY = 60;
|
||||
|
||||
public function __construct(
|
||||
private readonly CacheItemPoolInterface $cache,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly int $staleLifetime = 604800,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(): array $producer
|
||||
*/
|
||||
public function get(string $key, int $ttl, callable $producer): array
|
||||
{
|
||||
try {
|
||||
$fresh = $this->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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\MyEp\Import;
|
||||
|
||||
use App\MyEp\Api\CountryCode;
|
||||
|
||||
/**
|
||||
* One travel date at one hotel, as the MyE&P travel export reports it. This is the
|
||||
* unit the destination table is keyed on.
|
||||
*/
|
||||
class DestinationRow
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $busProId,
|
||||
public readonly string $code,
|
||||
public readonly string $product,
|
||||
public readonly \DateTimeImmutable $dateFrom,
|
||||
public readonly \DateTimeImmutable $dateTo,
|
||||
public readonly int $hotelBusProId,
|
||||
public readonly string $hotelCode,
|
||||
public readonly string $hotelName,
|
||||
public readonly ?string $costUnit,
|
||||
/** lowercase ISO-3166-1 alpha-2, null when MyE&P could not resolve one */
|
||||
public readonly ?string $country,
|
||||
public readonly array $pickups,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidRowException
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$hotel = $data['hotel'] ?? null;
|
||||
|
||||
if (false === is_array($hotel)) {
|
||||
throw new InvalidRowException('Travel export row without a hotel');
|
||||
}
|
||||
|
||||
$busProId = (int) ($data['busProId'] ?? 0);
|
||||
$hotelBusProId = (int) ($hotel['busProId'] ?? 0);
|
||||
|
||||
if (0 === $busProId || 0 === $hotelBusProId) {
|
||||
throw new InvalidRowException(sprintf('Travel export row with an unusable id pair (%d, %d)', $busProId, $hotelBusProId));
|
||||
}
|
||||
|
||||
return new self(
|
||||
$busProId,
|
||||
(string) ($data['code'] ?? ''),
|
||||
(string) ($data['product'] ?? ''),
|
||||
self::readDate($data, 'dateFrom'),
|
||||
self::readDate($data, 'dateTo'),
|
||||
$hotelBusProId,
|
||||
(string) ($hotel['code'] ?? ''),
|
||||
(string) ($hotel['name'] ?? ''),
|
||||
self::readNullableString($hotel, 'costUnit'),
|
||||
CountryCode::normalize($hotel['countryCode'] ?? null),
|
||||
self::readPickups($data['pickups'] ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The columns an existing destination is refreshed with. The hotel name and code
|
||||
* are part of it because they do change upstream, and rows that keep an outdated
|
||||
* name drop out of the house matching.
|
||||
*/
|
||||
public function toUpdatePayload(\DateTimeImmutable $now): array
|
||||
{
|
||||
return [
|
||||
'code' => $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\MyEp\Import;
|
||||
|
||||
/**
|
||||
* A single unusable row of the travel export. Thrown per row so the import can skip
|
||||
* it and carry on instead of losing the whole run.
|
||||
*/
|
||||
class InvalidRowException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\MyEp\Import;
|
||||
|
||||
use App\MyEp\Api\ApiClient;
|
||||
use App\MyEp\Api\ApiException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Reads the full travel export from MyE&P, one page at a time.
|
||||
*/
|
||||
class TravelExportClient
|
||||
{
|
||||
private const PATH = 'api/travels/export';
|
||||
private const PER_PAGE = 500;
|
||||
|
||||
/**
|
||||
* Stops a malformed meta block from turning the import into an endless loop.
|
||||
*/
|
||||
private const MAX_PAGES = 100;
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return DestinationRow[]
|
||||
*
|
||||
* @throws ApiException
|
||||
*/
|
||||
public function fetchAll(): array
|
||||
{
|
||||
$rows = [];
|
||||
$page = 1;
|
||||
$totalPages = 1;
|
||||
|
||||
do {
|
||||
$payload = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\BusProNet\DataProvider;
|
||||
|
||||
use App\BusProNet\DataProvider\CountryDataProvider;
|
||||
use App\MyEp\Api\AccessTokenProvider;
|
||||
use App\MyEp\Api\ApiClient;
|
||||
use App\MyEp\Api\ResponseMapper;
|
||||
use App\MyEp\Cache\StaleWhileErrorCache;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
class CountryDataProviderTest extends TestCase
|
||||
{
|
||||
public function testCountriesAreKeyedByTheRawToken(): void
|
||||
{
|
||||
$provider = $this->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<MockResponse> $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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\BusProNet\DataProvider;
|
||||
|
||||
use App\BusProNet\DataProvider\HotelDataProvider;
|
||||
use App\MyEp\Api\AccessTokenProvider;
|
||||
use App\MyEp\Api\ApiClient;
|
||||
use App\MyEp\Api\ResponseMapper;
|
||||
use App\MyEp\Cache\StaleWhileErrorCache;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
class HotelDataProviderTest extends TestCase
|
||||
{
|
||||
public function testHotelsAreKeyedByBusProId(): void
|
||||
{
|
||||
$provider = $this->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<MockResponse> $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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\BusProNet\DataProvider;
|
||||
|
||||
use App\BusProNet\DataProvider\PickupDataProvider;
|
||||
use App\MyEp\Api\AccessTokenProvider;
|
||||
use App\MyEp\Api\ApiClient;
|
||||
use App\MyEp\Api\ResponseMapper;
|
||||
use App\MyEp\Cache\StaleWhileErrorCache;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
class PickupDataProviderTest extends TestCase
|
||||
{
|
||||
public function testPickupsAreKeyedByBusProId(): void
|
||||
{
|
||||
$provider = $this->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<MockResponse> $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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\MyEp\Api;
|
||||
|
||||
use App\MyEp\Api\AccessTokenProvider;
|
||||
use App\MyEp\Api\ApiException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
class AccessTokenProviderTest extends TestCase
|
||||
{
|
||||
private const CACHE_KEY = 'myep.api.access_token';
|
||||
|
||||
public function testTokenIsReturnedFromTheTokenEndpoint(): void
|
||||
{
|
||||
$httpClient = new MockHttpClient([$this->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('<html>gateway timeout</html>')]);
|
||||
|
||||
$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',
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\MyEp\Api;
|
||||
|
||||
use App\MyEp\Api\AccessTokenProvider;
|
||||
use App\MyEp\Api\ApiClient;
|
||||
use App\MyEp\Api\ApiException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
class ApiClientTest extends TestCase
|
||||
{
|
||||
public function testPayloadIsReturned(): void
|
||||
{
|
||||
$httpClient = $this->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('<html>not json</html>')]);
|
||||
|
||||
$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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\MyEp\Api;
|
||||
|
||||
use App\BusProNet\Model\Country;
|
||||
use App\BusProNet\Model\Hotel;
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\MyEp\Api\ResponseMapper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
class ResponseMapperTest extends TestCase
|
||||
{
|
||||
public function testHotelsAreKeyedByTheirBusProId(): void
|
||||
{
|
||||
$hotels = $this->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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\MyEp\Cache;
|
||||
|
||||
use App\MyEp\Cache\StaleWhileErrorCache;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
|
||||
class StaleWhileErrorCacheTest extends TestCase
|
||||
{
|
||||
public function testTheProducedValueIsReturnedAndCached(): void
|
||||
{
|
||||
$pool = new ArrayAdapter();
|
||||
$cache = new StaleWhileErrorCache($pool, new NullLogger());
|
||||
|
||||
$value = $cache->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']));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\MyEp\Import;
|
||||
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\MyEp\Import\DestinationRow;
|
||||
use App\MyEp\Import\InvalidRowException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class DestinationRowTest extends TestCase
|
||||
{
|
||||
public function testARowIsMapped(): void
|
||||
{
|
||||
$row = DestinationRow::fromArray($this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\MyEp\Import;
|
||||
|
||||
use App\MyEp\Api\AccessTokenProvider;
|
||||
use App\MyEp\Api\ApiClient;
|
||||
use App\MyEp\Api\ApiException;
|
||||
use App\MyEp\Import\TravelExportClient;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
class TravelExportClientTest extends TestCase
|
||||
{
|
||||
public function testEveryPageIsRead(): void
|
||||
{
|
||||
$httpClient = $this->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());
|
||||
}
|
||||
}
|
||||
@@ -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('<html>gateway timeout</html>'),
|
||||
'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),
|
||||
|
||||
Reference in new issue
Block a user