diff --git a/apps/webapp/test/pauseEnvironment.server.test.ts b/apps/webapp/test/pauseEnvironment.server.test.ts new file mode 100644 index 0000000000..ea31560264 --- /dev/null +++ b/apps/webapp/test/pauseEnvironment.server.test.ts @@ -0,0 +1,137 @@ +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>; + +async function authEnv( + loaded: Loaded, + prisma: PrismaClient, + environmentId: string +): Promise { + 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); + } + ); +});