diff --git a/.env b/.env
index caf53dd02..ea4dfd90c 100644
--- a/.env
+++ b/.env
@@ -180,6 +180,11 @@ CALENDAR_API_FEED_SOURCE_CACHE_EXPIRE_SECONDS=300
EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS=300
###< Event Database Api V2 Feed Type ###
+###> Instant Book ###
+# Source of busy intervals data for instant booking interactive slides (graph or feed).
+INSTANT_BOOK_BUSY_INTERVALS_SOURCE=graph
+###< Instant Book ###
+
###> Admin configuration ###
# API key for Rejseplanen (Danish journey planner) integration.
ADMIN_REJSEPLANEN_APIKEY=
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2065790f1..6c2b2bf43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+- Added `INSTANT_BOOK_BUSY_INTERVALS_SOURCE` to select between Graph and the slide's calendar feed as the source of busy
+ intervals for InstantBook.
+- Changed polling interval for instant booking template.
- Fixed admin toast leaking a raw `SyntaxError: Unexpected token '<'` when an upload was rejected
upstream (e.g. nginx 413); the toast now shows `HTTP ` instead.
- Made the media upload max size configurable via the new `MEDIA_MAX_UPLOAD_SIZE_MB` env var.
diff --git a/README.md b/README.md
index 4e9de902a..7281f5f12 100644
--- a/README.md
+++ b/README.md
@@ -629,6 +629,21 @@ EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS=300
- EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS: What should the expiration be for cache entries in
EventDatabaseApiV2FeedType?
+#### InstantBook
+
+```dotenv
+###> InstantBook ###
+INSTANT_BOOK_BUSY_INTERVALS_SOURCE=graph
+###< InstantBook ###
+```
+
+- INSTANT_BOOK_BUSY_INTERVALS_SOURCE: Where the InstantBook interactive slide fetches resource
+ busy-intervals from.
+ - `graph`: Fetch busy intervals from Microsoft Graph (results cached for 15 minutes).
+ - `feed`: Fetch busy intervals from the slide's configured calendar-output feed.
+
+ **Default**: `graph`.
+
## Rest API & Relationships
To avoid embedding all relations in REST representations but still allow the clients to minimize the amount of API calls
diff --git a/assets/shared/templates/calendar/calendar-single-booking.jsx b/assets/shared/templates/calendar/calendar-single-booking.jsx
index b9e5d6156..1db066f54 100644
--- a/assets/shared/templates/calendar/calendar-single-booking.jsx
+++ b/assets/shared/templates/calendar/calendar-single-booking.jsx
@@ -64,7 +64,6 @@ function CalendarSingleBooking({
// Get values from client localstorage.
const token = localStorage.getItem("apiToken");
const tenantKey = localStorage.getItem("tenantKey");
- const apiUrl = localStorage.getItem("apiUrl");
const [bookableIntervals, setBookableIntervals] = useState([]);
const [fetchingIntervals, setFetchingIntervals] = useState(false);
@@ -72,14 +71,14 @@ function CalendarSingleBooking({
const [bookingResult, setBookingResult] = useState(null);
const [processingBooking, setProcessingBooking] = useState(false);
const [secondsUntilNextEvent, setSecondsUntilNextEvent] = useState(null);
- const [bookingError, setBookingError] = useState(false);
+ const [bookingError, setBookingError] = useState(null);
const fetchBookingIntervals = () => {
if (!instantBookingEnabled) {
return;
}
- if (!apiUrl || !slide || !token || !tenantKey) {
+ if (!slide || !token || !tenantKey) {
setFetchingIntervals(false);
return;
}
@@ -93,7 +92,7 @@ function CalendarSingleBooking({
if (resources.length === 1) {
setFetchingIntervals(true);
- fetch(`${apiUrl}${slide["@id"]}/action`, {
+ fetch(`${slide["@id"]}/action`, {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
@@ -173,7 +172,7 @@ function CalendarSingleBooking({
};
const clickInterval = (interval) => {
- if (!apiUrl || !slide || !token || !tenantKey) {
+ if (!slide || !token || !tenantKey) {
return;
}
@@ -183,7 +182,7 @@ function CalendarSingleBooking({
setProcessingBooking(true);
- fetch(`${apiUrl}${slide["@id"]}/action`, {
+ fetch(`${slide["@id"]}/action`, {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
@@ -198,14 +197,21 @@ function CalendarSingleBooking({
},
}),
})
- .then((r) => r.json())
+ .then((r) => {
+ if (!r.ok) {
+ const error = new Error(`Booking failed with status ${r.status}`);
+ error.status = r.status;
+ throw error;
+ }
+ return r.json();
+ })
.then((data) => {
setBookingResult(data);
setInstantBookingFromLocalStorage(slide["@id"], data);
})
- .catch(() => {
- setBookingError(true);
- setTimeout(() => setBookingError(false), 10000);
+ .catch((err) => {
+ setBookingError(err?.status === 409 ? "conflict" : "generic");
+ setTimeout(() => setBookingError(null), 10000);
})
.finally(() => {
setProcessingBooking(false);
@@ -217,7 +223,8 @@ function CalendarSingleBooking({
dayjs.extend(localizedFormat);
intervalChecking();
- const interval = setInterval(intervalChecking, 5000);
+ // Check every minute if the current time has changed.
+ const interval = setInterval(intervalChecking, 60000);
return () => {
if (interval !== null) {
@@ -385,7 +392,15 @@ function CalendarSingleBooking({
/>
)}
- {bookingError && (
+ {bookingError === "conflict" && (
+
+
+
+ )}
+ {bookingError && bookingError !== "conflict" && (
{
await expect(page.getByText("Ledigt")).toHaveCount(1);
await expect(page.getByText("Ledigt")).toBeVisible();
- await page.waitForTimeout(5500);
+ await page.clock.runFor(61000);
await expect(page.getByText("Optaget")).toHaveCount(1);
await expect(page.getByText("Optaget")).toBeVisible();
diff --git a/config/services.yaml b/config/services.yaml
index 2092f896d..cc13a9e48 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -104,6 +104,10 @@ services:
$timezone: '%env(string:CALENDAR_API_FEED_SOURCE_DATE_TIMEZONE)%'
$cacheExpireSeconds: '%env(int:CALENDAR_API_FEED_SOURCE_CACHE_EXPIRE_SECONDS)%'
+ App\InteractiveSlide\InstantBook:
+ arguments:
+ $busyIntervalsSource: '%env(string:INSTANT_BOOK_BUSY_INTERVALS_SOURCE)%'
+
App\Service\KeyVaultService:
arguments:
$keyVaultSource: '%env(string:KEY_VAULT_SOURCE)%'
diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php
index 0b3c5242e..60f4532ea 100644
--- a/src/InteractiveSlide/InstantBook.php
+++ b/src/InteractiveSlide/InstantBook.php
@@ -5,6 +5,7 @@
namespace App\InteractiveSlide;
use App\Entity\Tenant;
+use App\Entity\Tenant\Feed;
use App\Entity\Tenant\InteractiveSlideConfig;
use App\Entity\Tenant\Slide;
use App\Exceptions\BadRequestException;
@@ -12,10 +13,13 @@
use App\Exceptions\ForbiddenException;
use App\Exceptions\NotAcceptableException;
use App\Exceptions\TooManyRequestsException;
+use App\Feed\FeedOutputModels;
+use App\Service\FeedService;
use App\Service\InteractiveSlideService;
use App\Service\KeyVaultService;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\InvalidArgumentException;
+use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
@@ -45,6 +49,8 @@ class InstantBook implements InteractiveSlideInterface
private const string CACHE_LIFETIME_QUICK_BOOK_OPTIONS = 'PT5M';
private const string CACHE_LIFETIME_BUSY_INTERVALS = 'PT15M';
private const string CACHE_LIFETIME_QUICK_BOOK_SPAM_PROTECT = 'PT1M';
+ public const string SOURCE_GRAPH = 'graph';
+ public const string SOURCE_FEED = 'feed';
// see https://docs.microsoft.com/en-us/graph/api/resources/datetimetimezone?view=graph-rest-1.0
// example 2019-03-15T09:00:00
public const string GRAPH_DATE_FORMAT = 'Y-m-d\TH:i:s';
@@ -54,7 +60,13 @@ public function __construct(
private readonly HttpClientInterface $client,
private readonly KeyVaultService $keyVaultService,
private readonly CacheInterface $interactiveSlideCache,
- ) {}
+ private readonly FeedService $feedService,
+ private readonly string $busyIntervalsSource,
+ ) {
+ if (!in_array($busyIntervalsSource, [self::SOURCE_GRAPH, self::SOURCE_FEED], true)) {
+ throw new \InvalidArgumentException(sprintf('Invalid INSTANT_BOOK_BUSY_INTERVALS_SOURCE "%s"; expected "%s" or "%s".', $busyIntervalsSource, self::SOURCE_GRAPH, self::SOURCE_FEED));
+ }
+ }
public function getConfigOptions(): array
{
@@ -195,13 +207,7 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest,
$watchedResources[] = $resource;
}
- $schedules = $this->interactiveSlideCache->get(self::CACHE_KEY_BUSY_INTERVALS_PREFIX,
- function (CacheItemInterface $item) use ($token, $watchedResources, $start, $startPlus1Hour) {
- $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_BUSY_INTERVALS));
-
- return $this->getBusyIntervals($token, $watchedResources, $start, $startPlus1Hour);
- }
- );
+ $schedules = $this->fetchBusyIntervals($token, $watchedResources, $start, $startPlus1Hour, $slide);
$result = [];
@@ -328,6 +334,8 @@ function (CacheItemInterface $item) use (&$isFreshRequest): bool {
$start = (new \DateTime())->setTimezone(new \DateTimeZone('UTC'));
$startPlusDuration = (clone $start)->add(new \DateInterval('PT'.$durationMinutes.'M'))->setTimezone(new \DateTimeZone('UTC'));
+ $this->assertSlotFree($token, $resource, $start, $startPlusDuration);
+
$requestBody = [
'subject' => self::BOOKING_TITLE,
'start' => [
@@ -435,6 +443,137 @@ private function getBusyIntervals(string $token, array $resources, \DateTime $st
return $result;
}
+ /**
+ * Dispatch busy-intervals lookup according to INSTANT_BOOK_BUSY_INTERVALS_SOURCE.
+ *
+ * Both branches return the same shape:
+ * [resourceId => [['startTime' => DateTime, 'endTime' => DateTime], ...]].
+ *
+ * @param string[] $resources
+ *
+ * @return array>
+ *
+ * @throws \Throwable
+ */
+ private function fetchBusyIntervals(string $token, array $resources, \DateTime $from, \DateTime $to, Slide $slide): array
+ {
+ return match ($this->busyIntervalsSource) {
+ self::SOURCE_FEED => $this->getBusyIntervalsFromFeed($slide->getFeed(), $resources, $from, $to),
+ self::SOURCE_GRAPH => $this->getBusyIntervalsCached($token, $resources, $from, $to),
+ };
+ }
+
+ /**
+ * Cached wrapper around getBusyIntervals().
+ *
+ * The cache key includes the resource set and the from/to window (minute precision) so that
+ * concurrent requests with different watched resources or windows do not share an entry; the
+ * previous fixed-key behaviour caused different slides to read each other's data.
+ *
+ * @param string[] $resources
+ *
+ * @return array>
+ *
+ * @throws InvalidArgumentException
+ */
+ private function getBusyIntervalsCached(string $token, array $resources, \DateTime $from, \DateTime $to): array
+ {
+ $sortedResources = $resources;
+ sort($sortedResources);
+
+ $cacheKey = sprintf(
+ '%s-%s-%s-%s',
+ self::CACHE_KEY_BUSY_INTERVALS_PREFIX,
+ hash('xxh128', implode('|', $sortedResources)),
+ $from->format('YmdHi'),
+ $to->format('YmdHi'),
+ );
+
+ return $this->interactiveSlideCache->get(
+ $cacheKey,
+ function (CacheItemInterface $item) use ($token, $resources, $from, $to) {
+ $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_BUSY_INTERVALS));
+
+ return $this->getBusyIntervals($token, $resources, $from, $to);
+ },
+ );
+ }
+
+ /**
+ * Verify that the requested slot is free in live Graph state before booking.
+ *
+ * This is required, not an optimization: the resources used here are not configured to block
+ * conflicting bookings at the Graph API level — POSTing a conflicting booking will simply
+ * succeed rather than return 409. The 409 catch on the booking POST is a backstop for
+ * resources whose AutomateProcessing setting does reject conflicts.
+ *
+ * @throws ConflictException if the slot is already booked
+ */
+ private function assertSlotFree(string $token, string $resource, \DateTime $start, \DateTime $end): void
+ {
+ $schedules = $this->getBusyIntervals($token, [$resource], $start, $end);
+
+ if (!$this->intervalFree($schedules[$resource] ?? [], $start, $end)) {
+ throw new ConflictException('Interval booked already');
+ }
+ }
+
+ /**
+ * Derive busy intervals from the slide's calendar-output-model feed.
+ *
+ * @param string[] $resources resource ids to include in the result
+ *
+ * @return array>
+ *
+ * @throws UnprocessableEntityHttpException when the slide is not configured for feed-sourced busy intervals
+ */
+ private function getBusyIntervalsFromFeed(?Feed $feed, array $resources, \DateTime $from, \DateTime $to): array
+ {
+ if (null === $feed) {
+ throw new UnprocessableEntityHttpException('InstantBook (feed source): slide feed not set.');
+ }
+
+ $feedSource = $feed->getFeedSource();
+
+ if (null === $feedSource) {
+ throw new UnprocessableEntityHttpException('InstantBook (feed source): feed source not set on slide feed.');
+ }
+
+ $feedType = $this->feedService->getFeedType($feedSource->getFeedType());
+
+ if (FeedOutputModels::CALENDAR_OUTPUT !== $feedType->getSupportedFeedOutputType()) {
+ throw new UnprocessableEntityHttpException('InstantBook (feed source) requires a calendar-output feed.');
+ }
+
+ $events = $this->feedService->getData($feed) ?? [];
+
+ $result = array_fill_keys($resources, []);
+ $fromTs = $from->getTimestamp();
+ $toTs = $to->getTimestamp();
+
+ foreach ($events as $event) {
+ $resourceId = $event['resourceId'] ?? null;
+
+ if (!is_string($resourceId) || !array_key_exists($resourceId, $result)) {
+ continue;
+ }
+
+ $startTs = (int) ($event['startTime'] ?? 0);
+ $endTs = (int) ($event['endTime'] ?? 0);
+
+ if ($endTs <= $fromTs || $startTs >= $toTs) {
+ continue;
+ }
+
+ $result[$resourceId][] = [
+ 'startTime' => new \DateTime('@'.$startTs),
+ 'endTime' => new \DateTime('@'.$endTs),
+ ];
+ }
+
+ return $result;
+ }
+
public function intervalFree(array $schedule, \DateTime $from, \DateTime $to): bool
{
foreach ($schedule as $scheduleEntry) {
diff --git a/tests/Interactive/InstantBookTest.php b/tests/Interactive/InstantBookTest.php
index 17b405b17..95da2d24e 100644
--- a/tests/Interactive/InstantBookTest.php
+++ b/tests/Interactive/InstantBookTest.php
@@ -4,11 +4,24 @@
namespace App\Tests\Interactive;
+use App\Entity\Tenant\Feed;
+use App\Entity\Tenant\FeedSource;
+use App\Exceptions\ConflictException;
+use App\Feed\FeedOutputModels;
+use App\Feed\FeedTypeInterface;
use App\InteractiveSlide\InstantBook;
+use App\Service\FeedService;
+use App\Service\InteractiveSlideService;
+use App\Service\KeyVaultService;
use Doctrine\ORM\EntityManager;
use Hautelook\AliceBundle\PhpUnit\BaseDatabaseTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
+use Symfony\Contracts\Cache\CacheInterface;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
class InstantBookTest extends KernelTestCase
{
@@ -49,4 +62,197 @@ public function testIntervalFree(): void
$intervalFree = $service->intervalFree($schedules, new \DateTime('+15 minutes'), new \DateTime('+45 minutes'));
$this->assertFalse($intervalFree);
}
+
+ public function testGetBusyIntervalsFromFeedFiltersByResourceAndWindow(): void
+ {
+ $from = new \DateTime('2026-01-01T10:00:00', new \DateTimeZone('UTC'));
+ $to = new \DateTime('2026-01-01T11:00:00', new \DateTimeZone('UTC'));
+
+ $events = [
+ // In-window event for resource A.
+ ['resourceId' => 'a@example.com', 'startTime' => $from->getTimestamp() + 600, 'endTime' => $from->getTimestamp() + 1800],
+ // In-window event for resource B.
+ ['resourceId' => 'b@example.com', 'startTime' => $from->getTimestamp() + 1200, 'endTime' => $from->getTimestamp() + 2400],
+ // Out-of-window event (entirely before from).
+ ['resourceId' => 'a@example.com', 'startTime' => $from->getTimestamp() - 7200, 'endTime' => $from->getTimestamp() - 3600],
+ // Event for an unwatched resource.
+ ['resourceId' => 'c@example.com', 'startTime' => $from->getTimestamp() + 600, 'endTime' => $from->getTimestamp() + 1800],
+ ];
+
+ $instantBook = $this->buildInstantBookWithFeedData($events, FeedOutputModels::CALENDAR_OUTPUT);
+
+ $feed = $this->buildFeedWithSource(\App\Feed\CalendarApiFeedType::class);
+
+ $result = $this->invokePrivate($instantBook, 'getBusyIntervalsFromFeed', [$feed, ['a@example.com', 'b@example.com'], $from, $to]);
+
+ $this->assertSame(['a@example.com', 'b@example.com'], array_keys($result));
+ $this->assertCount(1, $result['a@example.com']);
+ $this->assertCount(1, $result['b@example.com']);
+ $this->assertInstanceOf(\DateTime::class, $result['a@example.com'][0]['startTime']);
+ $this->assertInstanceOf(\DateTime::class, $result['a@example.com'][0]['endTime']);
+ $this->assertSame($from->getTimestamp() + 600, $result['a@example.com'][0]['startTime']->getTimestamp());
+ }
+
+ public function testGetBusyIntervalsFromFeedReturnsEmptyForResourcesWithoutEvents(): void
+ {
+ $from = new \DateTime('2026-01-01T10:00:00', new \DateTimeZone('UTC'));
+ $to = new \DateTime('2026-01-01T11:00:00', new \DateTimeZone('UTC'));
+
+ $instantBook = $this->buildInstantBookWithFeedData([], FeedOutputModels::CALENDAR_OUTPUT);
+ $feed = $this->buildFeedWithSource(\App\Feed\CalendarApiFeedType::class);
+
+ $result = $this->invokePrivate($instantBook, 'getBusyIntervalsFromFeed', [$feed, ['a@example.com'], $from, $to]);
+
+ $this->assertSame(['a@example.com' => []], $result);
+ }
+
+ public function testGetBusyIntervalsFromFeedRejectsNonCalendarFeed(): void
+ {
+ $instantBook = $this->buildInstantBookWithFeedData([], FeedOutputModels::RSS_OUTPUT);
+ $feed = $this->buildFeedWithSource(\App\Feed\RssFeedType::class);
+
+ $this->expectException(UnprocessableEntityHttpException::class);
+
+ $this->invokePrivate($instantBook, 'getBusyIntervalsFromFeed', [$feed, ['a@example.com'], new \DateTime(), new \DateTime('+1 hour')]);
+ }
+
+ public function testGetBusyIntervalsFromFeedRejectsNullFeed(): void
+ {
+ $instantBook = $this->buildInstantBookWithFeedData([], FeedOutputModels::CALENDAR_OUTPUT);
+
+ $this->expectException(UnprocessableEntityHttpException::class);
+
+ $this->invokePrivate($instantBook, 'getBusyIntervalsFromFeed', [null, ['a@example.com'], new \DateTime(), new \DateTime('+1 hour')]);
+ }
+
+ public function testAssertSlotFreeThrowsConflictWhenResourceIsBusy(): void
+ {
+ $start = new \DateTime('2030-01-01T10:00:00', new \DateTimeZone('UTC'));
+ $end = (clone $start)->modify('+30 minutes');
+ $resource = 'room-a@example.com';
+
+ $instantBook = $this->buildInstantBookWithGraphSchedule([
+ 'value' => [[
+ 'scheduleId' => $resource,
+ 'scheduleItems' => [[
+ 'start' => ['dateTime' => '2030-01-01T10:10:00', 'timeZone' => 'UTC'],
+ 'end' => ['dateTime' => '2030-01-01T10:20:00', 'timeZone' => 'UTC'],
+ ]],
+ ]],
+ ]);
+
+ $this->expectException(ConflictException::class);
+
+ $this->invokePrivate($instantBook, 'assertSlotFree', ['fake-token', $resource, $start, $end]);
+ }
+
+ public function testAssertSlotFreePassesWhenScheduleIsEmpty(): void
+ {
+ $start = new \DateTime('2030-01-01T10:00:00', new \DateTimeZone('UTC'));
+ $end = (clone $start)->modify('+30 minutes');
+ $resource = 'room-a@example.com';
+
+ $instantBook = $this->buildInstantBookWithGraphSchedule([
+ 'value' => [[
+ 'scheduleId' => $resource,
+ 'scheduleItems' => [],
+ ]],
+ ]);
+
+ $this->invokePrivate($instantBook, 'assertSlotFree', ['fake-token', $resource, $start, $end]);
+ $this->expectNotToPerformAssertions();
+ }
+
+ private function buildInstantBookWithFeedData(array $events, string $outputType): InstantBook
+ {
+ $feedType = new class($outputType) implements FeedTypeInterface {
+ public function __construct(
+ private readonly string $outputType,
+ ) {}
+
+ public function getData(Feed $feed): array
+ {
+ return [];
+ }
+
+ public function getAdminFormOptions(FeedSource $feedSource): array
+ {
+ return [];
+ }
+
+ public function getConfigOptions(Request $request, FeedSource $feedSource, string $name): ?array
+ {
+ return null;
+ }
+
+ public function getRequiredSecrets(): array
+ {
+ return [];
+ }
+
+ public function getRequiredConfiguration(): array
+ {
+ return [];
+ }
+
+ public function getSupportedFeedOutputType(): string
+ {
+ return $this->outputType;
+ }
+
+ public function getSchema(): array
+ {
+ return [];
+ }
+ };
+
+ $feedService = $this->createMock(FeedService::class);
+ $feedService->method('getFeedType')->willReturn($feedType);
+ $feedService->method('getData')->willReturn($events);
+
+ return new InstantBook(
+ $this->container->get(InteractiveSlideService::class),
+ $this->createMock(HttpClientInterface::class),
+ $this->container->get(KeyVaultService::class),
+ $this->createMock(CacheInterface::class),
+ $feedService,
+ InstantBook::SOURCE_FEED,
+ );
+ }
+
+ private function buildInstantBookWithGraphSchedule(array $scheduleResponse): InstantBook
+ {
+ $response = $this->createMock(ResponseInterface::class);
+ $response->method('toArray')->willReturn($scheduleResponse);
+
+ $client = $this->createMock(HttpClientInterface::class);
+ $client->method('request')->willReturn($response);
+
+ return new InstantBook(
+ $this->container->get(InteractiveSlideService::class),
+ $client,
+ $this->container->get(KeyVaultService::class),
+ $this->createMock(CacheInterface::class),
+ $this->createMock(FeedService::class),
+ InstantBook::SOURCE_GRAPH,
+ );
+ }
+
+ private function buildFeedWithSource(string $feedTypeClassName): Feed
+ {
+ $feedSource = new FeedSource();
+ $feedSource->setFeedType($feedTypeClassName);
+
+ $feed = new Feed();
+ $feed->setFeedSource($feedSource);
+
+ return $feed;
+ }
+
+ private function invokePrivate(object $target, string $method, array $args): mixed
+ {
+ $ref = new \ReflectionMethod($target, $method);
+
+ return $ref->invokeArgs($target, $args);
+ }
}