Implement public audit pages

This commit is contained in:
Matthias
2026-06-05 14:14:07 +02:00
parent 03cb65fde4
commit 47ee2c2d51
25 changed files with 1039 additions and 45 deletions

View File

@@ -99,6 +99,41 @@ test("pageSpeedAction starts and finishes run mutations", () => {
);
});
test("pageSpeedAction queues audit generation after PageSpeed completion", () => {
assert.equal(
hasPattern(actionSource, /internal\.auditGeneration\.queueLeadAuditGeneration/),
true,
"Action should call internal.auditGeneration.queueLeadAuditGeneration after PageSpeed work.",
);
assert.equal(
hasPattern(
actionSource,
/queueAuditGenerationAfterPageSpeed\(\s*ctx,\s*args\.runId,\s*started\s*\)/,
),
true,
"Action should route PageSpeed completion through a shared audit-generation handoff helper.",
);
assert.equal(
hasPattern(
actionSource,
/queueAuditGenerationAfterPageSpeed[\s\S]*leadId:\s*started\.lead\._id[\s\S]*parentRunId:\s*runId/,
),
true,
"The handoff should queue audit generation for the same lead and parent PageSpeed run.",
);
assert.equal(
hasPattern(
actionSource,
/catch \(auditQueueError\)[\s\S]*Audit-Generierung konnte nicht in die Warteschlange gesetzt werden/,
),
true,
"Audit-generation queue failures should be logged without failing PageSpeed completion.",
);
});
test("pageSpeedAction has action-level guard to fail whole run on unexpected errors", () => {
assert.equal(
hasPattern(

View File

@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const source = async (relativePath: string) => {
return await readFile(
join(process.cwd(), ...relativePath.split("/")),
"utf8",
);
};
test("public audit schema stores reviewed public observations and offer separately", async () => {
const schemaSource = await source("convex/schema.ts");
assert.match(schemaSource, /publicObservations/);
assert.match(schemaSource, /observation:\s*v\.string\(\)/);
assert.match(schemaSource, /impact:\s*v\.string\(\)/);
assert.match(schemaSource, /suggestion:\s*v\.string\(\)/);
assert.match(schemaSource, /screenshotIds:\s*v\.optional\(v\.array\(v\.id\("_storage"\)\)\)/);
assert.match(schemaSource, /publicOffer/);
assert.match(schemaSource, /ctaLabel:\s*v\.optional\(v\.string\(\)\)/);
assert.match(schemaSource, /ctaHref:\s*v\.optional\(v\.string\(\)\)/);
});
test("public audit convex query only exposes published, bounded, public data", async () => {
const auditsSource = await source("convex/audits.ts");
assert.match(auditsSource, /export const getPublicBySlug = query/);
assert.match(
auditsSource,
/\.withIndex\("by_slug",\s*\(q\)\s*=>\s*q\.eq\("slug",\s*args\.slug\)\)\s*\.unique\(\)/,
"Public lookup should use the unique slug index.",
);
assert.match(
auditsSource,
/audit\.status !== "published"/,
"Draft and approved audits should not expose public content.",
);
assert.match(auditsSource, /audit\.status === "deactivated"/);
assert.match(auditsSource, /\.withIndex\("by_auditId"/);
assert.match(auditsSource, /\.take\(\s*8\s*\)/);
assert.match(auditsSource, /ctx\.storage\.getUrl\(screenshot\.storageId\)/);
assert.doesNotMatch(
auditsSource.match(/export const getPublicBySlug[\s\S]*?export const/)?.[0] ?? "",
/usedSkills|internalSummary|skillSummaries|pageSpeedSummary/,
"The public query should not return internal audit fields.",
);
});
test("public audit write mutations require authenticated operators", async () => {
const auditsSource = await source("convex/audits.ts");
for (const exportName of [
"savePublicAuditContent",
"publishPublicAudit",
"reapprovePublicAudit",
"deactivatePublicAudit",
]) {
assert.match(auditsSource, new RegExp(`export const ${exportName} = mutation`));
}
assert.match(auditsSource, /ctx\.auth\.getUserIdentity\(\)/);
assert.match(auditsSource, /Nicht autorisiert/);
assert.match(auditsSource, /publishedAt:\s*now/);
assert.match(auditsSource, /deactivatedAt:\s*now/);
});

View File

@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parsePublicAuditSlug, toPublicAuditSlug } from "../lib/audits/slugs";
import { toPublicAuditRenderState } from "../lib/audits/public-audit-presenter";
test("public audit slug helpers normalize German company names without leaking arbitrary path input", () => {
assert.equal(toPublicAuditSlug("Müller & Söhne GmbH", "Example.COM"), "mueller-soehne-gmbh-example-com");
assert.equal(parsePublicAuditSlug("mueller-soehne-gmbh-example-com"), "mueller-soehne-gmbh-example-com");
assert.equal(parsePublicAuditSlug("../secret"), null);
assert.equal(parsePublicAuditSlug("x".repeat(121)), null);
});
test("public audit presenter hides unavailable records and sanitizes external CTA links", () => {
assert.deepEqual(toPublicAuditRenderState(null), { kind: "unavailable" });
assert.deepEqual(toPublicAuditRenderState({ publicationStatus: "draft" }), { kind: "pending" });
assert.deepEqual(toPublicAuditRenderState({ publicationStatus: "deactivated" }), { kind: "unavailable" });
const rendered = toPublicAuditRenderState({
publicationStatus: "published",
companyName: "Lemon Space",
domain: "lemonspace.example",
publishedAt: "2026-06-05T10:00:00.000Z",
publicContent: {
headline: "Mehr Anfragen über die Website",
intro: "Die Website hat gute Grundlagen.",
observations: [
{
title: "Kontakt ist schwer zu finden",
observation: "Der primäre Kontaktweg liegt zu tief.",
impact: "Mehr Absprünge auf mobilen Geräten.",
suggestion: "CTA im ersten sichtbaren Bereich ergänzen.",
},
],
finalOffer: {
body: "Wir priorisieren die nächsten Verbesserungen gemeinsam.",
ctaLabel: "Audit besprechen",
ctaHref: "javascript:alert(1)",
},
},
screenshots: [],
});
assert.equal(rendered.kind, "published");
if (rendered.kind !== "published") {
return;
}
assert.equal(rendered.audit.finalOffer.ctaHref, undefined);
assert.equal(rendered.audit.observations[0]?.impact, "Mehr Absprünge auf mobilen Geräten.");
});

View File

@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const source = async (relativePath: string) => {
return await readFile(
join(process.cwd(), ...relativePath.split("/")),
"utf8",
);
};
test("public audit revalidation route requires a secret and slug before invalidating cache", async () => {
const routeSource = await source("app/api/internal/revalidate-public-audit/route.ts");
assert.match(routeSource, /PUBLIC_AUDIT_REVALIDATION_SECRET/);
assert.match(routeSource, /request\.headers\.get\("authorization"\)/);
assert.match(routeSource, /Bearer \$\{secret\}/);
assert.match(routeSource, /parsePublicAuditSlug/);
assert.match(routeSource, /revalidatePublicAudit\(normalizedSlug\)/);
assert.match(routeSource, /NextResponse\.json\(\{\s*ok:\s*true/);
});

View File

@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const source = async (relativePath: string) => {
return await readFile(
join(process.cwd(), ...relativePath.split("/")),
"utf8",
);
};
test("public audit route uses cache components and never leaks the requested slug in fallback states", async () => {
const routeSource = await source("app/audit/[slug]/page.tsx");
const configSource = await source("next.config.ts");
assert.match(configSource, /cacheComponents:\s*true/);
assert.match(routeSource, /params:\s*Promise<\{\s*slug:\s*string\s*\}>/);
assert.match(routeSource, /<Suspense/);
assert.match(routeSource, /cacheTag\(publicAuditCacheTag\(normalizedSlug\)\)/);
assert.match(routeSource, /cacheLife\("days"\)/);
assert.match(routeSource, /api\.audits\.getPublicBySlug/);
assert.match(routeSource, /robots:\s*\{[\s\S]*index:\s*false/);
assert.doesNotMatch(routeSource, /Audit:\s*\{slug\}/);
assert.doesNotMatch(routeSource, /Verwendete Skills|usedSkills/i);
});
test("public audit presentation renders observations, screenshots, and final offer", async () => {
const pageSource = await source("components/public-audit/public-audit-page.tsx");
const screenshotSource = await source("components/public-audit/public-audit-screenshot.tsx");
const statusSource = await source("components/public-audit/public-audit-status.tsx");
assert.match(pageSource, /observation\.observation/);
assert.match(pageSource, /observation\.impact/);
assert.match(pageSource, /observation\.suggestion/);
assert.match(pageSource, /audit\.screenshots\.map/);
assert.match(pageSource, /audit\.finalOffer/);
assert.match(pageSource, /href=\{audit\.finalOffer\.ctaHref\}/);
assert.match(screenshotSource, /alt=\{screenshot\.alt\}/);
assert.match(statusSource, /Dieser Audit ist noch nicht freigegeben/);
assert.match(statusSource, /Dieser Audit ist nicht verfügbar/);
});
test("sitemap stays indexable while excluding public audit URLs", async () => {
const sitemapSource = await source("app/sitemap.ts");
assert.match(sitemapSource, /MetadataRoute\.Sitemap/);
assert.match(sitemapSource, /NEXT_PUBLIC_SITE_URL/);
assert.doesNotMatch(sitemapSource, /\/audit\//);
});