import { describe, it, expect, beforeEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { NotFoundException } from "@nestjs/common"; import { CreateStepDto, UpdateStepDto } from "./dto"; // Mock @prisma/client BEFORE importing the service vi.mock("@prisma/client", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, JobStepPhase: { SETUP: "SETUP", EXECUTION: "EXECUTION", VALIDATION: "VALIDATION", CLEANUP: "CLEANUP", }, JobStepType: { COMMAND: "COMMAND", AI_ACTION: "AI_ACTION", GATE: "GATE", ARTIFACT: "ARTIFACT", }, JobStepStatus: { PENDING: "PENDING", RUNNING: "RUNNING", COMPLETED: "COMPLETED", FAILED: "FAILED", SKIPPED: "SKIPPED", }, }; }); // Import after mocking import { JobStepsService } from "./job-steps.service"; import { PrismaService } from "../prisma/prisma.service"; // Re-import the enums from the mock for use in tests import { JobStepPhase, JobStepType, JobStepStatus } from "@prisma/client"; describe("JobStepsService", () => { let service: JobStepsService; let prisma: PrismaService; const mockPrismaService = { jobStep: { create: vi.fn(), findMany: vi.fn(), findUnique: vi.fn(), update: vi.fn(), }, runnerJob: { findUnique: vi.fn(), }, }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ JobStepsService, { provide: PrismaService, useValue: mockPrismaService, }, ], }).compile(); service = module.get(JobStepsService); prisma = module.get(PrismaService); // Clear all mocks before each test vi.clearAllMocks(); }); it("should be defined", () => { expect(service).toBeDefined(); }); describe("create", () => { it("should create a job step", async () => { const jobId = "job-123"; const createDto: CreateStepDto = { ordinal: 1, phase: JobStepPhase.SETUP, name: "Clone repository", type: JobStepType.COMMAND, }; const mockStep = { id: "step-123", jobId, ordinal: 1, phase: JobStepPhase.SETUP, name: "Clone repository", type: JobStepType.COMMAND, status: JobStepStatus.PENDING, output: null, tokensInput: null, tokensOutput: null, startedAt: null, completedAt: null, durationMs: null, }; mockPrismaService.jobStep.create.mockResolvedValue(mockStep); const result = await service.create(jobId, createDto); expect(result).toEqual(mockStep); expect(prisma.jobStep.create).toHaveBeenCalledWith({ data: { job: { connect: { id: jobId } }, ordinal: 1, phase: JobStepPhase.SETUP, name: "Clone repository", type: JobStepType.COMMAND, status: JobStepStatus.PENDING, }, }); }); it("should use provided status when creating step", async () => { const jobId = "job-123"; const createDto: CreateStepDto = { ordinal: 2, phase: JobStepPhase.EXECUTION, name: "Run tests", type: JobStepType.COMMAND, status: JobStepStatus.RUNNING, }; const mockStep = { id: "step-124", jobId, ordinal: 2, phase: JobStepPhase.EXECUTION, name: "Run tests", type: JobStepType.COMMAND, status: JobStepStatus.RUNNING, output: null, tokensInput: null, tokensOutput: null, startedAt: new Date(), completedAt: null, durationMs: null, }; mockPrismaService.jobStep.create.mockResolvedValue(mockStep); const result = await service.create(jobId, createDto); expect(result).toEqual(mockStep); expect(prisma.jobStep.create).toHaveBeenCalledWith({ data: { job: { connect: { id: jobId } }, ordinal: 2, phase: JobStepPhase.EXECUTION, name: "Run tests", type: JobStepType.COMMAND, status: JobStepStatus.RUNNING, }, }); }); }); describe("findAllByJob", () => { it("should return all steps for a job ordered by ordinal", async () => { const jobId = "job-123"; const mockSteps = [ { id: "step-1", jobId, ordinal: 1, phase: JobStepPhase.SETUP, name: "Clone repo", type: JobStepType.COMMAND, status: JobStepStatus.COMPLETED, output: "Cloned successfully", tokensInput: null, tokensOutput: null, startedAt: new Date("2024-01-01T10:00:00Z"), completedAt: new Date("2024-01-01T10:00:05Z"), durationMs: 5000, }, { id: "step-2", jobId, ordinal: 2, phase: JobStepPhase.EXECUTION, name: "Run tests", type: JobStepType.COMMAND, status: JobStepStatus.RUNNING, output: null, tokensInput: null, tokensOutput: null, startedAt: new Date("2024-01-01T10:00:05Z"), completedAt: null, durationMs: null, }, ]; mockPrismaService.jobStep.findMany.mockResolvedValue(mockSteps); const result = await service.findAllByJob(jobId); expect(result).toEqual(mockSteps); expect(prisma.jobStep.findMany).toHaveBeenCalledWith({ where: { jobId }, orderBy: { ordinal: "asc" }, }); }); }); describe("findOne", () => { it("should return a single step by ID", async () => { const stepId = "step-123"; const jobId = "job-123"; const mockStep = { id: stepId, jobId, ordinal: 1, phase: JobStepPhase.SETUP, name: "Clone repo", type: JobStepType.COMMAND, status: JobStepStatus.COMPLETED, output: "Cloned successfully", tokensInput: null, tokensOutput: null, startedAt: new Date("2024-01-01T10:00:00Z"), completedAt: new Date("2024-01-01T10:00:05Z"), durationMs: 5000, }; mockPrismaService.jobStep.findUnique.mockResolvedValue(mockStep); const result = await service.findOne(stepId, jobId); expect(result).toEqual(mockStep); expect(prisma.jobStep.findUnique).toHaveBeenCalledWith({ where: { id: stepId, jobId }, }); }); it("should throw NotFoundException when step not found", async () => { const stepId = "step-999"; const jobId = "job-123"; mockPrismaService.jobStep.findUnique.mockResolvedValue(null); await expect(service.findOne(stepId, jobId)).rejects.toThrow(NotFoundException); await expect(service.findOne(stepId, jobId)).rejects.toThrow( `JobStep with ID ${stepId} not found` ); }); }); describe("update", () => { it("should update step status", async () => { const stepId = "step-123"; const jobId = "job-123"; const updateDto: UpdateStepDto = { status: JobStepStatus.COMPLETED, }; const existingStep = { id: stepId, jobId, ordinal: 1, phase: JobStepPhase.SETUP, name: "Clone repo", type: JobStepType.COMMAND, status: JobStepStatus.RUNNING, output: null, tokensInput: null, tokensOutput: null, startedAt: new Date("2024-01-01T10:00:00Z"), completedAt: null, durationMs: null, }; const updatedStep = { ...existingStep, status: JobStepStatus.COMPLETED, completedAt: new Date("2024-01-01T10:00:05Z"), durationMs: 5000, }; mockPrismaService.jobStep.findUnique.mockResolvedValue(existingStep); mockPrismaService.jobStep.update.mockResolvedValue(updatedStep); const result = await service.update(stepId, jobId, updateDto); expect(result).toEqual(updatedStep); expect(prisma.jobStep.update).toHaveBeenCalledWith({ where: { id: stepId, jobId }, data: { status: JobStepStatus.COMPLETED }, }); }); it("should update step with output and token usage", async () => { const stepId = "step-123"; const jobId = "job-123"; const updateDto: UpdateStepDto = { status: JobStepStatus.COMPLETED, output: "Analysis complete", tokensInput: 1000, tokensOutput: 500, }; const existingStep = { id: stepId, jobId, ordinal: 2, phase: JobStepPhase.EXECUTION, name: "AI Analysis", type: JobStepType.AI_ACTION, status: JobStepStatus.RUNNING, output: null, tokensInput: null, tokensOutput: null, startedAt: new Date("2024-01-01T10:00:00Z"), completedAt: null, durationMs: null, }; const updatedStep = { ...existingStep, status: JobStepStatus.COMPLETED, output: "Analysis complete", tokensInput: 1000, tokensOutput: 500, completedAt: new Date("2024-01-01T10:00:10Z"), durationMs: 10000, }; mockPrismaService.jobStep.findUnique.mockResolvedValue(existingStep); mockPrismaService.jobStep.update.mockResolvedValue(updatedStep); const result = await service.update(stepId, jobId, updateDto); expect(result).toEqual(updatedStep); expect(prisma.jobStep.update).toHaveBeenCalledWith({ where: { id: stepId, jobId }, data: { status: JobStepStatus.COMPLETED, output: "Analysis complete", tokensInput: 1000, tokensOutput: 500, }, }); }); it("should throw NotFoundException when step not found", async () => { const stepId = "step-999"; const jobId = "job-123"; const updateDto: UpdateStepDto = { status: JobStepStatus.COMPLETED, }; mockPrismaService.jobStep.findUnique.mockResolvedValue(null); await expect(service.update(stepId, jobId, updateDto)).rejects.toThrow(NotFoundException); }); }); describe("startStep", () => { it("should mark step as running and set startedAt", async () => { const stepId = "step-123"; const jobId = "job-123"; const existingStep = { id: stepId, jobId, ordinal: 1, phase: JobStepPhase.SETUP, name: "Clone repo", type: JobStepType.COMMAND, status: JobStepStatus.PENDING, output: null, tokensInput: null, tokensOutput: null, startedAt: null, completedAt: null, durationMs: null, }; const startedStep = { ...existingStep, status: JobStepStatus.RUNNING, startedAt: new Date("2024-01-01T10:00:00Z"), }; mockPrismaService.jobStep.findUnique.mockResolvedValue(existingStep); mockPrismaService.jobStep.update.mockResolvedValue(startedStep); const result = await service.startStep(stepId, jobId); expect(result).toEqual(startedStep); expect(prisma.jobStep.update).toHaveBeenCalledWith({ where: { id: stepId, jobId }, data: { status: JobStepStatus.RUNNING, startedAt: expect.any(Date), }, }); }); }); describe("completeStep", () => { it("should mark step as completed and calculate duration", async () => { const stepId = "step-123"; const jobId = "job-123"; const startTime = new Date("2024-01-01T10:00:00Z"); const existingStep = { id: stepId, jobId, ordinal: 1, phase: JobStepPhase.SETUP, name: "Clone repo", type: JobStepType.COMMAND, status: JobStepStatus.RUNNING, output: null, tokensInput: null, tokensOutput: null, startedAt: startTime, completedAt: null, durationMs: null, }; const completedStep = { ...existingStep, status: JobStepStatus.COMPLETED, output: "Success", completedAt: new Date("2024-01-01T10:00:05Z"), durationMs: 5000, }; mockPrismaService.jobStep.findUnique.mockResolvedValue(existingStep); mockPrismaService.jobStep.update.mockResolvedValue(completedStep); const result = await service.completeStep(stepId, jobId, "Success"); expect(result).toEqual(completedStep); expect(prisma.jobStep.update).toHaveBeenCalledWith({ where: { id: stepId, jobId }, data: { status: JobStepStatus.COMPLETED, output: "Success", completedAt: expect.any(Date), durationMs: expect.any(Number), }, }); }); it("should handle step without startedAt by setting durationMs to null", async () => { const stepId = "step-123"; const jobId = "job-123"; const existingStep = { id: stepId, jobId, ordinal: 1, phase: JobStepPhase.SETUP, name: "Clone repo", type: JobStepType.COMMAND, status: JobStepStatus.PENDING, output: null, tokensInput: null, tokensOutput: null, startedAt: null, completedAt: null, durationMs: null, }; const completedStep = { ...existingStep, status: JobStepStatus.COMPLETED, output: "Success", completedAt: new Date("2024-01-01T10:00:05Z"), durationMs: null, }; mockPrismaService.jobStep.findUnique.mockResolvedValue(existingStep); mockPrismaService.jobStep.update.mockResolvedValue(completedStep); const result = await service.completeStep(stepId, jobId, "Success"); expect(result.durationMs).toBeNull(); }); }); describe("failStep", () => { it("should mark step as failed with error output", async () => { const stepId = "step-123"; const jobId = "job-123"; const error = "Command failed with exit code 1"; const startTime = new Date("2024-01-01T10:00:00Z"); const existingStep = { id: stepId, jobId, ordinal: 1, phase: JobStepPhase.VALIDATION, name: "Run tests", type: JobStepType.GATE, status: JobStepStatus.RUNNING, output: null, tokensInput: null, tokensOutput: null, startedAt: startTime, completedAt: null, durationMs: null, }; const failedStep = { ...existingStep, status: JobStepStatus.FAILED, output: error, completedAt: new Date("2024-01-01T10:00:03Z"), durationMs: 3000, }; mockPrismaService.jobStep.findUnique.mockResolvedValue(existingStep); mockPrismaService.jobStep.update.mockResolvedValue(failedStep); const result = await service.failStep(stepId, jobId, error); expect(result).toEqual(failedStep); expect(prisma.jobStep.update).toHaveBeenCalledWith({ where: { id: stepId, jobId }, data: { status: JobStepStatus.FAILED, output: error, completedAt: expect.any(Date), durationMs: expect.any(Number), }, }); }); }); });