Files
stack/apps/api/src/job-events/job-events.service.ts
T
jason.woltjeandClaude Opus 4.5 3cdcbf6774 feat(#175): Implement E2E test harness
- Create comprehensive E2E test suite for job orchestration
- Add test fixtures for Discord, BullMQ, and Prisma mocks
- Implement 9 end-to-end test scenarios covering:
  * Happy path: webhook → job → step execution → completion
  * Event emission throughout job lifecycle
  * Step failure and retry handling
  * Job failure after max retries
  * Discord command parsing and job creation
  * WebSocket status updates integration
  * Job cancellation workflow
  * Job retry mechanism
  * Progress percentage tracking

- Add helper methods to services for simplified testing:
  * JobStepsService: start(), complete(), fail(), findByJob()
  * RunnerJobsService: updateStatus(), updateProgress()
  * JobEventsService: findByJob()

- Configure vitest.e2e.config.ts for E2E test execution
- All 9 E2E tests passing
- All 1405 unit tests passing
- Quality gates: typecheck, lint, build all passing

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-01 21:44:04 -06:00

221 lines
5.3 KiB
TypeScript

import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { CreateEventDto, QueryEventsDto } from "./dto";
import {
JOB_CREATED,
JOB_STARTED,
JOB_COMPLETED,
JOB_FAILED,
STEP_STARTED,
STEP_COMPLETED,
AI_TOKENS_USED,
} from "./event-types";
/**
* Service for managing job events
* Events are immutable once created and provide an audit log of all job activities
*/
@Injectable()
export class JobEventsService {
constructor(private readonly prisma: PrismaService) {}
/**
* Emit a job event
* Events are stored immutably in PostgreSQL
*/
async emitEvent(jobId: string, createEventDto: CreateEventDto) {
// Verify job exists
const job = await this.prisma.runnerJob.findUnique({
where: { id: jobId },
select: { id: true },
});
if (!job) {
throw new NotFoundException(`RunnerJob with ID ${jobId} not found`);
}
// Verify step exists if stepId is provided
if (createEventDto.stepId) {
const step = await this.prisma.jobStep.findUnique({
where: { id: createEventDto.stepId },
select: { id: true },
});
if (!step) {
throw new NotFoundException(`JobStep with ID ${createEventDto.stepId} not found`);
}
}
// Build event data
const data: Prisma.JobEventCreateInput = {
job: { connect: { id: jobId } },
type: createEventDto.type,
timestamp: new Date(),
actor: createEventDto.actor,
payload: createEventDto.payload as unknown as Prisma.InputJsonValue,
};
// Add step connection if provided
if (createEventDto.stepId) {
data.step = { connect: { id: createEventDto.stepId } };
}
// Create and return the event
return this.prisma.jobEvent.create({ data });
}
/**
* Get events for a specific job with optional filtering
*/
async getEventsByJobId(jobId: string, query: QueryEventsDto) {
// Verify job exists
const job = await this.prisma.runnerJob.findUnique({
where: { id: jobId },
select: { id: true },
});
if (!job) {
throw new NotFoundException(`RunnerJob with ID ${jobId} not found`);
}
const page = query.page ?? 1;
const limit = query.limit ?? 50;
const skip = (page - 1) * limit;
// Build where clause
const where: Prisma.JobEventWhereInput = { jobId };
if (query.type) {
where.type = query.type;
}
if (query.stepId) {
where.stepId = query.stepId;
}
// Execute queries in parallel
const [data, total] = await Promise.all([
this.prisma.jobEvent.findMany({
where,
orderBy: { timestamp: "asc" },
skip,
take: limit,
}),
this.prisma.jobEvent.count({ where }),
]);
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Convenience method: Emit job.created event
*/
async emitJobCreated(jobId: string, payload: Record<string, unknown> = {}) {
return this.emitEvent(jobId, {
type: JOB_CREATED,
actor: "system",
payload,
});
}
/**
* Convenience method: Emit job.started event
*/
async emitJobStarted(jobId: string, payload: Record<string, unknown> = {}) {
return this.emitEvent(jobId, {
type: JOB_STARTED,
actor: "system",
payload,
});
}
/**
* Convenience method: Emit job.completed event
*/
async emitJobCompleted(jobId: string, payload: Record<string, unknown> = {}) {
return this.emitEvent(jobId, {
type: JOB_COMPLETED,
actor: "system",
payload,
});
}
/**
* Convenience method: Emit job.failed event
*/
async emitJobFailed(jobId: string, payload: Record<string, unknown> = {}) {
return this.emitEvent(jobId, {
type: JOB_FAILED,
actor: "system",
payload,
});
}
/**
* Convenience method: Emit step.started event
*/
async emitStepStarted(jobId: string, stepId: string, payload: Record<string, unknown> = {}) {
return this.emitEvent(jobId, {
type: STEP_STARTED,
actor: "system",
payload,
stepId,
});
}
/**
* Convenience method: Emit step.completed event
*/
async emitStepCompleted(jobId: string, stepId: string, payload: Record<string, unknown> = {}) {
return this.emitEvent(jobId, {
type: STEP_COMPLETED,
actor: "system",
payload,
stepId,
});
}
/**
* Convenience method: Emit ai.tokens_used event
*/
async emitAiTokensUsed(jobId: string, payload: Record<string, unknown> = {}) {
return this.emitEvent(jobId, {
type: AI_TOKENS_USED,
actor: "system",
payload,
});
}
/**
* Get all events for a job (no pagination)
* Alias for getEventsByJobId without pagination
*/
async findByJob(
jobId: string
): Promise<Awaited<ReturnType<typeof this.prisma.jobEvent.findMany>>> {
// Verify job exists
const job = await this.prisma.runnerJob.findUnique({
where: { id: jobId },
select: { id: true },
});
if (!job) {
throw new NotFoundException(`RunnerJob with ID ${jobId} not found`);
}
return this.prisma.jobEvent.findMany({
where: { jobId },
orderBy: { timestamp: "asc" },
});
}
}