Files
prowler/ui/lib/schedules.test.ts
2026-06-24 13:35:53 +02:00

571 lines
15 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildProviderScheduleSummary,
buildScheduleAttributesFromProvider,
buildSchedulesByProviderId,
buildScheduleUpdatePayload,
formatDayOfMonth,
formatScheduleHour,
getBrowserTimezone,
getNextScheduledRun,
getScanScheduleCapability,
getScheduleFormDefaults,
getScheduleFormValues,
isScheduleConfigured,
} from "@/lib/schedules";
import {
SCAN_SCHEDULE_CAPABILITY,
SCHEDULE_FREQUENCY,
type ScheduleAttributes,
type ScheduleProps,
} from "@/types/schedules";
describe("schedule payload mapping", () => {
beforeEach(() => {
vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({
resolvedOptions: () => ({ timeZone: "Europe/Madrid" }),
} as Intl.DateTimeFormat);
});
it("maps daily schedules and clears unused fields", () => {
// Given
const values = {
frequency: SCHEDULE_FREQUENCY.DAILY,
hour: 8,
dayOfWeek: 2,
dayOfMonth: 12,
intervalHours: 48,
launchInitialScan: false,
};
// When
const payload = buildScheduleUpdatePayload(values);
// Then
expect(payload).toEqual({
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
scan_hour: 8,
scan_timezone: "Europe/Madrid",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: null,
});
});
it("maps every 48 hours schedules as an interval", () => {
// Given
const values = {
frequency: SCHEDULE_FREQUENCY.INTERVAL,
hour: 23,
dayOfWeek: 0,
dayOfMonth: 1,
intervalHours: 48,
launchInitialScan: false,
};
// When
const payload = buildScheduleUpdatePayload(values);
// Then
expect(payload).toEqual({
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.INTERVAL,
scan_hour: 23,
scan_timezone: "Europe/Madrid",
scan_interval_hours: 48,
scan_day_of_week: null,
scan_day_of_month: null,
});
});
it("preserves a custom interval instead of rewriting it to 48 hours", () => {
// Given a schedule whose interval was set outside the UI (e.g. bulk API)
const values = {
frequency: SCHEDULE_FREQUENCY.INTERVAL,
hour: 5,
dayOfWeek: 0,
dayOfMonth: 1,
intervalHours: 72,
launchInitialScan: false,
};
// When
const payload = buildScheduleUpdatePayload(values);
// Then
expect(payload.scan_interval_hours).toBe(72);
});
it("maps weekly schedules with 0 as Sunday and clears interval/month", () => {
// Given
const values = {
frequency: SCHEDULE_FREQUENCY.WEEKLY,
hour: 6,
dayOfWeek: 0,
dayOfMonth: 28,
intervalHours: 48,
launchInitialScan: false,
};
// When
const payload = buildScheduleUpdatePayload(values);
// Then
expect(payload).toEqual({
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.WEEKLY,
scan_hour: 6,
scan_timezone: "Europe/Madrid",
scan_interval_hours: null,
scan_day_of_week: 0,
scan_day_of_month: null,
});
});
it("maps monthly schedules and keeps scan_hour 0 (not falsy-coerced)", () => {
// Given
const values = {
frequency: SCHEDULE_FREQUENCY.MONTHLY,
hour: 0,
dayOfWeek: 5,
dayOfMonth: 28,
intervalHours: 48,
launchInitialScan: false,
};
// When
const payload = buildScheduleUpdatePayload(values);
// Then
expect(payload).toEqual({
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.MONTHLY,
scan_hour: 0,
scan_timezone: "Europe/Madrid",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: 28,
});
});
});
describe("formatScheduleHour", () => {
it.each([
[0, "12:00am"],
[12, "12:00pm"],
[13, "1:00pm"],
[23, "11:00pm"],
[-1, "11:00pm"],
[24, "12:00am"],
])("formats hour %i as %s", (hour, expected) => {
expect(formatScheduleHour(hour)).toBe(expected);
});
});
describe("formatDayOfMonth", () => {
it.each([
[1, "1st"],
[2, "2nd"],
[3, "3rd"],
[4, "4th"],
[11, "11th"],
[12, "12th"],
[13, "13th"],
[21, "21st"],
[22, "22nd"],
[23, "23rd"],
[24, "24th"],
[31, "31st"],
])("formats day %i as %s", (day, expected) => {
expect(formatDayOfMonth(day)).toBe(expected);
});
});
describe("isScheduleConfigured", () => {
it("treats a null scan_hour as not configured", () => {
expect(isScheduleConfigured({ scan_hour: null })).toBe(false);
});
it("treats scan_hour 0 (midnight) as configured", () => {
expect(isScheduleConfigured({ scan_hour: 0 })).toBe(true);
});
it("treats a set scan_hour as configured", () => {
expect(isScheduleConfigured({ scan_hour: 14 })).toBe(true);
});
});
describe("getScheduleFormDefaults", () => {
it("uses the current local hour when the clock is exactly on the hour", () => {
expect(
getScheduleFormDefaults(new Date(2026, 5, 10, 10, 0, 0, 0)).hour,
).toBe(10);
});
it("uses the next local hour when the current hour already started", () => {
expect(
getScheduleFormDefaults(new Date(2026, 5, 10, 10, 30, 0, 0)).hour,
).toBe(11);
});
it("wraps the upcoming hour from 23:xx to 0", () => {
expect(
getScheduleFormDefaults(new Date(2026, 5, 10, 23, 1, 0, 0)).hour,
).toBe(0);
});
});
describe("getScheduleFormValues", () => {
const buildAttributes = (
overrides: Partial<ScheduleAttributes> = {},
): ScheduleAttributes => ({
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.WEEKLY,
scan_hour: 9,
scan_timezone: "Europe/Madrid",
scan_interval_hours: null,
scan_day_of_week: 4,
scan_day_of_month: 20,
...overrides,
});
it("returns defaults when there is no schedule", () => {
expect(
getScheduleFormValues(null, new Date(2026, 5, 10, 0, 0, 0, 0)),
).toEqual({
frequency: SCHEDULE_FREQUENCY.DAILY,
hour: 0,
dayOfWeek: 1,
dayOfMonth: 1,
intervalHours: 48,
launchInitialScan: false,
});
});
it("returns defaults when scan_hour is null (unconfigured provider)", () => {
expect(
getScheduleFormValues(
buildAttributes({ scan_hour: null }),
new Date(2026, 5, 10, 0, 0, 0, 0),
),
).toEqual({
frequency: SCHEDULE_FREQUENCY.DAILY,
hour: 0,
dayOfWeek: 1,
dayOfMonth: 1,
intervalHours: 48,
launchInitialScan: false,
});
});
it("maps a configured schedule onto the form", () => {
expect(getScheduleFormValues(buildAttributes())).toEqual({
frequency: SCHEDULE_FREQUENCY.WEEKLY,
hour: 9,
dayOfWeek: 4,
dayOfMonth: 20,
intervalHours: 48,
launchInitialScan: false,
});
});
it("keeps a custom interval from the stored schedule", () => {
const values = getScheduleFormValues(
buildAttributes({
scan_frequency: SCHEDULE_FREQUENCY.INTERVAL,
scan_interval_hours: 72,
scan_day_of_week: null,
scan_day_of_month: null,
}),
);
expect(values.frequency).toBe(SCHEDULE_FREQUENCY.INTERVAL);
expect(values.intervalHours).toBe(72);
});
it("falls back to default day fields when the schedule leaves them null", () => {
const values = getScheduleFormValues(
buildAttributes({ scan_day_of_week: null, scan_day_of_month: null }),
);
expect(values.dayOfWeek).toBe(1);
expect(values.dayOfMonth).toBe(1);
});
});
describe("getNextScheduledRun", () => {
const baseValues = {
frequency: SCHEDULE_FREQUENCY.DAILY,
hour: 14,
dayOfWeek: 5,
dayOfMonth: 15,
intervalHours: 48,
launchInitialScan: false,
};
// Wednesday 2026-06-10 10:30 local.
const now = new Date(2026, 5, 10, 10, 30, 0, 0);
const parts = (date: Date) => ({
year: date.getFullYear(),
month: date.getMonth(),
day: date.getDate(),
hour: date.getHours(),
});
it("DAILY: same day when the hour is still ahead", () => {
expect(
parts(getNextScheduledRun({ ...baseValues, hour: 14 }, now)),
).toEqual({ year: 2026, month: 5, day: 10, hour: 14 });
});
it("DAILY: next day when the hour already passed", () => {
expect(parts(getNextScheduledRun({ ...baseValues, hour: 8 }, now))).toEqual(
{
year: 2026,
month: 5,
day: 11,
hour: 8,
},
);
});
it("INTERVAL: anchors at the next occurrence of the hour, like DAILY", () => {
// The backend derives the INTERVAL anchor as today/tomorrow at scan_hour
// and fires the first run there; repeats only start after that.
expect(
parts(
getNextScheduledRun(
{ ...baseValues, frequency: SCHEDULE_FREQUENCY.INTERVAL, hour: 14 },
now,
),
),
).toEqual({ year: 2026, month: 5, day: 10, hour: 14 });
expect(
parts(
getNextScheduledRun(
{ ...baseValues, frequency: SCHEDULE_FREQUENCY.INTERVAL, hour: 8 },
now,
),
),
).toEqual({ year: 2026, month: 5, day: 11, hour: 8 });
});
it("WEEKLY: advances to the target weekday this week", () => {
expect(
parts(
getNextScheduledRun(
{
...baseValues,
frequency: SCHEDULE_FREQUENCY.WEEKLY,
dayOfWeek: 5,
hour: 9,
},
now,
),
),
).toEqual({ year: 2026, month: 5, day: 12, hour: 9 });
});
it("WEEKLY: jumps a week when the target day/hour already passed today", () => {
expect(
parts(
getNextScheduledRun(
{
...baseValues,
frequency: SCHEDULE_FREQUENCY.WEEKLY,
dayOfWeek: 3,
hour: 8,
},
now,
),
),
).toEqual({ year: 2026, month: 5, day: 17, hour: 8 });
});
it("MONTHLY: this month when the day is still ahead", () => {
expect(
parts(
getNextScheduledRun(
{
...baseValues,
frequency: SCHEDULE_FREQUENCY.MONTHLY,
dayOfMonth: 15,
},
now,
),
),
).toEqual({ year: 2026, month: 5, day: 15, hour: 14 });
});
it("MONTHLY: next month when the day already passed", () => {
expect(
parts(
getNextScheduledRun(
{
...baseValues,
frequency: SCHEDULE_FREQUENCY.MONTHLY,
dayOfMonth: 5,
},
now,
),
),
).toEqual({ year: 2026, month: 6, day: 5, hour: 14 });
});
});
describe("browser timezone", () => {
it("falls back to UTC when browser timezone is unavailable", () => {
// Given
vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({
resolvedOptions: () => ({}),
} as Intl.DateTimeFormat);
// When / Then
expect(getBrowserTimezone()).toBe("UTC");
});
});
describe("scan schedule capability", () => {
it("returns DAILY_LEGACY for non-Cloud (OSS)", () => {
expect(getScanScheduleCapability(false)).toBe(
SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY,
);
});
it("returns ADVANCED for Cloud", () => {
expect(getScanScheduleCapability(true)).toBe(
SCAN_SCHEDULE_CAPABILITY.ADVANCED,
);
});
});
describe("buildSchedulesByProviderId", () => {
const buildSchedule = (
id: string,
overrides: Partial<ScheduleAttributes> = {},
): ScheduleProps => ({
type: "schedules",
id,
attributes: {
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
scan_hour: 9,
scan_timezone: "Europe/Madrid",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: null,
...overrides,
},
relationships: {
provider: { data: { type: "providers", id } },
},
});
it("indexes schedule attributes by provider id (the schedule's own id)", () => {
const result = {
data: [
buildSchedule("provider-1", { scan_hour: 6 }),
buildSchedule("provider-2", { scan_hour: null }),
],
};
expect(buildSchedulesByProviderId(result)).toEqual({
"provider-1": result.data[0].attributes,
"provider-2": result.data[1].attributes,
});
});
it("returns an empty map when the request errored (e.g. OSS without /schedules)", () => {
expect(buildSchedulesByProviderId({ error: "Not found" })).toEqual({});
});
it("returns an empty map for a null/undefined result", () => {
expect(buildSchedulesByProviderId(null)).toEqual({});
expect(buildSchedulesByProviderId(undefined)).toEqual({});
});
});
describe("buildScheduleAttributesFromProvider", () => {
it("returns undefined when the provider payload does not include scan fields", () => {
expect(buildScheduleAttributesFromProvider({})).toBeUndefined();
});
it("keeps scan_hour null as an unconfigured provider schedule", () => {
const attributes = buildScheduleAttributesFromProvider({
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
scan_hour: null,
scan_timezone: "UTC",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: null,
next_scan_at: null,
last_scan_at: null,
});
expect(attributes).toEqual({
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
scan_hour: null,
scan_timezone: "UTC",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: null,
next_scan_at: null,
last_scan_at: null,
});
expect(isScheduleConfigured(attributes!)).toBe(false);
});
});
describe("buildProviderScheduleSummary", () => {
const buildAttributes = (
overrides: Partial<ScheduleAttributes> = {},
): ScheduleAttributes => ({
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
scan_hour: 9,
scan_timezone: "Europe/Madrid",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: null,
...overrides,
});
const now = new Date(2026, 5, 10, 10, 30, 0, 0);
it.each([
[{ scan_frequency: SCHEDULE_FREQUENCY.DAILY }, "Daily"],
[
{ scan_frequency: SCHEDULE_FREQUENCY.WEEKLY, scan_day_of_week: 1 },
"Weekly on Monday",
],
[
{ scan_frequency: SCHEDULE_FREQUENCY.MONTHLY, scan_day_of_month: 15 },
"Monthly on the 15th",
],
[
{ scan_frequency: SCHEDULE_FREQUENCY.INTERVAL, scan_interval_hours: 72 },
"Every 72 hours",
],
])("exposes the %o cadence as %s", (overrides, cadence) => {
expect(
buildProviderScheduleSummary(buildAttributes(overrides), now).cadence,
).toBe(cadence);
});
it("passes through server-computed next/last run timestamps", () => {
const summary = buildProviderScheduleSummary(
buildAttributes({
next_scan_at: "2026-06-15T07:00:00Z",
last_scan_at: "2026-06-08T07:00:00Z",
}),
now,
);
expect(summary.nextScanAt).toBe("2026-06-15T07:00:00Z");
expect(summary.lastScanAt).toBe("2026-06-08T07:00:00Z");
});
});