Update public feature details and align SEO metadata

This commit is contained in:
t.indorf
2026-10-13 09:23:46 +02:00
parent 1472779781
commit abe73bb9ac
15 changed files with 683 additions and 234 deletions
+57
View File
@@ -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')));
});