503 lines
17 KiB
TypeScript
503 lines
17 KiB
TypeScript
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
|
|
import { BufferService } from "../../../src/service/bufferService.ts";
|
|
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
|
import type { ICheckService } from "../../../src/domain/checks/check.service.ts";
|
|
import type { IGeoChecksService } from "../../../src/domain/geo-checks/geo-check.service.ts";
|
|
import type { ISettingsService } from "../../../src/domain/app-settings/app-settings.service.ts";
|
|
import type { IJobsRepository } from "../../../src/domain/jobs/job.repository.interface.ts";
|
|
import type { Check } from "../../../src/domain/checks/check.type.ts";
|
|
import type { GeoCheck } from "../../../src/domain/geo-checks/geo-check.type.ts";
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
const createMockCheckService = () =>
|
|
({
|
|
createChecks: jest.fn().mockResolvedValue([]),
|
|
}) as unknown as jest.Mocked<ICheckService>;
|
|
|
|
const createMockGeoChecksService = () =>
|
|
({
|
|
createGeoChecks: jest.fn().mockResolvedValue([]),
|
|
}) as unknown as jest.Mocked<IGeoChecksService>;
|
|
|
|
const createMockSettingsService = (nodeEnv: string = "development") =>
|
|
({
|
|
getSettings: jest.fn().mockReturnValue({ nodeEnv }),
|
|
}) as unknown as jest.Mocked<ISettingsService>;
|
|
|
|
const createMockJobsRepository = () =>
|
|
({
|
|
upsertEvaluate: jest.fn().mockResolvedValue(true),
|
|
}) as unknown as jest.Mocked<IJobsRepository>;
|
|
|
|
const makeCheck = (overrides?: Partial<Check>): Check =>
|
|
({
|
|
id: "check-1",
|
|
metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" },
|
|
status: true,
|
|
statusCode: 200,
|
|
responseTime: 100,
|
|
message: "OK",
|
|
...overrides,
|
|
}) as Check;
|
|
|
|
const makeGeoCheck = (overrides?: Partial<GeoCheck>): GeoCheck =>
|
|
({
|
|
id: "geo-1",
|
|
monitorId: "mon-1",
|
|
...overrides,
|
|
}) as GeoCheck;
|
|
|
|
const createService = (nodeEnv: string = "development") => {
|
|
const logger = createMockLogger();
|
|
const checkService = createMockCheckService();
|
|
const geoChecksService = createMockGeoChecksService();
|
|
const settingsService = createMockSettingsService(nodeEnv);
|
|
const jobsRepository = createMockJobsRepository();
|
|
const service = new BufferService(logger as any, checkService, geoChecksService, settingsService, jobsRepository);
|
|
return { service, logger, checkService, geoChecksService, jobsRepository };
|
|
};
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe("BufferService", () => {
|
|
beforeEach(() => {
|
|
jest.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
// ── Static / instance properties ─────────────────────────────────────
|
|
|
|
// ── constructor ──────────────────────────────────────────────────────
|
|
|
|
describe("constructor", () => {
|
|
it("logs initialization with development timeout", () => {
|
|
const { logger } = createService("development");
|
|
expect(logger.info).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: expect.stringContaining("1s"),
|
|
service: "BufferService",
|
|
method: "constructor",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("uses 60s timeout in non-development environment", () => {
|
|
const { logger } = createService("production");
|
|
expect(logger.info).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: expect.stringContaining("60s"),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("schedules a flush on construction", () => {
|
|
createService();
|
|
expect(jest.getTimerCount()).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
// ── addToBuffer ──────────────────────────────────────────────────────
|
|
|
|
describe("addToBuffer", () => {
|
|
it("adds a check to the buffer", async () => {
|
|
const { service, checkService } = createService();
|
|
const check = makeCheck();
|
|
|
|
service.addToBuffer(check);
|
|
await service.flushBuffer();
|
|
|
|
expect(checkService.createChecks).toHaveBeenCalledWith([check]);
|
|
});
|
|
|
|
it("adds multiple checks to the buffer", async () => {
|
|
const { service, checkService } = createService();
|
|
const check1 = makeCheck({ id: "c1" });
|
|
const check2 = makeCheck({ id: "c2" });
|
|
|
|
service.addToBuffer(check1);
|
|
service.addToBuffer(check2);
|
|
await service.flushBuffer();
|
|
|
|
expect(checkService.createChecks).toHaveBeenCalledWith([check1, check2]);
|
|
});
|
|
|
|
it("logs error if push throws", () => {
|
|
const { service, logger } = createService();
|
|
// Force buffer.push to throw by making buffer non-extensible
|
|
Object.defineProperty(service, "buffer", { value: Object.freeze([]) });
|
|
|
|
service.addToBuffer(makeCheck());
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
service: "BufferService",
|
|
method: "addToBuffer",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("logs 'Unknown error' for non-Error thrown values", () => {
|
|
const { service, logger } = createService();
|
|
Object.defineProperty(service, "buffer", {
|
|
value: {
|
|
push: () => {
|
|
throw "string error";
|
|
},
|
|
},
|
|
});
|
|
|
|
service.addToBuffer(makeCheck());
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "Unknown error",
|
|
stack: undefined,
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── addGeoCheckToBuffer ──────────────────────────────────────────────
|
|
|
|
describe("addGeoCheckToBuffer", () => {
|
|
it("adds a geo check to the buffer", async () => {
|
|
const { service, geoChecksService } = createService();
|
|
const geoCheck = makeGeoCheck();
|
|
|
|
service.addGeoCheckToBuffer(geoCheck);
|
|
await service.flushGeoBuffer();
|
|
|
|
expect(geoChecksService.createGeoChecks).toHaveBeenCalledWith([geoCheck]);
|
|
});
|
|
|
|
it("logs error if push throws", () => {
|
|
const { service, logger } = createService();
|
|
Object.defineProperty(service, "geoBuffer", { value: Object.freeze([]) });
|
|
|
|
service.addGeoCheckToBuffer(makeGeoCheck());
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
service: "BufferService",
|
|
method: "addGeoCheckToBuffer",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("logs 'Unknown error' for non-Error thrown values", () => {
|
|
const { service, logger } = createService();
|
|
Object.defineProperty(service, "geoBuffer", {
|
|
value: {
|
|
push: () => {
|
|
throw 42;
|
|
},
|
|
},
|
|
});
|
|
|
|
service.addGeoCheckToBuffer(makeGeoCheck());
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "Unknown error",
|
|
stack: undefined,
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── scheduleNextFlush ────────────────────────────────────────────────
|
|
|
|
describe("scheduleNextFlush", () => {
|
|
it("clears existing timer and sets a new one", () => {
|
|
const { service } = createService();
|
|
// Constructor already scheduled one flush
|
|
const initialTimerCount = jest.getTimerCount();
|
|
|
|
service.scheduleNextFlush();
|
|
|
|
// Should still have timers (cleared old, set new)
|
|
expect(jest.getTimerCount()).toBe(initialTimerCount);
|
|
});
|
|
|
|
it("flushes buffer and geo buffer when timer fires", async () => {
|
|
const { service, checkService, geoChecksService } = createService();
|
|
service.addToBuffer(makeCheck());
|
|
service.addGeoCheckToBuffer(makeGeoCheck());
|
|
|
|
await jest.advanceTimersByTimeAsync(1000);
|
|
|
|
expect(checkService.createChecks).toHaveBeenCalled();
|
|
expect(geoChecksService.createGeoChecks).toHaveBeenCalled();
|
|
});
|
|
|
|
it("reschedules after flush completes", async () => {
|
|
const { service, checkService } = createService();
|
|
(checkService.createChecks as jest.Mock).mockResolvedValue([]);
|
|
|
|
service.addToBuffer(makeCheck());
|
|
await jest.advanceTimersByTimeAsync(1000);
|
|
|
|
// Add another check and advance again to confirm rescheduling
|
|
service.addToBuffer(makeCheck({ id: "c2" }));
|
|
await jest.advanceTimersByTimeAsync(1000);
|
|
|
|
expect(checkService.createChecks).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("reschedules even when flush throws", async () => {
|
|
const { service, checkService, logger } = createService();
|
|
(checkService.createChecks as jest.Mock).mockRejectedValueOnce(new Error("DB down"));
|
|
service.addToBuffer(makeCheck());
|
|
|
|
await jest.advanceTimersByTimeAsync(1000);
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "DB down",
|
|
method: "flushBuffer",
|
|
})
|
|
);
|
|
|
|
// Should still reschedule — add another check and flush
|
|
(checkService.createChecks as jest.Mock).mockResolvedValue([]);
|
|
service.addToBuffer(makeCheck({ id: "c2" }));
|
|
await jest.advanceTimersByTimeAsync(1000);
|
|
|
|
expect(checkService.createChecks).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("logs error and reschedules when flush throws past its own catch", async () => {
|
|
const { service, logger } = createService();
|
|
// Override flushBuffer to throw past its own try/catch
|
|
service.flushBuffer = jest.fn<() => Promise<void>>().mockRejectedValueOnce(new Error("unexpected"));
|
|
|
|
await jest.advanceTimersByTimeAsync(1000);
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "unexpected",
|
|
method: "scheduleNextFlush",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("logs 'Unknown error' when flush throws non-Error past its own catch", async () => {
|
|
const { service, logger } = createService();
|
|
service.flushBuffer = jest.fn<() => Promise<void>>().mockRejectedValueOnce("string error");
|
|
|
|
await jest.advanceTimersByTimeAsync(1000);
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "Unknown error",
|
|
method: "scheduleNextFlush",
|
|
stack: undefined,
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── flushBuffer ──────────────────────────────────────────────────────
|
|
|
|
describe("flushBuffer", () => {
|
|
it("does nothing when buffer is empty", async () => {
|
|
const { service, checkService } = createService();
|
|
|
|
await service.flushBuffer();
|
|
|
|
expect(checkService.createChecks).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("flushes checks to checksService and clears buffer", async () => {
|
|
const { service, checkService } = createService();
|
|
const check = makeCheck();
|
|
service.addToBuffer(check);
|
|
|
|
await service.flushBuffer();
|
|
|
|
expect(checkService.createChecks).toHaveBeenCalledWith([check]);
|
|
// Buffer should be empty now
|
|
await service.flushBuffer();
|
|
expect(checkService.createChecks).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("logs debug message before flushing", async () => {
|
|
const { service, logger } = createService();
|
|
service.addToBuffer(makeCheck());
|
|
|
|
await service.flushBuffer();
|
|
|
|
expect(logger.debug).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "Flushing 1 checks to database",
|
|
service: "BufferService",
|
|
method: "flushBuffer",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("arms the evaluate stage once per distinct monitor after the checks are stored", async () => {
|
|
const { service, jobsRepository } = createService();
|
|
service.addToBuffer(makeCheck({ id: "c1", metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" } }));
|
|
service.addToBuffer(makeCheck({ id: "c2", metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" } }));
|
|
service.addToBuffer(makeCheck({ id: "c3", metadata: { monitorId: "mon-2", teamId: "team-1", type: "http" } }));
|
|
|
|
await service.flushBuffer();
|
|
|
|
expect(jobsRepository.upsertEvaluate).toHaveBeenCalledTimes(2);
|
|
expect(jobsRepository.upsertEvaluate).toHaveBeenCalledWith("mon-1", expect.any(Number));
|
|
expect(jobsRepository.upsertEvaluate).toHaveBeenCalledWith("mon-2", expect.any(Number));
|
|
});
|
|
|
|
it("does not arm the evaluate stage when the check write fails", async () => {
|
|
const { service, checkService, jobsRepository } = createService();
|
|
(checkService.createChecks as jest.Mock).mockRejectedValue(new Error("DB write failed"));
|
|
service.addToBuffer(makeCheck());
|
|
|
|
await service.flushBuffer();
|
|
|
|
expect(jobsRepository.upsertEvaluate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("clears buffer even on error to prevent infinite retries", async () => {
|
|
const { service, checkService, logger } = createService();
|
|
(checkService.createChecks as jest.Mock).mockRejectedValue(new Error("DB write failed"));
|
|
service.addToBuffer(makeCheck());
|
|
|
|
await service.flushBuffer();
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "DB write failed",
|
|
method: "flushBuffer",
|
|
})
|
|
);
|
|
// Buffer should be cleared
|
|
await service.flushBuffer();
|
|
expect(checkService.createChecks).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("logs 'Unknown error' for non-Error thrown values", async () => {
|
|
const { service, checkService, logger } = createService();
|
|
(checkService.createChecks as jest.Mock).mockRejectedValue(null);
|
|
service.addToBuffer(makeCheck());
|
|
|
|
await service.flushBuffer();
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "Unknown error",
|
|
method: "flushBuffer",
|
|
stack: undefined,
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── flushGeoBuffer ───────────────────────────────────────────────────
|
|
|
|
describe("flushGeoBuffer", () => {
|
|
it("does nothing when geo buffer is empty", async () => {
|
|
const { service, geoChecksService } = createService();
|
|
|
|
await service.flushGeoBuffer();
|
|
|
|
expect(geoChecksService.createGeoChecks).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("flushes geo checks and clears buffer", async () => {
|
|
const { service, geoChecksService } = createService();
|
|
const geoCheck = makeGeoCheck();
|
|
service.addGeoCheckToBuffer(geoCheck);
|
|
|
|
await service.flushGeoBuffer();
|
|
|
|
expect(geoChecksService.createGeoChecks).toHaveBeenCalledWith([geoCheck]);
|
|
// Buffer should be empty now
|
|
await service.flushGeoBuffer();
|
|
expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("logs debug message before flushing", async () => {
|
|
const { service, logger } = createService();
|
|
service.addGeoCheckToBuffer(makeGeoCheck());
|
|
|
|
await service.flushGeoBuffer();
|
|
|
|
expect(logger.debug).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "Flushing 1 geo checks to database",
|
|
service: "BufferService",
|
|
method: "flushGeoBuffer",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("clears geo buffer even on error", async () => {
|
|
const { service, geoChecksService, logger } = createService();
|
|
(geoChecksService.createGeoChecks as jest.Mock).mockRejectedValue(new Error("DB error"));
|
|
service.addGeoCheckToBuffer(makeGeoCheck());
|
|
|
|
await service.flushGeoBuffer();
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "DB error",
|
|
method: "flushGeoBuffer",
|
|
})
|
|
);
|
|
// Buffer should be cleared
|
|
await service.flushGeoBuffer();
|
|
expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("logs 'Unknown error' for non-Error thrown values", async () => {
|
|
const { service, geoChecksService, logger } = createService();
|
|
(geoChecksService.createGeoChecks as jest.Mock).mockRejectedValue(undefined);
|
|
service.addGeoCheckToBuffer(makeGeoCheck());
|
|
|
|
await service.flushGeoBuffer();
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: "Unknown error",
|
|
method: "flushGeoBuffer",
|
|
stack: undefined,
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── shutdown ─────────────────────────────────────────────────────────────
|
|
|
|
describe("shutdown", () => {
|
|
it("stops the flush timer and flushes both buffers", async () => {
|
|
const { service, checkService, geoChecksService } = createService();
|
|
service.addToBuffer(makeCheck());
|
|
service.addGeoCheckToBuffer(makeGeoCheck());
|
|
|
|
await service.shutdown();
|
|
|
|
expect(checkService.createChecks).toHaveBeenCalledTimes(1);
|
|
expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1);
|
|
|
|
// Timer is cleared: advancing past the flush interval triggers no further flush.
|
|
await jest.advanceTimersByTimeAsync(60 * 1000);
|
|
expect(checkService.createChecks).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("is safe to call with empty buffers", async () => {
|
|
const { service, checkService, geoChecksService } = createService();
|
|
|
|
await service.shutdown();
|
|
|
|
expect(checkService.createChecks).not.toHaveBeenCalled();
|
|
expect(geoChecksService.createGeoChecks).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|