501 lines
16 KiB
TypeScript
501 lines
16 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
callScriptSchema,
|
|
emailDraftSchema,
|
|
emailSubjectSchema,
|
|
followUpDraftSchema,
|
|
auditSummarySchema,
|
|
qualityReviewSchema,
|
|
publicAuditTextSchema,
|
|
auditClassificationSchema,
|
|
internalFindingsSchema,
|
|
auditGenerationResultSchema,
|
|
type CallScript,
|
|
type EmailDraft,
|
|
type EmailSubject,
|
|
type FollowUpDraft,
|
|
type AuditSummary,
|
|
type PublicAuditText,
|
|
type AuditClassification,
|
|
type QualityReview,
|
|
type InternalFindings,
|
|
type AuditGenerationResult,
|
|
} from "../lib/ai/schemas";
|
|
|
|
test("internal findings schema accepts task-focused evidence", () => {
|
|
const parsed = internalFindingsSchema.parse({
|
|
findings: [
|
|
{
|
|
section: "UX",
|
|
finding: "Landingpage is not responsive on mobile viewport.",
|
|
suggestion: "Add responsive breakpoints for cards and typography.",
|
|
},
|
|
],
|
|
summary: "One high-priority UX gap was found.",
|
|
});
|
|
|
|
assert.equal(parsed.findings.length, 1);
|
|
assert.equal(parsed.findings[0].section, "UX");
|
|
});
|
|
|
|
test("audit generation result schema accepts v3 findings and aggregate outreach fields", () => {
|
|
const parsed = auditGenerationResultSchema.parse({
|
|
findings: [
|
|
{
|
|
skill_id: "contact-conversion",
|
|
observation: "Die Telefonnummer ist mobil erst nach langem Scrollen sichtbar.",
|
|
customer_benefit: "Ein sichtbarer Kontaktweg senkt Reibung und erhöht Anfragen.",
|
|
public_phrasing:
|
|
"Mir ist aufgefallen, dass der Kontaktweg am Smartphone noch schneller erreichbar sein könnte.",
|
|
severity: 3,
|
|
evidence: "screenshot_mobile",
|
|
applies: true,
|
|
},
|
|
],
|
|
usedSkills: ["contact-conversion", "mobile-usability"],
|
|
publicAuditText:
|
|
"Mir ist aufgefallen, dass der Kontaktweg am Smartphone noch schneller erreichbar sein könnte.",
|
|
finalSummary: "Hohe Priorität: mobile Kontaktaufnahme sichtbarer machen.",
|
|
emailSubject: "Kurzer Blick auf euren Webauftritt",
|
|
emailBody: "Hallo, ich habe mir eure Website angesehen...",
|
|
phoneScript: "Ich habe mir kurz eure mobile Kontaktstrecke angesehen.",
|
|
ctaType: "anruf",
|
|
});
|
|
|
|
assert.equal(parsed.findings[0].skill_id, "contact-conversion");
|
|
assert.equal(parsed.findings[0].severity, 3);
|
|
assert.equal(parsed.findings[0].applies, true);
|
|
assert.deepEqual(parsed.usedSkills, ["contact-conversion", "mobile-usability"]);
|
|
});
|
|
|
|
test("audit classification schema accepts v3 findings and required used skills", () => {
|
|
const parsed = auditClassificationSchema.parse({
|
|
findings: [
|
|
{
|
|
skill_id: "contact-conversion",
|
|
observation: "Die Telefonnummer ist mobil erst nach langem Scrollen sichtbar.",
|
|
customer_benefit: "Ein sichtbarer Kontaktweg senkt Reibung und erhöht Anfragen.",
|
|
public_phrasing:
|
|
"Mir ist aufgefallen, dass der Kontaktweg am Smartphone noch schneller erreichbar sein könnte.",
|
|
severity: 3,
|
|
evidence: "screenshot_mobile",
|
|
applies: true,
|
|
},
|
|
],
|
|
summary: "Kontaktaufnahme hat die höchste Priorität.",
|
|
usedSkills: ["contact-conversion"],
|
|
});
|
|
|
|
assert.equal(parsed.findings[0].skill_id, "contact-conversion");
|
|
assert.deepEqual(parsed.usedSkills, ["contact-conversion"]);
|
|
});
|
|
|
|
test("structured output schemas avoid optional top-level fields for OpenAI strict mode", () => {
|
|
const classificationPayload = {
|
|
findings: [
|
|
{
|
|
skill_id: "contact-conversion",
|
|
observation: "Die Telefonnummer ist mobil erst nach langem Scrollen sichtbar.",
|
|
customer_benefit: "Ein sichtbarer Kontaktweg senkt Reibung und erhöht Anfragen.",
|
|
public_phrasing:
|
|
"Mir ist aufgefallen, dass der Kontaktweg am Smartphone noch schneller erreichbar sein könnte.",
|
|
severity: 3,
|
|
evidence: "screenshot_mobile",
|
|
applies: true,
|
|
},
|
|
],
|
|
summary: "Kontaktaufnahme hat die höchste Priorität.",
|
|
} as const;
|
|
|
|
assert.throws(
|
|
() => auditClassificationSchema.parse(classificationPayload),
|
|
/usedSkills|invalid|required/i,
|
|
);
|
|
assert.equal(
|
|
auditClassificationSchema.parse({
|
|
...classificationPayload,
|
|
usedSkills: null,
|
|
}).usedSkills,
|
|
null,
|
|
);
|
|
|
|
assert.throws(
|
|
() =>
|
|
followUpDraftSchema.parse({
|
|
message: "Kurzer Follow-up-Hinweis für nächste Woche.",
|
|
}),
|
|
/followInDays|goals|invalid|required/i,
|
|
);
|
|
const followParsed = followUpDraftSchema.parse({
|
|
message: "Kurzer Follow-up-Hinweis für nächste Woche.",
|
|
followInDays: null,
|
|
goals: null,
|
|
});
|
|
assert.equal(followParsed.followInDays, null);
|
|
assert.equal(followParsed.goals, null);
|
|
|
|
assert.throws(
|
|
() =>
|
|
qualityReviewSchema.parse({
|
|
isValid: true,
|
|
severity: "ok",
|
|
issues: [],
|
|
suggestions: [],
|
|
rewriteRequired: false,
|
|
revisedCopy: null,
|
|
}),
|
|
/notes|invalid|required/i,
|
|
);
|
|
assert.equal(
|
|
qualityReviewSchema.parse({
|
|
isValid: true,
|
|
severity: "ok",
|
|
issues: [],
|
|
suggestions: [],
|
|
rewriteRequired: false,
|
|
revisedCopy: null,
|
|
notes: null,
|
|
}).notes,
|
|
null,
|
|
);
|
|
});
|
|
|
|
test("audit classification schema rejects legacy-only finding payloads", () => {
|
|
assert.throws(
|
|
() =>
|
|
auditClassificationSchema.parse({
|
|
findings: [
|
|
{
|
|
section: "UX",
|
|
finding: "Landingpage is not responsive on mobile viewport.",
|
|
suggestion: "Add responsive breakpoints for cards and typography.",
|
|
},
|
|
],
|
|
summary: "Legacy payload.",
|
|
}),
|
|
/invalid|expected|required/i,
|
|
);
|
|
});
|
|
|
|
test("v3 finding severity only accepts internal priority levels 1 through 3", () => {
|
|
assert.throws(
|
|
() =>
|
|
auditGenerationResultSchema.parse({
|
|
findings: [
|
|
{
|
|
skill_id: "visual-design",
|
|
observation: "Kontrast ist gering.",
|
|
customer_benefit: "Bessere Lesbarkeit stärkt den ersten Eindruck.",
|
|
public_phrasing: "Ein staerkerer Kontrast wuerde die Lesbarkeit verbessern.",
|
|
severity: 4,
|
|
evidence: "screenshot_desktop",
|
|
applies: true,
|
|
},
|
|
],
|
|
usedSkills: ["visual-design"],
|
|
publicAuditText: "Ein staerkerer Kontrast wuerde die Lesbarkeit verbessern.",
|
|
finalSummary: "Kontrast priorisieren.",
|
|
emailSubject: "Kurzer Website-Hinweis",
|
|
emailBody: "Hallo...",
|
|
phoneScript: "Kurzer Gespraechseinstieg.",
|
|
ctaType: "anruf",
|
|
}),
|
|
/invalid input/i,
|
|
);
|
|
});
|
|
|
|
test("audit generation result schema rejects blank text fields and empty collections", () => {
|
|
const validPayload = {
|
|
findings: [
|
|
{
|
|
skill_id: "contact-conversion",
|
|
observation: "Die Telefonnummer ist mobil erst nach langem Scrollen sichtbar.",
|
|
customer_benefit: "Ein sichtbarer Kontaktweg senkt Reibung und erhöht Anfragen.",
|
|
public_phrasing:
|
|
"Mir ist aufgefallen, dass der Kontaktweg am Smartphone noch schneller erreichbar sein könnte.",
|
|
severity: 2,
|
|
evidence: "screenshot_mobile",
|
|
applies: true,
|
|
},
|
|
],
|
|
usedSkills: ["contact-conversion"],
|
|
publicAuditText:
|
|
"Mir ist aufgefallen, dass der Kontaktweg am Smartphone noch schneller erreichbar sein könnte.",
|
|
finalSummary: "Mobile Kontaktaufnahme sichtbarer machen.",
|
|
emailSubject: "Kurzer Blick auf euren Webauftritt",
|
|
emailBody: "Hallo, ich habe mir eure Website angesehen...",
|
|
phoneScript: "Ich habe mir kurz eure mobile Kontaktstrecke angesehen.",
|
|
ctaType: "termin",
|
|
};
|
|
|
|
assert.throws(
|
|
() =>
|
|
auditGenerationResultSchema.parse({
|
|
...validPayload,
|
|
publicAuditText: " ",
|
|
}),
|
|
/too small|invalid/i,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
auditGenerationResultSchema.parse({
|
|
...validPayload,
|
|
findings: [],
|
|
}),
|
|
/too small|invalid/i,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
auditGenerationResultSchema.parse({
|
|
...validPayload,
|
|
usedSkills: [],
|
|
}),
|
|
/too small|invalid/i,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
auditGenerationResultSchema.parse({
|
|
...validPayload,
|
|
findings: [
|
|
{
|
|
...validPayload.findings[0],
|
|
observation: "",
|
|
},
|
|
],
|
|
}),
|
|
/too small|invalid/i,
|
|
);
|
|
});
|
|
|
|
test("audit generation result schema only accepts documented cta types", () => {
|
|
const basePayload = {
|
|
findings: [
|
|
{
|
|
skill_id: "visual-design",
|
|
observation: "Die Schrift ist mobil klein.",
|
|
customer_benefit: "Lesbare Inhalte halten Besucher laenger auf der Seite.",
|
|
public_phrasing: "Die mobile Schrift koennte an einigen Stellen lesbarer sein.",
|
|
severity: 1,
|
|
evidence: "screenshot_mobile",
|
|
applies: true,
|
|
},
|
|
],
|
|
usedSkills: ["visual-design"],
|
|
publicAuditText: "Die mobile Schrift koennte an einigen Stellen lesbarer sein.",
|
|
finalSummary: "Mobile Lesbarkeit verbessern.",
|
|
emailSubject: "Kurzer Website-Hinweis",
|
|
emailBody: "Hallo...",
|
|
phoneScript: "Kurzer Gespraechseinstieg.",
|
|
};
|
|
|
|
for (const ctaType of ["anruf", "termin", "rueckruf"] as const) {
|
|
assert.equal(
|
|
auditGenerationResultSchema.parse({
|
|
...basePayload,
|
|
ctaType,
|
|
}).ctaType,
|
|
ctaType,
|
|
);
|
|
}
|
|
|
|
assert.throws(
|
|
() =>
|
|
auditGenerationResultSchema.parse({
|
|
...basePayload,
|
|
ctaType: "angebot",
|
|
}),
|
|
/invalid/i,
|
|
);
|
|
});
|
|
|
|
test("audit summary and public text schemas remain intentionally lightweight", () => {
|
|
const summaryParsed = auditSummarySchema.parse({
|
|
summary: "Kurze Zusammenfassung mit den wichtigsten Verbesserungen.",
|
|
keyFindings: ["Fehlende Kontaktmöglichkeit.", "Langsame Ladezeiten."],
|
|
});
|
|
const publicParsed = publicAuditTextSchema.parse({
|
|
publicText: "Dein Shop ist sauber, aber der erste Eindruck lässt Potenzial erkennen.",
|
|
});
|
|
|
|
assert.equal(summaryParsed.keyFindings.length, 2);
|
|
assert.equal(typeof publicParsed.publicText, "string");
|
|
});
|
|
|
|
test("outreach schemas parse German customer-facing payloads", () => {
|
|
const emailDraftParsed = emailDraftSchema.parse({
|
|
body: "Hallo, ich habe mir euer Angebot angesehen...",
|
|
});
|
|
const subjectParsed = emailSubjectSchema.parse({
|
|
subject: "Kurznotiz zu eurem Webauftritt",
|
|
});
|
|
const callParsed = callScriptSchema.parse({
|
|
openingLine: "Guten Tag, ich bin ...",
|
|
callScript: [
|
|
"Euer Fokus auf Terminbuchung ist stark.",
|
|
"Wie läuft eure aktuelle Lead-Generierung?",
|
|
],
|
|
closeLine: "Ich schicke im Anschluss kurz die wichtigsten Beobachtungen.",
|
|
});
|
|
const followParsed = followUpDraftSchema.parse({
|
|
message: "Kurzer Follow-up-Hinweis für nächste Woche.",
|
|
followInDays: 4,
|
|
goals: ["Antwort auf Rückmeldung erhalten"],
|
|
});
|
|
const qualityParsed = qualityReviewSchema.parse({
|
|
isValid: true,
|
|
severity: "ok",
|
|
issues: [],
|
|
suggestions: ["Mehr Kundennutzen konkret beschreiben."],
|
|
rewriteRequired: false,
|
|
revisedCopy: null,
|
|
notes: null,
|
|
});
|
|
|
|
assert.equal(typeof emailDraftParsed.body, "string");
|
|
assert.equal(typeof subjectParsed.subject, "string");
|
|
assert.equal(Array.isArray(callParsed.callScript), true);
|
|
assert.equal(typeof followParsed.message, "string");
|
|
assert.equal(Array.isArray(qualityParsed.suggestions), true);
|
|
});
|
|
|
|
test("quality review schema accepts one-shot revised copy payloads", () => {
|
|
const parsed: QualityReview = qualityReviewSchema.parse({
|
|
isValid: false,
|
|
severity: "warning",
|
|
issues: ["Betreff klingt noch etwas generisch."],
|
|
suggestions: ["Betreff konkreter machen."],
|
|
rewriteRequired: true,
|
|
revisedCopy: {
|
|
publicSummary: "Mir ist aufgefallen, dass die mobile Seite etwas traege wirkt.",
|
|
publicBody:
|
|
"Mein Vorschlag waere, zuerst die sichtbaren Ladebremsen der Startseite zu pruefen.",
|
|
emailSubject: "Kurzer Hinweis zur mobilen Seite",
|
|
emailBody:
|
|
"Guten Tag, mir ist beim Blick auf Ihre Website aufgefallen, dass die mobile Seite etwas traege wirkt.",
|
|
phoneScript: {
|
|
openingLine: "Guten Tag, hier ist Matthias Meister.",
|
|
callScript: [
|
|
"Mir ist bei Ihrer mobilen Website ein konkreter Ladezeitpunkt aufgefallen.",
|
|
"Mein Vorschlag waere, diesen Punkt kurz zu priorisieren.",
|
|
],
|
|
closeLine: "Soll ich Ihnen den Hinweis kurz per E-Mail senden?",
|
|
},
|
|
followUpDraft: {
|
|
message:
|
|
"Ich wollte kurz nachfassen, ob der Hinweis zur mobilen Seite fuer Sie relevant ist.",
|
|
followInDays: 7,
|
|
goals: ["kurze Rueckmeldung", "Interesse klaeren"],
|
|
},
|
|
},
|
|
notes: ["Ein Rewrite ist sinnvoll."],
|
|
});
|
|
|
|
assert.equal(parsed.rewriteRequired, true);
|
|
assert.equal(parsed.revisedCopy?.emailSubject, "Kurzer Hinweis zur mobilen Seite");
|
|
assert.deepEqual(parsed.revisedCopy?.followUpDraft.goals, [
|
|
"kurze Rueckmeldung",
|
|
"Interesse klaeren",
|
|
]);
|
|
});
|
|
|
|
test("schema-inferred types are exported for Convex action wiring", () => {
|
|
const typedFindings: InternalFindings = {
|
|
findings: [
|
|
{
|
|
section: "Homepage",
|
|
finding: "No visible Datenschutzhinweis.",
|
|
suggestion: "Bitte Hinweis ergänzen.",
|
|
},
|
|
],
|
|
summary: "One finding identified.",
|
|
};
|
|
|
|
const typedSummary: AuditSummary = {
|
|
summary: "Kernbefund mit 2 Punkten.",
|
|
keyFindings: ["Kontaktseite fehlt."],
|
|
};
|
|
|
|
const typedPublicText: PublicAuditText = {
|
|
publicText: "Starker Start, aber optimierungsfähig.",
|
|
};
|
|
|
|
const typedEmail: EmailDraft = {
|
|
body: "Text mit Ich-Perspektive und konkretem Vorschlag.",
|
|
};
|
|
|
|
const typedSubject: EmailSubject = {
|
|
subject: "Kurzer Betreff",
|
|
};
|
|
|
|
const typedCall: CallScript = {
|
|
openingLine: "Hallo, ich habe euer Shop geprüft.",
|
|
callScript: ["Wie gehen die Leads aktuell rein?"],
|
|
closeLine: "Ich melde mich nach der Rückfrage erneut.",
|
|
};
|
|
|
|
const typedFollowUp: FollowUpDraft = {
|
|
message: "Kurzes Follow-up ohne harte Floskel.",
|
|
followInDays: null,
|
|
goals: null,
|
|
};
|
|
|
|
const typedQuality: QualityReview = {
|
|
isValid: true,
|
|
severity: "ok",
|
|
issues: [],
|
|
suggestions: [],
|
|
rewriteRequired: false,
|
|
revisedCopy: null,
|
|
notes: null,
|
|
};
|
|
|
|
const typedAuditGeneration: AuditGenerationResult = {
|
|
findings: [
|
|
{
|
|
skill_id: "visual-design",
|
|
observation: "Schrift ist mobil klein.",
|
|
customer_benefit: "Lesbare Inhalte halten Besucher laenger auf der Seite.",
|
|
public_phrasing: "Die mobile Schrift koennte an einigen Stellen lesbarer sein.",
|
|
severity: 2,
|
|
evidence: "screenshot_mobile",
|
|
applies: true,
|
|
},
|
|
],
|
|
usedSkills: ["visual-design"],
|
|
publicAuditText: "Die mobile Schrift koennte an einigen Stellen lesbarer sein.",
|
|
finalSummary: "Mobile Lesbarkeit verbessern.",
|
|
emailSubject: "Kurzer Website-Hinweis",
|
|
emailBody: "Hallo...",
|
|
phoneScript: "Kurzer Gespraechseinstieg.",
|
|
ctaType: "anruf",
|
|
};
|
|
|
|
const typedClassification: AuditClassification = {
|
|
findings: [
|
|
{
|
|
skill_id: "contact-conversion",
|
|
observation: "Kontakt ist mobil spaet sichtbar.",
|
|
customer_benefit: "Schneller Kontakt senkt Reibung.",
|
|
public_phrasing: "Der Kontaktweg koennte mobil schneller sichtbar sein.",
|
|
severity: 2,
|
|
evidence: "screenshot_mobile",
|
|
applies: true,
|
|
},
|
|
],
|
|
summary: "Kontaktweg priorisieren.",
|
|
usedSkills: ["contact-conversion"],
|
|
};
|
|
|
|
assert.equal(typedFindings.findings.length, 1);
|
|
assert.equal(typedSummary.keyFindings.length, 1);
|
|
assert.equal(typedPublicText.publicText.length > 0, true);
|
|
assert.equal(typeof typedEmail.body, "string");
|
|
assert.equal(typeof typedSubject.subject, "string");
|
|
assert.equal(typedCall.callScript.length, 1);
|
|
assert.equal(typedFollowUp.message.length > 0, true);
|
|
assert.equal(typedQuality.isValid, true);
|
|
assert.equal(typedAuditGeneration.usedSkills.length, 1);
|
|
assert.equal(typedClassification.findings.length, 1);
|
|
});
|