Update public feature details and align SEO metadata
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports -- HTTP tests run directly in Node. */
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const origin = process.env.TEST_SEO_ORIGIN;
|
||||
if (!origin || !['127.0.0.1', 'localhost'].includes(new URL(origin).hostname)) throw new Error('TEST_SEO_ORIGIN muss ausdrücklich auf den lokalen Produktionsbuild zeigen.');
|
||||
const site = new URL(process.env.TEST_SEO_SITE_URL || 'https://mein-kitaplaner.de').origin;
|
||||
const paths = ['/', '/features/notdienst-planer', '/features/kita-kalender', '/features/eltern-adressbuch', '/features/krankmeldung', '/features/schwarzes-brett', '/features/personal-arbeitszeit', '/impressum', '/datenschutz'];
|
||||
const request = (path, ua = 'Googlebot') => fetch(new URL(path, origin), { headers: { 'user-agent': ua }, redirect: 'manual', signal: AbortSignal.timeout(15000) });
|
||||
const attributes = text => Object.fromEntries([...text.matchAll(/([\w:-]+)="([^"]*)"/g)].map(match => [match[1], match[2]]));
|
||||
const elements = (html, tag) => [...html.matchAll(new RegExp(`<${tag}\\b[^>]*>`, 'g'))].map(match => attributes(match[0]));
|
||||
function meta(html, key) { const values = elements(html, 'meta').filter(row => row.name === key || row.property === key); assert.equal(values.length, 1, key); return values[0].content; }
|
||||
const structured = html => [...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/g)].filter(match => attributes(match[1]).type === 'application/ld+json').map(match => ({ attrs: attributes(match[1]), data: JSON.parse(match[2]) }));
|
||||
|
||||
for (const path of paths) test(`SEO im ausgelieferten HTML: ${path}`, async () => {
|
||||
const response = await request(path); assert.equal(response.status, 200); const html = await response.text();
|
||||
assert.ok(html.includes('<html lang="de"')); assert.equal((html.match(/<h1\b/g) || []).length, 1);
|
||||
const canonical = elements(html, 'link').filter(row => row.rel === 'canonical'); assert.equal(canonical.length, 1); assert.equal(new URL(canonical[0].href).href, new URL(path, site).href);
|
||||
assert.equal((html.match(/<title>/g) || []).length, 1); const title = html.match(/<title>(.*?)<\/title>/)[1]; assert.equal((title.match(/Kita-Planer/g) || []).length, 1);
|
||||
assert.ok(meta(html, 'description').length > 40); assert.ok(meta(html, 'description').length < 180);
|
||||
assert.equal(meta(html, 'og:description'), meta(html, 'description')); assert.equal(meta(html, 'twitter:description'), meta(html, 'description'));
|
||||
assert.equal(new URL(meta(html, 'og:url')).href, new URL(path, site).href);
|
||||
assert.match(meta(html, 'robots'), /\bindex, follow\b/); assert.doesNotMatch(meta(html, 'robots'), /noindex/);
|
||||
assert.equal(response.headers.get('x-robots-tag'), null); assert.equal(meta(html, 'og:image'), `${site}/og-image.png`);
|
||||
if (path === '/' || path.startsWith('/features/')) {
|
||||
const data = structured(html); assert.equal(data.length, 1); assert.equal(data[0].data['@context'], 'https://schema.org');
|
||||
assert.ok(data[0].attrs.nonce); assert.ok(response.headers.get('content-security-policy').includes(`'nonce-${data[0].attrs.nonce}'`));
|
||||
const graph = data[0].data['@graph']; assert.ok(graph.some(row => row['@type'] === (path === '/' ? 'SoftwareApplication' : 'BreadcrumbList')));
|
||||
}
|
||||
});
|
||||
|
||||
test('Social-Crawler erhält Canonical und Vorschaudaten direkt im HTML-Head', async () => {
|
||||
const response = await request('/features/personal-arbeitszeit', 'facebookexternalhit/1.1'); assert.equal(response.status, 200);
|
||||
const html = await response.text(); const head = html.match(/<head>([\s\S]*?)<\/head>/)[1];
|
||||
assert.equal(meta(head, 'og:url'), `${site}/features/personal-arbeitszeit`); assert.equal(meta(head, 'twitter:card'), 'summary_large_image');
|
||||
assert.ok(elements(head, 'link').some(row => row.rel === 'canonical'));
|
||||
});
|
||||
|
||||
test('Ausgelieferte Sitemap enthält ausschließlich erreichbare kanonische öffentliche Seiten', async () => {
|
||||
const response = await request('/sitemap.xml'); assert.equal(response.status, 200); const xml = await response.text();
|
||||
const urls = [...xml.matchAll(/<loc>(.*?)<\/loc>/g)].map(match => new URL(match[1]).href).sort(); assert.deepEqual(urls, paths.map(path => new URL(path, site).href).sort());
|
||||
assert.ok(xml.includes('<lastmod>2026-10-13</lastmod>')); assert.ok(!xml.includes('T00:'));
|
||||
});
|
||||
test('Robots verwenden dieselbe Domain und halten Loginformulare zum Lesen von noindex offen', async () => {
|
||||
const response = await request('/robots.txt'); assert.equal(response.status, 200); const text = await response.text();
|
||||
assert.ok(text.includes(`Sitemap: ${site}/sitemap.xml`)); assert.ok(text.includes('Disallow: /admin')); assert.ok(text.includes('Disallow: /neues-passwort/')); assert.ok(!text.includes('Disallow: /login'));
|
||||
});
|
||||
test('Social-Image ist tatsächlich ein PNG mit 1200 × 630 Pixeln', async () => {
|
||||
const response = await request('/og-image.png'); assert.equal(response.status, 200); assert.match(response.headers.get('content-type'), /image\/png/);
|
||||
const data = Buffer.from(await response.arrayBuffer()); assert.ok(data.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))); assert.equal(data.readUInt32BE(16), 1200); assert.equal(data.readUInt32BE(20), 630);
|
||||
});
|
||||
for (const path of ['/login', '/register', '/passwort-vergessen', '/neues-passwort/invalid', '/dashboard', '/admin', '/onboarding', '/alert/invalid', '/invite/invalid', '/forbidden']) test(`Private Route erhält noindex und no-store: ${path}`, async () => {
|
||||
const response = await request(path); assert.ok(response.status < 500, `${path}: ${response.status}`); assert.match(response.headers.get('x-robots-tag'), /noindex, nofollow/); assert.match(response.headers.get('cache-control'), /private, no-store/);
|
||||
if (response.status === 200) assert.match(meta(await response.text(), 'robots'), /noindex/);
|
||||
});
|
||||
test('Unbekannte Feature-Seiten antworten mit 404 und noindex', async () => {
|
||||
const response = await request('/features/nicht-vorhanden'); assert.equal(response.status, 404); assert.ok(elements(await response.text(), 'meta').some(row => row.name === 'robots' && row.content.includes('noindex')));
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/* 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));
|
||||
});
|
||||
Reference in New Issue
Block a user