diff --git a/tests/src/Kernel/Controller/ViewerControllerTest.php b/tests/src/Kernel/Controller/ViewerControllerTest.php
new file mode 100644
index 00000000..327845cf
--- /dev/null
+++ b/tests/src/Kernel/Controller/ViewerControllerTest.php
@@ -0,0 +1,355 @@
+createMock(CollaboraDiscoveryFetcherInterface::class);
+ $file = dirname(__DIR__, 3) . '/fixtures/discovery.mimetypes.xml';
+ $xml = file_get_contents($file);
+ $fetcher->method('getDiscoveryXml')->willReturn($xml);
+ $this->container->set(CollaboraDiscoveryFetcherInterface::class, $fetcher);
+
+ $this->logger = new TestLogger();
+ \Drupal::service('logger.factory')->addLogger($this->logger);
+
+ $collabora_settings = \Drupal::configFactory()->getEditable('collabora_online.settings');
+ $cool = $collabora_settings->get('cool');
+ $cool['key_id'] = 'collabora';
+ $collabora_settings->set('cool', $cool)->save();
+
+ $this->media = $this->createMediaEntity('document');
+ $this->user = $this->createUser([
+ 'access content',
+ 'preview document in collabora',
+ 'edit any document in collabora',
+ ]);
+ $fid = $this->media->getSource()->getSourceFieldValue($this->media);
+ $this->file = File::load($fid);
+
+ $this->setCurrentUser($this->user);
+ }
+
+ /**
+ * Tests successful requests.
+ *
+ * @covers ::editor
+ */
+ public function testEditor(): void {
+ // Requests to view the editor.
+ $request = $this->createRequest('view');
+ $this->assertResponseOk($request);
+ $request = $this->createRequest('view', write: TRUE);
+ $this->assertResponseOk($request);
+
+ // Requests to edit the editor.
+ $request = $this->createRequest('edit', write: TRUE);
+ $this->assertResponseOk($request);
+ $request = $this->createRequest('edit');
+ $this->assertResponseOk($request);
+ }
+
+ /**
+ * Tests requests with unavailable Collabora.
+ *
+ * @covers ::editor
+ */
+ public function testEditorCollaboraUnavailable(): void {
+ // Collabora is not available on tests, we need to restore the service.
+ $fetcher = $this->createMock(CollaboraDiscoveryFetcherInterface::class);
+ $this->container->set(CollaboraDiscoveryFetcherInterface::class, $fetcher);
+
+ // Requests to view the editor.
+ $request = $this->createRequest('view');
+ $this->assertBadRequestResponse(
+ 'The Collabora Online editor/viewer is not available.',
+ $request,
+ "Collabora Online is not available.
\n" . Error::DEFAULT_ERROR_MESSAGE,
+ );
+
+ // Requests to edit the editor.
+ $request = $this->createRequest('edit');
+ $this->assertBadRequestResponse(
+ 'The Collabora Online editor/viewer is not available.',
+ $request,
+ "Collabora Online is not available.
\n" . Error::DEFAULT_ERROR_MESSAGE,
+ );
+ }
+
+ /**
+ * Tests requests with a scheme not matching the Collabora client URL.
+ *
+ * @covers ::editor
+ */
+ public function testEditorMismatchScheme(): void {
+ // Requests to view the editor.
+ $request = $this->createRequest('view', 'https');
+ $this->assertBadRequestResponse(
+ 'Viewer error: Protocol mismatch.',
+ $request,
+ "The current request uses 'https' url scheme, but the Collabora client url is 'https'.",
+ );
+
+ // Requests to edit the editor.
+ $request = $this->createRequest('edit', 'https');
+ $this->assertBadRequestResponse(
+ 'Viewer error: Protocol mismatch.',
+ $request,
+ "The current request uses 'https' url scheme, but the Collabora client url is 'https'.",
+ );
+ }
+
+ /**
+ * Tests requests with a no viewer avaliable after request.
+ *
+ * @covers ::editor
+ */
+ public function testEditorNoViewer(): void {
+ $request = $this->createRequest('view');
+
+ // Mock transcoder to force fail.
+ $transcoder = $this->createMock(jwtTranscoderInterface::class);
+ $transcoder->method('encode')->willThrowException(new CollaboraNotAvailableException());
+ $this->container->set(jwtTranscoderInterface::class, $transcoder);
+ $this->expectException(CollaboraNotAvailableException::class);
+
+ // Requests to view the editor.
+ $this->assertBadRequestResponse(
+ 'The Collabora Online editor/viewer is not available.',
+ $request,
+ "Cannot show the viewer/editor.
\n" . Error::DEFAULT_ERROR_MESSAGE,
+ );
+
+ // Requests to edit the editor.
+ $request = $this->createRequest('edit');
+ $this->assertBadRequestResponse(
+ 'The Collabora Online editor/viewer is not available.',
+ $request,
+ [
+ 'message' => "Cannot show the viewer/editor.
\n" . Error::DEFAULT_ERROR_MESSAGE,
+ ]
+ );
+ }
+
+ /**
+ * Creates a view/edit request.
+ *
+ * @param string $action
+ * E.g. 'view' or 'edit'.
+ * @param string $scheme
+ * The protocol used for the request.
+ * @param int|null $media_id
+ * Media entity id, if different from the default.
+ * @param int|null $user_id
+ * User id, if different from the default.
+ * @param bool $write
+ * TRUE if write access is requested.
+ * @param array $token_payload
+ * Explicit token payload values.
+ * This can be used to cause a bad token.
+ *
+ * @return \Symfony\Component\HttpFoundation\Request
+ * The request.
+ */
+ protected function createRequest(
+ string $action,
+ string $scheme = 'http',
+ ?int $media_id = NULL,
+ ?int $user_id = NULL,
+ bool $write = FALSE,
+ array $token_payload = [],
+ ): Request {
+ $media_id ??= (int) $this->media->id();
+ $user_id ??= (int) $this->user->id();
+ $uri = "$scheme://localhost/cool/$action/$media_id";
+ $token = $this->createAccessToken($media_id, $user_id, $write, $token_payload);
+ $parameters = [
+ 'media' => $media_id,
+ 'edit' => $write,
+ 'access_token' => $token,
+ ];
+ return Request::create($uri, 'GET', $parameters);
+ }
+
+ /**
+ * Retrieves an encoded access token.
+ *
+ * @param int|null $fid
+ * The file id.
+ * @param int|null $uid
+ * The user id.
+ * @param bool $write
+ * The write permission.
+ * @param array $payload
+ * Explicit payload values.
+ * This can be used to cause a bad token.
+ *
+ * @return string
+ * The enconded token.
+ */
+ protected function createAccessToken(?int $fid = NULL, ?int $uid = NULL, bool $write = FALSE, array $payload = []): string {
+ /** @var \Drupal\collabora_online\Jwt\JwtTranscoderInterface $transcoder */
+ $transcoder = \Drupal::service(JwtTranscoderInterface::class);
+ $expire_timestamp = gettimeofday(TRUE) + 1000;
+ $payload += [
+ 'fid' => (string) ($fid ?? $this->media->id()),
+ 'uid' => (string) ($uid ?? $this->user->id()),
+ 'wri' => $write,
+ 'exp' => $expire_timestamp,
+ ];
+ return $transcoder->encode($payload, $expire_timestamp);
+ }
+
+ /**
+ * Asserts an sucessful response given a request.
+ *
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ * The request to perform.
+ * @param string $message
+ * Message to distinguish this from other assertions.
+ */
+ protected function assertResponseOk(Request $request, string $message = ''): void {
+ $response = $this->handleRequest($request);
+
+ $this->assertEquals(Response::HTTP_OK, $response->getStatusCode(), $message);
+ $this->assertStringContainsString('iframe', $response->getContent(), $message);
+ $this->assertEquals('', $response->headers->get('Content-Type'), $message);
+ }
+
+ /**
+ * Asserts an bad request response given a request.
+ *
+ * @param string $expected_content
+ * The expected content.
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ * The request to perform.
+ * @param array $expected_log
+ * The expected log entry.
+ * @param string $message
+ * Message to distinguish this from other assertions.
+ */
+ protected function assertBadRequestResponse(string $expected_content, Request $request, string $expected_log = '', string $message = ''): void {
+ $this->assertResponse(
+ Response::HTTP_BAD_REQUEST,
+ $expected_content,
+ 'text/plain',
+ $request,
+ $message,
+ );
+
+ if ($expected_log) {
+ $this->assertTrue(
+ $this->logger->hasRecord($expected_log),
+ sprintf('The logger does not contain a record: "%s".', $expected_log)
+ );
+ $this->logger->reset();
+ }
+ }
+
+ /**
+ * Asserts status code and content in a response given a request.
+ *
+ * @param int $expected_code
+ * The expected response status code.
+ * @param string $expected_content
+ * The expected response content.
+ * @param string $expected_content_type
+ * The type of content of the response.
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ * The request to perform.
+ * @param string $message
+ * Message to distinguish this from other assertions.
+ */
+ protected function assertResponse(int $expected_code, string $expected_content, string $expected_content_type, Request $request, string $message = ''): void {
+ $response = $this->handleRequest($request);
+
+ $this->assertEquals($expected_code, $response->getStatusCode(), $message);
+ $this->assertEquals($expected_content, $response->getContent(), $message);
+ $this->assertEquals($expected_content_type, $response->headers->get('Content-Type'), $message);
+ }
+
+ /**
+ * Handles a request and gets the response.
+ *
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ * Incoming request.
+ *
+ * @return \Symfony\Component\HttpFoundation\Response
+ * The response.
+ */
+ protected function handleRequest(Request $request): Response {
+ /** @var \Symfony\Component\HttpKernel\HttpKernelInterface $kernel */
+ $kernel = \Drupal::service('http_kernel');
+ return $kernel->handle($request);
+ }
+
+}