Files
Checkmate/server/test/unit/services/bufferService.test.ts
2026-07-22 10:13:37 -07:00

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();
});
});
});