Files
kita-planer/tests/marketing-seo.test.cjs

121 lines
8.4 KiB
JavaScript

/* eslint-disable @typescript-eslint/no-require-imports -- Load real TS modules with isolated framework boundaries. */
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm');
const ts = require('typescript');
const { renderToStaticMarkup } = require('react-dom/server');
function loader(overrides = {}) {
const cache = new Map();
function load(file) {
file = path.resolve(file);
if (cache.has(file)) return cache.get(file).exports;
const mod = { exports: {} }; cache.set(file, mod);
const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true },
fileName: file,
}).outputText;
const req = spec => {
if (Object.hasOwn(overrides, spec)) return overrides[spec];
if (spec === 'server-only') return {};
if (spec === '@/lib/prisma') throw new Error('Public SEO modules must not query the database.');
if (spec === 'next/headers') return { headers: async () => new Headers({ 'x-nonce': 'synthetic-seo-test-nonce' }) };
if (spec === 'next/server') return { after() {} };
if (spec === 'next/cache') return { revalidatePath() {} };
if (spec === 'next/navigation') return { redirect(url) { throw new Error(`REDIRECT:${url}`); }, notFound() { throw new Error('NOT_FOUND'); } };
if (spec.startsWith('@/') || spec.startsWith('.')) {
const target = spec.startsWith('@/') ? path.resolve('src', spec.slice(2)) : path.resolve(path.dirname(file), spec);
for (const suffix of ['.ts', '.tsx', '/index.ts']) if (fs.existsSync(target + suffix)) return load(target + suffix);
}
return require(spec);
};
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { filename: file })(req, mod, mod.exports);
return mod.exports;
}
return load;
}
const savedOrigin = process.env.NEXT_PUBLIC_SITE_URL;
afterEach(() => { if (savedOrigin === undefined) delete process.env.NEXT_PUBLIC_SITE_URL; else process.env.NEXT_PUBLIC_SITE_URL = savedOrigin; });
const load = loader();
const seo = load('src/lib/marketing/seo.ts');
const { marketingFeatures } = load('src/lib/marketing/features.ts');
const sitemap = load('src/app/sitemap.ts').default;
const robots = load('src/app/robots.ts').default;
const validSite = () => { process.env.NEXT_PUBLIC_SITE_URL = 'https://example.invalid/'; };
test('Öffentliche URLs verwenden dieselbe normalisierte Adresse ohne doppelte Slashes', () => {
process.env.NEXT_PUBLIC_SITE_URL = ' https://example.invalid/setup?preview=true#fragment ';
assert.equal(seo.getPublicSiteOrigin(), 'https://example.invalid');
assert.equal(seo.publicUrl('/features/schwarzes-brett'), 'https://example.invalid/features/schwarzes-brett');
assert.equal(robots().sitemap, 'https://example.invalid/sitemap.xml');
});
test('Ohne konfigurierte Adresse wird die etablierte öffentliche Domain verwendet', () => {
delete process.env.NEXT_PUBLIC_SITE_URL; assert.equal(seo.publicUrl(), 'https://mein-kitaplaner.de/');
});
test('Ungültige Adressen, Zugangsdaten und fremde Seitenpfade werden zurückgewiesen', () => {
for (const value of ['javascript:alert(1)','ftp://example.invalid','https://user:secret@example.invalid','not-a-url']) {
process.env.NEXT_PUBLIC_SITE_URL = value; assert.throws(() => seo.getPublicSiteOrigin());
}
validSite(); for (const value of ['https://evil.invalid','//evil.invalid','/\\evil.invalid']) assert.throws(() => seo.publicUrl(value));
});
test('Öffentliche Metadaten haben eigene Canonicals, passende soziale URLs und Indexierung', async () => {
validSite(); const page = load('src/app/features/[slug]/page.tsx');
const titles = new Set(); const descriptions = new Set();
for (const feature of marketingFeatures) {
const metadata = await page.generateMetadata({ params: Promise.resolve({ slug: feature.slug }) });
assert.equal(metadata.alternates.canonical, seo.publicUrl(`/features/${feature.slug}`));
assert.equal(metadata.openGraph.url, metadata.alternates.canonical);
assert.equal(metadata.openGraph.description, metadata.description); assert.equal(metadata.twitter.description, metadata.description);
assert.equal(metadata.robots.index, true); assert.equal(metadata.robots.googleBot.index, true);
assert.equal(metadata.openGraph.images[0].width, 1200); assert.equal(metadata.openGraph.images[0].height, 630);
assert.ok(!metadata.openGraph.images[0].alt.includes('Kind spielt'));
assert.ok(!titles.has(metadata.title.absolute)); titles.add(metadata.title.absolute);
assert.ok(!descriptions.has(metadata.description)); descriptions.add(metadata.description);
}
});
test('Startseite und rechtliche Seiten wiederholen den Markennamen im Titel nicht', () => {
validSite(); const metadata = seo.publicPageMetadata('/', seo.marketingTitle, seo.marketingDescription);
assert.equal((metadata.title.absolute.match(/Kita-Planer/g) || []).length, 1);
for (const slug of ['impressum','datenschutz']) {
const value = load(`src/app/${slug}/page.tsx`).metadata;
assert.equal((value.title.absolute.match(/Kita-Planer/g) || []).length, 1);
assert.equal(value.alternates.canonical, seo.publicUrl(`/${slug}`)); assert.equal(value.robots.index, true);
}
});
test('Sitemap enthält genau die öffentlichen Seiten und den neuen Personalbereich', () => {
validSite(); const result = sitemap(); assert.equal(result.length, 9);
assert.equal(new Set(result.map(item => item.url)).size, result.length);
assert.deepEqual(result.map(item => item.url).sort(), ['/', '/impressum', '/datenschutz', ...marketingFeatures.map(feature => `/features/${feature.slug}`)].map(seo.publicUrl).sort());
assert.ok(result.some(item => item.url.endsWith('/features/personal-arbeitszeit')));
assert.ok(result.every(item => !/dashboard|login|register|passwort/.test(item.url)));
});
test('Änderungsdatum bleibt bei erneuter Generierung gleich; Rechtsseiten haben kein erfundenes Datum', () => {
validSite(); assert.deepEqual(sitemap(), sitemap());
for (const row of sitemap()) if (/impressum|datenschutz/.test(row.url)) assert.equal(row.lastModified, undefined); else assert.equal(row.lastModified, seo.marketingLastModified);
});
test('Robots blockieren interne und Tokenrouten; öffentliche Loginformulare erlauben noindex-Prüfung', () => {
validSite(); const result = robots(); assert.ok(result.rules.allow.includes('/features/'));
for (const value of ['/api/','/admin','/dashboard','/invite/','/alert/','/onboarding','/neues-passwort/']) assert.ok(result.rules.disallow.includes(value));
for (const value of ['/login','/register','/passwort-vergessen']) assert.ok(!result.rules.disallow.includes(value));
});
test('JSON-LD beschreibt vorhandene Funktionen ohne erfundene Bewertungen oder Preise', () => {
validSite(); const graph = seo.landingStructuredData(marketingFeatures); const software = graph['@graph'].find(row => row['@type'] === 'SoftwareApplication');
assert.equal(software.featureList.length, 6); assert.ok(software.featureList.some(text => text.startsWith('Personal & Arbeitszeit')));
assert.equal(software.offers, undefined); assert.equal(software.aggregateRating, undefined);
for (const feature of marketingFeatures) {
const data = seo.featureStructuredData(feature); const breadcrumb = data['@graph'].find(row => row['@type'] === 'BreadcrumbList');
assert.equal(breadcrumb.itemListElement[1].item, seo.publicUrl(`/features/${feature.slug}`));
}
});
test('Strukturierte Daten erhalten CSP-Nonce und lassen kein Script-Ende einschleusen', async () => {
const value = { title: '</script><script>alert(1)</script>' };
assert.ok(!seo.serializeStructuredData(value).includes('<')); assert.deepEqual(JSON.parse(seo.serializeStructuredData(value)), value);
const component = load('src/components/marketing/structured-data.tsx').StructuredData;
const html = renderToStaticMarkup(await component({ data: value }));
assert.ok(html.includes('nonce="synthetic-seo-test-nonce"')); assert.equal((html.match(/<script/g) || []).length, 1);
});
test('Landingtitel und veröffentlichte Funktionsbeschreibungen vermeiden veraltete Zusagen', () => {
assert.equal(marketingFeatures.length, 6); assert.equal(new Set(marketingFeatures.map(feature => feature.slug)).size, 6);
const text = JSON.stringify(marketingFeatures); assert.ok(!/1-Klick-Alarmierung|Raumbuchungen|Lesebestätigung/.test(text));
});