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 <noreply@anthropic.com>
This commit is contained in:
3
apps/api/test/fixtures/index.ts
vendored
Normal file
3
apps/api/test/fixtures/index.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./mock-discord.fixture";
|
||||
export * from "./mock-bullmq.fixture";
|
||||
export * from "./mock-prisma.fixture";
|
||||
83
apps/api/test/fixtures/mock-bullmq.fixture.ts
vendored
Normal file
83
apps/api/test/fixtures/mock-bullmq.fixture.ts
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
import { vi } from "vitest";
|
||||
import type { Queue, Job } from "bullmq";
|
||||
|
||||
/**
|
||||
* Mock BullMQ job for testing
|
||||
*/
|
||||
export function createMockBullMqJob(overrides?: Partial<Job>): Partial<Job> {
|
||||
return {
|
||||
id: "mock-bull-job-id",
|
||||
name: "runner-job",
|
||||
data: {
|
||||
jobId: "mock-job-id",
|
||||
workspaceId: "mock-workspace-id",
|
||||
type: "code-task",
|
||||
},
|
||||
progress: vi.fn().mockReturnValue(0),
|
||||
updateProgress: vi.fn().mockResolvedValue(undefined),
|
||||
log: vi.fn().mockResolvedValue(undefined),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock BullMQ queue for testing
|
||||
*/
|
||||
export function createMockBullMqQueue(): Partial<Queue> {
|
||||
const jobs = new Map<string, Partial<Job>>();
|
||||
|
||||
return {
|
||||
add: vi.fn().mockImplementation((name: string, data: unknown) => {
|
||||
const job = createMockBullMqJob({
|
||||
id: `job-${Date.now()}`,
|
||||
name,
|
||||
data: data as never,
|
||||
});
|
||||
jobs.set(job.id as string, job);
|
||||
return Promise.resolve(job);
|
||||
}),
|
||||
getJob: vi.fn().mockImplementation((jobId: string) => {
|
||||
return Promise.resolve(jobs.get(jobId) || null);
|
||||
}),
|
||||
getJobs: vi.fn().mockResolvedValue([]),
|
||||
pause: vi.fn().mockResolvedValue(undefined),
|
||||
resume: vi.fn().mockResolvedValue(undefined),
|
||||
clean: vi.fn().mockResolvedValue([]),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock BullMQ service for testing
|
||||
*/
|
||||
export function createMockBullMqService() {
|
||||
const queues = new Map<string, Partial<Queue>>();
|
||||
|
||||
return {
|
||||
addJob: vi
|
||||
.fn()
|
||||
.mockImplementation((queueName: string, jobName: string, data: unknown, opts?: unknown) => {
|
||||
let queue = queues.get(queueName);
|
||||
if (!queue) {
|
||||
queue = createMockBullMqQueue();
|
||||
queues.set(queueName, queue);
|
||||
}
|
||||
return queue.add?.(jobName, data, opts as never);
|
||||
}),
|
||||
getQueue: vi.fn().mockImplementation((queueName: string) => {
|
||||
let queue = queues.get(queueName);
|
||||
if (!queue) {
|
||||
queue = createMockBullMqQueue();
|
||||
queues.set(queueName, queue);
|
||||
}
|
||||
return queue;
|
||||
}),
|
||||
getJob: vi.fn().mockImplementation((queueName: string, jobId: string) => {
|
||||
const queue = queues.get(queueName);
|
||||
return queue?.getJob?.(jobId);
|
||||
}),
|
||||
};
|
||||
}
|
||||
72
apps/api/test/fixtures/mock-discord.fixture.ts
vendored
Normal file
72
apps/api/test/fixtures/mock-discord.fixture.ts
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
import { vi } from "vitest";
|
||||
import type { Client, Message, TextChannel } from "discord.js";
|
||||
|
||||
/**
|
||||
* Mock Discord client for testing
|
||||
*/
|
||||
export function createMockDiscordClient(): Partial<Client> {
|
||||
const mockChannel: Partial<TextChannel> = {
|
||||
send: vi.fn().mockResolvedValue({
|
||||
id: "mock-message-id",
|
||||
content: "Mock message sent",
|
||||
}),
|
||||
id: "mock-channel-id",
|
||||
name: "test-channel",
|
||||
};
|
||||
|
||||
return {
|
||||
channels: {
|
||||
fetch: vi.fn().mockResolvedValue(mockChannel),
|
||||
cache: {
|
||||
get: vi.fn().mockReturnValue(mockChannel),
|
||||
},
|
||||
} as never,
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
login: vi.fn().mockResolvedValue("mock-token"),
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Discord message for testing command parsing
|
||||
*/
|
||||
export function createMockDiscordMessage(
|
||||
content: string,
|
||||
overrides?: Partial<Message>
|
||||
): Partial<Message> {
|
||||
return {
|
||||
content,
|
||||
author: {
|
||||
id: "mock-user-id",
|
||||
username: "test-user",
|
||||
bot: false,
|
||||
discriminator: "0001",
|
||||
avatar: null,
|
||||
tag: "test-user#0001",
|
||||
} as never,
|
||||
channel: {
|
||||
id: "mock-channel-id",
|
||||
type: 0, // GuildText
|
||||
send: vi.fn().mockResolvedValue({
|
||||
id: "response-message-id",
|
||||
content: "Response sent",
|
||||
}),
|
||||
} as never,
|
||||
guild: {
|
||||
id: "mock-guild-id",
|
||||
name: "Test Guild",
|
||||
} as never,
|
||||
createdTimestamp: Date.now(),
|
||||
id: "mock-message-id",
|
||||
mentions: {
|
||||
has: vi.fn().mockReturnValue(false),
|
||||
users: new Map(),
|
||||
} as never,
|
||||
reply: vi.fn().mockResolvedValue({
|
||||
id: "reply-message-id",
|
||||
content: "Reply sent",
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
235
apps/api/test/fixtures/mock-prisma.fixture.ts
vendored
Normal file
235
apps/api/test/fixtures/mock-prisma.fixture.ts
vendored
Normal file
@@ -0,0 +1,235 @@
|
||||
import { vi } from "vitest";
|
||||
import { RunnerJobStatus, JobStepStatus, JobStepPhase, JobStepType } from "@prisma/client";
|
||||
import type { PrismaService } from "../../src/prisma/prisma.service";
|
||||
|
||||
/**
|
||||
* Create a mock RunnerJob
|
||||
*/
|
||||
export function createMockRunnerJob(
|
||||
overrides?: Partial<{
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
type: string;
|
||||
status: RunnerJobStatus;
|
||||
priority: number;
|
||||
progressPercent: number;
|
||||
result: unknown;
|
||||
error: string | null;
|
||||
createdAt: Date;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
agentTaskId: string | null;
|
||||
}>
|
||||
) {
|
||||
return {
|
||||
id: "job-123",
|
||||
workspaceId: "workspace-123",
|
||||
type: "code-task",
|
||||
status: RunnerJobStatus.PENDING,
|
||||
priority: 10,
|
||||
progressPercent: 0,
|
||||
result: null,
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
agentTaskId: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock JobStep
|
||||
*/
|
||||
export function createMockJobStep(
|
||||
overrides?: Partial<{
|
||||
id: string;
|
||||
jobId: string;
|
||||
ordinal: number;
|
||||
phase: JobStepPhase;
|
||||
name: string;
|
||||
type: JobStepType;
|
||||
status: JobStepStatus;
|
||||
output: string | null;
|
||||
tokensInput: number | null;
|
||||
tokensOutput: number | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
durationMs: number | null;
|
||||
}>
|
||||
) {
|
||||
return {
|
||||
id: "step-123",
|
||||
jobId: "job-123",
|
||||
ordinal: 1,
|
||||
phase: JobStepPhase.VALIDATION,
|
||||
name: "Validate requirements",
|
||||
type: JobStepType.TOOL,
|
||||
status: JobStepStatus.PENDING,
|
||||
output: null,
|
||||
tokensInput: null,
|
||||
tokensOutput: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock JobEvent
|
||||
*/
|
||||
export function createMockJobEvent(
|
||||
overrides?: Partial<{
|
||||
id: string;
|
||||
jobId: string;
|
||||
stepId: string | null;
|
||||
type: string;
|
||||
timestamp: Date;
|
||||
actor: string;
|
||||
payload: unknown;
|
||||
}>
|
||||
) {
|
||||
return {
|
||||
id: "event-123",
|
||||
jobId: "job-123",
|
||||
stepId: null,
|
||||
type: "job.created",
|
||||
timestamp: new Date(),
|
||||
actor: "stitcher",
|
||||
payload: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock Prisma service with commonly used methods
|
||||
*/
|
||||
export function createMockPrismaService(): Partial<PrismaService> {
|
||||
const jobs = new Map<string, ReturnType<typeof createMockRunnerJob>>();
|
||||
const steps = new Map<string, ReturnType<typeof createMockJobStep>>();
|
||||
const events: ReturnType<typeof createMockJobEvent>[] = [];
|
||||
|
||||
return {
|
||||
runnerJob: {
|
||||
create: vi.fn().mockImplementation(({ data }) => {
|
||||
// Use a counter to ensure unique IDs even if called in quick succession
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = Math.floor(Math.random() * 1000);
|
||||
const job = createMockRunnerJob({
|
||||
id: `job-${timestamp}-${randomSuffix}`,
|
||||
workspaceId: data.workspaceId || data.workspace?.connect?.id,
|
||||
type: data.type,
|
||||
status: data.status,
|
||||
priority: data.priority,
|
||||
progressPercent: data.progressPercent,
|
||||
});
|
||||
jobs.set(job.id, job);
|
||||
return Promise.resolve(job);
|
||||
}),
|
||||
findUnique: vi.fn().mockImplementation(({ where, include }) => {
|
||||
const job = jobs.get(where.id);
|
||||
if (!job) return Promise.resolve(null);
|
||||
|
||||
const result = { ...job };
|
||||
if (include?.steps) {
|
||||
(result as never)["steps"] = Array.from(steps.values()).filter((s) => s.jobId === job.id);
|
||||
}
|
||||
if (include?.events) {
|
||||
(result as never)["events"] = events.filter((e) => e.jobId === job.id);
|
||||
}
|
||||
return Promise.resolve(result);
|
||||
}),
|
||||
findMany: vi.fn().mockImplementation(({ where }) => {
|
||||
const allJobs = Array.from(jobs.values());
|
||||
if (!where) return Promise.resolve(allJobs);
|
||||
|
||||
return Promise.resolve(
|
||||
allJobs.filter((job) => {
|
||||
if (where.workspaceId && job.workspaceId !== where.workspaceId) return false;
|
||||
if (where.status && job.status !== where.status) return false;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
}),
|
||||
update: vi.fn().mockImplementation(({ where, data }) => {
|
||||
const job = jobs.get(where.id);
|
||||
if (!job) return Promise.resolve(null);
|
||||
|
||||
const updated = { ...job, ...data };
|
||||
jobs.set(job.id, updated);
|
||||
return Promise.resolve(updated);
|
||||
}),
|
||||
count: vi.fn().mockImplementation(() => Promise.resolve(jobs.size)),
|
||||
} as never,
|
||||
jobStep: {
|
||||
create: vi.fn().mockImplementation(({ data }) => {
|
||||
const step = createMockJobStep({
|
||||
id: `step-${Date.now()}`,
|
||||
jobId: data.jobId || data.job?.connect?.id,
|
||||
ordinal: data.ordinal,
|
||||
phase: data.phase,
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
status: data.status,
|
||||
});
|
||||
steps.set(step.id, step);
|
||||
return Promise.resolve(step);
|
||||
}),
|
||||
findUnique: vi.fn().mockImplementation(({ where }) => {
|
||||
const step = steps.get(where.id);
|
||||
return Promise.resolve(step || null);
|
||||
}),
|
||||
findMany: vi.fn().mockImplementation(({ where }) => {
|
||||
const allSteps = Array.from(steps.values());
|
||||
if (!where) return Promise.resolve(allSteps);
|
||||
|
||||
return Promise.resolve(allSteps.filter((step) => step.jobId === where.jobId));
|
||||
}),
|
||||
update: vi.fn().mockImplementation(({ where, data }) => {
|
||||
const step = steps.get(where.id);
|
||||
if (!step) return Promise.resolve(null);
|
||||
|
||||
const updated = { ...step, ...data };
|
||||
steps.set(step.id, updated);
|
||||
return Promise.resolve(updated);
|
||||
}),
|
||||
} as never,
|
||||
jobEvent: {
|
||||
create: vi.fn().mockImplementation(({ data }) => {
|
||||
const event = createMockJobEvent({
|
||||
id: `event-${Date.now()}`,
|
||||
jobId: data.jobId || data.job?.connect?.id,
|
||||
stepId: data.stepId || data.step?.connect?.id || null,
|
||||
type: data.type,
|
||||
timestamp: data.timestamp || new Date(),
|
||||
actor: data.actor,
|
||||
payload: data.payload,
|
||||
});
|
||||
events.push(event);
|
||||
return Promise.resolve(event);
|
||||
}),
|
||||
findMany: vi.fn().mockImplementation(({ where, orderBy }) => {
|
||||
let filtered = events;
|
||||
if (where?.jobId) {
|
||||
filtered = filtered.filter((e) => e.jobId === where.jobId);
|
||||
}
|
||||
if (orderBy?.timestamp) {
|
||||
filtered = [...filtered].sort((a, b) =>
|
||||
orderBy.timestamp === "asc"
|
||||
? a.timestamp.getTime() - b.timestamp.getTime()
|
||||
: b.timestamp.getTime() - a.timestamp.getTime()
|
||||
);
|
||||
}
|
||||
return Promise.resolve(filtered);
|
||||
}),
|
||||
} as never,
|
||||
workspace: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "workspace-123",
|
||||
slug: "test-workspace",
|
||||
name: "Test Workspace",
|
||||
}),
|
||||
} as never,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user