266 lines
8.1 KiB
TypeScript
266 lines
8.1 KiB
TypeScript
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<string, unknown>;
|
|
error?: {
|
|
flatten?: () => { fieldErrors: Record<string, string[] | undefined> };
|
|
issues?: Array<{
|
|
path?: Array<string | number>;
|
|
message?: string;
|
|
}>;
|
|
};
|
|
};
|
|
};
|
|
mapCampaignFormToPayload: (values: Record<string, unknown>) => Record<string, unknown>;
|
|
};
|
|
|
|
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<string, unknown> = {}) {
|
|
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<string, unknown>,
|
|
) as Record<string, unknown>;
|
|
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}`,
|
|
);
|
|
}
|
|
});
|