diff --git a/src/bundle/Resources/config/services.yaml b/src/bundle/Resources/config/services.yaml index f2b92354..ec128802 100644 --- a/src/bundle/Resources/config/services.yaml +++ b/src/bundle/Resources/config/services.yaml @@ -58,6 +58,8 @@ services: - '@ibexa.api.service.content' - '@ibexa.api.service.location' - '@router' + - '@ibexa.config.resolver' + - '@Ibexa\Contracts\Core\Repository\Strategy\ContentPublication\ContentPublicationStrategyInterface' tags: - { name: kernel.event_subscriber } diff --git a/src/lib/Form/Processor/ContentFormProcessor.php b/src/lib/Form/Processor/ContentFormProcessor.php index 0222971f..2f51e142 100644 --- a/src/lib/Form/Processor/ContentFormProcessor.php +++ b/src/lib/Form/Processor/ContentFormProcessor.php @@ -15,10 +15,12 @@ use Ibexa\ContentForms\Event\FormActionEvent; use Ibexa\Contracts\Core\Repository\ContentService; use Ibexa\Contracts\Core\Repository\LocationService; +use Ibexa\Contracts\Core\Repository\Strategy\ContentPublication\ContentPublicationStrategyInterface; use Ibexa\Contracts\Core\Repository\Values\Content\Content; use Ibexa\Contracts\Core\Repository\Values\Content\ContentStruct; use Ibexa\Contracts\Core\Repository\Values\Content\Location; use Ibexa\Contracts\Core\Repository\Values\Content\VersionInfo; +use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; use Ibexa\Core\Base\Exceptions\InvalidArgumentException; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -33,7 +35,9 @@ public function __construct( private ContentService $contentService, private LocationService $locationService, - private RouterInterface $router + private RouterInterface $router, + private ConfigResolverInterface $configResolver, + private ContentPublicationStrategyInterface $contentPublicationStrategy ) { } @@ -150,19 +154,29 @@ public function processPublish(FormActionEvent $event): void $draft = $this->saveDraft($data, $form->getConfig()->getOption('languageCode')); $versionInfo = $draft->getVersionInfo(); - $content = $this->contentService->publishVersion( + $publicationResult = $this->contentPublicationStrategy->publishVersion( $versionInfo, [$versionInfo->getInitialLanguage()->getLanguageCode()] ); - $event->setPayload('content', $content); + $event->setPayload('content_type', $draft->getContentType()); $event->setPayload('is_new', $draft->getContentInfo()->isDraft()); - $locationId = $referrerLocation !== null && $data instanceof ContentUpdateData - ? $referrerLocation->id - : $content->getContentInfo()->getMainLocationId(); + $publishedContent = $publicationResult->publishedContent; + if ($publishedContent !== null) { + $locationId = $referrerLocation !== null && $data instanceof ContentUpdateData + ? $referrerLocation->id + : $publishedContent->getContentInfo()->getMainLocationId(); + $contentId = $publishedContent->getId(); + } else { + // The publication is deferred to background processing; the published version and, + // for never-published content, its main location do not exist yet. + $locationId = $referrerLocation !== null && $data instanceof ContentUpdateData + ? $referrerLocation->id + : (int) $this->configResolver->getParameter('content.tree_root.location_id'); + $contentId = $this->locationService->loadLocation($locationId)->getContentId(); + } - $contentId = $content->getId(); $redirectUrl = $form['redirectUrlAfterPublish']->getData() ?: $this->router->generate( 'ibexa.content.view', [ diff --git a/tests/lib/Form/Processor/ContentFormProcessorTest.php b/tests/lib/Form/Processor/ContentFormProcessorTest.php new file mode 100644 index 00000000..eadd4523 --- /dev/null +++ b/tests/lib/Form/Processor/ContentFormProcessorTest.php @@ -0,0 +1,321 @@ +|null $expectedRouteParameters route parameters the redirect + * is expected to be generated with, or null when a custom redirect URL is provided and + * the router must not be invoked at all + */ + public function testProcessPublish( + bool $isNewContent, + bool $publishedSynchronously, + bool $withReferrerLocation, + ?string $customRedirectUrl, + ?array $expectedRouteParameters + ): void { + $draft = $this->createDraft( + $isNewContent ? null : self::DRAFT_MAIN_LOCATION_ID, + $isNewContent ? ContentInfo::STATUS_DRAFT : ContentInfo::STATUS_PUBLISHED, + ); + $versionInfo = $draft->getVersionInfo(); + + $data = $isNewContent ? $this->createCreateData() : $this->createUpdateData($draft); + $options = $withReferrerLocation + ? ['referrerLocation' => new Location(['id' => self::REFERRER_LOCATION_ID])] + : []; + $event = $this->createEvent($data, $this->createForm($customRedirectUrl), $options); + + // The draft is persisted via create for new content or update for existing content; + // publication must never go through the service. + $contentService = $this->createMock(ContentService::class); + $contentService + ->expects($isNewContent ? self::once() : self::never()) + ->method('createContent') + ->willReturn($draft); + $contentService + ->expects($isNewContent ? self::never() : self::once()) + ->method('updateContent') + ->willReturn($draft); + $contentService + ->expects(self::never()) + ->method('publishVersion'); + + // Publication is delegated to the strategy, called once with the draft version and language. + $publishedContent = $publishedSynchronously ? $this->createPublishedContent() : null; + $contentPublicationStrategy = $this->createMock(ContentPublicationStrategyInterface::class); + $contentPublicationStrategy + ->expects(self::once()) + ->method('publishVersion') + ->with(self::identicalTo($versionInfo), [self::LANGUAGE_CODE]) + ->willReturn(new ContentPublicationResult($publishedContent)); + + // The router is called once with the expected route parameters, unless a custom redirect + // URL coming from the form data short-circuits URL generation entirely. + $router = $this->createMock(RouterInterface::class); + if ($expectedRouteParameters === null) { + $router + ->expects(self::never()) + ->method('generate'); + } else { + $router + ->expects(self::once()) + ->method('generate') + ->with('ibexa.content.view', $expectedRouteParameters) + ->willReturn(self::GENERATED_URL); + } + + // The deferred (async) redirect resolves its location from the content tree root config + // and the location lookup. + $configResolver = $this->createStub(ConfigResolverInterface::class); + $configResolver->method('getParameter')->willReturn(self::TREE_ROOT_LOCATION_ID); + + $locationService = $this->createStub(LocationService::class); + $locationService->method('loadLocation')->willReturn( + new Location([ + 'id' => self::TREE_ROOT_LOCATION_ID, + 'contentInfo' => new ContentInfo(['id' => self::CONTENT_ID]), + ]) + ); + + $processor = new ContentFormProcessor( + $contentService, + $locationService, + $router, + $configResolver, + $contentPublicationStrategy + ); + + $processor->processPublish($event); + + self::assertSame($draft->getContentType(), $event->getPayload('content_type')); + self::assertSame($isNewContent, $event->getPayload('is_new')); + + $response = $event->getResponse(); + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame($customRedirectUrl ?? self::GENERATED_URL, $response->getTargetUrl()); + } + + /** + * @return iterable + */ + public static function provideProcessPublishCases(): iterable + { + yield 'sync: update with referrer location' => [ + 'isNewContent' => false, + 'publishedSynchronously' => true, + 'withReferrerLocation' => true, + 'customRedirectUrl' => null, + 'expectedRouteParameters' => [ + 'contentId' => self::CONTENT_ID, + 'locationId' => self::REFERRER_LOCATION_ID, + 'publishedContentId' => self::CONTENT_ID, + ], + ]; + + yield 'sync: update without referrer location' => [ + 'isNewContent' => false, + 'publishedSynchronously' => true, + 'withReferrerLocation' => false, + 'customRedirectUrl' => null, + 'expectedRouteParameters' => [ + 'contentId' => self::CONTENT_ID, + 'locationId' => self::PUBLISHED_MAIN_LOCATION_ID, + 'publishedContentId' => self::CONTENT_ID, + ], + ]; + + yield 'sync: new content' => [ + 'isNewContent' => true, + 'publishedSynchronously' => true, + 'withReferrerLocation' => false, + 'customRedirectUrl' => null, + 'expectedRouteParameters' => [ + 'contentId' => self::CONTENT_ID, + 'locationId' => self::PUBLISHED_MAIN_LOCATION_ID, + 'publishedContentId' => self::CONTENT_ID, + ], + ]; + + yield 'async: update with referrer location' => [ + 'isNewContent' => false, + 'publishedSynchronously' => false, + 'withReferrerLocation' => true, + 'customRedirectUrl' => null, + 'expectedRouteParameters' => [ + 'contentId' => self::CONTENT_ID, + 'locationId' => self::REFERRER_LOCATION_ID, + 'publishedContentId' => self::CONTENT_ID, + ], + ]; + + yield 'async: update without referrer location redirects to tree root' => [ + 'isNewContent' => false, + 'publishedSynchronously' => false, + 'withReferrerLocation' => false, + 'customRedirectUrl' => null, + 'expectedRouteParameters' => [ + 'contentId' => self::CONTENT_ID, + 'locationId' => self::TREE_ROOT_LOCATION_ID, + 'publishedContentId' => self::CONTENT_ID, + ], + ]; + + yield 'async: new content without location yet redirects to tree root' => [ + 'isNewContent' => true, + 'publishedSynchronously' => false, + 'withReferrerLocation' => false, + 'customRedirectUrl' => null, + 'expectedRouteParameters' => [ + 'contentId' => self::CONTENT_ID, + 'locationId' => self::TREE_ROOT_LOCATION_ID, + 'publishedContentId' => self::CONTENT_ID, + ], + ]; + + yield 'custom redirect URL after publish bypasses router' => [ + 'isNewContent' => false, + 'publishedSynchronously' => false, + 'withReferrerLocation' => false, + 'customRedirectUrl' => 'custom-redirect-url', + 'expectedRouteParameters' => null, + ]; + } + + private function createDraft(?int $mainLocationId, int $status): Content + { + $contentInfo = new ContentInfo([ + 'id' => self::CONTENT_ID, + 'mainLocationId' => $mainLocationId, + 'mainLanguageCode' => self::LANGUAGE_CODE, + 'status' => $status, + ]); + + $versionInfo = $this->createStub(VersionInfo::class); + $versionInfo->method('getInitialLanguage')->willReturn( + new Language(['languageCode' => self::LANGUAGE_CODE]) + ); + $versionInfo->method('getContentInfo')->willReturn($contentInfo); + + $draft = $this->createStub(Content::class); + $draft->method('getVersionInfo')->willReturn($versionInfo); + $draft->method('getContentInfo')->willReturn($contentInfo); + $draft->method('getContentType')->willReturn($this->createStub(ContentType::class)); + + return $draft; + } + + private function createPublishedContent(): Content + { + $contentInfo = new ContentInfo([ + 'id' => self::CONTENT_ID, + 'mainLocationId' => self::PUBLISHED_MAIN_LOCATION_ID, + 'mainLanguageCode' => self::LANGUAGE_CODE, + 'status' => ContentInfo::STATUS_PUBLISHED, + ]); + + $publishedContent = $this->createStub(Content::class); + $publishedContent->method('getContentInfo')->willReturn($contentInfo); + $publishedContent->method('getId')->willReturn(self::CONTENT_ID); + + return $publishedContent; + } + + private function createUpdateData(Content $contentDraft): ContentUpdateData + { + return new ContentUpdateData([ + 'contentDraft' => $contentDraft, + 'fieldsData' => [], + ]); + } + + private function createCreateData(): ContentCreateData + { + return new ContentCreateData([ + 'mainLanguageCode' => self::LANGUAGE_CODE, + 'fieldsData' => [], + ]); + } + + /** + * @return \Symfony\Component\Form\FormInterface + */ + private function createForm(?string $redirectUrlAfterPublish = null): FormInterface + { + $formConfig = $this->createStub(FormConfigInterface::class); + $formConfig->method('getOption')->willReturn(self::LANGUAGE_CODE); + + $redirectUrlField = $this->createStub(FormInterface::class); + $redirectUrlField->method('getData')->willReturn($redirectUrlAfterPublish); + + $form = $this->createStub(FormInterface::class); + $form->method('getConfig')->willReturn($formConfig); + $form->method('offsetGet')->willReturn($redirectUrlField); + + return $form; + } + + /** + * @param \Symfony\Component\Form\FormInterface $form + * @param array $options + */ + private function createEvent( + ContentCreateData|ContentUpdateData $data, + FormInterface $form, + array $options = [] + ): FormActionEvent { + return new FormActionEvent($form, $data, 'publish', $options); + } +}