Files
stack/apps/api/src/stitcher/stitcher.service.spec.ts
Jason Woltje a2cd614e87 feat(#166): Implement Stitcher module structure
Created the mosaic-stitcher module - the workflow orchestration layer
that wraps OpenClaw.

Responsibilities:
- Receive webhooks from @mosaic bot
- Apply Guard Rails (capability permissions)
- Apply Quality Rails (mandatory gates)
- Track all job steps and events
- Dispatch work to OpenClaw with constraints

Implementation:
- StitcherModule: Module definition with PrismaModule and BullMqModule
- StitcherService: Core orchestration logic
  - handleWebhook(): Process webhooks from @mosaic bot
  - dispatchJob(): Create RunnerJob and dispatch to BullMQ queue
  - applyGuardRails(): Check capability permissions for agent profiles
  - applyQualityRails(): Determine mandatory gates for job types
  - trackJobEvent(): Log events to database for audit trail
- StitcherController: HTTP endpoints
  - POST /stitcher/webhook: Webhook receiver
  - POST /stitcher/dispatch: Manual job dispatch
- DTOs and interfaces for type safety

TDD Process:
1. RED: Created failing tests (12 tests)
2. GREEN: Implemented minimal code to pass tests
3. REFACTOR: Fixed TypeScript strict mode issues

Quality Gates: ALL PASS
- Typecheck: PASS
- Lint: PASS
- Build: PASS
- Tests: PASS (12/12)

Token estimate: ~56,000 tokens

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:08:32 -06:00

200 lines
5.5 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { StitcherService } from "./stitcher.service";
import { PrismaService } from "../prisma/prisma.service";
import { BullMqService } from "../bullmq/bullmq.service";
import { QUEUE_NAMES } from "../bullmq/queues";
import type { JobDispatchContext, JobDispatchResult } from "./interfaces";
describe("StitcherService", () => {
let service: StitcherService;
let prismaService: PrismaService;
let bullMqService: BullMqService;
const mockPrismaService = {
runnerJob: {
create: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
jobEvent: {
create: vi.fn(),
},
};
const mockBullMqService = {
addJob: vi.fn(),
getQueue: vi.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
StitcherService,
{ provide: PrismaService, useValue: mockPrismaService },
{ provide: BullMqService, useValue: mockBullMqService },
],
}).compile();
service = module.get<StitcherService>(StitcherService);
prismaService = module.get<PrismaService>(PrismaService);
bullMqService = module.get<BullMqService>(BullMqService);
vi.clearAllMocks();
});
describe("dispatchJob", () => {
it("should create a RunnerJob and dispatch to queue", async () => {
const context: JobDispatchContext = {
workspaceId: "workspace-123",
type: "code-task",
priority: 10,
};
const mockJob = {
id: "job-123",
workspaceId: "workspace-123",
type: "code-task",
status: "PENDING",
priority: 10,
progressPercent: 0,
createdAt: new Date(),
};
mockPrismaService.runnerJob.create.mockResolvedValue(mockJob);
mockBullMqService.addJob.mockResolvedValue({ id: "queue-job-123" });
const result = await service.dispatchJob(context);
expect(result).toEqual<JobDispatchResult>({
jobId: "job-123",
queueName: QUEUE_NAMES.MAIN,
status: "PENDING",
});
expect(mockPrismaService.runnerJob.create).toHaveBeenCalledWith({
data: {
workspaceId: "workspace-123",
type: "code-task",
priority: 10,
status: "PENDING",
progressPercent: 0,
},
});
expect(mockBullMqService.addJob).toHaveBeenCalledWith(
QUEUE_NAMES.MAIN,
"code-task",
expect.objectContaining({
jobId: "job-123",
workspaceId: "workspace-123",
}),
expect.objectContaining({
priority: 10,
})
);
});
it("should log job event after dispatch", async () => {
const context: JobDispatchContext = {
workspaceId: "workspace-123",
type: "git-status",
};
const mockJob = {
id: "job-456",
workspaceId: "workspace-123",
type: "git-status",
status: "PENDING",
priority: 5,
progressPercent: 0,
createdAt: new Date(),
};
mockPrismaService.runnerJob.create.mockResolvedValue(mockJob);
mockBullMqService.addJob.mockResolvedValue({ id: "queue-job-456" });
await service.dispatchJob(context);
expect(mockPrismaService.jobEvent.create).toHaveBeenCalledWith({
data: expect.objectContaining({
jobId: "job-456",
type: "job.queued",
actor: "stitcher",
}),
});
});
it("should handle dispatch errors", async () => {
const context: JobDispatchContext = {
workspaceId: "workspace-123",
type: "invalid-type",
};
mockPrismaService.runnerJob.create.mockRejectedValue(new Error("Database error"));
await expect(service.dispatchJob(context)).rejects.toThrow("Database error");
});
});
describe("applyGuardRails", () => {
it("should return allowed for valid capabilities", () => {
const result = service.applyGuardRails("runner", ["read"]);
expect(result.allowed).toBe(true);
});
it("should return not allowed for invalid capabilities", () => {
const result = service.applyGuardRails("runner", ["write"]);
expect(result.allowed).toBe(false);
expect(result.reason).toBeDefined();
});
});
describe("applyQualityRails", () => {
it("should return required gates for code tasks", () => {
const result = service.applyQualityRails("code-task");
expect(result.required).toBe(true);
expect(result.gates).toContain("lint");
expect(result.gates).toContain("typecheck");
expect(result.gates).toContain("test");
});
it("should return no gates for read-only tasks", () => {
const result = service.applyQualityRails("git-status");
expect(result.required).toBe(false);
expect(result.gates).toHaveLength(0);
});
});
describe("trackJobEvent", () => {
it("should create job event in database", async () => {
const mockEvent = {
id: "event-123",
jobId: "job-123",
type: "job.started",
timestamp: new Date(),
actor: "stitcher",
payload: {},
};
mockPrismaService.jobEvent.create.mockResolvedValue(mockEvent);
await service.trackJobEvent("job-123", "job.started", "stitcher", {});
expect(mockPrismaService.jobEvent.create).toHaveBeenCalledWith({
data: {
jobId: "job-123",
type: "job.started",
actor: "stitcher",
timestamp: expect.any(Date),
payload: {},
},
});
});
});
});