feat: add campaign configuration controls
This commit is contained in:
270
tests/campaign-scheduling.test.ts
Normal file
270
tests/campaign-scheduling.test.ts
Normal 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.",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user