From f5ba5d574b905ae66755d5c6f7dc733111cb5e22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 10 Sep 2024 10:44:45 +0200 Subject: [PATCH] Add WopiConverter service and update Collabora integration tests Introduce the WopiConverter service to handle document-to-PDF conversion using Collabora Online. Extend and update related tests in WopiConvertToPdfTest and ConvertControllerTest for better coverage and reliability. Enhance the GitLab CI configuration to exclude new test category "collabora-integration". --- .env | 2 +- .env.test | 2 + .gitlab-ci.yml | 2 +- .../src/Controller/ConvertController.php | 64 +++------------- .../src/Service/WopiConverter.php | 69 ++++++++++++++++++ .../Controller/ConvertControllerTest.php | 56 ++++++-------- .../tests/Service/WopiConvertToPdfTest.php | 63 ++++++++++++++++ .../tests/Service/fixtures/test-document.odt | Bin 0 -> 8918 bytes tests/app/config/packages/wopi.yaml | 2 + 9 files changed, 171 insertions(+), 89 deletions(-) create mode 100644 src/Bundle/ChillWopiBundle/src/Service/WopiConverter.php create mode 100644 src/Bundle/ChillWopiBundle/tests/Service/WopiConvertToPdfTest.php create mode 100644 src/Bundle/ChillWopiBundle/tests/Service/fixtures/test-document.odt create mode 100644 tests/app/config/packages/wopi.yaml diff --git a/.env b/.env index 1714966d4..b2eecb78f 100644 --- a/.env +++ b/.env @@ -23,7 +23,7 @@ TRUSTED_HOSTS='^(localhost|example\.com|nginx)$' ###< symfony/framework-bundle ### ## Wopi server for editing documents online -WOPI_SERVER=http://collabora:9980 +EDITOR_SERVER=http://collabora:9980 # must be manually set in .env.local # ADMIN_PASSWORD= diff --git a/.env.test b/.env.test index f84920e54..c78a1bc63 100644 --- a/.env.test +++ b/.env.test @@ -41,3 +41,5 @@ DATABASE_URL="postgresql://postgres:postgres@db:5432/test?serverVersion=14&chars ASYNC_UPLOAD_TEMP_URL_KEY= ASYNC_UPLOAD_TEMP_URL_BASE_PATH= ASYNC_UPLOAD_TEMP_URL_CONTAINER= + +EDITOR_SERVER=https://localhost:9980 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index acd66a42e..c1fdebf43 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -122,7 +122,7 @@ unit_tests: - php tests/console chill:db:sync-views --env=test - php -d memory_limit=2G tests/console cache:clear --env=test - php -d memory_limit=3G tests/console doctrine:fixtures:load -n --env=test - - php -d memory_limit=4G bin/phpunit --colors=never --exclude-group dbIntensive,openstack-integration + - php -d memory_limit=4G bin/phpunit --colors=never --exclude-group dbIntensive,openstack-integration,collabora-integration artifacts: expire_in: 1 day paths: diff --git a/src/Bundle/ChillWopiBundle/src/Controller/ConvertController.php b/src/Bundle/ChillWopiBundle/src/Controller/ConvertController.php index 73207e13a..86a624e4b 100644 --- a/src/Bundle/ChillWopiBundle/src/Controller/ConvertController.php +++ b/src/Bundle/ChillWopiBundle/src/Controller/ConvertController.php @@ -14,84 +14,44 @@ namespace Chill\WopiBundle\Controller; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Service\StoredObjectManager; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; -use Chill\MainBundle\Entity\User; +use Chill\WopiBundle\Service\WopiConverter; use Psr\Log\LoggerInterface; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; -use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; -use Symfony\Component\Mime\Part\DataPart; -use Symfony\Component\Mime\Part\Multipart\FormDataPart; use Symfony\Component\Security\Core\Security; -use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; -use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; class ConvertController { private const LOG_PREFIX = '[convert] '; - private readonly string $collaboraDomain; - /** * @param StoredObjectManager $storedObjectManager */ public function __construct( - private readonly HttpClientInterface $httpClient, - private readonly RequestStack $requestStack, private readonly Security $security, private readonly StoredObjectManagerInterface $storedObjectManager, + private readonly WopiConverter $wopiConverter, private readonly LoggerInterface $logger, - ParameterBagInterface $parameters, - ) { - $this->collaboraDomain = $parameters->get('wopi')['server']; - } + ) {} - public function __invoke(StoredObject $storedObject): Response + public function __invoke(StoredObject $storedObject, Request $request): Response { - if (!$this->security->getUser() instanceof User) { + if (!($this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN'))) { throw new AccessDeniedHttpException('User must be authenticated'); } $content = $this->storedObjectManager->read($storedObject); - $query = []; - if (null !== $request = $this->requestStack->getCurrentRequest()) { - $query['lang'] = $request->getLocale(); - } + $lang = $request->getLocale(); try { - $url = sprintf('%s/cool/convert-to/pdf', $this->collaboraDomain); - $form = new FormDataPart([ - 'data' => new DataPart($content, $storedObject->getUuid()->toString(), $storedObject->getType()), - ]); - $response = $this->httpClient->request('POST', $url, [ - 'headers' => $form->getPreparedHeaders()->toArray(), - 'query' => $query, - 'body' => $form->bodyToString(), - 'timeout' => 10, - ]); - - return new Response($response->getContent(), Response::HTTP_OK, [ + return new Response($this->wopiConverter->convert($lang, $content, $storedObject->getType()), Response::HTTP_OK, [ 'Content-Type' => 'application/pdf', ]); - } catch (ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|TransportExceptionInterface $exception) { - return $this->onConversionFailed($url, $exception->getResponse()); + } catch (\RuntimeException $exception) { + $this->logger->alert(self::LOG_PREFIX.'Could not convert document', ['message' => $exception->getMessage(), 'exception', $exception->getTraceAsString()]); + + return new Response('convert server not available', Response::HTTP_SERVICE_UNAVAILABLE); } } - - private function onConversionFailed(string $url, ResponseInterface $response): JsonResponse - { - $this->logger->error(self::LOG_PREFIX.' could not convert document', [ - 'response_status' => $response->getStatusCode(), - 'message' => $response->getContent(false), - 'server' => $this->collaboraDomain, - 'url' => $url, - ]); - - return new JsonResponse(['message' => 'conversion failed : '.$response->getContent(false)], Response::HTTP_SERVICE_UNAVAILABLE); - } } diff --git a/src/Bundle/ChillWopiBundle/src/Service/WopiConverter.php b/src/Bundle/ChillWopiBundle/src/Service/WopiConverter.php new file mode 100644 index 000000000..ded9b3188 --- /dev/null +++ b/src/Bundle/ChillWopiBundle/src/Service/WopiConverter.php @@ -0,0 +1,69 @@ +collaboraDomain = $parameters->get('wopi')['server']; + } + + public function convert(string $lang, string $content, string $contentType, $convertTo = 'pdf'): string + { + try { + $url = sprintf('%s/cool/convert-to/%s', $this->collaboraDomain, $convertTo); + + $form = new FormDataPart([ + 'data' => new DataPart($content, uniqid('temp-file-'), contentType: $contentType), + ]); + $response = $this->httpClient->request('POST', $url, [ + 'headers' => $form->getPreparedHeaders()->toArray(), + 'query' => ['lang' => $lang], + 'body' => $form->bodyToString(), + 'timeout' => 10, + ]); + + if (200 === $response->getStatusCode()) { + $this->logger->info(self::LOG_PREFIX.'document converted successfully', ['size' => strlen($content)]); + } + + return $response->getContent(); + } catch (ClientExceptionInterface $e) { + throw new \LogicException('no correct request to collabora online', previous: $e); + } catch (RedirectionExceptionInterface $e) { + throw new \RuntimeException('no redirection expected', previous: $e); + } catch (ServerExceptionInterface|TransportExceptionInterface $e) { + throw new \RuntimeException('error while converting document', previous: $e); + } + } +} diff --git a/src/Bundle/ChillWopiBundle/tests/Controller/ConvertControllerTest.php b/src/Bundle/ChillWopiBundle/tests/Controller/ConvertControllerTest.php index 0a928bcd9..9a529df6b 100644 --- a/src/Bundle/ChillWopiBundle/tests/Controller/ConvertControllerTest.php +++ b/src/Bundle/ChillWopiBundle/tests/Controller/ConvertControllerTest.php @@ -13,16 +13,12 @@ namespace Chill\WopiBundle\Tests\Controller; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; -use Chill\MainBundle\Entity\User; use Chill\WopiBundle\Controller\ConvertController; +use Chill\WopiBundle\Service\WopiConverter; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Log\NullLogger; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; -use Symfony\Component\HttpClient\MockHttpClient; -use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Security\Core\Security; /** @@ -39,28 +35,27 @@ final class ConvertControllerTest extends TestCase $storedObject = new StoredObject(); $storedObject->registerVersion(type: 'application/vnd.oasis.opendocument.text'); - $httpClient = new MockHttpClient([ - new MockResponse('not authorized', ['http_code' => 401]), - ], 'http://collabora:9980'); - $security = $this->prophesize(Security::class); - $security->getUser()->willReturn(new User()); + $security->isGranted('ROLE_USER')->willReturn(true); $storeManager = $this->prophesize(StoredObjectManagerInterface::class); $storeManager->read($storedObject)->willReturn('content'); - $parameterBag = new ParameterBag(['wopi' => ['server' => 'http://collabora:9980']]); + $wopiConverter = $this->prophesize(WopiConverter::class); + $wopiConverter->convert('fr', 'content', 'application/vnd.oasis.opendocument.text') + ->willThrow(new \RuntimeException()); - $convert = new ConvertController( - $httpClient, - $this->makeRequestStack(), + $controller = new ConvertController( $security->reveal(), $storeManager->reveal(), + $wopiConverter->reveal(), new NullLogger(), - $parameterBag ); - $response = $convert($storedObject); + $request = new Request(); + $request->setLocale('fr'); + + $response = $controller($storedObject, $request); $this->assertNotEquals(200, $response->getStatusCode()); } @@ -70,38 +65,29 @@ final class ConvertControllerTest extends TestCase $storedObject = new StoredObject(); $storedObject->registerVersion(type: 'application/vnd.oasis.opendocument.text'); - $httpClient = new MockHttpClient([ - new MockResponse('1234', ['http_code' => 200]), - ], 'http://collabora:9980'); - $security = $this->prophesize(Security::class); - $security->getUser()->willReturn(new User()); + $security->isGranted('ROLE_USER')->willReturn(true); $storeManager = $this->prophesize(StoredObjectManagerInterface::class); $storeManager->read($storedObject)->willReturn('content'); - $parameterBag = new ParameterBag(['wopi' => ['server' => 'http://collabora:9980']]); + $wopiConverter = $this->prophesize(WopiConverter::class); + $wopiConverter->convert('fr', 'content', 'application/vnd.oasis.opendocument.text') + ->willReturn('1234'); - $convert = new ConvertController( - $httpClient, - $this->makeRequestStack(), + $controller = new ConvertController( $security->reveal(), $storeManager->reveal(), + $wopiConverter->reveal(), new NullLogger(), - $parameterBag ); - $response = $convert($storedObject); + $request = new Request(); + $request->setLocale('fr'); + + $response = $controller($storedObject, $request); $this->assertEquals(200, $response->getStatusCode()); $this->assertEquals('1234', $response->getContent()); } - - private function makeRequestStack(): RequestStack - { - $requestStack = new RequestStack(); - $requestStack->push(new Request()); - - return $requestStack; - } } diff --git a/src/Bundle/ChillWopiBundle/tests/Service/WopiConvertToPdfTest.php b/src/Bundle/ChillWopiBundle/tests/Service/WopiConvertToPdfTest.php new file mode 100644 index 000000000..317932ea5 --- /dev/null +++ b/src/Bundle/ChillWopiBundle/tests/Service/WopiConvertToPdfTest.php @@ -0,0 +1,63 @@ + ['server' => $_ENV['EDITOR_SERVER']], + ]); + + $converter = new WopiConverter($client, new NullLogger(), $parameters); + + $actual = $converter->convert('fr', $content, 'application/vnd.oasis.opendocument.text'); + + self::assertIsString($actual); + } + + public function testConvertToPdfWithMock(): void + { + $httpClient = new MockHttpClient([ + new MockResponse('1234', ['http_code' => 200]), + ], 'http://collabora:9980'); + $parameters = new ParameterBag([ + 'wopi' => ['server' => 'http://collabora:9980'], + ]); + + $converter = new WopiConverter($httpClient, new NullLogger(), $parameters); + + $actual = $converter->convert('fr', 'content-string', 'application/vnd.oasis.opendocument.text'); + + self::assertEquals('1234', $actual); + } +} diff --git a/src/Bundle/ChillWopiBundle/tests/Service/fixtures/test-document.odt b/src/Bundle/ChillWopiBundle/tests/Service/fixtures/test-document.odt new file mode 100644 index 0000000000000000000000000000000000000000..b6f6444082151390d1fc8e52ea568c70c745d22b GIT binary patch literal 8918 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