Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/administration-tests.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions src/Resources/app/administration/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Original file line number Diff line number Diff line change
@@ -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;
}
},
},
};
138 changes: 138 additions & 0 deletions tests/administration/cms-detail-routing.test.mjs
Original file line number Diff line number Diff line change
@@ -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, []);
});
Loading