diff --git a/.github/workflows/administration-tests.yml b/.github/workflows/administration-tests.yml new file mode 100644 index 00000000..f1831eef --- /dev/null +++ b/.github/workflows/administration-tests.yml @@ -0,0 +1,31 @@ +name: Administration Tests + +on: + pull_request: + paths: + - 'src/Resources/app/administration/**' + - 'tests/administration/**' + - '.github/workflows/administration-tests.yml' + push: + branches: + - main + paths: + - 'src/Resources/app/administration/**' + - 'tests/administration/**' + - '.github/workflows/administration-tests.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + administration: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Run administration tests + working-directory: src/Resources/app/administration + run: npm test diff --git a/src/Resources/app/administration/package.json b/src/Resources/app/administration/package.json index 681ba0eb..d80a1eb8 100755 --- a/src/Resources/app/administration/package.json +++ b/src/Resources/app/administration/package.json @@ -2,6 +2,9 @@ "name": "administration", "version": "1.0.0", "license": "MIT", + "scripts": { + "test": "node --test ../../../../tests/administration/*.test.mjs" + }, "dependencies": { "slugify": "^1.6.6", "vite": "^6.0.11" diff --git a/src/Resources/app/administration/src/init/cms-page-type.init.js b/src/Resources/app/administration/src/init/cms-page-type.init.js index 27b47b6e..94ff1591 100644 --- a/src/Resources/app/administration/src/init/cms-page-type.init.js +++ b/src/Resources/app/administration/src/init/cms-page-type.init.js @@ -5,4 +5,6 @@ const pageTypeService = Shopware.Service('cmsPageTypeService'); pageTypeService.register({ name: BLOG.PAGE_TYPES.BLOG_DETAIL, icon: 'regular-file-text', + // blog pages are created in the blog module together with their blog entry + hideInList: true, }); diff --git a/src/Resources/app/administration/src/module/blog-module/extension/sw-cms/index.js b/src/Resources/app/administration/src/module/blog-module/extension/sw-cms/index.js index 4efbca7d..84a8802f 100644 --- a/src/Resources/app/administration/src/module/blog-module/extension/sw-cms/index.js +++ b/src/Resources/app/administration/src/module/blog-module/extension/sw-cms/index.js @@ -5,3 +5,4 @@ Component.override( () => import('./component/sw-cms-sidebar') ); Component.override('sw-cms-list', () => import('./page/sw-cms-list')); +Component.override('sw-cms-detail', () => import('./page/sw-cms-detail')); diff --git a/src/Resources/app/administration/src/module/blog-module/extension/sw-cms/page/sw-cms-detail/index.js b/src/Resources/app/administration/src/module/blog-module/extension/sw-cms/page/sw-cms-detail/index.js new file mode 100644 index 00000000..af19c89d --- /dev/null +++ b/src/Resources/app/administration/src/module/blog-module/extension/sw-cms/page/sw-cms-detail/index.js @@ -0,0 +1,59 @@ +const { + Data: { Criteria }, +} = Shopware; + +export default { + data() { + return { + isBlogPageLookupCancelled: false, + }; + }, + + beforeUnmount() { + this.isBlogPageLookupCancelled = true; + }, + + methods: { + async createdComponent() { + const route = this.$route; + const blogEntry = await this.findBlogEntryForPage(route.params.id); + + // A late response must not redirect or reset stores after navigation. + if (this.isBlogPageLookupCancelled || this.$route !== route) { + return; + } + + if (blogEntry) { + // blog pages belong to the blog module, the core editor has no blog sidebar + this.$router.replace({ + name: 'blog.module.detail', + params: { id: blogEntry.id }, + }); + + return; + } + + this.$super('createdComponent'); + }, + + async findBlogEntryForPage(pageId) { + if (!pageId) { + return null; + } + + const criteria = new Criteria(1, 1); + criteria.addFilter(Criteria.equals('cmsPageId', pageId)); + + try { + const blogEntries = await this.repositoryFactory + .create('werkl_blog_entry') + .search(criteria, Shopware.Context.api); + + return blogEntries.first(); + } catch { + // a failing lookup must not block the core editor + return null; + } + }, + }, +}; diff --git a/tests/administration/cms-detail-routing.test.mjs b/tests/administration/cms-detail-routing.test.mjs new file mode 100644 index 00000000..ef4b79fa --- /dev/null +++ b/tests/administration/cms-detail-routing.test.mjs @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; + +class Criteria { + constructor(page, limit) { + this.page = page; + this.limit = limit; + this.filters = []; + } + + addFilter(filter) { + this.filters.push(filter); + } + + static equals(field, value) { + return { field, value }; + } +} + +const apiContext = {}; +globalThis.Shopware = { Data: { Criteria }, Context: { api: apiContext } }; + +// Load the actual administration override without a Vite build. +const source = await readFile(new URL( + '../../src/Resources/app/administration/src/module/blog-module/extension/sw-cms/page/sw-cms-detail/index.js', + import.meta.url +), 'utf8'); +const { default: override } = await import( + `data:text/javascript;base64,${Buffer.from(source).toString('base64')}` +); + +function createInstance(search, id = 'cms-page-a') { + const calls = []; + const instance = { + ...override.data?.(), + ...override.methods, + $route: { name: 'sw.cms.detail', params: { id } }, + $router: { replace: (route) => calls.push({ redirect: route }) }, + $super: (method) => calls.push({ core: method }), + repositoryFactory: { + create(entity) { + assert.equal(entity, 'werkl_blog_entry'); + return { search }; + }, + }, + }; + + return { instance, calls }; +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const result = (entry) => ({ first: () => entry }); + +test('a linked CMS page redirects to its blog entry', async () => { + const { instance, calls } = createInstance(async (criteria, context) => { + assert.equal(criteria.limit, 1); + assert.deepEqual(criteria.filters, [{ field: 'cmsPageId', value: 'cms-page-a' }]); + assert.equal(context, apiContext); + return result({ id: 'blog-entry-a' }); + }); + + await instance.createdComponent(); + assert.deepEqual(calls, [{ + redirect: { name: 'blog.module.detail', params: { id: 'blog-entry-a' } }, + }]); +}); + +test('an unlinked CMS page opens in the core editor', async () => { + const { instance, calls } = createInstance(async () => result(null)); + await instance.createdComponent(); + assert.deepEqual(calls, [{ core: 'createdComponent' }]); +}); + +test('an API failure falls back to the core editor', async () => { + const { instance, calls } = createInstance(async () => { throw new Error('API unavailable'); }); + await instance.createdComponent(); + assert.deepEqual(calls, [{ core: 'createdComponent' }]); +}); + +test('a route without a page ID never searches for a blog entry', async () => { + const { instance, calls } = createInstance(() => assert.fail('Unexpected lookup'), null); + await instance.createdComponent(); + assert.deepEqual(calls, [{ core: 'createdComponent' }]); +}); + +for (const entry of [{ id: 'blog-entry-a' }, null]) { + const kind = entry ? 'linked' : 'unlinked'; + + test(`a late ${kind} result cannot affect another route`, async () => { + const response = deferred(); + const { instance, calls } = createInstance(() => response.promise); + const pending = instance.createdComponent(); + instance.$route = { name: 'sw.product.detail', params: { id: 'product-b' } }; + response.resolve(result(entry)); + await pending; + assert.deepEqual(calls, []); + }); + + test(`a late ${kind} result cannot affect an unmounted component`, async () => { + const response = deferred(); + const { instance, calls } = createInstance(() => response.promise); + const pending = instance.createdComponent(); + override.beforeUnmount?.call(instance); + response.resolve(result(entry)); + await pending; + assert.deepEqual(calls, []); + }); +} + +test('a late API failure cannot initialize the editor after navigation', async () => { + const response = deferred(); + const { instance, calls } = createInstance(() => response.promise); + const pending = instance.createdComponent(); + instance.$route = { name: 'sw.product.index', params: {} }; + response.reject(new Error('API unavailable')); + await pending; + assert.deepEqual(calls, []); +}); + +test('a new route with the same ID cannot use the previous lookup', async () => { + const response = deferred(); + const { instance, calls } = createInstance(() => response.promise); + const pending = instance.createdComponent(); + instance.$route = { name: 'sw.cms.detail', params: { id: 'cms-page-a' } }; + response.resolve(result({ id: 'blog-entry-a' })); + await pending; + assert.deepEqual(calls, []); +});