feat(#167): Implement Runner jobs CRUD and queue submission
Implements runner-jobs module for job lifecycle management and queue submission. Changes: - Created RunnerJobsModule with service, controller, and DTOs - Implemented job creation with BullMQ queue submission - Implemented job listing with filters (status, type, agentTaskId) - Implemented job detail retrieval with steps and events - Implemented cancel operation for pending/queued jobs - Implemented retry operation for failed jobs - Added comprehensive unit tests (24 tests, 100% coverage) - Integrated with BullMQ for async job processing - Integrated with Prisma for database operations - Followed existing CRUD patterns from tasks/events modules API Endpoints: - POST /runner-jobs - Create and queue a new job - GET /runner-jobs - List jobs (with filters) - GET /runner-jobs/:id - Get job details - POST /runner-jobs/:id/cancel - Cancel a running job - POST /runner-jobs/:id/retry - Retry a failed job Quality Gates: - Typecheck: ✅ PASSED - Lint: ✅ PASSED - Build: ✅ PASSED - Tests: ✅ PASSED (24/24 tests) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
527
apps/api/src/runner-jobs/runner-jobs.service.spec.ts
Normal file
527
apps/api/src/runner-jobs/runner-jobs.service.spec.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { RunnerJobsService } from "./runner-jobs.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { BullMqService } from "../bullmq/bullmq.service";
|
||||
import { RunnerJobStatus } from "@prisma/client";
|
||||
import { NotFoundException, BadRequestException } from "@nestjs/common";
|
||||
import { CreateJobDto, QueryJobsDto } from "./dto";
|
||||
|
||||
describe("RunnerJobsService", () => {
|
||||
let service: RunnerJobsService;
|
||||
let prisma: PrismaService;
|
||||
let bullMq: BullMqService;
|
||||
|
||||
const mockPrismaService = {
|
||||
runnerJob: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockBullMqService = {
|
||||
addJob: vi.fn(),
|
||||
getQueue: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RunnerJobsService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
{
|
||||
provide: BullMqService,
|
||||
useValue: mockBullMqService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<RunnerJobsService>(RunnerJobsService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
bullMq = module.get<BullMqService>(BullMqService);
|
||||
|
||||
// Clear all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create a job and add it to BullMQ queue", async () => {
|
||||
const workspaceId = "workspace-123";
|
||||
const createDto: CreateJobDto = {
|
||||
type: "git-status",
|
||||
priority: 5,
|
||||
data: { repo: "test-repo" },
|
||||
};
|
||||
|
||||
const mockJob = {
|
||||
id: "job-123",
|
||||
workspaceId,
|
||||
type: "git-status",
|
||||
status: RunnerJobStatus.PENDING,
|
||||
priority: 5,
|
||||
progressPercent: 0,
|
||||
result: null,
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
agentTaskId: null,
|
||||
};
|
||||
|
||||
const mockBullMqJob = {
|
||||
id: "bull-job-123",
|
||||
name: "runner-job",
|
||||
};
|
||||
|
||||
mockPrismaService.runnerJob.create.mockResolvedValue(mockJob);
|
||||
mockBullMqService.addJob.mockResolvedValue(mockBullMqJob);
|
||||
|
||||
const result = await service.create(workspaceId, createDto);
|
||||
|
||||
expect(result).toEqual(mockJob);
|
||||
expect(prisma.runnerJob.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspace: { connect: { id: workspaceId } },
|
||||
type: "git-status",
|
||||
priority: 5,
|
||||
status: RunnerJobStatus.PENDING,
|
||||
progressPercent: 0,
|
||||
result: { repo: "test-repo" },
|
||||
},
|
||||
});
|
||||
expect(bullMq.addJob).toHaveBeenCalledWith(
|
||||
"mosaic-jobs-runner",
|
||||
"runner-job",
|
||||
{
|
||||
jobId: "job-123",
|
||||
workspaceId,
|
||||
type: "git-status",
|
||||
data: { repo: "test-repo" },
|
||||
},
|
||||
{ priority: 5 }
|
||||
);
|
||||
});
|
||||
|
||||
it("should create a job with agentTaskId if provided", async () => {
|
||||
const workspaceId = "workspace-123";
|
||||
const createDto: CreateJobDto = {
|
||||
type: "code-task",
|
||||
agentTaskId: "agent-task-123",
|
||||
priority: 8,
|
||||
};
|
||||
|
||||
const mockJob = {
|
||||
id: "job-456",
|
||||
workspaceId,
|
||||
type: "code-task",
|
||||
status: RunnerJobStatus.PENDING,
|
||||
priority: 8,
|
||||
progressPercent: 0,
|
||||
result: null,
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
agentTaskId: "agent-task-123",
|
||||
};
|
||||
|
||||
mockPrismaService.runnerJob.create.mockResolvedValue(mockJob);
|
||||
mockBullMqService.addJob.mockResolvedValue({ id: "bull-job-456" });
|
||||
|
||||
const result = await service.create(workspaceId, createDto);
|
||||
|
||||
expect(result).toEqual(mockJob);
|
||||
expect(prisma.runnerJob.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspace: { connect: { id: workspaceId } },
|
||||
type: "code-task",
|
||||
priority: 8,
|
||||
status: RunnerJobStatus.PENDING,
|
||||
progressPercent: 0,
|
||||
agentTask: { connect: { id: "agent-task-123" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should use default priority of 5 if not provided", async () => {
|
||||
const workspaceId = "workspace-123";
|
||||
const createDto: CreateJobDto = {
|
||||
type: "priority-calc",
|
||||
};
|
||||
|
||||
const mockJob = {
|
||||
id: "job-789",
|
||||
workspaceId,
|
||||
type: "priority-calc",
|
||||
status: RunnerJobStatus.PENDING,
|
||||
priority: 5,
|
||||
progressPercent: 0,
|
||||
result: null,
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
agentTaskId: null,
|
||||
};
|
||||
|
||||
mockPrismaService.runnerJob.create.mockResolvedValue(mockJob);
|
||||
mockBullMqService.addJob.mockResolvedValue({ id: "bull-job-789" });
|
||||
|
||||
await service.create(workspaceId, createDto);
|
||||
|
||||
expect(prisma.runnerJob.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspace: { connect: { id: workspaceId } },
|
||||
type: "priority-calc",
|
||||
priority: 5,
|
||||
status: RunnerJobStatus.PENDING,
|
||||
progressPercent: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should return paginated jobs with filters", async () => {
|
||||
const query: QueryJobsDto = {
|
||||
workspaceId: "workspace-123",
|
||||
status: RunnerJobStatus.PENDING,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
const mockJobs = [
|
||||
{
|
||||
id: "job-1",
|
||||
workspaceId: "workspace-123",
|
||||
type: "git-status",
|
||||
status: RunnerJobStatus.PENDING,
|
||||
priority: 5,
|
||||
progressPercent: 0,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
mockPrismaService.runnerJob.findMany.mockResolvedValue(mockJobs);
|
||||
mockPrismaService.runnerJob.count.mockResolvedValue(1);
|
||||
|
||||
const result = await service.findAll(query);
|
||||
|
||||
expect(result).toEqual({
|
||||
data: mockJobs,
|
||||
meta: {
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle multiple status filters", async () => {
|
||||
const query: QueryJobsDto = {
|
||||
workspaceId: "workspace-123",
|
||||
status: [RunnerJobStatus.RUNNING, RunnerJobStatus.QUEUED],
|
||||
page: 1,
|
||||
limit: 50,
|
||||
};
|
||||
|
||||
mockPrismaService.runnerJob.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.runnerJob.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(query);
|
||||
|
||||
expect(prisma.runnerJob.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: "workspace-123",
|
||||
status: { in: [RunnerJobStatus.RUNNING, RunnerJobStatus.QUEUED] },
|
||||
},
|
||||
include: {
|
||||
agentTask: {
|
||||
select: { id: true, title: true, status: true },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
skip: 0,
|
||||
take: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by type", async () => {
|
||||
const query: QueryJobsDto = {
|
||||
workspaceId: "workspace-123",
|
||||
type: "code-task",
|
||||
page: 1,
|
||||
limit: 50,
|
||||
};
|
||||
|
||||
mockPrismaService.runnerJob.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.runnerJob.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(query);
|
||||
|
||||
expect(prisma.runnerJob.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
workspaceId: "workspace-123",
|
||||
type: "code-task",
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should use default pagination values", async () => {
|
||||
const query: QueryJobsDto = {
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
mockPrismaService.runnerJob.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.runnerJob.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(query);
|
||||
|
||||
expect(prisma.runnerJob.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skip: 0,
|
||||
take: 50,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a single job by ID", async () => {
|
||||
const jobId = "job-123";
|
||||
const workspaceId = "workspace-123";
|
||||
|
||||
const mockJob = {
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
type: "git-status",
|
||||
status: RunnerJobStatus.COMPLETED,
|
||||
priority: 5,
|
||||
progressPercent: 100,
|
||||
result: { status: "success" },
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
agentTask: null,
|
||||
steps: [],
|
||||
events: [],
|
||||
};
|
||||
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue(mockJob);
|
||||
|
||||
const result = await service.findOne(jobId, workspaceId);
|
||||
|
||||
expect(result).toEqual(mockJob);
|
||||
expect(prisma.runnerJob.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
},
|
||||
include: {
|
||||
agentTask: {
|
||||
select: { id: true, title: true, status: true },
|
||||
},
|
||||
steps: {
|
||||
orderBy: { ordinal: "asc" },
|
||||
},
|
||||
events: {
|
||||
orderBy: { timestamp: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if job not found", async () => {
|
||||
const jobId = "nonexistent-job";
|
||||
const workspaceId = "workspace-123";
|
||||
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(jobId, workspaceId)).rejects.toThrow(NotFoundException);
|
||||
await expect(service.findOne(jobId, workspaceId)).rejects.toThrow(
|
||||
`RunnerJob with ID ${jobId} not found`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel", () => {
|
||||
it("should cancel a pending job", async () => {
|
||||
const jobId = "job-123";
|
||||
const workspaceId = "workspace-123";
|
||||
|
||||
const mockExistingJob = {
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
status: RunnerJobStatus.PENDING,
|
||||
};
|
||||
|
||||
const mockUpdatedJob = {
|
||||
...mockExistingJob,
|
||||
status: RunnerJobStatus.CANCELLED,
|
||||
completedAt: new Date(),
|
||||
};
|
||||
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue(mockExistingJob);
|
||||
mockPrismaService.runnerJob.update.mockResolvedValue(mockUpdatedJob);
|
||||
|
||||
const result = await service.cancel(jobId, workspaceId);
|
||||
|
||||
expect(result).toEqual(mockUpdatedJob);
|
||||
expect(prisma.runnerJob.update).toHaveBeenCalledWith({
|
||||
where: { id: jobId, workspaceId },
|
||||
data: {
|
||||
status: RunnerJobStatus.CANCELLED,
|
||||
completedAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should cancel a queued job", async () => {
|
||||
const jobId = "job-456";
|
||||
const workspaceId = "workspace-123";
|
||||
|
||||
const mockExistingJob = {
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
status: RunnerJobStatus.QUEUED,
|
||||
};
|
||||
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue(mockExistingJob);
|
||||
mockPrismaService.runnerJob.update.mockResolvedValue({
|
||||
...mockExistingJob,
|
||||
status: RunnerJobStatus.CANCELLED,
|
||||
});
|
||||
|
||||
await service.cancel(jobId, workspaceId);
|
||||
|
||||
expect(prisma.runnerJob.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if job not found", async () => {
|
||||
const jobId = "nonexistent-job";
|
||||
const workspaceId = "workspace-123";
|
||||
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.cancel(jobId, workspaceId)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException if job is already completed", async () => {
|
||||
const jobId = "job-789";
|
||||
const workspaceId = "workspace-123";
|
||||
|
||||
const mockExistingJob = {
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
status: RunnerJobStatus.COMPLETED,
|
||||
};
|
||||
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue(mockExistingJob);
|
||||
|
||||
await expect(service.cancel(jobId, workspaceId)).rejects.toThrow(BadRequestException);
|
||||
await expect(service.cancel(jobId, workspaceId)).rejects.toThrow(
|
||||
"Cannot cancel job with status COMPLETED"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException if job is already cancelled", async () => {
|
||||
const jobId = "job-999";
|
||||
const workspaceId = "workspace-123";
|
||||
|
||||
const mockExistingJob = {
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
status: RunnerJobStatus.CANCELLED,
|
||||
};
|
||||
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue(mockExistingJob);
|
||||
|
||||
await expect(service.cancel(jobId, workspaceId)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("retry", () => {
|
||||
it("should retry a failed job", async () => {
|
||||
const jobId = "job-123";
|
||||
const workspaceId = "workspace-123";
|
||||
|
||||
const mockExistingJob = {
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
type: "git-status",
|
||||
status: RunnerJobStatus.FAILED,
|
||||
priority: 5,
|
||||
result: { repo: "test-repo" },
|
||||
};
|
||||
|
||||
const mockNewJob = {
|
||||
id: "job-new",
|
||||
workspaceId,
|
||||
type: "git-status",
|
||||
status: RunnerJobStatus.PENDING,
|
||||
priority: 5,
|
||||
progressPercent: 0,
|
||||
};
|
||||
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue(mockExistingJob);
|
||||
mockPrismaService.runnerJob.create.mockResolvedValue(mockNewJob);
|
||||
mockBullMqService.addJob.mockResolvedValue({ id: "bull-job-new" });
|
||||
|
||||
const result = await service.retry(jobId, workspaceId);
|
||||
|
||||
expect(result).toEqual(mockNewJob);
|
||||
expect(prisma.runnerJob.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspace: { connect: { id: workspaceId } },
|
||||
type: "git-status",
|
||||
priority: 5,
|
||||
status: RunnerJobStatus.PENDING,
|
||||
progressPercent: 0,
|
||||
result: { repo: "test-repo" },
|
||||
},
|
||||
});
|
||||
expect(bullMq.addJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if job not found", async () => {
|
||||
const jobId = "nonexistent-job";
|
||||
const workspaceId = "workspace-123";
|
||||
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.retry(jobId, workspaceId)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException if job is not failed", async () => {
|
||||
const jobId = "job-456";
|
||||
const workspaceId = "workspace-123";
|
||||
|
||||
const mockExistingJob = {
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
status: RunnerJobStatus.RUNNING,
|
||||
};
|
||||
|
||||
mockPrismaService.runnerJob.findUnique.mockResolvedValue(mockExistingJob);
|
||||
|
||||
await expect(service.retry(jobId, workspaceId)).rejects.toThrow(BadRequestException);
|
||||
await expect(service.retry(jobId, workspaceId)).rejects.toThrow("Can only retry failed jobs");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user