271 lines
7.5 KiB
TypeScript
271 lines
7.5 KiB
TypeScript
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.",
|
|
);
|
|
});
|