Merge pull request #3668 from bluewave-labs/fix/update-monitors-on-maintenance-window-change
fix: update monitors on maintenance window change
This commit is contained in:
9 files changed
+427
-32
No files matched your search
@@ -5,6 +5,7 @@ export interface IMaintenanceWindowsRepository {
|
||||
// fetch
|
||||
findById(id: string, teamId: string): Promise<MaintenanceWindow>;
|
||||
findByMonitorId(monitorId: string, teamId: string): Promise<MaintenanceWindow[]>;
|
||||
findByMonitorIds(monitorIds: string[], teamId: string, excludeId?: string): Promise<MaintenanceWindow[]>;
|
||||
findByTeamId(teamId: string, page: number, rowsPerPage: number, field?: string, order?: string, active?: boolean): Promise<MaintenanceWindow[]>;
|
||||
|
||||
// update
|
||||
|
||||
@@ -65,6 +65,18 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository
|
||||
return this.toEntity(maintenanceWindow);
|
||||
};
|
||||
|
||||
findByMonitorIds = async (monitorIds: string[], teamId: string, excludeId?: string): Promise<MaintenanceWindow[]> => {
|
||||
const query: Record<string, unknown> = {
|
||||
monitorIds: { $in: monitorIds },
|
||||
teamId,
|
||||
};
|
||||
if (excludeId) {
|
||||
query._id = { $ne: new mongoose.Types.ObjectId(excludeId) };
|
||||
}
|
||||
const maintenanceWindows = await MaintenanceWindowModel.find(query);
|
||||
return this.mapDocuments(maintenanceWindows);
|
||||
};
|
||||
|
||||
findByMonitorId = async (monitorId: string, teamId: string): Promise<MaintenanceWindow[]> => {
|
||||
const maintenanceWindows = await MaintenanceWindowModel.find({
|
||||
monitorIds: monitorId,
|
||||
@@ -113,6 +125,7 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository
|
||||
if (!updated) {
|
||||
throw new AppError({ message: "Maintenance window not found or could not be updated", status: 404 });
|
||||
}
|
||||
|
||||
return this.toEntity(updated);
|
||||
};
|
||||
|
||||
@@ -124,6 +137,7 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository
|
||||
if (!deleted) {
|
||||
throw new AppError({ message: "Maintenance window not found or could not be deleted", status: 404 });
|
||||
}
|
||||
|
||||
return this.toEntity(deleted);
|
||||
};
|
||||
countByTeamId = async (teamId: string, active?: boolean) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type MonitorType, type Monitor, type MonitorsSummary, CheckSnapshot } from "@/types/index.js";
|
||||
import { type MonitorType, type Monitor, type MonitorStatus, type MonitorsSummary, CheckSnapshot } from "@/types/index.js";
|
||||
|
||||
export interface TeamQueryConfig {
|
||||
limit?: number;
|
||||
@@ -31,6 +31,7 @@ export interface IMonitorsRepository {
|
||||
|
||||
// update
|
||||
updateById(monitorId: string, teamId: string, updates: Partial<Monitor>): Promise<Monitor>;
|
||||
updateByIds(monitorIds: string[], teamId: string, updates: Partial<Monitor>, excludeStatuses?: MonitorStatus[]): Promise<number>;
|
||||
updateStatusWindowAndChecks(
|
||||
monitorId: string,
|
||||
teamId: string,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MonitorModel } from "@/db/models/index.js";
|
||||
import type { MonitorDocument, CheckSnapshotDocument } from "@/db/models/index.js";
|
||||
import type { Monitor, MonitorsSummary, CheckSnapshot } from "@/types/index.js";
|
||||
import type { Monitor, MonitorStatus, MonitorsSummary, CheckSnapshot } from "@/types/index.js";
|
||||
import mongoose, { type FilterQuery, type PipelineStage } from "mongoose";
|
||||
import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "./IMonitorsRepository.js";
|
||||
import { MongoBulkWriteError } from "mongodb";
|
||||
@@ -190,6 +190,16 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
return this.toEntity(updatedMonitor);
|
||||
};
|
||||
|
||||
updateByIds = async (monitorIds: string[], teamId: string, updates: Partial<Monitor>, excludeStatuses?: MonitorStatus[]) => {
|
||||
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
|
||||
const filter: Record<string, unknown> = { _id: { $in: objectIds }, teamId };
|
||||
if (excludeStatuses && excludeStatuses.length > 0) {
|
||||
filter.status = { $nin: excludeStatuses };
|
||||
}
|
||||
const updated = await MonitorModel.updateMany(filter, { $set: updates }, { runValidators: true });
|
||||
return updated.modifiedCount;
|
||||
};
|
||||
|
||||
updateStatusWindowAndChecks = async (
|
||||
monitorId: string,
|
||||
teamId: string,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IMaintenanceWindowsRepository, IMonitorsRepository } from "@/repositories/index.js";
|
||||
import type { DurationUnit, MaintenanceWindow } from "@/types/index.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { isWindowActive } from "@/utils/maintenanceWindow.js";
|
||||
|
||||
const SERVICE_NAME = "maintenanceWindowService";
|
||||
|
||||
@@ -54,6 +55,24 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
|
||||
return MaintenanceWindowService.SERVICE_NAME;
|
||||
}
|
||||
|
||||
private flipMonitorsLeavingMaintenance = async (monitorIds: string[], teamId: string, excludeWindowId: string, now: Date): Promise<void> => {
|
||||
if (monitorIds.length === 0) return;
|
||||
|
||||
const otherWindows = await this.maintenanceWindowsRepository.findByMonitorIds(monitorIds, teamId, excludeWindowId);
|
||||
const stillCovered = new Set<string>();
|
||||
for (const otherWindow of otherWindows) {
|
||||
if (!isWindowActive(otherWindow, now)) continue;
|
||||
for (const monitorId of otherWindow.monitorIds) {
|
||||
if (monitorIds.includes(monitorId)) stillCovered.add(monitorId);
|
||||
}
|
||||
}
|
||||
|
||||
const toInitializing = monitorIds.filter((monitorId) => !stillCovered.has(monitorId));
|
||||
if (toInitializing.length > 0) {
|
||||
await this.monitorsRepository.updateByIds(toInitializing, teamId, { status: "initializing" }, ["paused"]);
|
||||
}
|
||||
};
|
||||
|
||||
createMaintenanceWindow = async ({
|
||||
teamId,
|
||||
monitorIDs,
|
||||
@@ -88,7 +107,7 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
|
||||
});
|
||||
}
|
||||
|
||||
await this.maintenanceWindowsRepository.create({
|
||||
const created = await this.maintenanceWindowsRepository.create({
|
||||
teamId,
|
||||
monitorIds: monitorIDs,
|
||||
name: name,
|
||||
@@ -99,6 +118,10 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
|
||||
start: start,
|
||||
end: end,
|
||||
});
|
||||
|
||||
if (isWindowActive(created)) {
|
||||
await this.monitorsRepository.updateByIds(created.monitorIds, teamId, { status: "maintenance" }, ["paused"]);
|
||||
}
|
||||
};
|
||||
|
||||
getMaintenanceWindowById = async ({ id, teamId }: { id: string; teamId: string }) => {
|
||||
@@ -133,7 +156,14 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
|
||||
};
|
||||
|
||||
deleteMaintenanceWindow = async ({ id, teamId }: { id: string; teamId: string }) => {
|
||||
return await this.maintenanceWindowsRepository.deleteById(id, teamId);
|
||||
const deleted = await this.maintenanceWindowsRepository.deleteById(id, teamId);
|
||||
|
||||
const now = new Date();
|
||||
if (isWindowActive(deleted, now)) {
|
||||
await this.flipMonitorsLeavingMaintenance(deleted.monitorIds, teamId, deleted.id, now);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
};
|
||||
|
||||
editMaintenanceWindow = async ({
|
||||
@@ -145,6 +175,8 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
|
||||
teamId: string;
|
||||
body: Partial<Omit<MaintenanceWindow, "monitorIds">> & { monitors?: string[] };
|
||||
}) => {
|
||||
const existing = await this.maintenanceWindowsRepository.findById(id, teamId);
|
||||
|
||||
const { monitors, ...rest } = body;
|
||||
const update: Partial<MaintenanceWindow> = rest;
|
||||
|
||||
@@ -162,6 +194,24 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
|
||||
update.monitorIds = monitors;
|
||||
}
|
||||
|
||||
return await this.maintenanceWindowsRepository.updateById(id, teamId, update);
|
||||
const updated = await this.maintenanceWindowsRepository.updateById(id, teamId, update);
|
||||
|
||||
const now = new Date();
|
||||
const wasActive = isWindowActive(existing, now);
|
||||
const isActive = isWindowActive(updated, now);
|
||||
|
||||
const enteringMaintenance = isActive ? updated.monitorIds.filter((monitorId) => !wasActive || !existing.monitorIds.includes(monitorId)) : [];
|
||||
|
||||
const leavingCandidates = wasActive ? existing.monitorIds.filter((monitorId) => !isActive || !updated.monitorIds.includes(monitorId)) : [];
|
||||
|
||||
if (enteringMaintenance.length > 0) {
|
||||
await this.monitorsRepository.updateByIds(enteringMaintenance, teamId, { status: "maintenance" }, ["paused"]);
|
||||
}
|
||||
|
||||
if (leavingCandidates.length > 0) {
|
||||
await this.flipMonitorsLeavingMaintenance(leavingCandidates, teamId, existing.id, now);
|
||||
}
|
||||
|
||||
return updated;
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
IncidentService,
|
||||
type IGeoChecksService,
|
||||
} from "@/service/index.js";
|
||||
import { CHECK_TTL_SENTINEL, type MaintenanceWindow, type StatusChangeResult } from "@/types/index.js";
|
||||
import { CHECK_TTL_SENTINEL, type StatusChangeResult } from "@/types/index.js";
|
||||
import { isWindowActive } from "@/utils/maintenanceWindow.js";
|
||||
import {
|
||||
IMaintenanceWindowsRepository,
|
||||
IMonitorsRepository,
|
||||
@@ -358,32 +359,8 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
|
||||
async isInMaintenanceWindow(monitorId: string, teamId: string) {
|
||||
const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId);
|
||||
// Check for active maintenance window:
|
||||
const maintenanceWindowIsActive = maintenanceWindows.reduce((acc: boolean, window: MaintenanceWindow) => {
|
||||
if (window.active) {
|
||||
const start = new Date(window.start);
|
||||
const end = new Date(window.end);
|
||||
const now = new Date();
|
||||
const repeatInterval = window.repeat || 0;
|
||||
|
||||
// If start is < now and end > now, we're in maintenance
|
||||
if (start <= now && end >= now) return true;
|
||||
|
||||
// If maintenance window was set in the past with a repeat,
|
||||
// we need to advance start and end to see if we are in range
|
||||
|
||||
while (start < now && repeatInterval !== 0) {
|
||||
start.setTime(start.getTime() + repeatInterval);
|
||||
end.setTime(end.getTime() + repeatInterval);
|
||||
if (start <= now && end >= now) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
return acc;
|
||||
}, false);
|
||||
return maintenanceWindowIsActive;
|
||||
const now = new Date();
|
||||
return maintenanceWindows.some((window) => isWindowActive(window, now));
|
||||
}
|
||||
|
||||
getCleanupRetentionJob = () => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { MaintenanceWindow } from "@/types/index.js";
|
||||
|
||||
export const isWindowActive = (window: MaintenanceWindow, now: Date = new Date()): boolean => {
|
||||
if (!window.active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const start = new Date(window.start);
|
||||
const end = new Date(window.end);
|
||||
const repeatInterval = window.repeat || 0;
|
||||
|
||||
if (start <= now && end >= now) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For a recurring window whose first occurrence is in the past,
|
||||
// advance start/end by the repeat interval until we either catch
|
||||
// an occurrence covering `now` or move past it.
|
||||
while (start < now && repeatInterval !== 0) {
|
||||
start.setTime(start.getTime() + repeatInterval);
|
||||
end.setTime(end.getTime() + repeatInterval);
|
||||
if (start <= now && end >= now) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -11,6 +11,7 @@ const createMaintenanceWindowsRepo = () =>
|
||||
create: jest.fn().mockResolvedValue(makeWindow()),
|
||||
findById: jest.fn().mockResolvedValue(makeWindow()),
|
||||
findByMonitorId: jest.fn().mockResolvedValue([makeWindow()]),
|
||||
findByMonitorIds: jest.fn().mockResolvedValue([]),
|
||||
findByTeamId: jest.fn().mockResolvedValue([makeWindow()]),
|
||||
updateById: jest.fn().mockResolvedValue(makeWindow()),
|
||||
deleteById: jest.fn().mockResolvedValue(makeWindow()),
|
||||
@@ -23,6 +24,7 @@ const createMonitorsRepo = () =>
|
||||
{ id: "mon-1", teamId: "team-1" },
|
||||
{ id: "mon-2", teamId: "team-1" },
|
||||
]),
|
||||
updateByIds: jest.fn().mockResolvedValue(0),
|
||||
}) as unknown as jest.Mocked<IMonitorsRepository>;
|
||||
|
||||
const createService = (overrides?: {
|
||||
@@ -51,6 +53,16 @@ const makeWindow = (overrides?: Partial<MaintenanceWindow>): MaintenanceWindow =
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// A window whose start/end straddle "now" — active per isWindowActive
|
||||
const makeActiveWindow = (overrides?: Partial<MaintenanceWindow>): MaintenanceWindow => {
|
||||
const now = Date.now();
|
||||
return makeWindow({
|
||||
start: new Date(now - 60_000).toISOString(),
|
||||
end: new Date(now + 60_000).toISOString(),
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
const defaultCreateParams = {
|
||||
teamId: "team-1",
|
||||
monitorIDs: ["mon-1", "mon-2"],
|
||||
@@ -137,6 +149,26 @@ describe("MaintenanceWindowService", () => {
|
||||
expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(1);
|
||||
expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ monitorIds: ["mon-1"] }));
|
||||
});
|
||||
|
||||
it("flips monitors to maintenance when the created window is active", async () => {
|
||||
const maintenanceWindowsRepository = createMaintenanceWindowsRepo();
|
||||
(maintenanceWindowsRepository.create as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] }));
|
||||
const { service, monitorsRepository } = createService({ maintenanceWindowsRepository });
|
||||
|
||||
await service.createMaintenanceWindow(defaultCreateParams);
|
||||
|
||||
expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1);
|
||||
expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "maintenance" }, ["paused"]);
|
||||
});
|
||||
|
||||
it("does not touch monitor status when the created window is inactive", async () => {
|
||||
// default mock returns makeWindow() with past dates → inactive
|
||||
const { service, monitorsRepository } = createService();
|
||||
|
||||
await service.createMaintenanceWindow(defaultCreateParams);
|
||||
|
||||
expect(monitorsRepository.updateByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getMaintenanceWindowById ─────────────────────────────────────────────
|
||||
@@ -221,6 +253,49 @@ describe("MaintenanceWindowService", () => {
|
||||
expect(result).toBe(deleted);
|
||||
expect(maintenanceWindowsRepository.deleteById).toHaveBeenCalledWith("mw-1", "team-1");
|
||||
});
|
||||
|
||||
it("does not touch monitor status when deleting an inactive window", async () => {
|
||||
// default fixture has past dates → inactive
|
||||
const { service, monitorsRepository, maintenanceWindowsRepository } = createService();
|
||||
|
||||
await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" });
|
||||
|
||||
expect(monitorsRepository.updateByIds).not.toHaveBeenCalled();
|
||||
expect(maintenanceWindowsRepository.findByMonitorIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flips covered monitors to initializing when deleting an active window", async () => {
|
||||
const maintenanceWindowsRepository = createMaintenanceWindowsRepo();
|
||||
(maintenanceWindowsRepository.deleteById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] }));
|
||||
const { service, monitorsRepository } = createService({ maintenanceWindowsRepository });
|
||||
|
||||
await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" });
|
||||
|
||||
expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1);
|
||||
expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "initializing" }, ["paused"]);
|
||||
});
|
||||
|
||||
it("excludes the deleted window from the overlap check", async () => {
|
||||
const maintenanceWindowsRepository = createMaintenanceWindowsRepo();
|
||||
(maintenanceWindowsRepository.deleteById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"] }));
|
||||
const { service } = createService({ maintenanceWindowsRepository });
|
||||
|
||||
await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" });
|
||||
|
||||
expect(maintenanceWindowsRepository.findByMonitorIds).toHaveBeenCalledWith(["mon-1"], "team-1", "mw-1");
|
||||
});
|
||||
|
||||
it("does not flip a monitor still covered by another active window", async () => {
|
||||
const maintenanceWindowsRepository = createMaintenanceWindowsRepo();
|
||||
(maintenanceWindowsRepository.deleteById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] }));
|
||||
(maintenanceWindowsRepository.findByMonitorIds as jest.Mock).mockResolvedValue([makeActiveWindow({ id: "mw-2", monitorIds: ["mon-2"] })]);
|
||||
const { service, monitorsRepository } = createService({ maintenanceWindowsRepository });
|
||||
|
||||
await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" });
|
||||
|
||||
expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1);
|
||||
expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1"], "team-1", { status: "initializing" }, ["paused"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── editMaintenanceWindow ────────────────────────────────────────────────
|
||||
@@ -302,5 +377,149 @@ describe("MaintenanceWindowService", () => {
|
||||
|
||||
expect(monitorsRepository.findByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── transitions ──────────────────────────────────────────────────
|
||||
|
||||
it("does not touch monitor status when window was inactive and stays inactive", async () => {
|
||||
// default fixture: start/end in 2026-04-10 (past) → inactive
|
||||
const { service, monitorsRepository } = createService();
|
||||
|
||||
await service.editMaintenanceWindow({
|
||||
id: "mw-1",
|
||||
teamId: "team-1",
|
||||
body: { name: "Updated Name" },
|
||||
});
|
||||
|
||||
expect(monitorsRepository.updateByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets all monitors to maintenance when window transitions inactive → active", async () => {
|
||||
const maintenanceWindowsRepository = createMaintenanceWindowsRepo();
|
||||
(maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeWindow({ monitorIds: ["mon-1", "mon-2"] }));
|
||||
(maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] }));
|
||||
const { service, monitorsRepository } = createService({ maintenanceWindowsRepository });
|
||||
|
||||
await service.editMaintenanceWindow({
|
||||
id: "mw-1",
|
||||
teamId: "team-1",
|
||||
body: { start: new Date(Date.now() - 60_000).toISOString() },
|
||||
});
|
||||
|
||||
expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1);
|
||||
expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "maintenance" }, ["paused"]);
|
||||
});
|
||||
|
||||
it("sets all monitors to initializing when window transitions active → inactive", async () => {
|
||||
const maintenanceWindowsRepository = createMaintenanceWindowsRepo();
|
||||
(maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] }));
|
||||
(maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"], active: false }));
|
||||
const { service, monitorsRepository } = createService({ maintenanceWindowsRepository });
|
||||
|
||||
await service.editMaintenanceWindow({
|
||||
id: "mw-1",
|
||||
teamId: "team-1",
|
||||
body: { active: false },
|
||||
});
|
||||
|
||||
expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1);
|
||||
expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "initializing" }, ["paused"]);
|
||||
});
|
||||
|
||||
it("flips only newly added monitors to maintenance when window stays active", async () => {
|
||||
const maintenanceWindowsRepository = createMaintenanceWindowsRepo();
|
||||
(maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"] }));
|
||||
(maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] }));
|
||||
const { service, monitorsRepository } = createService({ maintenanceWindowsRepository });
|
||||
|
||||
await service.editMaintenanceWindow({
|
||||
id: "mw-1",
|
||||
teamId: "team-1",
|
||||
body: { monitors: ["mon-1", "mon-2"] },
|
||||
});
|
||||
|
||||
expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1);
|
||||
expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-2"], "team-1", { status: "maintenance" }, ["paused"]);
|
||||
});
|
||||
|
||||
it("flips only removed monitors to initializing when window stays active", async () => {
|
||||
const maintenanceWindowsRepository = createMaintenanceWindowsRepo();
|
||||
(maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] }));
|
||||
(maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"] }));
|
||||
const { service, monitorsRepository } = createService({ maintenanceWindowsRepository });
|
||||
|
||||
await service.editMaintenanceWindow({
|
||||
id: "mw-1",
|
||||
teamId: "team-1",
|
||||
body: { monitors: ["mon-1"] },
|
||||
});
|
||||
|
||||
expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1);
|
||||
expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-2"], "team-1", { status: "initializing" }, ["paused"]);
|
||||
});
|
||||
|
||||
it("makes no monitor updates when window stays active and monitor set is unchanged", async () => {
|
||||
const maintenanceWindowsRepository = createMaintenanceWindowsRepo();
|
||||
(maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] }));
|
||||
(maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"], name: "Renamed" }));
|
||||
const { service, monitorsRepository } = createService({ maintenanceWindowsRepository });
|
||||
|
||||
await service.editMaintenanceWindow({
|
||||
id: "mw-1",
|
||||
teamId: "team-1",
|
||||
body: { name: "Renamed" },
|
||||
});
|
||||
|
||||
expect(monitorsRepository.updateByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not flip a leaving monitor to initializing if another active window still covers it", async () => {
|
||||
const maintenanceWindowsRepository = createMaintenanceWindowsRepo();
|
||||
(maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] }));
|
||||
(maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"], active: false }));
|
||||
(maintenanceWindowsRepository.findByMonitorIds as jest.Mock).mockResolvedValue([makeActiveWindow({ id: "mw-2", monitorIds: ["mon-2"] })]);
|
||||
const { service, monitorsRepository } = createService({ maintenanceWindowsRepository });
|
||||
|
||||
await service.editMaintenanceWindow({
|
||||
id: "mw-1",
|
||||
teamId: "team-1",
|
||||
body: { active: false },
|
||||
});
|
||||
|
||||
expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1);
|
||||
expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1"], "team-1", { status: "initializing" }, ["paused"]);
|
||||
});
|
||||
|
||||
it("excludes the current window from the overlap check", async () => {
|
||||
const maintenanceWindowsRepository = createMaintenanceWindowsRepo();
|
||||
(maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"] }));
|
||||
(maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"], active: false }));
|
||||
const { service } = createService({ maintenanceWindowsRepository });
|
||||
|
||||
await service.editMaintenanceWindow({
|
||||
id: "mw-1",
|
||||
teamId: "team-1",
|
||||
body: { active: false },
|
||||
});
|
||||
|
||||
expect(maintenanceWindowsRepository.findByMonitorIds).toHaveBeenCalledWith(["mon-1"], "team-1", "mw-1");
|
||||
});
|
||||
|
||||
it("flips a leaving monitor to initializing if other windows covering it are inactive", async () => {
|
||||
const maintenanceWindowsRepository = createMaintenanceWindowsRepo();
|
||||
(maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"] }));
|
||||
(maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"], active: false }));
|
||||
(maintenanceWindowsRepository.findByMonitorIds as jest.Mock).mockResolvedValue([
|
||||
makeWindow({ id: "mw-2", monitorIds: ["mon-1"], active: false }),
|
||||
]);
|
||||
const { service, monitorsRepository } = createService({ maintenanceWindowsRepository });
|
||||
|
||||
await service.editMaintenanceWindow({
|
||||
id: "mw-1",
|
||||
teamId: "team-1",
|
||||
body: { active: false },
|
||||
});
|
||||
|
||||
expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1"], "team-1", { status: "initializing" }, ["paused"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import { isWindowActive } from "../../../src/utils/maintenanceWindow.ts";
|
||||
import type { MaintenanceWindow } from "../../../src/types/index.ts";
|
||||
|
||||
const makeWindow = (overrides?: Partial<MaintenanceWindow>): MaintenanceWindow => {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: "mw-1",
|
||||
monitorIds: ["mon-1"],
|
||||
teamId: "team-1",
|
||||
active: true,
|
||||
name: "Scheduled Maintenance",
|
||||
duration: 60,
|
||||
durationUnit: "minutes",
|
||||
repeat: 0,
|
||||
start: new Date(now - 1000).toISOString(),
|
||||
end: new Date(now + 1000).toISOString(),
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
describe("isWindowActive", () => {
|
||||
it("returns false when window.active is false even if dates span now", () => {
|
||||
expect(isWindowActive(makeWindow({ active: false }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for a one-time window where now is between start and end", () => {
|
||||
expect(isWindowActive(makeWindow())).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the one-time window has not started yet", () => {
|
||||
const now = Date.now();
|
||||
expect(
|
||||
isWindowActive(
|
||||
makeWindow({
|
||||
start: new Date(now + 60_000).toISOString(),
|
||||
end: new Date(now + 120_000).toISOString(),
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the one-time window has already ended", () => {
|
||||
const now = Date.now();
|
||||
expect(
|
||||
isWindowActive(
|
||||
makeWindow({
|
||||
start: new Date(now - 120_000).toISOString(),
|
||||
end: new Date(now - 60_000).toISOString(),
|
||||
repeat: 0,
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when a recurring window's advanced occurrence covers now", () => {
|
||||
const now = Date.now();
|
||||
// 2h ago, 10-min window, hourly repeat — third occurrence (0h) covers now
|
||||
expect(
|
||||
isWindowActive(
|
||||
makeWindow({
|
||||
start: new Date(now - 7_200_000).toISOString(),
|
||||
end: new Date(now - 7_200_000 + 600_000).toISOString(),
|
||||
repeat: 3_600_000,
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when a recurring window's occurrences all miss now", () => {
|
||||
const now = Date.now();
|
||||
// 31min ago, 1-min window, hourly repeat → next occurrence ~29min from now
|
||||
expect(
|
||||
isWindowActive(
|
||||
makeWindow({
|
||||
start: new Date(now - 1_860_000).toISOString(),
|
||||
end: new Date(now - 1_800_000).toISOString(),
|
||||
repeat: 3_600_000,
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("respects the explicit `now` parameter for comparisons", () => {
|
||||
const win = makeWindow({
|
||||
start: "2026-01-15T11:00:00Z",
|
||||
end: "2026-01-15T13:00:00Z",
|
||||
});
|
||||
expect(isWindowActive(win, new Date("2026-01-15T12:00:00Z"))).toBe(true);
|
||||
expect(isWindowActive(win, new Date("2026-01-15T14:00:00Z"))).toBe(false);
|
||||
expect(isWindowActive(win, new Date("2026-01-15T10:00:00Z"))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user