Skip to content

Commit 0f349dd

Browse files
authored
test(webapp): regression tests for resuming manually paused environments (triggerdotdev#4127)
Follow-up to triggerdotdev#4120, adding regression coverage for the pause/resume path in `PauseEnvironmentService`. Three tests against the real service with testcontainers Postgres (no mocks), seeded org/project/environment rows, and the same `AuthenticatedEnvironment` coercion production uses: 1. **resumes a manually paused env** - the actual regression: pause leaves `pauseSource` null, resume must succeed. Verified this fails with the exact pre-triggerdotdev#4120 symptom (false billing-limit error) when the fix is reverted locally. 2. **rejects resume of a billing-limit paused env** - the guard still blocks manual resume while `pauseSource = BILLING_LIMIT`, and the env stays paused. 3. **manual pause while billing-limit paused is a no-op** - returns success without overwriting `pauseSource`, so billing-limit converge can still find and unpause the environment. Tests-only change, no runtime behavior touched.
1 parent c7f6ed5 commit 0f349dd

1 file changed

Lines changed: 137 additions & 0 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { containerTest } from "@internal/testcontainers";
2+
import { EnvironmentPauseSource, type PrismaClient } from "@trigger.dev/database";
3+
import type { RedisOptions } from "ioredis";
4+
import { describe, expect, vi } from "vitest";
5+
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
6+
import {
7+
createRuntimeEnvironment,
8+
createTestOrgProjectWithMember,
9+
uniqueId,
10+
} from "./fixtures/environmentVariablesFixtures";
11+
12+
vi.setConfig({ testTimeout: 60_000 });
13+
14+
// The service's import chain reaches module-level singletons that throw at load
15+
// time when REDIS_HOST/REDIS_PORT are unset (autoIncrementCounter via
16+
// triggerTaskV1), so the env must point at the redis container BEFORE the
17+
// module is imported. Hence dynamic imports; vitest runs each file in its own
18+
// fork, so the env mutation cannot leak into other suites.
19+
async function loadService(redisOptions: RedisOptions) {
20+
process.env.REDIS_HOST = redisOptions.host;
21+
process.env.REDIS_PORT = String(redisOptions.port);
22+
process.env.REDIS_TLS_DISABLED = "true";
23+
const [{ PauseEnvironmentService }, { authIncludeBase, toAuthenticated }] = await Promise.all([
24+
import("~/v3/services/pauseEnvironment.server"),
25+
import("~/models/runtimeEnvironment.server"),
26+
]);
27+
return { PauseEnvironmentService, authIncludeBase, toAuthenticated };
28+
}
29+
30+
type Loaded = Awaited<ReturnType<typeof loadService>>;
31+
32+
async function authEnv(
33+
loaded: Loaded,
34+
prisma: PrismaClient,
35+
environmentId: string
36+
): Promise<AuthenticatedEnvironment> {
37+
const row = await prisma.runtimeEnvironment.findFirstOrThrow({
38+
where: { id: environmentId },
39+
include: loaded.authIncludeBase,
40+
});
41+
return loaded.toAuthenticated(row);
42+
}
43+
44+
async function seedProductionEnv(prisma: PrismaClient) {
45+
const { organization, project } = await createTestOrgProjectWithMember(prisma);
46+
const environment = await createRuntimeEnvironment(prisma, {
47+
projectId: project.id,
48+
organizationId: organization.id,
49+
type: "PRODUCTION",
50+
slug: uniqueId("prod"),
51+
});
52+
return { organization, project, environment };
53+
}
54+
55+
describe("PauseEnvironmentService", () => {
56+
containerTest(
57+
"resumes a manually paused env (pauseSource stays null through pause and resume)",
58+
async ({ prisma, redisOptions }) => {
59+
const loaded = await loadService(redisOptions);
60+
const { environment } = await seedProductionEnv(prisma);
61+
const service = new loaded.PauseEnvironmentService(prisma);
62+
const env = await authEnv(loaded, prisma, environment.id);
63+
64+
const paused = await service.call(env, "paused");
65+
expect(paused).toEqual({ success: true, state: "paused" });
66+
67+
const afterPause = await prisma.runtimeEnvironment.findFirstOrThrow({
68+
where: { id: environment.id },
69+
});
70+
// Manual pause never sets pauseSource; leaving it null is what tripped the
71+
// pre-fix resume guard (Prisma NOT on a nullable field excludes NULL rows).
72+
expect(afterPause.paused).toBe(true);
73+
expect(afterPause.pauseSource).toBeNull();
74+
75+
const resumed = await service.call(env, "resumed");
76+
expect(resumed).toEqual({ success: true, state: "resumed" });
77+
78+
const afterResume = await prisma.runtimeEnvironment.findFirstOrThrow({
79+
where: { id: environment.id },
80+
});
81+
expect(afterResume.paused).toBe(false);
82+
expect(afterResume.pauseSource).toBeNull();
83+
}
84+
);
85+
86+
containerTest(
87+
"rejects resume of a billing-limit paused env and leaves it paused",
88+
async ({ prisma, redisOptions }) => {
89+
const loaded = await loadService(redisOptions);
90+
const { environment } = await seedProductionEnv(prisma);
91+
await prisma.runtimeEnvironment.update({
92+
where: { id: environment.id },
93+
data: { paused: true, pauseSource: EnvironmentPauseSource.BILLING_LIMIT },
94+
});
95+
96+
const service = new loaded.PauseEnvironmentService(prisma);
97+
const env = await authEnv(loaded, prisma, environment.id);
98+
99+
const result = await service.call(env, "resumed");
100+
expect(result.success).toBe(false);
101+
if (result.success) return;
102+
expect(result.error).toContain("billing limit");
103+
104+
const after = await prisma.runtimeEnvironment.findFirstOrThrow({
105+
where: { id: environment.id },
106+
});
107+
expect(after.paused).toBe(true);
108+
expect(after.pauseSource).toBe(EnvironmentPauseSource.BILLING_LIMIT);
109+
}
110+
);
111+
112+
containerTest(
113+
"manual pause while billing-limit paused is a no-op that preserves pauseSource",
114+
async ({ prisma, redisOptions }) => {
115+
const loaded = await loadService(redisOptions);
116+
const { environment } = await seedProductionEnv(prisma);
117+
await prisma.runtimeEnvironment.update({
118+
where: { id: environment.id },
119+
data: { paused: true, pauseSource: EnvironmentPauseSource.BILLING_LIMIT },
120+
});
121+
122+
const service = new loaded.PauseEnvironmentService(prisma);
123+
const env = await authEnv(loaded, prisma, environment.id);
124+
125+
const result = await service.call(env, "paused");
126+
// Idempotent success without overwriting pauseSource, so billing-limit
127+
// converge can still find and unpause this env on resolve.
128+
expect(result).toEqual({ success: true, state: "paused" });
129+
130+
const after = await prisma.runtimeEnvironment.findFirstOrThrow({
131+
where: { id: environment.id },
132+
});
133+
expect(after.paused).toBe(true);
134+
expect(after.pauseSource).toBe(EnvironmentPauseSource.BILLING_LIMIT);
135+
}
136+
);
137+
});

0 commit comments

Comments
 (0)