Integrate local business workflow and SaaS redesign
This commit is contained in:
23
tests/campaign-form-dialog-source.test.ts
Normal file
23
tests/campaign-form-dialog-source.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const campaignFormDialogPath = join(
|
||||
process.cwd(),
|
||||
"components",
|
||||
"campaigns",
|
||||
"campaign-form-dialog.tsx",
|
||||
);
|
||||
|
||||
test("CampaignFormDialog presents campaigns as lead discovery only", async () => {
|
||||
const source = await readFile(campaignFormDialogPath, "utf8");
|
||||
|
||||
assert.match(source, /Max\. neue Leads/);
|
||||
assert.doesNotMatch(source, /Max\. Audits/);
|
||||
assert.match(
|
||||
source,
|
||||
/maxAuditsPerRun:\s*campaign\.maxAuditsPerRun\s*\?\?\s*campaignFormDefaults\.maxAuditsPerRun/,
|
||||
);
|
||||
assert.match(source, /maxAuditsPerRun:\s*values\.maxAuditsPerRun/);
|
||||
});
|
||||
@@ -29,6 +29,8 @@ test("campaign board renders campaigns as responsive cards", async () => {
|
||||
assert.match(source, /openEditDialog\(campaign\)/);
|
||||
assert.match(source, /toggleCampaign\(campaign\)/);
|
||||
assert.match(source, /runCampaign\(campaign\)/);
|
||||
assert.match(source, /Lead-Limit:\s*\{campaign\.maxNewLeadsPerRun\}/);
|
||||
assert.doesNotMatch(source, /Limits:\s*L/);
|
||||
});
|
||||
|
||||
test("campaign board surfaces recent run logs", async () => {
|
||||
|
||||
@@ -15,6 +15,8 @@ test("settings metadata rejects secret-like keys", () => {
|
||||
"OPENROUTER_API_KEY",
|
||||
"smtp.password",
|
||||
"googlePlacesToken",
|
||||
"LOCAL_BUSINESS_DATA_API_KEY",
|
||||
"rapidapi.token",
|
||||
"provider_secret",
|
||||
"convex credential",
|
||||
];
|
||||
|
||||
@@ -211,6 +211,7 @@ test("duplicate detection uses placeId and websiteDomain", () => {
|
||||
const existingLeads = [
|
||||
{
|
||||
googlePlaceId: "dup-1",
|
||||
sourceBusinessId: "business-dup-1",
|
||||
websiteDomain: "other.de",
|
||||
email: "blocked@example.de",
|
||||
},
|
||||
@@ -233,6 +234,31 @@ test("duplicate detection uses placeId and websiteDomain", () => {
|
||||
googlePrimaryType: null,
|
||||
googleMapsUrl: null,
|
||||
sourceProvider: "google_places",
|
||||
sourceBusinessId: "business-other",
|
||||
sourceFetchedAt: 0,
|
||||
},
|
||||
existingLeads,
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isDuplicateCandidate(
|
||||
{
|
||||
placeId: "none",
|
||||
businessName: "Test",
|
||||
address: "A",
|
||||
websiteUrl: null,
|
||||
websiteDomain: null,
|
||||
phone: null,
|
||||
rating: null,
|
||||
userRatingCount: null,
|
||||
businessStatus: null,
|
||||
googleTypes: [],
|
||||
googlePrimaryType: null,
|
||||
googleMapsUrl: null,
|
||||
sourceProvider: "local_business_data",
|
||||
sourceBusinessId: "business-dup-1",
|
||||
sourceFetchedAt: 0,
|
||||
},
|
||||
existingLeads,
|
||||
@@ -439,9 +465,10 @@ test("probable duplicates are detected by normalized company+address or normaliz
|
||||
);
|
||||
});
|
||||
|
||||
test("blacklist matches include google_place_id, domain, company and phone", () => {
|
||||
test("blacklist matches include source ids, domain, company and phone", () => {
|
||||
const candidate = {
|
||||
placeId: "place-blacklisted",
|
||||
sourceBusinessId: "business-blacklisted",
|
||||
businessName: "Muster GmbH",
|
||||
address: "A",
|
||||
websiteUrl: "https://www.Blocked.de",
|
||||
@@ -461,6 +488,7 @@ test("blacklist matches include google_place_id, domain, company and phone", ()
|
||||
|
||||
assert.deepEqual(getBlacklistLookupValues(candidate), [
|
||||
{ type: "google_place_id", normalizedValue: "place-blacklisted" },
|
||||
{ type: "source_business_id", normalizedValue: "business-blacklisted" },
|
||||
{ type: "domain", normalizedValue: "blocked.de" },
|
||||
{ type: "company", normalizedValue: "muster gmbh" },
|
||||
{ type: "phone", normalizedValue: "4930555123" },
|
||||
@@ -477,6 +505,11 @@ test("blacklist matches include google_place_id, domain, company and phone", ()
|
||||
value: "place-blacklisted",
|
||||
normalizedValue: "place-blacklisted",
|
||||
},
|
||||
{
|
||||
type: "source_business_id",
|
||||
value: "business-blacklisted",
|
||||
normalizedValue: "business-blacklisted",
|
||||
},
|
||||
{ type: "domain", value: "blocked.de", normalizedValue: "blocked.de" },
|
||||
{ type: "company", value: "Muster GmbH", normalizedValue: "muster gmbh" },
|
||||
{ type: "phone", value: "+49 30 555 123", normalizedValue: "4930555123" },
|
||||
@@ -498,7 +531,15 @@ test("blacklist matches include google_place_id, domain, company and phone", ()
|
||||
const matchTypes = matches.map((match) => match.type).sort();
|
||||
assert.deepEqual(
|
||||
matchTypes,
|
||||
["company", "domain", "google_place_id", "phone", "phone", "email"].sort(),
|
||||
[
|
||||
"company",
|
||||
"domain",
|
||||
"google_place_id",
|
||||
"source_business_id",
|
||||
"phone",
|
||||
"phone",
|
||||
"email",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -522,6 +563,7 @@ test("company normalization for blacklist lookup uses text normalization", () =>
|
||||
|
||||
assert.deepEqual(getBlacklistLookupValues(candidate), [
|
||||
{ type: "google_place_id", normalizedValue: "place-company-spaces" },
|
||||
{ type: "source_business_id", normalizedValue: "place-company-spaces" },
|
||||
{ type: "company", normalizedValue: "muster gmbh" },
|
||||
{ type: "phone", normalizedValue: "4930555123" },
|
||||
{ type: "phone", normalizedValue: "+49 30 555 123" },
|
||||
|
||||
152
tests/lead-discovery-local-business.test.ts
Normal file
152
tests/lead-discovery-local-business.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
LOCAL_BUSINESS_DATA_HOST,
|
||||
buildLocalBusinessSearchUrl,
|
||||
getLocalBusinessSearchSpec,
|
||||
normalizeLocalBusinessSearchResponse,
|
||||
} from "../lib/lead-discovery-local-business";
|
||||
|
||||
test("Local Business Data search URL uses RapidAPI host-compatible query params", () => {
|
||||
const url = new URL(
|
||||
buildLocalBusinessSearchUrl({
|
||||
query: "Anwalt in 10115 Deutschland",
|
||||
limit: 9999,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(url.origin, `https://${LOCAL_BUSINESS_DATA_HOST}`);
|
||||
assert.equal(url.pathname, "/search");
|
||||
assert.equal(url.searchParams.get("query"), "Anwalt in 10115 Deutschland");
|
||||
assert.equal(url.searchParams.get("limit"), "500");
|
||||
assert.equal(url.searchParams.get("language"), "de");
|
||||
assert.equal(url.searchParams.get("region"), "de");
|
||||
assert.equal(url.searchParams.get("extract_emails_and_contacts"), "true");
|
||||
});
|
||||
|
||||
test("Local Business Data campaign spec builds the SaaS lead-discovery query from niche and PLZ", () => {
|
||||
const spec = getLocalBusinessSearchSpec({
|
||||
categoryMode: "custom",
|
||||
category: "Anderes",
|
||||
customSearchTerm: "Webdesigner fuer Restaurants",
|
||||
postalCode: "79098",
|
||||
maxNewLeads: 8,
|
||||
});
|
||||
|
||||
assert.equal(spec.query, "Webdesigner fuer Restaurants in 79098 Deutschland");
|
||||
assert.equal(spec.limit, 8);
|
||||
assert.match(spec.url, /extract_emails_and_contacts=true/);
|
||||
});
|
||||
|
||||
test("Local Business Data response normalizes direct business emails into lead candidates", () => {
|
||||
const candidates = normalizeLocalBusinessSearchResponse(
|
||||
{
|
||||
status: "OK",
|
||||
request_id: "req-123",
|
||||
data: [
|
||||
{
|
||||
business_id: "biz-1",
|
||||
place_id: "place-1",
|
||||
name: "Kanzlei Beispiel",
|
||||
full_address: "Musterstrasse 1, 10115 Berlin",
|
||||
website: "https://www.beispiel-kanzlei.de/kontakt",
|
||||
phone_number: "+49 30 123456",
|
||||
rating: 4.7,
|
||||
review_count: 31,
|
||||
business_status: "OPERATIONAL",
|
||||
types: ["lawyer"],
|
||||
google_maps_url: "https://maps.google.com/?cid=biz-1",
|
||||
emails: ["Herr.Bewerber@beispiel-kanzlei.de", "Info@Beispiel-Kanzlei.de"],
|
||||
},
|
||||
],
|
||||
},
|
||||
1717480000000,
|
||||
);
|
||||
|
||||
assert.equal(candidates.length, 1);
|
||||
assert.equal(candidates[0]?.sourceProvider, "local_business_data");
|
||||
assert.equal(candidates[0]?.sourceBusinessId, "biz-1");
|
||||
assert.equal(candidates[0]?.placeId, "place-1");
|
||||
assert.equal(candidates[0]?.businessName, "Kanzlei Beispiel");
|
||||
assert.equal(candidates[0]?.websiteDomain, "beispiel-kanzlei.de");
|
||||
assert.equal(candidates[0]?.contactEmails?.length, 2);
|
||||
assert.equal(candidates[0]?.contactEmails?.[1]?.email, "info@beispiel-kanzlei.de");
|
||||
assert.equal(candidates[0]?.contactEmails?.[1]?.emailSource, "local_business_data");
|
||||
});
|
||||
|
||||
test("Local Business Data response reads extracted emails from emails_and_contacts", () => {
|
||||
const candidates = normalizeLocalBusinessSearchResponse(
|
||||
{
|
||||
status: "OK",
|
||||
request_id: "req-nested-email",
|
||||
data: [
|
||||
{
|
||||
business_id: "biz-nested",
|
||||
name: "PORZIG Immobilien GmbH",
|
||||
full_address: "Silberstrasse 18, 08451 Crimmitschau",
|
||||
website: "https://porzig.info",
|
||||
phone_number: "+49 3762 759775",
|
||||
emails_and_contacts: {
|
||||
emails: [
|
||||
"info@porzig.info",
|
||||
"Info@Porzig.Info",
|
||||
"makler@porzig.info",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
1717480000003,
|
||||
);
|
||||
|
||||
assert.equal(candidates.length, 1);
|
||||
assert.equal(candidates[0]?.contactEmails?.length, 2);
|
||||
assert.equal(candidates[0]?.email, "info@porzig.info");
|
||||
assert.equal(candidates[0]?.emailSource, "local_business_data");
|
||||
assert.deepEqual(
|
||||
candidates[0]?.contactEmails?.map((entry) => entry.email),
|
||||
["info@porzig.info", "makler@porzig.info"],
|
||||
);
|
||||
});
|
||||
|
||||
test("Local Business Data response accepts object-wrapped business arrays", () => {
|
||||
const candidates = normalizeLocalBusinessSearchResponse(
|
||||
{
|
||||
status: "OK",
|
||||
request_id: "req-456",
|
||||
data: {
|
||||
businesses: [
|
||||
{
|
||||
business_id: "biz-2",
|
||||
name: "Malerbetrieb Beispiel",
|
||||
address: "Hauptstrasse 2, 79098 Freiburg",
|
||||
site: "https://maler.example",
|
||||
phone: "+49 761 123",
|
||||
email: "kontakt@maler.example",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
1717480000001,
|
||||
);
|
||||
|
||||
assert.equal(candidates.length, 1);
|
||||
assert.equal(candidates[0]?.placeId, "biz-2");
|
||||
assert.equal(candidates[0]?.email, "kontakt@maler.example");
|
||||
});
|
||||
|
||||
test("Local Business Data response throws provider error messages", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeLocalBusinessSearchResponse(
|
||||
{
|
||||
status: "ERROR",
|
||||
request_id: "req-error",
|
||||
error: { message: "Missing query", code: 400 },
|
||||
},
|
||||
1717480000002,
|
||||
),
|
||||
/Local Business Data API error 400: Missing query/,
|
||||
);
|
||||
});
|
||||
@@ -61,24 +61,49 @@ test("persistDiscoveredLeads does not schedule website enrichment jobs directly"
|
||||
);
|
||||
});
|
||||
|
||||
test("processCampaignRun schedules website enrichment after lead persistence", () => {
|
||||
test("persistDiscoveredLeads backfills missing emails on duplicate re-runs only", () => {
|
||||
const source = extractExportSource("persistDiscoveredLeads");
|
||||
|
||||
assert.match(source, /getUsableContactEmail\(candidate\)/);
|
||||
assert.match(source, /duplicateLeadForEmailBackfill/);
|
||||
assert.match(source, /ctx\.db\.patch\(\s*duplicateLeadForEmailBackfill\._id/);
|
||||
assert.match(source, /normalizedEmail:\s*usableEmail\.email/);
|
||||
assert.match(source, /email:\s*usableEmail\.email/);
|
||||
assert.match(source, /contactStatus\s*=\s*"new"/);
|
||||
assert.match(source, /contactStatus\s*!==\s*"do_not_contact"/);
|
||||
assert.match(source, /blacklistStatus\s*!==\s*"blocked"/);
|
||||
assert.match(source, /duplicateByEmailRows\.length\s*===\s*0/);
|
||||
assert.doesNotMatch(source, /internal\.websiteEnrichment\.queueLeadEnrichment/);
|
||||
assert.doesNotMatch(source, /internal\.pageSpeed\.queueLeadPageSpeedAudit/);
|
||||
});
|
||||
|
||||
test("processCampaignRun uses Local Business Data and does not schedule website enrichment", () => {
|
||||
const source = extractExportSource("processCampaignRun");
|
||||
|
||||
const persistIndex = source.indexOf(
|
||||
"internal.leadDiscovery.persistDiscoveredLeads",
|
||||
);
|
||||
const queueCall = source.indexOf("internal.websiteEnrichment.queueLeadEnrichment");
|
||||
const eventMessageIndex = source.indexOf("Website-Kontaktanreicherung geplant.");
|
||||
|
||||
assert.notEqual(persistIndex, -1, "processCampaignRun should persist discovered leads");
|
||||
assert.notEqual(queueCall, -1, "processCampaignRun should schedule website enrichment");
|
||||
assert.notEqual(eventMessageIndex, -1, "processCampaignRun should append enrichment schedule events");
|
||||
assert.ok(
|
||||
persistIndex < queueCall,
|
||||
"processCampaignRun should schedule enrichment after persistence succeeds",
|
||||
assert.equal(
|
||||
queueCall,
|
||||
-1,
|
||||
"Campaign discovery must not schedule website enrichment in the SaaS flow",
|
||||
);
|
||||
assert.ok(
|
||||
queueCall < eventMessageIndex,
|
||||
"processCampaignRun should append enrichment event after scheduling",
|
||||
assert.equal(
|
||||
source.includes("GOOGLE_GEOCODING_API_KEY") || source.includes("GOOGLE_PLACES_API_KEY"),
|
||||
false,
|
||||
"Campaign discovery should no longer require Google lead-discovery keys",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/LOCAL_BUSINESS_DATA_API_KEY/,
|
||||
"Campaign discovery should read the Local Business Data API key",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/local_business_data/,
|
||||
"Campaign discovery should record Local Business Data as the source",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -80,25 +80,34 @@ test("LeadsReviewTable uses compact card summaries with modal review details", a
|
||||
"Location should use overflow-safe text classes in compact card.",
|
||||
);
|
||||
|
||||
const emailSpanMatch = source.match(
|
||||
/<span className="([^"]+)">\s*\{lead\.email \|\| "Keine E-Mail"\}\s*<\/span>/,
|
||||
const emailAnchorMatch = source.match(
|
||||
/<a className="([^"]+)" href=\{emailHref\}>\s*\{lead\.email\}\s*<\/a>/,
|
||||
);
|
||||
assert.ok(
|
||||
emailSpanMatch !== null &&
|
||||
emailAnchorMatch !== null &&
|
||||
/(?:^|\s)(break-all|max-w-full|min-w-0)(?:\s|$)/.test(
|
||||
emailSpanMatch[1],
|
||||
emailAnchorMatch[1],
|
||||
),
|
||||
"Lead email should use overflow-safe text classes in compact card.",
|
||||
"Lead email should use an overflow-safe mailto link in compact card.",
|
||||
);
|
||||
assert.match(source, /<span className="[^"]*">\s*Keine E-Mail\s*<\/span>/);
|
||||
|
||||
const phoneSpanMatch = source.match(
|
||||
/<span className="([^"]+)">\s*\{lead\.phone\}\s*<\/span>/,
|
||||
const phoneAnchorMatch = source.match(
|
||||
/<a className="([^"]+)" href=\{phoneHref\}>\s*\{lead\.phone\}\s*<\/a>/,
|
||||
);
|
||||
assert.ok(
|
||||
phoneSpanMatch !== null &&
|
||||
/(?:^|\s)(break-all|max-w-full|min-w-0)(?:\s|$)/.test(phoneSpanMatch[1]),
|
||||
"Lead phone should use overflow-safe text classes in compact card.",
|
||||
phoneAnchorMatch !== null &&
|
||||
/(?:^|\s)(break-all|max-w-full|min-w-0)(?:\s|$)/.test(phoneAnchorMatch[1]),
|
||||
"Lead phone should use an overflow-safe tel link in compact card.",
|
||||
);
|
||||
assert.match(source, /toEmailHref/);
|
||||
assert.match(source, /mailto:\$\{normalizedEmail\}/);
|
||||
assert.match(source, /toPhoneHref/);
|
||||
assert.match(source, /tel:\$\{dialablePhone\}/);
|
||||
assert.match(source, /toWebsiteHref/);
|
||||
assert.match(source, /href=\{websiteHref\}/);
|
||||
assert.match(source, /target="_blank"/);
|
||||
assert.match(source, /rel="noreferrer"/);
|
||||
|
||||
assert.match(source, /Kontaktstatus/);
|
||||
assert.match(source, /Review-E-Mail/);
|
||||
@@ -122,3 +131,15 @@ test("LeadsReviewTable exposes count filters and live status feedback", async ()
|
||||
assert.match(source, /role="status"/);
|
||||
assert.match(source, /role="alert"/);
|
||||
});
|
||||
|
||||
test("LeadsReviewTable exposes explicit manual audit start action", async () => {
|
||||
const source = await readFile(leadsReviewPath, "utf8");
|
||||
|
||||
assert.match(source, /api\.pageSpeed\.requestLeadAudit/);
|
||||
assert.match(source, /api\.pageSpeed\.getLeadAuditStartStates/);
|
||||
assert.match(source, /Audit starten/);
|
||||
assert.match(source, /auditStartDisabledReason/);
|
||||
assert.match(source, /Website fehlt|Keine Website/);
|
||||
assert.match(source, /Audit l(?:ä|ae)uft|Audit läuft/);
|
||||
assert.doesNotMatch(source, /api\.websiteEnrichment\.queueLeadEnrichment/);
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ test("integration readiness covers all MVP providers", () => {
|
||||
assert.deepEqual(
|
||||
integrationReadinessDefinitions.map((definition) => definition.id),
|
||||
[
|
||||
"google",
|
||||
"local_business_data",
|
||||
"pagespeed",
|
||||
"openrouter",
|
||||
"screenshotone",
|
||||
@@ -24,24 +24,21 @@ test("integration readiness covers all MVP providers", () => {
|
||||
|
||||
test("integration readiness reports missing configuration without leaking values", () => {
|
||||
const rows = getIntegrationReadiness({
|
||||
GOOGLE_GEOCODING_API_KEY: "secret-google",
|
||||
GOOGLE_PLACES_API_KEY: "secret-places",
|
||||
LOCAL_BUSINESS_DATA_API_KEY: "secret-local-business",
|
||||
PAGESPEED_API_KEY: "",
|
||||
});
|
||||
|
||||
const google = rows.find((row) => row.id === "google");
|
||||
const localBusinessData = rows.find((row) => row.id === "local_business_data");
|
||||
const pageSpeed = rows.find((row) => row.id === "pagespeed");
|
||||
|
||||
assert.equal(google?.status, "configured");
|
||||
assert.equal(localBusinessData?.status, "configured");
|
||||
assert.equal(pageSpeed?.status, "missing");
|
||||
assert.equal(JSON.stringify(rows).includes("secret-google"), false);
|
||||
assert.equal(JSON.stringify(rows).includes("secret-places"), false);
|
||||
assert.equal(JSON.stringify(rows).includes("secret-local-business"), 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",
|
||||
LOCAL_BUSINESS_DATA_API_KEY: "secret-local-business",
|
||||
PAGESPEED_API_KEY: "secret-pagespeed",
|
||||
PAGESPEED_TIMEOUT_MS: "60000",
|
||||
OPENROUTER_API_KEY: "secret-openrouter",
|
||||
|
||||
@@ -16,7 +16,7 @@ test("settings page surfaces integration status instead of a placeholder", () =>
|
||||
assert.match(pageSource, /OperationsReadiness/);
|
||||
|
||||
for (const label of [
|
||||
"Google",
|
||||
"Local Business Data",
|
||||
"PageSpeed",
|
||||
"OpenRouter",
|
||||
"ScreenshotOne",
|
||||
|
||||
@@ -112,13 +112,14 @@ test("OutreachReviewWorkspace separates audit publication from email approval",
|
||||
|
||||
assert.match(source, /Audit veröffentlichen/);
|
||||
assert.match(source, /Änderungen speichern/);
|
||||
assert.match(source, /E-Mail freigeben und senden/);
|
||||
assert.match(source, /E-Mail freigeben/);
|
||||
assert.match(source, /Final senden/);
|
||||
assert.match(source, /useAction/);
|
||||
assert.match(source, /outreachSendAction[\s\S]*sendApprovedEmail/);
|
||||
|
||||
const auditPublishIndex = source.indexOf("Audit veröffentlichen");
|
||||
const auditSaveIndex = source.indexOf("Änderungen speichern");
|
||||
const emailApprovalIndex = source.indexOf("E-Mail freigeben und senden");
|
||||
const emailApprovalIndex = source.indexOf("E-Mail freigeben");
|
||||
|
||||
assert.ok(auditPublishIndex >= 0);
|
||||
assert.ok(auditSaveIndex >= 0);
|
||||
|
||||
@@ -84,6 +84,8 @@ test("pageSpeed module exports mutation contracts", () => {
|
||||
assert.equal(existsSync(pageSpeedPath), true, "pageSpeed.ts should be present");
|
||||
const exports = getExportedConstNames(sourceFile);
|
||||
const required = [
|
||||
"getLeadAuditStartStates",
|
||||
"requestLeadAudit",
|
||||
"queueLeadPageSpeedAudit",
|
||||
"startPageSpeedAuditRun",
|
||||
"persistPageSpeedResult",
|
||||
@@ -110,12 +112,40 @@ test("pageSpeed module uses internalMutation for queue/start/persist/finish", ()
|
||||
}
|
||||
});
|
||||
|
||||
test("requestLeadAudit is a public authenticated mutation that queues PageSpeed only after user intent", () => {
|
||||
const source = extractExportSource("requestLeadAudit");
|
||||
|
||||
assert.equal(
|
||||
hasPattern(pageSpeedSource, /export const requestLeadAudit = mutation\s*\(/),
|
||||
true,
|
||||
"requestLeadAudit should be a public mutation for UI-triggered audit starts.",
|
||||
);
|
||||
assert.match(source, /requireOperator\(ctx\)/);
|
||||
assert.match(source, /queueLeadPageSpeedAuditForLead/);
|
||||
assert.match(source, /triggeredBy:\s*"manual"/);
|
||||
assert.match(source, /Audit-Start wurde manuell angefordert\./);
|
||||
});
|
||||
|
||||
test("getLeadAuditStartStates exposes active audit run status for lead review buttons", () => {
|
||||
const source = extractExportSource("getLeadAuditStartStates");
|
||||
|
||||
assert.equal(
|
||||
hasPattern(pageSpeedSource, /export const getLeadAuditStartStates = query\s*\(/),
|
||||
true,
|
||||
"getLeadAuditStartStates should be a public query.",
|
||||
);
|
||||
assert.match(source, /requireOperator\(ctx\)/);
|
||||
assert.match(source, /leadIds:\s*v\.array\(v\.id\("leads"\)\)/);
|
||||
assert.match(pageSpeedSource, /by_type_and_status_and_leadId/);
|
||||
assert.match(source, /canStart/);
|
||||
});
|
||||
|
||||
test("queueLeadPageSpeedAudit dedupes per lead and schedules pagespeed action", () => {
|
||||
const queueSource = extractExportSource("queueLeadPageSpeedAudit");
|
||||
const queueSource = pageSpeedSource;
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
queueSource,
|
||||
/withIndex\(\s*"by_type_and_status_and_leadId"[\s\S]*?eq\("type",\s*"audit"\)[\s\S]*?eq\("status",\s*"pending"\)[\s\S]*?eq\("leadId",\s*args\.leadId\)/,
|
||||
/withIndex\(\s*"by_type_and_status_and_leadId"[\s\S]*?eq\("type",\s*"audit"\)[\s\S]*?eq\("status",\s*"pending"\)[\s\S]*?eq\("leadId",\s*(?:args\.)?leadId\)/,
|
||||
),
|
||||
true,
|
||||
"Queue should dedupe pending audit runs by type+status+leadId.",
|
||||
@@ -123,7 +153,7 @@ test("queueLeadPageSpeedAudit dedupes per lead and schedules pagespeed action",
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
queueSource,
|
||||
/withIndex\(\s*"by_type_and_status_and_leadId"[\s\S]*?eq\("type",\s*"audit"\)[\s\S]*?eq\("status",\s*"running"\)[\s\S]*?eq\("leadId",\s*args\.leadId\)/,
|
||||
/withIndex\(\s*"by_type_and_status_and_leadId"[\s\S]*?eq\("type",\s*"audit"\)[\s\S]*?eq\("status",\s*"running"\)[\s\S]*?eq\("leadId",\s*(?:args\.)?leadId\)/,
|
||||
),
|
||||
true,
|
||||
"Queue should dedupe running audit runs by type+status+leadId.",
|
||||
|
||||
@@ -151,7 +151,7 @@ const usageReadQueries = [
|
||||
|
||||
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/,
|
||||
/USAGE_EVENT_PROVIDERS\s*=\s*\[[\s\S]*"openrouter"[\s\S]*"screenshotone"[\s\S]*"jina"[\s\S]*"pagespeed"[\s\S]*"google_places"[\s\S]*"local_business_data"[\s\S]*\]\s*as const/,
|
||||
domainSource,
|
||||
"Domain should declare usage providers for all managed external services.",
|
||||
);
|
||||
|
||||
@@ -336,8 +336,8 @@ test("browserless website enrichment persists crawl evidence without screenshots
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(fallbackSource, /internal\.pageSpeed\.queueLeadPageSpeedAudit/),
|
||||
true,
|
||||
"Browserless enrichment should keep the downstream PageSpeed handoff.",
|
||||
false,
|
||||
"Browserless enrichment must not automatically queue PageSpeed or audit runs.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -760,7 +760,7 @@ test("website enrichment guards long browser work before Convex action runtime a
|
||||
);
|
||||
});
|
||||
|
||||
test("processLeadEnrichment schedules PageSpeed audit jobs after successful enrichment", () => {
|
||||
test("processLeadEnrichment does not schedule PageSpeed audit jobs after enrichment", () => {
|
||||
const processBody = extractExportSource(actionSource, "processLeadEnrichment");
|
||||
const persistIndex = processBody.indexOf(
|
||||
"internal.websiteEnrichment.persistLeadEnrichmentResult",
|
||||
@@ -769,49 +769,16 @@ test("processLeadEnrichment schedules PageSpeed audit jobs after successful enri
|
||||
"internal.pageSpeed.queueLeadPageSpeedAudit",
|
||||
persistIndex,
|
||||
);
|
||||
const finishIndex = processBody.indexOf(
|
||||
"internal.websiteEnrichment.finishLeadEnrichmentRun",
|
||||
persistIndex,
|
||||
);
|
||||
|
||||
assert.notEqual(queueIndex, -1, "processLeadEnrichment should queue PageSpeed audits");
|
||||
assert.notEqual(persistIndex, -1, "processLeadEnrichment should persist website enrichment result");
|
||||
assert.notEqual(finishIndex, -1, "processLeadEnrichment should finish enrichment run");
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
processBody,
|
||||
/runMutation\(\s*internal\.pageSpeed\.queueLeadPageSpeedAudit[\s\S]*leadId:\s*started\.lead\._id[\s\S]*parentRunId:\s*runId[\s\S]*\)/,
|
||||
),
|
||||
true,
|
||||
"Queue call should pass lead ID and parent run ID",
|
||||
);
|
||||
|
||||
assert.equal(queueIndex > persistIndex, true, "PageSpeed queueing should happen after persistence");
|
||||
assert.equal(queueIndex < finishIndex, true, "PageSpeed queueing should happen before success finish");
|
||||
});
|
||||
|
||||
test("processLeadEnrichment records warning on PageSpeed queue failure and continues", () => {
|
||||
const processBody = extractExportSource(actionSource, "processLeadEnrichment");
|
||||
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
processBody,
|
||||
/try\s*\{[\s\S]*internal\.pageSpeed\.queueLeadPageSpeedAudit[\s\S]*\}\s*catch\s*\([^)]*\)\s*\{[\s\S]*internal\.runs\.appendEventInternal[\s\S]*level:\s*"warning"/,
|
||||
),
|
||||
true,
|
||||
"Queueing PageSpeed should be wrapped in warning-safe try/catch",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
processBody,
|
||||
/PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden\./,
|
||||
),
|
||||
true,
|
||||
"Warning event should describe queue failure",
|
||||
queueIndex,
|
||||
-1,
|
||||
"Website enrichment must not automatically queue PageSpeed or audit runs",
|
||||
);
|
||||
});
|
||||
|
||||
test("processLeadEnrichment regression: queue PageSpeed on invalid URL failure when started lead exists", () => {
|
||||
test("processLeadEnrichment regression: invalid URL failure does not queue PageSpeed", () => {
|
||||
const processBody = extractExportSource(actionSource, "processLeadEnrichment");
|
||||
const invalidUrlStart = processBody.indexOf("if (!rootUrl)");
|
||||
assert.notEqual(invalidUrlStart, -1, "Invalid URL guard should exist");
|
||||
@@ -823,27 +790,16 @@ test("processLeadEnrichment regression: queue PageSpeed on invalid URL failure w
|
||||
"Invalid URL branch should return null",
|
||||
);
|
||||
|
||||
const queueCallInInvalidUrl = processBody.indexOf(
|
||||
"internal.pageSpeed.queueLeadPageSpeedAudit",
|
||||
invalidUrlStart,
|
||||
);
|
||||
assert.equal(
|
||||
queueCallInInvalidUrl > invalidUrlStart && queueCallInInvalidUrl < invalidUrlReturnNull,
|
||||
true,
|
||||
"Invalid URL failure path should queue PageSpeed before returning.",
|
||||
);
|
||||
const invalidUrlBranch = processBody.slice(invalidUrlStart, invalidUrlReturnNull);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
invalidUrlBranch,
|
||||
/leadId:\s*started\.lead\._id[\s\S]*?parentRunId:\s*runId/,
|
||||
processBody.slice(invalidUrlStart, invalidUrlReturnNull).includes(
|
||||
"internal.pageSpeed.queueLeadPageSpeedAudit",
|
||||
),
|
||||
true,
|
||||
"Invalid URL queue payload should use started.lead._id and parentRunId runId.",
|
||||
false,
|
||||
"Invalid URL branch should not queue PageSpeed automatically.",
|
||||
);
|
||||
});
|
||||
|
||||
test("processLeadEnrichment regression: queue PageSpeed in fatal catch path with started lead", () => {
|
||||
test("processLeadEnrichment regression: fatal catch path does not queue PageSpeed", () => {
|
||||
const processBody = extractExportSource(actionSource, "processLeadEnrichment");
|
||||
const outerCatchStart = processBody.lastIndexOf("catch (error)");
|
||||
assert.notEqual(outerCatchStart, -1, "Outer catch block should exist");
|
||||
@@ -858,24 +814,11 @@ test("processLeadEnrichment regression: queue PageSpeed in fatal catch path with
|
||||
"Outer catch should return null on unrecoverable errors.",
|
||||
);
|
||||
|
||||
const queueCallInCatch = processBody.indexOf(
|
||||
"internal.pageSpeed.queueLeadPageSpeedAudit",
|
||||
outerCatchStart,
|
||||
);
|
||||
assert.equal(
|
||||
queueCallInCatch > outerCatchStart &&
|
||||
queueCallInCatch > startedGuard &&
|
||||
queueCallInCatch < catchReturnNull,
|
||||
true,
|
||||
"Fatal catch path should queue PageSpeed before returning, while started lead exists.",
|
||||
);
|
||||
const catchBlock = processBody.slice(outerCatchStart, catchReturnNull);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
catchBlock,
|
||||
/leadId:\s*started\.lead\._id[\s\S]*?parentRunId:\s*runId/,
|
||||
processBody.slice(outerCatchStart, catchReturnNull).includes(
|
||||
"internal.pageSpeed.queueLeadPageSpeedAudit",
|
||||
),
|
||||
true,
|
||||
"Catch-path PageSpeed queue payload should use started.lead._id and parentRunId runId.",
|
||||
false,
|
||||
"Fatal catch path should not queue PageSpeed automatically.",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user