Files
stack/apps/api/src/rejection-handler/rejection-handler.service.spec.ts
Jason Woltje a86d304f07
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat(#139): build Gate Rejection Response Handler
Implement rejection handling for tasks that fail quality gates after
all continuation attempts are exhausted.

Schema:
- Add TaskRejection model for tracking rejections
- Store failures, attempts, escalation state

Service:
- handleRejection: Main entry point for rejection handling
- logRejection: Database logging
- determineEscalation: Rule-based escalation determination
- executeEscalation: Execute escalation actions
- sendNotification: Notification dispatch
- markForManualReview: Flag tasks for human review
- getRejectionHistory: Query rejection history
- generateRejectionReport: Markdown report generation

Escalation rules:
- max-attempts: Trigger after 3+ attempts
- time-exceeded: Trigger after 2+ hours
- critical-failure: Trigger on security/critical issues

Actions: notify, block, reassign, cancel

Tests: 16 passing with 80% statement coverage

Fixes #139

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 14:01:42 -06:00

443 lines
14 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { RejectionHandlerService } from "./rejection-handler.service";
import { PrismaService } from "../prisma/prisma.service";
import { Logger } from "@nestjs/common";
import type {
RejectionContext,
RejectionResult,
EscalationConfig,
EscalationRule,
} from "./interfaces";
describe("RejectionHandlerService", () => {
let service: RejectionHandlerService;
let prismaService: PrismaService;
const mockRejectionContext: RejectionContext = {
taskId: "task-123",
workspaceId: "workspace-456",
agentId: "agent-789",
attemptCount: 3,
failures: [
{
gateName: "type-check",
failureType: "compilation-error",
message: "Type error in module",
attempts: 2,
},
{
gateName: "test-gate",
failureType: "test-failure",
message: "5 tests failed",
attempts: 1,
},
],
originalTask: "Implement user authentication",
startedAt: new Date("2026-01-31T10:00:00Z"),
rejectedAt: new Date("2026-01-31T12:30:00Z"),
};
const mockPrismaService = {
taskRejection: {
create: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
};
const mockLogger = {
log: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RejectionHandlerService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
{
provide: Logger,
useValue: mockLogger,
},
],
}).compile();
service = module.get<RejectionHandlerService>(RejectionHandlerService);
prismaService = module.get(PrismaService);
// Clear all mocks before each test
vi.clearAllMocks();
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("handleRejection", () => {
it("should handle rejection and return result", async () => {
const mockRejection = {
id: "rejection-1",
taskId: mockRejectionContext.taskId,
workspaceId: mockRejectionContext.workspaceId,
agentId: mockRejectionContext.agentId,
attemptCount: mockRejectionContext.attemptCount,
failures: mockRejectionContext.failures as any,
originalTask: mockRejectionContext.originalTask,
startedAt: mockRejectionContext.startedAt,
rejectedAt: mockRejectionContext.rejectedAt,
escalated: true,
manualReview: true,
resolvedAt: null,
resolution: null,
};
mockPrismaService.taskRejection.create.mockResolvedValue(mockRejection);
mockPrismaService.taskRejection.findMany.mockResolvedValue([mockRejection]);
mockPrismaService.taskRejection.update.mockResolvedValue(mockRejection);
const result = await service.handleRejection(mockRejectionContext);
expect(result.handled).toBe(true);
expect(result.manualReviewRequired).toBe(true);
expect(mockPrismaService.taskRejection.create).toHaveBeenCalled();
});
it("should handle rejection without escalation for low attempt count", async () => {
const lowAttemptContext: RejectionContext = {
...mockRejectionContext,
attemptCount: 1,
startedAt: new Date("2026-01-31T12:00:00Z"),
rejectedAt: new Date("2026-01-31T12:30:00Z"), // 30 minutes - under maxWaitTime
};
const mockRejection = {
id: "rejection-2",
taskId: lowAttemptContext.taskId,
workspaceId: lowAttemptContext.workspaceId,
agentId: lowAttemptContext.agentId,
attemptCount: lowAttemptContext.attemptCount,
failures: lowAttemptContext.failures as any,
originalTask: lowAttemptContext.originalTask,
startedAt: lowAttemptContext.startedAt,
rejectedAt: lowAttemptContext.rejectedAt,
escalated: false,
manualReview: false,
resolvedAt: null,
resolution: null,
};
mockPrismaService.taskRejection.create.mockResolvedValue(mockRejection);
mockPrismaService.taskRejection.findMany.mockResolvedValue([mockRejection]);
mockPrismaService.taskRejection.update.mockResolvedValue(mockRejection);
const result = await service.handleRejection(lowAttemptContext);
expect(result.handled).toBe(true);
expect(result.escalated).toBe(false);
});
});
describe("logRejection", () => {
it("should log rejection to database", async () => {
prismaService.taskRejection.create.mockResolvedValue({
id: "rejection-3",
taskId: mockRejectionContext.taskId,
workspaceId: mockRejectionContext.workspaceId,
agentId: mockRejectionContext.agentId,
attemptCount: mockRejectionContext.attemptCount,
failures: mockRejectionContext.failures as any,
originalTask: mockRejectionContext.originalTask,
startedAt: mockRejectionContext.startedAt,
rejectedAt: mockRejectionContext.rejectedAt,
escalated: false,
manualReview: false,
resolvedAt: null,
resolution: null,
});
await service.logRejection(mockRejectionContext);
expect(prismaService.taskRejection.create).toHaveBeenCalledWith({
data: expect.objectContaining({
taskId: mockRejectionContext.taskId,
workspaceId: mockRejectionContext.workspaceId,
agentId: mockRejectionContext.agentId,
attemptCount: mockRejectionContext.attemptCount,
}),
});
});
});
describe("determineEscalation", () => {
it("should determine escalation rules based on max attempts", () => {
const config: EscalationConfig = {
rules: [
{
condition: "max-attempts",
action: "notify",
target: "admin@example.com",
priority: "high",
},
],
notifyOnRejection: true,
autoReassign: false,
maxWaitTime: 60,
};
const rules = service.determineEscalation(mockRejectionContext, config);
expect(rules).toHaveLength(1);
expect(rules[0].condition).toBe("max-attempts");
expect(rules[0].action).toBe("notify");
expect(rules[0].priority).toBe("high");
});
it("should determine escalation for time exceeded", () => {
const longRunningContext: RejectionContext = {
...mockRejectionContext,
startedAt: new Date("2026-01-31T08:00:00Z"),
rejectedAt: new Date("2026-01-31T12:00:00Z"),
};
const config: EscalationConfig = {
rules: [
{
condition: "time-exceeded",
action: "block",
priority: "critical",
},
],
notifyOnRejection: true,
autoReassign: false,
maxWaitTime: 120, // 2 hours
};
const rules = service.determineEscalation(longRunningContext, config);
expect(rules.length).toBeGreaterThan(0);
expect(rules.some((r) => r.condition === "time-exceeded")).toBe(true);
});
it("should determine escalation for critical failures", () => {
const criticalContext: RejectionContext = {
...mockRejectionContext,
failures: [
{
gateName: "security-scan",
failureType: "critical-vulnerability",
message: "SQL injection detected",
attempts: 1,
},
],
};
const config: EscalationConfig = {
rules: [
{
condition: "critical-failure",
action: "block",
priority: "critical",
},
],
notifyOnRejection: true,
autoReassign: false,
maxWaitTime: 60,
};
const rules = service.determineEscalation(criticalContext, config);
expect(rules.some((r) => r.condition === "critical-failure")).toBe(true);
});
});
describe("executeEscalation", () => {
it("should execute notification escalation", async () => {
const rules: EscalationRule[] = [
{
condition: "max-attempts",
action: "notify",
target: "admin@example.com",
priority: "high",
},
];
// Mock sendNotification
vi.spyOn(service, "sendNotification").mockReturnValue();
await service.executeEscalation(mockRejectionContext, rules);
expect(service.sendNotification).toHaveBeenCalledWith(
mockRejectionContext,
"admin@example.com",
"high"
);
});
it("should execute block escalation", async () => {
const rules: EscalationRule[] = [
{
condition: "critical-failure",
action: "block",
priority: "critical",
},
];
vi.spyOn(service, "markForManualReview").mockResolvedValue();
await service.executeEscalation(mockRejectionContext, rules);
expect(service.markForManualReview).toHaveBeenCalledWith(
mockRejectionContext.taskId,
expect.any(String)
);
});
});
describe("sendNotification", () => {
it("should send notification with context and priority", () => {
service.sendNotification(mockRejectionContext, "admin@example.com", "high");
// Verify logging occurred
expect(true).toBe(true); // Placeholder - actual implementation will log
});
});
describe("markForManualReview", () => {
it("should mark task for manual review", async () => {
const mockRejection = {
id: "rejection-4",
taskId: mockRejectionContext.taskId,
workspaceId: mockRejectionContext.workspaceId,
agentId: mockRejectionContext.agentId,
attemptCount: mockRejectionContext.attemptCount,
failures: mockRejectionContext.failures as any,
originalTask: mockRejectionContext.originalTask,
startedAt: mockRejectionContext.startedAt,
rejectedAt: mockRejectionContext.rejectedAt,
escalated: false,
manualReview: false,
resolvedAt: null,
resolution: null,
};
mockPrismaService.taskRejection.findMany.mockResolvedValue([mockRejection]);
mockPrismaService.taskRejection.update.mockResolvedValue({
...mockRejection,
escalated: true,
manualReview: true,
});
await service.markForManualReview(mockRejectionContext.taskId, "Max attempts exceeded");
expect(mockPrismaService.taskRejection.findMany).toHaveBeenCalledWith({
where: { taskId: mockRejectionContext.taskId },
orderBy: { rejectedAt: "desc" },
take: 1,
});
expect(mockPrismaService.taskRejection.update).toHaveBeenCalledWith({
where: { id: mockRejection.id },
data: {
manualReview: true,
escalated: true,
},
});
});
});
describe("getRejectionHistory", () => {
it("should retrieve rejection history for a task", async () => {
const mockHistory = [
{
id: "rejection-5",
taskId: mockRejectionContext.taskId,
workspaceId: mockRejectionContext.workspaceId,
agentId: mockRejectionContext.agentId,
attemptCount: 1,
failures: [],
originalTask: mockRejectionContext.originalTask,
startedAt: new Date("2026-01-31T09:00:00Z"),
rejectedAt: new Date("2026-01-31T10:00:00Z"),
escalated: false,
manualReview: false,
resolvedAt: null,
resolution: null,
},
{
id: "rejection-6",
taskId: mockRejectionContext.taskId,
workspaceId: mockRejectionContext.workspaceId,
agentId: mockRejectionContext.agentId,
attemptCount: 2,
failures: [],
originalTask: mockRejectionContext.originalTask,
startedAt: new Date("2026-01-31T10:30:00Z"),
rejectedAt: new Date("2026-01-31T11:30:00Z"),
escalated: false,
manualReview: false,
resolvedAt: null,
resolution: null,
},
];
prismaService.taskRejection.findMany.mockResolvedValue(mockHistory);
const history = await service.getRejectionHistory(mockRejectionContext.taskId);
expect(history).toHaveLength(2);
expect(prismaService.taskRejection.findMany).toHaveBeenCalledWith({
where: { taskId: mockRejectionContext.taskId },
orderBy: { rejectedAt: "desc" },
});
});
});
describe("generateRejectionReport", () => {
it("should generate a formatted rejection report", () => {
const report = service.generateRejectionReport(mockRejectionContext);
expect(report).toContain("Task Rejection Report");
expect(report).toContain(mockRejectionContext.taskId);
expect(report).toContain(mockRejectionContext.attemptCount.toString());
expect(report).toContain("type-check");
expect(report).toContain("test-gate");
});
it("should include failure details in report", () => {
const report = service.generateRejectionReport(mockRejectionContext);
mockRejectionContext.failures.forEach((failure) => {
expect(report).toContain(failure.gateName);
expect(report).toContain(failure.message);
});
});
});
describe("getDefaultEscalationConfig", () => {
it("should return default escalation configuration", () => {
const config = service.getDefaultEscalationConfig();
expect(config.rules).toBeDefined();
expect(config.rules.length).toBeGreaterThan(0);
expect(config.notifyOnRejection).toBeDefined();
expect(config.autoReassign).toBeDefined();
expect(config.maxWaitTime).toBeGreaterThan(0);
});
it("should include all escalation conditions in default config", () => {
const config = service.getDefaultEscalationConfig();
const conditions = config.rules.map((r) => r.condition);
expect(conditions).toContain("max-attempts");
expect(conditions).toContain("critical-failure");
expect(conditions).toContain("time-exceeded");
});
});
});