diff --git a/server/src/__tests__/routines-e2e.test.ts b/server/src/__tests__/routines-e2e.test.ts new file mode 100644 index 00000000..301f045f --- /dev/null +++ b/server/src/__tests__/routines-e2e.test.ts @@ -0,0 +1,340 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { eq } from "drizzle-orm"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + activityLog, + agentWakeupRequests, + agents, + applyPendingMigrations, + companies, + companyMemberships, + createDb, + ensurePostgresDatabase, + heartbeatRunEvents, + heartbeatRuns, + instanceSettings, + issues, + principalPermissionGrants, + projects, + routineRuns, + routines, + routineTriggers, +} from "@paperclipai/db"; +import { errorHandler } from "../middleware/index.js"; +import { accessService } from "../services/access.js"; + +vi.mock("../services/index.js", async () => { + const actual = await vi.importActual("../services/index.js"); + const { randomUUID } = await import("node:crypto"); + const { eq } = await import("drizzle-orm"); + const { heartbeatRuns, issues } = await import("@paperclipai/db"); + + return { + ...actual, + routineService: (db: any) => + actual.routineService(db, { + heartbeat: { + wakeup: async (agentId: string, wakeupOpts: any) => { + const issueId = + (typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) || + (typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) || + null; + if (!issueId) return null; + + const issue = await db + .select({ companyId: issues.companyId }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows: Array<{ companyId: string }>) => rows[0] ?? null); + if (!issue) return null; + + const queuedRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: queuedRunId, + companyId: issue.companyId, + agentId, + invocationSource: wakeupOpts?.source ?? "assignment", + triggerDetail: wakeupOpts?.triggerDetail ?? null, + status: "queued", + contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId }, + }); + await db + .update(issues) + .set({ + executionRunId: queuedRunId, + executionLockedAt: new Date(), + }) + .where(eq(issues.id, issueId)); + return { id: queuedRunId }; + }, + }, + }), + }; +}); + +type EmbeddedPostgresInstance = { + initialise(): Promise; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; + initdbFlags?: string[]; + onLog?: (message: unknown) => void; + onError?: (message: unknown) => void; +}) => EmbeddedPostgresInstance; + +async function getEmbeddedPostgresCtor(): Promise { + const mod = await import("embedded-postgres"); + return mod.default as EmbeddedPostgresCtor; +} + +async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to allocate test port"))); + return; + } + const { port } = address; + server.close((error) => { + if (error) reject(error); + else resolve(port); + }); + }); + }); +} + +async function startTempDatabase() { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-e2e-")); + const port = await getAvailablePort(); + const EmbeddedPostgres = await getEmbeddedPostgresCtor(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C"], + onLog: () => {}, + onError: () => {}, + }); + await instance.initialise(); + await instance.start(); + + const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + await applyPendingMigrations(connectionString); + return { connectionString, dataDir, instance }; +} + +describe("routine routes end-to-end", () => { + let db!: ReturnType; + let instance: EmbeddedPostgresInstance | null = null; + let dataDir = ""; + + beforeAll(async () => { + const started = await startTempDatabase(); + db = createDb(started.connectionString); + instance = started.instance; + dataDir = started.dataDir; + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(routineRuns); + await db.delete(routineTriggers); + await db.delete(heartbeatRunEvents); + await db.delete(heartbeatRuns); + await db.delete(agentWakeupRequests); + await db.delete(issues); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(routines); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + await db.delete(instanceSettings); + }); + + afterAll(async () => { + await instance?.stop(); + if (dataDir) { + fs.rmSync(dataDir, { recursive: true, force: true }); + } + }); + + async function createApp(actor: Record) { + const { routineRoutes } = await import("../routes/routines.js"); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", routineRoutes(db)); + app.use(errorHandler); + return app; + } + + async function seedFixture() { + const companyId = randomUUID(); + const agentId = randomUUID(); + const projectId = randomUUID(); + const userId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Routine Project", + status: "in_progress", + }); + + const access = accessService(db); + const membership = await access.ensureMembership(companyId, "user", userId, "owner", "active"); + await access.setMemberPermissions( + companyId, + membership.id, + [{ permissionKey: "tasks:assign" }], + userId, + ); + + return { companyId, agentId, projectId, userId }; + } + + it("supports creating, scheduling, and manually running a routine through the API", async () => { + const { companyId, agentId, projectId, userId } = await seedFixture(); + const app = await createApp({ + type: "board", + userId, + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const createRes = await request(app) + .post(`/api/companies/${companyId}/routines`) + .send({ + projectId, + title: "Daily standup prep", + description: "Summarize blockers and open PRs", + assigneeAgentId: agentId, + priority: "high", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + }); + + expect(createRes.status).toBe(201); + expect(createRes.body.title).toBe("Daily standup prep"); + expect(createRes.body.assigneeAgentId).toBe(agentId); + + const routineId = createRes.body.id as string; + + const triggerRes = await request(app) + .post(`/api/routines/${routineId}/triggers`) + .send({ + kind: "schedule", + label: "Weekday morning", + cronExpression: "0 10 * * 1-5", + timezone: "UTC", + }); + + expect(triggerRes.status).toBe(201); + expect(triggerRes.body.trigger.kind).toBe("schedule"); + expect(triggerRes.body.trigger.enabled).toBe(true); + expect(triggerRes.body.secretMaterial).toBeNull(); + + const runRes = await request(app) + .post(`/api/routines/${routineId}/run`) + .send({ + source: "manual", + payload: { origin: "e2e-test" }, + }); + + expect(runRes.status).toBe(202); + expect(runRes.body.status).toBe("issue_created"); + expect(runRes.body.source).toBe("manual"); + expect(runRes.body.linkedIssueId).toBeTruthy(); + + const detailRes = await request(app).get(`/api/routines/${routineId}`); + expect(detailRes.status).toBe(200); + expect(detailRes.body.triggers).toHaveLength(1); + expect(detailRes.body.triggers[0]?.id).toBe(triggerRes.body.trigger.id); + expect(detailRes.body.recentRuns).toHaveLength(1); + expect(detailRes.body.recentRuns[0]?.id).toBe(runRes.body.id); + expect(detailRes.body.activeIssue?.id).toBe(runRes.body.linkedIssueId); + + const runsRes = await request(app).get(`/api/routines/${routineId}/runs?limit=10`); + expect(runsRes.status).toBe(200); + expect(runsRes.body).toHaveLength(1); + expect(runsRes.body[0]?.id).toBe(runRes.body.id); + + const [issue] = await db + .select({ + id: issues.id, + originId: issues.originId, + originKind: issues.originKind, + executionRunId: issues.executionRunId, + }) + .from(issues) + .where(eq(issues.id, runRes.body.linkedIssueId)); + + expect(issue).toMatchObject({ + id: runRes.body.linkedIssueId, + originId: routineId, + originKind: "routine_execution", + }); + expect(issue?.executionRunId).toBeTruthy(); + + const actions = await db + .select({ + action: activityLog.action, + }) + .from(activityLog) + .where(eq(activityLog.companyId, companyId)); + + expect(actions.map((entry) => entry.action)).toEqual( + expect.arrayContaining([ + "routine.created", + "routine.trigger_created", + "routine.run_triggered", + ]), + ); + }); +});