feat(#168): Implement job steps tracking
Implement JobStepsModule for granular step tracking within runner jobs. Features: - Create and track job steps (SETUP, EXECUTION, VALIDATION, CLEANUP) - Track step status transitions (PENDING → RUNNING → COMPLETED/FAILED) - Record token usage for AI_ACTION steps - Calculate step duration automatically - GET endpoints for listing and retrieving steps Implementation: - JobStepsService: CRUD operations, status tracking, duration calculation - JobStepsController: GET /runner-jobs/:jobId/steps endpoints - DTOs: CreateStepDto, UpdateStepDto with validation - Full unit test coverage (16 tests) Quality gates: - Build: ✅ Passed - Lint: ✅ Passed - Tests: ✅ 16/16 passed - Coverage: ✅ 100% statements, 100% functions, 100% lines, 83.33% branches Also fixed pre-existing TypeScript strict mode issue in job-events DTO. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
338
apps/api/src/job-events/job-events.service.spec.ts
Normal file
338
apps/api/src/job-events/job-events.service.spec.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { JobEventsService } from "./job-events.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { JOB_CREATED, STEP_STARTED, AI_TOKENS_USED } from "./event-types";
|
||||
|
||||
describe("JobEventsService", () => {
|
||||
let service: JobEventsService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const mockPrismaService = {
|
||||
runnerJob: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
jobStep: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
jobEvent: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
JobEventsService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<JobEventsService>(JobEventsService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("emitEvent", () => {
|
||||
const jobId = "job-123";
|
||||
const mockEvent = {
|
||||
id: "event-123",
|
||||
jobId,
|
||||
stepId: null,
|
||||
type: JOB_CREATED,
|
||||
timestamp: new Date(),
|
||||
actor: "system",
|
||||
payload: { message: "Job created" },
|
||||
};
|
||||
|
||||
it("should create a job event without stepId", async () => {
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue({ id: jobId });
|
||||
mockPrismaService.jobEvent.create.mockResolvedValue(mockEvent);
|
||||
|
||||
const result = await service.emitEvent(jobId, {
|
||||
type: JOB_CREATED,
|
||||
actor: "system",
|
||||
payload: { message: "Job created" },
|
||||
});
|
||||
|
||||
expect(prisma.runnerJob.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: jobId },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(prisma.jobEvent.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
job: { connect: { id: jobId } },
|
||||
type: JOB_CREATED,
|
||||
timestamp: expect.any(Date),
|
||||
actor: "system",
|
||||
payload: { message: "Job created" },
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockEvent);
|
||||
});
|
||||
|
||||
it("should create a job event with stepId", async () => {
|
||||
const stepId = "step-123";
|
||||
const eventWithStep = { ...mockEvent, stepId, type: STEP_STARTED };
|
||||
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue({ id: jobId });
|
||||
mockPrismaService.jobStep.findUnique.mockResolvedValue({ id: stepId });
|
||||
mockPrismaService.jobEvent.create.mockResolvedValue(eventWithStep);
|
||||
|
||||
const result = await service.emitEvent(jobId, {
|
||||
type: STEP_STARTED,
|
||||
actor: "system",
|
||||
payload: { stepName: "Setup" },
|
||||
stepId,
|
||||
});
|
||||
|
||||
expect(prisma.jobStep.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: stepId },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(prisma.jobEvent.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
job: { connect: { id: jobId } },
|
||||
step: { connect: { id: stepId } },
|
||||
type: STEP_STARTED,
|
||||
timestamp: expect.any(Date),
|
||||
actor: "system",
|
||||
payload: { stepName: "Setup" },
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(eventWithStep);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if job does not exist", async () => {
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.emitEvent(jobId, {
|
||||
type: JOB_CREATED,
|
||||
actor: "system",
|
||||
payload: {},
|
||||
})
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if step does not exist", async () => {
|
||||
const stepId = "step-invalid";
|
||||
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue({ id: jobId });
|
||||
mockPrismaService.jobStep.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.emitEvent(jobId, {
|
||||
type: STEP_STARTED,
|
||||
actor: "system",
|
||||
payload: {},
|
||||
stepId,
|
||||
})
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEventsByJobId", () => {
|
||||
const jobId = "job-123";
|
||||
const mockEvents = [
|
||||
{
|
||||
id: "event-1",
|
||||
jobId,
|
||||
stepId: null,
|
||||
type: JOB_CREATED,
|
||||
timestamp: new Date("2026-01-01T10:00:00Z"),
|
||||
actor: "system",
|
||||
payload: {},
|
||||
},
|
||||
{
|
||||
id: "event-2",
|
||||
jobId,
|
||||
stepId: "step-1",
|
||||
type: STEP_STARTED,
|
||||
timestamp: new Date("2026-01-01T10:01:00Z"),
|
||||
actor: "system",
|
||||
payload: {},
|
||||
},
|
||||
];
|
||||
|
||||
it("should return paginated events for a job", async () => {
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue({ id: jobId });
|
||||
mockPrismaService.jobEvent.findMany.mockResolvedValue(mockEvents);
|
||||
mockPrismaService.jobEvent.count.mockResolvedValue(2);
|
||||
|
||||
const result = await service.getEventsByJobId(jobId, {});
|
||||
|
||||
expect(prisma.runnerJob.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: jobId },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(prisma.jobEvent.findMany).toHaveBeenCalledWith({
|
||||
where: { jobId },
|
||||
orderBy: { timestamp: "asc" },
|
||||
skip: 0,
|
||||
take: 50,
|
||||
});
|
||||
expect(prisma.jobEvent.count).toHaveBeenCalledWith({
|
||||
where: { jobId },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
data: mockEvents,
|
||||
meta: {
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
totalPages: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter events by type", async () => {
|
||||
const filteredEvents = [mockEvents[0]];
|
||||
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue({ id: jobId });
|
||||
mockPrismaService.jobEvent.findMany.mockResolvedValue(filteredEvents);
|
||||
mockPrismaService.jobEvent.count.mockResolvedValue(1);
|
||||
|
||||
const result = await service.getEventsByJobId(jobId, { type: JOB_CREATED });
|
||||
|
||||
expect(prisma.jobEvent.findMany).toHaveBeenCalledWith({
|
||||
where: { jobId, type: JOB_CREATED },
|
||||
orderBy: { timestamp: "asc" },
|
||||
skip: 0,
|
||||
take: 50,
|
||||
});
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.meta.total).toBe(1);
|
||||
});
|
||||
|
||||
it("should filter events by stepId", async () => {
|
||||
const stepId = "step-1";
|
||||
const filteredEvents = [mockEvents[1]];
|
||||
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue({ id: jobId });
|
||||
mockPrismaService.jobEvent.findMany.mockResolvedValue(filteredEvents);
|
||||
mockPrismaService.jobEvent.count.mockResolvedValue(1);
|
||||
|
||||
const result = await service.getEventsByJobId(jobId, { stepId });
|
||||
|
||||
expect(prisma.jobEvent.findMany).toHaveBeenCalledWith({
|
||||
where: { jobId, stepId },
|
||||
orderBy: { timestamp: "asc" },
|
||||
skip: 0,
|
||||
take: 50,
|
||||
});
|
||||
expect(result.data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should paginate results correctly", async () => {
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue({ id: jobId });
|
||||
mockPrismaService.jobEvent.findMany.mockResolvedValue([mockEvents[1]]);
|
||||
mockPrismaService.jobEvent.count.mockResolvedValue(2);
|
||||
|
||||
const result = await service.getEventsByJobId(jobId, { page: 2, limit: 1 });
|
||||
|
||||
expect(prisma.jobEvent.findMany).toHaveBeenCalledWith({
|
||||
where: { jobId },
|
||||
orderBy: { timestamp: "asc" },
|
||||
skip: 1,
|
||||
take: 1,
|
||||
});
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.meta.page).toBe(2);
|
||||
expect(result.meta.limit).toBe(1);
|
||||
expect(result.meta.totalPages).toBe(2);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if job does not exist", async () => {
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getEventsByJobId(jobId, {})).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convenience methods", () => {
|
||||
const jobId = "job-123";
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue({ id: jobId });
|
||||
mockPrismaService.jobEvent.create.mockResolvedValue({
|
||||
id: "event-123",
|
||||
jobId,
|
||||
stepId: null,
|
||||
type: JOB_CREATED,
|
||||
timestamp: new Date(),
|
||||
actor: "system",
|
||||
payload: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit job.created event", async () => {
|
||||
await service.emitJobCreated(jobId, { type: "code-task" });
|
||||
|
||||
expect(prisma.jobEvent.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
job: { connect: { id: jobId } },
|
||||
type: JOB_CREATED,
|
||||
timestamp: expect.any(Date),
|
||||
actor: "system",
|
||||
payload: { type: "code-task" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit job.started event", async () => {
|
||||
await service.emitJobStarted(jobId);
|
||||
|
||||
expect(prisma.jobEvent.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
job: { connect: { id: jobId } },
|
||||
type: "job.started",
|
||||
timestamp: expect.any(Date),
|
||||
actor: "system",
|
||||
payload: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit step.started event", async () => {
|
||||
const stepId = "step-123";
|
||||
mockPrismaService.jobStep.findUnique.mockResolvedValue({ id: stepId });
|
||||
|
||||
await service.emitStepStarted(jobId, stepId, { name: "Setup" });
|
||||
|
||||
expect(prisma.jobEvent.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
job: { connect: { id: jobId } },
|
||||
step: { connect: { id: stepId } },
|
||||
type: STEP_STARTED,
|
||||
timestamp: expect.any(Date),
|
||||
actor: "system",
|
||||
payload: { name: "Setup" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit ai.tokens_used event", async () => {
|
||||
await service.emitAiTokensUsed(jobId, { input: 100, output: 50 });
|
||||
|
||||
expect(prisma.jobEvent.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
job: { connect: { id: jobId } },
|
||||
type: AI_TOKENS_USED,
|
||||
timestamp: expect.any(Date),
|
||||
actor: "system",
|
||||
payload: { input: 100, output: 50 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user