diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b7340f20..0f94509db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Refactored InteractiveController to use a typed `InteractiveSlideActionInput` DTO; regenerated API spec and RTK types. +- Fixed multiple InstantBook bugs: interval boundary overlap, busy-interval timezone, per-resource spam-protect + throttling, duration validation, error responses (409/4xx), resource cache TTL, and assorted + typos/string-interpolation issues. +- Added `getBusyIntervals` cache (PT15M) with a shared `validateResourceAccess()` helper, eliminating per-poll Graph + calls at the cost of up to 15-minute-stale availability in `quickBookOptions`. + ## [3.0.0-rc3] - 2026-05-11 - Made the Admin login sidebar text configurable via the new `ADMIN_LOGIN_SCREEN_TEXT` diff --git a/assets/shared/redux/generated-api.ts b/assets/shared/redux/generated-api.ts index 63959ac3e..6134d6d74 100644 --- a/assets/shared/redux/generated-api.ts +++ b/assets/shared/redux/generated-api.ts @@ -2475,8 +2475,9 @@ export type SlideSlideInputJsonld = { content?: string[]; }; export type SlideInteractiveSlideActionInputJsonld = { + implementationClass?: string | null; action?: string | null; - data?: string[]; + data?: string[] | null; }; export type ThemeThemeJsonld = { title?: string; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 96fb5c500..97136d783 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2892,12 +2892,6 @@ parameters: count: 1 path: src/Service/InteractiveSlideService.php - - - message: '#^Method App\\Service\\InteractiveSlideService\:\:parseRequestBody\(\) has parameter \$requestBody with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Service/InteractiveSlideService.php - - message: '#^Method App\\Service\\InteractiveSlideService\:\:performAction\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue diff --git a/public/api-spec-v2.json b/public/api-spec-v2.json index df8506599..3c3abc0a6 100644 --- a/public/api-spec-v2.json +++ b/public/api-spec-v2.json @@ -14274,6 +14274,12 @@ "description": "", "deprecated": false, "properties": { + "implementationClass": { + "type": [ + "string", + "null" + ] + }, "action": { "type": [ "string", @@ -14281,7 +14287,10 @@ ] }, "data": { - "type": "array", + "type": [ + "array", + "null" + ], "items": { "type": "string" } @@ -14293,6 +14302,12 @@ "description": "", "deprecated": false, "properties": { + "implementationClass": { + "type": [ + "string", + "null" + ] + }, "action": { "type": [ "string", @@ -14300,7 +14315,10 @@ ] }, "data": { - "type": "array", + "type": [ + "array", + "null" + ], "items": { "type": "string" } diff --git a/public/api-spec-v2.yaml b/public/api-spec-v2.yaml index 9c1b21eb1..b39e7a2bd 100644 --- a/public/api-spec-v2.yaml +++ b/public/api-spec-v2.yaml @@ -9948,12 +9948,18 @@ components: description: '' deprecated: false properties: + implementationClass: + type: + - string + - 'null' action: type: - string - 'null' data: - type: array + type: + - array + - 'null' items: type: string Slide.InteractiveSlideActionInput.jsonld: @@ -9961,12 +9967,18 @@ components: description: '' deprecated: false properties: + implementationClass: + type: + - string + - 'null' action: type: - string - 'null' data: - type: array + type: + - array + - 'null' items: type: string Slide.Slide: diff --git a/src/Controller/Api/InteractiveController.php b/src/Controller/Api/InteractiveController.php index 6cdde602a..eb7697009 100644 --- a/src/Controller/Api/InteractiveController.php +++ b/src/Controller/Api/InteractiveController.php @@ -4,6 +4,7 @@ namespace App\Controller\Api; +use App\Dto\InteractiveSlideActionInput; use App\Entity\ScreenUser; use App\Entity\Tenant\Slide; use App\Exceptions\BadRequestException; @@ -41,9 +42,10 @@ public function __invoke(Request $request, Slide $slide): JsonResponse $tenant = $user->getActiveTenant(); - $requestBody = $request->toArray(); + /** @var InteractiveSlideActionInput $input */ + $input = $request->attributes->get('data'); - $interactionRequest = $this->interactiveSlideService->parseRequestBody($requestBody); + $interactionRequest = $this->interactiveSlideService->parseInteractiveSlideActionInput($input); $actionResult = $this->interactiveSlideService->performAction($tenant, $slide, $interactionRequest); diff --git a/src/Dto/InteractiveSlideActionInput.php b/src/Dto/InteractiveSlideActionInput.php index 25ef131e0..c98fe3fff 100644 --- a/src/Dto/InteractiveSlideActionInput.php +++ b/src/Dto/InteractiveSlideActionInput.php @@ -6,6 +6,7 @@ class InteractiveSlideActionInput { + public ?string $implementationClass = null; public ?string $action = null; - public array $data = []; + public ?array $data = null; } diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index 489575561..0b3c5242e 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -41,7 +41,9 @@ class InstantBook implements InteractiveSlideInterface private const string CACHE_KEY_RESOURCES = self::CACHE_PREFIX.'-RESOURCES'; private const string BOOKING_TITLE = 'Straksbooking'; private const array DURATIONS = [15, 30, 60]; + private const string CACHE_KEY_BUSY_INTERVALS_PREFIX = self::CACHE_PREFIX.'-BUSY-INTERVALS'; 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'; // see https://docs.microsoft.com/en-us/graph/api/resources/datetimetimezone?view=graph-rest-1.0 // example 2019-03-15T09:00:00 @@ -50,7 +52,7 @@ class InstantBook implements InteractiveSlideInterface public function __construct( private readonly InteractiveSlideService $interactiveService, private readonly HttpClientInterface $client, - private readonly KeyVaultService $keyValueService, + private readonly KeyVaultService $keyVaultService, private readonly CacheInterface $interactiveSlideCache, ) {} @@ -105,10 +107,10 @@ public function performAction(Tenant $tenant, Slide $slide, InteractionSlideRequ */ private function authenticate(array $configuration): array { - $tenantId = $this->keyValueService->getValue($configuration['tenantId']); - $clientId = $this->keyValueService->getValue($configuration['clientId']); - $username = $this->keyValueService->getValue($configuration['username']); - $password = $this->keyValueService->getValue($configuration['password']); + $tenantId = $this->keyVaultService->getValue($configuration['tenantId']); + $clientId = $this->keyVaultService->getValue($configuration['clientId']); + $username = $this->keyVaultService->getValue($configuration['username']); + $password = $this->keyVaultService->getValue($configuration['password']); if (4 !== count(array_filter([$tenantId, $clientId, $username, $password]))) { throw new NotAcceptableException('tenantId, clientId, username, password must all be set.'); @@ -167,47 +169,43 @@ private function getQuickBookOptions(Tenant $tenant, Slide $slide, InteractionSl $start = (new \DateTime())->setTimezone(new \DateTimeZone('UTC')); - return $this->interactiveSlideCache->get(self::CACHE_KEY_OPTIONS_PREFIX.$resource, - function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest, $start, $tenant) { + $siblingEntries = []; + $updatedWatchedResources = []; + + $result = $this->interactiveSlideCache->get(self::CACHE_KEY_OPTIONS_PREFIX.$resource, + function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest, $start, $tenant, &$siblingEntries, &$updatedWatchedResources) { $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); // If any exceptions are thrown we return an empty options entry. try { - $interactiveSlideConfig = $this->interactiveService->getInteractiveSlideConfig($tenant, $interactionRequest->implementationClass); - - if (null === $interactiveSlideConfig) { - throw new NotAcceptableException('InteractiveSlideConfig not found'); - } - - $this->checkPermission($interactiveSlideConfig, $resource); - - $feed = $slide->getFeed(); - - if (null === $feed) { - throw new NotAcceptableException('Slide feed not set.'); - } - - if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { - throw new NotAcceptableException('Resource not in feed resources'); - } - + $interactiveSlideConfig = $this->validateResourceAccess($tenant, $slide, $interactionRequest->implementationClass, $resource); $token = $this->getToken($tenant, $interactiveSlideConfig); $startPlus1Hour = (clone $start)->add(new \DateInterval('PT1H'))->setTimezone(new \DateTimeZone('UTC')); // Get resources that are watched for availability. - $watchedResources = $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => []); + $watchedResources = $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, function (CacheItemInterface $item) { + $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); + + return []; + }); // Add resource to watchedResources, if not in list. if (!in_array($resource, $watchedResources)) { $watchedResources[] = $resource; } - $schedules = $this->getBusyIntervals($token, $watchedResources, $start, $startPlus1Hour); + $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); + } + ); $result = []; - // Refresh entries for all watched resources. + // Compute entries for all watched resources. foreach ($watchedResources as $key => $watchResource) { $schedule = $schedules[$watchResource] ?? null; @@ -220,20 +218,11 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest, if ($watchResource == $resource) { $result = $entry; } else { - // Refresh cache entry for resources in watch list that are not handled in current request. - $this->interactiveSlideCache->delete(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource); - $this->interactiveSlideCache->get(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource, - function (CacheItemInterface $item) use ($entry) { - $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); - - return $entry; - } - ); + $siblingEntries[$watchResource] = $entry; } } - $this->interactiveSlideCache->delete(self::CACHE_KEY_RESOURCES); - $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => $watchedResources); + $updatedWatchedResources = $watchedResources; return $result; } catch (\Throwable) { @@ -242,6 +231,18 @@ function (CacheItemInterface $item) use ($entry) { } } ); + + // Refresh sibling cache entries and watched resources list outside the callback. + // $siblingEntries is only populated on cache miss (when the callback ran). + foreach ($siblingEntries as $watchResource => $entry) { + $this->setCacheValue(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource, $entry, self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS); + } + + if (!empty($updatedWatchedResources)) { + $this->setCacheValue(self::CACHE_KEY_RESOURCES, $updatedWatchedResources, self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS); + } + + return $result; } private function createEntry(string $resource, \DateTime $start, ?array $schedules = null): array @@ -288,43 +289,32 @@ private function createEntry(string $resource, \DateTime $start, ?array $schedul private function quickBook(Tenant $tenant, Slide $slide, InteractionSlideRequest $interactionRequest): array { $resource = (string) $this->getValueFromInterval('resource', $interactionRequest); - $durationMinutes = $this->getValueFromInterval('durationMinutes', $interactionRequest); + $durationMinutes = (int) $this->getValueFromInterval('durationMinutes', $interactionRequest); - $now = new \DateTime(); + if (!in_array($durationMinutes, self::DURATIONS, true)) { + throw new BadRequestException('Invalid duration. Allowed values: '.implode(', ', self::DURATIONS)); + } // Make sure that booking requests are not spammed. - $lastRequestDateTime = $this->interactiveSlideCache->get( - self::CACHE_PREFIX_SPAM_PROTECT_PREFIX.$slide->getId(), - function (CacheItemInterface $item) use ($now): \DateTime { + // The callback only runs on cache miss (no recent booking exists). + // On cache hit, $isFreshRequest stays false and we rate-limit. + $isFreshRequest = false; + + $this->interactiveSlideCache->get( + self::CACHE_PREFIX_SPAM_PROTECT_PREFIX.$resource, + function (CacheItemInterface $item) use (&$isFreshRequest): bool { + $isFreshRequest = true; $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_SPAM_PROTECT)); - return $now; + return true; } ); - if ($lastRequestDateTime->add(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_SPAM_PROTECT)) > $now) { + if (!$isFreshRequest) { throw new TooManyRequestsException('Service unavailable'); } - $interactiveSlideConfig = $this->interactiveService->getInteractiveSlideConfig($tenant, $interactionRequest->implementationClass); - - if (null === $interactiveSlideConfig) { - throw new NotAcceptableException('InteractiveSlideConfig not found'); - } - - // Optional limiting of available resources. - $this->checkPermission($interactiveSlideConfig, $resource); - - $feed = $slide->getFeed(); - - if (null === $feed) { - throw new NotAcceptableException('Slide feed not set.'); - } - - if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { - throw new NotAcceptableException('Resource not in feed resources'); - } - + $interactiveSlideConfig = $this->validateResourceAccess($tenant, $slide, $interactionRequest->implementationClass, $resource); $token = $this->getToken($tenant, $interactiveSlideConfig); $configuration = $interactiveSlideConfig->getConfiguration(); @@ -333,17 +323,11 @@ function (CacheItemInterface $item) use ($now): \DateTime { throw new NotAcceptableException('InteractiveSlideConfig has no configuration'); } - $username = $this->keyValueService->getValue($configuration['username']); + $username = $this->keyVaultService->getValue($configuration['username']); $start = (new \DateTime())->setTimezone(new \DateTimeZone('UTC')); $startPlusDuration = (clone $start)->add(new \DateInterval('PT'.$durationMinutes.'M'))->setTimezone(new \DateTimeZone('UTC')); - // Make sure interval is free. - $busyIntervals = $this->getBusyIntervals($token, [$resource], $start, $startPlusDuration); - if (count($busyIntervals[$resource]) > 0) { - throw new ConflictException('Interval booked already'); - } - $requestBody = [ 'subject' => self::BOOKING_TITLE, 'start' => [ @@ -377,6 +361,14 @@ function (CacheItemInterface $item) use ($now): \DateTime { $status = $response->getStatusCode(); + if (409 === $status) { + throw new ConflictException('Interval booked already'); + } + + if ($status >= 400) { + throw new NotAcceptableException('Booking failed with status '.$status); + } + return [ 'status' => $status, 'interval' => [ @@ -412,12 +404,9 @@ private function getBusyIntervals(string $token, array $resources, \DateTime $st ]); $data = $response->toArray(); - - $scheduleData = $data['value']; - $result = []; - foreach ($scheduleData as $schedule) { + foreach ($data['value'] as $schedule) { $scheduleId = $schedule['scheduleId'] ?? null; $scheduleItems = $schedule['scheduleItems'] ?? null; @@ -425,14 +414,16 @@ private function getBusyIntervals(string $token, array $resources, \DateTime $st continue; } - $result[$scheduleId] = []; + if (!isset($result[$scheduleId])) { + $result[$scheduleId] = []; + } foreach ($scheduleItems as $scheduleItem) { $eventStartArray = $scheduleItem['start']; $eventEndArray = $scheduleItem['end']; $start = new \DateTime($eventStartArray['dateTime'], new \DateTimeZone($eventStartArray['timeZone'])); - $end = new \DateTime($eventEndArray['dateTime'], new \DateTimeZone($eventStartArray['timeZone'])); + $end = new \DateTime($eventEndArray['dateTime'], new \DateTimeZone($eventEndArray['timeZone'])); $result[$scheduleId][] = [ 'startTime' => $start, @@ -447,7 +438,7 @@ private function getBusyIntervals(string $token, array $resources, \DateTime $st public function intervalFree(array $schedule, \DateTime $from, \DateTime $to): bool { foreach ($schedule as $scheduleEntry) { - if (!($scheduleEntry['startTime'] > $to || $scheduleEntry['endTime'] < $from)) { + if (!($scheduleEntry['startTime'] >= $to || $scheduleEntry['endTime'] <= $from)) { return false; } } @@ -469,7 +460,7 @@ private function getValueFromInterval(string $key, InteractionSlideRequest $inte $value = $interval[$key] ?? null; if (null === $value) { - throw new BadRequestException("interval.'.$key.' not set."); + throw new BadRequestException("interval.{$key} not set."); } return $value; @@ -484,6 +475,47 @@ private function getHeaders(string $token): array ]; } + /** + * @throws NotAcceptableException + * @throws ForbiddenException + * @throws InvalidArgumentException + */ + private function validateResourceAccess(Tenant $tenant, Slide $slide, string $implementationClass, string $resource): InteractiveSlideConfig + { + $interactiveSlideConfig = $this->interactiveService->getInteractiveSlideConfig($tenant, $implementationClass); + + if (null === $interactiveSlideConfig) { + throw new NotAcceptableException('InteractiveSlideConfig not found'); + } + + $this->checkPermission($interactiveSlideConfig, $resource); + + $feed = $slide->getFeed(); + + if (null === $feed) { + throw new NotAcceptableException('Slide feed not set.'); + } + + if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { + throw new NotAcceptableException('Resource not in feed resources'); + } + + return $interactiveSlideConfig; + } + + /** + * @throws InvalidArgumentException + */ + private function setCacheValue(string $key, mixed $value, string $lifetime): void + { + $this->interactiveSlideCache->delete($key); + $this->interactiveSlideCache->get($key, function (CacheItemInterface $item) use ($value, $lifetime): mixed { + $item->expiresAfter(new \DateInterval($lifetime)); + + return $value; + }); + } + /** * @throws NotAcceptableException * @throws ForbiddenException @@ -517,7 +549,7 @@ private function getAllowedResources(InteractiveSlideConfig $interactive): array throw new NotAcceptableException('resourceEndpoint not set'); } - $resourceEndpoint = $this->keyValueService->getValue($key); + $resourceEndpoint = $this->keyVaultService->getValue($key); if (null === $resourceEndpoint) { throw new NotAcceptableException('resourceEndpoint value not set'); diff --git a/src/InteractiveSlide/InteractiveSlideInterface.php b/src/InteractiveSlide/InteractiveSlideInterface.php index df957842e..05e950d16 100644 --- a/src/InteractiveSlide/InteractiveSlideInterface.php +++ b/src/InteractiveSlide/InteractiveSlideInterface.php @@ -6,10 +6,6 @@ use App\Entity\Tenant; use App\Entity\Tenant\Slide; -use App\Exceptions\BadRequestException; -use App\Exceptions\ConflictException; -use App\Exceptions\NotAcceptableException; -use App\Exceptions\TooManyRequestsException; interface InteractiveSlideInterface { @@ -18,10 +14,7 @@ public function getConfigOptions(): array; /** * Perform the given InteractionRequest with the given Slide. * - * @throws ConflictException - * @throws BadRequestException - * @throws NotAcceptableException - * @throws TooManyRequestsException + * @throws \Throwable */ public function performAction(Tenant $tenant, Slide $slide, InteractionSlideRequest $interactionRequest): array; } diff --git a/src/Service/InteractiveSlideService.php b/src/Service/InteractiveSlideService.php index 21460e002..b055d5861 100644 --- a/src/Service/InteractiveSlideService.php +++ b/src/Service/InteractiveSlideService.php @@ -4,6 +4,7 @@ namespace App\Service; +use App\Dto\InteractiveSlideActionInput; use App\Entity\Tenant; use App\Entity\Tenant\InteractiveSlideConfig; use App\Entity\Tenant\Slide; @@ -29,23 +30,17 @@ public function __construct( ) {} /** - * Create InteractionRequest from the request body. - * - * @param array $requestBody the request body from the http request + * Create InteractionRequest from the DTO. * * @throws BadRequestException */ - public function parseRequestBody(array $requestBody): InteractionSlideRequest + public function parseInteractiveSlideActionInput(InteractiveSlideActionInput $input): InteractionSlideRequest { - $implementationClass = $requestBody['implementationClass'] ?? null; - $action = $requestBody['action'] ?? null; - $data = $requestBody['data'] ?? null; - - if (null === $implementationClass || null === $action || null === $data) { + if (null === $input->implementationClass || null === $input->action || null === $input->data) { throw new BadRequestException('implementationClass, action and/or data not set.'); } - return new InteractionSlideRequest($implementationClass, $action, $data); + return new InteractionSlideRequest($input->implementationClass, $input->action, $input->data); } /** @@ -99,7 +94,7 @@ public function getImplementation(?string $implementationClass): InteractiveSlid throw new BadRequestException('Interactive implementation class not found'); } - return $interactiveImplementations[0]; + return reset($interactiveImplementations); } /** diff --git a/tests/Service/InteractiveServiceTest.php b/tests/Service/InteractiveServiceTest.php index 9ce070292..b89599459 100644 --- a/tests/Service/InteractiveServiceTest.php +++ b/tests/Service/InteractiveServiceTest.php @@ -4,6 +4,7 @@ namespace App\Tests\Service; +use App\Dto\InteractiveSlideActionInput; use App\Entity\Tenant\Slide; use App\Exceptions\BadRequestException; use App\Exceptions\NotAcceptableException; @@ -38,28 +39,28 @@ public function setUp(): void $this->entityManager = $this->container->get('doctrine')->getManager(); } - public function testParseRequestBody(): void + public function testParseInteractiveSlideActionInput(): void { $interactiveService = $this->container->get(InteractiveSlideService::class); $this->expectException(BadRequestException::class); - $interactiveService->parseRequestBody([ - 'test' => 'test', - ]); + $emptyInput = new InteractiveSlideActionInput(); + $interactiveService->parseInteractiveSlideActionInput($emptyInput); - $interactionRequest = $interactiveService->parseRequestBody([ - 'implementationClass' => InstantBook::class, - 'action' => 'test', - 'data' => [], - ]); + $input = new InteractiveSlideActionInput(); + $input->implementationClass = InstantBook::class; + $input->action = 'test'; + $input->data = []; + + $interactionRequest = $interactiveService->parseInteractiveSlideActionInput($input); $correctReturnType = $interactionRequest instanceof InteractionSlideRequest; $this->assertTrue($correctReturnType); } - public function testPerformAction(): void + public function testPerformActionWithoutConfig(): void { $interactiveService = $this->container->get(InteractiveSlideService::class); $user = $this->container->get(UserRepository::class)->findOneBy(['email' => 'admin@example.com']); @@ -68,23 +69,40 @@ public function testPerformAction(): void $slide = new Slide(); - $interactionRequest = $interactiveService->parseRequestBody([ - 'implementationClass' => InstantBook::class, - 'action' => 'ACTION_NOT_EXIST', - 'data' => [], - ]); + $input = new InteractiveSlideActionInput(); + $input->implementationClass = InstantBook::class; + $input->action = 'ACTION_NOT_EXIST'; + $input->data = []; + + $interactionRequest = $interactiveService->parseInteractiveSlideActionInput($input); $this->expectException(NotAcceptableException::class); $this->expectExceptionMessage('Interactive slide config not found'); - $tenant = $user->getActiveTenant(); + $interactiveService->performAction($user->getActiveTenant(), $slide, $interactionRequest); + } - $interactiveService->performAction($tenant, $slide, $interactionRequest); + public function testPerformActionWithUnsupportedAction(): void + { + $interactiveService = $this->container->get(InteractiveSlideService::class); + $user = $this->container->get(UserRepository::class)->findOneBy(['email' => 'admin@example.com']); + $this->assertNotNull($user); + + $slide = new Slide(); + + $input = new InteractiveSlideActionInput(); + $input->implementationClass = InstantBook::class; + $input->action = 'ACTION_NOT_EXIST'; + $input->data = []; + + $interactionRequest = $interactiveService->parseInteractiveSlideActionInput($input); + + $tenant = $user->getActiveTenant(); $interactiveService->saveConfiguration($tenant, InstantBook::class, []); $this->expectException(NotAcceptableException::class); - $this->expectExceptionMessage('Action not allowed'); + $this->expectExceptionMessage('Action not supported'); $interactiveService->performAction($tenant, $slide, $interactionRequest); }