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:
2026-02-01 21:44:04 -06:00
parent d3058cb3de
commit 3cdcbf6774
9 changed files with 1089 additions and 0 deletions

3
apps/api/test/fixtures/index.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
export * from "./mock-discord.fixture";
export * from "./mock-bullmq.fixture";
export * from "./mock-prisma.fixture";

View 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);
}),
};
}

View 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,
};
}

View 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,
};
}