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

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.",
);
});