update tests
This commit is contained in:
1 parent
6587ddf389
commit
f7e1793bac
8 files changed
+392
-11
No files matched your search
@@ -24,6 +24,8 @@ import {
|
||||
dockerContainerDetailsResponseSchema,
|
||||
monitorResponseSchema,
|
||||
uptimeDetailsResponseSchema,
|
||||
getDockerContainerLogsQueryValidation,
|
||||
dockerContainerLogsResponseSchema,
|
||||
} from "@/api/validation/monitorValidation.js";
|
||||
import { updateNotificationsValidation } from "@/api/validation/notificationValidation.js";
|
||||
|
||||
@@ -131,6 +133,16 @@ registry.registerPath({
|
||||
responses: { "200": okJson(dockerContainerDetailsResponseSchema), ...standardErrors },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/monitors/docker/details/{monitorId}/containers/{containerName}/logs",
|
||||
tags,
|
||||
summary: "Get stored log lines for one container on a Docker host monitor, newest first",
|
||||
security: bearer,
|
||||
request: { params: getDockerContainerNameParamValidation, query: getDockerContainerLogsQueryValidation },
|
||||
responses: { "200": okJson(dockerContainerLogsResponseSchema), ...standardErrors },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/monitors/pagespeed/details/{monitorId}",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
|
||||
import { AppError } from "../../../../src/utils/AppError.ts";
|
||||
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
|
||||
import { DOCKER_LOG_TAIL_LINES } from "../../../../src/domain/docker/docker.type.ts";
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -53,10 +54,19 @@ const makeStats = (overrides?: Record<string, any>) => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setup = (overrides: Partial<{ ping: any; listContainers: any; getContainer: any; inspect: any; stats: any }> = {}) => {
|
||||
const makeLogFrame = (streamType: number, text: string): Buffer => {
|
||||
const payload = Buffer.from(text);
|
||||
const header = Buffer.alloc(8);
|
||||
header.writeUInt8(streamType, 0);
|
||||
header.writeUInt32BE(payload.length, 4);
|
||||
return Buffer.concat([header, payload]);
|
||||
};
|
||||
|
||||
const setup = (overrides: Partial<{ ping: any; listContainers: any; getContainer: any; inspect: any; stats: any; logs: any }> = {}) => {
|
||||
const inspect = overrides.inspect ?? jest.fn().mockResolvedValue(makeInspect());
|
||||
const stats = overrides.stats ?? jest.fn().mockResolvedValue(makeStats());
|
||||
const getContainer = overrides.getContainer ?? jest.fn().mockReturnValue({ inspect, stats });
|
||||
const logs = overrides.logs ?? jest.fn().mockResolvedValue(Buffer.alloc(0));
|
||||
const getContainer = overrides.getContainer ?? jest.fn().mockReturnValue({ inspect, stats, logs });
|
||||
const instance = {
|
||||
ping: overrides.ping ?? jest.fn().mockResolvedValue("OK"),
|
||||
listContainers: overrides.listContainers ?? jest.fn().mockResolvedValue([makeContainer()]),
|
||||
@@ -65,7 +75,7 @@ const setup = (overrides: Partial<{ ping: any; listContainers: any; getContainer
|
||||
const DockerLib = jest.fn().mockReturnValue(instance) as any;
|
||||
const logger = createMockLogger();
|
||||
const provider = new DockerProvider(logger as any, DockerLib);
|
||||
return { provider, logger, DockerLib, ping: instance.ping, listContainers: instance.listContainers, getContainer, inspect, stats };
|
||||
return { provider, logger, DockerLib, ping: instance.ping, listContainers: instance.listContainers, getContainer, inspect, stats, logs };
|
||||
};
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
@@ -278,6 +288,65 @@ describe("DockerProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("container logs", () => {
|
||||
it("parses multiplexed stdout and stderr with normalized timestamps", async () => {
|
||||
const logs = jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
Buffer.concat([makeLogFrame(1, "2026-01-01T00:00:37.1Z ready\n"), makeLogFrame(2, "2026-01-01T00:00:38.123Z failed\r\n")])
|
||||
);
|
||||
const { provider } = setup({ logs });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.payload?.logs?.[0]?.lines).toEqual([
|
||||
{ ts: "2026-01-01T00:00:37.100000000Z", stream: "stdout", text: "ready" },
|
||||
{ ts: "2026-01-01T00:00:38.123000000Z", stream: "stderr", text: "failed" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats raw TTY output as stdout and drops lines without timestamps", async () => {
|
||||
const logs = jest.fn().mockResolvedValue(Buffer.from("noise\n2026-01-01T00:00:00Z first\n2026-01-01T00:00:01.12Z second\n"));
|
||||
const { provider } = setup({ logs });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.payload?.logs?.[0]?.lines).toEqual([
|
||||
{ ts: "2026-01-01T00:00:00.000000000Z", stream: "stdout", text: "first" },
|
||||
{ ts: "2026-01-01T00:00:01.120000000Z", stream: "stdout", text: "second" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("truncates oversized UTF-8 lines and appends the marker", async () => {
|
||||
const logs = jest.fn().mockResolvedValue(Buffer.from(`2026-01-01T00:00:00Z ${"x".repeat(5000)}\n`));
|
||||
const { provider } = setup({ logs });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
const text = result.payload?.logs?.[0]?.lines[0]?.text;
|
||||
expect(text).toEqual(expect.stringMatching(/ …\[truncated\]$/));
|
||||
expect(Buffer.byteLength(text?.replace(" …[truncated]", "") ?? "", "utf8")).toBeLessThanOrEqual(4096);
|
||||
});
|
||||
|
||||
it("isolates a logs failure and warns", async () => {
|
||||
const { provider, logger } = setup({ logs: jest.fn().mockRejectedValue(new Error("logs failed")) });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.payload?.logs).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to read logs for container my-container" }));
|
||||
});
|
||||
|
||||
it("requests a bounded timestamped tail for every container state", async () => {
|
||||
const containers = [makeContainer({ Id: "a".repeat(64) }), makeContainer({ Id: "b".repeat(64), State: "exited" })];
|
||||
const { provider, logs } = setup({ listContainers: jest.fn().mockResolvedValue(containers) });
|
||||
|
||||
await provider.handle(makeMonitor());
|
||||
|
||||
expect(logs).toHaveBeenCalledTimes(2);
|
||||
expect(logs).toHaveBeenCalledWith({ follow: false, stdout: true, stderr: true, timestamps: true, tail: DOCKER_LOG_TAIL_LINES });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Enrichment fault isolation ───────────────────────────────────────
|
||||
|
||||
describe("enrichment fault isolation", () => {
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { ISettingsService } from "../../../src/domain/app-settings/app-sett
|
||||
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";
|
||||
import type { IDockerLogsService } from "../../../src/domain/docker/docker-log.service.ts";
|
||||
import type { DockerLog } from "../../../src/domain/docker/docker-log.type.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -20,6 +22,11 @@ const createMockGeoChecksService = () =>
|
||||
createGeoChecks: jest.fn().mockResolvedValue([]),
|
||||
}) as unknown as jest.Mocked<IGeoChecksService>;
|
||||
|
||||
const createMockDockerLogsService = () =>
|
||||
({
|
||||
createDockerLogs: jest.fn().mockResolvedValue(0),
|
||||
}) as unknown as jest.Mocked<IDockerLogsService>;
|
||||
|
||||
const createMockSettingsService = (nodeEnv: string = "development") =>
|
||||
({
|
||||
getSettings: jest.fn().mockReturnValue({ nodeEnv }),
|
||||
@@ -48,14 +55,28 @@ const makeGeoCheck = (overrides?: Partial<GeoCheck>): GeoCheck =>
|
||||
...overrides,
|
||||
}) as GeoCheck;
|
||||
|
||||
const makeDockerLog = (overrides?: Partial<DockerLog>): DockerLog =>
|
||||
({
|
||||
id: "docker-log-1",
|
||||
metadata: { monitorId: "mon-1", teamId: "team-1", containerId: "container-1", containerName: "web" },
|
||||
lines: [{ ts: "2026-01-01T00:00:00.000000000Z", stream: "stdout", text: "ready" }],
|
||||
gap: false,
|
||||
checkedAt: "2026-01-01T00:00:01.000Z",
|
||||
expiry: "2026-01-08T00:00:01.000Z",
|
||||
createdAt: "2026-01-01T00:00:01.000Z",
|
||||
updatedAt: "2026-01-01T00:00:01.000Z",
|
||||
...overrides,
|
||||
}) as DockerLog;
|
||||
|
||||
const createService = (nodeEnv: string = "development") => {
|
||||
const logger = createMockLogger();
|
||||
const checkService = createMockCheckService();
|
||||
const geoChecksService = createMockGeoChecksService();
|
||||
const dockerLogsService = createMockDockerLogsService();
|
||||
const settingsService = createMockSettingsService(nodeEnv);
|
||||
const jobsRepository = createMockJobsRepository();
|
||||
const service = new BufferService(logger as any, checkService, geoChecksService, settingsService, jobsRepository);
|
||||
return { service, logger, checkService, geoChecksService, jobsRepository };
|
||||
const service = new BufferService(logger as any, checkService, geoChecksService, dockerLogsService, settingsService, jobsRepository);
|
||||
return { service, logger, checkService, geoChecksService, dockerLogsService, jobsRepository };
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
@@ -209,6 +230,27 @@ describe("BufferService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("addDockerLogToBuffer", () => {
|
||||
it("adds a docker log to the buffer", async () => {
|
||||
const { service, dockerLogsService } = createService();
|
||||
const dockerLog = makeDockerLog();
|
||||
|
||||
service.addDockerLogToBuffer(dockerLog);
|
||||
await service.flushDockerLogsBuffer();
|
||||
|
||||
expect(dockerLogsService.createDockerLogs).toHaveBeenCalledWith([dockerLog]);
|
||||
});
|
||||
|
||||
it("logs an error if push throws", () => {
|
||||
const { service, logger } = createService();
|
||||
Object.defineProperty(service, "dockerLogBuffer", { value: Object.freeze([]) });
|
||||
|
||||
service.addDockerLogToBuffer(makeDockerLog());
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ method: "addDockerLogToBuffer" }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── scheduleNextFlush ────────────────────────────────────────────────
|
||||
|
||||
describe("scheduleNextFlush", () => {
|
||||
@@ -223,15 +265,17 @@ describe("BufferService", () => {
|
||||
expect(jest.getTimerCount()).toBe(initialTimerCount);
|
||||
});
|
||||
|
||||
it("flushes buffer and geo buffer when timer fires", async () => {
|
||||
const { service, checkService, geoChecksService } = createService();
|
||||
it("flushes all buffers when timer fires", async () => {
|
||||
const { service, checkService, geoChecksService, dockerLogsService } = createService();
|
||||
service.addToBuffer(makeCheck());
|
||||
service.addGeoCheckToBuffer(makeGeoCheck());
|
||||
service.addDockerLogToBuffer(makeDockerLog());
|
||||
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(checkService.createChecks).toHaveBeenCalled();
|
||||
expect(geoChecksService.createGeoChecks).toHaveBeenCalled();
|
||||
expect(dockerLogsService.createDockerLogs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reschedules after flush completes", async () => {
|
||||
@@ -472,18 +516,72 @@ describe("BufferService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("flushDockerLogsBuffer", () => {
|
||||
it("does nothing when the docker log buffer is empty", async () => {
|
||||
const { service, dockerLogsService } = createService();
|
||||
|
||||
await service.flushDockerLogsBuffer();
|
||||
|
||||
expect(dockerLogsService.createDockerLogs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flushes the batch and clears it", async () => {
|
||||
const { service, dockerLogsService } = createService();
|
||||
const dockerLog = makeDockerLog();
|
||||
service.addDockerLogToBuffer(dockerLog);
|
||||
|
||||
await service.flushDockerLogsBuffer();
|
||||
await service.flushDockerLogsBuffer();
|
||||
|
||||
expect(dockerLogsService.createDockerLogs).toHaveBeenCalledWith([dockerLog]);
|
||||
expect(dockerLogsService.createDockerLogs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not drop logs added while a flush is in flight", async () => {
|
||||
const { service, dockerLogsService } = createService();
|
||||
let resolveWrite!: (value: number) => void;
|
||||
(dockerLogsService.createDockerLogs as jest.Mock).mockImplementationOnce(() => new Promise<number>((resolve) => (resolveWrite = resolve)));
|
||||
const first = makeDockerLog({ id: "docker-log-1" });
|
||||
const second = makeDockerLog({ id: "docker-log-2" });
|
||||
service.addDockerLogToBuffer(first);
|
||||
|
||||
const flush = service.flushDockerLogsBuffer();
|
||||
service.addDockerLogToBuffer(second);
|
||||
resolveWrite(1);
|
||||
await flush;
|
||||
await service.flushDockerLogsBuffer();
|
||||
|
||||
expect(dockerLogsService.createDockerLogs).toHaveBeenNthCalledWith(1, [first]);
|
||||
expect(dockerLogsService.createDockerLogs).toHaveBeenNthCalledWith(2, [second]);
|
||||
});
|
||||
|
||||
it("drops a failed batch and logs the error", async () => {
|
||||
const { service, dockerLogsService, logger } = createService();
|
||||
(dockerLogsService.createDockerLogs as jest.Mock).mockRejectedValueOnce(new Error("DB error"));
|
||||
service.addDockerLogToBuffer(makeDockerLog());
|
||||
|
||||
await service.flushDockerLogsBuffer();
|
||||
await service.flushDockerLogsBuffer();
|
||||
|
||||
expect(dockerLogsService.createDockerLogs).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "DB error", method: "flushDockerLogsBuffer" }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── shutdown ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("shutdown", () => {
|
||||
it("stops the flush timer and flushes both buffers", async () => {
|
||||
const { service, checkService, geoChecksService } = createService();
|
||||
it("stops the flush timer and flushes all buffers", async () => {
|
||||
const { service, checkService, geoChecksService, dockerLogsService } = createService();
|
||||
service.addToBuffer(makeCheck());
|
||||
service.addGeoCheckToBuffer(makeGeoCheck());
|
||||
service.addDockerLogToBuffer(makeDockerLog());
|
||||
|
||||
await service.shutdown();
|
||||
|
||||
expect(checkService.createChecks).toHaveBeenCalledTimes(1);
|
||||
expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1);
|
||||
expect(dockerLogsService.createDockerLogs).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Timer is cleared: advancing past the flush interval triggers no further flush.
|
||||
await jest.advanceTimersByTimeAsync(60 * 1000);
|
||||
@@ -491,12 +589,13 @@ describe("BufferService", () => {
|
||||
});
|
||||
|
||||
it("is safe to call with empty buffers", async () => {
|
||||
const { service, checkService, geoChecksService } = createService();
|
||||
const { service, checkService, geoChecksService, dockerLogsService } = createService();
|
||||
|
||||
await service.shutdown();
|
||||
|
||||
expect(checkService.createChecks).not.toHaveBeenCalled();
|
||||
expect(geoChecksService.createGeoChecks).not.toHaveBeenCalled();
|
||||
expect(dockerLogsService.createDockerLogs).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ const createProducer = (overrides?: Record<string, any>) => {
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) },
|
||||
proxyResolver: { resolve: jest.fn().mockResolvedValue(undefined) },
|
||||
buffer: { addToBuffer: jest.fn() },
|
||||
dockerLogsService: { buildDockerLogs: jest.fn().mockResolvedValue([]) },
|
||||
...overrides,
|
||||
};
|
||||
const producer = new CheckProducer(
|
||||
@@ -37,6 +38,7 @@ const createProducer = (overrides?: Record<string, any>) => {
|
||||
defaults.networkService as any,
|
||||
defaults.proxyResolver as any,
|
||||
defaults.buffer as any,
|
||||
defaults.dockerLogsService as any,
|
||||
defaults.logger as any
|
||||
);
|
||||
return { producer, defaults };
|
||||
@@ -142,4 +144,28 @@ describe("CheckProducer", () => {
|
||||
expect(defaults.buffer.addToBuffer).toHaveBeenCalledWith(check);
|
||||
expect(result).toEqual({ status, check });
|
||||
});
|
||||
|
||||
it("builds and buffers docker logs for a docker status", async () => {
|
||||
const status = { type: "docker", monitorId: "m1", teamId: "team", status: true, code: 200, message: "OK", payload: {} };
|
||||
const dockerLogs = [{ id: "docker-log-1" }, { id: "docker-log-2" }];
|
||||
const { producer, defaults } = createProducer({
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue(status) },
|
||||
buffer: { addToBuffer: jest.fn(), addDockerLogToBuffer: jest.fn() },
|
||||
dockerLogsService: { buildDockerLogs: jest.fn().mockResolvedValue(dockerLogs) },
|
||||
});
|
||||
|
||||
await producer.produce(makeMonitor({ type: "docker" }));
|
||||
|
||||
expect(defaults.dockerLogsService.buildDockerLogs).toHaveBeenCalledWith(status);
|
||||
expect(defaults.buffer.addDockerLogToBuffer).toHaveBeenNthCalledWith(1, dockerLogs[0]);
|
||||
expect(defaults.buffer.addDockerLogToBuffer).toHaveBeenNthCalledWith(2, dockerLogs[1]);
|
||||
});
|
||||
|
||||
it("does not build docker logs for a non-docker status", async () => {
|
||||
const { producer, defaults } = createProducer();
|
||||
|
||||
await producer.produce(makeMonitor());
|
||||
|
||||
expect(defaults.dockerLogsService.buildDockerLogs).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { DockerLogsService } from "../../../src/domain/docker/docker-log.service.ts";
|
||||
import { DOCKER_LOG_TAIL_LINES, type DockerLogLine } from "../../../src/domain/docker/docker.type.ts";
|
||||
import type { MonitorStatusResponse, DockerStatusPayload } from "../../../src/types/network.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
|
||||
const line = (second: number): DockerLogLine => ({
|
||||
ts: `2026-01-01T00:00:${String(second).padStart(2, "0")}.000000000Z`,
|
||||
stream: "stdout",
|
||||
text: `line ${second}`,
|
||||
});
|
||||
|
||||
const status = (logs?: DockerStatusPayload["logs"]): MonitorStatusResponse<DockerStatusPayload> => ({
|
||||
monitorId: "monitor-1",
|
||||
teamId: "team-1",
|
||||
type: "docker",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "OK",
|
||||
payload: { containers: [], summary: { total: 0, running: 0, stopped: 0, unhealthy: 0 }, logs },
|
||||
});
|
||||
|
||||
const createService = () => {
|
||||
const repository = {
|
||||
findLastLineTimestamp: jest.fn().mockResolvedValue(null),
|
||||
createDockerLogs: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
const logger = createMockLogger();
|
||||
return { service: new DockerLogsService({ logger: logger as any, dockerLogsRepository: repository as any }), repository, logger };
|
||||
};
|
||||
|
||||
describe("DockerLogsService", () => {
|
||||
it("returns no documents for missing or empty logs", async () => {
|
||||
const { service, repository } = createService();
|
||||
expect(await service.buildDockerLogs(status())).toEqual([]);
|
||||
expect(await service.buildDockerLogs(status([]))).toEqual([]);
|
||||
expect(repository.findLastLineTimestamp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("seeds the cursor, drops old lines, and sets seven-day expiry", async () => {
|
||||
const { service, repository } = createService();
|
||||
repository.findLastLineTimestamp.mockResolvedValue(line(1).ts);
|
||||
|
||||
const result = await service.buildDockerLogs(status([{ containerId: "container-1", containerName: "web", lines: [line(1), line(2)] }]));
|
||||
|
||||
expect(repository.findLastLineTimestamp).toHaveBeenCalledWith("monitor-1", "container-1");
|
||||
expect(result[0]?.lines).toEqual([line(2)]);
|
||||
expect(new Date(result[0]!.expiry).getTime() - new Date(result[0]!.checkedAt).getTime()).toBe(7 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("advances the cursor in memory and does not query again", async () => {
|
||||
const { service, repository } = createService();
|
||||
const logs = [{ containerId: "container-1", containerName: "web", lines: [line(1)] }];
|
||||
|
||||
expect(await service.buildDockerLogs(status(logs))).toHaveLength(1);
|
||||
expect(await service.buildDockerLogs(status(logs))).toEqual([]);
|
||||
expect(repository.findLastLineTimestamp).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sets gap only when every tail line is fresh", async () => {
|
||||
const { service } = createService();
|
||||
const lines = Array.from({ length: DOCKER_LOG_TAIL_LINES }, (_, index) => ({
|
||||
ts: `2026-01-01T00:00:00.${String(index).padStart(9, "0")}Z`,
|
||||
stream: "stdout" as const,
|
||||
text: String(index),
|
||||
}));
|
||||
|
||||
const [result] = await service.buildDockerLogs(status([{ containerId: "container-1", containerName: "web", lines }]));
|
||||
expect(result?.gap).toBe(true);
|
||||
});
|
||||
|
||||
it("skips a failed container and continues with the others", async () => {
|
||||
const { service, repository, logger } = createService();
|
||||
repository.findLastLineTimestamp.mockRejectedValueOnce(new Error("DB down")).mockResolvedValueOnce(null);
|
||||
|
||||
const result = await service.buildDockerLogs(
|
||||
status([
|
||||
{ containerId: "bad", containerName: "bad-container", lines: [line(1)] },
|
||||
{ containerId: "good", containerName: "good-container", lines: [line(2)] },
|
||||
])
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.metadata.containerId).toBe("good");
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build docker logs for container bad-container" }));
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { MonitorService } from "../../../src/domain/monitors/monitor.service.ts";
|
||||
import type { IChecksRepository } from "../../../src/domain/checks/check.repository.interface.ts";
|
||||
import type { IGeoChecksRepository } from "../../../src/domain/geo-checks/geo-check.repository.interface.ts";
|
||||
import type { IDockerLogsRepository } from "../../../src/domain/docker/docker-log.repository.interface.ts";
|
||||
import type { IIncidentsRepository } from "../../../src/domain/incidents/incident.repository.interface.ts";
|
||||
import type { IMonitorStatsRepository } from "../../../src/domain/monitor-stats/monitor-stats.repository.interface.ts";
|
||||
import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts";
|
||||
@@ -50,6 +51,12 @@ const createGeoChecksRepositoryMock = () =>
|
||||
deleteByMonitorId: jest.fn(),
|
||||
}) as unknown as IGeoChecksRepository;
|
||||
|
||||
const createDockerLogsRepositoryMock = () =>
|
||||
({
|
||||
findByContainerName: jest.fn(),
|
||||
deleteByMonitorId: jest.fn().mockResolvedValue(0),
|
||||
}) as unknown as IDockerLogsRepository;
|
||||
|
||||
const createIncidentsRepositoryMock = () =>
|
||||
({
|
||||
deleteByMonitorId: jest.fn(),
|
||||
@@ -89,6 +96,7 @@ const createService = (
|
||||
monitorStatsRepository?: IMonitorStatsRepository;
|
||||
statusPagesRepository?: IStatusPagesRepository;
|
||||
geoChecksRepository?: IGeoChecksRepository;
|
||||
dockerLogsRepository?: IDockerLogsRepository;
|
||||
incidentsRepository?: IIncidentsRepository;
|
||||
jobQueue?: ReturnType<typeof createJobQueueMock>;
|
||||
logger?: ReturnType<typeof createMockLogger>;
|
||||
@@ -104,6 +112,7 @@ const createService = (
|
||||
monitorsRepository: overrides.monitorsRepository ?? createMonitorsRepositoryMock(),
|
||||
checksRepository: overrides.checksRepository ?? createChecksRepositoryMock(),
|
||||
geoChecksRepository: overrides.geoChecksRepository ?? createGeoChecksRepositoryMock(),
|
||||
dockerLogsRepository: overrides.dockerLogsRepository ?? createDockerLogsRepositoryMock(),
|
||||
monitorStatsRepository: overrides.monitorStatsRepository ?? createMonitorStatsRepositoryMock(),
|
||||
statusPagesRepository: overrides.statusPagesRepository ?? createStatusPagesRepositoryMock(),
|
||||
incidentsRepository: overrides.incidentsRepository ?? createIncidentsRepositoryMock(),
|
||||
@@ -594,6 +603,61 @@ describe("MonitorService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDockerContainerLogs", () => {
|
||||
it("throws 404 when the monitor is not found", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(null);
|
||||
const { service } = createService({ monitorsRepository });
|
||||
|
||||
await expect(service.getDockerContainerLogs({ teamId: TEAM_ID, monitorId: "missing", containerName: "web", limit: 20 })).rejects.toThrow(
|
||||
"Monitor with ID missing not found"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws 400 for a non-docker monitor", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "http" }));
|
||||
const { service } = createService({ monitorsRepository });
|
||||
|
||||
await expect(service.getDockerContainerLogs({ teamId: TEAM_ID, monitorId: MONITOR_ID, containerName: "web", limit: 20 })).rejects.toThrow(
|
||||
"monitors are not supported for docker container logs"
|
||||
);
|
||||
});
|
||||
|
||||
it("passes pagination through and returns the last checkedAt as a full-page cursor", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
const dockerLogsRepository = createDockerLogsRepositoryMock();
|
||||
const before = new Date("2026-01-02T00:00:00.000Z");
|
||||
const logs = [{ checkedAt: "2026-01-01T02:00:00.000Z" }, { checkedAt: "2026-01-01T01:00:00.000Z" }];
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" }));
|
||||
(dockerLogsRepository.findByContainerName as jest.Mock).mockResolvedValue(logs);
|
||||
const { service } = createService({ monitorsRepository, dockerLogsRepository });
|
||||
|
||||
const result = await service.getDockerContainerLogs({
|
||||
teamId: TEAM_ID,
|
||||
monitorId: MONITOR_ID,
|
||||
containerName: "web",
|
||||
before,
|
||||
limit: 2,
|
||||
});
|
||||
|
||||
expect(dockerLogsRepository.findByContainerName).toHaveBeenCalledWith({ monitorId: MONITOR_ID, containerName: "web", before, limit: 2 });
|
||||
expect(result.nextCursor).toBe("2026-01-01T01:00:00.000Z");
|
||||
});
|
||||
|
||||
it("returns a null cursor for a short page", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
const dockerLogsRepository = createDockerLogsRepositoryMock();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" }));
|
||||
(dockerLogsRepository.findByContainerName as jest.Mock).mockResolvedValue([{ checkedAt: "2026-01-01T01:00:00.000Z" }]);
|
||||
const { service } = createService({ monitorsRepository, dockerLogsRepository });
|
||||
|
||||
const result = await service.getDockerContainerLogs({ teamId: TEAM_ID, monitorId: MONITOR_ID, containerName: "web", limit: 2 });
|
||||
|
||||
expect(result.nextCursor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPageSpeedDetailsById", () => {
|
||||
it("returns pagespeed details for a pagespeed monitor", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
@@ -1802,6 +1866,7 @@ describe("MonitorService", () => {
|
||||
monitorsRepository,
|
||||
checksRepository,
|
||||
geoChecksRepository,
|
||||
dockerLogsRepository: createDockerLogsRepositoryMock(),
|
||||
monitorStatsRepository,
|
||||
statusPagesRepository,
|
||||
incidentsRepository,
|
||||
|
||||
@@ -32,6 +32,9 @@ const createHelper = (overrides?: Record<string, unknown>) => {
|
||||
const geoChecksRepository = {
|
||||
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
const dockerLogsRepository = {
|
||||
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
const settingsService = {
|
||||
getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 30 }),
|
||||
};
|
||||
@@ -50,6 +53,7 @@ const createHelper = (overrides?: Record<string, unknown>) => {
|
||||
checksRepository,
|
||||
incidentsRepository,
|
||||
geoChecksRepository,
|
||||
dockerLogsRepository,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -63,7 +67,8 @@ const createHelper = (overrides?: Record<string, unknown>) => {
|
||||
defaults.monitorStatsRepository as any,
|
||||
defaults.checksRepository as any,
|
||||
defaults.incidentsRepository as any,
|
||||
defaults.geoChecksRepository as any
|
||||
defaults.geoChecksRepository as any,
|
||||
defaults.dockerLogsRepository as any
|
||||
);
|
||||
return { helper, defaults };
|
||||
};
|
||||
@@ -81,6 +86,7 @@ describe("WorkerHelper", () => {
|
||||
(defaults.checksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(4);
|
||||
(defaults.incidentsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(1);
|
||||
(defaults.geoChecksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(5);
|
||||
(defaults.dockerLogsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(7);
|
||||
(defaults.jobsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(6);
|
||||
|
||||
const job = helper.getCleanupOrphanedJob();
|
||||
@@ -93,12 +99,14 @@ describe("WorkerHelper", () => {
|
||||
expect(defaults.checksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
|
||||
expect(defaults.incidentsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
|
||||
expect(defaults.geoChecksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
|
||||
expect(defaults.dockerLogsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
|
||||
expect(defaults.jobsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
|
||||
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("2 orphaned monitors") }));
|
||||
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("3 orphaned monitor stats") }));
|
||||
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("4 orphaned checks") }));
|
||||
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 orphaned incidents") }));
|
||||
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("5 orphaned geo checks") }));
|
||||
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("7 orphaned docker logs") }));
|
||||
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("6 orphaned jobs") }));
|
||||
});
|
||||
|
||||
|
||||
@@ -3,8 +3,23 @@ import {
|
||||
createMonitorBodyValidation,
|
||||
editMonitorBodyValidation,
|
||||
importMonitorsBodyValidation,
|
||||
getDockerContainerLogsQueryValidation,
|
||||
} from "../../../src/api/validation/monitorValidation.ts";
|
||||
|
||||
describe("getDockerContainerLogsQueryValidation", () => {
|
||||
it("defaults limit to 20", () => {
|
||||
expect(getDockerContainerLogsQueryValidation.parse({})).toEqual({ limit: 20 });
|
||||
});
|
||||
|
||||
it("rejects a limit above 50", () => {
|
||||
expect(() => getDockerContainerLogsQueryValidation.parse({ limit: 51 })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a non-ISO before cursor", () => {
|
||||
expect(() => getDockerContainerLogsQueryValidation.parse({ before: "yesterday" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
const baseDnsBody = {
|
||||
name: "DNS check",
|
||||
type: "dns" as const,
|
||||
|
||||
Reference in new issue
Block a user