Externalize audit pipeline services
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildAuditEvidenceInput,
|
||||
type SkillRegistryEntryEvidence,
|
||||
} from "../lib/ai/audit-evidence";
|
||||
import { parseSkillsRegistry } from "../lib/skills-registry";
|
||||
|
||||
const SAMPLE_SKILL_REGISTRY: SkillRegistryEntryEvidence[] = [
|
||||
{
|
||||
@@ -335,3 +338,159 @@ test("buildAuditEvidenceInput selects deterministic skills and supports design/u
|
||||
assert.equal(selectedCategories.has(category), true);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildAuditEvidenceInput prioritizes local-audit v3 skills before cap", () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "v2_elemente", "skills.md"),
|
||||
"utf8",
|
||||
);
|
||||
const skillRegistry = parseSkillsRegistry(source);
|
||||
|
||||
assert.equal(
|
||||
skillRegistry.some((skill) => skill.id === "visual-design" && !skill.category),
|
||||
true,
|
||||
);
|
||||
|
||||
const actual = buildAuditEvidenceInput({
|
||||
lead: {
|
||||
companyName: "Bäckerei Muster",
|
||||
niche: "Bäckerei",
|
||||
city: "Berlin",
|
||||
websiteDomain: "example.com",
|
||||
},
|
||||
crawlPages: [
|
||||
{
|
||||
sourceUrl: "https://example.com",
|
||||
finalUrl: "https://example.com",
|
||||
pageKind: "homepage",
|
||||
title: "Bäckerei Muster Berlin",
|
||||
visibleTextExcerpt:
|
||||
"Frische Backwaren in Berlin. Rufen Sie uns an oder schreiben Sie uns fuer eine Bestellung.",
|
||||
hasContactCtaSignal: true,
|
||||
},
|
||||
{
|
||||
sourceUrl: "https://example.com/kontakt",
|
||||
finalUrl: "https://example.com/kontakt",
|
||||
pageKind: "contact",
|
||||
title: "Kontakt",
|
||||
visibleTextExcerpt:
|
||||
"Telefon 030 123456, E-Mail hallo@example.com, Öffnungszeiten und Kontaktformular.",
|
||||
hasContactFormSignal: true,
|
||||
hasContactCtaSignal: true,
|
||||
},
|
||||
],
|
||||
technicalChecks: [
|
||||
{
|
||||
sourceUrl: "https://example.com",
|
||||
finalUrl: "https://example.com",
|
||||
usesHttps: true,
|
||||
missingMetaDescription: true,
|
||||
hasVisibleContactPath: true,
|
||||
},
|
||||
],
|
||||
screenshots: [
|
||||
{
|
||||
storageId: "desktop-storage",
|
||||
sourceUrl: "https://example.com",
|
||||
viewport: "desktop",
|
||||
width: 1280,
|
||||
height: 900,
|
||||
mimeType: "image/png",
|
||||
capturedAt: 1700000000000,
|
||||
},
|
||||
{
|
||||
storageId: "mobile-storage",
|
||||
sourceUrl: "https://example.com",
|
||||
viewport: "mobile",
|
||||
width: 390,
|
||||
height: 844,
|
||||
mimeType: "image/png",
|
||||
capturedAt: 1700000001000,
|
||||
},
|
||||
],
|
||||
pageSpeedInputs: [
|
||||
{
|
||||
strategy: "mobile",
|
||||
status: "succeeded",
|
||||
sourceUrl: "https://example.com",
|
||||
normalized: {
|
||||
implications: [
|
||||
"Die wichtigsten Inhalte erscheinen auf dem Smartphone spürbar verzögert.",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
skillRegistry,
|
||||
});
|
||||
|
||||
const selectedIds = new Set(actual.selectedSkills.map((skill) => skill.id));
|
||||
assert.deepEqual(actual.selectedSkills.map((skill) => skill.id), [
|
||||
"visual-design",
|
||||
"contact-conversion",
|
||||
"local-seo-basics",
|
||||
"performance-experience",
|
||||
"mobile-usability",
|
||||
"conversion-copy",
|
||||
]);
|
||||
assert.equal(actual.selectedSkills.length, 6);
|
||||
for (const id of [
|
||||
"visual-design",
|
||||
"contact-conversion",
|
||||
"local-seo-basics",
|
||||
"performance-experience",
|
||||
]) {
|
||||
assert.equal(selectedIds.has(id), true, `${id} should be inside the cap.`);
|
||||
}
|
||||
assert.equal(
|
||||
actual.selectedSkills.every((skill) => skill.category === undefined),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAuditEvidenceInput gates v3 skills when declared inputs are missing", () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "v2_elemente", "skills.md"),
|
||||
"utf8",
|
||||
);
|
||||
const skillRegistry = parseSkillsRegistry(source);
|
||||
|
||||
const actual = buildAuditEvidenceInput({
|
||||
lead: {
|
||||
companyName: "Bäckerei Muster",
|
||||
websiteDomain: "example.com",
|
||||
},
|
||||
crawlPages: [
|
||||
{
|
||||
sourceUrl: "https://example.com",
|
||||
finalUrl: "https://example.com",
|
||||
pageKind: "homepage",
|
||||
title: "Bäckerei Muster",
|
||||
},
|
||||
],
|
||||
screenshots: [
|
||||
{
|
||||
storageId: "desktop-storage",
|
||||
sourceUrl: "https://example.com",
|
||||
viewport: "desktop",
|
||||
width: 1280,
|
||||
height: 900,
|
||||
mimeType: "image/png",
|
||||
capturedAt: 1700000000000,
|
||||
},
|
||||
],
|
||||
skillRegistry,
|
||||
});
|
||||
|
||||
const selectedIds = new Set(actual.selectedSkills.map((skill) => skill.id));
|
||||
for (const id of [
|
||||
"visual-design",
|
||||
"first-impression-clarity",
|
||||
"contact-conversion",
|
||||
"mobile-usability",
|
||||
"conversion-copy",
|
||||
"performance-experience",
|
||||
]) {
|
||||
assert.equal(selectedIds.has(id), false, `${id} should require missing inputs.`);
|
||||
}
|
||||
assert.equal(selectedIds.has("accessibility-basics"), true);
|
||||
});
|
||||
|
||||
@@ -285,6 +285,29 @@ test("sanitizer masks env-backed secret values in persistence", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("persistence sanitizer handles external service secrets with regex metacharacters", () => {
|
||||
for (const secretKey of ["SCREENSHOTONE_API_KEY", "JINA_API_KEY"]) {
|
||||
assert.equal(
|
||||
hasPattern(auditGenerationSource, new RegExp(`["']${secretKey}["']`)),
|
||||
true,
|
||||
`Persistence sanitizer should redact ${secretKey}.`,
|
||||
);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
auditGenerationSource.includes(
|
||||
'return value.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&");',
|
||||
),
|
||||
true,
|
||||
"escapeRegExp should escape regex metacharacters with the canonical character class.",
|
||||
);
|
||||
assert.equal(
|
||||
auditGenerationSource.includes("/[.*+?^${}()|[\\\\]\\\\]/g"),
|
||||
false,
|
||||
"escapeRegExp should not keep the malformed bracket/backslash character class.",
|
||||
);
|
||||
});
|
||||
|
||||
test("finishAuditGenerationRun updates run status/counters/currentStep", () => {
|
||||
const finishSource = extractExportSource("finishAuditGenerationRun");
|
||||
|
||||
|
||||
87
tests/audit-skill-registry-v3.test.ts
Normal file
87
tests/audit-skill-registry-v3.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { parseSkillsRegistry, toAuditUsedSkill } from "../lib/skills-registry";
|
||||
|
||||
test("parseSkillsRegistry parses v3 yaml metablocks from v2 source", async () => {
|
||||
const source = await readFile(join(process.cwd(), "v2_elemente", "skills.md"), "utf8");
|
||||
|
||||
const parsed = parseSkillsRegistry(source);
|
||||
|
||||
assert.equal(parsed.length, 9);
|
||||
const visualDesign = parsed.find((entry) => entry.id === "visual-design");
|
||||
assert.ok(visualDesign);
|
||||
assert.equal(visualDesign.title, "Visueller Gesamteindruck & Zeitgemäßheit");
|
||||
assert.equal(visualDesign.name, "Visueller Gesamteindruck & Zeitgemäßheit");
|
||||
assert.equal(visualDesign.appliesWhen, "website_exists");
|
||||
assert.deepEqual(visualDesign.inputs, [
|
||||
"desktop_screenshot",
|
||||
"mobile_screenshot",
|
||||
]);
|
||||
assert.equal(visualDesign.outputs, "findings");
|
||||
const instructions = visualDesign.instructions;
|
||||
if (typeof instructions !== "string") {
|
||||
assert.fail("Expected visual-design instructions to be parsed.");
|
||||
}
|
||||
assert.match(instructions, /Beurteile den ersten visuellen Eindruck/);
|
||||
});
|
||||
|
||||
test("toAuditUsedSkill exposes stable ids for v3 registry entries", async () => {
|
||||
const source = await readFile(join(process.cwd(), "v2_elemente", "skills.md"), "utf8");
|
||||
const parsed = parseSkillsRegistry(source);
|
||||
const skill = parsed.find((entry) => entry.id === "contact-conversion");
|
||||
|
||||
assert.ok(skill);
|
||||
assert.deepEqual(toAuditUsedSkill(skill), {
|
||||
id: "contact-conversion",
|
||||
name: "Kontaktaufnahme & Handlungsaufforderung",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseSkillsRegistry does not infer categories for v3 entries without explicit metadata", async () => {
|
||||
const source = await readFile(join(process.cwd(), "v2_elemente", "skills.md"), "utf8");
|
||||
const parsed = parseSkillsRegistry(source);
|
||||
const skill = parsed.find((entry) => entry.id === "performance-experience");
|
||||
|
||||
assert.ok(skill);
|
||||
assert.equal(skill.category, undefined);
|
||||
assert.deepEqual(toAuditUsedSkill(skill), {
|
||||
id: "performance-experience",
|
||||
name: "Tempo & Ladeerlebnis",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseSkillsRegistry can read legacy and v3 skill sections from one registry", () => {
|
||||
const source = `
|
||||
## Legacy Copy Skill
|
||||
Purpose: Improve customer-facing copy.
|
||||
When to use: Use when page text is unclear.
|
||||
When not to use: Skip when copy is not available.
|
||||
Required input: Markdown copy.
|
||||
Expected output: Copy recommendations.
|
||||
Category: copy
|
||||
|
||||
## mobile-usability
|
||||
|
||||
\`\`\`yaml
|
||||
id: mobile-usability
|
||||
title: Mobile Nutzbarkeit
|
||||
applies_when: has_mobile_screenshot
|
||||
inputs: [mobile_screenshot, pagespeed]
|
||||
outputs: findings
|
||||
\`\`\`
|
||||
|
||||
Pruefe mobile Lesbarkeit und Tap-Ziele.
|
||||
`;
|
||||
|
||||
const parsed = parseSkillsRegistry(source);
|
||||
|
||||
assert.equal(parsed.length, 2);
|
||||
assert.equal(parsed[0].name, "Legacy Copy Skill");
|
||||
assert.equal(parsed[0].category, "copy");
|
||||
assert.equal(parsed[1].id, "mobile-usability");
|
||||
assert.equal(parsed[1].category, undefined);
|
||||
assert.deepEqual(parsed[1].inputs, ["mobile_screenshot", "pagespeed"]);
|
||||
});
|
||||
@@ -127,8 +127,13 @@ test("audits schema stores compact usedSkills metadata", () => {
|
||||
);
|
||||
hasPattern(
|
||||
usedSkillsSection,
|
||||
/category:\s*v\.string\(\)/,
|
||||
"usedSkills.category should be string.",
|
||||
/id:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
||||
"usedSkills.id should be optional string.",
|
||||
);
|
||||
hasPattern(
|
||||
usedSkillsSection,
|
||||
/category:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
||||
"usedSkills.category should be optional string.",
|
||||
);
|
||||
hasPattern(
|
||||
usedSkillsSection,
|
||||
@@ -179,8 +184,8 @@ test("audits.create accepts usedSkills validator and persists metadata payloads"
|
||||
);
|
||||
hasPattern(
|
||||
auditsSource,
|
||||
/v\.object\([\s\S]*?name:\s*v\.string\(\)[\s\S]*?category:\s*v\.string\(\)[\s\S]*?version:\s*v\.optional\(\s*v\.string\(\)\s*\)[\s\S]*?source:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
||||
"audits.ts should define a reusable usedSkillsValidator.",
|
||||
/v\.object\([\s\S]*?id:\s*v\.optional\(\s*v\.string\(\)\s*\)[\s\S]*?name:\s*v\.string\(\)[\s\S]*?category:\s*v\.optional\(\s*v\.string\(\)\s*\)[\s\S]*?version:\s*v\.optional\(\s*v\.string\(\)\s*\)[\s\S]*?source:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
||||
"audits.ts should define reusable v3-compatible usedSkillsValidator fields.",
|
||||
);
|
||||
|
||||
hasPattern(
|
||||
|
||||
73
tests/audits-auth-source.test.ts
Normal file
73
tests/audits-auth-source.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
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",
|
||||
);
|
||||
};
|
||||
|
||||
function extractExportSource(sourceText: string, name: string) {
|
||||
const marker = `export const ${name} = `;
|
||||
const declarationIndex = sourceText.indexOf(marker);
|
||||
assert.notEqual(declarationIndex, -1, `Expected declaration for ${name}.`);
|
||||
|
||||
const openBraceIndex = sourceText.indexOf("{", declarationIndex);
|
||||
let depth = 0;
|
||||
let end = -1;
|
||||
|
||||
for (let index = openBraceIndex; index < sourceText.length; index += 1) {
|
||||
const char = sourceText[index];
|
||||
if (char === "{") {
|
||||
depth += 1;
|
||||
} else if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.notEqual(end, -1, `Expected balanced braces for ${name}.`);
|
||||
return sourceText.slice(openBraceIndex, end + 1);
|
||||
}
|
||||
|
||||
test("audit admin APIs require operator auth before database access", async () => {
|
||||
const auditsSource = await source("convex/audits.ts");
|
||||
|
||||
assert.match(
|
||||
auditsSource,
|
||||
/const requireOperator\s*=\s*async\s*\(\s*ctx:\s*(?:MutationCtx\s*\|\s*QueryCtx|QueryCtx\s*\|\s*MutationCtx)\s*\)[\s\S]*ctx\.auth\.getUserIdentity\(\)[\s\S]*throw new Error\(["']Nicht autorisiert\.["']\)/,
|
||||
"audits should define the local requireOperator auth guard.",
|
||||
);
|
||||
|
||||
for (const name of ["create", "getDetail", "get", "getBySlug", "list"]) {
|
||||
const exportSource = extractExportSource(auditsSource, name);
|
||||
const authIndex = exportSource.indexOf("await requireOperator(ctx)");
|
||||
const dbIndex = exportSource.indexOf("ctx.db");
|
||||
|
||||
assert.notEqual(authIndex, -1, `${name} should require operator auth.`);
|
||||
assert.notEqual(dbIndex, -1, `${name} should access ctx.db.`);
|
||||
assert.equal(
|
||||
authIndex < dbIndex,
|
||||
true,
|
||||
`${name} should require operator auth before accessing ctx.db.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("public audit slug lookup remains unauthenticated", async () => {
|
||||
const auditsSource = await source("convex/audits.ts");
|
||||
const publicSource = extractExportSource(auditsSource, "getPublicBySlug");
|
||||
|
||||
assert.match(publicSource, /ctx\.db/, "public audit lookup should keep reading public audit data.");
|
||||
assert.doesNotMatch(
|
||||
publicSource,
|
||||
/requireOperator\(ctx\)/,
|
||||
"getPublicBySlug should remain public and unauthenticated.",
|
||||
);
|
||||
});
|
||||
335
tests/external-audit-pipeline-source.test.ts
Normal file
335
tests/external-audit-pipeline-source.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const actionPath = path.join(process.cwd(), "convex", "auditGenerationAction.ts");
|
||||
const actionSource = existsSync(actionPath) ? readFileSync(actionPath, "utf8") : "";
|
||||
const generationPath = path.join(process.cwd(), "convex", "auditGeneration.ts");
|
||||
const generationSource = existsSync(generationPath)
|
||||
? readFileSync(generationPath, "utf8")
|
||||
: "";
|
||||
|
||||
function extractFunctionSource(functionName: string) {
|
||||
const declarationPattern = new RegExp(
|
||||
`(?:async\\s+)?function\\s+${functionName}\\s*\\([\\s\\S]*?\\n\\)\\s*(?::\\s*[^\\{]+)?\\{`,
|
||||
);
|
||||
const match = declarationPattern.exec(actionSource);
|
||||
assert.notEqual(match, null, `Expected function ${functionName}.`);
|
||||
|
||||
const openBraceIndex = match!.index + match![0].lastIndexOf("{");
|
||||
let depth = 0;
|
||||
let end = -1;
|
||||
|
||||
for (let index = openBraceIndex; index < actionSource.length; index += 1) {
|
||||
const char = actionSource[index];
|
||||
if (char === "{") {
|
||||
depth += 1;
|
||||
} else if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.notEqual(end, -1, `Expected balanced braces for ${functionName}.`);
|
||||
return actionSource.slice(match!.index, end + 1);
|
||||
}
|
||||
|
||||
test("audit generation action orchestrates external capture helpers when legacy crawl artifacts are absent", () => {
|
||||
assert.match(
|
||||
actionSource,
|
||||
/buildScreenshotOneRequests[\s\S]*buildJinaReaderAuditInput[\s\S]*estimateExternalAuditCostUsd|estimateExternalAuditCostUsd[\s\S]*buildScreenshotOneRequests[\s\S]*buildJinaReaderAuditInput/,
|
||||
"Action should import and use the approved external audit service helpers.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/SCREENSHOTONE_API_KEY/,
|
||||
"ScreenshotOne capture should be guarded by the managed SCREENSHOTONE_API_KEY env key.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/JINA_API_KEY/,
|
||||
"Jina capture should be compatible with the optional managed JINA_API_KEY env key.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/evidence\.screenshots\.length\s*===\s*0[\s\S]*(started\.lead\.websiteUrl|started\.lead\.websiteDomain)/,
|
||||
"External capture should be prepared from the started lead URL/domain when legacy screenshots are missing.",
|
||||
);
|
||||
});
|
||||
|
||||
test("audit generation action records provider usage events for capture and OpenRouter generation", () => {
|
||||
assert.match(
|
||||
actionSource,
|
||||
/internal\.usageEvents\.recordUsageEvent/,
|
||||
"Action should record usage through internal.usageEvents.recordUsageEvent.",
|
||||
);
|
||||
|
||||
for (const provider of ["screenshotone", "jina", "openrouter"]) {
|
||||
assert.match(
|
||||
actionSource,
|
||||
new RegExp(`provider:\\s*["']${provider}["']`),
|
||||
`Action should record ${provider} usage.`,
|
||||
);
|
||||
}
|
||||
|
||||
assert.match(
|
||||
actionSource,
|
||||
/provider:\s*["']openrouter["'][\s\S]*operation:\s*["']audit_generation["']/,
|
||||
"OpenRouter usage should be recorded as audit_generation.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/provider:\s*["']screenshotone["'][\s\S]*operation:\s*["']audit_capture["']/,
|
||||
"ScreenshotOne usage should be recorded as audit_capture.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/provider:\s*["']jina["'][\s\S]*operation:\s*["']audit_capture["']/,
|
||||
"Jina usage should be recorded as audit_capture.",
|
||||
);
|
||||
});
|
||||
|
||||
test("Jina markdown joins the evidence prompt without requiring Playwright crawl pages", () => {
|
||||
assert.match(
|
||||
actionSource,
|
||||
/jina(?:Reader)?AuditInput[\s\S]*markdown/,
|
||||
"Action should keep Jina reader markdown as an audit evidence input.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/buildAuditEvidenceInput\(\{[\s\S]*externalMarkdown|externalMarkdown[\s\S]*buildAuditEvidenceInput\(\{/,
|
||||
"Action should pass external markdown into the evidence builder.",
|
||||
);
|
||||
assert.match(
|
||||
generationSource,
|
||||
/externalMarkdown/,
|
||||
"Audit generation evidence types should expose external markdown for prompts.",
|
||||
);
|
||||
});
|
||||
|
||||
test("external capture fetches use timeout, abort signal, and bounded response readers", () => {
|
||||
for (const constantName of [
|
||||
"EXTERNAL_CAPTURE_TIMEOUT_MS",
|
||||
"MAX_SCREENSHOT_BYTES",
|
||||
"MAX_JINA_MARKDOWN_BYTES",
|
||||
"MAX_JINA_MARKDOWN_CHARS",
|
||||
]) {
|
||||
assert.match(
|
||||
actionSource,
|
||||
new RegExp(`const\\s+${constantName}\\s*=`),
|
||||
`Action should define ${constantName}.`,
|
||||
);
|
||||
}
|
||||
|
||||
assert.match(
|
||||
actionSource,
|
||||
/AbortController/,
|
||||
"External fetches should use AbortController for per-request timeouts.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/fetch\([\s\S]*signal:/,
|
||||
"External fetches should pass an AbortSignal.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
actionSource,
|
||||
/response\.blob\(\)/,
|
||||
"ScreenshotOne capture should not call unbounded response.blob().",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
actionSource,
|
||||
/response\.text\(\)/,
|
||||
"Jina capture should not call unbounded response.text().",
|
||||
);
|
||||
});
|
||||
|
||||
test("audit generation action sanitizes raw errors before run events and run failure summaries", () => {
|
||||
assert.match(
|
||||
actionSource,
|
||||
/function messageFromError[\s\S]*sanitizeSecretCandidates/,
|
||||
"messageFromError should sanitize/redact before returning error text.",
|
||||
);
|
||||
|
||||
for (const secretName of ["SCREENSHOTONE_API_KEY", "JINA_API_KEY"]) {
|
||||
assert.match(
|
||||
actionSource,
|
||||
new RegExp(`["']${secretName}["']`),
|
||||
`Secret sanitizer should know ${secretName}.`,
|
||||
);
|
||||
}
|
||||
|
||||
assert.doesNotMatch(
|
||||
actionSource,
|
||||
/value:\s*messageFromError\(error\)/,
|
||||
"Run event details should not receive raw messageFromError calls inline.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
actionSource,
|
||||
/errorSummary\s*=\s*messageFromError\(error\)/,
|
||||
"Failure summaries should not assign unsanitized raw errors inline.",
|
||||
);
|
||||
});
|
||||
|
||||
test("german-copy OpenRouter usage event aggregates all six generation calls", () => {
|
||||
assert.match(
|
||||
actionSource,
|
||||
/aggregateOpenRouterUsage/,
|
||||
"Action should expose an aggregation helper for stage-level OpenRouter usage.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/aggregateOpenRouterUsage\(\[[\s\S]*publicSummaryResult\.usage[\s\S]*germanBodyResult\.usage[\s\S]*germanSubjectResult\.usage[\s\S]*germanEmailResult\.usage[\s\S]*germanCallScriptResult\.usage[\s\S]*germanFollowUpResult\.usage[\s\S]*\]\)/,
|
||||
"German-copy usage should aggregate public summary, body, subject, email, call script, and follow-up calls.",
|
||||
);
|
||||
});
|
||||
|
||||
test("usage event recording is best-effort and cannot fail audit generation", () => {
|
||||
const usageRecorder = extractFunctionSource("recordAuditUsageEvent");
|
||||
|
||||
assert.match(
|
||||
usageRecorder,
|
||||
/try\s*\{[\s\S]*await ctx\.runMutation\(internal\.usageEvents\.recordUsageEvent/,
|
||||
"Usage recorder should isolate recordUsageEvent in a try block.",
|
||||
);
|
||||
assert.match(
|
||||
usageRecorder,
|
||||
/catch\s*\(error\)\s*\{[\s\S]*messageFromError\(error\)[\s\S]*level:\s*["']warning["']/,
|
||||
"Usage recorder should sanitize/log failures as warnings.",
|
||||
);
|
||||
assert.match(
|
||||
usageRecorder,
|
||||
/catch\s*\(error\)\s*\{[\s\S]*try\s*\{[\s\S]*appendRunEvent[\s\S]*\}\s*catch/,
|
||||
"Warning logging for usage failures should also be best-effort.",
|
||||
);
|
||||
});
|
||||
|
||||
test("external capture timeout covers body streaming and cancels readers", () => {
|
||||
const fetcher = extractFunctionSource("fetchExternalCapture");
|
||||
const reader = extractFunctionSource("readLimitedResponseBytes");
|
||||
|
||||
assert.match(
|
||||
fetcher,
|
||||
/return\s*\{[\s\S]*response[\s\S]*abortController:\s*controller[\s\S]*timeout[\s\S]*\}/,
|
||||
"fetchExternalCapture should return the active deadline context for body reads.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
fetcher,
|
||||
/finally\s*\{[\s\S]*clearTimeout\(timeout\)/,
|
||||
"fetchExternalCapture should not clear the timeout before body streaming completes.",
|
||||
);
|
||||
assert.match(
|
||||
reader,
|
||||
/signal\??:\s*AbortSignal/,
|
||||
"Bounded response reader should accept an AbortSignal.",
|
||||
);
|
||||
assert.match(
|
||||
reader,
|
||||
/signal\?\.addEventListener\(\s*["']abort["'][\s\S]*reader\.cancel/,
|
||||
"Bounded response reader should cancel the reader on timeout/abort.",
|
||||
);
|
||||
assert.match(
|
||||
reader,
|
||||
/totalBytes\s*>\s*maxBytes[\s\S]*await reader\.cancel\(/,
|
||||
"Bounded response reader should cancel the stream when the byte cap is exceeded.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/readLimitedResponseBytes\([\s\S]*MAX_SCREENSHOT_BYTES[\s\S]*abortController\.signal/,
|
||||
"Screenshot body reads should use the active timeout signal.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/readLimitedMarkdown\([\s\S]*abortController\.signal/,
|
||||
"Jina markdown body reads should use the active timeout signal.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/finally\s*\{[\s\S]*clearExternalCaptureTimeout/,
|
||||
"Capture loops should clear the external timeout after fetch and body streaming finish.",
|
||||
);
|
||||
});
|
||||
|
||||
test("external capture request builders are provider-level best-effort", () => {
|
||||
const capture = extractFunctionSource("captureExternalAuditArtifacts");
|
||||
|
||||
assert.match(
|
||||
capture,
|
||||
/if\s*\(args\.needsScreenshots\)[\s\S]*try\s*\{[\s\S]*buildScreenshotOneRequests/,
|
||||
"ScreenshotOne request construction should be inside a provider-level try block.",
|
||||
);
|
||||
assert.match(
|
||||
capture,
|
||||
/buildScreenshotOneRequests[\s\S]*catch\s*\(error\)[\s\S]*messageFromError\(error\)[\s\S]*level:\s*["']warning["']/,
|
||||
"ScreenshotOne request construction failures should degrade to sanitized warnings.",
|
||||
);
|
||||
assert.match(
|
||||
capture,
|
||||
/if\s*\(args\.needsMarkdown\)[\s\S]*try\s*\{[\s\S]*buildJinaReaderAuditInput/,
|
||||
"Jina reader input construction should be inside a provider-level try block.",
|
||||
);
|
||||
assert.match(
|
||||
capture,
|
||||
/buildJinaReaderAuditInput[\s\S]*catch\s*\(error\)[\s\S]*messageFromError\(error\)[\s\S]*level:\s*["']warning["']/,
|
||||
"Jina reader input construction failures should degrade to sanitized warnings.",
|
||||
);
|
||||
});
|
||||
|
||||
test("ScreenshotOne missing-key skip emits best-effort warning only when screenshots are needed", () => {
|
||||
const capture = extractFunctionSource("captureExternalAuditArtifacts");
|
||||
const needsScreenshotsIndex = capture.indexOf("if (args.needsScreenshots)");
|
||||
const needsMarkdownIndex = capture.indexOf("if (args.needsMarkdown)");
|
||||
const missingKeyWarningIndex = capture.indexOf(
|
||||
"ScreenshotOne ist nicht konfiguriert; Screenshot-Erfassung wurde übersprungen.",
|
||||
);
|
||||
|
||||
assert.notEqual(
|
||||
needsScreenshotsIndex,
|
||||
-1,
|
||||
"External capture should branch on needsScreenshots.",
|
||||
);
|
||||
assert.notEqual(
|
||||
needsMarkdownIndex,
|
||||
-1,
|
||||
"External capture should keep the later needsMarkdown branch.",
|
||||
);
|
||||
assert.notEqual(
|
||||
missingKeyWarningIndex,
|
||||
-1,
|
||||
"Missing ScreenshotOne config should emit a clear warning message.",
|
||||
);
|
||||
assert.equal(
|
||||
missingKeyWarningIndex > needsScreenshotsIndex &&
|
||||
missingKeyWarningIndex < needsMarkdownIndex,
|
||||
true,
|
||||
"Missing-key warning should live inside the needsScreenshots branch, so legacy screenshots do not warn.",
|
||||
);
|
||||
assert.match(
|
||||
capture,
|
||||
/if\s*\(!screenshotOneApiKey\)\s*\{[\s\S]*try\s*\{[\s\S]*await appendRunEvent\(ctx,\s*\{[\s\S]*level:\s*["']warning["'][\s\S]*ScreenshotOne ist nicht konfiguriert; Screenshot-Erfassung wurde übersprungen\.[\s\S]*\}\s*\);[\s\S]*\}\s*catch\s*\{[\s\S]*\}/,
|
||||
"Missing-key warning logging should be best-effort and unable to fail the audit run.",
|
||||
);
|
||||
});
|
||||
|
||||
test("external capture non-OK responses cancel bodies before continuing", () => {
|
||||
const capture = extractFunctionSource("captureExternalAuditArtifacts");
|
||||
const nonOkCancelCount = [
|
||||
...capture.matchAll(
|
||||
/if\s*\(!response\.ok\)\s*\{[\s\S]*?await cancelExternalResponseBody\(response\);[\s\S]*?continue;/g,
|
||||
),
|
||||
].length;
|
||||
|
||||
assert.match(
|
||||
actionSource,
|
||||
/async function cancelExternalResponseBody/,
|
||||
"Action should centralize best-effort body cancellation for non-OK responses.",
|
||||
);
|
||||
assert.equal(
|
||||
nonOkCancelCount,
|
||||
2,
|
||||
"Both ScreenshotOne and Jina non-OK branches should cancel bodies before continue.",
|
||||
);
|
||||
});
|
||||
184
tests/external-audit-services.test.ts
Normal file
184
tests/external-audit-services.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildJinaReaderAuditInput,
|
||||
buildScreenshotOneRequests,
|
||||
estimateExternalAuditCostUsd,
|
||||
} from "../lib/external-audit-services";
|
||||
|
||||
test("estimateExternalAuditCostUsd totals managed provider usage", () => {
|
||||
const estimate = estimateExternalAuditCostUsd({
|
||||
openRouter: {
|
||||
inputTokens: 1_500_000,
|
||||
outputTokens: 250_000,
|
||||
inputUsdPerMillionTokens: 0.25,
|
||||
outputUsdPerMillionTokens: 1.25,
|
||||
},
|
||||
screenshotOne: {
|
||||
screenshots: 2,
|
||||
usdPerScreenshot: 0.01,
|
||||
},
|
||||
jina: {
|
||||
requests: 4,
|
||||
pages: 4,
|
||||
usdPerRequest: 0.001,
|
||||
usdPerPage: 0.002,
|
||||
},
|
||||
pageSpeed: {
|
||||
requests: 2,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(estimate.totalUsd, 0.7195);
|
||||
assert.deepEqual(estimate.byProvider, {
|
||||
openRouter: 0.6875,
|
||||
screenshotOne: 0.02,
|
||||
jina: 0.012,
|
||||
pageSpeed: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("estimateExternalAuditCostUsd clamps negative usage and prices to zero", () => {
|
||||
const estimate = estimateExternalAuditCostUsd({
|
||||
openRouter: {
|
||||
inputTokens: -1_000_000,
|
||||
outputTokens: 100_000,
|
||||
inputUsdPerMillionTokens: 0.25,
|
||||
outputUsdPerMillionTokens: -1.25,
|
||||
},
|
||||
screenshotOne: {
|
||||
screenshots: -2,
|
||||
usdPerScreenshot: 0.01,
|
||||
},
|
||||
jina: {
|
||||
requests: 4,
|
||||
pages: -4,
|
||||
usdPerRequest: -0.001,
|
||||
usdPerPage: 0.002,
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(estimate.byProvider, {
|
||||
openRouter: 0,
|
||||
screenshotOne: 0,
|
||||
jina: 0,
|
||||
pageSpeed: 0,
|
||||
});
|
||||
assert.equal(estimate.totalUsd, 0);
|
||||
});
|
||||
|
||||
test("buildScreenshotOneRequests creates stable desktop and mobile URLs", () => {
|
||||
const requests = buildScreenshotOneRequests({
|
||||
accessKey: "sso_secret_key",
|
||||
targetUrl: "https://example.com/landing?utm=abc",
|
||||
});
|
||||
|
||||
assert.equal(requests.length, 2);
|
||||
assert.deepEqual(
|
||||
requests.map((request) => request.viewport),
|
||||
["desktop", "mobile"],
|
||||
);
|
||||
|
||||
const desktop = new URL(requests[0]?.url ?? "");
|
||||
assert.equal(desktop.searchParams.get("access_key"), "sso_secret_key");
|
||||
assert.equal(desktop.searchParams.get("url"), "https://example.com/landing?utm=abc");
|
||||
assert.equal(desktop.searchParams.get("viewport_width"), "1280");
|
||||
assert.equal(desktop.searchParams.get("viewport_height"), "900");
|
||||
assert.equal(desktop.searchParams.get("device_scale_factor"), "1");
|
||||
assert.equal(desktop.searchParams.get("full_page"), "true");
|
||||
assert.equal(desktop.searchParams.get("block_cookie_banners"), "true");
|
||||
assert.equal(desktop.searchParams.get("block_ads"), "true");
|
||||
assert.equal(desktop.searchParams.get("block_trackers"), "true");
|
||||
|
||||
const mobile = new URL(requests[1]?.url ?? "");
|
||||
assert.equal(mobile.searchParams.get("viewport_width"), "390");
|
||||
assert.equal(mobile.searchParams.get("viewport_height"), "844");
|
||||
assert.equal(mobile.searchParams.get("device_scale_factor"), "2");
|
||||
assert.equal(mobile.searchParams.get("full_page"), "true");
|
||||
});
|
||||
|
||||
test("buildScreenshotOneRequests rejects non-web target URLs without leaking secrets", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
buildScreenshotOneRequests({
|
||||
accessKey: "sso_secret_key",
|
||||
targetUrl: "ftp://example.com/landing",
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error instanceof Error, true);
|
||||
assert.equal((error as Error).message.includes("sso_secret_key"), false);
|
||||
assert.match((error as Error).message, /http.*https/i);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("buildScreenshotOneRequests does not leak the access key in validation errors", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
buildScreenshotOneRequests({
|
||||
accessKey: "sso_secret_key",
|
||||
targetUrl: "not a url",
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error instanceof Error, true);
|
||||
assert.equal((error as Error).message.includes("sso_secret_key"), false);
|
||||
assert.match((error as Error).message, /target url/i);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("buildJinaReaderAuditInput rejects non-web base and page URLs", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
buildJinaReaderAuditInput({
|
||||
baseUrl: "file:///tmp/site.html",
|
||||
maxMarkdownChars: 100,
|
||||
}),
|
||||
/http.*https/i,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
buildJinaReaderAuditInput({
|
||||
baseUrl: "https://example.com",
|
||||
pages: [{ url: "ftp://example.com/kontakt", markdown: "Kontakt" }],
|
||||
maxMarkdownChars: 100,
|
||||
}),
|
||||
/http.*https/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildJinaReaderAuditInput prepares capped markdown for relevant pages", () => {
|
||||
const input = buildJinaReaderAuditInput({
|
||||
baseUrl: "https://example.com",
|
||||
pages: [
|
||||
{ url: "https://example.com", markdown: "# Home\nWillkommen auf der Startseite." },
|
||||
{ url: "https://example.com/kontakt", markdown: "Kontaktformular und Telefonnummer." },
|
||||
{ url: "https://example.com/impressum", markdown: "Impressum mit Anbieterkennzeichnung." },
|
||||
{ url: "https://example.com/leistungen", markdown: "Leistungen fuer Webdesign und SEO." },
|
||||
{ url: "https://example.com/ueber-uns", markdown: "Ueber uns und Arbeitsweise." },
|
||||
],
|
||||
maxMarkdownChars: 95,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
input.pages.map((page) => page.path),
|
||||
["/", "/kontakt", "/impressum", "/leistungen", "/ueber-uns"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
input.readerUrls,
|
||||
[
|
||||
"https://r.jina.ai/https://example.com/",
|
||||
"https://r.jina.ai/https://example.com/kontakt",
|
||||
"https://r.jina.ai/https://example.com/impressum",
|
||||
"https://r.jina.ai/https://example.com/leistungen",
|
||||
"https://r.jina.ai/https://example.com/ueber-uns",
|
||||
],
|
||||
);
|
||||
assert.equal(input.markdown.length <= 95, true);
|
||||
assert.match(input.markdown, /Source: https:\/\/example.com/);
|
||||
assert.match(input.markdown, /\[truncated to 95 chars\]$/);
|
||||
});
|
||||
195
tests/leads-runs-auth-source.test.ts
Normal file
195
tests/leads-runs-auth-source.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
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",
|
||||
);
|
||||
};
|
||||
|
||||
function extractExportSource(sourceText: string, name: string) {
|
||||
const marker = `export const ${name} = `;
|
||||
const declarationIndex = sourceText.indexOf(marker);
|
||||
assert.notEqual(declarationIndex, -1, `Expected declaration for ${name}.`);
|
||||
|
||||
const openBraceIndex = sourceText.indexOf("{", declarationIndex);
|
||||
let depth = 0;
|
||||
let end = -1;
|
||||
|
||||
for (let index = openBraceIndex; index < sourceText.length; index += 1) {
|
||||
const char = sourceText[index];
|
||||
if (char === "{") {
|
||||
depth += 1;
|
||||
} else if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.notEqual(end, -1, `Expected balanced braces for ${name}.`);
|
||||
return sourceText.slice(openBraceIndex, end + 1);
|
||||
}
|
||||
|
||||
function assertRequiresOperatorBeforeDataAccess(
|
||||
moduleSource: string,
|
||||
exportName: string,
|
||||
helperName?: string,
|
||||
) {
|
||||
const functionSource = extractExportSource(moduleSource, exportName);
|
||||
const authIndex = functionSource.indexOf("await requireOperator(ctx)");
|
||||
const dbIndex = functionSource.indexOf("ctx.db");
|
||||
const helperIndex = helperName === undefined
|
||||
? -1
|
||||
: functionSource.indexOf(helperName);
|
||||
const dataAccessIndex = dbIndex === -1 ? helperIndex : dbIndex;
|
||||
|
||||
assert.notEqual(
|
||||
authIndex,
|
||||
-1,
|
||||
`${exportName} should call requireOperator before DB access.`,
|
||||
);
|
||||
assert.notEqual(
|
||||
dataAccessIndex,
|
||||
-1,
|
||||
`${exportName} should access ctx.db or call its DB helper.`,
|
||||
);
|
||||
assert.ok(
|
||||
authIndex < dataAccessIndex,
|
||||
`${exportName} should require operator auth before its first data access.`,
|
||||
);
|
||||
}
|
||||
|
||||
test("lead public APIs require operator auth before DB access", async () => {
|
||||
const leadsSource = await source("convex/leads.ts");
|
||||
|
||||
assert.match(
|
||||
leadsSource,
|
||||
/const requireOperator\s*=\s*async\s*\(\s*ctx:\s*(?:MutationCtx\s*\|\s*QueryCtx|QueryCtx\s*\|\s*MutationCtx)\s*\)/,
|
||||
"leads.ts should define a local requireOperator helper.",
|
||||
);
|
||||
assert.match(
|
||||
leadsSource,
|
||||
/ctx\.auth\.getUserIdentity\(\)[\s\S]*throw new Error\(["']Nicht autorisiert\.["']\)/,
|
||||
"requireOperator should derive operator identity from Convex auth.",
|
||||
);
|
||||
|
||||
for (const exportName of [
|
||||
"create",
|
||||
"reviewUpdate",
|
||||
"get",
|
||||
"list",
|
||||
"listFunnel",
|
||||
]) {
|
||||
assertRequiresOperatorBeforeDataAccess(
|
||||
leadsSource,
|
||||
exportName,
|
||||
exportName === "reviewUpdate" ? "reviewUpdateLead" : undefined,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("lead internal APIs exist for audit-generation action callsites", async () => {
|
||||
const [leadsSource, actionSource] = await Promise.all([
|
||||
source("convex/leads.ts"),
|
||||
source("convex/auditGenerationAction.ts"),
|
||||
]);
|
||||
|
||||
assert.match(
|
||||
leadsSource,
|
||||
/import\s*{[\s\S]*internalMutation[\s\S]*internalQuery[\s\S]*}/,
|
||||
"leads.ts should import internal Convex builders.",
|
||||
);
|
||||
assert.match(
|
||||
leadsSource,
|
||||
/export const getInternal\s*=\s*internalQuery\(/,
|
||||
"leads.ts should expose an internal lead get query for actions.",
|
||||
);
|
||||
assert.match(
|
||||
leadsSource,
|
||||
/export const reviewUpdateInternal\s*=\s*internalMutation\(/,
|
||||
"leads.ts should expose an internal lead review mutation for actions.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/internal\.leads\.getInternal/,
|
||||
"auditGenerationAction should load leads through internal.leads.getInternal.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/internal\.leads\.reviewUpdateInternal/,
|
||||
"auditGenerationAction should update leads through internal.leads.reviewUpdateInternal.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
actionSource,
|
||||
/api\.leads\.(get|reviewUpdate)/,
|
||||
"auditGenerationAction should not use public lead APIs for internal calls.",
|
||||
);
|
||||
});
|
||||
|
||||
test("run public APIs require operator auth before DB access", async () => {
|
||||
const runsSource = await source("convex/runs.ts");
|
||||
|
||||
assert.match(
|
||||
runsSource,
|
||||
/const requireOperator\s*=\s*async\s*\(\s*ctx:\s*(?:MutationCtx\s*\|\s*QueryCtx|QueryCtx\s*\|\s*MutationCtx)\s*\)/,
|
||||
"runs.ts should define a local requireOperator helper.",
|
||||
);
|
||||
assert.match(
|
||||
runsSource,
|
||||
/ctx\.auth\.getUserIdentity\(\)[\s\S]*throw new Error\(["']Nicht autorisiert\.["']\)/,
|
||||
"requireOperator should derive operator identity from Convex auth.",
|
||||
);
|
||||
|
||||
for (const exportName of [
|
||||
"create",
|
||||
"updateStatus",
|
||||
"list",
|
||||
"appendEvent",
|
||||
"listEvents",
|
||||
]) {
|
||||
assertRequiresOperatorBeforeDataAccess(
|
||||
runsSource,
|
||||
exportName,
|
||||
exportName === "appendEvent" ? "appendRunEvent" : undefined,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("actions append run events through internal run mutation", async () => {
|
||||
const [runsSource, auditAction, pageSpeedAction, enrichmentAction] =
|
||||
await Promise.all([
|
||||
source("convex/runs.ts"),
|
||||
source("convex/auditGenerationAction.ts"),
|
||||
source("convex/pageSpeedAction.ts"),
|
||||
source("convex/websiteEnrichmentAction.ts"),
|
||||
]);
|
||||
|
||||
assert.match(
|
||||
runsSource,
|
||||
/export const appendEventInternal\s*=\s*internalMutation\(/,
|
||||
"runs.ts should expose an internal append event mutation for actions.",
|
||||
);
|
||||
|
||||
for (const [name, actionSource] of [
|
||||
["auditGenerationAction", auditAction],
|
||||
["pageSpeedAction", pageSpeedAction],
|
||||
["websiteEnrichmentAction", enrichmentAction],
|
||||
] as const) {
|
||||
assert.match(
|
||||
actionSource,
|
||||
/internal\.runs\.appendEventInternal/,
|
||||
`${name} should append events through internal.runs.appendEventInternal.`,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
actionSource,
|
||||
/api\.runs\.appendEvent/,
|
||||
`${name} should not append events through the public runs API.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -13,10 +13,11 @@ test("integration readiness covers all MVP providers", () => {
|
||||
"google",
|
||||
"pagespeed",
|
||||
"openrouter",
|
||||
"playwright",
|
||||
"screenshotone",
|
||||
"smtp",
|
||||
"convex_jobs",
|
||||
"rybbit",
|
||||
"jina",
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -36,3 +37,45 @@ test("integration readiness reports missing configuration without leaking values
|
||||
assert.equal(JSON.stringify(rows).includes("secret-google"), false);
|
||||
assert.equal(JSON.stringify(rows).includes("secret-places"), false);
|
||||
});
|
||||
|
||||
test("integration readiness treats ScreenshotOne as required and Jina as optional", () => {
|
||||
const rows = getIntegrationReadiness({
|
||||
GOOGLE_GEOCODING_API_KEY: "secret-google",
|
||||
GOOGLE_PLACES_API_KEY: "secret-places",
|
||||
PAGESPEED_API_KEY: "secret-pagespeed",
|
||||
PAGESPEED_TIMEOUT_MS: "60000",
|
||||
OPENROUTER_API_KEY: "secret-openrouter",
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
SMTP_USER: "user",
|
||||
SMTP_PASSWORD: "password",
|
||||
SMTP_FROM: "Audit <audit@example.com>",
|
||||
NEXT_PUBLIC_CONVEX_URL: "https://example.convex.cloud",
|
||||
CONVEX_DEPLOYMENT: "prod:example",
|
||||
RYBBIT_API_URL: "https://analytics.example.com",
|
||||
RYBBIT_API_KEY: "secret-rybbit",
|
||||
NEXT_PUBLIC_RYBBIT_SITE_ID: "site-id",
|
||||
});
|
||||
|
||||
const screenshotOne = rows.find((row) => row.id === "screenshotone");
|
||||
const jina = rows.find((row) => row.id === "jina");
|
||||
|
||||
assert.equal(screenshotOne?.status, "missing");
|
||||
assert.deepEqual(screenshotOne?.missingEnv, ["SCREENSHOTONE_API_KEY"]);
|
||||
assert.equal(jina?.status, "configured");
|
||||
assert.deepEqual(jina?.missingEnv, []);
|
||||
});
|
||||
|
||||
test("integration readiness no longer requires Playwright for the new pipeline", () => {
|
||||
const definitionIds = integrationReadinessDefinitions.map((definition) => definition.id as string);
|
||||
|
||||
assert.equal(
|
||||
definitionIds.includes("playwright"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
integrationReadinessDefinitions.some((definition) =>
|
||||
definition.requiredEnv.includes("TASK8_BROWSER_ASSET_URL"),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -19,14 +19,20 @@ test("settings page surfaces integration status instead of a placeholder", () =>
|
||||
"Google",
|
||||
"PageSpeed",
|
||||
"OpenRouter",
|
||||
"Playwright",
|
||||
"ScreenshotOne",
|
||||
"SMTP",
|
||||
"Convex Jobs",
|
||||
"Rybbit",
|
||||
"Jina",
|
||||
"Konfiguration fehlt",
|
||||
]) {
|
||||
assert.match(`${componentSource}\n${helperSource}`, new RegExp(label));
|
||||
}
|
||||
|
||||
assert.doesNotMatch(helperSource, /id: "playwright"/);
|
||||
assert.doesNotMatch(helperSource, /requiredEnv: \["TASK8_BROWSER_ASSET_URL"\]/);
|
||||
assert.match(helperSource, /requiredEnv: \["SCREENSHOTONE_API_KEY"\]/);
|
||||
assert.match(helperSource, /requiredEnv: \[\]/);
|
||||
});
|
||||
|
||||
test("verification notes cover critical MVP flows", () => {
|
||||
|
||||
@@ -238,7 +238,7 @@ test("pageSpeedAction stores and persists results and writes events", () => {
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
/api\.runs\.appendEvent,\s*{\s*[\s\S]*runId:\s*args\.runId,\s*[\s\S]*level:\s*["']info["']/.test(
|
||||
/internal\.runs\.appendEventInternal,\s*{\s*[\s\S]*runId:\s*args\.runId,\s*[\s\S]*level:\s*["']info["']/.test(
|
||||
actionSource,
|
||||
),
|
||||
true,
|
||||
@@ -283,7 +283,7 @@ test("pageSpeedAction does not expose API key in event messages/details", () =>
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/api\.runs\.appendEvent[\s\S]{0,500}PAGESPEED_API_KEY/,
|
||||
/internal\.runs\.appendEventInternal[\s\S]{0,500}PAGESPEED_API_KEY/,
|
||||
),
|
||||
false,
|
||||
"Action events should not include raw PAGESPEED_API_KEY",
|
||||
|
||||
356
tests/usage-events-source.test.ts
Normal file
356
tests/usage-events-source.test.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const schemaPath = join(process.cwd(), "convex", "schema.ts");
|
||||
const domainPath = join(process.cwd(), "convex", "domain.ts");
|
||||
const usageEventsPath = join(process.cwd(), "convex", "usageEvents.ts");
|
||||
|
||||
const schemaSource = readFileSync(schemaPath, "utf8");
|
||||
const domainSource = readFileSync(domainPath, "utf8");
|
||||
const usageEventsSource = existsSync(usageEventsPath)
|
||||
? readFileSync(usageEventsPath, "utf8")
|
||||
: "";
|
||||
|
||||
const usageEventsSourceFile = ts.createSourceFile(
|
||||
"usageEvents.ts",
|
||||
usageEventsSource,
|
||||
ts.ScriptTarget.ES2022,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
|
||||
function getExportedConstNames(file: ts.SourceFile) {
|
||||
const names = new Set<string>();
|
||||
|
||||
const visit = (node: ts.Node) => {
|
||||
if (ts.isVariableStatement(node)) {
|
||||
const isExported = node.modifiers?.some(
|
||||
(mod) => mod.kind === ts.SyntaxKind.ExportKeyword,
|
||||
);
|
||||
const isConst = node.declarationList.flags & ts.NodeFlags.Const;
|
||||
|
||||
if (isExported && isConst) {
|
||||
for (const declaration of node.declarationList.declarations) {
|
||||
if (ts.isIdentifier(declaration.name)) {
|
||||
names.add(declaration.name.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
ts.forEachChild(file, visit);
|
||||
return names;
|
||||
}
|
||||
|
||||
function extractTableSection(tableName: string) {
|
||||
const marker = `${tableName}: defineTable({`;
|
||||
const markerIndex = schemaSource.indexOf(marker);
|
||||
assert.notEqual(
|
||||
markerIndex,
|
||||
-1,
|
||||
`Expected schema table definition for ${tableName}.`,
|
||||
);
|
||||
|
||||
const objectStart = schemaSource.indexOf("{", markerIndex);
|
||||
let depth = 0;
|
||||
let objectEnd = -1;
|
||||
|
||||
for (let index = objectStart; index < schemaSource.length; index += 1) {
|
||||
if (schemaSource[index] === "{") {
|
||||
depth += 1;
|
||||
} else if (schemaSource[index] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
objectEnd = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.notEqual(objectEnd, -1, `Could not parse schema object for ${tableName}.`);
|
||||
|
||||
const remainder = schemaSource.slice(objectEnd + 1);
|
||||
const nextTableMatch = remainder.match(/^\s*[a-zA-Z_][\w]*:\s*defineTable\(/m);
|
||||
const sectionEnd =
|
||||
nextTableMatch === null ? schemaSource.length : objectEnd + 1 + nextTableMatch.index!;
|
||||
|
||||
return {
|
||||
objectBlock: schemaSource.slice(markerIndex, objectEnd + 1),
|
||||
section: schemaSource.slice(markerIndex, sectionEnd),
|
||||
};
|
||||
}
|
||||
|
||||
function extractExportSource(name: string) {
|
||||
const marker = `export const ${name} = `;
|
||||
const declarationIndex = usageEventsSource.indexOf(marker);
|
||||
assert.notEqual(declarationIndex, -1, `Expected declaration for ${name}`);
|
||||
|
||||
const openBraceIndex = usageEventsSource.indexOf("{", declarationIndex);
|
||||
let depth = 0;
|
||||
let end = -1;
|
||||
|
||||
for (let index = openBraceIndex; index < usageEventsSource.length; index += 1) {
|
||||
const char = usageEventsSource[index];
|
||||
if (char === "{") {
|
||||
depth += 1;
|
||||
} else if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.notEqual(end, -1, `Expected balanced braces for ${name}`);
|
||||
return usageEventsSource.slice(openBraceIndex, end + 1);
|
||||
}
|
||||
|
||||
function assertHas(pattern: RegExp, source: string, message: string) {
|
||||
assert.equal(pattern.test(source), true, message);
|
||||
}
|
||||
|
||||
const usageReadQueries = [
|
||||
{
|
||||
name: "listLatestUsageEvents",
|
||||
indexAssertion:
|
||||
/withIndex\("by_createdAt"\)/,
|
||||
message: "latest query should use by_createdAt.",
|
||||
},
|
||||
{
|
||||
name: "listUsageEventsByRun",
|
||||
indexAssertion:
|
||||
/withIndex\("by_runId_and_createdAt"[\s\S]*?eq\("runId",\s*args\.runId\)/,
|
||||
message: "run query should use by_runId_and_createdAt with runId equality.",
|
||||
},
|
||||
{
|
||||
name: "listUsageEventsByLead",
|
||||
indexAssertion:
|
||||
/withIndex\("by_leadId_and_createdAt"[\s\S]*?eq\("leadId",\s*args\.leadId\)/,
|
||||
message: "lead query should use by_leadId_and_createdAt with leadId equality.",
|
||||
},
|
||||
{
|
||||
name: "listUsageEventsByAudit",
|
||||
indexAssertion:
|
||||
/withIndex\("by_auditId_and_createdAt"[\s\S]*?eq\("auditId",\s*args\.auditId\)/,
|
||||
message: "audit query should use by_auditId_and_createdAt with auditId equality.",
|
||||
},
|
||||
{
|
||||
name: "listUsageEventsByProvider",
|
||||
indexAssertion:
|
||||
/withIndex\("by_provider_and_createdAt"[\s\S]*?eq\("provider",\s*args\.provider\)/,
|
||||
message: "provider query should use by_provider_and_createdAt with provider equality.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
test("usage domain constants declare supported providers and operations", () => {
|
||||
assertHas(
|
||||
/USAGE_EVENT_PROVIDERS\s*=\s*\[[\s\S]*"openrouter"[\s\S]*"screenshotone"[\s\S]*"jina"[\s\S]*"pagespeed"[\s\S]*"google_places"[\s\S]*\]\s*as const/,
|
||||
domainSource,
|
||||
"Domain should declare usage providers for all managed external services.",
|
||||
);
|
||||
assertHas(
|
||||
/USAGE_EVENT_OPERATIONS\s*=\s*\[[\s\S]*"audit_capture"[\s\S]*"audit_generation"[\s\S]*"lead_lookup"[\s\S]*\]\s*as const/,
|
||||
domainSource,
|
||||
"Domain should declare usage operations for capture, generation, and lookup.",
|
||||
);
|
||||
});
|
||||
|
||||
test("usageEvents schema stores cost and usage dimensions with bounded indexes", () => {
|
||||
const { objectBlock, section } = extractTableSection("usageEvents");
|
||||
|
||||
assertHas(/provider:\s*usageEventProvider/, objectBlock, "provider should use the provider validator.");
|
||||
assertHas(/operation:\s*usageEventOperation/, objectBlock, "operation should use the operation validator.");
|
||||
assertHas(
|
||||
/runId:\s*v\.optional\(\s*v\.id\(["']agentRuns["']\)\s*\)/,
|
||||
objectBlock,
|
||||
"runId should be optional for SaaS-ready attribution.",
|
||||
);
|
||||
assertHas(
|
||||
/leadId:\s*v\.optional\(\s*v\.id\(["']leads["']\)\s*\)/,
|
||||
objectBlock,
|
||||
"leadId should be optional for lead-level attribution.",
|
||||
);
|
||||
assertHas(
|
||||
/auditId:\s*v\.optional\(\s*v\.id\(["']audits["']\)\s*\)/,
|
||||
objectBlock,
|
||||
"auditId should be optional for audit-level attribution.",
|
||||
);
|
||||
assertHas(
|
||||
/estimatedCostUsd:\s*v\.number\(\)/,
|
||||
objectBlock,
|
||||
"estimatedCostUsd should be a required normalized number.",
|
||||
);
|
||||
assertHas(
|
||||
/tokens:\s*v\.optional\(\s*v\.object\([\s\S]*?inputTokens:\s*v\.optional\(v\.number\(\)\)[\s\S]*?outputTokens:\s*v\.optional\(v\.number\(\)\)[\s\S]*?promptTokens:\s*v\.optional\(v\.number\(\)\)[\s\S]*?completionTokens:\s*v\.optional\(v\.number\(\)\)[\s\S]*?totalTokens:\s*v\.optional\(v\.number\(\)\)[\s\S]*?\)\s*\)/,
|
||||
objectBlock,
|
||||
"tokens should capture OpenRouter-compatible token dimensions.",
|
||||
);
|
||||
assertHas(
|
||||
/callCounts:\s*v\.optional\(\s*v\.object\(/,
|
||||
objectBlock,
|
||||
"callCounts should be an optional object.",
|
||||
);
|
||||
for (const countName of ["requests", "pages", "screenshots", "lookups"]) {
|
||||
assertHas(
|
||||
new RegExp(`${countName}:\\s*v\\.optional\\(v\\.number\\(\\)\\)`),
|
||||
objectBlock,
|
||||
`callCounts.${countName} should be optional number.`,
|
||||
);
|
||||
}
|
||||
assertHas(/createdAt:\s*v\.number\(\)/, objectBlock, "createdAt should be required.");
|
||||
|
||||
for (const [indexName, fields] of [
|
||||
["by_runId_and_createdAt", '"runId",\\s*"createdAt"'],
|
||||
["by_leadId_and_createdAt", '"leadId",\\s*"createdAt"'],
|
||||
["by_auditId_and_createdAt", '"auditId",\\s*"createdAt"'],
|
||||
["by_provider_and_createdAt", '"provider",\\s*"createdAt"'],
|
||||
["by_createdAt", '"createdAt"'],
|
||||
] as const) {
|
||||
assertHas(
|
||||
new RegExp(`index\\("${indexName}",\\s*\\[${fields}\\]\\)`),
|
||||
section,
|
||||
`usageEvents should define ${indexName}.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("usageEvents module exposes internal recorder and authenticated bounded read queries", () => {
|
||||
assert.equal(existsSync(usageEventsPath), true, "usageEvents.ts should be present.");
|
||||
|
||||
const exports = getExportedConstNames(usageEventsSourceFile);
|
||||
for (const exportName of [
|
||||
"recordUsageEvent",
|
||||
...usageReadQueries.map((readQuery) => readQuery.name),
|
||||
]) {
|
||||
assert.equal(exports.has(exportName), true, `Expected export: ${exportName}`);
|
||||
}
|
||||
|
||||
assertHas(
|
||||
/export const recordUsageEvent = internalMutation\s*\(/,
|
||||
usageEventsSource,
|
||||
"recordUsageEvent should be an internalMutation.",
|
||||
);
|
||||
for (const { name } of usageReadQueries) {
|
||||
assertHas(
|
||||
new RegExp(`export const ${name} = query\\s*\\(`),
|
||||
usageEventsSource,
|
||||
`${name} should remain a public authenticated bounded query.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("usageEvents queries use indexes and bounded take without filters or collect", () => {
|
||||
const querySources = usageReadQueries.map((readQuery) => ({
|
||||
...readQuery,
|
||||
source: extractExportSource(readQuery.name),
|
||||
}));
|
||||
|
||||
for (const { source } of querySources) {
|
||||
assertHas(/limit:\s*v\.optional\(v\.number\(\)\)/, source, "read query should validate limit.");
|
||||
}
|
||||
for (const { source, indexAssertion, message } of querySources) {
|
||||
assertHas(indexAssertion, source, message);
|
||||
}
|
||||
|
||||
for (const source of [usageEventsSource, ...querySources.map((querySource) => querySource.source)]) {
|
||||
assert.doesNotMatch(source, /\.filter\s*\(/, "usageEvents should not use query filters.");
|
||||
assert.doesNotMatch(source, /\.collect\s*\(/, "usageEvents should not use unbounded collect.");
|
||||
}
|
||||
for (const { source } of querySources) {
|
||||
assertHas(
|
||||
/\.take\(\s*normalizeListLimit\(args\.limit\)\s*\)/,
|
||||
source,
|
||||
"read query should be bounded.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("usageEvents read queries require operator auth before reading telemetry", () => {
|
||||
assertHas(
|
||||
/const\s+requireOperator\s*=\s*async\s*\(\s*ctx:\s*QueryCtx\s*\)[\s\S]*ctx\.auth\.getUserIdentity\(\)[\s\S]*throw new Error\(["']Nicht autorisiert\.["']\)/,
|
||||
usageEventsSource,
|
||||
"usageEvents should define the local requireOperator auth guard.",
|
||||
);
|
||||
|
||||
for (const { name } of usageReadQueries) {
|
||||
const source = extractExportSource(name);
|
||||
const authIndex = source.indexOf("await requireOperator(ctx)");
|
||||
const readIndex = source.indexOf("ctx.db");
|
||||
|
||||
assert.notEqual(authIndex, -1, `${name} should require operator auth.`);
|
||||
assert.notEqual(readIndex, -1, `${name} should read from ctx.db.`);
|
||||
assert.equal(
|
||||
authIndex < readIndex,
|
||||
true,
|
||||
`${name} should require auth before reading usage telemetry.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("recordUsageEvent guards finite non-negative usage numbers before insert", () => {
|
||||
const recordSource = extractExportSource("recordUsageEvent");
|
||||
const guardCallIndex = recordSource.indexOf("assertValidUsageEventNumbers(args)");
|
||||
const insertIndex = recordSource.indexOf('ctx.db.insert("usageEvents"');
|
||||
|
||||
assert.notEqual(
|
||||
guardCallIndex,
|
||||
-1,
|
||||
"recordUsageEvent should call assertValidUsageEventNumbers(args).",
|
||||
);
|
||||
assert.notEqual(insertIndex, -1, "recordUsageEvent should insert usageEvents.");
|
||||
assert.equal(
|
||||
guardCallIndex < insertIndex,
|
||||
true,
|
||||
"recordUsageEvent should validate usage numbers before inserting.",
|
||||
);
|
||||
|
||||
assertHas(
|
||||
/function\s+assertFiniteNonNegativeNumber[\s\S]*Number\.isFinite\(value\)[\s\S]*value\s*<\s*0/,
|
||||
usageEventsSource,
|
||||
"Cost guard should reject NaN, Infinity, and negative numbers.",
|
||||
);
|
||||
assertHas(
|
||||
/function\s+assertFiniteNonNegativeInteger[\s\S]*Number\.isFinite\(value\)[\s\S]*value\s*<\s*0[\s\S]*Number\.isInteger\(value\)/,
|
||||
usageEventsSource,
|
||||
"Token/count guard should require finite non-negative integers.",
|
||||
);
|
||||
assertHas(
|
||||
/assertFiniteNonNegativeNumber\(args\.estimatedCostUsd,\s*["']estimatedCostUsd["']\)/,
|
||||
usageEventsSource,
|
||||
"estimatedCostUsd should use the finite non-negative number guard.",
|
||||
);
|
||||
|
||||
for (const tokenName of [
|
||||
"inputTokens",
|
||||
"outputTokens",
|
||||
"promptTokens",
|
||||
"completionTokens",
|
||||
"totalTokens",
|
||||
"cacheReadTokens",
|
||||
]) {
|
||||
assertHas(
|
||||
new RegExp(
|
||||
`assertFiniteNonNegativeInteger\\(args\\.tokens\\?\\.${tokenName},\\s*["']tokens\\.${tokenName}["']\\)`,
|
||||
),
|
||||
usageEventsSource,
|
||||
`tokens.${tokenName} should use the finite non-negative integer guard.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const countName of ["requests", "pages", "screenshots", "lookups"]) {
|
||||
assertHas(
|
||||
new RegExp(
|
||||
`assertFiniteNonNegativeInteger\\(args\\.callCounts\\?\\.${countName},\\s*["']callCounts\\.${countName}["']\\)`,
|
||||
),
|
||||
usageEventsSource,
|
||||
`callCounts.${countName} should use the finite non-negative integer guard.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user