diff --git a/.claude/skills/add-feed-type/SKILL.md b/.claude/skills/add-feed-type/SKILL.md index aff6b22c4..e685fb678 100644 --- a/.claude/skills/add-feed-type/SKILL.md +++ b/.claude/skills/add-feed-type/SKILL.md @@ -83,9 +83,11 @@ class MyFeedType implements FeedTypeInterface Authoritative signatures live in `src/Feed/FeedTypeInterface.php`. Canonical behaviour for each: ### 3.1 `getSupportedFeedOutputType(): string` + Return one of `FeedOutputModels::*`. **Frozen for the source's lifetime** — changing it on an existing `FeedSource` orphans all linked slides. ### 3.2 `getRequiredSecrets(): array` + Declare keys for `FeedSource.secrets`. `'exposeValue' => true` makes a key readable by the frontend. ```php @@ -100,9 +102,11 @@ return [ ``` ### 3.3 `getRequiredConfiguration(): array` + Keys that must exist in `Feed.configuration` before `getData()` runs. Documentation only — enforce in `getData()`. ### 3.4 `getSchema(): array` + JSON Schema (draft-04) validated by `FeedSourceProcessor` on POST/PUT. Start permissive. ```php @@ -113,9 +117,11 @@ return [ 'required' => ['token'], ]; ``` + Gotcha: PUT with empty `secrets` payload skips validation (intentional — allows editing title without re-sending secrets). ### 3.5 `getAdminFormOptions(FeedSource $feedSource): array` + Admin form descriptor. Common inputs: | `input` | Use for | @@ -141,6 +147,7 @@ return [[ `name` becomes the key under `Feed.configuration`. The second arg to `getFeedSourceConfigUrl()` is the `$name` your `getConfigOptions()` dispatches on. ### 3.6 `getConfigOptions(Request $request, FeedSource $feedSource, string $name): ?array` + Called by `FeedSourceConfigGetController` for dynamic dropdowns. Return `[{id, title, value}, …]` or `null` for unknown `$name`. **Swallow exceptions here** — the admin form should degrade gracefully while an editor is still typing credentials. Log + return `null`/`[]`. @@ -168,6 +175,7 @@ Rules (these are the most common bugs): 1. **Validate required secrets/config first** — throw `\RuntimeException` with class-prefixed message if missing. 2. **Let exceptions bubble.** `FeedService::getData()` wraps the call and caches an empty result for **30 s** on failure (`ERROR_CACHE_TTL_SECONDS`). Catching-and-returning-`[]` yourself caches empty with the **full feed TTL** (often hours) — silent breakage. 3. **Log at the catch site, then rethrow:** + ```php try { /* … */ } catch (\Throwable $t) { @@ -179,6 +187,7 @@ Rules (these are the most common bugs): throw $t; } ``` + 4. **Don't add per-call caching around the whole `getData()`** — `FeedService` already caches per-feed and honours `configuration['cache_expire']` as a per-feed TTL override. Cache lower-level shared lookups separately if needed. ## 4. Output data contract @@ -226,7 +235,8 @@ Declare in `config/packages/cache.yaml` and inject by name. **Don't** reuse `$fe | External system with its own SDK/client | `src/Feed/BrndFeedType.php` + `src/Feed/SourceType/Brnd/` | | Hydra/REST poster-event source | `src/Feed/EventDatabaseApiV2FeedType.php` + `src/Feed/EventDatabaseApiV2Helper.php` | -**Deprecated — do not use as templates:** `SparkleIOFeedType`, `EventDatabaseApiFeedType`, `KobaFeedType`. +**Removed in 3.0.0:** `SparkleIOFeedType`, `EventDatabaseApiFeedType` (use `EventDatabaseApiV2FeedType`), +and `KobaFeedType` were deprecated in 2.x and removed in 3.0.0 — do not reintroduce them. ## 7. Testing @@ -237,6 +247,7 @@ Add `tests/Feed/MyFeedTypeTest.php`. Pure unit tests (no kernel) are fine for tr `testGetDataErrorIsNotCachedWithNormalTtl` in the same file documents the error-caching contract — read it before tweaking exception handling. Run: + ```shell task test:api -- tests/Feed tests/Service/FeedServiceTest.php task code-analysis diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f1e01c09..4faff68fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Removed the deprecated feed types `SparkleIOFeedType`, `EventDatabaseApiFeedType` and `KobaFeedType`. + Made the unknown-feed-type handling consistent: **reads degrade, writes are rejected.** Feed sources + (and feeds) that reference a removed type keep loading — item and collection reads return them with no + exposed secrets instead of HTTP 500, and the feed data endpoint still returns an empty result — while + creating or updating a feed source with an unknown feed type now returns HTTP 422 (was an opaque 500). + Run the new `app:feed:remove-deprecated-feed-sources` command to review and `--force`-remove the + inert feed sources together with their feeds and slides; `app:update` prints a notice when any exist. + Migrate event database feeds to `EventDatabaseApiV2FeedType`. See `UPGRADE.md`. - Decoupled the dev compose stack from `itkdev-docker`: dropped the wrapper overlays in favour of a self-contained stack with bundled traefik (opt-in via `COMPOSE_PROFILES=traefik`) and a `docker-compose.shared-frontend.yml` overlay for devs keeping a host-level traefik. diff --git a/UPGRADE.md b/UPGRADE.md index cb52e80e8..ad199f463 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -268,3 +268,24 @@ docker compose exec phpfpm bin/console app:screen-layouts:install - Use `--all` option for installing all available templates. - Use `--update` option for updating existing templates. - Use `--cleanupRegions` option for cleaning up regions that are no longer connected to a layout. + +#### 8 - Clean up feed sources using removed feed types + +The feed types `SparkleIOFeedType`, `EventDatabaseApiFeedType` and `KobaFeedType` were deprecated in +2.x and **removed in 3.0**. Unknown feed types are now handled consistently: **reads degrade, writes are +rejected.** Feed sources that still reference a removed type keep loading (item and collection reads +return them with no exposed secrets, and feed data endpoints return empty) but can no longer fetch data; +creating or updating a feed source with such a type returns HTTP 422. `app:update` prints a notice when +any such feed sources exist. + +Review them, then remove them together with their feeds and slides: + +```shell +# Report feed sources referencing a removed feed type (no changes made). +docker compose exec phpfpm bin/console app:feed:remove-deprecated-feed-sources + +# Remove them, their feeds and the slides bound to those feeds. +docker compose exec phpfpm bin/console app:feed:remove-deprecated-feed-sources --force +``` + +Event database feeds should be recreated using `EventDatabaseApiV2FeedType`. diff --git a/assets/admin/components/feed-sources/feed-source-form.jsx b/assets/admin/components/feed-sources/feed-source-form.jsx index 6c004ab8c..67777799c 100644 --- a/assets/admin/components/feed-sources/feed-source-form.jsx +++ b/assets/admin/components/feed-sources/feed-source-form.jsx @@ -9,7 +9,6 @@ import ContentBody from "../util/content-body/content-body"; import FormInput from "../util/forms/form-input"; import CalendarApiFeedType from "./templates/calendar-api-feed-type"; import NotifiedFeedType from "./templates/notified-feed-type"; -import EventDatabaseApiFeedType from "./templates/event-database-feed-type"; import ColiboFeedType from "./templates/colibo-feed-type"; import StickyFooter from "../util/sticky-footer"; import EventDatabaseApiV2FeedType from "./templates/event-database-v2-feed-type"; @@ -133,14 +132,6 @@ function FeedSourceForm({ feedSourceId={feedSource["@id"]} /> )} - {feedSource?.feedType === - "App\\Feed\\EventDatabaseApiFeedType" && ( - - )} {feedSource?.feedType === "App\\Feed\\EventDatabaseApiV2FeedType" && ( { - const { t } = useTranslation("common", { - keyPrefix: - "feed-source-manager.dynamic-fields.event-database-api-feed-type", - }); - return ( - <> - - - ); -}; - -export default EventDatabaseApiTemplate; diff --git a/assets/admin/translations/da/common.json b/assets/admin/translations/da/common.json index de2822ea3..e690bbcd3 100644 --- a/assets/admin/translations/da/common.json +++ b/assets/admin/translations/da/common.json @@ -287,11 +287,6 @@ "title": "BRND" }, "dynamic-fields": { - "event-database-api-feed-type": { - "title": "Eventdatabase API", - "host": "Eventdatabase API host", - "redacted-value-input-placeholder": "Skjult værdi" - }, "notified-feed-type": { "title": "Notified", "token": "Notified API token", diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 274fc66ba..2ac60f9d7 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -63,6 +63,11 @@ api_platform: # Validation exception ApiPlatform\Validator\Exception\ValidationException: !php/const Symfony\Component\HttpFoundation\Response::HTTP_UNPROCESSABLE_ENTITY + # Writing a feed source with a feed type that cannot be resolved (e.g. a + # type removed in 3.0.0) is a validation failure on feedType, not a 500. + # Reads degrade instead of throwing (see FeedSourceProvider::toOutput). + App\Exceptions\UnknownFeedTypeException: !php/const Symfony\Component\HttpFoundation\Response::HTTP_UNPROCESSABLE_ENTITY + # App exception mappings App\Exceptions\BadRequestException: 400 App\Exceptions\ForbiddenException: 403 diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 97136d783..ec306812c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1608,42 +1608,6 @@ parameters: count: 1 path: src/Feed/ColiboFeedType.php - - - message: '#^Method App\\Feed\\EventDatabaseApiFeedType\:\:getAdminFormOptions\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/EventDatabaseApiFeedType.php - - - - message: '#^Method App\\Feed\\EventDatabaseApiFeedType\:\:getConfigOptions\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/EventDatabaseApiFeedType.php - - - - message: '#^Method App\\Feed\\EventDatabaseApiFeedType\:\:getData\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/EventDatabaseApiFeedType.php - - - - message: '#^Method App\\Feed\\EventDatabaseApiFeedType\:\:getRequiredConfiguration\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/EventDatabaseApiFeedType.php - - - - message: '#^Method App\\Feed\\EventDatabaseApiFeedType\:\:getRequiredSecrets\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/EventDatabaseApiFeedType.php - - - - message: '#^Method App\\Feed\\EventDatabaseApiFeedType\:\:getSchema\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/EventDatabaseApiFeedType.php - - message: '#^Method App\\Feed\\EventDatabaseApiV2FeedType\:\:getAdminFormOptions\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -1764,48 +1728,6 @@ parameters: count: 1 path: src/Feed/FeedTypeInterface.php - - - message: '#^Method App\\Feed\\KobaFeedType\:\:getAdminFormOptions\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/KobaFeedType.php - - - - message: '#^Method App\\Feed\\KobaFeedType\:\:getBookingsFromResource\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/KobaFeedType.php - - - - message: '#^Method App\\Feed\\KobaFeedType\:\:getConfigOptions\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/KobaFeedType.php - - - - message: '#^Method App\\Feed\\KobaFeedType\:\:getData\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/KobaFeedType.php - - - - message: '#^Method App\\Feed\\KobaFeedType\:\:getRequiredConfiguration\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/KobaFeedType.php - - - - message: '#^Method App\\Feed\\KobaFeedType\:\:getRequiredSecrets\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/KobaFeedType.php - - - - message: '#^Method App\\Feed\\KobaFeedType\:\:getSchema\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/KobaFeedType.php - - message: '#^Method App\\Feed\\NotifiedFeedType\:\:getAdminFormOptions\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -1932,42 +1854,6 @@ parameters: count: 1 path: src/Feed/SourceType/Colibo/ApiClient.php - - - message: '#^Method App\\Feed\\SparkleIOFeedType\:\:getAdminFormOptions\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/SparkleIOFeedType.php - - - - message: '#^Method App\\Feed\\SparkleIOFeedType\:\:getConfigOptions\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/SparkleIOFeedType.php - - - - message: '#^Method App\\Feed\\SparkleIOFeedType\:\:getData\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/SparkleIOFeedType.php - - - - message: '#^Method App\\Feed\\SparkleIOFeedType\:\:getRequiredConfiguration\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/SparkleIOFeedType.php - - - - message: '#^Method App\\Feed\\SparkleIOFeedType\:\:getRequiredSecrets\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/SparkleIOFeedType.php - - - - message: '#^Method App\\Feed\\SparkleIOFeedType\:\:getSchema\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Feed/SparkleIOFeedType.php - - message: '#^Method App\\Filter\\CampaignFilter\:\:filterProperty\(\) has parameter \$value with no type specified\.$#' identifier: missingType.parameter diff --git a/src/Command/Feed/RemoveDeprecatedFeedSourcesCommand.php b/src/Command/Feed/RemoveDeprecatedFeedSourcesCommand.php new file mode 100644 index 000000000..48b239fbc --- /dev/null +++ b/src/Command/Feed/RemoveDeprecatedFeedSourcesCommand.php @@ -0,0 +1,145 @@ +addOption( + 'force', + null, + InputOption::VALUE_NONE, + 'Actually remove the deprecated feed sources (and their feeds and slides). Without this flag the command only reports.', + ); + } + + final protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $force = (bool) $input->getOption('force'); + + $feedSources = $this->finder->findDeprecated(); + + if (0 === count($feedSources)) { + $io->success('No feed sources reference a deprecated feed type. Nothing to do.'); + + return Command::SUCCESS; + } + + $this->report($io, $feedSources); + + if (!$force) { + $io->note('This was a dry run. Re-run with --force to remove the feed sources, their feeds and the associated slides.'); + + return Command::SUCCESS; + } + + if ($input->isInteractive() && !$io->confirm(sprintf('Remove %d feed source(s) together with their feeds and slides? This cannot be undone.', count($feedSources)), false)) { + $io->info('Aborted. No changes made.'); + + return Command::SUCCESS; + } + + [$removedFeedSources, $removedFeeds, $removedSlides] = $this->remove($feedSources); + + $io->success(sprintf( + 'Removed %d feed source(s), %d feed(s) and %d slide(s).', + $removedFeedSources, + $removedFeeds, + $removedSlides, + )); + + return Command::SUCCESS; + } + + /** + * @param FeedSource[] $feedSources + */ + private function report(SymfonyStyle $io, array $feedSources): void + { + $io->section(sprintf('Found %d feed source(s) referencing a deprecated feed type', count($feedSources))); + + $rows = []; + foreach ($feedSources as $feedSource) { + $feeds = $feedSource->getFeeds(); + $slideCount = 0; + foreach ($feeds as $feed) { + if (null !== $feed->getSlide()) { + ++$slideCount; + } + } + + $rows[] = [ + (string) $feedSource->getId(), + $feedSource->getTitle(), + $feedSource->getTenant()->getTenantKey(), + $feedSource->getFeedType() ?? '', + count($feeds), + $slideCount, + ]; + } + + $io->table(['Feed source', 'Title', 'Tenant', 'Feed type', 'Feeds', 'Slides'], $rows); + } + + /** + * Remove the feed sources together with their feeds and the slides bound to + * those feeds. + * + * Removing a Slide cascades to its PlaylistSlide rows and its Feed (Slide owns + * the relation with cascade: ['remove'] + orphanRemoval — see + * App\Entity\Tenant\Slide). Removing the FeedSource then orphan-removes any + * remaining feeds that had no slide. + * + * @param FeedSource[] $feedSources + * + * @return array{0: int, 1: int, 2: int} removed feed sources, feeds and slides + */ + private function remove(array $feedSources): array + { + $removedFeeds = 0; + $removedSlides = 0; + + foreach ($feedSources as $feedSource) { + foreach ($feedSource->getFeeds() as $feed) { + ++$removedFeeds; + + $slide = $feed->getSlide(); + if (null !== $slide) { + ++$removedSlides; + $this->entityManager->remove($slide); + } + } + + $this->entityManager->remove($feedSource); + } + + $this->entityManager->flush(); + + return [count($feedSources), $removedFeeds, $removedSlides]; + } +} diff --git a/src/Command/UpdateCommand.php b/src/Command/UpdateCommand.php index 55ba3c352..b324c95b1 100644 --- a/src/Command/UpdateCommand.php +++ b/src/Command/UpdateCommand.php @@ -4,6 +4,7 @@ namespace App\Command; +use App\Service\DeprecatedFeedSourceFinder; use App\Service\ScreenLayoutService; use App\Service\TemplateService; use Symfony\Component\Console\Attribute\AsCommand; @@ -23,6 +24,7 @@ class UpdateCommand extends Command public function __construct( private readonly TemplateService $templateService, private readonly ScreenLayoutService $screenLayoutService, + private readonly DeprecatedFeedSourceFinder $deprecatedFeedSourceFinder, ?string $name = null, ) { parent::__construct($name); @@ -53,6 +55,19 @@ final protected function execute(InputInterface $input, OutputInterface $output) return Command::FAILURE; } + // The feed types SparkleIOFeedType, EventDatabaseApiFeedType and + // KobaFeedType were removed in 3.0.0. Existing feed sources referencing + // them are now inert (the read path degrades gracefully, but they no + // longer fetch data). Let the operator know they can be cleaned up. + $deprecatedFeedSourceCount = $this->deprecatedFeedSourceFinder->countDeprecated(); + if ($deprecatedFeedSourceCount > 0) { + $io->note(sprintf( + '%d feed source(s) reference a feed type removed in 3.0.0 and no longer work. ' + .'Run "app:feed:remove-deprecated-feed-sources" to review them, then add --force to remove them (and their feeds and slides).', + $deprecatedFeedSourceCount, + )); + } + $allTemplates = $this->templateService->getAll(); $installedTemplates = array_filter($allTemplates, fn ($entry): bool => $entry->installed); diff --git a/src/Feed/EventDatabaseApiFeedType.php b/src/Feed/EventDatabaseApiFeedType.php deleted file mode 100644 index f24695594..000000000 --- a/src/Feed/EventDatabaseApiFeedType.php +++ /dev/null @@ -1,331 +0,0 @@ -getFeedSource(); - $secrets = $feedSource?->getSecrets(); - $configuration = $feed->getConfiguration(); - - if (!isset($secrets['host'])) { - throw new \RuntimeException('EventDatabaseApiFeedType: Host secret is not set.'); - } - - $host = $secrets['host']; - - if (isset($configuration['posterType'])) { - switch ($configuration['posterType']) { - case 'subscription': - $places = $configuration['subscriptionPlaceValue'] ?? null; - $organizers = $configuration['subscriptionOrganizerValue'] ?? null; - $tags = $configuration['subscriptionTagValue'] ?? null; - $numberOfItems = $configuration['subscriptionNumberValue'] ?? 5; - - $queryParams = [ - 'items_per_page' => $numberOfItems, - ]; - - if (null !== $places) { - $queryParams['occurrences.place.id'] = array_map(static fn (array $place) => str_replace('/api/places/', '', (string) $place['value']), $places); - } - - if (null !== $organizers) { - $queryParams['organizer.id'] = array_map(static fn (array $organizer) => str_replace('/api/organizers/', '', (string) $organizer['value']), $organizers); - } - - if (null !== $tags) { - $queryParams['tags'] = array_map(static fn (array $tag) => str_replace('/api/tags/', '', (string) $tag['value']), $tags); - } - - $response = $this->client->request( - 'GET', - "$host/api/events", - [ - 'query' => $queryParams, - ] - ); - - $content = $response->getContent(); - $decoded = json_decode($content, false, 512, JSON_THROW_ON_ERROR); - - return $decoded->{'hydra:member'}; - case 'single': - if (isset($configuration['singleSelectedOccurrence'])) { - $occurrenceId = $configuration['singleSelectedOccurrence']; - - $response = $this->client->request( - 'GET', - "$host$occurrenceId", - ); - - $content = $response->getContent(); - $decoded = json_decode($content, null, 512, JSON_THROW_ON_ERROR); - - $baseUrl = parse_url((string) $decoded->event->{'url'}, PHP_URL_HOST); - - $eventOccurrence = (object) [ - 'eventId' => $decoded->event->{'@id'}, - 'occurrenceId' => $decoded->{'@id'}, - 'ticketPurchaseUrl' => $decoded->event->{'ticketPurchaseUrl'}, - 'excerpt' => $decoded->event->{'excerpt'}, - 'name' => $decoded->event->{'name'}, - 'url' => $decoded->event->{'url'}, - 'baseUrl' => $baseUrl, - 'image' => $decoded->event->{'image'}, - 'startDate' => $decoded->{'startDate'}, - 'endDate' => $decoded->{'endDate'}, - 'ticketPriceRange' => $decoded->{'ticketPriceRange'}, - 'eventStatusText' => $decoded->{'eventStatusText'}, - ]; - - if (isset($decoded->place)) { - $eventOccurrence->place = (object) [ - 'name' => $decoded->place->name, - 'streetAddress' => $decoded->place->streetAddress, - 'addressLocality' => $decoded->place->addressLocality, - 'postalCode' => $decoded->place->postalCode, - 'image' => $decoded->place->image, - 'telephone' => $decoded->place->telephone, - ]; - } - - return [$eventOccurrence]; - } - - throw new \RuntimeException('EventDatabaseApiFeedType: singleSelectedOccurrence is not set.'); - default: - throw new \RuntimeException(sprintf('EventDatabaseApiFeedType: Unsupported posterType "%s".', $configuration['posterType'])); - } - } - - throw new \RuntimeException('EventDatabaseApiFeedType: posterType is not set.'); - } catch (\Throwable $throwable) { - // If the content does not exist anymore, unpublished the slide. - if ($throwable instanceof ClientException && Response::HTTP_NOT_FOUND == $throwable->getCode()) { - try { - // Slide publishedTo is set to now. This will make the slide unpublished from this point on. - $slide = $feed->getSlide()?->setPublishedTo(new \DateTime('now', new \DateTimeZone('UTC'))); - - $this->entityManager->flush(); - - $this->logger->info('Feed with id: {feedId} depends on an item that does not exist in Event Database. Unpublished slide with id: {slideId}', [ - 'feedId' => $feed->getId(), - 'slideId' => $slide?->getId(), - ]); - } catch (\Exception $exception) { - $this->logger->error('{code}: {message}', [ - 'code' => $exception->getCode(), - 'message' => $exception->getMessage(), - ]); - } - } else { - $this->logger->error('{code}: {message}', [ - 'code' => $throwable->getCode(), - 'message' => $throwable->getMessage(), - ]); - } - - throw $throwable; - } - } - - /** - * {@inheritDoc} - */ - public function getAdminFormOptions(FeedSource $feedSource): array - { - $searchEndpoint = $this->feedService->getFeedSourceConfigUrl($feedSource, 'search'); - $endpointEntity = $this->feedService->getFeedSourceConfigUrl($feedSource, 'entity'); - - // @TODO: Translation. - return [ - [ - 'key' => 'poster-selector', - 'input' => 'poster-selector', - 'endpointSearch' => $searchEndpoint, - 'endpointEntity' => $endpointEntity, - 'name' => 'resources', - 'label' => 'Vælg resurser', - 'helpText' => 'Her vælger du hvilke resourcer der skal hentes indgange fra.', - 'formGroupClasses' => 'col-md-6 mb-3', - ], - ]; - } - - /** - * {@inheritDoc} - */ - public function getConfigOptions(Request $request, FeedSource $feedSource, string $name): ?array - { - try { - $secrets = $feedSource->getSecrets(); - - if (!isset($secrets['host'])) { - return []; - } - - $host = $secrets['host']; - - if ('entity' === $name) { - $path = $request->query->get('path'); - $response = $this->client->request( - 'GET', - "$host$path", - ); - - $content = $response->getContent(); - - return json_decode($content, true, 512, JSON_THROW_ON_ERROR); - } elseif ('search' === $name) { - $queryParams = $request->query->all(); - $type = $queryParams['type']; - $displayAsOptions = isset($queryParams['display']) && 'options' == $queryParams['display']; - - unset($queryParams['type']); - - if ($displayAsOptions) { - unset($queryParams['display']); - } - - if ('events' == $type) { - if (isset($queryParams['tag'])) { - $tag = $queryParams['tag']; - unset($queryParams['tag']); - $queryParams['tags'] = $tag; - } - - if (isset($queryParams['organizer'])) { - $organizer = $queryParams['organizer']; - unset($queryParams['organizer']); - $queryParams['organizer.id'] = $organizer; - } - - if (isset($queryParams['place'])) { - $place = $queryParams['place']; - unset($queryParams['place']); - $queryParams['occurrences.place.id'] = $place; - } - } - - $queryParams['occurrences.endDate'] = ['after' => date('Y-m-d')]; - - !isset($queryParams['items_per_page']) && $queryParams['items_per_page'] = 10; - - $response = $this->client->request( - 'GET', - "$host/api/$type", - [ - 'query' => $queryParams, - ] - ); - - $content = $response->getContent(); - $decoded = json_decode($content, null, 512, JSON_THROW_ON_ERROR); - - $members = $decoded->{'hydra:member'}; - - $result = []; - - foreach ($members as $member) { - // Special handling of searching in tags, since EventDatabaseApi does not support this. - if ('tags' == $type) { - if (!isset($queryParams['name']) || str_contains(strtolower((string) $member->name), strtolower((string) $queryParams['name']))) { - $result[] = $displayAsOptions ? [ - 'label' => $member->name, - 'value' => $member->{'@id'}, - ] : $member; - } - } else { - $result[] = $displayAsOptions ? [ - 'label' => $member->name, - 'value' => $member->{'@id'}, - ] : $member; - } - } - - return $result; - } - } catch (\Throwable $throwable) { - $this->logger->error('{code}: {message}', [ - 'code' => $throwable->getCode(), - 'message' => $throwable->getMessage(), - ]); - } - - return null; - } - - /** - * {@inheritDoc} - */ - public function getRequiredSecrets(): array - { - return [ - 'host' => [ - 'type' => 'string', - 'exposeValue' => true, - ], - ]; - } - - /** - * {@inheritDoc} - */ - public function getRequiredConfiguration(): array - { - return []; - } - - /** - * {@inheritDoc} - */ - public function getSupportedFeedOutputType(): string - { - return self::SUPPORTED_FEED_TYPE; - } - - public function getSchema(): array - { - return [ - '$schema' => 'http://json-schema.org/draft-04/schema#', - 'type' => 'object', - 'properties' => [ - 'host' => [ - 'type' => 'string', - 'format' => 'uri', - ], - ], - 'required' => ['host'], - ]; - } -} diff --git a/src/Feed/KobaFeedType.php b/src/Feed/KobaFeedType.php deleted file mode 100644 index 91b40c9cf..000000000 --- a/src/Feed/KobaFeedType.php +++ /dev/null @@ -1,281 +0,0 @@ -getFeedSource(); - $secrets = $feedSource?->getSecrets(); - $configuration = $feed->getConfiguration(); - - if (!isset($secrets['kobaHost']) || !isset($secrets['kobaApiKey'])) { - throw new \RuntimeException('KobaFeedType: "Host" and "ApiKey" not configured.'); - } - - $kobaHost = $secrets['kobaHost']; - $kobaApiKey = $secrets['kobaApiKey']; - $kobaGroup = $secrets['kobaGroup'] ?? 'default'; - $filterList = $configuration['filterList'] ?? false; - $rewriteBookedTitles = $configuration['rewriteBookedTitles'] ?? false; - - if (!isset($configuration['resources'])) { - throw new \RuntimeException('KobaFeedType: Resources not set.'); - } - - $resources = $configuration['resources']; - - // Round down to the nearest hour. - $from = time() - (time() % 3600); - - // Get bookings for the coming week. - // @TODO: Support for configuring interest period. - $to = $from + 7 * 24 * 60 * 60; - - foreach ($resources as $resource) { - try { - $bookings = $this->getBookingsFromResource($kobaHost, $kobaApiKey, $resource, $kobaGroup, $from, $to); - } catch (\Throwable $throwable) { - $this->logger->error('KobaFeedType: Get bookings from resources failed. Code: {code}, Message: {message}', [ - 'code' => $throwable->getCode(), - 'message' => $throwable->getMessage(), - ]); - continue; - } - - foreach ($bookings as $booking) { - $title = $booking['event_name'] ?? ''; - - if (!is_string($title)) { - $this->logger->error('KobaFeedType: event_name is not string.'); - - throw new \InvalidArgumentException('Koba event_name is not string'); - } - - // Apply list filter. If enabled it removes all events that do not have (liste) in title. - if (true === $filterList) { - if (!str_contains($title, '(liste)')) { - continue; - } - $title = str_replace('(liste)', '', $title); - } - - // Apply booked title override. If enabled it changes the title to Optaget if it contains (optaget). - if (true === $rewriteBookedTitles) { - if (str_contains($title, '(optaget)')) { - $title = 'Optaget'; - } - } - - $results[] = [ - 'id' => Ulid::generate(), - 'title' => $title, - 'description' => $booking['event_description'] ?? '', - 'startTime' => $booking['start_time'] ?? '', - 'endTime' => $booking['end_time'] ?? '', - 'resourceTitle' => $booking['resource_alias'] ?? '', - 'resourceId' => $booking['resource_id'] ?? '', - ]; - } - } - - // Sort bookings by start time. - usort($results, fn ($a, $b) => strcmp((string) $a['startTime'], (string) $b['startTime'])); - - return $results; - } catch (\Throwable $throwable) { - $this->logger->error('{code}: {message}', [ - 'code' => $throwable->getCode(), - 'message' => $throwable->getMessage(), - ]); - - throw $throwable; - } - } - - /** - * {@inheritDoc} - */ - public function getAdminFormOptions(FeedSource $feedSource): array - { - $endpoint = $this->feedService->getFeedSourceConfigUrl($feedSource, 'resources'); - - // @TODO: Translation. - return [ - [ - 'key' => 'koba-resource-selector', - 'input' => 'multiselect-from-endpoint', - 'endpoint' => $endpoint, - 'name' => 'resources', - 'label' => 'Vælg resurser', - 'helpText' => 'Her vælger du hvilke resourcer der skal hentes indgange fra.', - 'formGroupClasses' => 'col-md-6 mb-3', - ], - [ - 'key' => 'koba-resource-rewrite-booked', - 'input' => 'checkbox', - 'name' => 'rewriteBookedTitles', - 'label' => 'Omskriv titler med (optaget)', - 'helpText' => 'Denne mulighed gør at titler som indeholder (optaget) bliver omskrevet til "Optaget".', - 'formGroupClasses' => 'col mb-3', - ], - [ - 'key' => 'koba-resource-filter-not-list', - 'input' => 'checkbox', - 'name' => 'filterList', - 'label' => 'Vis kun begivenheder med (liste) i titlen', - 'helpText' => 'Denne mulighed fjerner begivenheder der IKKE har (liste) i titlen. Den fjerner også (liste) fra titlen.', - 'formGroupClasses' => 'col mb-3', - ], - ]; - } - - /** - * {@inheritDoc} - */ - public function getConfigOptions(Request $request, FeedSource $feedSource, string $name): ?array - { - try { - if ('resources' === $name) { - $secrets = $feedSource->getSecrets(); - - if (!isset($secrets['kobaHost']) || !isset($secrets['kobaApiKey'])) { - return []; - } - - $kobaHost = $secrets['kobaHost']; - $kobaApiKey = $secrets['kobaApiKey']; - $kobaGroup = $secrets['kobaGroup'] ?? 'default'; - - $requestUrl = "$kobaHost/api/resources/group/$kobaGroup"; - - $response = $this->client->request('GET', $requestUrl, [ - 'query' => [ - 'apikey' => $kobaApiKey, - ], - ]); - - $content = $response->toArray(); - - $resources = []; - - foreach ($content as $entry) { - // Ignore entries without mail. - if (empty($entry['mail'])) { - continue; - } - - $mail = $entry['mail']; - $alias = !empty($entry['alias']) ? " ({$entry['alias']})" : ''; - $name = $entry['name'] ?? $mail; - - // Make sure a title has been set. - $title = $name.$alias; - - $resources[] = [ - 'id' => Ulid::generate(), - 'title' => $title, - 'value' => $entry['mail'], - ]; - } - - usort($resources, fn ($a, $b) => strcmp((string) $a['title'], (string) $b['title'])); - - return $resources; - } - } catch (\Throwable $throwable) { - $this->logger->error('{code}: {message}', [ - 'code' => $throwable->getCode(), - 'message' => $throwable->getMessage(), - ]); - } - - return null; - } - - public function getRequiredSecrets(): array - { - return [ - 'kobaHost' => [ - 'type' => 'string', - 'exposeValue' => true, - ], - 'kobaApiKey' => [ - 'type' => 'string', - ], - ]; - } - - public function getRequiredConfiguration(): array - { - return ['resources']; - } - - public function getSupportedFeedOutputType(): string - { - return self::SUPPORTED_FEED_TYPE; - } - - /** - * @return array - * - * @throws \Throwable - */ - private function getBookingsFromResource(string $host, string $apikey, string $resource, string $group, int $from, int $to): array - { - $requestUrl = "$host/api/resources/$resource/group/$group/bookings/from/$from/to/$to"; - - $response = $this->client->request('GET', $requestUrl, [ - 'query' => [ - 'apikey' => $apikey, - ], - ]); - - return $response->toArray(); - } - - public function getSchema(): array - { - return [ - '$schema' => 'http://json-schema.org/draft-04/schema#', - 'type' => 'object', - 'properties' => [ - 'kobaHost' => [ - 'type' => 'string', - ], - 'kobaApiKey' => [ - 'type' => 'string', - ], - ], - 'required' => ['kobaHost', 'kobaApiKey'], - ]; - } -} diff --git a/src/Feed/SparkleIOFeedType.php b/src/Feed/SparkleIOFeedType.php deleted file mode 100644 index 7edf742fb..000000000 --- a/src/Feed/SparkleIOFeedType.php +++ /dev/null @@ -1,314 +0,0 @@ -getFeedSource()?->getSecrets(); - if (!isset($secrets['baseUrl']) || !isset($secrets['clientId']) || !isset($secrets['clientSecret'])) { - throw new \RuntimeException('SparkleIOFeedType: Required secrets (baseUrl, clientId, clientSecret) are not set.'); - } - - $configuration = $feed->getConfiguration(); - if (!isset($configuration['feeds']) || 0 === count($configuration['feeds'])) { - throw new \RuntimeException('SparkleIOFeedType: Feeds configuration is not set.'); - } - - $baseUrl = $secrets['baseUrl']; - $clientId = $secrets['clientId']; - $clientSecret = $secrets['clientSecret']; - $token = $this->getToken($baseUrl, $clientId, $clientSecret); - - $res = $this->client->request( - 'GET', - $baseUrl.'v0.1/feed/'.$configuration['feeds'][0], - [ - 'headers' => [ - 'Authorization' => sprintf('Bearer %s', $token), - ], - ] - ); - - $contents = $res->getContent(); - $data = json_decode($contents, false, 512, JSON_THROW_ON_ERROR); - - $res = []; - foreach ($data->items as $item) { - $res[] = $this->getFeedItemObject($item); - } - - return $res; - } catch (\Throwable $throwable) { - $this->logger->error('{code}: {message}', [ - 'code' => $throwable->getCode(), - 'message' => $throwable->getMessage(), - ]); - - throw $throwable; - } - } - - /** - * {@inheritDoc} - */ - public function getAdminFormOptions(FeedSource $feedSource): array - { - $endpoint = $this->feedService->getFeedSourceConfigUrl($feedSource, 'feeds'); - - // @TODO: Translation. - return [ - [ - 'key' => 'sparkle-io-selector', - 'input' => 'multiselect-from-endpoint', - 'endpoint' => $endpoint, - 'name' => 'feeds', - 'label' => 'Vælg feed', - 'helpText' => 'Her vælger du hvilket feed der skal hentes indgange fra.', - 'formGroupClasses' => 'col-md-6 mb-3', - ], - ]; - } - - /** - * {@inheritDoc} - */ - public function getConfigOptions(Request $request, FeedSource $feedSource, string $name): ?array - { - try { - if ('feeds' === $name) { - $secrets = $feedSource->getSecrets(); - - if (!isset($secrets['baseUrl']) || !isset($secrets['clientId']) || !isset($secrets['clientSecret'])) { - return []; - } - - $baseUrl = $secrets['baseUrl']; - $clientId = $secrets['clientId']; - $clientSecret = $secrets['clientSecret']; - - $token = $this->getToken($baseUrl, $clientId, $clientSecret); - - $response = $this->client->request( - 'GET', - $baseUrl.'v0.1/feed', - [ - 'headers' => [ - 'Authorization' => sprintf('Bearer %s', $token), - ], - ] - ); - - $contents = $response->getContent(); - - $items = json_decode($contents, null, 512, JSON_THROW_ON_ERROR); - - $feeds = []; - - foreach ($items as $item) { - $feeds[] = [ - 'id' => Ulid::generate(), - 'title' => $item->name ?? '', - 'value' => $item->id ?? '', - ]; - } - - return $feeds; - } - } catch (\Throwable $throwable) { - $this->logger->error('{code}: {message}', [ - 'code' => $throwable->getCode(), - 'message' => $throwable->getMessage(), - ]); - } - - return null; - } - - /** - * {@inheritDoc} - */ - public function getRequiredSecrets(): array - { - return [ - 'baseUrl' => [ - 'type' => 'string', - 'exposeValue' => true, - ], - 'clientId' => [ - 'type' => 'string', - ], - 'clientSecret' => [ - 'type' => 'string', - ], - ]; - } - - /** - * {@inheritDoc} - */ - public function getRequiredConfiguration(): array - { - return ['feeds']; - } - - /** - * {@inheritDoc} - */ - public function getSupportedFeedOutputType(): string - { - return self::SUPPORTED_FEED_TYPE; - } - - /** - * Get oAuth token. - * - * @return string - * - * @throws RedirectionExceptionInterface - * @throws ServerExceptionInterface - * @throws TransportExceptionInterface - * @throws ClientExceptionInterface - * @throws \JsonException|InvalidArgumentException - */ - private function getToken(string $baseUrl, string $clientId, string $clientSecret): string - { - /** @var CacheItemInterface $cacheItem */ - $cacheItem = $this->feedsCache->getItem('sparkleio-token'); - - if ($cacheItem->isHit()) { - /** @var string $token */ - $token = $cacheItem->get(); - } else { - $response = $this->client->request( - 'POST', - $baseUrl.'oauth/token', - [ - 'headers' => [ - 'Content-Type' => 'application/x-www-form-urlencoded', - ], - 'body' => [ - 'grant_type' => urlencode('client_credentials'), - 'client_id' => urlencode($clientId), - 'client_secret' => urlencode($clientSecret), - ], - ] - ); - - $content = $response->getContent(); - $contentDecoded = json_decode($content, false, 512, JSON_THROW_ON_ERROR); - - $token = $contentDecoded->access_token; - $expireSeconds = intval($contentDecoded->expires_in / 1000 - 30); - - $cacheItem->set($token); - $cacheItem->expiresAfter($expireSeconds); - } - - return $token; - } - - /** - * Parse feed item into object. - * - * @return object - */ - private function getFeedItemObject(object $item): object - { - return (object) [ - 'text' => $item->text, - 'textMarkup' => null !== $item->text ? $this->wrapTags($item->text) : null, - 'mediaUrl' => $item->mediaUrl, - 'videoUrl' => $item->videoUrl, - 'username' => $item->username, - 'createdTime' => $item->createdTime, - ]; - } - - /** - * @return string - */ - private function wrapTags(string $input): string - { - $text = trim($input); - - // Strip unicode zero-width-space. - $text = str_replace("\xE2\x80\x8B", '', $text); - - // Collects trailing tags one by one. - $trailingTags = []; - $pattern = "/\s*#(?[^\s#]+)\n?$/u"; - while (preg_match($pattern, (string) $text, $matches)) { - // We're getting tags in reverse order. - array_unshift($trailingTags, $matches['tag']); - $text = preg_replace($pattern, '', (string) $text); - } - - // Wrap sections in p tags. - $text = preg_replace("/(.+)\n?/u", '

