Files
triggerdotdev--trigger.dev/apps/webapp/test/bulk-actions-api.e2e.full.test.ts
2026-07-13 13:32:57 +08:00

323 lines
12 KiB
TypeScript

import { randomBytes } from "node:crypto";
import {
BulkActionStatus,
BulkActionType,
type PrismaClient,
type Project,
type RuntimeEnvironment,
} from "@trigger.dev/database";
import { describe, expect, it } from "vitest";
import { getTestServer } from "./helpers/sharedTestServer";
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
describe("Bulk actions API", () => {
it("lists bulk actions with cursor pagination", async () => {
const server = getTestServer();
const { apiKey, project, environment } = await seedTestEnvironment(server.prisma);
const oldest = await seedBulkAction(server.prisma, project, environment, {
name: "Oldest",
createdAt: new Date("2026-07-01T10:00:00.000Z"),
});
const middle = await seedBulkAction(server.prisma, project, environment, {
name: "Middle",
createdAt: new Date("2026-07-01T10:01:00.000Z"),
});
const latest = await seedBulkAction(server.prisma, project, environment, {
name: "Latest",
createdAt: new Date("2026-07-01T10:02:00.000Z"),
});
const firstResponse = await server.webapp.fetch("/api/v1/bulk-actions?page[size]=2", {
headers: authHeaders(apiKey),
});
expect(firstResponse.status).toBe(200);
const firstPage = await firstResponse.json();
expect(firstPage.data.map((item: { id: string }) => item.id)).toEqual([
latest.friendlyId,
middle.friendlyId,
]);
expect(firstPage.pagination.next).toEqual(expect.any(String));
expect(firstPage.pagination.previous).toBeUndefined();
const secondResponse = await server.webapp.fetch(
`/api/v1/bulk-actions?page[size]=2&page[after]=${encodeURIComponent(
firstPage.pagination.next
)}`,
{ headers: authHeaders(apiKey) }
);
expect(secondResponse.status).toBe(200);
const secondPage = await secondResponse.json();
expect(secondPage.data.map((item: { id: string }) => item.id)).toEqual([oldest.friendlyId]);
expect(secondPage.pagination.next).toBeUndefined();
expect(secondPage.pagination.previous).toEqual(expect.any(String));
const previousResponse = await server.webapp.fetch(
`/api/v1/bulk-actions?page[size]=2&page[before]=${encodeURIComponent(
secondPage.pagination.previous
)}`,
{ headers: authHeaders(apiKey) }
);
expect(previousResponse.status).toBe(200);
const previousPage = await previousResponse.json();
expect(previousPage.data.map((item: { id: string }) => item.id)).toEqual([
latest.friendlyId,
middle.friendlyId,
]);
});
it("retrieves a bulk action in the authenticated environment", async () => {
const server = getTestServer();
const { apiKey, project, environment } = await seedTestEnvironment(server.prisma);
const bulkAction = await seedBulkAction(server.prisma, project, environment, {
name: "Retrieve me",
type: BulkActionType.REPLAY,
status: BulkActionStatus.COMPLETED,
totalCount: 4,
successCount: 3,
failureCount: 1,
completedAt: new Date("2026-07-01T10:05:00.000Z"),
});
const response = await server.webapp.fetch(`/api/v1/bulk-actions/${bulkAction.friendlyId}`, {
headers: authHeaders(apiKey),
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toMatchObject({
id: bulkAction.friendlyId,
name: "Retrieve me",
type: "REPLAY",
status: "COMPLETED",
counts: { total: 4, success: 3, failure: 1 },
});
expect(body.createdAt).toEqual(expect.any(String));
expect(body.completedAt).toEqual("2026-07-01T10:05:00.000Z");
});
it("does not retrieve bulk actions from another environment", async () => {
const server = getTestServer();
const a = await seedTestEnvironment(server.prisma);
const b = await seedTestEnvironment(server.prisma);
const bulkAction = await seedBulkAction(server.prisma, a.project, a.environment, {
name: "Other environment",
});
const response = await server.webapp.fetch(`/api/v1/bulk-actions/${bulkAction.friendlyId}`, {
headers: authHeaders(b.apiKey),
});
expect(response.status).toBe(404);
});
it("aborts a pending bulk action", async () => {
const server = getTestServer();
const { apiKey, project, environment } = await seedTestEnvironment(server.prisma);
const bulkAction = await seedBulkAction(server.prisma, project, environment, {
status: BulkActionStatus.PENDING,
});
const response = await server.webapp.fetch(
`/api/v1/bulk-actions/${bulkAction.friendlyId}/abort`,
{ method: "POST", headers: authHeaders(apiKey) }
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ id: bulkAction.friendlyId });
const updated = await server.prisma.bulkActionGroup.findUniqueOrThrow({
where: { id: bulkAction.id },
select: { status: true },
});
expect(updated.status).toBe(BulkActionStatus.ABORTED);
});
it("returns a safe validation error when aborting a completed bulk action", async () => {
const server = getTestServer();
const { apiKey, project, environment } = await seedTestEnvironment(server.prisma);
const bulkAction = await seedBulkAction(server.prisma, project, environment, {
status: BulkActionStatus.COMPLETED,
completedAt: new Date("2026-07-01T10:05:00.000Z"),
});
const response = await server.webapp.fetch(
`/api/v1/bulk-actions/${bulkAction.friendlyId}/abort`,
{ method: "POST", headers: authHeaders(apiKey) }
);
expect(response.status).toBe(409);
const body = await response.json();
expect(body.error).toEqual(expect.any(String));
expect(body.error).toContain(bulkAction.friendlyId);
});
it("rejects create requests with both filter and runIds", async () => {
const server = getTestServer();
const { apiKey } = await seedTestEnvironment(server.prisma);
const response = await server.webapp.fetch("/api/v1/bulk-actions", {
method: "POST",
headers: authHeaders(apiKey),
body: JSON.stringify({ action: "cancel", filter: { status: "FAILED" }, runIds: ["run_123"] }),
});
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("Exactly one of filter or runIds must be provided");
});
it("rejects create requests with an empty filter", async () => {
const server = getTestServer();
const { apiKey } = await seedTestEnvironment(server.prisma);
const response = await server.webapp.fetch("/api/v1/bulk-actions", {
method: "POST",
headers: authHeaders(apiKey),
body: JSON.stringify({ action: "cancel", filter: {} }),
});
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("At least one filter must be provided");
});
it("returns a generic error for unexpected create failures", async () => {
const server = getTestServer();
const { apiKey } = await seedTestEnvironment(server.prisma);
const response = await server.webapp.fetch("/api/v1/bulk-actions", {
method: "POST",
headers: authHeaders(apiKey),
body: JSON.stringify({
action: "cancel",
filter: { status: "FAILED" },
name: "No ClickHouse in this suite",
}),
});
expect(response.status).toBe(500);
await expect(response.json()).resolves.toEqual({ error: "Failed to create bulk action" });
});
it("blocks a new replay once the concurrent-replay limit is reached", async () => {
const server = getTestServer();
const { apiKey, project, environment } = await seedTestEnvironment(server.prisma);
// Fill the per-environment concurrent-replay slots with fresh, in-flight replays.
// The guard runs before the ClickHouse count, so this asserts cleanly without it.
for (let i = 0; i < 3; i++) {
await seedBulkAction(server.prisma, project, environment, {
type: BulkActionType.REPLAY,
status: BulkActionStatus.PENDING,
});
}
const response = await server.webapp.fetch("/api/v1/bulk-actions", {
method: "POST",
headers: authHeaders(apiKey),
body: JSON.stringify({ action: "replay", filter: { status: "FAILED" } }),
});
expect(response.status).toBe(429);
// The cap is a semantic limit, not a transient rate limit, so the SDK must not retry it.
expect(response.headers.get("x-should-retry")).toBe("false");
const body = await response.json();
expect(body.error).toContain("bulk replays at a time");
});
it("does not count stale replays that have stopped making progress", async () => {
const server = getTestServer();
const { apiKey, project, environment } = await seedTestEnvironment(server.prisma);
for (let i = 0; i < 3; i++) {
await seedBulkAction(server.prisma, project, environment, {
type: BulkActionType.REPLAY,
status: BulkActionStatus.PENDING,
});
}
// Backdate updatedAt past the in-flight window so these look like dead replays.
// (updatedAt is @updatedAt, so it can only be set via raw SQL, not on create.)
await server.prisma.$executeRawUnsafe(
`UPDATE "BulkActionGroup" SET "updatedAt" = now() - interval '31 minutes' WHERE "environmentId" = $1`,
environment.id
);
const response = await server.webapp.fetch("/api/v1/bulk-actions", {
method: "POST",
headers: authHeaders(apiKey),
body: JSON.stringify({ action: "replay", filter: { status: "FAILED" } }),
});
// Stale replays don't hold a slot, so the guard lets the request through and it
// reaches the count step, which fails (no ClickHouse in this suite) with a 500 rather
// than being blocked by the concurrency guard's 429.
expect(response.status).toBe(500);
});
it("rejects create requests with more runIds than the allowed maximum", async () => {
const server = getTestServer();
const { apiKey } = await seedTestEnvironment(server.prisma);
const runIds = Array.from({ length: 501 }, (_, i) => `run_${i}`);
const response = await server.webapp.fetch("/api/v1/bulk-actions", {
method: "POST",
headers: authHeaders(apiKey),
body: JSON.stringify({ action: "cancel", runIds }),
});
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("Too many runIds");
});
});
function authHeaders(apiKey: string) {
return {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
};
}
async function seedBulkAction(
prisma: PrismaClient,
project: Pick<Project, "id">,
environment: Pick<RuntimeEnvironment, "id">,
overrides: {
name?: string;
type?: BulkActionType;
status?: BulkActionStatus;
createdAt?: Date;
completedAt?: Date;
totalCount?: number;
successCount?: number;
failureCount?: number;
} = {}
) {
return prisma.bulkActionGroup.create({
data: {
friendlyId: `bulk_${randomHex(16)}`,
projectId: project.id,
environmentId: environment.id,
name: overrides.name ?? "Test bulk action",
type: overrides.type ?? BulkActionType.CANCEL,
status: overrides.status ?? BulkActionStatus.PENDING,
queryName: "bulk_action_v1",
params: {},
totalCount: overrides.totalCount ?? 1,
successCount: overrides.successCount ?? 0,
failureCount: overrides.failureCount ?? 0,
createdAt: overrides.createdAt,
completedAt: overrides.completedAt,
},
});
}
function randomHex(length: number) {
return randomBytes(Math.ceil(length / 2))
.toString("hex")
.slice(0, length);
}