[^\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