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 => ({ 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 = {}, ): 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 => ({ 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"); }); });