/* 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: '' }; 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(/