import assert from "node:assert/strict"; import test from "node:test"; import * as campaignForm from "../lib/campaign-form"; type CampaignFormModule = { campaignFormSchema: { safeParse: (value: unknown) => { success: boolean; data?: Record; error?: { flatten?: () => { fieldErrors: Record }; issues?: Array<{ path?: Array; message?: string; }>; }; }; }; mapCampaignFormToPayload: (values: Record) => Record; }; type ErrorLike = CampaignFormModule["campaignFormSchema"]["safeParse"] extends ( value: unknown, ) => infer Result ? Result : never; const formModule = campaignForm as unknown as CampaignFormModule; function toString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } function extractFieldMessages( result: ErrorLike, field: string, ): string[] { const messages: string[] = []; const flatten = result.error?.flatten?.(); const flattenedMessages = flatten?.fieldErrors?.[field]; if (flattenedMessages && Array.isArray(flattenedMessages)) { for (const message of flattenedMessages) { if (typeof message === "string" && message.length > 0) { messages.push(message); } } } for (const issue of result.error?.issues ?? []) { const isTargetField = issue.path?.at(-1) === field; if ( isTargetField && typeof issue.message === "string" && issue.message.length > 0 ) { messages.push(issue.message); } } if (messages.length === 0) { const fallback = JSON.stringify(result.error); if (fallback.length > 2) { messages.push(fallback); } } return messages; } function createMinimalValidForm(overrides: Record = {}) { return { name: "Malerbetrieb Konstruktiv", categoryMode: "preset", category: "Maler", customSearchTerm: "", postalCode: "79098", radiusKm: 10, maxNewLeadsPerRun: 5, maxAuditsPerRun: 5, recurrence: "daily", status: "active", ...overrides, }; } test("valid minimal campaign form maps to a Germany-only payload", () => { const schemaResult = formModule.campaignFormSchema.safeParse( createMinimalValidForm(), ); assert.equal(schemaResult.success, true); const payload = formModule.mapCampaignFormToPayload( schemaResult.data as Record, ) as Record; const germanyIndicators = [ payload.countryCode, payload.country, payload.region, ]; const hasGermany = germanyIndicators.some((value) => { const normalized = toString(value)?.toLowerCase(); return ( normalized === "de" || normalized === "deutschland" || normalized?.includes("deutschland") ); }); assert.equal( hasGermany, true, "Mapped payload should enforce Germany-only context", ); }); test("German PLZ validation enforces exactly five digits with clear German feedback", () => { const schema = formModule.campaignFormSchema; const invalidPostcodes = [ "", "1234", "123456", "abcde", "12 34", ]; for (const postalCode of invalidPostcodes) { const result = schema.safeParse(createMinimalValidForm({ postalCode })); assert.equal(result.success, false); const messages = extractFieldMessages(result as ErrorLike, "postalCode"); assert.ok(messages.length > 0, "Expected a validation message for postal code"); const messageText = messages.join(" "); assert.match( messageText, /PLZ|Postleitzahl/i, `Expected postal-code message in German semantics, got: ${messageText}`, ); assert.match( messageText, /5|fünf|5-stellig|stellig/i, `Expected explicit five-digit guidance, got: ${messageText}`, ); } }); test("predefined categories do not require custom niche input", () => { const preset = createMinimalValidForm({ categoryMode: "preset", category: "Maler", customSearchTerm: "", }); const result = formModule.campaignFormSchema.safeParse(preset); assert.equal(result.success, true, "Predefined category should parse without custom term"); }); test("Anderes/custom category requires a non-empty custom niche field", () => { const anderes = formModule.campaignFormSchema.safeParse( createMinimalValidForm({ categoryMode: "custom", category: "Anderes", customSearchTerm: "", }), ); assert.equal(anderes.success, false); const messages = extractFieldMessages(anderes as ErrorLike, "customSearchTerm"); assert.ok(messages.length > 0, "Expected a validation message for missing custom term"); const messageText = messages.join(" "); assert.match( messageText, /Anderes|eigene|benötigt|erforderlich|muss/i, `Expected explicit guidance for Anderes custom term, got: ${messageText}`, ); }); test("radius and lead/audit limits reject invalid zero, negative, and out-of-range values", () => { const schema = formModule.campaignFormSchema; const fieldLimits: Array<{ field: "radiusKm" | "maxNewLeadsPerRun" | "maxAuditsPerRun"; invalid: number[] }> = [ { field: "radiusKm", invalid: [0, -1, 10000] }, { field: "maxNewLeadsPerRun", invalid: [0, -1, 10000] }, { field: "maxAuditsPerRun", invalid: [0, -1, 10000] }, ]; for (const { field, invalid } of fieldLimits) { for (const value of invalid) { const values = createMinimalValidForm({ [field]: value }); const result = schema.safeParse(values); assert.equal(result.success, false); const messages = extractFieldMessages(result as ErrorLike, field); assert.ok( messages.length > 0, `Expected validation for invalid value on ${field}: ${value}`, ); const messageText = messages.join(" "); assert.match( messageText, /muss|mindestens|zwischen|gültig|positiv/i, `Expected German limit/range explanation for ${field}, got: ${messageText}`, ); } } }); test("radius and lead/audit limits reject decimal values", () => { const schema = formModule.campaignFormSchema; const fieldLimits: Array<{ field: "radiusKm" | "maxNewLeadsPerRun" | "maxAuditsPerRun"; invalid: number[] }> = [ { field: "radiusKm", invalid: [10.5, 0.1, 12.99] }, { field: "maxNewLeadsPerRun", invalid: [1.9, 5.25] }, { field: "maxAuditsPerRun", invalid: [0.01, 2.75] }, ]; for (const { field, invalid } of fieldLimits) { for (const value of invalid) { const values = createMinimalValidForm({ [field]: value }); const result = schema.safeParse(values); assert.equal(result.success, false); const messages = extractFieldMessages(result as ErrorLike, field); assert.ok( messages.length > 0, `Expected validation for decimal value on ${field}: ${value}`, ); const messageText = messages.join(" "); assert.match( messageText, /Ganzzahl|ganze|integer|Nachkommastellen|dezim|komma/i, `Expected decimal-rejection guidance for ${field}, got: ${messageText}`, ); } } }); test("recurrence/cadence only accepts manual, daily, weekly, monthly", () => { const schema = formModule.campaignFormSchema; const validValues = ["manual", "daily", "weekly", "monthly"] as const; const invalidValues = ["hourly", "biweekly", "yearly", ""] as const; for (const recurrence of validValues) { const valid = schema.safeParse(createMinimalValidForm({ recurrence })); assert.equal(valid.success, true, `Expected recurrence ${recurrence} to parse`); } for (const recurrence of invalidValues) { const invalid = schema.safeParse(createMinimalValidForm({ recurrence })); assert.equal(invalid.success, false, `Expected recurrence ${recurrence} to be rejected`); const messages = extractFieldMessages(invalid as ErrorLike, "recurrence"); assert.ok(messages.length > 0); const messageText = messages.join(" "); assert.match( messageText, /manuell|täglich|wöchentlich|monatlich|Auswahl|gültig/i, `Expected actionable German cadence guidance, got: ${messageText}`, ); } });