diff --git a/README.md b/README.md index 185b65d..0971e85 100644 --- a/README.md +++ b/README.md @@ -67,3 +67,56 @@ To add new Vue components: 1. Create the component in the `assets/vue/` directory 2. Import and mount it in `assets/app.js` 3. Add a mount point in the appropriate template + +## CKEditor 5 Integration + +The rich text editor lives in `assets/editor/` and is exposed to the existing forms through `assets/vue/components/base/CkEditorField.vue`. + +### Architecture + +- `assets/editor/CkEditor.vue` is the reusable Vue 3 editor component. +- `assets/editor/uploadAdapter.ts` provides CKEditor upload support against a Symfony endpoint. +- `assets/editor/assetBrowserPlugin.js` adds a toolbar button that opens the native asset browser. +- `assets/editor/EditorAssetPicker.vue` shows existing uploaded files and inserts them as images or links. +- `assets/editor/plugins.ts` and `assets/editor/toolbar.ts` keep the editor configuration isolated and reusable. +- `src/Service/EditorUploadService.php` stores uploads in the public filesystem and returns a browser URL. +- `src/Controller/EditorUploadController.php` accepts authenticated uploads and lists existing assets. + +### Reused from the legacy plugin + +- upload path conventions +- image validation rules +- public-file URL generation strategy +- file storage separation for editor content +- asset browsing behavior, expressed as a native Vue modal instead of a popup window + +### New behavior + +- Vue component with `v-model` +- configurable toolbar and CKEditor options +- upload adapter with progress support +- cleanup on unmount +- read-only mode support +- existing file browser for reusing uploaded content + +### Build and test + +```bash +yarn encore dev +yarn test:vue +vendor/bin/phpunit +``` + +### Configuration + +The backend uses the existing phpList upload directory parameters: + +- `phplist.upload_images_dir` + +Uploads are stored below `public//ckeditor5/`. + +### Limitations + +- This integration only covers image uploads from CKEditor. +- elFinder is not embedded in the frontend UI; the frontend now provides a native asset browser instead. +- Existing legacy HTML content is preserved as-is, but custom HTML support still depends on CKEditor 5 output rules. diff --git a/assets/editor/CkEditor.vue b/assets/editor/CkEditor.vue new file mode 100644 index 0000000..1439afb --- /dev/null +++ b/assets/editor/CkEditor.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/assets/editor/EditorAssetPicker.vue b/assets/editor/EditorAssetPicker.vue new file mode 100644 index 0000000..4903306 --- /dev/null +++ b/assets/editor/EditorAssetPicker.vue @@ -0,0 +1,218 @@ + + + diff --git a/assets/editor/assetBrowserPlugin.js b/assets/editor/assetBrowserPlugin.js new file mode 100644 index 0000000..1bf752e --- /dev/null +++ b/assets/editor/assetBrowserPlugin.js @@ -0,0 +1,26 @@ +import { ButtonView, Plugin } from 'ckeditor5'; + +export default class AssetBrowserPlugin extends Plugin { + init() { + const editor = this.editor; + + editor.ui.componentFactory.add('assetBrowser', (locale) => { + const view = new ButtonView(locale); + const openAssetPicker = editor.config.get('openAssetPicker'); + + view.set({ + label: 'Browse files', + tooltip: true, + withText: true, + }); + + view.on('execute', () => { + if (typeof openAssetPicker === 'function') { + openAssetPicker(editor); + } + }); + + return view; + }); + } +} diff --git a/assets/editor/index.ts b/assets/editor/index.ts new file mode 100644 index 0000000..a5c67cf --- /dev/null +++ b/assets/editor/index.ts @@ -0,0 +1,6 @@ +export { default as CkEditor } from './CkEditor.vue'; +export { default as EditorUploadAdapter } from './uploadAdapter.ts'; +export { DEFAULT_TOOLBAR, DEFAULT_IMAGE_TOOLBAR } from './toolbar.ts'; +export { DEFAULT_PLUGINS } from './plugins.ts'; +export { default as EditorAssetPicker } from './EditorAssetPicker.vue'; +export { default as AssetBrowserPlugin } from './assetBrowserPlugin.js'; diff --git a/assets/editor/plugins.ts b/assets/editor/plugins.ts new file mode 100644 index 0000000..8809fcf --- /dev/null +++ b/assets/editor/plugins.ts @@ -0,0 +1,118 @@ +import { + Alignment, + AutoImage, + AutoLink, + Autosave, + BlockQuote, + Bold, + Code, + FontBackgroundColor, + FontColor, + FontFamily, + FontSize, + Essentials, + FileRepository, + GeneralHtmlSupport, + Heading, + Highlight, + HorizontalLine, + HtmlEmbed, + Indent, + IndentBlock, + Image, + ImageCaption, + ImageBlock, + ImageInsert, + ImageInsertUI, + ImageInsertViaUrl, + ImageInline, + ImageResize, + ImageStyle, + ImageToolbar, + ImageUpload, + Italic, + Link, + LinkImage, + List, + ListProperties, + MediaEmbed, + Paragraph, + PictureEditing, + PlainTableOutput, + RemoveFormat, + SourceEditing, + Strikethrough, + Subscript, + Superscript, + Table, + TableCaption, + TableCellProperties, + TableColumnResize, + TableLayout, + TableProperties, + TableToolbar, + TextTransformation, + TodoList, + Underline, +} from 'ckeditor5'; + +import AssetBrowserPlugin from './assetBrowserPlugin.js'; + +export const DEFAULT_PLUGINS = [ + Essentials, + Alignment, + AutoLink, + Autosave, + Paragraph, + Bold, + Italic, + Code, + Heading, + FontBackgroundColor, + FontColor, + FontFamily, + FontSize, + Link, + List, + ListProperties, + BlockQuote, + Highlight, + Table, + TableCaption, + TableCellProperties, + TableColumnResize, + TableLayout, + TableProperties, + TableToolbar, + HorizontalLine, + HtmlEmbed, + SourceEditing, + RemoveFormat, + Strikethrough, + Subscript, + Superscript, + Underline, + TextTransformation, + TodoList, + Indent, + IndentBlock, + MediaEmbed, + Image, + ImageToolbar, + ImageCaption, + ImageStyle, + ImageResize, + ImageUpload, + ImageInsert, + ImageInsertUI, + ImageInsertViaUrl, + ImageInline, + ImageBlock, + LinkImage, + AutoImage, + PictureEditing, + PlainTableOutput, + FileRepository, + GeneralHtmlSupport, + AssetBrowserPlugin, +]; diff --git a/assets/editor/toolbar.ts b/assets/editor/toolbar.ts new file mode 100644 index 0000000..b711b54 --- /dev/null +++ b/assets/editor/toolbar.ts @@ -0,0 +1,50 @@ +export const DEFAULT_TOOLBAR = [ + 'undo', + 'redo', + '|', + 'heading', + '|', + 'fontSize', + 'fontFamily', + 'fontColor', + 'fontBackgroundColor', + '|', + 'bold', + 'italic', + 'underline', + 'strikethrough', + 'subscript', + 'superscript', + 'code', + 'removeFormat', + '|', + 'link', + 'highlight', + 'alignment', + '|', + 'bulletedList', + 'numberedList', + 'todoList', + '|', + 'blockQuote', + 'insertTable', + 'insertTableLayout', + 'insertImage', + 'sourceEditing', + 'htmlEmbed', + 'mediaEmbed', + 'assetBrowser', + '|', + 'horizontalLine', + 'outdent', + 'indent', +]; + +export const DEFAULT_IMAGE_TOOLBAR = [ + 'imageStyle:inline', + 'imageStyle:wrapText', + 'imageStyle:breakText', + '|', + 'toggleImageCaption', + 'imageTextAlternative', +]; diff --git a/assets/editor/uploadAdapter.ts b/assets/editor/uploadAdapter.ts new file mode 100644 index 0000000..f54c273 --- /dev/null +++ b/assets/editor/uploadAdapter.ts @@ -0,0 +1,88 @@ +export default class EditorUploadAdapter { + constructor(loader, options = {}) { + this.loader = loader; + this.endpoint = options.endpoint || '/editor/upload'; + this.headers = options.headers || {}; + this.withCredentials = options.withCredentials !== false; + this.xhr = null; + } + + upload() { + return this.loader.file.then((file) => new Promise((resolve, reject) => { + const xhr = this.xhr = new XMLHttpRequest(); + const formData = new FormData(); + + xhr.open('POST', this.endpoint, true); + xhr.responseType = 'json'; + xhr.withCredentials = this.withCredentials; + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + + Object.entries(this.headers).forEach(([key, value]) => { + xhr.setRequestHeader(key, value); + }); + + xhr.upload.addEventListener('progress', (event) => { + if (!event.lengthComputable) { + return; + } + + this.loader.uploadTotal = event.total; + this.loader.uploaded = event.loaded; + }); + + xhr.addEventListener('error', () => { + reject('The file could not be uploaded.'); + }); + + xhr.addEventListener('abort', () => { + reject('The upload was aborted.'); + }); + + xhr.addEventListener('load', () => { + const response = xhr.response || parseResponse(xhr.responseText); + + if (!response) { + reject('The upload response was empty.'); + return; + } + + if (response.error?.message) { + reject(response.error.message); + return; + } + + if (typeof response.url === 'string' && response.url.length > 0) { + resolve({ + default: response.url, + }); + return; + } + + reject('The upload response did not include a file URL.'); + }); + + formData.append('upload', file); + formData.append('fileName', file.name); + + xhr.send(formData); + })); + } + + abort() { + if (this.xhr) { + this.xhr.abort(); + } + } +} + +function parseResponse(responseText) { + if (typeof responseText !== 'string' || responseText.trim() === '') { + return null; + } + + try { + return JSON.parse(responseText); + } catch (_error) { + return null; + } +} diff --git a/assets/vue/components/base/CkEditorField.vue b/assets/vue/components/base/CkEditorField.vue index 9a56ad6..2f8e3b3 100644 --- a/assets/vue/components/base/CkEditorField.vue +++ b/assets/vue/components/base/CkEditorField.vue @@ -1,5 +1,5 @@ diff --git a/composer.json b/composer.json index 2b1dd66..fdfa974 100755 --- a/composer.json +++ b/composer.json @@ -57,7 +57,8 @@ "symfony/security-bundle": "^6.4", "tatevikgr/rest-api-client": "dev-dev", "phplist/phplist-lan-texts": "^2021.05", - "psr/simple-cache": "^3.0" + "psr/simple-cache": "^3.0", + "ext-fileinfo": "*" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/src/Controller/AuthController.php b/src/Controller/AuthController.php index 7a06a17..00182f5 100755 --- a/src/Controller/AuthController.php +++ b/src/Controller/AuthController.php @@ -7,6 +7,8 @@ use Exception; use GuzzleHttp\Exception\GuzzleException; use PhpList\RestApiClient\Endpoint\AuthClient; +use PhpList\RestApiClient\Exception\ApiException; +use PhpList\RestApiClient\Exception\AuthenticationException; use PhpList\WebFrontend\Trait\RedirectValidationTrait; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -20,7 +22,7 @@ class AuthController extends AbstractController use RedirectValidationTrait; public function __construct( - private readonly AuthClient $authClient, + private readonly AuthClient $authClient, private readonly LoggerInterface $logger ) { } @@ -88,12 +90,17 @@ public function about(): JsonResponse { try { $user = $this->authClient->getSessionUser(); - } catch (Exception | GuzzleException $e) { + } catch (AuthenticationException) { + return new JsonResponse( + ['error' => 'Unable to load current user.'], + 401 + ); + } catch (ApiException $e) { $this->logger->error('Unable to load current user: ' . $e->getMessage()); return new JsonResponse( ['error' => 'Unable to load current user.'], - Response::HTTP_SERVICE_UNAVAILABLE + Response::HTTP_BAD_GATEWAY ); } diff --git a/src/Controller/BaseController.php b/src/Controller/BaseController.php index b796bff..142ad6f 100644 --- a/src/Controller/BaseController.php +++ b/src/Controller/BaseController.php @@ -23,6 +23,10 @@ protected function getAdmin(): ?Administrator try { $admin = $this->authClient->getSessionUser(); } catch (ApiException | AuthenticationException | AuthorizationException) { + // Best-effort context: knowing whether an admin is viewing is optional, so any + // failure (including an upstream ApiException) degrades to "anonymous visitor" + // rather than breaking public pages. Callers that need the API result to succeed + // must not rely on this method. $admin = null; } diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php index 6e865e7..aea6970 100755 --- a/src/Controller/DashboardController.php +++ b/src/Controller/DashboardController.php @@ -4,7 +4,6 @@ namespace PhpList\WebFrontend\Controller; -use PhpList\RestApiClient\Exception\AuthenticationException; use PhpList\RestApiClient\Exception\AuthorizationException; use PhpList\RestApiClient\Endpoint\StatisticsClient; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -27,7 +26,7 @@ public function index(Request $request): Response try { $stats = $this->statisticsClient->getDashboardStats(); $dashboardStats = $this->buildDashboardStats($stats); - } catch (AuthenticationException | AuthorizationException $e) { + } catch (AuthorizationException $e) { $dashboardError = $e->getMessage() ?: 'Unable to load dashboard statistics.'; } diff --git a/src/Controller/EditorUploadController.php b/src/Controller/EditorUploadController.php new file mode 100644 index 0000000..743d9d7 --- /dev/null +++ b/src/Controller/EditorUploadController.php @@ -0,0 +1,72 @@ +files->get('upload'); + + if (!$uploadedFile) { + return new JsonResponse([ + 'error' => [ + 'message' => 'No file was provided.', + ], + ], Response::HTTP_BAD_REQUEST); + } + + try { + $result = $this->editorUploadService->storeImage($uploadedFile); + } catch (UpstreamServiceException $exception) { + return new JsonResponse([ + 'error' => [ + 'message' => $exception->getMessage(), + ], + ], Response::HTTP_BAD_GATEWAY); + } + + return new JsonResponse([ + 'url' => $result->relativeUrl, + 'fileName' => $result->fileName, + ]); + } + + #[Route('/assets', name: 'assets', methods: ['GET'])] + public function assets(): JsonResponse + { + try { + $assets = $this->editorUploadService->listAssets(); + } catch (UpstreamServiceException $exception) { + return new JsonResponse([ + 'error' => [ + 'message' => $exception->getMessage(), + ], + ], Response::HTTP_BAD_GATEWAY); + } + + return new JsonResponse([ + 'items' => array_map( + static fn ($asset) => $asset->toArray(), + $assets + ), + ]); + } +} diff --git a/src/Controller/PublicSubscribeController.php b/src/Controller/PublicSubscribeController.php index c0ef1ec..2bab727 100644 --- a/src/Controller/PublicSubscribeController.php +++ b/src/Controller/PublicSubscribeController.php @@ -9,7 +9,6 @@ use PhpList\Core\Domain\Configuration\Service\Provider\DefaultConfigProvider; use PhpList\RestApiClient\Endpoint\AuthClient; use PhpList\RestApiClient\Endpoint\SubscribePagesClient; -use PhpList\RestApiClient\Exception\ApiException; use PhpList\RestApiClient\Exception\ValidationException; use PhpList\RestApiClient\Request\SubscribePage\PublicSubscriptionRequest; use PhpList\WebFrontend\Service\LanguageService; @@ -139,7 +138,10 @@ public function subscribe(Request $request, int $pageId): Response } $successHtml = trim((string) ($pageData['thankyoupage'] ?? '')); - } catch (ValidationException | ApiException $exception) { + } catch (ValidationException $exception) { + // Genuine client-input rejection from the API: show it inline on the form. + // A bare ApiException (upstream failure) is intentionally left to propagate + // so the global handler can map it to a 502 rather than masking it here. $errorMessages[] = $exception->getMessage(); } } diff --git a/src/Dto/EditorAssetItem.php b/src/Dto/EditorAssetItem.php new file mode 100644 index 0000000..ef010d6 --- /dev/null +++ b/src/Dto/EditorAssetItem.php @@ -0,0 +1,30 @@ + $this->fileName, + 'url' => $this->url, + 'mimeType' => $this->mimeType, + 'size' => $this->size, + 'modifiedAt' => $this->modifiedAt, + 'isImage' => $this->isImage, + ]; + } +} diff --git a/src/Dto/EditorUploadResult.php b/src/Dto/EditorUploadResult.php new file mode 100644 index 0000000..c2aa731 --- /dev/null +++ b/src/Dto/EditorUploadResult.php @@ -0,0 +1,14 @@ + 'onKernelException', + ]; + } + + public function onKernelException(ExceptionEvent $event): void + { + $exception = $event->getThrowable(); + // An ApiException whose status is a transport failure (code 0) or a 5xx means the + // upstream REST API failed or was unreachable. Surface that as a 502 Bad Gateway + // rather than letting it fall through to a generic 500, which would wrongly imply the + // fault is in this frontend. Client-shaped subclasses (NotFoundException 404, + // ValidationException 400/422) carry their own status and are left untouched here. + if ($exception instanceof ApiException && $this->isUpstreamFailure($exception)) { + $message = 'The upstream service is currently unavailable. Please try again later.'; + + if ($event->getRequest()->isXmlHttpRequest()) { + $event->setResponse(new JsonResponse([ + 'error' => 'upstream_unavailable', + 'message' => $message, + ], Response::HTTP_BAD_GATEWAY)); + + return; + } + + $event->setResponse(new Response($message, Response::HTTP_BAD_GATEWAY, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + ])); + } + } + + private function isUpstreamFailure(ApiException $exception): bool + { + $statusCode = $exception->getStatusCode(); + + // Code 0 is a transport error (connection refused/timeout); >= 500 is an upstream 5xx. + return $statusCode === 0 || $statusCode >= 500; + } +} diff --git a/src/Exception/UpstreamServiceException.php b/src/Exception/UpstreamServiceException.php new file mode 100644 index 0000000..0abcc12 --- /dev/null +++ b/src/Exception/UpstreamServiceException.php @@ -0,0 +1,19 @@ +validateUploadedFile($uploadedFile); + + // The API validates the extension of the transmitted filename, and the REST + // client derives that filename from the path's basename. The framework stores + // uploads under an extensionless temp name (e.g. /tmp/phpAB12), so hand the + // client a path whose basename carries the real extension. + $uploadPath = $this->prepareUploadPath($uploadedFile, $tempPath); + + try { + $response = $this->uploadsClient->upload($uploadPath, 'upload'); + } catch (AuthenticationException | AuthorizationException $e) { + throw $e; + } catch (Exception $e) { + throw new UpstreamServiceException('Upload failed: ' . $e->getMessage(), 0, $e); + } finally { + if ($uploadPath !== $tempPath && is_file($uploadPath)) { + unlink($uploadPath); + } + } + + $fileName = $response['fileName'] + ?? ($uploadedFile->getClientOriginalName() ?: basename($uploadPath)); + $relativeUrl = $response['url'] ?? $this->buildRelativeUrl($fileName); + + return new EditorUploadResult( + fileName: $fileName, + relativeUrl: $relativeUrl, + ); + } + + /** + * @throws AuthorizationException + * @throws AuthenticationException + * @throws UpstreamServiceException on API/transport failure + * @return array + */ + public function listAssets(): array + { + try { + $response = $this->uploadsClient->getUploads(self::UPLOAD_DIRECTORY); + } catch (AuthenticationException | AuthorizationException $e) { + throw $e; + } catch (NotFoundException) { + // The upload directory is created lazily on first upload; treat a missing + // directory as an empty asset list rather than an error. + return []; + } catch (Exception $e) { + throw new UpstreamServiceException('Failed to list assets: ' . $e->getMessage(), 0, $e); + } + + $files = $response['files'] ?? []; + + return $this->loadItems($files); + } + + public function buildRelativeUrl(string $fileName): string + { + return '/' . self::UPLOAD_DIRECTORY . '/' . rawurlencode(basename($fileName)); + } + + public function getTargetDirectory(): string + { + return '/' . self::UPLOAD_DIRECTORY; + } + + /** + * Return a filesystem path whose basename carries the upload's real extension so + * the API can validate it. Returns the original temp path when no extension can be + * determined or the copy fails. + */ + private function prepareUploadPath(UploadedFile $uploadedFile, string $tempPath): string + { + $extension = $uploadedFile->getClientOriginalExtension(); + if ($extension === '') { + $extension = (string) $uploadedFile->guessExtension(); + } + + if ($extension === '' || pathinfo($tempPath, PATHINFO_EXTENSION) !== '') { + return $tempPath; + } + + $namedPath = $tempPath . '.' . strtolower($extension); + if (!copy($tempPath, $namedPath)) { + return $tempPath; + } + + return $namedPath; + } + + private function loadItems(array $files): array + { + $items = []; + foreach ($files as $file) { + $fileName = (string) ($file['name'] ?? ''); + if ($fileName === '') { + continue; + } + + if (($file['type'] ?? '') === 'directory') { + continue; + } + + $mimeType = (string) ($file['mime_type'] ?? $this->guessMimeType($fileName)); + + $items[] = new EditorAssetItem( + fileName: $fileName, + url: (string) ($file['path'] ?? $this->buildRelativeUrl($fileName)), + mimeType: $mimeType, + size: (int) ($file['size'] ?? 0), + modifiedAt: (int) ($file['modified'] ?? time()), + isImage: str_starts_with($mimeType, 'image/'), + ); + } + + // Newest first, so the most recently uploaded assets surface at the top of the picker. + usort( + $items, + static fn (EditorAssetItem $as1, EditorAssetItem $as2): int => $as2->modifiedAt <=> $as1->modifiedAt + ); + + return $items; + } + + private function guessMimeType(string $fileName): string + { + return match (strtolower(pathinfo($fileName, PATHINFO_EXTENSION))) { + 'jpg', 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'bmp' => 'image/bmp', + 'svg' => 'image/svg+xml', + default => 'application/octet-stream', + }; + } + + private function validateUploadedFile(UploadedFile $uploadedFile): string + { + if (!$uploadedFile->isValid()) { + throw new RuntimeException($uploadedFile->getErrorMessage()); + } + + if ($uploadedFile->getSize() > self::MAX_FILE_SIZE) { + throw new RuntimeException(sprintf( + 'The uploaded file exceeds the maximum allowed size of %d MB.', + intdiv(self::MAX_FILE_SIZE, 1024 * 1024) + )); + } + + $mimeType = (string) $uploadedFile->getMimeType(); + if (!str_starts_with($mimeType, 'image/')) { + throw new RuntimeException('Only image uploads are supported.'); + } + + $tempPath = $uploadedFile->getRealPath(); + if (!is_string($tempPath) || $tempPath === '') { + throw new RuntimeException('Failed to get uploaded file path.'); + } + + return $tempPath; + } +} diff --git a/tests/Integration/Controller/DashboardControllerTest.php b/tests/Integration/Controller/DashboardControllerTest.php index 309bd9c..a24a786 100644 --- a/tests/Integration/Controller/DashboardControllerTest.php +++ b/tests/Integration/Controller/DashboardControllerTest.php @@ -57,7 +57,7 @@ public function testDashboardRendersSpaPayloadWithStats(): void self::assertStringContainsString('data-dashboard-error=""', $content); } - public function testDashboardRendersDashboardErrorWhenAuthFails(): void + public function testDashboardPropagatesAuthenticationExceptionForLoginRedirect(): void { self::bootKernel(); @@ -74,12 +74,12 @@ public function testDashboardRendersDashboardErrorWhenAuthFails(): void $session->set('auth_token', 'integration-token'); $request->setSession($session); - $response = $controller->index($request); - $content = (string) $response->getContent(); + // An expired session must not be rendered inline: the controller lets the + // AuthenticationException propagate so UnauthorizedSubscriber redirects to /login. + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Session expired'); - self::assertSame(200, $response->getStatusCode()); - self::assertStringContainsString('data-dashboard-error="Session expired"', $content); - self::assertStringContainsString('data-dashboard-stats="[]"', $content); + $controller->index($request); } public function testDashboardRendersDashboardErrorWhenAuthorizationFails(): void diff --git a/tests/Integration/Controller/EditorUploadControllerTest.php b/tests/Integration/Controller/EditorUploadControllerTest.php new file mode 100644 index 0000000..44ef79a --- /dev/null +++ b/tests/Integration/Controller/EditorUploadControllerTest.php @@ -0,0 +1,151 @@ +get('router'); + + self::assertSame('/editor/upload', $router->generate('editor_upload')); + } + + public function testAssetsRouteIsRegistered(): void + { + self::bootKernel(); + + /** @var RouterInterface $router */ + $router = static::getContainer()->get('router'); + + self::assertSame('/editor/assets', $router->generate('editor_assets')); + } + + public function testUploadReturnsAJsonUrl(): void + { + $uploadsClient = $this->createMock(UploadsClient::class); + $uploadsClient->expects(self::once()) + ->method('upload') + ->willReturn([ + 'fileName' => 'newsletter-image.jpg', + 'url' => '/uploadimages/newsletter-image.jpg', + ]); + + $controller = new EditorUploadController(new EditorUploadService($uploadsClient)); + + $response = $controller->upload($this->createUploadRequest()); + + self::assertSame(200, $response->getStatusCode()); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame('/uploadimages/newsletter-image.jpg', $payload['url']); + self::assertSame('newsletter-image.jpg', $payload['fileName']); + } + + public function testUploadReturnsBadRequestWhenNoFileProvided(): void + { + $uploadsClient = $this->createMock(UploadsClient::class); + $uploadsClient->expects(self::never())->method('upload'); + + $controller = new EditorUploadController(new EditorUploadService($uploadsClient)); + + $response = $controller->upload(Request::create('/editor/upload', 'POST')); + + self::assertSame(400, $response->getStatusCode()); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertSame('No file was provided.', $payload['error']['message']); + } + + public function testUploadReturnsBadGatewayWhenApiFails(): void + { + $uploadsClient = $this->createMock(UploadsClient::class); + $uploadsClient->method('upload') + ->willThrowException(new ApiException('boom', 500)); + + $controller = new EditorUploadController(new EditorUploadService($uploadsClient)); + + $response = $controller->upload($this->createUploadRequest()); + + self::assertSame(502, $response->getStatusCode()); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertSame('Upload failed: boom', $payload['error']['message']); + } + + public function testAssetsReturnsMappedItems(): void + { + $uploadsClient = $this->createMock(UploadsClient::class); + $uploadsClient->expects(self::once()) + ->method('getUploads') + ->with('uploadimages') + ->willReturn([ + 'files' => [ + [ + 'name' => 'asset.png', + 'path' => '/uploadimages/asset.png', + 'size' => 12, + 'type' => 'file', + 'modified' => 100, + ], + ], + ]); + + $controller = new EditorUploadController(new EditorUploadService($uploadsClient)); + + $response = $controller->assets(); + self::assertSame(200, $response->getStatusCode()); + + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertCount(1, $payload['items']); + self::assertSame('asset.png', $payload['items'][0]['fileName']); + self::assertTrue($payload['items'][0]['isImage']); + } + + public function testAssetsReturnsBadGatewayWhenApiFails(): void + { + $uploadsClient = $this->createMock(UploadsClient::class); + $uploadsClient->method('getUploads') + ->willThrowException(new ApiException('unavailable', 500)); + + $controller = new EditorUploadController(new EditorUploadService($uploadsClient)); + + $response = $controller->assets(); + + self::assertSame(502, $response->getStatusCode()); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertSame('Failed to list assets: unavailable', $payload['error']['message']); + } + + private function createUploadRequest(): Request + { + $sourceFile = tempnam(sys_get_temp_dir(), 'editor-upload-'); + self::assertIsString($sourceFile); + file_put_contents($sourceFile, base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3Z4foAAAAASUVORK5CYII=' + )); + + $uploadedFile = new UploadedFile($sourceFile, 'newsletter-image.jpg', 'image/png', null, true); + + $request = Request::create('/editor/upload', 'POST', [], [], ['upload' => $uploadedFile]); + $session = new Session(new MockArraySessionStorage()); + $session->set('auth_token', 'integration-token'); + $request->setSession($session); + + return $request; + } +} diff --git a/tests/Unit/Controller/AuthControllerTest.php b/tests/Unit/Controller/AuthControllerTest.php index dced01b..261d9bf 100644 --- a/tests/Unit/Controller/AuthControllerTest.php +++ b/tests/Unit/Controller/AuthControllerTest.php @@ -5,6 +5,7 @@ namespace PhpList\WebFrontend\Tests\Unit\Controller; use PhpList\RestApiClient\Entity\Administrator; +use PhpList\RestApiClient\Exception\ApiException; use PhpList\WebFrontend\Controller\AuthController; use PhpList\RestApiClient\Endpoint\AuthClient; use PHPUnit\Framework\Assert; @@ -317,4 +318,16 @@ public function testAbout(): void $response->getContent() ); } + + public function testAboutReturnsBadGatewayWhenUpstreamFails(): void + { + $this->authClient->expects($this->once()) + ->method('getSessionUser') + ->willThrowException(new ApiException('upstream down', 500)); + + $response = $this->controller->about(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals(Response::HTTP_BAD_GATEWAY, $response->getStatusCode()); + } } diff --git a/tests/Unit/EventSubscriber/ApiExceptionSubscriberTest.php b/tests/Unit/EventSubscriber/ApiExceptionSubscriberTest.php new file mode 100644 index 0000000..653bf22 --- /dev/null +++ b/tests/Unit/EventSubscriber/ApiExceptionSubscriberTest.php @@ -0,0 +1,124 @@ +subscriber = new ApiExceptionSubscriber(); + } + + public function testGetSubscribedEvents(): void + { + $events = ApiExceptionSubscriber::getSubscribedEvents(); + + $this->assertArrayHasKey(KernelEvents::EXCEPTION, $events); + $this->assertEquals('onKernelException', $events[KernelEvents::EXCEPTION]); + } + + public function testOnKernelExceptionWithOtherException(): void + { + $exception = new Exception('Some other error'); + + $request = $this->createMock(Request::class); + + $kernel = $this->createMock(HttpKernelInterface::class); + $event = new ExceptionEvent( + $kernel, + $request, + HttpKernelInterface::MAIN_REQUEST, + $exception + ); + + $this->subscriber->onKernelException($event); + + $this->assertNull($event->getResponse()); + } + + public function testOnKernelExceptionMapsUpstreamApiFailureToBadGateway(): void + { + $apiException = new ApiException('API request failed', 500); + + $request = $this->createMock(Request::class); + $request->method('isXmlHttpRequest')->willReturn(false); + + $kernel = $this->createMock(HttpKernelInterface::class); + $event = new ExceptionEvent( + $kernel, + $request, + HttpKernelInterface::MAIN_REQUEST, + $apiException + ); + + $this->subscriber->onKernelException($event); + + $response = $event->getResponse(); + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals(Response::HTTP_BAD_GATEWAY, $response->getStatusCode()); + } + + public function testOnKernelExceptionMapsUpstreamApiFailureToBadGatewayForXhr(): void + { + // statusCode 0 models a transport failure (connection refused / timeout). + $apiException = new ApiException('API request failed', 0); + + $request = $this->createMock(Request::class); + $request->method('isXmlHttpRequest')->willReturn(true); + + $kernel = $this->createMock(HttpKernelInterface::class); + $event = new ExceptionEvent( + $kernel, + $request, + HttpKernelInterface::MAIN_REQUEST, + $apiException + ); + + $this->subscriber->onKernelException($event); + + $response = $event->getResponse(); + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals(Response::HTTP_BAD_GATEWAY, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertEquals('upstream_unavailable', $data['error']); + } + + public function testOnKernelExceptionLeavesClientShapedApiExceptionUntouched(): void + { + // A ValidationException (400/422) extends ApiException but is a genuine client error; + // it must not be converted to a 502. + $validationException = new ValidationException('Validation failed', 422); + + $request = $this->createMock(Request::class); + $request->method('isXmlHttpRequest')->willReturn(false); + + $kernel = $this->createMock(HttpKernelInterface::class); + $event = new ExceptionEvent( + $kernel, + $request, + HttpKernelInterface::MAIN_REQUEST, + $validationException + ); + + $this->subscriber->onKernelException($event); + + $this->assertNull($event->getResponse()); + } +} diff --git a/tests/Unit/Service/EditorUploadServiceTest.php b/tests/Unit/Service/EditorUploadServiceTest.php new file mode 100644 index 0000000..6895fa4 --- /dev/null +++ b/tests/Unit/Service/EditorUploadServiceTest.php @@ -0,0 +1,227 @@ +createMock(UploadsClient::class); + $uploadsClient->expects(self::once()) + ->method('upload') + ->with( + self::callback(static fn (string $path): bool => str_ends_with($path, '.png')), + 'upload' + ) + ->willReturn([ + 'fileName' => 'hero-image.png', + 'url' => '/uploadimages/hero-image.png', + ]); + + $service = new EditorUploadService($uploadsClient); + $result = $service->storeImage($this->createImageUpload()); + + self::assertSame('hero-image.png', $result->fileName); + self::assertSame('/uploadimages/hero-image.png', $result->relativeUrl); + } + + public function testStoreImageFallsBackToClientNameAndBuiltUrl(): void + { + $uploadsClient = $this->createMock(UploadsClient::class); + $uploadsClient->method('upload')->willReturn([]); + + $service = new EditorUploadService($uploadsClient); + $result = $service->storeImage($this->createImageUpload('hero image.png')); + + self::assertSame('hero image.png', $result->fileName); + self::assertSame('/uploadimages/hero%20image.png', $result->relativeUrl); + } + + public function testStoreImageRejectsNonImageUploads(): void + { + $uploadsClient = $this->createMock(UploadsClient::class); + $uploadsClient->expects(self::never())->method('upload'); + + $sourceFile = tempnam(sys_get_temp_dir(), 'editor-upload-'); + self::assertIsString($sourceFile); + file_put_contents($sourceFile, 'just some plain text, definitely not an image'); + $upload = new UploadedFile($sourceFile, 'notes.txt', 'text/plain', null, true); + + $service = new EditorUploadService($uploadsClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Only image uploads are supported.'); + + $service->storeImage($upload); + } + + public function testStoreImageRejectsFilesExceedingMaxSize(): void + { + $uploadsClient = $this->createMock(UploadsClient::class); + $uploadsClient->expects(self::never())->method('upload'); + + $sourceFile = tempnam(sys_get_temp_dir(), 'editor-upload-'); + self::assertIsString($sourceFile); + // Write just over the 10 MB limit so the size check trips before upload. + file_put_contents($sourceFile, str_repeat("\0", 10 * 1024 * 1024 + 1)); + $upload = new UploadedFile($sourceFile, 'huge.png', 'image/png', null, true); + + $service = new EditorUploadService($uploadsClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The uploaded file exceeds the maximum allowed size of 10 MB.'); + + $service->storeImage($upload); + } + + public function testStoreImageWrapsApiErrorsInUpstreamServiceException(): void + { + $uploadsClient = $this->createMock(UploadsClient::class); + $uploadsClient->method('upload') + ->willThrowException(new ApiException('boom', 500)); + + $service = new EditorUploadService($uploadsClient); + + $this->expectException(UpstreamServiceException::class); + $this->expectExceptionMessage('Upload failed: boom'); + + $service->storeImage($this->createImageUpload()); + } + + public function testStoreImageRethrowsAuthenticationException(): void + { + $uploadsClient = $this->createMock(UploadsClient::class); + $uploadsClient->method('upload') + ->willThrowException(new AuthenticationException('Session expired', 401)); + + $service = new EditorUploadService($uploadsClient); + + $this->expectException(AuthenticationException::class); + + $service->storeImage($this->createImageUpload()); + } + + public function testListAssetsMapsApiFieldsAndSortsByNewestFirst(): void + { + $uploadsClient = $this->createMock(UploadsClient::class); + $uploadsClient->expects(self::once()) + ->method('getUploads') + ->with('uploadimages') + ->willReturn([ + // Real API listing shape: name/path/size/type/modified, no mimeType. + 'files' => [ + [ + 'name' => 'image-one.png', + 'path' => '/uploadimages/image-one.png', + 'size' => 120, + 'type' => 'file', + 'modified' => 100, + ], + [ + 'name' => 'notes.txt', + 'path' => '/uploadimages/notes.txt', + 'size' => 8, + 'type' => 'file', + 'modified' => 200, + ], + ], + ]); + + $service = new EditorUploadService($uploadsClient); + $assets = $service->listAssets(); + + self::assertCount(2, $assets); + self::assertSame('notes.txt', $assets[0]->fileName); + self::assertFalse($assets[0]->isImage); + self::assertSame('/uploadimages/notes.txt', $assets[0]->url); + self::assertSame(200, $assets[0]->modifiedAt); + self::assertSame('image-one.png', $assets[1]->fileName); + self::assertTrue($assets[1]->isImage); + self::assertSame('image/png', $assets[1]->mimeType); + self::assertSame(100, $assets[1]->modifiedAt); + } + + public function testListAssetsSkipsDirectoriesAndUnnamedEntries(): void + { + $uploadsClient = $this->createMock(UploadsClient::class); + $uploadsClient->method('getUploads')->willReturn([ + 'files' => [ + ['type' => 'file'], + ['name' => 'nested', 'type' => 'directory'], + ['name' => 'kept.png', 'type' => 'file', 'path' => '/uploads/kept.png', 'size' => '2MB'], + ], + ]); + + $service = new EditorUploadService($uploadsClient); + $assets = $service->listAssets(); + + self::assertCount(1, $assets); + self::assertSame('kept.png', $assets[0]->fileName); + } + + public function testListAssetsReturnsEmptyWhenDirectoryMissing(): void + { + $uploadsClient = $this->createMock(UploadsClient::class); + $uploadsClient->method('getUploads') + ->willThrowException(new NotFoundException('Directory "uploadimages" not found.', 404)); + + $service = new EditorUploadService($uploadsClient); + + self::assertSame([], $service->listAssets()); + } + + public function testListAssetsWrapsApiErrorsInUpstreamServiceException(): void + { + $uploadsClient = $this->createMock(UploadsClient::class); + $uploadsClient->method('getUploads') + ->willThrowException(new ApiException('unavailable', 500)); + + $service = new EditorUploadService($uploadsClient); + + $this->expectException(UpstreamServiceException::class); + $this->expectExceptionMessage('Failed to list assets: unavailable'); + + $service->listAssets(); + } + + public function testListAssetsRethrowsAuthenticationException(): void + { + $uploadsClient = $this->createMock(UploadsClient::class); + $uploadsClient->method('getUploads') + ->willThrowException(new AuthenticationException('Session expired', 401)); + + $service = new EditorUploadService($uploadsClient); + + $this->expectException(AuthenticationException::class); + + $service->listAssets(); + } + + private function createImageUpload(string $clientName = 'newsletter-image.png'): UploadedFile + { + return new UploadedFile($this->createSourceFile(), $clientName, 'image/png', null, true); + } + + private function createSourceFile(): string + { + $sourceFile = tempnam(sys_get_temp_dir(), 'editor-upload-'); + self::assertIsString($sourceFile); + file_put_contents($sourceFile, base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3Z4foAAAAASUVORK5CYII=' + )); + + return $sourceFile; + } +} diff --git a/tests/Unit/assets/editor/CkEditor.spec.js b/tests/Unit/assets/editor/CkEditor.spec.js new file mode 100644 index 0000000..1848b12 --- /dev/null +++ b/tests/Unit/assets/editor/CkEditor.spec.js @@ -0,0 +1,140 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import CkEditor from '../../../../assets/editor/CkEditor.vue' + +vi.mock('ckeditor5', () => ({ + ClassicEditor: {}, + Alignment: {}, + AutoImage: {}, + AutoLink: {}, + Autosave: {}, + Essentials: {}, + Paragraph: {}, + Bold: {}, + Italic: {}, + Heading: {}, + Code: {}, + FontBackgroundColor: {}, + FontColor: {}, + FontFamily: {}, + FontSize: {}, + Link: {}, + List: {}, + ListProperties: {}, + BlockQuote: {}, + Highlight: {}, + Table: {}, + TableCaption: {}, + TableCellProperties: {}, + TableColumnResize: {}, + TableLayout: {}, + TableProperties: {}, + TableToolbar: {}, + HorizontalLine: {}, + HtmlEmbed: {}, + SourceEditing: {}, + RemoveFormat: {}, + Strikethrough: {}, + Subscript: {}, + Superscript: {}, + Underline: {}, + TextTransformation: {}, + TodoList: {}, + Indent: {}, + IndentBlock: {}, + MediaEmbed: {}, + Image: {}, + ImageToolbar: {}, + ImageCaption: {}, + ImageStyle: {}, + ImageResize: {}, + ImageUpload: {}, + ImageInsert: {}, + ImageInsertUI: {}, + ImageInsertViaUrl: {}, + ImageInline: {}, + ImageBlock: {}, + LinkImage: {}, + PictureEditing: {}, + PlainTableOutput: {}, + FileRepository: {}, + GeneralHtmlSupport: {}, + Plugin: class {}, + ButtonView: class {}, +})) + +vi.mock('@ckeditor/ckeditor5-vue', () => ({ + Ckeditor: { + name: 'ckeditor', + props: [ + 'modelValue', + 'editor', + 'config', + 'id', + 'disabled', + ], + emits: ['update:modelValue', 'ready'], + template: ` +
+ + +
+ `, + }, +})) + +describe('CkEditor', () => { + const createWrapper = (props = {}) => + mount(CkEditor, { + props: { + ...props, + }, + }) + + it('syncs v-model updates', async () => { + const wrapper = createWrapper({ modelValue: '

Initial

' }) + + await wrapper.find('.change-value').trigger('click') + + expect(wrapper.emitted('update:modelValue')).toEqual([ + ['

Updated

'], + ]) + }) + + it('passes the expected editor config', () => { + const wrapper = createWrapper() + + const editor = wrapper.findComponent({ name: 'ckeditor' }) + const config = editor.props('config') + + expect(config.licenseKey).toBe('GPL') + expect(config.toolbar).toContain('insertImage') + expect(config.toolbar).toContain('assetBrowser') + expect(config.htmlSupport.allow[0].name).toBeDefined() + }) + + it('exposes an asset picker hook in the editor config', () => { + const wrapper = createWrapper() + + const editor = wrapper.findComponent({ name: 'ckeditor' }) + const config = editor.props('config') + + expect(typeof config.openAssetPicker).toBe('function') + }) + + it('disables the editor when readonly is set', () => { + const wrapper = createWrapper({ readonly: true }) + + const editor = wrapper.findComponent({ name: 'ckeditor' }) + expect(editor.props('disabled')).toBe(true) + }) +}) diff --git a/tests/Unit/assets/editor/uploadAdapter.spec.js b/tests/Unit/assets/editor/uploadAdapter.spec.js new file mode 100644 index 0000000..7f60758 --- /dev/null +++ b/tests/Unit/assets/editor/uploadAdapter.spec.js @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import EditorUploadAdapter from '../../../../assets/editor/uploadAdapter.ts' + +const originalXhr = global.XMLHttpRequest + +describe('EditorUploadAdapter', () => { + let listeners + let sendMock + + beforeEach(() => { + listeners = {} + sendMock = vi.fn() + + global.XMLHttpRequest = class { + constructor() { + this.upload = { + addEventListener: (event, handler) => { + listeners[`upload:${event}`] = handler + }, + } + this.addEventListener = (event, handler) => { + listeners[event] = handler + } + this.open = vi.fn() + this.setRequestHeader = vi.fn() + this.send = sendMock + this.abort = vi.fn() + this.response = null + this.responseText = '' + this.responseType = '' + this.withCredentials = false + } + } + }) + + afterEach(() => { + global.XMLHttpRequest = originalXhr + }) + + it('uploads files and resolves the returned URL', async () => { + const loader = { + file: Promise.resolve(new File(['image'], 'banner.png', { type: 'image/png' })), + } + + const adapter = new EditorUploadAdapter(loader, { + endpoint: '/editor/upload', + }) + + const uploadPromise = adapter.upload() + await Promise.resolve() + const xhr = adapter.xhr + + xhr.response = { url: '/uploadimages/ckeditor5/banner.png' } + listeners.load() + + await expect(uploadPromise).resolves.toEqual({ + default: '/uploadimages/ckeditor5/banner.png', + }) + + expect(sendMock).toHaveBeenCalledOnce() + expect(xhr.open).toHaveBeenCalledWith('POST', '/editor/upload', true) + }) + + it('rejects when the backend returns an error message', async () => { + const loader = { + file: Promise.resolve(new File(['image'], 'banner.png', { type: 'image/png' })), + } + + const adapter = new EditorUploadAdapter(loader) + const uploadPromise = adapter.upload() + await Promise.resolve() + + adapter.xhr.response = { error: { message: 'Upload failed.' } } + listeners.load() + + await expect(uploadPromise).rejects.toBe('Upload failed.') + }) +}) diff --git a/tests/Unit/assets/vue/components/base/CkEditorField.spec.js b/tests/Unit/assets/vue/components/base/CkEditorField.spec.js index 25047ce..21bf85a 100644 --- a/tests/Unit/assets/vue/components/base/CkEditorField.spec.js +++ b/tests/Unit/assets/vue/components/base/CkEditorField.spec.js @@ -1,50 +1,30 @@ -// CkEditorField.spec.js - import { describe, it, expect, vi } from 'vitest' import { mount } from '@vue/test-utils' import CkEditorField from '../../../../../../assets/vue/components/base/CkEditorField.vue' -vi.mock('ckeditor5', () => ({ - ClassicEditor: {}, - Essentials: {}, - Paragraph: {}, - Bold: {}, - Italic: {}, - Heading: {}, - Link: {}, - List: {}, - BlockQuote: {}, - Table: {}, - TableToolbar: {}, - HorizontalLine: {}, - Image: {}, - ImageToolbar: {}, - ImageCaption: {}, - ImageStyle: {}, - ImageResize: {}, - AutoImage: {}, - PictureEditing: {}, -})) - -const CkeditorStub = { - name: 'ckeditor', +const CkEditorStub = { + name: 'CkEditor', props: [ 'modelValue', - 'editor', - 'config', 'id', + 'readonly', + 'disabled', + 'minHeight', + 'uploadEndpoint', + 'uploadHeaders', + 'withCredentials', + 'toolbar', + 'plugins', + 'config', ], emits: ['update:modelValue'], template: ` -
- -
- `, +
+ +
+ `, } describe('CkEditorField', () => { @@ -55,7 +35,7 @@ describe('CkEditorField', () => { }, global: { stubs: { - ckeditor: CkeditorStub, + CkEditor: CkEditorStub, }, }, }) @@ -80,8 +60,7 @@ describe('CkEditorField', () => { modelValue: '

Hello

', }) - const editor = - wrapper.findComponent(CkeditorStub) + const editor = wrapper.findComponent(CkEditorStub) expect(editor.props('modelValue')) .toBe('

Hello

') @@ -130,55 +109,33 @@ describe('CkEditorField', () => { id: 'editor-1', }) - const editor = - wrapper.findComponent(CkeditorStub) + const editor = wrapper.findComponent(CkEditorStub) expect(editor.props('id')) .toBe('editor-1') }) - it('passes editor instance to ckeditor', () => { - const wrapper = createWrapper() - - const editor = - wrapper.findComponent(CkeditorStub) - - expect(editor.props('editor')) - .toBeDefined() - }) - - it('passes editor config to ckeditor', () => { + it('passes editor options through to the editor component', () => { const wrapper = createWrapper() - const editor = - wrapper.findComponent(CkeditorStub) + const editor = wrapper.findComponent(CkEditorStub) - const config = editor.props('config') + expect(editor.props('uploadEndpoint')) + .toBe('/editor/upload') - expect(config.licenseKey) - .toBe('GPL') - - expect(config.toolbar) - .toContain('bold') - - expect(config.toolbar) - .toContain('italic') - - expect(config.toolbar) - .toContain('insertTable') + expect(editor.props('minHeight')) + .toBe(300) }) - it('contains image toolbar configuration', () => { - const wrapper = createWrapper() - - const config = - wrapper.findComponent(CkeditorStub) - .props('config') - - expect(config.image.toolbar) - .toContain('toggleImageCaption') + it('forwards custom config and toolbar props', () => { + const toolbar = ['undo', 'bold'] + const wrapper = createWrapper({ + toolbar, + config: { placeholder: 'Write here' }, + }) - expect(config.image.toolbar) - .toContain('imageTextAlternative') + const editor = wrapper.findComponent(CkEditorStub) + expect(editor.props('toolbar')).toEqual(toolbar) + expect(editor.props('config')).toEqual({ placeholder: 'Write here' }) }) }) diff --git a/vitest.config.mjs b/vitest.config.mjs index df36cd0..6bd1776 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -6,6 +6,10 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, - include: ['assets/vue/**/*.spec.js', 'tests/Unit/assets/vue/**/*.spec.js'], + include: [ + 'assets/vue/**/*.spec.js', + 'tests/Unit/assets/vue/**/*.spec.js', + 'tests/Unit/assets/editor/**/*.spec.js', + ], }, })