From 1acde7de020324000807fdd5616901353ae7e071 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:27:30 +0100 Subject: [PATCH 1/3] test(webapp): regression tests for resuming manually paused environments --- .../test/pauseEnvironment.server.test.ts | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 apps/webapp/test/pauseEnvironment.server.test.ts diff --git a/apps/webapp/test/pauseEnvironment.server.test.ts b/apps/webapp/test/pauseEnvironment.server.test.ts new file mode 100644 index 0000000000..c1281d6a70 --- /dev/null +++ b/apps/webapp/test/pauseEnvironment.server.test.ts @@ -0,0 +1,112 @@ +import { postgresTest } from "@internal/testcontainers"; +import { EnvironmentPauseSource, type PrismaClient } from "@trigger.dev/database"; +import { describe, expect, vi } from "vitest"; +import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { authIncludeBase, toAuthenticated } from "~/models/runtimeEnvironment.server"; +import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server"; +import { + createRuntimeEnvironment, + createTestOrgProjectWithMember, + uniqueId, +} from "./fixtures/environmentVariablesFixtures"; + +vi.setConfig({ testTimeout: 60_000 }); + +async function authEnv(prisma: PrismaClient, environmentId: string): Promise { + const row = await prisma.runtimeEnvironment.findFirstOrThrow({ + where: { id: environmentId }, + include: authIncludeBase, + }); + return 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", () => { + postgresTest( + "resumes a manually paused env (pauseSource stays null through pause and resume)", + async ({ prisma }) => { + const { environment } = await seedProductionEnv(prisma); + const service = new PauseEnvironmentService(prisma); + const env = await authEnv(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(); + } + ); + + postgresTest("rejects resume of a billing-limit paused env and leaves it paused", async ({ + prisma, + }) => { + const { environment } = await seedProductionEnv(prisma); + await prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { paused: true, pauseSource: EnvironmentPauseSource.BILLING_LIMIT }, + }); + + const service = new PauseEnvironmentService(prisma); + const env = await authEnv(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); + }); + + postgresTest( + "manual pause while billing-limit paused is a no-op that preserves pauseSource", + async ({ prisma }) => { + const { environment } = await seedProductionEnv(prisma); + await prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { paused: true, pauseSource: EnvironmentPauseSource.BILLING_LIMIT }, + }); + + const service = new PauseEnvironmentService(prisma); + const env = await authEnv(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); + } + ); +}); From 3a3263545da3710f741acd4bd91dc140566858e2 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:32:32 +0100 Subject: [PATCH 2/3] chore: format test file --- .../test/pauseEnvironment.server.test.ts | 52 ++++++++++--------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/apps/webapp/test/pauseEnvironment.server.test.ts b/apps/webapp/test/pauseEnvironment.server.test.ts index c1281d6a70..947761dea4 100644 --- a/apps/webapp/test/pauseEnvironment.server.test.ts +++ b/apps/webapp/test/pauseEnvironment.server.test.ts @@ -12,7 +12,10 @@ import { vi.setConfig({ testTimeout: 60_000 }); -async function authEnv(prisma: PrismaClient, environmentId: string): Promise { +async function authEnv( + prisma: PrismaClient, + environmentId: string +): Promise { const row = await prisma.runtimeEnvironment.findFirstOrThrow({ where: { id: environmentId }, include: authIncludeBase, @@ -61,29 +64,30 @@ describe("PauseEnvironmentService", () => { } ); - postgresTest("rejects resume of a billing-limit paused env and leaves it paused", async ({ - prisma, - }) => { - const { environment } = await seedProductionEnv(prisma); - await prisma.runtimeEnvironment.update({ - where: { id: environment.id }, - data: { paused: true, pauseSource: EnvironmentPauseSource.BILLING_LIMIT }, - }); - - const service = new PauseEnvironmentService(prisma); - const env = await authEnv(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); - }); + postgresTest( + "rejects resume of a billing-limit paused env and leaves it paused", + async ({ prisma }) => { + const { environment } = await seedProductionEnv(prisma); + await prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { paused: true, pauseSource: EnvironmentPauseSource.BILLING_LIMIT }, + }); + + const service = new PauseEnvironmentService(prisma); + const env = await authEnv(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); + } + ); postgresTest( "manual pause while billing-limit paused is a no-op that preserves pauseSource", From 58664f8bcad4c14d38a65a70149ba7b6ed3324d9 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:00:05 +0100 Subject: [PATCH 3/3] test(webapp): load service after pointing env at the redis container --- .../test/pauseEnvironment.server.test.ts | 55 +++++++++++++------ 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/apps/webapp/test/pauseEnvironment.server.test.ts b/apps/webapp/test/pauseEnvironment.server.test.ts index 947761dea4..ea31560264 100644 --- a/apps/webapp/test/pauseEnvironment.server.test.ts +++ b/apps/webapp/test/pauseEnvironment.server.test.ts @@ -1,9 +1,8 @@ -import { postgresTest } from "@internal/testcontainers"; +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 { authIncludeBase, toAuthenticated } from "~/models/runtimeEnvironment.server"; -import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server"; import { createRuntimeEnvironment, createTestOrgProjectWithMember, @@ -12,15 +11,34 @@ import { 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: authIncludeBase, + include: loaded.authIncludeBase, }); - return toAuthenticated(row); + return loaded.toAuthenticated(row); } async function seedProductionEnv(prisma: PrismaClient) { @@ -35,12 +53,13 @@ async function seedProductionEnv(prisma: PrismaClient) { } describe("PauseEnvironmentService", () => { - postgresTest( + containerTest( "resumes a manually paused env (pauseSource stays null through pause and resume)", - async ({ prisma }) => { + async ({ prisma, redisOptions }) => { + const loaded = await loadService(redisOptions); const { environment } = await seedProductionEnv(prisma); - const service = new PauseEnvironmentService(prisma); - const env = await authEnv(prisma, environment.id); + 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" }); @@ -64,17 +83,18 @@ describe("PauseEnvironmentService", () => { } ); - postgresTest( + containerTest( "rejects resume of a billing-limit paused env and leaves it paused", - async ({ prisma }) => { + 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 PauseEnvironmentService(prisma); - const env = await authEnv(prisma, environment.id); + 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); @@ -89,17 +109,18 @@ describe("PauseEnvironmentService", () => { } ); - postgresTest( + containerTest( "manual pause while billing-limit paused is a no-op that preserves pauseSource", - async ({ prisma }) => { + 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 PauseEnvironmentService(prisma); - const env = await authEnv(prisma, environment.id); + 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