feat: add campaign configuration controls

This commit is contained in:
2026-06-04 14:45:47 +02:00
parent 07841aea0f
commit 585c4eeb2a
24 changed files with 2941 additions and 34 deletions

265
tests/campaign-form.test.ts Normal file
View File

@@ -0,0 +1,265 @@
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}`,
);
}
});

View File

@@ -0,0 +1,270 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as campaignScheduling from "../lib/campaign-scheduling";
type CampaignSchedulingModule = {
calculateNextRunAt: (input: {
recurrence: string;
status: "active" | "paused";
lastRunAt?: number | null;
now?: number;
}) => number | null;
getCampaignCurrentRunStatus: (
input: {
campaignStatus: "active" | "paused";
agentRuns?: Array<{
status: string;
updatedAt?: number;
}>;
},
) => string;
isAllowedCampaignRecurrence?: (value: string) => boolean;
CAMPAIGN_RECURRENCES?: readonly string[];
};
type SchedulingRun = {
status: string;
updatedAt?: number;
};
const schedulingModule = campaignScheduling as unknown as CampaignSchedulingModule;
function addDaysUTC(timestamp: number, days: number) {
const date = new Date(timestamp);
return Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate() + days,
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
date.getUTCMilliseconds(),
);
}
function addMonthsUTC(timestamp: number, months: number) {
const date = new Date(timestamp);
return Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth() + months,
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
date.getUTCMilliseconds(),
);
}
function expectFunction<T>(value: unknown, name: string): T {
assert.equal(
typeof value,
"function",
`Expected ${name} to be implemented and exported`,
);
return value as T;
}
function runWithLatest(updatedRuns: SchedulingRun[]) {
return updatedRuns.sort((a, b) => {
const aTime = typeof a.updatedAt === "number" ? a.updatedAt : 0;
const bTime = typeof b.updatedAt === "number" ? b.updatedAt : 0;
return bTime - aTime;
});
}
test("recurrence validation only allows manual/daily/weekly/monthly", () => {
const allowed = ["manual", "daily", "weekly", "monthly"];
const forbidden = ["hourly", "2xweekly", "biweekly", ""];
if (typeof schedulingModule.isAllowedCampaignRecurrence === "function") {
for (const value of allowed) {
assert.equal(
schedulingModule.isAllowedCampaignRecurrence(value),
true,
`${value} should be accepted`,
);
}
for (const value of forbidden) {
assert.equal(
schedulingModule.isAllowedCampaignRecurrence(value),
false,
`${value} should be rejected`,
);
}
return;
}
assert.ok(
Array.isArray(schedulingModule.CAMPAIGN_RECURRENCES),
"Expected recurrence validation helper or CAMPAIGN_RECURRENCES list",
);
assert.deepEqual(
[...schedulingModule.CAMPAIGN_RECURRENCES!].sort(),
[...allowed].sort(),
);
});
test("next run is null for manual or paused campaigns and scheduled for active recurring campaigns", () => {
const calculateNextRunAt = expectFunction<(input: {
recurrence: string;
status: "active" | "paused";
lastRunAt?: number | null;
now?: number;
}) => number | null>(schedulingModule.calculateNextRunAt, "calculateNextRunAt");
const lastRunAt = Date.UTC(2026, 5, 1, 8, 0, 0);
assert.equal(
calculateNextRunAt({
recurrence: "manual",
status: "active",
lastRunAt,
}),
null,
);
assert.equal(
calculateNextRunAt({
recurrence: "daily",
status: "paused",
lastRunAt,
}),
null,
);
const dailyNext = calculateNextRunAt({
recurrence: "daily",
status: "active",
lastRunAt,
});
const weeklyNext = calculateNextRunAt({
recurrence: "weekly",
status: "active",
lastRunAt,
});
const monthlyNext = calculateNextRunAt({
recurrence: "monthly",
status: "active",
lastRunAt,
});
assert.equal(dailyNext, addDaysUTC(lastRunAt, 1));
assert.equal(weeklyNext, addDaysUTC(lastRunAt, 7));
assert.equal(monthlyNext, addMonthsUTC(lastRunAt, 1));
});
test("run status favors running/pending over finished states", () => {
const getCampaignCurrentRunStatus = expectFunction<
(input: {
campaignStatus: "active" | "paused";
agentRuns?: Array<{
status: string;
updatedAt?: number;
}>;
}) => string
>(schedulingModule.getCampaignCurrentRunStatus, "getCampaignCurrentRunStatus");
const activeWithRunningAndFinished = runWithLatest([
{ status: "succeeded", updatedAt: Date.UTC(2026, 5, 2, 10, 0, 0) },
{ status: "running", updatedAt: Date.UTC(2026, 5, 2, 10, 5, 0) },
{ status: "failed", updatedAt: Date.UTC(2026, 5, 2, 9, 50, 0) },
]);
const runningStatus = getCampaignCurrentRunStatus({
campaignStatus: "active",
agentRuns: activeWithRunningAndFinished,
});
assert.equal(
runningStatus,
"running",
"Active running campaign should surface running as current status",
);
const pendingOverFinished = runWithLatest([
{ status: "succeeded", updatedAt: Date.UTC(2026, 5, 2, 10, 0, 0) },
{ status: "pending", updatedAt: Date.UTC(2026, 5, 2, 10, 5, 0) },
{ status: "failed", updatedAt: Date.UTC(2026, 5, 2, 9, 50, 0) },
]);
const pendingStatus = getCampaignCurrentRunStatus({
campaignStatus: "active",
agentRuns: pendingOverFinished,
});
assert.equal(
pendingStatus,
"pending",
"Pending should outrank finished statuses when no running is active",
);
const pausedWithoutRuns = getCampaignCurrentRunStatus({
campaignStatus: "paused",
agentRuns: [],
});
assert.equal(
pausedWithoutRuns,
"paused",
"Paused campaigns should not report campaign activity by default",
);
});
test("run status uses latest run first (running, pending, otherwise terminal)", () => {
const getCampaignCurrentRunStatus = expectFunction<
(input: {
campaignStatus: "active" | "paused";
agentRuns?: Array<{
status: string;
updatedAt?: number;
}>;
}) => string
>(schedulingModule.getCampaignCurrentRunStatus, "getCampaignCurrentRunStatus");
const activeWithoutRuns = getCampaignCurrentRunStatus({
campaignStatus: "active",
agentRuns: [],
});
assert.equal(activeWithoutRuns, "idle");
const unsortedRuns = [
{ status: "failed", updatedAt: Date.UTC(2026, 5, 1, 9, 0, 0) },
{ status: "running", updatedAt: Date.UTC(2026, 5, 1, 8, 0, 0) },
{ status: "failed", updatedAt: Date.UTC(2026, 5, 1, 7, 0, 0) },
];
const latestRunWins = getCampaignCurrentRunStatus({
campaignStatus: "active",
agentRuns: unsortedRuns,
});
assert.equal(
latestRunWins,
"failed",
"Latest status should determine current status, not any older run",
);
const unsortedPending = [
{ status: "running", updatedAt: Date.UTC(2026, 5, 1, 9, 0, 0) },
{ status: "pending", updatedAt: Date.UTC(2026, 5, 1, 9, 5, 0) },
{ status: "succeeded", updatedAt: Date.UTC(2026, 5, 1, 7, 0, 0) },
];
const latestPending = getCampaignCurrentRunStatus({
campaignStatus: "active",
agentRuns: unsortedPending,
});
assert.equal(
latestPending,
"pending",
"Pending latest run should be surfaced when it is the most recent.",
);
const unsortedRunning = [
{ status: "succeeded", updatedAt: Date.UTC(2026, 5, 1, 9, 0, 0) },
{ status: "running", updatedAt: Date.UTC(2026, 5, 1, 10, 5, 0) },
{ status: "pending", updatedAt: Date.UTC(2026, 5, 1, 8, 0, 0) },
];
const latestRunning = getCampaignCurrentRunStatus({
campaignStatus: "active",
agentRuns: unsortedRunning,
});
assert.equal(
latestRunning,
"running",
"Running should be surfaced when it is the latest run.",
);
});

View File

@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
validateCampaignCreateInput,
validateCampaignUpdateInput,
} from "../lib/campaign-validation";
test("campaign mutation validation normalizes and enforces fixed Germany context", () => {
const payload = validateCampaignCreateInput({
status: "active",
recurrence: "daily",
postalCode: "10115",
radiusKm: 10,
maxNewLeadsPerRun: 5,
maxAuditsPerRun: 5,
});
assert.equal(payload.countryCode, "DE");
assert.equal(payload.country, "Deutschland");
});
test("campaign validation rejects invalid German PLZ", () => {
assert.throws(
() =>
validateCampaignCreateInput({
status: "active",
recurrence: "daily",
postalCode: "1234",
radiusKm: 10,
maxNewLeadsPerRun: 5,
maxAuditsPerRun: 5,
}),
(error: unknown) => {
return (
error instanceof Error &&
error.message.includes("5") &&
/PLZ|Postleitzahl/i.test(error.message)
);
},
);
});
test("campaign validation rejects decimal limits", () => {
assert.throws(
() =>
validateCampaignCreateInput({
status: "active",
recurrence: "daily",
postalCode: "10115",
radiusKm: 10.5,
maxNewLeadsPerRun: 5,
maxAuditsPerRun: 5,
}),
(error: unknown) => {
return error instanceof Error && error.message.includes("ganze");
},
);
});
test("campaign validation rejects invalid recurrence/status in German", () => {
assert.throws(
() =>
validateCampaignCreateInput({
status: "running",
recurrence: "daily",
postalCode: "10115",
radiusKm: 10,
maxNewLeadsPerRun: 5,
maxAuditsPerRun: 5,
}),
(error: unknown) => error instanceof Error && error.message.includes("Status"),
);
assert.throws(
() =>
validateCampaignCreateInput({
status: "active",
recurrence: "hourly",
postalCode: "10115",
radiusKm: 10,
maxNewLeadsPerRun: 5,
maxAuditsPerRun: 5,
}),
(error: unknown) =>
error instanceof Error && /Frequenz|ungültig/.test(error.message),
);
});
test("campaign update validation rejects partial Germany-context payloads", () => {
assert.throws(
() =>
validateCampaignUpdateInput({
countryCode: "DE",
}),
(error: unknown) =>
error instanceof Error &&
/vollständig|Deutschland-Kontext/.test(error.message),
);
});