Implement public audit pages
This commit is contained in:
@@ -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(
|
||||
|
||||
67
tests/public-audit-contract.test.ts
Normal file
67
tests/public-audit-contract.test.ts
Normal 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/);
|
||||
});
|
||||
51
tests/public-audit-presenter.test.ts
Normal file
51
tests/public-audit-presenter.test.ts
Normal 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.");
|
||||
});
|
||||
22
tests/public-audit-revalidation-route.test.ts
Normal file
22
tests/public-audit-revalidation-route.test.ts
Normal 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/);
|
||||
});
|
||||
50
tests/public-audit-ui.test.ts
Normal file
50
tests/public-audit-ui.test.ts
Normal 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\//);
|
||||
});
|
||||
Reference in New Issue
Block a user