feat: read BusProNet base data from the MyE&P API

This commit is contained in:
Björn Fromme authored and Björn Fromme committed 2026-09-29 11:08:04 +02:00
1 parent 1505f9b50b
commit 2e9328fc51
37 files changed
+2302 -148

No files matched your search

@@ -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),
);
}
}
+142
View File
@@ -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',
],
);
}
}
+166
View File
@@ -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());
}
}
+167
View File
@@ -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']));
}
}
+180
View File
@@ -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());
}
}
+43 -19
View File
@@ -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),