\1

', (string) $text); - - // Wrap inline tags. - $pattern = '/(#(?[^\s#]+))/'; - $text = '
'.preg_replace($pattern, - '\1', (string) $text).'
'; - // Append tags. - $text .= PHP_EOL.'
'.implode(' ', - array_map(fn ($tag) => '#'.$tag.'', $trailingTags)).'
'; - - return $text; - } - - public function getSchema(): array - { - return [ - '$schema' => 'http://json-schema.org/draft-04/schema#', - 'type' => 'object', - 'properties' => [ - 'baseUrl' => [ - 'type' => 'string', - ], - 'clientId' => [ - 'type' => 'string', - ], - 'clientSecret' => [ - 'type' => 'string', - ], - ], - 'required' => ['baseUrl', 'clientId', 'clientSecret'], - ]; - } -} diff --git a/src/Service/DeprecatedFeedSourceFinder.php b/src/Service/DeprecatedFeedSourceFinder.php new file mode 100644 index 000000000..0f92f5bbd --- /dev/null +++ b/src/Service/DeprecatedFeedSourceFinder.php @@ -0,0 +1,56 @@ + + */ + public const array DEPRECATED_FEED_TYPES = [ + 'App\\Feed\\SparkleIOFeedType', + 'App\\Feed\\EventDatabaseApiFeedType', + 'App\\Feed\\KobaFeedType', + ]; + + public function __construct( + private readonly FeedSourceRepository $feedSourceRepository, + ) {} + + /** + * Find all feed sources referencing a deprecated feed type, across all tenants. + * + * Uses the repository directly (not the API Platform state layer), so the + * tenant scoping applied via App\Filter\TenantExtension does not restrict the + * result — every tenant's rows are returned. + * + * @return FeedSource[] + */ + public function findDeprecated(): array + { + return $this->feedSourceRepository->findBy(['feedType' => self::DEPRECATED_FEED_TYPES]); + } + + public function countDeprecated(): int + { + return count($this->findDeprecated()); + } +} diff --git a/src/State/FeedSourceProvider.php b/src/State/FeedSourceProvider.php index 547a428d4..7a4628905 100644 --- a/src/State/FeedSourceProvider.php +++ b/src/State/FeedSourceProvider.php @@ -9,6 +9,7 @@ use App\Dto\FeedSource as FeedSourceDTO; use App\Entity\Tenant\Feed; use App\Entity\Tenant\FeedSource; +use App\Exceptions\UnknownFeedTypeException; use App\Repository\FeedSourceRepository; use App\Service\FeedService; @@ -48,15 +49,26 @@ public function toOutput(object $object): FeedSourceDTO $feedTypeString = $object->getFeedType(); if (null !== $feedTypeString) { - $feedType = $this->feedService->getFeedType($feedTypeString); - $feedTypeSecretsArray = $feedType->getRequiredSecrets(); + try { + $feedType = $this->feedService->getFeedType($feedTypeString); + $feedTypeSecretsArray = $feedType->getRequiredSecrets(); - foreach ($object->getSecrets() ?? [] as $key => $secret) { - if (isset($feedTypeSecretsArray[$key])) { - if (isset($feedTypeSecretsArray[$key]['exposeValue']) && true === $feedTypeSecretsArray[$key]['exposeValue']) { - $secrets[$key] = $secret; + foreach ($object->getSecrets() ?? [] as $key => $secret) { + if (isset($feedTypeSecretsArray[$key])) { + if (isset($feedTypeSecretsArray[$key]['exposeValue']) && true === $feedTypeSecretsArray[$key]['exposeValue']) { + $secrets[$key] = $secret; + } } } + } catch (UnknownFeedTypeException) { + // Policy: reads degrade, writes reject. The stored feed type is no + // longer registered (e.g. a type removed in 3.0.0), so expose no + // secrets rather than failing the read with an HTTP 500 — the feed + // source (and any feed embedding it) stays listable and removable. + // The write path still rejects an unknown feedType: FeedSourceProcessor + // lets UnknownFeedTypeException propagate, mapped to 422 via + // exception_to_status in config/packages/api_platform.yaml. + $secrets = []; } } diff --git a/tests/Api/FeedSourceTest.php b/tests/Api/FeedSourceTest.php index f72e32618..c94047de9 100644 --- a/tests/Api/FeedSourceTest.php +++ b/tests/Api/FeedSourceTest.php @@ -60,9 +60,10 @@ public function testCreateFeedSource(): void 'json' => [ 'title' => 'Test feed source', 'description' => 'This is a test feed source', - 'feedType' => \App\Feed\EventDatabaseApiFeedType::class, + 'feedType' => \App\Feed\EventDatabaseApiV2FeedType::class, 'secrets' => [ 'host' => 'https://www.test.dk', + 'apikey' => 'test-api-key', ], ], 'headers' => [ @@ -77,7 +78,7 @@ public function testCreateFeedSource(): void '@type' => 'FeedSource', 'title' => 'Test feed source', 'description' => 'This is a test feed source', - 'feedType' => \App\Feed\EventDatabaseApiFeedType::class, + 'feedType' => \App\Feed\EventDatabaseApiV2FeedType::class, 'secrets' => [ 'host' => 'https://www.test.dk', ], @@ -146,7 +147,7 @@ public function testCreateFeedSourceWithEventDatabaseFeedTypeWithoutRequiredSecr 'json' => [ 'title' => 'Test feed source', 'outputType' => 'This is a test output type', - 'feedType' => \App\Feed\EventDatabaseApiFeedType::class, + 'feedType' => \App\Feed\EventDatabaseApiV2FeedType::class, 'secrets' => [ 'test secret', ], @@ -171,7 +172,7 @@ public function testUpdateFeedSource(): void 'title' => 'Updated title', 'description' => 'Updated description', 'outputType' => 'This is a test output type', - 'feedType' => \App\Feed\EventDatabaseApiFeedType::class, + 'feedType' => \App\Feed\EventDatabaseApiV2FeedType::class, 'secrets' => [ ], ], @@ -198,9 +199,10 @@ public function testDeleteFeedSource(): void 'title' => 'Test feed source', 'description' => 'This is a test feed source', 'outputType' => 'This is a test output type', - 'feedType' => \App\Feed\EventDatabaseApiFeedType::class, + 'feedType' => \App\Feed\EventDatabaseApiV2FeedType::class, 'secrets' => [ 'host' => 'https://www.test.dk', + 'apikey' => 'test-api-key', ], ], 'headers' => [ @@ -247,4 +249,112 @@ public function testDeleteFeedSourceInUse(): void $manager->getRepository(FeedSource::class)->findOneBy(['id' => $ulid]) ); } + + /** + * A feed source whose stored feed type was removed (e.g. a deprecated type + * dropped in 3.0.0) must still be readable. The provider degrades gracefully + * instead of throwing UnknownFeedTypeException and returning HTTP 500, so the + * row stays listable and can be cleaned up. + */ + public function testGetItemWithRemovedFeedTypeDoesNotError(): void + { + $client = $this->getAuthenticatedClient('ROLE_ADMIN'); + + $manager = static::getContainer()->get('doctrine')->getManager(); + $tenant = $manager->getRepository(\App\Entity\Tenant::class)->findOneBy(['tenantKey' => $this->tenant->getTenantKey()]); + + $feedSource = new FeedSource(); + $feedSource->setTitle('Feed source with removed feed type'); + $feedSource->setDescription('References a feed type that no longer exists'); + $feedSource->setFeedType('App\\Feed\\KobaFeedType'); + $feedSource->setSupportedFeedOutputType('calendar'); + $feedSource->setSecrets(['kobaApiKey' => 'secret-value']); + $feedSource->setTenant($tenant); + $manager->persist($feedSource); + $manager->flush(); + + $iri = $this->findIriBy(FeedSource::class, ['id' => $feedSource->getId()]); + $response = $client->request('GET', $iri, ['headers' => ['Content-Type' => 'application/ld+json']]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@type' => 'FeedSource', + 'feedType' => 'App\\Feed\\KobaFeedType', + ]); + + // No secrets are exposed for an unresolvable feed type. + $this->assertSame([], $response->toArray()['secrets']); + } + + /** + * Writing a feed source with a feed type that cannot be resolved is rejected + * with 422 (mapped from UnknownFeedTypeException), not a 500. This is the + * "reject writes" half of the policy. + */ + public function testCreateFeedSourceWithUnknownFeedTypeIsRejected(): void + { + $client = $this->getAuthenticatedClient('ROLE_ADMIN'); + + $client->request('POST', '/v2/feed-sources', [ + 'json' => [ + 'title' => 'Feed source with unknown feed type', + 'description' => 'References a feed type that no longer exists', + 'feedType' => 'App\\Feed\\KobaFeedType', + 'secrets' => [], + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(422); + } + + /** + * Reading a Feed whose feed source references a removed feed type must also + * degrade (no 500). FeedProvider embeds the feed source via + * FeedSourceProvider::toOutput, which catches the unknown type. + */ + public function testGetFeedWithRemovedFeedTypeDoesNotError(): void + { + $client = $this->getAuthenticatedClient('ROLE_ADMIN'); + + $manager = static::getContainer()->get('doctrine')->getManager(); + $tenant = $manager->getRepository(\App\Entity\Tenant::class)->findOneBy(['tenantKey' => $this->tenant->getTenantKey()]); + $template = $manager->getRepository(\App\Entity\Template::class)->findOneBy([]); + + $feedSource = new FeedSource(); + $feedSource->setTitle('Feed source with removed feed type (feed read)'); + $feedSource->setDescription('References a feed type that no longer exists'); + $feedSource->setFeedType('App\\Feed\\KobaFeedType'); + $feedSource->setSupportedFeedOutputType('calendar'); + $feedSource->setSecrets(['kobaApiKey' => 'secret-value']); + $feedSource->setTenant($tenant); + $manager->persist($feedSource); + + $slide = new Slide(); + $slide->setTitle('Slide for removed-feed-type feed'); + $slide->setTemplate($template); + $slide->setPublishedFrom(null); + $slide->setPublishedTo(null); + $slide->setTenant($tenant); + $manager->persist($slide); + + $feed = new Feed(); + $feed->setFeedSource($feedSource); + $feed->setTenant($tenant); + $manager->persist($feed); + $slide->setFeed($feed); + + $manager->flush(); + + $iri = $this->findIriBy(Feed::class, ['id' => $feed->getId()]); + $client->request('GET', $iri, ['headers' => ['Content-Type' => 'application/ld+json']]); + + // The feed read embeds the feed source via FeedSourceProvider::toOutput; + // an unresolvable feed type must degrade there, so the read returns 200 + // (the embedded feed source is referenced as an IRI in the Feed payload). + $this->assertResponseIsSuccessful(); + $this->assertJsonContains(['@type' => 'Feed']); + } } diff --git a/tests/Command/Feed/RemoveDeprecatedFeedSourcesCommandTest.php b/tests/Command/Feed/RemoveDeprecatedFeedSourcesCommandTest.php new file mode 100644 index 000000000..e4c65c7e0 --- /dev/null +++ b/tests/Command/Feed/RemoveDeprecatedFeedSourcesCommandTest.php @@ -0,0 +1,126 @@ +createDeprecatedFeedSourceGraph('dry-run feed source'); + + $tester = $this->runCommand([]); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('dry-run feed source', $output); + $this->assertStringContainsString('dry run', $output); + + // Nothing should have been removed. + $this->assertNotNull($this->entityManager()->getRepository(FeedSource::class)->find($feedSourceId)); + } + + public function testForceRemovesFeedSourceFeedAndSlide(): void + { + [$feedSourceId, $feedId, $slideId, $playlistSlideId] = $this->createDeprecatedFeedSourceGraph('force feed source'); + + $tester = $this->runCommand(['--force' => true]); + $this->assertSame(0, $tester->getStatusCode()); + + $em = $this->entityManager(); + $em->clear(); + + // The feed source, its feed, the bound slide and the slide's playlist + // relation must all be gone (Slide cascades to Feed + PlaylistSlide). + $this->assertNull($em->getRepository(FeedSource::class)->find($feedSourceId), 'feed source removed'); + $this->assertNull($em->getRepository(Feed::class)->find($feedId), 'feed removed'); + $this->assertNull($em->getRepository(Slide::class)->find($slideId), 'slide removed'); + $this->assertNull($em->getRepository(PlaylistSlide::class)->find($playlistSlideId), 'playlist slide removed'); + } + + /** + * Build FeedSource -> Feed -> Slide -> PlaylistSlide referencing a removed feed type. + * + * @return array{0: Ulid, 1: Ulid, 2: Ulid, 3: Ulid} + */ + private function createDeprecatedFeedSourceGraph(string $title): array + { + $em = $this->entityManager(); + + $tenant = $em->getRepository(Tenant::class)->findOneBy(['tenantKey' => 'ABC']); + $this->assertInstanceOf(Tenant::class, $tenant); + $template = $em->getRepository(Template::class)->findOneBy([]); + $this->assertInstanceOf(Template::class, $template); + + $feedSource = new FeedSource(); + $feedSource->setTitle($title); + $feedSource->setDescription('Deprecated feed source for cleanup test'); + $feedSource->setFeedType(self::DEPRECATED_FEED_TYPE); + $feedSource->setSupportedFeedOutputType('calendar'); + $feedSource->setTenant($tenant); + $em->persist($feedSource); + + $slide = new Slide(); + $slide->setTitle($title.' slide'); + $slide->setTemplate($template); + $slide->setTenant($tenant); + $em->persist($slide); + + $feed = new Feed(); + $feed->setFeedSource($feedSource); + $feed->setTenant($tenant); + $em->persist($feed); + + // Slide owns the relation to Feed. + $slide->setFeed($feed); + + $playlist = new Playlist(); + $playlist->setTitle($title.' playlist'); + $playlist->setTenant($tenant); + $em->persist($playlist); + + $playlistSlide = new PlaylistSlide(); + $playlistSlide->setPlaylist($playlist); + $playlistSlide->setSlide($slide); + $playlistSlide->setTenant($tenant); + $em->persist($playlistSlide); + + $em->flush(); + + $ids = [$feedSource->getId(), $feed->getId(), $slide->getId(), $playlistSlide->getId()]; + $em->clear(); + + return $ids; + } + + private function runCommand(array $input): CommandTester + { + $application = new Application(self::$kernel); + $command = $application->find('app:feed:remove-deprecated-feed-sources'); + $tester = new CommandTester($command); + // Run non-interactively so --force skips the confirmation prompt. + $tester->execute($input, ['interactive' => false]); + + return $tester; + } + + private function entityManager(): EntityManagerInterface + { + return static::getContainer()->get(EntityManagerInterface::class); + } +} diff --git a/tests/Service/FeedServiceTest.php b/tests/Service/FeedServiceTest.php index 21f68cbfa..fd66c2a21 100644 --- a/tests/Service/FeedServiceTest.php +++ b/tests/Service/FeedServiceTest.php @@ -7,12 +7,10 @@ use App\Entity\Tenant\Feed; use App\Entity\Tenant\FeedSource; use App\Feed\CalendarApiFeedType; -use App\Feed\EventDatabaseApiFeedType; +use App\Feed\EventDatabaseApiV2FeedType; use App\Feed\FeedTypeInterface; -use App\Feed\KobaFeedType; use App\Feed\NotifiedFeedType; use App\Feed\RssFeedType; -use App\Feed\SparkleIOFeedType; use App\Service\FeedService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -38,11 +36,15 @@ public function testGetFeedTypes(): void { $feedTypes = $this->feedService->getFeedTypes(); $this->assertTrue(in_array(CalendarApiFeedType::class, $feedTypes)); - $this->assertTrue(in_array(EventDatabaseApiFeedType::class, $feedTypes)); - $this->assertTrue(in_array(KobaFeedType::class, $feedTypes)); + $this->assertTrue(in_array(EventDatabaseApiV2FeedType::class, $feedTypes)); $this->assertTrue(in_array(NotifiedFeedType::class, $feedTypes)); $this->assertTrue(in_array(RssFeedType::class, $feedTypes)); - $this->assertTrue(in_array(SparkleIOFeedType::class, $feedTypes)); + + // Feed types removed in 3.0.0 must no longer be registered (compared as + // strings since the classes no longer exist). + $this->assertNotContains('App\Feed\SparkleIOFeedType', $feedTypes); + $this->assertNotContains('App\Feed\EventDatabaseApiFeedType', $feedTypes); + $this->assertNotContains('App\Feed\KobaFeedType', $feedTypes); } public function testGetFeedUrl(): void