Skip to content
File

Blob: tests/worker/routes/project-runs.test.ts

typescript237 lines
1import { env } from "cloudflare:workers";
2import { eq } from "drizzle-orm";
3import { describe, expect, it } from "vitest";
4 
5import {
6 DEFAULT_DISPATCH_MODE,
7 DEFAULT_EXECUTION_RUNTIME,
8 type GetMeResponse,
9 type GetProjectsResponse,
10 type LogStreamTicketResponse,
11 type ProjectDetail,
12 type ProjectResponse,
13 type RunDetail,
14 type TriggerRunAcceptedResponse,
15} from "@/contracts";
16import { createD1Db } from "@/worker/db/d1";
17import * as d1Schema from "@/worker/db/d1/schema";
18 
19import { acceptManualRunWithoutAlarm } from "../../helpers/project-do";
20import { authHeaders, fetchJson, mintCookieAuth, seedUser } from "../../helpers/runtime";
21import { registerWorkerRuntimeHooks } from "../../helpers/worker-hooks";
22 
23describe("worker routes", () => {
24 registerWorkerRuntimeHooks();
25 
26 describe("project and run flows", () => {
27 it("supports session auth, project creation, run trigger, cancel, and log ticket flows", async () => {
28 const user = await seedUser({
29 email: "routes@example.com",
30 slug: "routes-user",
31 });
32 
33 const { sessionId } = await mintCookieAuth(user.id);
34 
35 const me = await fetchJson<GetMeResponse>("/api/private/me", {
36 headers: authHeaders(sessionId),
37 });
38 expect(me.status).toBe(200);
39 expect(me.body?.user.id).toBe(user.id);
40 
41 const createdProject = await fetchJson<ProjectResponse>("/api/private/projects", {
42 method: "POST",
43 headers: authHeaders(sessionId, {
44 "content-type": "application/json; charset=utf-8",
45 }),
46 body: JSON.stringify({
47 projectSlug: "api-tests",
48 name: "API Tests",
49 repoUrl: "https://github.com/example/api-tests",
50 defaultBranch: "main",
51 configPath: ".anvil.yml",
52 }),
53 });
54 expect(createdProject.status).toBe(201);
55 expect(createdProject.body).not.toBeNull();
56 expect(createdProject.body?.project.dispatchMode).toBe(DEFAULT_DISPATCH_MODE);
57 
58 const projectId = createdProject.body!.project.id;
59 
60 const listProjects = await fetchJson<GetProjectsResponse>("/api/private/projects", {
61 headers: authHeaders(sessionId),
62 });
63 expect(listProjects.status).toBe(200);
64 expect(listProjects.body?.projects.map((project) => project.id)).toContain(projectId);
65 
66 const acceptedRun = await fetchJson<TriggerRunAcceptedResponse>(`/api/private/projects/${projectId}/runs`, {
67 method: "POST",
68 headers: authHeaders(sessionId, {
69 "content-type": "application/json; charset=utf-8",
70 }),
71 body: JSON.stringify({}),
72 });
73 expect(acceptedRun.status).toBe(202);
74 expect(acceptedRun.body).not.toBeNull();
75 
76 const runId = acceptedRun.body!.runId;
77 
78 const projectDetail = await fetchJson<ProjectDetail>(`/api/private/projects/${projectId}`, {
79 headers: authHeaders(sessionId),
80 });
81 expect(projectDetail.status).toBe(200);
82 expect(projectDetail.body?.project.dispatchMode).toBe(DEFAULT_DISPATCH_MODE);
83 const visibleRunIds = [
84 projectDetail.body?.activeRun?.id,
85 ...(projectDetail.body?.pendingRuns.map((run) => run.runId) ?? []),
86 ].filter((value) => value !== undefined);
87 expect(visibleRunIds).toContain(runId);
88 
89 const runDetailBeforeCancel = await fetchJson<RunDetail>(`/api/private/runs/${runId}`, {
90 headers: authHeaders(sessionId),
91 });
92 expect(runDetailBeforeCancel.status).toBe(200);
93 expect(runDetailBeforeCancel.body?.run.status).not.toBe("pending");
94 
95 const canceledRun = await fetchJson<RunDetail>(`/api/private/runs/${runId}/cancel`, {
96 method: "POST",
97 headers: authHeaders(sessionId),
98 });
99 expect(canceledRun.status).toBe(200);
100 expect(canceledRun.body?.run.status).toBe("cancel_requested");
101 
102 const logTicket = await fetchJson<LogStreamTicketResponse>(`/api/private/runs/${runId}/log-ticket`, {
103 method: "POST",
104 headers: authHeaders(sessionId),
105 });
106 expect(logTicket.status).toBe(200);
107 expect(logTicket.body).not.toBeNull();
108 expect(logTicket.body!.ticket).toMatch(/^[A-Za-z0-9_-]+$/u);
109 expect(typeof logTicket.body!.expiresAt).toBe("string");
110 
111 const runDetailAfterCancel = await fetchJson<RunDetail>(`/api/private/runs/${runId}`, {
112 headers: authHeaders(sessionId),
113 });
114 expect(runDetailAfterCancel.status).toBe(200);
115 expect(["cancel_requested", "canceling", "canceled", "failed"]).toContain(runDetailAfterCancel.body?.run.status);
116 
117 const db = createD1Db(env.DB);
118 await db
119 .update(d1Schema.users)
120 .set({
121 disabledAt: Date.now(),
122 })
123 .where(eq(d1Schema.users.id, user.id));
124 
125 const disabledUpgrade = await fetchJson(`/api/private/runs/${runId}/logs?ticket=${logTicket.body!.ticket}`, {
126 headers: {
127 upgrade: "websocket",
128 },
129 });
130 expect(disabledUpgrade.status).toBe(403);
131 expect(disabledUpgrade.body).toMatchObject({
132 error: {
133 code: "user_disabled",
134 },
135 });
136 });
137 
138 it("returns project_queue_full when a manual trigger exceeds the per-project queue cap", async () => {
139 const user = await seedUser({
140 email: "routes-queue-full@example.com",
141 slug: "routes-queue-full",
142 });
143 
144 const { sessionId } = await mintCookieAuth(user.id);
145 
146 const createdProject = await fetchJson<ProjectResponse>("/api/private/projects", {
147 method: "POST",
148 headers: authHeaders(sessionId, {
149 "content-type": "application/json; charset=utf-8",
150 }),
151 body: JSON.stringify({
152 projectSlug: "queue-full-project",
153 name: "Queue Full Project",
154 repoUrl: "https://github.com/example/queue-full-project",
155 defaultBranch: "main",
156 configPath: ".anvil.yml",
157 }),
158 });
159 expect(createdProject.status).toBe(201);
160 expect(createdProject.body).not.toBeNull();
161 
162 const project = createdProject.body!.project;
163 const projectStub = env.PROJECT_DO.getByName(project.id);
164 
165 for (let index = 0; index < 20; index += 1) {
166 await acceptManualRunWithoutAlarm(projectStub, {
167 projectId: project.id,
168 triggeredByUserId: user.id,
169 branch: project.defaultBranch,
170 });
171 }
172 
173 const overflow = await fetchJson(`/api/private/projects/${project.id}/runs`, {
174 method: "POST",
175 headers: authHeaders(sessionId, {
176 "content-type": "application/json; charset=utf-8",
177 }),
178 body: JSON.stringify({}),
179 });
180 
181 expect(overflow.status).toBe(409);
182 expect(overflow.body).toMatchObject({
183 error: {
184 code: "project_queue_full",
185 },
186 });
187 });
188 
189 it("accepts and returns explicit dispatch mode changes on create and update", async () => {
190 const user = await seedUser({
191 email: "routes-dispatch-mode@example.com",
192 slug: "routes-dispatch-mode",
193 });
194 
195 const { sessionId } = await mintCookieAuth(user.id);
196 
197 const createdProject = await fetchJson<ProjectResponse>("/api/private/projects", {
198 method: "POST",
199 headers: authHeaders(sessionId, {
200 "content-type": "application/json; charset=utf-8",
201 }),
202 body: JSON.stringify({
203 projectSlug: "workflow-project",
204 name: "Workflow Project",
205 repoUrl: "https://github.com/example/workflow-project",
206 defaultBranch: "main",
207 configPath: ".anvil.yml",
208 dispatchMode: "workflows",
209 }),
210 });
211 expect(createdProject.status).toBe(201);
212 expect(createdProject.body?.project.dispatchMode).toBe("workflows");
213 
214 const updatedProject = await fetchJson<ProjectResponse>(
215 `/api/private/projects/${createdProject.body!.project.id}`,
216 {
217 method: "PATCH",
218 headers: authHeaders(sessionId, {
219 "content-type": "application/json; charset=utf-8",
220 }),
221 body: JSON.stringify({
222 dispatchMode: "queue",
223 }),
224 },
225 );
226 expect(updatedProject.status).toBe(200);
227 expect(updatedProject.body?.project.dispatchMode).toBe("queue");
228 
229 const detail = await fetchJson<ProjectDetail>(`/api/private/projects/${createdProject.body!.project.id}`, {
230 headers: authHeaders(sessionId),
231 });
232 expect(detail.status).toBe(200);
233 expect(detail.body?.project.dispatchMode).toBe("queue");
234 });
235 });
236});