Files
2026-07-13 13:32:57 +08:00

138 lines
5.3 KiB
TypeScript

import { containerTest } from "@internal/testcontainers";
import { EnvironmentPauseSource, type PrismaClient } from "@trigger.dev/database";
import type { RedisOptions } from "ioredis";
import { describe, expect, vi } from "vitest";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import {
createRuntimeEnvironment,
createTestOrgProjectWithMember,
uniqueId,
} from "./fixtures/environmentVariablesFixtures";
vi.setConfig({ testTimeout: 60_000 });
// The service's import chain reaches module-level singletons that throw at load
// time when REDIS_HOST/REDIS_PORT are unset (autoIncrementCounter via
// triggerTaskV1), so the env must point at the redis container BEFORE the
// module is imported. Hence dynamic imports; vitest runs each file in its own
// fork, so the env mutation cannot leak into other suites.
async function loadService(redisOptions: RedisOptions) {
process.env.REDIS_HOST = redisOptions.host;
process.env.REDIS_PORT = String(redisOptions.port);
process.env.REDIS_TLS_DISABLED = "true";
const [{ PauseEnvironmentService }, { authIncludeBase, toAuthenticated }] = await Promise.all([
import("~/v3/services/pauseEnvironment.server"),
import("~/models/runtimeEnvironment.server"),
]);
return { PauseEnvironmentService, authIncludeBase, toAuthenticated };
}
type Loaded = Awaited<ReturnType<typeof loadService>>;
async function authEnv(
loaded: Loaded,
prisma: PrismaClient,
environmentId: string
): Promise<AuthenticatedEnvironment> {
const row = await prisma.runtimeEnvironment.findFirstOrThrow({
where: { id: environmentId },
include: loaded.authIncludeBase,
});
return loaded.toAuthenticated(row);
}
async function seedProductionEnv(prisma: PrismaClient) {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const environment = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
slug: uniqueId("prod"),
});
return { organization, project, environment };
}
describe("PauseEnvironmentService", () => {
containerTest(
"resumes a manually paused env (pauseSource stays null through pause and resume)",
async ({ prisma, redisOptions }) => {
const loaded = await loadService(redisOptions);
const { environment } = await seedProductionEnv(prisma);
const service = new loaded.PauseEnvironmentService(prisma);
const env = await authEnv(loaded, prisma, environment.id);
const paused = await service.call(env, "paused");
expect(paused).toEqual({ success: true, state: "paused" });
const afterPause = await prisma.runtimeEnvironment.findFirstOrThrow({
where: { id: environment.id },
});
// Manual pause never sets pauseSource; leaving it null is what tripped the
// pre-fix resume guard (Prisma NOT on a nullable field excludes NULL rows).
expect(afterPause.paused).toBe(true);
expect(afterPause.pauseSource).toBeNull();
const resumed = await service.call(env, "resumed");
expect(resumed).toEqual({ success: true, state: "resumed" });
const afterResume = await prisma.runtimeEnvironment.findFirstOrThrow({
where: { id: environment.id },
});
expect(afterResume.paused).toBe(false);
expect(afterResume.pauseSource).toBeNull();
}
);
containerTest(
"rejects resume of a billing-limit paused env and leaves it paused",
async ({ prisma, redisOptions }) => {
const loaded = await loadService(redisOptions);
const { environment } = await seedProductionEnv(prisma);
await prisma.runtimeEnvironment.update({
where: { id: environment.id },
data: { paused: true, pauseSource: EnvironmentPauseSource.BILLING_LIMIT },
});
const service = new loaded.PauseEnvironmentService(prisma);
const env = await authEnv(loaded, prisma, environment.id);
const result = await service.call(env, "resumed");
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toContain("billing limit");
const after = await prisma.runtimeEnvironment.findFirstOrThrow({
where: { id: environment.id },
});
expect(after.paused).toBe(true);
expect(after.pauseSource).toBe(EnvironmentPauseSource.BILLING_LIMIT);
}
);
containerTest(
"manual pause while billing-limit paused is a no-op that preserves pauseSource",
async ({ prisma, redisOptions }) => {
const loaded = await loadService(redisOptions);
const { environment } = await seedProductionEnv(prisma);
await prisma.runtimeEnvironment.update({
where: { id: environment.id },
data: { paused: true, pauseSource: EnvironmentPauseSource.BILLING_LIMIT },
});
const service = new loaded.PauseEnvironmentService(prisma);
const env = await authEnv(loaded, prisma, environment.id);
const result = await service.call(env, "paused");
// Idempotent success without overwriting pauseSource, so billing-limit
// converge can still find and unpause this env on resolve.
expect(result).toEqual({ success: true, state: "paused" });
const after = await prisma.runtimeEnvironment.findFirstOrThrow({
where: { id: environment.id },
});
expect(after.paused).toBe(true);
expect(after.pauseSource).toBe(EnvironmentPauseSource.BILLING_LIMIT);
}
);
});