chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,186 @@
import { describe, expect, vi } from "vitest";
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
$transaction: async (
prismaClient: {
$transaction: (fn: (tx: unknown) => Promise<unknown>) => Promise<unknown>;
},
nameOrFn: string | ((tx: unknown) => Promise<unknown>),
fnOrOptions?: ((tx: unknown) => Promise<unknown>) | unknown
) => {
const fn =
typeof nameOrFn === "string" ? (fnOrOptions as (tx: unknown) => Promise<unknown>) : nameOrFn;
return prismaClient.$transaction(fn);
},
}));
import { postgresTest } from "@internal/testcontainers";
import { EnvironmentVariablesPresenter } from "~/presenters/v3/EnvironmentVariablesPresenter.server";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import {
createEnvironmentVariable,
createRuntimeEnvironment,
createTestOrgProjectWithMember,
} from "./fixtures/environmentVariablesFixtures";
vi.setConfig({ testTimeout: 60_000 });
describe("EnvironmentVariablesPresenter", () => {
postgresTest(
"keeps secret values redacted while returning non-secret values",
async ({ prisma }) => {
const { user, organization, project, projectSlug } =
await createTestOrgProjectWithMember(prisma);
const production = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
});
const repository = new EnvironmentVariablesRepository(prisma, prisma);
await createEnvironmentVariable(repository, project.id, {
environmentId: production.id,
key: "SECRET_VAR",
value: "super-secret",
isSecret: true,
userId: user.id,
});
await createEnvironmentVariable(repository, project.id, {
environmentId: production.id,
key: "PLAIN_VAR",
value: "plain-value",
isSecret: false,
userId: user.id,
});
const result = await new EnvironmentVariablesPresenter(prisma, prisma).call({
userId: user.id,
projectSlug,
});
const secretVariable = result.environmentVariables.find(
(variable) => variable.key === "SECRET_VAR"
);
const nonSecretVariable = result.environmentVariables.find(
(variable) => variable.key === "PLAIN_VAR"
);
expect(secretVariable).toBeDefined();
expect(nonSecretVariable).toBeDefined();
expect(secretVariable!.value).toBe("");
expect(nonSecretVariable!.value).toBe("plain-value");
}
);
postgresTest(
"returns values for active environments (including branch environments) and excludes archived branch environments",
async ({ prisma }) => {
const { user, organization, project, projectSlug } =
await createTestOrgProjectWithMember(prisma);
const prodEnvironment = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
});
const parentPreviewEnvironment = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PREVIEW",
});
await prisma.runtimeEnvironment.update({
where: { id: parentPreviewEnvironment.id },
data: { isBranchableEnvironment: true },
});
const activeBranchEnvironment = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PREVIEW",
});
await prisma.runtimeEnvironment.update({
where: { id: activeBranchEnvironment.id },
data: {
parentEnvironmentId: parentPreviewEnvironment.id,
branchName: "feature/active",
},
});
const archivedBranchEnvironment = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PREVIEW",
});
await prisma.runtimeEnvironment.update({
where: { id: archivedBranchEnvironment.id },
data: {
parentEnvironmentId: parentPreviewEnvironment.id,
branchName: "feature/archived",
},
});
const repository = new EnvironmentVariablesRepository(prisma, prisma);
await createEnvironmentVariable(repository, project.id, {
environmentId: prodEnvironment.id,
key: "MY_VAR",
value: "prod-value",
userId: user.id,
});
await createEnvironmentVariable(repository, project.id, {
environmentId: activeBranchEnvironment.id,
key: "MY_VAR",
value: "active-branch-value",
userId: user.id,
});
await createEnvironmentVariable(repository, project.id, {
environmentId: archivedBranchEnvironment.id,
key: "MY_VAR",
value: "archived-branch-value",
userId: user.id,
});
// Archive the branch after it accumulated values (archiving does not
// delete its EnvironmentVariableValue rows).
await prisma.runtimeEnvironment.update({
where: { id: archivedBranchEnvironment.id },
data: { archivedAt: new Date() },
});
const result = await new EnvironmentVariablesPresenter(prisma, prisma).call({
userId: user.id,
projectSlug,
});
const environmentIds = result.environments.map((environment) => environment.id);
expect(environmentIds).toContain(prodEnvironment.id);
expect(environmentIds).toContain(activeBranchEnvironment.id);
expect(environmentIds).not.toContain(archivedBranchEnvironment.id);
const myVarValues = result.environmentVariables.filter(
(variable) => variable.key === "MY_VAR"
);
expect(myVarValues).toHaveLength(2);
const prodValue = myVarValues.find(
(variable) => variable.environment.id === prodEnvironment.id
);
expect(prodValue?.value).toBe("prod-value");
const activeBranchValue = myVarValues.find(
(variable) => variable.environment.id === activeBranchEnvironment.id
);
expect(activeBranchValue?.value).toBe("active-branch-value");
expect(activeBranchValue?.environment.branchName).toBe("feature/active");
expect(
myVarValues.some((variable) => variable.environment.id === archivedBranchEnvironment.id)
).toBe(false);
}
);
});
+245
View File
@@ -0,0 +1,245 @@
// GCRARateLimiter.test.ts
import { redisTest } from "@internal/testcontainers";
import { describe, expect, vi } from "vitest";
import { GCRARateLimiter } from "../app/v3/GCRARateLimiter.server.js"; // adjust the import as needed
import Redis from "ioredis";
// Extend the timeout to 30 seconds (as in your redis tests)
vi.setConfig({ testTimeout: 30_000 });
describe("GCRARateLimiter", () => {
redisTest("should allow a single request when under the rate limit", async ({ redisOptions }) => {
const redis = new Redis(redisOptions);
const limiter = new GCRARateLimiter({
redis,
emissionInterval: 1000, // 1 request per second on average
burstTolerance: 3000, // Allows a burst of 4 requests (3 * 1000 + 1)
keyPrefix: "test:ratelimit:",
});
const result = await limiter.check("user:1");
expect(result.allowed).toBe(true);
});
redisTest(
"should allow bursts up to the configured limit and then reject further requests",
async ({ redisOptions }) => {
const redis = new Redis(redisOptions);
const limiter = new GCRARateLimiter({
redis,
emissionInterval: 1000,
burstTolerance: 3000, // With an emission interval of 1000ms, burstTolerance of 3000ms allows 4 rapid requests.
keyPrefix: "test:ratelimit:",
});
// Call 4 times in rapid succession (all should be allowed)
const results = await Promise.all([
limiter.check("user:burst"),
limiter.check("user:burst"),
limiter.check("user:burst"),
limiter.check("user:burst"),
]);
results.forEach((result) => expect(result.allowed).toBe(true));
// The 5th call should be rejected.
const fifthResult = await limiter.check("user:burst");
expect(fifthResult.allowed).toBe(false);
expect(fifthResult.retryAfter).toBeGreaterThan(0);
}
);
redisTest(
"should allow a request after the required waiting period",
async ({ redisOptions }) => {
const redis = new Redis(redisOptions);
const limiter = new GCRARateLimiter({
redis,
emissionInterval: 1000,
burstTolerance: 3000,
keyPrefix: "test:ratelimit:",
});
// Exhaust burst capacity with 4 rapid calls.
await limiter.check("user:wait");
await limiter.check("user:wait");
await limiter.check("user:wait");
await limiter.check("user:wait");
// The 5th call should be rejected.
const rejection = await limiter.check("user:wait");
expect(rejection.allowed).toBe(false);
expect(rejection.retryAfter).toBeGreaterThan(0);
// Wait for the period specified in retryAfter (plus a small buffer)
await new Promise((resolve) => setTimeout(resolve, rejection.retryAfter! + 50));
// Now the next call should be allowed.
const allowedAfterWait = await limiter.check("user:wait");
expect(allowedAfterWait.allowed).toBe(true);
}
);
redisTest(
"should rate limit independently for different identifiers",
async ({ redisOptions }) => {
const redis = new Redis(redisOptions);
const limiter = new GCRARateLimiter({
redis,
emissionInterval: 1000,
burstTolerance: 3000,
keyPrefix: "test:ratelimit:",
});
// For "user:independent", exhaust burst capacity.
await limiter.check("user:independent");
await limiter.check("user:independent");
await limiter.check("user:independent");
await limiter.check("user:independent");
const rejected = await limiter.check("user:independent");
expect(rejected.allowed).toBe(false);
// A different identifier should start fresh.
const fresh = await limiter.check("user:different");
expect(fresh.allowed).toBe(true);
}
);
redisTest("should gradually reduce retryAfter with time", async ({ redisOptions }) => {
const redis = new Redis(redisOptions);
const limiter = new GCRARateLimiter({
redis,
emissionInterval: 1000,
burstTolerance: 3000,
keyPrefix: "test:ratelimit:",
});
// Exhaust the burst capacity.
await limiter.check("user:gradual");
await limiter.check("user:gradual");
await limiter.check("user:gradual");
await limiter.check("user:gradual");
const firstRejection = await limiter.check("user:gradual");
expect(firstRejection.allowed).toBe(false);
const firstRetry = firstRejection.retryAfter!;
// Wait 500ms, then perform another check.
await new Promise((resolve) => setTimeout(resolve, 500));
const secondRejection = await limiter.check("user:gradual");
// It should still be rejected but with a smaller wait time.
expect(secondRejection.allowed).toBe(false);
const secondRetry = secondRejection.retryAfter!;
expect(secondRetry).toBeLessThan(firstRetry);
});
redisTest("should expire the key after the TTL", async ({ redisOptions }) => {
const redis = new Redis(redisOptions);
// For this test, override keyExpiration to a short value.
const keyExpiration = 1500; // 1.5 seconds TTL
const limiter = new GCRARateLimiter({
redis,
emissionInterval: 100,
burstTolerance: 300, // These values are arbitrary for this test.
keyPrefix: "test:expire:",
keyExpiration,
});
const identifier = "user:expire";
// Make a call to set the key.
const result = await limiter.check(identifier);
expect(result.allowed).toBe(true);
// Immediately verify the key exists.
const key = `test:expire:${identifier}`;
let stored = await redis.get(key);
expect(stored).not.toBeNull();
// Wait for longer than keyExpiration.
await new Promise((resolve) => setTimeout(resolve, keyExpiration + 200));
stored = await redis.get(key);
expect(stored).toBeNull();
});
redisTest("should not share state across different key prefixes", async ({ redisOptions }) => {
const redis = new Redis(redisOptions);
const limiter1 = new GCRARateLimiter({
redis,
emissionInterval: 1000,
burstTolerance: 3000,
keyPrefix: "test:ratelimit1:",
});
const limiter2 = new GCRARateLimiter({
redis,
emissionInterval: 1000,
burstTolerance: 3000,
keyPrefix: "test:ratelimit2:",
});
// Exhaust the burst capacity for a given identifier in limiter1.
await limiter1.check("user:shared");
await limiter1.check("user:shared");
await limiter1.check("user:shared");
await limiter1.check("user:shared");
const rejection1 = await limiter1.check("user:shared");
expect(rejection1.allowed).toBe(false);
// With a different key prefix, the same identifier should be fresh.
const result2 = await limiter2.check("user:shared");
expect(result2.allowed).toBe(true);
});
redisTest(
"should increment TAT correctly on sequential allowed requests",
async ({ redisOptions }) => {
const redis = new Redis(redisOptions);
const limiter = new GCRARateLimiter({
redis,
emissionInterval: 1000,
burstTolerance: 3000,
keyPrefix: "test:ratelimit:",
});
// The first request should be allowed.
const r1 = await limiter.check("user:sequential");
expect(r1.allowed).toBe(true);
// Wait a bit longer than the emission interval.
await new Promise((resolve) => setTimeout(resolve, 1100));
const r2 = await limiter.check("user:sequential");
expect(r2.allowed).toBe(true);
}
);
redisTest("should throw an error if redis command fails", async ({ redisOptions }) => {
const redis = new Redis(redisOptions);
const limiter = new GCRARateLimiter({
redis,
emissionInterval: 1000,
burstTolerance: 3000,
keyPrefix: "test:ratelimit:",
});
// Stub redis.gcra to simulate a failure.
// @ts-expect-error
const originalGcra = redis.gcra;
// @ts-ignore
redis.gcra = vi.fn(() => {
throw new Error("Simulated Redis error");
});
await expect(limiter.check("user:error")).rejects.toThrow("Simulated Redis error");
// Restore the original command.
// @ts-expect-error
redis.gcra = originalGcra;
});
});
+65
View File
@@ -0,0 +1,65 @@
# Webapp tests
Three suites live in this directory.
## Unit tests — `*.test.ts`
Run with `pnpm test` from `apps/webapp`. Default vitest pickup. No
container setup. Run on every PR via `unit-tests-webapp.yml`.
## Smoke e2e — `*.e2e.test.ts`
End-to-end auth baseline that proves the route auth plumbing is wired up.
Each file spins up its own webapp + Postgres + Redis container in
`beforeAll` (~30s startup). Vitest config: `vitest.e2e.config.ts`. Run on
every PR via `e2e-webapp.yml`.
```bash
cd apps/webapp
pnpm exec vitest --config vitest.e2e.config.ts
```
## Comprehensive auth e2e — `*.e2e.full.test.ts`
The full RBAC auth matrix — every route family with explicit pass/fail
scenarios. See TRI-8731 for the parent ticket and TRI-8732 onwards for
each family's coverage spec.
**Architecture**: one container reused across the whole suite via
`vitest.e2e.full.config.ts`'s `globalSetup`. Test files share the server
through `getTestServer()` from `helpers/sharedTestServer.ts`. Each test
seeds its own resources so order doesn't matter.
**Layout**:
| File | Top-level describe | Family subtasks |
|---|---|---|
| `auth-api.e2e.full.test.ts` | `API` | TRI-8733 trigger, TRI-8734 run resource, TRI-8735 run mutations, TRI-8736 run lists, TRI-8737 batches, TRI-8738 prompts, TRI-8739 deployments + query, TRI-8740 waitpoints + input streams, TRI-8741 PAT |
| `auth-dashboard.e2e.full.test.ts` | `Dashboard` | TRI-8742 admin pages |
| `auth-cross-cutting.e2e.full.test.ts` | `Cross-cutting` | TRI-8743 deleted projects / revoked keys / expired JWTs / env mismatch / force-fallback toggle |
**Adding a new family**: pick the relevant file, add a nested `describe`
block. Inside, seed your own fixtures via the helpers and hit the shared
server.
```ts
describe("Trigger task", () => {
const server = getTestServer();
it("missing Authorization → 401", async () => {
const res = await server.webapp.fetch("/api/v1/tasks/x/trigger", { method: "POST", body: "{}" });
expect(res.status).toBe(401);
});
});
```
**CI**: `e2e-webapp-auth-full.yml`. Triggers on `workflow_dispatch`,
nightly schedule, and PRs touching auth-relevant paths (route builders,
rbac.server.ts, apiAuth.server.ts, apiroutes, the suite itself).
**Run locally**:
```bash
cd apps/webapp
pnpm exec vitest --config vitest.e2e.full.config.ts
```
@@ -0,0 +1,549 @@
import { describe, expect, vi } from "vitest";
// The SpanPresenter module graph imports `~/v3/runStore.server`, which imports `~/db.server`
// at load (and a large transitive graph: runEngine, eventRepository, mollifier, ...). We stub the
// two boundaries the presenter reads through so the file loads under test, then drive it entirely
// against real Postgres containers — NEVER mocking a DB client.
//
// * `~/db.server` — the module-level `prisma`/`$replica` exports. The presenter receives its
// control-plane handle through the BasePresenter constructor (`new SpanPresenter(cp, cp)`), so
// these stubs are never read on the path under test.
// * `~/v3/runStore.server` — the run-ops store singleton. This is the ONE wiring boundary we
// override: the test injects a routing-shaped store (a RoutingRunStore over the two-DB hetero
// fixture) in its place. This is a wiring override, not a DB mock — every run-ops read still
// executes against a real container.
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
}));
const routingStoreRef = vi.hoisted(() => ({ current: undefined as unknown }));
vi.mock("~/v3/runStore.server", () => ({
get runStore() {
return routingStoreRef.current;
},
}));
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
import type { RunStore } from "@internal/run-store";
import { heteroPostgresTest } from "@internal/testcontainers";
import type { Prisma, PrismaClient } from "@trigger.dev/database";
import { SpanPresenter } from "~/presenters/v3/SpanPresenter.server";
vi.setConfig({ testTimeout: 90_000 });
// 25-char internal id → cuid → LEGACY; v1 internal id (26 chars, version "1" at index 25) → NEW (the residency
// classifier shared with the RoutingRunStore's default `ownerEngine`).
const CUID_25 = "c".repeat(25);
const NEW_ID_26 = "k".repeat(24) + "01";
type SeedContext = {
organizationId: string;
projectId: string;
environmentId: string;
};
async function seedParents(prisma: PrismaClient, slug: string): Promise<SeedContext> {
const organization = await prisma.organization.create({
data: { title: `org-${slug}`, slug: `org-${slug}` },
});
const project = await prisma.project.create({
data: {
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `proj-${slug}`,
},
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}`,
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_prod_${slug}`,
pkApiKey: `pk_prod_${slug}`,
shortcode: `sc-${slug}`,
},
});
return {
organizationId: organization.id,
projectId: project.id,
environmentId: runtimeEnvironment.id,
};
}
/** Mirror the org/project/env parents onto a second DB with the SAME ids (TaskRun FKs need them
* on every DB a run is hydrated from). */
async function mirrorParents(prisma: PrismaClient, ctx: SeedContext, slug: string): Promise<void> {
await prisma.organization.create({
data: { id: ctx.organizationId, title: `org-${slug}`, slug: `org-${slug}` },
});
await prisma.project.create({
data: {
id: ctx.projectId,
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: ctx.organizationId,
externalRef: `proj-${slug}`,
},
});
await prisma.runtimeEnvironment.create({
data: {
id: ctx.environmentId,
slug: `env-${slug}`,
type: "PRODUCTION",
projectId: ctx.projectId,
organizationId: ctx.organizationId,
apiKey: `tr_prod_${slug}_b`,
pkApiKey: `pk_prod_${slug}_b`,
shortcode: `sc-${slug}-b`,
},
});
}
async function createRun(
prisma: PrismaClient,
ctx: SeedContext,
run: {
id: string;
friendlyId: string;
spanId: string;
parentSpanId?: string;
taskIdentifier?: string;
status?: Prisma.TaskRunCreateInput["status"];
parentTaskRunId?: string;
rootTaskRunId?: string;
}
) {
return prisma.taskRun.create({
data: {
id: run.id,
friendlyId: run.friendlyId,
taskIdentifier: run.taskIdentifier ?? "my-task",
status: run.status ?? "COMPLETED_SUCCESSFULLY",
payload: JSON.stringify({ foo: run.friendlyId }),
payloadType: "application/json",
traceId: `trace_${run.friendlyId}`,
spanId: run.spanId,
parentSpanId: run.parentSpanId,
parentTaskRunId: run.parentTaskRunId,
rootTaskRunId: run.rootTaskRunId,
queue: "task/my-task",
runTags: ["alpha", "beta"],
runtimeEnvironmentId: ctx.environmentId,
projectId: ctx.projectId,
organizationId: ctx.organizationId,
environmentType: "PRODUCTION",
engine: "V2",
taskEventStore: "taskEvent",
},
});
}
/**
* Test-only wiring shim. In production the run-ops store's DB selection is the store's own
* concern, but `SpanPresenter` still passes `this._replica`/`this._prisma` (the control-plane
* handle) as the explicit `client` arg to `runStore.findRun`/`findRuns`. `PostgresRunStore`
* honours an explicit client (`client ?? this.readOnlyPrisma`), so without this shim a run-ops
* read would execute against the control-plane DB. Reconciling that explicit-client override
* with split routing is the job of the runStore.server.ts wiring seam, explicitly OUT of this
* unit's scope. The shim represents that reconciliation: it drops the
* presenter's client arg so each underlying PostgresRunStore reads from its OWN bound DB — the
* residency-routed behaviour the presenter will inherit once the seam is wired. It fakes ONLY the
* client wiring; every DB read still hits a real container.
*/
function ownDbStore(prisma: PrismaClient): RunStore {
const inner = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
return new Proxy(inner, {
get(target, prop) {
if (prop === "findRun" || prop === "findRuns") {
return (...args: unknown[]) => {
// Strip a trailing explicit `client` arg so the store reads from its own DB.
const stripped = stripTrailingClient(prop, args);
return (target[prop] as (...a: unknown[]) => unknown).apply(target, stripped);
};
}
const value = Reflect.get(target, prop, target);
return typeof value === "function" ? value.bind(target) : value;
},
}) as unknown as RunStore;
}
function stripTrailingClient(method: "findRun" | "findRuns", args: unknown[]): unknown[] {
// findRun(where, argsOrClient?, client?) ; findRuns(args, client?). The last arg is the
// presenter's explicit client when it is not a projection object.
const last = args[args.length - 1] as { select?: unknown; include?: unknown } | undefined;
const isProjection =
typeof last === "object" && last !== null && ("select" in last || "include" in last);
if (args.length === 0 || isProjection) {
return args;
}
return args.slice(0, -1);
}
/** A read-only client wrapper: throws on any write, asserting the legacy slot is replica-only. */
function asReplica(prisma: PrismaClient): PrismaClient {
return new Proxy(prisma, {
get(target, prop, receiver) {
if (prop === "taskRun") {
return new Proxy((target as any).taskRun, {
get(trTarget, trProp) {
if (
["create", "update", "updateMany", "upsert", "delete", "deleteMany"].includes(
String(trProp)
)
) {
return () => {
throw new Error(`legacy slot is read-replica-only; ${String(trProp)} is forbidden`);
};
}
return (trTarget as any)[trProp];
},
});
}
return Reflect.get(target, prop, receiver);
},
}) as unknown as PrismaClient;
}
describe("SpanPresenter run-ops/control-plane partition (legacy + new)", () => {
// Span detail resolves run + children through the run-ops store, region/schedule/session
// on control-plane, no cross-DB join.
heteroPostgresTest(
"findRun hydrates the run through the run-ops store (new-first) and the children-by-parentSpanId set; region/schedule/session resolve from the control-plane client",
async ({ prisma14, prisma17 }) => {
// prisma17 = NEW run-ops; prisma14 = LEGACY run-ops replica AND, for this partition proof,
// the control-plane DB (a physically distinct DB from the NEW run-ops store).
const cp = prisma14;
// Seed the env/project/org parents on BOTH run-ops DBs (FKs) and on the CP DB.
const ctxNew = await seedParents(prisma17, "partn");
await mirrorParents(prisma14, ctxNew, "partn"); // legacy run-ops + CP parents share ids
const runId = `run_${NEW_ID_26}`; // run-ops id → NEW residency
const childMigratedId = `run_a${NEW_ID_26.slice(1)}`; // also NEW
const parentFriendlyId = `run_p${NEW_ID_26.slice(1)}`; // v1 body → routes NEW by friendlyId
await createRun(prisma17, ctxNew, {
id: runId,
friendlyId: parentFriendlyId,
spanId: "span_parent",
taskIdentifier: "parent-task",
});
// A child whose parentSpanId points at the parent's span — lives on NEW.
await createRun(prisma17, ctxNew, {
id: childMigratedId,
friendlyId: "run_child_new",
spanId: "span_child_new",
parentSpanId: "span_parent",
parentTaskRunId: runId,
taskIdentifier: "child-task",
});
// Control-plane rows live on the CP DB only.
const workerGroup = await cp.workerInstanceGroup.create({
data: {
name: "us-east-1-group",
location: "N. Virginia, USA",
masterQueue: "main",
type: "MANAGED",
token: { create: { tokenHash: `tok-${ctxNew.projectId}` } },
},
});
const schedule = await cp.taskSchedule.create({
data: {
friendlyId: "sched_1234",
taskIdentifier: "parent-task",
projectId: ctxNew.projectId,
deduplicationKey: "dedup-1",
type: "DECLARATIVE",
generatorExpression: "0 * * * *",
generatorDescription: "every hour",
timezone: "UTC",
},
});
routingStoreRef.current = new RoutingRunStore({
new: ownDbStore(prisma17),
legacy: ownDbStore(prisma14),
});
const presenter = new SpanPresenter(cp, cp);
// (a) run hydrated through the run-ops store (NEW), byte-identical to the source row incl.
// the run-ops self-relations.
const run = await presenter.findRun({
originalRunId: parentFriendlyId,
spanId: "span_parent",
environmentId: ctxNew.environmentId,
});
expect(run?.id).toBe(runId);
expect(run?.friendlyId).toBe(parentFriendlyId);
expect(run?.taskIdentifier).toBe("parent-task");
expect(run?.runTags).toEqual(["alpha", "beta"]);
// Nested run-ops self-relation resolved on the same (NEW) store.
expect(run?.parentTaskRun).toBeNull();
// (b) the run does NOT exist on the CP DB — the run-ops read could only have come from the
// run-ops store, never a CP join.
expect(await cp.taskRun.findFirst({ where: { friendlyId: parentFriendlyId } })).toBeNull();
// (c) the control-plane standalone reads resolve from the CP client.
const region = await cp.workerInstanceGroup.findFirst({ where: { masterQueue: "main" } });
expect(region?.name).toBe(workerGroup.name);
expect(await presenter.resolveSchedule(schedule.id)).toMatchObject({
friendlyId: "sched_1234",
timezone: "UTC",
});
}
);
// Children set served by runStore.findRuns through the routing store.
heteroPostgresTest(
"triggeredRuns (children-by-parentSpanId) is served by runStore.findRuns with the presenter's exact select",
async ({ prisma14, prisma17 }) => {
const ctx = await seedParents(prisma17, "kids");
await createRun(prisma17, ctx, {
id: `run_${NEW_ID_26}`,
friendlyId: "run_parent2",
spanId: "span_p2",
});
await createRun(prisma17, ctx, {
id: `run_b${NEW_ID_26.slice(1)}`,
friendlyId: "run_kid_a",
spanId: "span_kid_a",
parentSpanId: "span_p2",
});
await createRun(prisma17, ctx, {
id: `run_c${NEW_ID_26.slice(1)}`,
friendlyId: "run_kid_b",
spanId: "span_kid_b",
parentSpanId: "span_p2",
});
const store = new RoutingRunStore({
new: ownDbStore(prisma17),
legacy: ownDbStore(prisma14),
});
const triggeredRuns = await store.findRuns({
where: { parentSpanId: "span_p2" },
select: {
friendlyId: true,
taskIdentifier: true,
spanId: true,
createdAt: true,
status: true,
},
});
expect(triggeredRuns.map((r) => r.friendlyId).sort()).toEqual(["run_kid_a", "run_kid_b"]);
// select projection holds: no `id`/`payload` leaked through.
expect(triggeredRuns[0]).not.toHaveProperty("id");
expect(triggeredRuns[0]).not.toHaveProperty("payload");
}
);
// Old in-retention run served from the legacy replica, never the primary.
heteroPostgresTest(
"a legacy-residency run resolves through the store's LEGACY slot, which exposes only a replica handle",
async ({ prisma14, prisma17 }) => {
const ctx = await seedParents(prisma14, "legacy");
const legacyRunId = `run_${CUID_25}`; // cuid → LEGACY residency
await createRun(prisma14, ctx, {
id: legacyRunId,
friendlyId: "run_legacy",
spanId: "span_legacy",
taskIdentifier: "legacy-task",
});
// The LEGACY slot is wired over a replica (read-only) handle; the NEW slot over the new DB.
const store = new RoutingRunStore({
new: ownDbStore(prisma17),
legacy: ownDbStore(asReplica(prisma14)),
});
// Routed by `id` residency (cuid → LEGACY). The presenter's findRun keys by friendlyId/spanId
// (which route NEW-default through the store today); routing by id is the store-level proof
// that the LEGACY slot serves in-retention runs. The legacy slot's replica handle forbids
// writes — proving the read route can never touch a legacy writer.
const found = await store.findRun(
{ id: legacyRunId },
{ select: { id: true, friendlyId: true, taskIdentifier: true } }
);
expect(found?.id).toBe(legacyRunId);
expect(found?.taskIdentifier).toBe("legacy-task");
}
);
// A known-migrated run is not re-probed on legacy.
heteroPostgresTest(
"a NEW-residency id is served by the NEW slot and the LEGACY slot is never invoked",
async ({ prisma14, prisma17 }) => {
const ctx = await seedParents(prisma17, "knownmig");
const newRunId = `run_${NEW_ID_26}`; // run-ops id → NEW residency
await createRun(prisma17, ctx, {
id: newRunId,
friendlyId: "run_known_new",
spanId: "span_known_new",
taskIdentifier: "new-task",
});
// LEGACY slot throws on ANY read — asserting the residency short-circuit never probes it.
const legacyThrows = new Proxy({} as RunStore, {
get(_t, prop) {
if (prop === "findRun" || prop === "findRuns") {
return () => {
throw new Error(`LEGACY slot must not be probed for a NEW id (${String(prop)})`);
};
}
return undefined;
},
});
const store = new RoutingRunStore({
new: ownDbStore(prisma17),
legacy: legacyThrows,
});
const found = await store.findRun(
{ id: newRunId },
{ select: { id: true, taskIdentifier: true } }
);
expect(found?.id).toBe(newRunId);
expect(found?.taskIdentifier).toBe("new-task");
}
);
// Passthrough (single-DB): NEW and LEGACY slots are the same store over one client.
heteroPostgresTest(
"single-DB collapses both slots to one PostgresRunStore; the presenter resolves run + children + control-plane from the one client",
async ({ prisma14 }) => {
const cp = prisma14;
const ctx = await seedParents(prisma14, "passthru");
const runId = `run_${NEW_ID_26}`;
await createRun(prisma14, ctx, {
id: runId,
friendlyId: "run_solo",
spanId: "span_solo",
taskIdentifier: "solo-task",
});
await createRun(prisma14, ctx, {
id: `run_d${NEW_ID_26.slice(1)}`,
friendlyId: "run_solo_kid",
spanId: "span_solo_kid",
parentSpanId: "span_solo",
});
const schedule = await cp.taskSchedule.create({
data: {
friendlyId: "sched_solo",
taskIdentifier: "solo-task",
projectId: ctx.projectId,
deduplicationKey: "dedup-solo",
type: "DECLARATIVE",
generatorExpression: "0 * * * *",
generatorDescription: "every hour",
timezone: "UTC",
},
});
// Both slots are the same store over the one client — the single-DB collapse.
const solo = ownDbStore(prisma14);
routingStoreRef.current = new RoutingRunStore({ new: solo, legacy: solo });
const presenter = new SpanPresenter(cp, cp);
const run = await presenter.findRun({
originalRunId: "run_solo",
spanId: "span_solo",
environmentId: ctx.environmentId,
});
expect(run?.id).toBe(runId);
expect(run?.taskIdentifier).toBe("solo-task");
// Children resolve from the same single store.
const children = await (routingStoreRef.current as RunStore).findRuns({
where: { parentSpanId: "span_solo" },
select: {
friendlyId: true,
taskIdentifier: true,
spanId: true,
createdAt: true,
status: true,
},
});
expect(children.map((c) => c.friendlyId)).toEqual(["run_solo_kid"]);
// Control-plane read from the same single client.
expect(await presenter.resolveSchedule(schedule.id)).toMatchObject({
friendlyId: "sched_solo",
});
}
);
// Cross-seam tree shape: parent on LEGACY (in-retention), child on NEW (born-new).
heteroPostgresTest(
"parent run on the legacy replica, child run on new — relations resolve across the seam, no cross-DB join",
async ({ prisma14, prisma17 }) => {
const ctx = await seedParents(prisma14, "e2e4");
await mirrorParents(prisma17, ctx, "e2e4");
const parentId = `run_${CUID_25}`; // cuid → LEGACY (in-retention)
const childId = `run_${NEW_ID_26}`; // run-ops id → NEW (born-new)
await createRun(prisma14, ctx, {
id: parentId,
friendlyId: "run_e2e_parent",
spanId: "span_e2e_parent",
taskIdentifier: "parent",
rootTaskRunId: parentId,
});
// The child lives on NEW; it links to the parent across the seam ONLY by `parentSpanId`
// (a plain indexed column — the exact key `triggeredRuns` uses), NOT by a cross-DB FK
// (`parentTaskRunId`/`rootTaskRunId` would violate the FK since the parent is on LEGACY;
// a tree's FK self-relations stay single-DB).
await createRun(prisma17, ctx, {
id: childId,
friendlyId: "run_e2e_child",
spanId: "span_e2e_child",
parentSpanId: "span_e2e_parent",
taskIdentifier: "child",
});
const store = new RoutingRunStore({
new: ownDbStore(prisma17),
legacy: ownDbStore(asReplica(prisma14)),
});
// The parent resolves from the LEGACY slot (routed by its cuid id).
const parent = await store.findRun(
{ id: parentId },
{
select: {
id: true,
friendlyId: true,
rootTaskRun: { select: { friendlyId: true } },
},
}
);
expect(parent?.id).toBe(parentId);
// Run-ops self-relation (rootTaskRun) resolves on the parent's own (LEGACY) store — a
// tree's FK self-relations stay single-DB.
expect(parent?.rootTaskRun?.friendlyId).toBe("run_e2e_parent");
// The child resolves from the NEW slot (routed by its run-ops id) and points back at the parent
// span — the cross-the-line parent/child shape, with no cross-DB join.
const child = await store.findRun(
{ id: childId },
{ select: { id: true, parentSpanId: true, friendlyId: true } }
);
expect(child?.id).toBe(childId);
expect(child?.parentSpanId).toBe("span_e2e_parent");
}
);
});
@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import {
chooseBucketSeconds,
groupRunStatus,
RUN_STATUS_GROUPS,
zeroFillGroupedSeries,
zeroFillScalarSeries,
} from "~/presenters/v3/activitySeries.server";
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
describe("chooseBucketSeconds", () => {
it("uses fine buckets for sub-hour ranges (the 5-minute bug)", () => {
// 5 minutes should NOT collapse to a single 1h bar.
expect(chooseBucketSeconds(5 * MINUTE)).toBe(5); // 60 buckets
expect(chooseBucketSeconds(1 * MINUTE)).toBe(1); // 60 buckets
expect(chooseBucketSeconds(30 * MINUTE)).toBe(30); // 60 buckets
});
it("scales the interval up for longer ranges", () => {
expect(chooseBucketSeconds(1 * HOUR)).toBe(60);
expect(chooseBucketSeconds(6 * HOUR)).toBe(300);
expect(chooseBucketSeconds(7 * DAY)).toBe(7200);
expect(chooseBucketSeconds(30 * DAY)).toBe(43200);
});
it("never exceeds the bucket ceiling", () => {
const ranges = [1 * MINUTE, 5 * MINUTE, 1 * HOUR, 24 * HOUR, 7 * DAY, 30 * DAY];
for (const range of ranges) {
const secs = chooseBucketSeconds(range);
const count = range / 1000 / secs;
expect(count).toBeLessThanOrEqual(120);
expect(count).toBeGreaterThan(0);
}
});
it("falls back to a computed interval for ranges beyond the ladder", () => {
const huge = 2000 * DAY;
const secs = chooseBucketSeconds(huge);
const count = huge / 1000 / secs;
expect(count).toBeLessThanOrEqual(120);
});
it("honours a custom target", () => {
// Smaller target => wider buckets => fewer bars.
const wide = chooseBucketSeconds(1 * HOUR, { targetBuckets: 12 });
const dense = chooseBucketSeconds(1 * HOUR, { targetBuckets: 72 });
expect(wide).toBeGreaterThan(dense);
});
});
describe("groupRunStatus", () => {
it("maps raw statuses to chart groups", () => {
expect(groupRunStatus("COMPLETED_SUCCESSFULLY")).toBe("COMPLETED");
expect(groupRunStatus("CRASHED")).toBe("FAILED");
expect(groupRunStatus("EXPIRED")).toBe("CANCELED");
expect(groupRunStatus("EXECUTING")).toBe("RUNNING");
expect(groupRunStatus("SOMETHING_UNKNOWN")).toBeUndefined();
});
});
describe("zeroFillGroupedSeries", () => {
it("emits a contiguous, fully zero-filled series", () => {
const from = new Date("2026-06-22T00:00:00.000Z");
const to = new Date("2026-06-22T00:00:05.000Z"); // 5 seconds
const bucketSeconds = 1;
const at2s = Math.floor(new Date("2026-06-22T00:00:02.000Z").getTime() / 1000);
const points = zeroFillGroupedSeries({
rows: [{ bucket: at2s, status: "COMPLETED_SUCCESSFULLY", val: 3 }],
from,
to,
bucketSeconds,
orderedKeys: RUN_STATUS_GROUPS,
groupFn: groupRunStatus,
fallbackKey: "RUNNING",
});
expect(points).toHaveLength(5);
// Every point has every key (stable legend).
for (const p of points) {
for (const key of RUN_STATUS_GROUPS) {
expect(typeof p[key]).toBe("number");
}
}
// The matching bucket carries the value; the rest are zero.
const filled = points.find((p) => p.bucket === at2s * 1000);
expect(filled?.COMPLETED).toBe(3);
expect(points.filter((p) => p.COMPLETED > 0)).toHaveLength(1);
});
it("uses identity grouping when no groupFn is provided", () => {
const from = new Date("2026-06-22T00:00:00.000Z");
const to = new Date("2026-06-22T00:00:02.000Z");
const at0 = Math.floor(from.getTime() / 1000);
const points = zeroFillGroupedSeries({
rows: [{ bucket: at0, status: "ACTIVE", val: 7 }],
from,
to,
bucketSeconds: 1,
orderedKeys: ["ACTIVE", "CLOSED", "EXPIRED"] as const,
});
expect(points).toHaveLength(2);
expect(points[0].ACTIVE).toBe(7);
expect(points[0].CLOSED).toBe(0);
});
});
describe("zeroFillScalarSeries", () => {
it("zero-fills a single series", () => {
const from = new Date("2026-06-22T00:00:00.000Z");
const to = new Date("2026-06-22T00:00:03.000Z");
const at1 = Math.floor(new Date("2026-06-22T00:00:01.000Z").getTime() / 1000);
const points = zeroFillScalarSeries({
rows: [{ bucket: at1, val: 42 }],
from,
to,
bucketSeconds: 1,
seriesKey: "cost",
});
expect(points).toHaveLength(3);
expect(points.find((p) => p.bucket === at1 * 1000)?.cost).toBe(42);
expect(points.filter((p) => p.cost > 0)).toHaveLength(1);
});
});
@@ -0,0 +1,54 @@
import { redisTest } from "@internal/testcontainers";
import { type RedisOptions } from "ioredis";
import { describe, expect, vi } from "vitest";
import { type RedisWithClusterOptions } from "../app/redis.server.js";
import {
AI_TITLE_RATE_LIMIT_ATTEMPTS,
createAITitleRateLimiter,
} from "../app/v3/services/aiTitleRateLimiter.server.js";
vi.setConfig({ testTimeout: 60_000 });
// Plaintext container: without tlsDisabled the client attempts TLS, the
// connection fails, and @upstash/ratelimit fails open (allowing everything).
const toRedisOptions = (o: RedisOptions): RedisWithClusterOptions => ({
host: o.host,
port: o.port,
username: o.username,
password: o.password,
tlsDisabled: true,
});
let seq = 0;
const userKey = (label: string) => `user:${label}-${seq++}`;
// The query ai-title endpoint isn't covered by the global apiRateLimiter, so
// this per-user limiter is the only thing bounding it.
describe("aiTitleRateLimiter", () => {
redisTest("allows up to the limit then blocks further attempts", async ({ redisOptions }) => {
const limiter = createAITitleRateLimiter(toRedisOptions(redisOptions));
const key = userKey("loop");
for (let i = 0; i < AI_TITLE_RATE_LIMIT_ATTEMPTS; i++) {
const r = await limiter.limit(key);
expect(r.success).toBe(true);
}
const blocked = await limiter.limit(key);
expect(blocked.success).toBe(false);
});
redisTest("scopes the limit per user", async ({ redisOptions }) => {
const limiter = createAITitleRateLimiter(toRedisOptions(redisOptions));
const victim = userKey("victim");
const bystander = userKey("bystander");
for (let i = 0; i < AI_TITLE_RATE_LIMIT_ATTEMPTS; i++) {
await limiter.limit(victim);
}
expect((await limiter.limit(victim)).success).toBe(false);
// A different user is unaffected.
expect((await limiter.limit(bystander)).success).toBe(true);
});
});
+426
View File
@@ -0,0 +1,426 @@
/**
* E2E auth baseline tests.
*
* These tests capture current auth behavior before the apiBuilder migration to RBAC.
* Run them before and after the migration to verify behavior is identical.
*
* Requires a pre-built webapp: pnpm run build --filter webapp
*/
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { TestServer } from "@internal/testcontainers/webapp";
import { startTestServer } from "@internal/testcontainers/webapp";
import { generateJWT } from "@trigger.dev/core/v3/jwt";
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
import { seedTestPAT, seedTestUser } from "./helpers/seedTestPAT";
import { seedTestRun } from "./helpers/seedTestRun";
import { seedTestWaitpoint } from "./helpers/seedTestWaitpoint";
vi.setConfig({ testTimeout: 180_000 });
// Shared across all tests in this file — one postgres container + one webapp instance.
let server: TestServer;
beforeAll(async () => {
server = await startTestServer();
}, 180_000);
afterAll(async () => {
await server?.stop();
}, 120_000);
async function generateTestJWT(
environment: { id: string; apiKey: string },
options: { scopes?: string[] } = {}
): Promise<string> {
const scopes = options.scopes ?? ["read:runs"];
return generateJWT({
secretKey: environment.apiKey,
payload: { pub: true, sub: environment.id, scopes },
expirationTime: "15m",
});
}
describe("API bearer auth — baseline behavior", () => {
it("valid API key: auth passes (404 not 401)", async () => {
const { apiKey } = await seedTestEnvironment(server.prisma);
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
headers: { Authorization: `Bearer ${apiKey}` },
});
// Auth passed — resource just doesn't exist
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
});
it("missing Authorization header: 401", async () => {
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result");
expect(res.status).toBe(401);
});
it("invalid API key: 401", async () => {
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
headers: { Authorization: "Bearer tr_dev_completely_invalid_key_xyz_not_real" },
});
expect(res.status).toBe(401);
});
it("401 response has error field", async () => {
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result");
const body = await res.json();
expect(body).toHaveProperty("error");
});
});
describe("JWT bearer auth — baseline behavior", () => {
it("valid JWT on JWT-enabled route: auth passes", async () => {
const { environment } = await seedTestEnvironment(server.prisma);
const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] });
// /api/v1/runs has allowJWT: true with superScopes: ["read:runs", ...]
const res = await server.webapp.fetch("/api/v1/runs", {
headers: { Authorization: `Bearer ${jwt}` },
});
// Auth passed — 200 (empty list) or 400 (bad search params), not 401
expect(res.status).not.toBe(401);
});
it("valid JWT on non-JWT route: 401", async () => {
const { environment } = await seedTestEnvironment(server.prisma);
const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] });
// /api/v1/runs/$runParam/result does NOT have allowJWT: true
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(res.status).toBe(401);
});
it("JWT with empty scopes on JWT-enabled route: 403", async () => {
const { environment } = await seedTestEnvironment(server.prisma);
const jwt = await generateTestJWT(environment, { scopes: [] });
const res = await server.webapp.fetch("/api/v1/runs", {
headers: { Authorization: `Bearer ${jwt}` },
});
// Empty scopes → no read:runs permission → 403
expect(res.status).toBe(403);
});
it("JWT signed with wrong key: 401", async () => {
const { environment } = await seedTestEnvironment(server.prisma);
const jwt = await generateJWT({
secretKey: "wrong-signing-key-that-does-not-match-environment-key",
payload: { pub: true, sub: environment.id, scopes: ["read:runs"] },
});
const res = await server.webapp.fetch("/api/v1/runs", {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(res.status).toBe(401);
});
});
// Exercises the RBAC plugin loader end-to-end. The test server boots
// with RBAC_FORCE_FALLBACK=1 (see internal-packages/testcontainers/src/webapp.ts),
// which makes rbac.server.ts use the default fallback regardless of
// whether a plugin is installed in node_modules. /admin/concurrency
// uses rbac.authenticateSession internally; an unauthenticated request
// must flow through LazyController → RoleBaseAccessFallback →
// redirect("/login").
describe("RBAC plugin — fallback wiring", () => {
it("unauthenticated dashboard route redirects to /login via the fallback", async () => {
const res = await server.webapp.fetch("/admin/concurrency", { redirect: "manual" });
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(new URL(location, "http://placeholder").pathname).toBe("/login");
});
});
// Covers createActionApiRoute's bearer auth path. The target route is
// POST /api/v1/idempotencyKeys/:key/reset — allowJWT: true, superScopes: ["write:runs", "admin"].
// Tests assert HTTP-observable behavior so they remain valid after TRI-8719 swaps
// authenticateApiRequestWithFailure for rbac.authenticateBearer.
describe("API bearer auth — action requests", () => {
const targetPath = "/api/v1/idempotencyKeys/does-not-exist/reset";
it("valid API key: auth passes (body validation fails, not 401/403)", async () => {
const { apiKey } = await seedTestEnvironment(server.prisma);
const res = await server.webapp.fetch(targetPath, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
body: JSON.stringify({}), // missing taskIdentifier → zod validation error
});
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
});
it("missing Authorization header: 401", async () => {
const res = await server.webapp.fetch(targetPath, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ taskIdentifier: "noop" }),
});
expect(res.status).toBe(401);
});
it("invalid API key: 401", async () => {
const res = await server.webapp.fetch(targetPath, {
method: "POST",
headers: {
Authorization: "Bearer tr_dev_completely_invalid_key_xyz_not_real",
"content-type": "application/json",
},
body: JSON.stringify({ taskIdentifier: "noop" }),
});
expect(res.status).toBe(401);
});
});
describe("JWT bearer auth — action requests", () => {
const targetPath = "/api/v1/idempotencyKeys/does-not-exist/reset";
it("JWT with matching scope: auth passes", async () => {
const { environment } = await seedTestEnvironment(server.prisma);
const jwt = await generateTestJWT(environment, { scopes: ["write:runs"] });
const res = await server.webapp.fetch(targetPath, {
method: "POST",
headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
});
it("JWT with wrong scope (read-only) on write route: 403", async () => {
const { environment } = await seedTestEnvironment(server.prisma);
const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] });
const res = await server.webapp.fetch(targetPath, {
method: "POST",
headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" },
body: JSON.stringify({ taskIdentifier: "noop" }),
});
expect(res.status).toBe(403);
});
});
// Covers createLoaderPATApiRoute via GET /api/v1/projects/:projectRef/runs.
// authenticateApiRequestWithPersonalAccessToken rejects anything that isn't tr_pat_-prefixed
// or doesn't match a non-revoked PersonalAccessToken row.
describe("Personal access token auth", () => {
const pathFor = (ref: string) => `/api/v1/projects/${ref}/runs`;
it("missing Authorization header: 401", async () => {
const res = await server.webapp.fetch(pathFor("nonexistent"));
expect(res.status).toBe(401);
});
it("API key (tr_dev_*) on PAT-only route: 401", async () => {
const { apiKey } = await seedTestEnvironment(server.prisma);
const res = await server.webapp.fetch(pathFor("nonexistent"), {
headers: { Authorization: `Bearer ${apiKey}` },
});
expect(res.status).toBe(401);
});
it("malformed PAT (wrong prefix): 401", async () => {
const res = await server.webapp.fetch(pathFor("nonexistent"), {
headers: { Authorization: "Bearer not_a_pat_at_all_random_string" },
});
expect(res.status).toBe(401);
});
it("well-formed but unknown PAT: 401", async () => {
const res = await server.webapp.fetch(pathFor("nonexistent"), {
headers: {
Authorization: "Bearer tr_pat_0000000000000000000000000000000000000000",
},
});
expect(res.status).toBe(401);
});
it("revoked PAT: 401", async () => {
const user = await seedTestUser(server.prisma);
const { token } = await seedTestPAT(server.prisma, user.id, { revoked: true });
const res = await server.webapp.fetch(pathFor("nonexistent"), {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status).toBe(401);
});
it("valid PAT on nonexistent project: 404 (auth passes)", async () => {
const user = await seedTestUser(server.prisma);
const { token } = await seedTestPAT(server.prisma, user.id);
const res = await server.webapp.fetch(pathFor("nonexistent"), {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status).toBe(404);
});
});
// Verifies resource-scoped JWT behaviour end-to-end against a real seeded resource.
// Target: POST /api/v1/waitpoints/tokens/:waitpointFriendlyId/complete — allowJWT: true,
// authorization: { action: "write", resource: (params) => ({ waitpoints: params.waitpointFriendlyId }),
// superScopes: ["write:waitpoints", "admin"] }.
//
// The Waitpoint is seeded with status COMPLETED so the handler short-circuits with
// { success: true } once auth passes — no run-engine worker needed. "Auth passes" is
// observable as a 200 response; "auth fails" is observable as a 403.
describe("JWT bearer auth — resource-scoped scopes", () => {
const pathFor = (friendlyId: string) => `/api/v1/waitpoints/tokens/${friendlyId}/complete`;
async function seedEnvAndWaitpoint() {
const seed = await seedTestEnvironment(server.prisma);
const waitpoint = await seedTestWaitpoint(server.prisma, {
environmentId: seed.environment.id,
projectId: seed.project.id,
});
return { ...seed, waitpoint };
}
async function completeRequest(friendlyId: string, jwt: string) {
return server.webapp.fetch(pathFor(friendlyId), {
method: "POST",
headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" },
body: JSON.stringify({}),
});
}
it("scope matches exact resource id: 200", async () => {
const { environment, waitpoint } = await seedEnvAndWaitpoint();
const jwt = await generateTestJWT(environment, {
scopes: [`write:waitpoints:${waitpoint.friendlyId}`],
});
const res = await completeRequest(waitpoint.friendlyId, jwt);
expect(res.status).toBe(200);
});
it("scope targets a different resource id: 403", async () => {
const { environment, waitpoint } = await seedEnvAndWaitpoint();
const jwt = await generateTestJWT(environment, {
scopes: ["write:waitpoints:waitpoint_someoneelse000000000000000"],
});
const res = await completeRequest(waitpoint.friendlyId, jwt);
expect(res.status).toBe(403);
});
it("type-level scope (no id) grants all resources of that type: 200", async () => {
const { environment, waitpoint } = await seedEnvAndWaitpoint();
const jwt = await generateTestJWT(environment, { scopes: ["write:waitpoints"] });
const res = await completeRequest(waitpoint.friendlyId, jwt);
expect(res.status).toBe(200);
});
it("scope action mismatch (read-only on write route) with matching resource id: 403", async () => {
const { environment, waitpoint } = await seedEnvAndWaitpoint();
const jwt = await generateTestJWT(environment, {
scopes: [`read:waitpoints:${waitpoint.friendlyId}`],
});
const res = await completeRequest(waitpoint.friendlyId, jwt);
expect(res.status).toBe(403);
});
it("scope targets a different resource type: 403", async () => {
const { environment, waitpoint } = await seedEnvAndWaitpoint();
const jwt = await generateTestJWT(environment, {
scopes: ["write:runs:run_abc000000000000000000000"],
});
const res = await completeRequest(waitpoint.friendlyId, jwt);
expect(res.status).toBe(403);
});
it("admin super-scope grants access (legacy behaviour): 200", async () => {
const { environment, waitpoint } = await seedEnvAndWaitpoint();
const jwt = await generateTestJWT(environment, { scopes: ["admin"] });
const res = await completeRequest(waitpoint.friendlyId, jwt);
expect(res.status).toBe(200);
});
it("unrelated type scope with no super-scope match: 403", async () => {
const { environment, waitpoint } = await seedEnvAndWaitpoint();
const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] });
const res = await completeRequest(waitpoint.friendlyId, jwt);
expect(res.status).toBe(403);
});
});
// Pre-migration coverage for the three behavioural constraints captured in TRI-8719.
// Each test locks in an observable current behaviour that the migration must preserve:
// - custom actions (trigger/batchTrigger/update) satisfied by write:* scopes
// - multi-key resource callbacks (runs/tags/batch/tasks) — any key match grants access
// - empty resource callbacks relying on superScopes
describe("JWT bearer auth — behaviours to preserve through TRI-8719", () => {
it('custom action: type-level write:tasks scope satisfies action="trigger" (auth passes)', async () => {
const { environment } = await seedTestEnvironment(server.prisma);
// Current SDK + MCP JWTs for task-trigger use type-level scope, e.g. write:tasks.
// Legacy checkAuthorization passes via exact superScope match ["write:tasks", "admin"].
// After TRI-8719, the ACTION_ALIASES map must keep this working: trigger action is
// satisfied by a scope whose action is write.
const jwt = await generateTestJWT(environment, { scopes: ["write:tasks"] });
const res = await server.webapp.fetch("/api/v1/tasks/nonexistent-task/trigger", {
method: "POST",
headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
});
it("multi-key resource: read:tags:<tag> scope grants access to a run carrying that tag (auth passes)", async () => {
const { environment, project } = await seedTestEnvironment(server.prisma);
const { runFriendlyId } = await seedTestRun(server.prisma, {
environmentId: environment.id,
projectId: project.id,
runTags: ["my-resource-scoped-tag"],
});
const jwt = await generateTestJWT(environment, {
scopes: ["read:tags:my-resource-scoped-tag"],
});
const res = await server.webapp.fetch(`/api/v1/runs/${runFriendlyId}/trace`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
});
it("multi-key resource: read:batch:<friendlyId> scope grants access to a run in that batch (auth passes)", async () => {
const { environment, project } = await seedTestEnvironment(server.prisma);
const { runFriendlyId, batchFriendlyId } = await seedTestRun(server.prisma, {
environmentId: environment.id,
projectId: project.id,
withBatch: true,
});
const jwt = await generateTestJWT(environment, {
scopes: [`read:batch:${batchFriendlyId}`],
});
const res = await server.webapp.fetch(`/api/v1/runs/${runFriendlyId}/trace`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
});
// Empty-resource routes (api.v1.batches.ts, api.v1.idempotencyKeys.$key.reset.ts)
// currently DENY all JWTs because legacy checkAuthorization's empty-resource check
// fires before the superScope check. TRI-8719's plan to add explicit { type: "runs" }
// changes this to "JWTs with read:runs or write:runs now work on these routes" — an
// intentional improvement, not a preserved behaviour. See TRI-8719 description for
// the note; there's nothing to lock in with a test here.
});
// Edge cases where auth-path DB state should cause 401 even with a valid-looking token.
describe("API bearer auth — environment/project edge cases", () => {
it("valid API key whose project is soft-deleted: 401", async () => {
const { apiKey, project } = await seedTestEnvironment(server.prisma);
await server.prisma.project.update({
where: { id: project.id },
data: { deletedAt: new Date() },
});
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
headers: { Authorization: `Bearer ${apiKey}` },
});
expect(res.status).toBe(401);
});
});
@@ -0,0 +1,198 @@
import { describe, expect, vi } from "vitest";
// Regression for the waitpoint-token completion route path: the route calls
// engine.completeWaitpoint, which must consult the cross-seam residency guard
// FIRST (routeKind "RESUME_TOKEN") and only then delegate the completion. A
// guard that throws (unclassifiable) must propagate loudly and leave the
// waitpoint PENDING — never a silent local apply. Single default store only.
import { RunEngine } from "@internal/run-engine";
import { setupAuthenticatedEnvironment } from "@internal/run-engine/tests";
import { containerTest, heteroPostgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { WaitpointId } from "@trigger.dev/core/v3/isomorphic";
import { trace } from "@opentelemetry/api";
vi.setConfig({ testTimeout: 60_000 });
type CrossSeamGuard = ConstructorParameters<typeof RunEngine>[0]["crossSeamGuard"];
function buildEngine(opts: { prisma: any; redisOptions: any; crossSeamGuard?: CrossSeamGuard }) {
return new RunEngine({
prisma: opts.prisma,
...(opts.crossSeamGuard ? { crossSeamGuard: opts.crossSeamGuard } : {}),
worker: {
redis: opts.redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: {
redis: opts.redisOptions,
},
runLock: {
redis: opts.redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
}
describe("waitpoint-token complete route — cross-seam guard", () => {
// The completion path consults the guard FIRST with routeKind RESUME_TOKEN
// recording the waitpointId, then delegates and the waitpoint becomes COMPLETED.
containerTest(
"consults the guard first (RESUME_TOKEN), then completes (single-store)",
async ({ prisma, redisOptions }) => {
const seen: Array<{ waitpointId: string; routeKind: string }> = [];
const engine = buildEngine({
prisma,
redisOptions,
crossSeamGuard: async ({ waitpointId, routeKind }) => {
seen.push({ waitpointId, routeKind });
// Single-store / split-OFF returns the single ("legacy") store; the
// engine delegates regardless of decision.store.
return { store: "legacy", residency: "LEGACY", routeKind };
},
});
try {
const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const { waitpoint } = await engine.createManualWaitpoint({
environmentId: env.id,
projectId: env.project.id,
});
expect(waitpoint.status).toBe("PENDING");
await engine.completeWaitpoint({
id: waitpoint.id,
output: { value: "{}", isError: false },
});
// The guard was consulted first, with the right id + RESUME_TOKEN route kind.
expect(seen).toEqual([{ waitpointId: waitpoint.id, routeKind: "RESUME_TOKEN" }]);
const after = await prisma.waitpoint.findFirst({ where: { id: waitpoint.id } });
expect(after?.status).toBe("COMPLETED");
} finally {
await engine.quit();
}
}
);
// An injected guard that throws (unclassifiable) causes completeWaitpoint
// to reject and the waitpoint stays PENDING (loud, not silently applied).
containerTest(
"propagates a guard throw and leaves the waitpoint PENDING (loud)",
async ({ prisma, redisOptions }) => {
const engine = buildEngine({
prisma,
redisOptions,
crossSeamGuard: async () => {
throw new Error("UnclassifiableRunId");
},
});
try {
const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const { waitpoint } = await engine.createManualWaitpoint({
environmentId: env.id,
projectId: env.project.id,
});
expect(waitpoint.status).toBe("PENDING");
await expect(
engine.completeWaitpoint({ id: waitpoint.id, output: { value: "{}", isError: false } })
).rejects.toThrow();
// The throw short-circuited before delegation — no silent local apply.
const after = await prisma.waitpoint.findFirst({ where: { id: waitpoint.id } });
expect(after?.status).toBe("PENDING");
} finally {
await engine.quit();
}
}
);
});
// no-FK-abort: with the Waitpoint table split off control-plane, the env/project Cascade
// FKs are physically absent. Completing a waitpoint (status flip) must not trip a now-missing FK
// on EITHER the PG14 (legacy) or PG17 (new) store. Seed + complete on the SAME store (single-store
// write, no two-store router). The DB is never mocked: writes hit the real PG14/PG17 containers.
const WAITPOINT_CROSS_SEAM_FKS = [
"Waitpoint_environmentId_fkey",
"Waitpoint_projectId_fkey",
] as const;
async function dropWaitpointCrossSeamFks(prisma: PrismaClient) {
for (const c of WAITPOINT_CROSS_SEAM_FKS) {
await prisma.$executeRawUnsafe(`ALTER TABLE "Waitpoint" DROP CONSTRAINT IF EXISTS "${c}"`);
}
}
let waitpointSeq = 0;
// Seed a PENDING MANUAL waitpoint, then complete it (status flip) on the SAME store.
async function seedAndCompleteOnStore(store: PrismaClient) {
const s = waitpointSeq++;
const { id, friendlyId } = WaitpointId.generate();
await store.waitpoint.create({
data: {
id,
friendlyId,
type: "MANUAL",
status: "PENDING",
idempotencyKey: `idem_nofk_${s}`,
userProvidedIdempotencyKey: false,
// No matching env/project row on this store (they'd live on the other DB).
environmentId: `env_other_db_${s}`,
projectId: `proj_other_db_${s}`,
},
});
await store.waitpoint.updateMany({
where: { id, status: "PENDING" },
data: {
status: "COMPLETED",
completedAt: new Date(),
output: JSON.stringify({ value: "{}" }),
outputType: "application/json",
outputIsError: false,
},
});
return store.waitpoint.findFirst({ where: { id }, select: { id: true, status: true } });
}
describe("waitpoint-token complete route — no FK abort across the PG14<->17 boundary", () => {
heteroPostgresTest(
"completes a run-ops waitpoint on each version store without tripping the absent control-plane Cascade FK",
async ({ prisma14, prisma17 }) => {
const legacy = prisma14 as unknown as PrismaClient;
const next = prisma17 as unknown as PrismaClient;
await dropWaitpointCrossSeamFks(legacy);
const completedOnLegacy = await seedAndCompleteOnStore(legacy);
expect(completedOnLegacy).not.toBeNull();
expect(completedOnLegacy!.status).toBe("COMPLETED");
await dropWaitpointCrossSeamFks(next);
const completedOnNew = await seedAndCompleteOnStore(next);
expect(completedOnNew).not.toBeNull();
expect(completedOnNew!.status).toBe("COMPLETED");
}
);
});
@@ -0,0 +1,376 @@
import { describe, expect, vi } from "vitest";
// Store-routed engine create/get seam + the residency-keyed id contract behind
// the create route (not its HTTP action). A standalone MANUAL token is cuid →
// LEGACY; NEW residency is reached only by co-locating the token with a run-ops run.
import { RunEngine } from "@internal/run-engine";
import { setupAuthenticatedEnvironment } from "@internal/run-engine/tests";
import {
containerTest,
heteroRunOpsPostgresTest,
network,
redisContainer,
redisOptions,
} from "@internal/testcontainers";
import { PostgresRunStore, RoutingRunStore, type CreateRunInput } from "@internal/run-store";
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
import {
WaitpointId,
RunId,
generateRunOpsId,
ownerEngine,
CUID_LENGTH,
} from "@trigger.dev/core/v3/isomorphic";
import type { Prisma, PrismaClient } from "@trigger.dev/database";
import { trace } from "@opentelemetry/api";
vi.setConfig({ testTimeout: 60_000 });
function buildEngine(opts: {
prisma: any;
redisOptions: any;
store?: ConstructorParameters<typeof RunEngine>[0]["store"];
}) {
return new RunEngine({
prisma: opts.prisma,
...(opts.store ? { store: opts.store } : {}),
worker: {
redis: opts.redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: {
redis: opts.redisOptions,
},
runLock: {
redis: opts.redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
}
describe("waitpoint-token create engine seam — residency-keyed id contract", () => {
// A standalone token (no owning run) mints a cuid WaitpointId and stays LEGACY.
containerTest(
"create mints a cuid WaitpointId for a standalone token (LEGACY)",
async ({ prisma, redisOptions }) => {
const engine = buildEngine({ prisma, redisOptions });
try {
const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const result = await engine.createManualWaitpoint({
environmentId: env.id,
projectId: env.project.id,
timeout: new Date(Date.now() + 60_000),
});
expect(result.waitpoint.id.length).toBe(CUID_LENGTH);
expect(result.waitpoint.type).toBe("MANUAL");
const row = await prisma.waitpoint.findUnique({ where: { id: result.waitpoint.id } });
expect(row).not.toBeNull();
expect(row?.environmentId).toBe(env.id);
// The exact response body id, computed as the route computes it.
const responseId = WaitpointId.toFriendlyId(result.waitpoint.id);
expect(responseId.startsWith("waitpoint_")).toBe(true);
expect(responseId).toBe("waitpoint_" + result.waitpoint.id);
expect(WaitpointId.fromFriendlyId(responseId)).toBe(result.waitpoint.id);
expect(ownerEngine(WaitpointId.fromFriendlyId(responseId))).toBe("LEGACY");
} finally {
await engine.quit();
}
}
);
// The standalone token id classifies LEGACY and resolves back.
containerTest(
"token id classifies to the legacy store and resolves back",
async ({ prisma, redisOptions }) => {
const engine = buildEngine({ prisma, redisOptions });
try {
const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const result = await engine.createManualWaitpoint({
environmentId: env.id,
projectId: env.project.id,
timeout: new Date(Date.now() + 60_000),
});
expect(ownerEngine(result.waitpoint.id)).toBe("LEGACY");
const resolved = await engine.getWaitpoint({
waitpointId: result.waitpoint.id,
environmentId: env.id,
projectId: env.project.id,
});
expect(resolved).not.toBeNull();
expect(resolved?.id).toBe(result.waitpoint.id);
} finally {
await engine.quit();
}
}
);
// The control-plane WaitpointTag write stays control-plane — it cannot
// route through the run-ops store, which exposes no tag-write surface at all.
containerTest(
"control-plane WaitpointTag write stays control-plane, not on the run-ops store",
async ({ prisma, redisOptions }) => {
// A run-ops store that counts every waitpoint write that passes through it.
// Overrides mirror the base PostgresRunStore generics so a base-signature
// change can't silently detach the counter.
let waitpointWrites = 0;
class CountingPostgresRunStore extends PostgresRunStore {
async upsertWaitpoint<T extends Prisma.WaitpointUpsertArgs>(
args: Prisma.SelectSubset<T, Prisma.WaitpointUpsertArgs>,
tx?: Parameters<PostgresRunStore["upsertWaitpoint"]>[1]
): Promise<Prisma.WaitpointGetPayload<T>> {
waitpointWrites++;
return super.upsertWaitpoint(args, tx);
}
async createWaitpoint<T extends Prisma.WaitpointCreateArgs>(
args: Prisma.SelectSubset<T, Prisma.WaitpointCreateArgs>,
tx?: Parameters<PostgresRunStore["createWaitpoint"]>[1]
): Promise<Prisma.WaitpointGetPayload<T>> {
waitpointWrites++;
return super.createWaitpoint(args, tx);
}
}
const countingStore = new CountingPostgresRunStore({
prisma,
readOnlyPrisma: prisma,
});
const engine = buildEngine({ prisma, redisOptions, store: countingStore });
try {
const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
// Issue the control-plane tag write directly (the same upsert the
// createWaitpointTag model performs), against the container prisma —
// control-plane, never the run-ops store.
await prisma.waitpointTag.upsert({
where: { environmentId_name: { environmentId: env.id, name: "t1" } },
create: { name: "t1", environmentId: env.id, projectId: env.project.id },
update: {},
});
const result = await engine.createManualWaitpoint({
environmentId: env.id,
projectId: env.project.id,
tags: ["t1"],
timeout: new Date(Date.now() + 60_000),
});
expect(result.waitpoint.id.length).toBe(CUID_LENGTH);
// The tag landed on the control-plane client.
const tagRow = await prisma.waitpointTag.findFirst({
where: { environmentId: env.id, name: "t1" },
});
expect(tagRow).not.toBeNull();
// The waitpoint went through the run-ops store (counting store fired).
expect(waitpointWrites).toBeGreaterThanOrEqual(1);
// The run-ops store has no tag-write surface, so the partition rests on the
// two assertions above: the tag landed on control-plane and the waitpoint went through the store.
} finally {
await engine.quit();
}
}
);
});
const twoDbEngineTest = heteroRunOpsPostgresTest.extend<{
redisContainer: any;
redisOptions: any;
}>({
network,
redisContainer,
redisOptions,
});
async function seedControlPlaneEnv(prisma: PrismaClient, suffix: string) {
const organization = await prisma.organization.create({
data: { title: `Org ${suffix}`, slug: `org-${suffix}` },
});
const project = await prisma.project.create({
data: {
name: `Project ${suffix}`,
slug: `project-${suffix}`,
externalRef: `proj_${suffix}`,
organizationId: organization.id,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
type: "PRODUCTION",
slug: `prod-${suffix}`,
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_prod_${suffix}`,
pkApiKey: `pk_prod_${suffix}`,
shortcode: `short_${suffix}`,
maximumConcurrencyLimit: 10,
},
});
return { organization, project, environment };
}
function buildCreateRunInput(params: {
runId: string;
friendlyId: string;
organizationId: string;
projectId: string;
runtimeEnvironmentId: string;
}): CreateRunInput {
return {
data: {
id: params.runId,
engine: "V2",
status: "EXECUTING",
friendlyId: params.friendlyId,
runtimeEnvironmentId: params.runtimeEnvironmentId,
environmentType: "PRODUCTION",
organizationId: params.organizationId,
projectId: params.projectId,
taskIdentifier: "parent-task",
payload: "{}",
payloadType: "application/json",
context: {},
traceContext: {},
traceId: `trace_${params.runId}`,
spanId: `span_${params.runId}`,
runTags: [],
queue: "task/parent-task",
isTest: false,
taskEventStore: "taskEvent",
depth: 0,
createdAt: new Date("2024-01-01T00:00:00.000Z"),
},
snapshot: {
engine: "V2",
executionStatus: "RUN_CREATED",
description: "Run was created",
runStatus: "PENDING",
environmentId: params.runtimeEnvironmentId,
environmentType: "PRODUCTION",
projectId: params.projectId,
organizationId: params.organizationId,
},
};
}
async function seedExecutingRunOpsRun(
prisma14: PrismaClient,
router: RoutingRunStore,
runId: string,
suffix: string
) {
const env = await seedControlPlaneEnv(prisma14, suffix);
await router.createRun(
buildCreateRunInput({
runId,
friendlyId: `run_${suffix}`,
organizationId: env.organization.id,
projectId: env.project.id,
runtimeEnvironmentId: env.environment.id,
})
);
const created = await router.findLatestExecutionSnapshot(runId);
await router.createExecutionSnapshot(
{
run: { id: runId, status: "EXECUTING", attemptNumber: 1 },
snapshot: { executionStatus: "EXECUTING", description: "run executing" },
previousSnapshotId: created!.id,
environmentId: env.environment.id,
environmentType: "PRODUCTION",
projectId: env.project.id,
organizationId: env.organization.id,
},
prisma14
);
return env;
}
function makeRouter(prisma14: PrismaClient, prisma17: RunOpsPrismaClient) {
const newStore = new PostgresRunStore({
prisma: prisma17 as never,
readOnlyPrisma: prisma17 as never,
schemaVariant: "dedicated",
});
const legacyStore = new PostgresRunStore({
prisma: prisma14,
readOnlyPrisma: prisma14,
schemaVariant: "legacy",
});
return new RoutingRunStore({ new: newStore, legacy: legacyStore });
}
describe("waitpoint-token create engine seam — NEW residency via a run-ops run across the version boundary", () => {
// NEW residency comes from co-locating the token with a run-ops run; the token
// resolves only on its owning (#new) store across the PG14<->PG17 boundary, never #legacy.
twoDbEngineTest(
"a run-ops run's token co-locates on #new and resolves only there, not on #legacy",
async ({ prisma14, prisma17, redisOptions }) => {
const p14 = prisma14 as unknown as PrismaClient;
const router = makeRouter(p14, prisma17);
const engine = buildEngine({ prisma: prisma14, redisOptions, store: router });
try {
// A NEW-classified run id (explicit run-ops id), mirroring the trigger-routing helper.
const runId = RunId.toFriendlyId(generateRunOpsId());
expect(ownerEngine(runId)).toBe("NEW");
const env = await seedExecutingRunOpsRun(p14, router, runId, "wpnew");
const { waitpoint } = await engine.createManualWaitpoint({
runId,
environmentId: env.environment.id,
projectId: env.project.id,
});
// The token is always cuid (Option B); its NEW residency comes from the run.
expect(waitpoint.id.length).toBe(CUID_LENGTH);
expect(ownerEngine(waitpoint.id)).toBe("LEGACY");
// Co-location: the token lives on #new next to its run, not on #legacy.
const onNew = await prisma17.waitpoint.findUnique({ where: { id: waitpoint.id } });
const onLegacy = await p14.waitpoint.findUnique({ where: { id: waitpoint.id } });
expect(onNew).not.toBeNull();
expect(onLegacy).toBeNull();
const resolved = await engine.getWaitpoint({
waitpointId: waitpoint.id,
environmentId: env.environment.id,
projectId: env.project.id,
});
expect(resolved?.id).toBe(waitpoint.id);
expect(await p14.waitpoint.findUnique({ where: { id: waitpoint.id } })).toBeNull();
} finally {
await engine.quit();
}
}
);
});
@@ -0,0 +1,229 @@
// Route-level regression for ApiBatchResultsPresenter: the /batches/:id/results route used to build
// the presenter with no read-through deps, collapsing to a passthrough read off the control-plane
// replica only, which 404s a NEW-resident (run-ops id) batch that lives on the dedicated run-ops DB.
import { heteroPostgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import type { PrismaReplicaClient } from "~/db.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { ApiBatchResultsPresenter } from "~/presenters/v3/ApiBatchResultsPresenter.server";
vi.setConfig({ testTimeout: 60_000 });
// 26-char v1 body (version "1" at index 25) → NEW residency. 25-char body → LEGACY residency (cuid analog).
function newRunId(c: string) {
return c.repeat(24) + "01";
}
// A prisma handle that throws on any access — proves the split path never reads the passthrough
// handles when the batch resolves off the NEW client.
const throwingPrisma = new Proxy(
{},
{
get(_t, prop) {
throw new Error(
`passthrough handle must not be touched on the split path (got .${String(prop)})`
);
},
}
) as unknown as PrismaReplicaClient;
let seedCounter = 0;
async function seedEnv(prisma: PrismaClient, slug: string) {
const n = seedCounter++;
const organization = await prisma.organization.create({
data: { title: `Org ${slug}`, slug: `org-${slug}-${n}` },
});
const project = await prisma.project.create({
data: {
name: `Proj ${slug}`,
slug: `proj-${slug}-${n}`,
organizationId: organization.id,
externalRef: `ext-${slug}-${n}`,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}-${n}`,
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `api-${slug}-${n}`,
pkApiKey: `pk-${slug}-${n}`,
shortcode: `sc-${slug}-${n}`,
},
});
return { organization, project, environment };
}
type SeedCtx = Awaited<ReturnType<typeof seedEnv>>;
// Mirror the same org/project/env ids onto the second DB so a passthrough read against the
// control-plane replica has the environment row to filter on (but NOT the batch row).
async function mirrorEnv(prisma: PrismaClient, ctx: SeedCtx, slug: string) {
const n = seedCounter++;
await prisma.organization.create({
data: { id: ctx.organization.id, title: `Org ${slug}`, slug: `org-${slug}-m-${n}` },
});
await prisma.project.create({
data: {
id: ctx.project.id,
name: `Proj ${slug}`,
slug: `proj-${slug}-m-${n}`,
organizationId: ctx.organization.id,
externalRef: `ext-${slug}-m-${n}`,
},
});
await prisma.runtimeEnvironment.create({
data: {
id: ctx.environment.id,
slug: `env-${slug}-m-${n}`,
type: "PRODUCTION",
projectId: ctx.project.id,
organizationId: ctx.organization.id,
apiKey: `api-${slug}-m-${n}`,
pkApiKey: `pk-${slug}-m-${n}`,
shortcode: `sc-${slug}-m-${n}`,
},
});
}
// Drop the per-DB TaskRunAttempt worker/queue FKs so we can seed an attempt (its output is what the
// execution-result carries) without standing up BackgroundWorker/TaskQueue parents.
async function relaxFks(prisma: PrismaClient) {
for (const sql of [
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_backgroundWorkerId_fkey"`,
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_backgroundWorkerTaskId_fkey"`,
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_queueId_fkey"`,
]) {
await prisma.$executeRawUnsafe(sql);
}
}
async function seedMember(
prisma: PrismaClient,
ctx: SeedCtx,
m: { id: string; friendlyId: string; output: string }
) {
const run = await prisma.taskRun.create({
data: {
id: m.id,
friendlyId: m.friendlyId,
taskIdentifier: "my-task",
status: "COMPLETED_SUCCESSFULLY",
payload: JSON.stringify({}),
payloadType: "application/json",
traceId: m.id,
spanId: m.id,
queue: "main",
runtimeEnvironmentId: ctx.environment.id,
projectId: ctx.project.id,
organizationId: ctx.organization.id,
environmentType: "PRODUCTION",
engine: "V2",
},
});
await prisma.taskRunAttempt.create({
data: {
friendlyId: `attempt_${m.id}`,
number: 1,
taskRunId: run.id,
backgroundWorkerId: "bw",
backgroundWorkerTaskId: "bwt",
runtimeEnvironmentId: ctx.environment.id,
queueId: "q",
status: "COMPLETED",
output: m.output,
outputType: "application/json",
},
});
return run;
}
async function seedBatch(
prisma: PrismaClient,
ctx: SeedCtx,
friendlyId: string,
memberIds: string[]
) {
const batch = await prisma.batchTaskRun.create({
data: {
friendlyId,
runtimeEnvironmentId: ctx.environment.id,
runCount: memberIds.length,
runIds: [],
batchVersion: "runengine:v2",
},
});
for (const taskRunId of memberIds) {
await prisma.batchTaskRunItem.create({
data: { batchTaskRunId: batch.id, taskRunId, status: "COMPLETED" },
});
}
return batch;
}
const env = (ctx: SeedCtx) =>
({
id: ctx.environment.id,
type: ctx.environment.type,
slug: ctx.environment.slug,
organizationId: ctx.organization.id,
organization: { slug: ctx.organization.slug, title: ctx.organization.title },
projectId: ctx.project.id,
project: { name: ctx.project.name },
}) as unknown as AuthenticatedEnvironment;
describe("ApiBatchResultsPresenter route wiring (the /batches/:id/results 404 regression)", () => {
heteroPostgresTest(
"a NEW-resident batch resolves with split deps but 404s (undefined) when built passthrough-only",
async ({ prisma14, prisma17 }) => {
const newDb = prisma17 as unknown as PrismaClient; // dedicated run-ops (NEW) DB analog
const legacyDb = prisma14 as unknown as PrismaClient; // control-plane / legacy replica analog
// Batch + members live ONLY on the NEW DB. The env is mirrored onto the legacy DB so the
// passthrough read has an environment to filter on — but never the batch row.
const ctx = await seedEnv(newDb, "route-new");
await mirrorEnv(legacyDb, ctx, "route-legacy");
await relaxFks(newDb);
const memberId = newRunId("a");
await seedMember(newDb, ctx, {
id: memberId,
friendlyId: "run_route_a",
output: JSON.stringify({ from: "new" }),
});
await seedBatch(newDb, ctx, "batch_route_new", [memberId]);
// Route wiring: splitEnabled + newClient + legacyReplica; passthrough handles throw.
const splitPresenter = new ApiBatchResultsPresenter(throwingPrisma, throwingPrisma, {
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
});
const resolved = await splitPresenter.call("batch_route_new", env(ctx));
expect(resolved).toBeDefined();
expect(resolved!.id).toBe("batch_route_new");
expect(resolved!.items).toHaveLength(1);
expect(resolved!.items[0]).toEqual({
ok: true,
id: "run_route_a",
taskIdentifier: "my-task",
output: JSON.stringify({ from: "new" }),
outputType: "application/json",
});
// Pre-fix route: no read-through deps => passthrough off the control-plane replica (the legacy
// DB, which never received the batch) => undefined, i.e. the 404.
const passthroughPresenter = new ApiBatchResultsPresenter(legacyDb, legacyDb);
const missed = await passthroughPresenter.call("batch_route_new", env(ctx));
expect(missed).toBeUndefined();
}
);
});
@@ -0,0 +1,413 @@
// Read-through proof for ApiBatchResultsPresenter.
//
// The batch row + its item rows resolve new-run-ops-first then off the LEGACY run-ops READ
// REPLICA ONLY; each member run is hydrated independently via the per-run read-through primitive,
// so a batch whose members span migrated (NEW) + abandoned (LEGACY) runs returns the
// complete reachable set. Single-DB collapses to one passthrough read. We NEVER mock the DB — the
// only injected fakes are the pure boundaries (splitEnabled / isPastRetention).
//
// The BatchTaskRunItem -> TaskRun FK is per-DB; a batch straddling the seam references member ids
// that live on the other DB, so we drop that one FK on the batch's DB at seed time (the cross-seam
// reality where the item row survives while the member's authoritative row lives on the other DB).
import { PostgresRunStore } from "@internal/run-store";
import { heteroPostgresTest, postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import type { PrismaReplicaClient } from "~/db.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { ApiBatchResultsPresenter } from "~/presenters/v3/ApiBatchResultsPresenter.server";
vi.setConfig({ testTimeout: 60_000 });
// 26-char v1 body (version "1" at index 25) → NEW residency. 25-char body → LEGACY residency (cuid analog).
function newRunId(c: string) {
return c.repeat(24) + "01";
}
function legacyRunId(c: string) {
return c.repeat(25);
}
// A prisma handle that throws on any access — proves the split path never reads it.
const throwingPrisma = new Proxy(
{},
{
get(_t, prop) {
throw new Error(
`passthrough handle must not be touched on the split path (got .${String(prop)})`
);
},
}
) as unknown as PrismaReplicaClient;
let seedCounter = 0;
async function seedEnv(prisma: PrismaClient, slug: string) {
const n = seedCounter++;
const organization = await prisma.organization.create({
data: { title: `Org ${slug}`, slug: `org-${slug}-${n}` },
});
const project = await prisma.project.create({
data: {
name: `Proj ${slug}`,
slug: `proj-${slug}-${n}`,
organizationId: organization.id,
externalRef: `ext-${slug}-${n}`,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}-${n}`,
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `api-${slug}-${n}`,
pkApiKey: `pk-${slug}-${n}`,
shortcode: `sc-${slug}-${n}`,
},
});
return { organization, project, environment };
}
type SeedCtx = Awaited<ReturnType<typeof seedEnv>>;
// Mirror the same org/project/env ids onto a second DB so member TaskRun FKs resolve there.
async function mirrorEnv(prisma: PrismaClient, ctx: SeedCtx, slug: string) {
const n = seedCounter++;
await prisma.organization.create({
data: { id: ctx.organization.id, title: `Org ${slug}`, slug: `org-${slug}-m-${n}` },
});
await prisma.project.create({
data: {
id: ctx.project.id,
name: `Proj ${slug}`,
slug: `proj-${slug}-m-${n}`,
organizationId: ctx.organization.id,
externalRef: `ext-${slug}-m-${n}`,
},
});
await prisma.runtimeEnvironment.create({
data: {
id: ctx.environment.id,
slug: `env-${slug}-m-${n}`,
type: "PRODUCTION",
projectId: ctx.project.id,
organizationId: ctx.organization.id,
apiKey: `api-${slug}-m-${n}`,
pkApiKey: `pk-${slug}-m-${n}`,
shortcode: `sc-${slug}-m-${n}`,
},
});
}
type MemberSeed = {
id: string;
friendlyId: string;
status: "COMPLETED_SUCCESSFULLY" | "COMPLETED_WITH_ERRORS";
output?: string;
error?: unknown;
};
async function seedMember(prisma: PrismaClient, ctx: SeedCtx, m: MemberSeed) {
const run = await prisma.taskRun.create({
data: {
id: m.id,
friendlyId: m.friendlyId,
taskIdentifier: "my-task",
status: m.status,
payload: JSON.stringify({}),
payloadType: "application/json",
traceId: m.id,
spanId: m.id,
queue: "main",
runtimeEnvironmentId: ctx.environment.id,
projectId: ctx.project.id,
organizationId: ctx.organization.id,
environmentType: "PRODUCTION",
engine: "V2",
},
});
await prisma.taskRunAttempt.create({
data: {
friendlyId: `attempt_${m.id}`,
number: 1,
taskRunId: run.id,
backgroundWorkerId: "bw",
backgroundWorkerTaskId: "bwt",
runtimeEnvironmentId: ctx.environment.id,
queueId: "q",
status: m.status === "COMPLETED_SUCCESSFULLY" ? "COMPLETED" : "FAILED",
output: m.output,
outputType: "application/json",
error: m.error as any,
},
});
return run;
}
// Drop the per-DB BatchTaskRunItem -> TaskRun FK so items on this DB can reference member ids whose
// authoritative TaskRun lives on the other DB (the cross-seam batch). Also drop the TaskRunAttempt
// worker/queue FKs so we can seed attempts (their output/error is what's under test) without
// standing up BackgroundWorker/TaskQueue parents — those rows are incidental to this read path.
async function relaxFks(prisma: PrismaClient) {
for (const sql of [
`ALTER TABLE "BatchTaskRunItem" DROP CONSTRAINT IF EXISTS "BatchTaskRunItem_taskRunId_fkey"`,
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_backgroundWorkerId_fkey"`,
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_backgroundWorkerTaskId_fkey"`,
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_queueId_fkey"`,
]) {
await prisma.$executeRawUnsafe(sql);
}
}
async function seedBatch(
prisma: PrismaClient,
ctx: SeedCtx,
friendlyId: string,
memberIds: string[]
) {
const batch = await prisma.batchTaskRun.create({
data: {
friendlyId,
runtimeEnvironmentId: ctx.environment.id,
runCount: memberIds.length,
runIds: [],
batchVersion: "runengine:v2",
},
});
// Items in a deterministic order so the result `items` order is assertable.
for (const taskRunId of memberIds) {
await prisma.batchTaskRunItem.create({
data: { batchTaskRunId: batch.id, taskRunId, status: "COMPLETED" },
});
}
return batch;
}
const env = (ctx: SeedCtx) =>
({
id: ctx.environment.id,
type: ctx.environment.type,
slug: ctx.environment.slug,
organizationId: ctx.organization.id,
organization: { slug: ctx.organization.slug, title: ctx.organization.title },
projectId: ctx.project.id,
project: { name: ctx.project.name },
}) as unknown as AuthenticatedEnvironment;
describe("ApiBatchResultsPresenter read-through (legacy + new DB)", () => {
// A batch with members on BOTH DBs returns the complete set, byte-identical.
heteroPostgresTest(
"members spanning NEW + legacy hydrate to the complete union, in item order",
async ({ prisma14, prisma17 }) => {
const newDb = prisma17 as unknown as PrismaClient;
const legacyDb = prisma14 as unknown as PrismaClient;
const ctx = await seedEnv(newDb, "span-new");
await mirrorEnv(legacyDb, ctx, "span-legacy");
await relaxFks(newDb);
await relaxFks(legacyDb);
const newMemberId = newRunId("a");
const legacyMemberId = legacyRunId("b");
// NEW member lives only on the new DB, legacy member only on the legacy DB.
await seedMember(newDb, ctx, {
id: newMemberId,
friendlyId: "run_new_a",
status: "COMPLETED_SUCCESSFULLY",
output: JSON.stringify({ from: "new" }),
});
await seedMember(legacyDb, ctx, {
id: legacyMemberId,
friendlyId: "run_legacy_b",
status: "COMPLETED_WITH_ERRORS",
error: { type: "BUILT_IN_ERROR", name: "Err", message: "boom", stackTrace: "" },
});
// The batch row + items live on the NEW DB; items reference both members.
await seedBatch(newDb, ctx, "batch_span", [newMemberId, legacyMemberId]);
const presenter = new ApiBatchResultsPresenter(throwingPrisma, throwingPrisma, {
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
});
const result = await presenter.call("batch_span", env(ctx));
expect(result).toBeDefined();
expect(result!.id).toBe("batch_span");
expect(result!.items).toHaveLength(2);
// Order follows item order: NEW member first, legacy member second.
const [first, second] = result!.items;
expect(first).toEqual({
ok: true,
id: "run_new_a",
taskIdentifier: "my-task",
output: JSON.stringify({ from: "new" }),
outputType: "application/json",
});
expect(second).toMatchObject({
ok: false,
id: "run_legacy_b",
taskIdentifier: "my-task",
});
}
);
// Batch row resident only on the legacy replica resolves via new-first-miss → legacy.
heteroPostgresTest(
"a legacy-resident batch row resolves off the legacy replica (new probe misses)",
async ({ prisma14, prisma17 }) => {
const newDb = prisma17 as unknown as PrismaClient;
const legacyDb = prisma14 as unknown as PrismaClient;
const ctx = await seedEnv(legacyDb, "lb-legacy");
await mirrorEnv(newDb, ctx, "lb-new");
await relaxFks(legacyDb);
const legacyMemberId = legacyRunId("c");
await seedMember(legacyDb, ctx, {
id: legacyMemberId,
friendlyId: "run_legacy_c",
status: "COMPLETED_SUCCESSFULLY",
output: JSON.stringify({ ok: 1 }),
});
// Batch row + items only on the legacy replica; absent from NEW.
await seedBatch(legacyDb, ctx, "batch_legacy", [legacyMemberId]);
const presenter = new ApiBatchResultsPresenter(throwingPrisma, throwingPrisma, {
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
});
const result = await presenter.call("batch_legacy", env(ctx));
expect(result).toBeDefined();
expect(result!.id).toBe("batch_legacy");
expect(result!.items).toHaveLength(1);
expect(result!.items[0]).toMatchObject({ ok: true, id: "run_legacy_c" });
}
);
// Past-retention / missing member is omitted (dangling-ref gate adjacent), not errored.
heteroPostgresTest(
"a member present on neither DB is omitted; the reachable members still return",
async ({ prisma14, prisma17 }) => {
const newDb = prisma17 as unknown as PrismaClient;
const legacyDb = prisma14 as unknown as PrismaClient;
const ctx = await seedEnv(newDb, "dangle-new");
await mirrorEnv(legacyDb, ctx, "dangle-legacy");
await relaxFks(newDb);
await relaxFks(legacyDb);
const presentId = newRunId("e");
const missingId = legacyRunId("f"); // referenced by an item but seeded on NO DB
await seedMember(newDb, ctx, {
id: presentId,
friendlyId: "run_present_e",
status: "COMPLETED_SUCCESSFULLY",
output: JSON.stringify({ present: true }),
});
await seedBatch(newDb, ctx, "batch_dangle", [presentId, missingId]);
const presenter = new ApiBatchResultsPresenter(throwingPrisma, throwingPrisma, {
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
});
const result = await presenter.call("batch_dangle", env(ctx));
expect(result).toBeDefined();
// The dangling member is dropped; the reachable member still returns. The dangling-reference
// termination gate (separate unit) governs whether such omission is permitted pre-termination.
expect(result!.items).toHaveLength(1);
expect(result!.items[0]).toMatchObject({ ok: true, id: "run_present_e" });
}
);
heteroPostgresTest(
"an absent batch friendlyId returns undefined (split on)",
async ({ prisma14, prisma17 }) => {
const ctx = await seedEnv(prisma17 as unknown as PrismaClient, "nf-new");
await mirrorEnv(prisma14 as unknown as PrismaClient, ctx, "nf-legacy");
const presenter = new ApiBatchResultsPresenter(throwingPrisma, throwingPrisma, {
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
});
const result = await presenter.call("batch_does_not_exist", env(ctx));
expect(result).toBeUndefined();
}
);
});
describe("ApiBatchResultsPresenter passthrough (single-DB collapse)", () => {
// Single-DB: one batch read + one store id-set hydrate; legacy boundaries never touched.
postgresTest(
"single-DB hydrates the full set and never reaches the read-through boundaries",
async ({ prisma }) => {
const ctx = await seedEnv(prisma, "pt");
await relaxFks(prisma);
const memberId = legacyRunId("g");
await seedMember(prisma, ctx, {
id: memberId,
friendlyId: "run_pt_g",
status: "COMPLETED_SUCCESSFULLY",
output: JSON.stringify({ value: 42 }),
});
await seedBatch(prisma, ctx, "batch_pt", [memberId]);
const runStore = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
// Throwing legacy replica: if the split path is entered, it blows up.
const presenter = new ApiBatchResultsPresenter(
prisma,
prisma,
{
splitEnabled: false,
legacyReplica: throwingPrisma,
},
runStore
);
const result = await presenter.call("batch_pt", env(ctx));
expect(result).toBeDefined();
expect(result!.id).toBe("batch_pt");
expect(result!.items).toHaveLength(1);
expect(result!.items[0]).toEqual({
ok: true,
id: "run_pt_g",
taskIdentifier: "my-task",
output: JSON.stringify({ value: 42 }),
outputType: "application/json",
});
}
);
postgresTest("single-DB absent batch friendlyId returns undefined", async ({ prisma }) => {
const ctx = await seedEnv(prisma, "pt-nf");
const runStore = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
const presenter = new ApiBatchResultsPresenter(
prisma,
prisma,
{ splitEnabled: false },
runStore
);
const result = await presenter.call("batch_missing", env(ctx));
expect(result).toBeUndefined();
});
});
@@ -0,0 +1,625 @@
import { containerTest, heteroPostgresTest } from "@internal/testcontainers";
import { PostgresRunStore } from "@internal/run-store";
import type { Prisma, PrismaClient } from "@trigger.dev/database";
import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
import { beforeEach, describe, expect, vi } from "vitest";
// `resolveSchedule` reads the module-level `prisma` (control-plane handle).
// Point `~/db.server`'s handles at a hoisted mutable holder each test sets to a
// real container client. Not a DB mock: reads still hit a real Postgres
// container — this only redirects which real client the module resolves.
const dbHolder = vi.hoisted(() => ({
prisma: null as PrismaClient | null,
$replica: null as PrismaClient | null,
}));
vi.mock("~/db.server", () => ({
get prisma() {
return dbHolder.prisma;
},
get $replica() {
return dbHolder.$replica;
},
}));
// Inert: payloads are inline JSON, never `application/store`, so no presign.
vi.mock("~/v3/objectStore.server", () => ({
generatePresignedUrl: vi.fn(async () => ({ success: false, error: "not-used" })),
}));
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
import type { FoundRun } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { CURRENT_API_VERSION } from "~/api/versions";
vi.setConfig({ testTimeout: 60_000 });
// The presenter's EXACT read shape — pinned so a refactor that changes
// `findRun`'s `where`/`select` must update this test in lockstep.
const commonRunSelect = {
id: true,
friendlyId: true,
status: true,
taskIdentifier: true,
createdAt: true,
startedAt: true,
updatedAt: true,
completedAt: true,
expiredAt: true,
delayUntil: true,
metadata: true,
metadataType: true,
ttl: true,
costInCents: true,
baseCostInCents: true,
usageDurationMs: true,
idempotencyKey: true,
idempotencyKeyOptions: true,
isTest: true,
depth: true,
scheduleId: true,
workerQueue: true,
region: true,
lockedToVersionId: true,
resumeParentOnCompletion: true,
batch: { select: { id: true, friendlyId: true } },
runTags: true,
} satisfies Prisma.TaskRunSelect;
const findRunSelect = {
...commonRunSelect,
traceId: true,
payload: true,
payloadType: true,
output: true,
outputType: true,
error: true,
attempts: { select: { id: true } },
attemptNumber: true,
engine: true,
taskEventStore: true,
parentTaskRun: { select: commonRunSelect },
rootTaskRun: { select: commonRunSelect },
childRuns: { select: commonRunSelect },
} satisfies Prisma.TaskRunSelect;
// Drive the read exactly as `findRun` does: the RunStore.findRun contract over
// the given store with the presenter's where+select. The scalar `lockedToVersionId`
// folds to a resolved `lockedToVersion` per node, matching the presenter's shape;
// seeded runs carry no locked version, so every node resolves to null.
async function readFoundRunViaStore(
store: PostgresRunStore,
friendlyId: string,
runtimeEnvironmentId: string
): Promise<FoundRun | null> {
const pgRow = (await store.findRun(
{ friendlyId, runtimeEnvironmentId },
{ select: findRunSelect }
)) as Record<string, any> | null;
if (!pgRow) return null;
const foldVersion = (run: Record<string, any>) => ({ ...run, lockedToVersion: null });
return {
...pgRow,
lockedToVersion: null,
parentTaskRun: pgRow.parentTaskRun ? foldVersion(pgRow.parentTaskRun) : null,
rootTaskRun: pgRow.rootTaskRun ? foldVersion(pgRow.rootTaskRun) : null,
childRuns: (pgRow.childRuns ?? []).map(foldVersion),
isBuffered: false,
} as FoundRun;
}
async function seedOrgProjectEnv(prisma: PrismaClient, suffix: string) {
const organization = await prisma.organization.create({
data: { title: `test-${suffix}`, slug: `test-${suffix}` },
});
const project = await prisma.project.create({
data: {
name: `test-${suffix}`,
slug: `test-${suffix}`,
organizationId: organization.id,
externalRef: `test-${suffix}`,
},
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: `test-${suffix}`,
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_dev_${suffix}`,
pkApiKey: `pk_dev_${suffix}`,
shortcode: `short-${suffix}`,
},
});
return { organization, project, runtimeEnvironment };
}
// Build the AuthenticatedEnvironment shape `call()` reads (id, organizationId,
// slug, project.externalRef). Only those fields are touched on the happy path.
function authEnv(
organization: { id: string },
project: { id: string; externalRef: string },
runtimeEnvironment: { id: string; slug: string }
): AuthenticatedEnvironment {
return {
id: runtimeEnvironment.id,
slug: runtimeEnvironment.slug,
organizationId: organization.id,
organization: { id: organization.id },
project: { id: project.id, externalRef: project.externalRef },
} as unknown as AuthenticatedEnvironment;
}
interface SeedRunOpts {
id: string;
friendlyId: string;
runtimeEnvironmentId: string;
projectId: string;
organizationId: string;
scheduleId?: string;
runTags?: string[];
parentTaskRunId?: string;
rootTaskRunId?: string;
metadata?: string;
}
async function seedRun(prisma: PrismaClient, opts: SeedRunOpts) {
return prisma.taskRun.create({
data: {
id: opts.id,
friendlyId: opts.friendlyId,
taskIdentifier: "my-task",
payload: JSON.stringify({ hello: "world" }),
payloadType: "application/json",
traceId: `trace_${opts.id}`,
spanId: `span_${opts.id}`,
queue: "task/my-task",
runtimeEnvironmentId: opts.runtimeEnvironmentId,
projectId: opts.projectId,
organizationId: opts.organizationId,
environmentType: "DEVELOPMENT",
engine: "V2",
runTags: opts.runTags ?? [],
scheduleId: opts.scheduleId,
parentTaskRunId: opts.parentTaskRunId,
rootTaskRunId: opts.rootTaskRunId,
metadata: opts.metadata,
metadataType: opts.metadata ? "application/json" : undefined,
},
});
}
// A TaskRunAttempt requires the BackgroundWorker -> BackgroundWorkerTask ->
// TaskQueue FK chain; seed the minimum of each for one attempt.
async function seedAttempt(
prisma: PrismaClient,
opts: { runId: string; runtimeEnvironmentId: string; projectId: string; suffix: string }
) {
const worker = await prisma.backgroundWorker.create({
data: {
friendlyId: `worker_${opts.runId}`,
contentHash: `hash_${opts.suffix}`,
version: "20260627.1",
metadata: {},
projectId: opts.projectId,
runtimeEnvironmentId: opts.runtimeEnvironmentId,
},
});
const task = await prisma.backgroundWorkerTask.create({
data: {
friendlyId: `task_${opts.runId}`,
slug: "my-task",
filePath: "src/trigger/my-task.ts",
workerId: worker.id,
projectId: opts.projectId,
runtimeEnvironmentId: opts.runtimeEnvironmentId,
},
});
const queue = await prisma.taskQueue.create({
data: {
friendlyId: `queue_${opts.runId}`,
name: "task/my-task",
projectId: opts.projectId,
runtimeEnvironmentId: opts.runtimeEnvironmentId,
},
});
return prisma.taskRunAttempt.create({
data: {
friendlyId: `attempt_${opts.runId}`,
number: 1,
taskRunId: opts.runId,
runtimeEnvironmentId: opts.runtimeEnvironmentId,
backgroundWorkerId: worker.id,
backgroundWorkerTaskId: task.id,
queueId: queue.id,
status: "EXECUTING",
},
});
}
// Seed a run plus a parent, a root, a child and one attempt — the tree that must
// round-trip. `seedTestRun.ts` only seeds a single root run, so the tree + attempt
// rows are created inline here.
async function seedRunWithTree(
prisma: PrismaClient,
base: {
runtimeEnvironmentId: string;
projectId: string;
organizationId: string;
suffix: string;
}
) {
const parentId = generateRunOpsId();
const rootId = generateRunOpsId();
const runId = generateRunOpsId();
const childId = generateRunOpsId();
await seedRun(prisma, {
id: rootId,
friendlyId: `run_${rootId}`,
runtimeEnvironmentId: base.runtimeEnvironmentId,
projectId: base.projectId,
organizationId: base.organizationId,
});
await seedRun(prisma, {
id: parentId,
friendlyId: `run_${parentId}`,
runtimeEnvironmentId: base.runtimeEnvironmentId,
projectId: base.projectId,
organizationId: base.organizationId,
rootTaskRunId: rootId,
});
const run = await seedRun(prisma, {
id: runId,
friendlyId: `run_${runId}`,
runtimeEnvironmentId: base.runtimeEnvironmentId,
projectId: base.projectId,
organizationId: base.organizationId,
parentTaskRunId: parentId,
rootTaskRunId: rootId,
runTags: ["alpha", "beta"],
metadata: JSON.stringify({ k: base.suffix }),
});
await seedRun(prisma, {
id: childId,
friendlyId: `run_${childId}`,
runtimeEnvironmentId: base.runtimeEnvironmentId,
projectId: base.projectId,
organizationId: base.organizationId,
parentTaskRunId: runId,
rootTaskRunId: rootId,
});
const attempt = await seedAttempt(prisma, {
runId,
runtimeEnvironmentId: base.runtimeEnvironmentId,
projectId: base.projectId,
suffix: base.suffix,
});
return {
run,
runFriendlyId: `run_${runId}`,
parentFriendlyId: `run_${parentId}`,
rootFriendlyId: `run_${rootId}`,
childFriendlyId: `run_${childId}`,
attemptId: attempt.id,
};
}
beforeEach(() => {
dbHolder.prisma = null;
dbHolder.$replica = null;
});
describe("ApiRetrieveRunPresenter.findRun store-routed read (single-DB invariant)", () => {
containerTest(
"returns run + attempts + tree from the store read; resolveSchedule reads control-plane prisma",
async ({ prisma }) => {
// Single-DB shape: one PostgresRunStore over the one prisma/replica pair,
// exactly as the production `runStore.server.ts` singleton constructs it.
dbHolder.prisma = prisma;
dbHolder.$replica = prisma;
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
const { project, organization, runtimeEnvironment } = await seedOrgProjectEnv(
prisma,
"single"
);
// Control-plane schedule on the SAME single client.
const scheduleId = generateRunOpsId();
await prisma.taskSchedule.create({
data: {
id: scheduleId,
friendlyId: `sched_${scheduleId}`,
externalId: "my-external-schedule",
taskIdentifier: "my-task",
generatorExpression: "0 * * * *",
generatorDescription: "Every hour",
projectId: project.id,
},
});
const tree = await seedRunWithTree(prisma, {
runtimeEnvironmentId: runtimeEnvironment.id,
projectId: project.id,
organizationId: organization.id,
suffix: "single",
});
await prisma.taskRun.update({
where: { id: tree.run.id },
data: { scheduleId },
});
const env = authEnv(organization, project, runtimeEnvironment);
const found = await readFoundRunViaStore(store, tree.runFriendlyId, env.id);
expect(found).not.toBeNull();
expect(found!.isBuffered).toBe(false);
expect(found!.friendlyId).toBe(tree.runFriendlyId);
expect(found!.parentTaskRun?.friendlyId).toBe(tree.parentFriendlyId);
expect(found!.rootTaskRun?.friendlyId).toBe(tree.rootFriendlyId);
expect(found!.childRuns.map((c) => c.friendlyId)).toEqual([tree.childFriendlyId]);
expect(found!.attempts.map((a) => a.id)).toEqual([tree.attemptId]);
expect([...found!.runTags].sort()).toEqual(["alpha", "beta"]);
// Drive the full presenter `call()` — exercises the real control-plane
// `resolveSchedule` against the module `prisma` (the container client).
const out = await new ApiRetrieveRunPresenter(CURRENT_API_VERSION).call(found!, env);
expect(out.schedule).toBeDefined();
expect(out.schedule?.externalId).toBe("my-external-schedule");
expect(out.schedule?.generator.expression).toBe("0 * * * *");
expect(out.relatedRuns.parent?.id).toBe(tree.parentFriendlyId);
expect(out.relatedRuns.root?.id).toBe(tree.rootFriendlyId);
expect(out.relatedRuns.children.map((c) => c.id)).toEqual([tree.childFriendlyId]);
expect(out.attemptCount).toBe(found!.attemptNumber ?? 0);
}
);
containerTest(
"resolveSchedule re-reads TaskSchedule off the control-plane prisma on every call (no caching)",
async ({ prisma }) => {
// Single-DB: this proves resolveSchedule re-reads `prisma.taskSchedule`
// on each call() and reflects a delete (no stale cache). The structural
// "schedule comes from the control-plane client, not the run-ops store"
// separation is proven by the cross-DB test below (distinct schedule
// client + an onLegacy=null check); in single-DB both views are the
// same physical row, so this case cannot discriminate the two paths.
dbHolder.prisma = prisma;
dbHolder.$replica = prisma;
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
const { project, organization, runtimeEnvironment } = await seedOrgProjectEnv(
prisma,
"cp-inv"
);
const scheduleId = generateRunOpsId();
await prisma.taskSchedule.create({
data: {
id: scheduleId,
friendlyId: `sched_${scheduleId}`,
taskIdentifier: "my-task",
generatorExpression: "*/5 * * * *",
projectId: project.id,
},
});
const runId = generateRunOpsId();
await seedRun(prisma, {
id: runId,
friendlyId: `run_${runId}`,
runtimeEnvironmentId: runtimeEnvironment.id,
projectId: project.id,
organizationId: organization.id,
scheduleId,
});
const env = authEnv(organization, project, runtimeEnvironment);
const found = await readFoundRunViaStore(store, `run_${runId}`, env.id);
expect(found).not.toBeNull();
expect(found!.scheduleId).toBe(scheduleId);
const presenter = new ApiRetrieveRunPresenter(CURRENT_API_VERSION);
const out = await presenter.call(found!, env);
expect(out.schedule?.id).toBe(`sched_${scheduleId}`);
// Delete the control-plane TaskSchedule row; the run-ops row is
// untouched. resolveSchedule must now return undefined — proving the
// schedule comes from `prisma.taskSchedule`, NOT from the run-ops store.
await prisma.taskSchedule.delete({ where: { id: scheduleId } });
const out2 = await presenter.call(found!, env);
expect(out2.schedule).toBeUndefined();
}
);
});
describe("ApiRetrieveRunPresenter.findRun cross-version read (PG14 + PG17)", () => {
heteroPostgresTest(
"single retrieve returns run + attempts + tree byte-identically from NEW (PG17) and LEGACY (PG14) stores",
async ({ prisma17, prisma14 }) => {
const newStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
// NEW residency subgraph on PG17.
const newEnv = await seedOrgProjectEnv(prisma17, "new");
const newTree = await seedRunWithTree(prisma17, {
runtimeEnvironmentId: newEnv.runtimeEnvironment.id,
projectId: newEnv.project.id,
organizationId: newEnv.organization.id,
suffix: "new",
});
// Distinct LEGACY residency subgraph on PG14.
const legacyEnv = await seedOrgProjectEnv(prisma14, "legacy");
const legacyTree = await seedRunWithTree(prisma14, {
runtimeEnvironmentId: legacyEnv.runtimeEnvironment.id,
projectId: legacyEnv.project.id,
organizationId: legacyEnv.organization.id,
suffix: "legacy",
});
// Read the NEW run from the PG17 store.
const foundNew = await readFoundRunViaStore(
newStore,
newTree.runFriendlyId,
newEnv.runtimeEnvironment.id
);
expect(foundNew).not.toBeNull();
expect(foundNew!.friendlyId).toBe(newTree.runFriendlyId);
expect(foundNew!.parentTaskRun?.friendlyId).toBe(newTree.parentFriendlyId);
expect(foundNew!.rootTaskRun?.friendlyId).toBe(newTree.rootFriendlyId);
expect(foundNew!.childRuns.map((c) => c.friendlyId)).toEqual([newTree.childFriendlyId]);
expect(foundNew!.attempts.map((a) => a.id)).toEqual([newTree.attemptId]);
expect([...foundNew!.runTags].sort()).toEqual(["alpha", "beta"]);
expect(foundNew!.metadata).toBe(JSON.stringify({ k: "new" }));
// Sanity: the two stores are genuinely distinct DBs — the NEW run's key
// is absent from PG14 (it was only ever written to PG17).
const leaked = await readFoundRunViaStore(
legacyStore,
newTree.runFriendlyId,
newEnv.runtimeEnvironment.id
);
expect(leaked).toBeNull();
// Read the LEGACY run from the PG14 store — byte-identical tree.
const foundLegacy = await readFoundRunViaStore(
legacyStore,
legacyTree.runFriendlyId,
legacyEnv.runtimeEnvironment.id
);
expect(foundLegacy).not.toBeNull();
expect(foundLegacy!.friendlyId).toBe(legacyTree.runFriendlyId);
expect(foundLegacy!.parentTaskRun?.friendlyId).toBe(legacyTree.parentFriendlyId);
expect(foundLegacy!.rootTaskRun?.friendlyId).toBe(legacyTree.rootFriendlyId);
expect(foundLegacy!.childRuns.map((c) => c.friendlyId)).toEqual([legacyTree.childFriendlyId]);
expect(foundLegacy!.attempts.map((a) => a.id)).toEqual([legacyTree.attemptId]);
expect(foundLegacy!.metadata).toBe(JSON.stringify({ k: "legacy" }));
}
);
heteroPostgresTest(
"schedule resolved cross-DB: run hydrated from run-ops store (PG14), schedule from a distinct control-plane client (PG17)",
async ({ prisma17, prisma14 }) => {
// Run-ops residency = LEGACY (PG14). Control-plane (TaskSchedule) lives
// on a DISTINCT client — PG17 — and is the handle the module `prisma`
// resolves to in this test. This proves the run-ops-row -> control-plane
// -schedule cross-seam read: the run comes from one DB, the schedule
// from another.
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
// Module control-plane handle -> PG17.
dbHolder.prisma = prisma17;
dbHolder.$replica = prisma17;
const legacyEnv = await seedOrgProjectEnv(prisma14, "x-run");
// The control-plane project the schedule hangs off lives on PG17.
const cpEnv = await seedOrgProjectEnv(prisma17, "x-cp");
const scheduleId = generateRunOpsId();
await prisma17.taskSchedule.create({
data: {
id: scheduleId,
friendlyId: `sched_${scheduleId}`,
externalId: "cross-db-schedule",
taskIdentifier: "my-task",
generatorExpression: "0 0 * * *",
projectId: cpEnv.project.id,
},
});
const runId = generateRunOpsId();
await seedRun(prisma14, {
id: runId,
friendlyId: `run_${runId}`,
runtimeEnvironmentId: legacyEnv.runtimeEnvironment.id,
projectId: legacyEnv.project.id,
organizationId: legacyEnv.organization.id,
scheduleId,
});
const env = authEnv(legacyEnv.organization, legacyEnv.project, legacyEnv.runtimeEnvironment);
const found = await readFoundRunViaStore(
legacyStore,
`run_${runId}`,
legacyEnv.runtimeEnvironment.id
);
expect(found).not.toBeNull();
expect(found!.scheduleId).toBe(scheduleId);
// The schedule row does NOT exist on the run-ops (PG14) client.
const onLegacy = await prisma14.taskSchedule.findFirst({ where: { id: scheduleId } });
expect(onLegacy).toBeNull();
const out = await new ApiRetrieveRunPresenter(CURRENT_API_VERSION).call(found!, env);
expect(out.schedule).toBeDefined();
expect(out.schedule?.id).toBe(`sched_${scheduleId}`);
expect(out.schedule?.externalId).toBe("cross-db-schedule");
expect(out.schedule?.generator.expression).toBe("0 0 * * *");
}
);
heteroPostgresTest(
"correct past-retention / not-found response: both stores miss => findRun returns null",
async ({ prisma17, prisma14 }) => {
const newStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
const newEnv = await seedOrgProjectEnv(prisma17, "miss-new");
const legacyEnv = await seedOrgProjectEnv(prisma14, "miss-legacy");
// A run that exists on NEITHER store (terminated + past-retention,
// observed at this layer as a miss on both underlying stores).
const goneFriendlyId = `run_${generateRunOpsId()}`;
const fromNew = await readFoundRunViaStore(
newStore,
goneFriendlyId,
newEnv.runtimeEnvironment.id
);
const fromLegacy = await readFoundRunViaStore(
legacyStore,
goneFriendlyId,
legacyEnv.runtimeEnvironment.id
);
expect(fromNew).toBeNull();
expect(fromLegacy).toBeNull();
}
);
heteroPostgresTest(
"single-DB passthrough: one PostgresRunStore over one client hydrates run + tree (self-host collapse)",
async ({ prisma17 }) => {
// The single-DB collapse: one plain PostgresRunStore over one client.
// No legacy probe / known-migrated machinery at this layer.
const store = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
const onlyEnv = await seedOrgProjectEnv(prisma17, "passthrough");
const tree = await seedRunWithTree(prisma17, {
runtimeEnvironmentId: onlyEnv.runtimeEnvironment.id,
projectId: onlyEnv.project.id,
organizationId: onlyEnv.organization.id,
suffix: "passthrough",
});
const found = await readFoundRunViaStore(
store,
tree.runFriendlyId,
onlyEnv.runtimeEnvironment.id
);
expect(found).not.toBeNull();
expect(found!.isBuffered).toBe(false);
expect(found!.parentTaskRun?.friendlyId).toBe(tree.parentFriendlyId);
expect(found!.rootTaskRun?.friendlyId).toBe(tree.rootFriendlyId);
expect(found!.childRuns.map((c) => c.friendlyId)).toEqual([tree.childFriendlyId]);
expect(found!.attempts.map((a) => a.id)).toEqual([tree.attemptId]);
}
);
});
@@ -0,0 +1,411 @@
import { describe, expect, vi } from "vitest";
// The presenter graph imports `~/v3/runStore.server` (via RunsRepository) which imports
// `~/db.server` at load, and the presenter itself reaches `~/db.server`'s `$replica` singleton
// through `findDisplayableEnvironment` and `getTaskIdentifiers`. Stub the module so those
// singleton reads resolve. This is the ONLY mock — the DB is NEVER mocked; the proxy delegates
// to the per-test REAL legacy (PG14) container so the env-lookup + task-identifier reads hit a
// real database. Everything asserted runs against real containers. Mirrors
// nextRunListPresenter.readthrough.test.ts.
const legacyReplicaHolder = vi.hoisted(() => ({ client: undefined as any }));
const newClientHolder = vi.hoisted(() => ({ client: undefined as any }));
// `ApiRunListPresenter` resolves its read ClickHouse internally via the `clickhouseFactory`
// singleton (which imports `~/env.server` and binds to a process-wide default client). Stub the
// instance module so `getClickhouseForOrganization` returns the per-test container's ClickHouse
// handle (set by each test before calling). This is a module-resolution shim — the ClickHouse is
// a REAL testcontainer, never mocked — mirroring the `~/db.server` stub below.
const clickhouseHolder = vi.hoisted(() => ({ client: undefined as any }));
vi.mock("~/services/clickhouse/clickhouseFactoryInstance.server", () => ({
clickhouseFactory: {
getClickhouseForOrganization: async () => {
if (!clickhouseHolder.client) {
throw new Error("clickhouseHolder.client not set for this test");
}
return clickhouseHolder.client;
},
},
}));
vi.mock("~/db.server", async () => {
const { Prisma } = await import("@trigger.dev/database");
const lazyProxy = (holder: { client: any }, label: string) =>
new Proxy(
{},
{
get(_t, prop) {
if (!holder.client) {
throw new Error(`${label} not set for this test`);
}
return holder.client[prop];
},
}
);
const replicaProxy = lazyProxy(legacyReplicaHolder, "legacyReplicaHolder.client");
const newProxy = lazyProxy(newClientHolder, "newClientHolder.client");
return {
prisma: replicaProxy,
$replica: replicaProxy,
runOpsNewPrisma: newProxy,
runOpsNewReplica: newProxy,
runOpsLegacyPrisma: replicaProxy,
runOpsLegacyReplica: replicaProxy,
sqlDatabaseSchema: Prisma.sql([`public`]),
};
});
import { createPostgresContainer, replicationContainerTest } from "@internal/testcontainers";
import { PrismaClient } from "@trigger.dev/database";
import { setTimeout } from "node:timers/promises";
import { CURRENT_API_VERSION } from "~/api/versions";
import { ApiRunListPresenter } from "~/presenters/v3/ApiRunListPresenter.server";
import { setupClickhouseReplication } from "./utils/replicationUtils";
vi.setConfig({ testTimeout: 90_000 });
type SeedContext = {
organizationId: string;
projectId: string;
environmentId: string;
environmentSlug: string;
};
/**
* Creates the org/project/env parents on a single prisma client. TaskRun FKs require these to
* exist on every DB a run lives on, so identical parents (same ids) are seeded on both the
* legacy (PG14) and new (PG17) databases.
*/
async function seedParents(
prisma: PrismaClient,
slug: string,
envSlug = `env-${slug}`
): Promise<SeedContext> {
const organization = await prisma.organization.create({
data: { title: `org-${slug}`, slug: `org-${slug}` },
});
const project = await prisma.project.create({
data: {
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `proj-${slug}`,
},
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: envSlug,
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_dev_${slug}`,
pkApiKey: `pk_dev_${slug}`,
shortcode: `sc-${slug}`,
},
});
return {
organizationId: organization.id,
projectId: project.id,
environmentId: runtimeEnvironment.id,
environmentSlug: runtimeEnvironment.slug,
};
}
/** Adds an extra RuntimeEnvironment (control-plane row) to an existing project. */
async function addEnvironment(
prisma: PrismaClient,
ctx: SeedContext,
slug: string,
envSlug: string
): Promise<string> {
const env = await prisma.runtimeEnvironment.create({
data: {
slug: envSlug,
type: "STAGING",
projectId: ctx.projectId,
organizationId: ctx.organizationId,
apiKey: `tr_${envSlug}_${slug}`,
pkApiKey: `pk_${envSlug}_${slug}`,
shortcode: `sc-${envSlug}-${slug}`,
},
});
return env.id;
}
/** Mirrors the org/project/env parents onto a second DB with the SAME ids. */
async function mirrorParents(prisma: PrismaClient, ctx: SeedContext, slug: string): Promise<void> {
await prisma.organization.create({
data: { id: ctx.organizationId, title: `org-${slug}`, slug: `org-${slug}` },
});
await prisma.project.create({
data: {
id: ctx.projectId,
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: ctx.organizationId,
externalRef: `proj-${slug}`,
},
});
await prisma.runtimeEnvironment.create({
data: {
id: ctx.environmentId,
slug: ctx.environmentSlug,
type: "DEVELOPMENT",
projectId: ctx.projectId,
organizationId: ctx.organizationId,
apiKey: `tr_dev_${slug}_b`,
pkApiKey: `pk_dev_${slug}_b`,
shortcode: `sc-${slug}-b`,
},
});
}
async function createRun(
prisma: PrismaClient,
ctx: SeedContext,
run: {
friendlyId: string;
taskIdentifier?: string;
status?: any;
runtimeEnvironmentId?: string;
}
) {
return prisma.taskRun.create({
data: {
friendlyId: run.friendlyId,
taskIdentifier: run.taskIdentifier ?? "my-task",
status: run.status ?? "PENDING",
payload: JSON.stringify({ foo: run.friendlyId }),
traceId: run.friendlyId,
spanId: run.friendlyId,
queue: "test",
runTags: [],
runtimeEnvironmentId: run.runtimeEnvironmentId ?? ctx.environmentId,
projectId: ctx.projectId,
organizationId: ctx.organizationId,
environmentType: "DEVELOPMENT",
engine: "V2",
},
});
}
describe("ApiRunListPresenter public /runs list (PG14 legacy + PG17 new)", () => {
// Public list serves run-ops rows through the routed store. The
// forwarded readThroughDeps thread the dual-DB union into NextRunListPresenter; the public
// payload (`{ data, pagination }`) must list the NEW legacy union, proving the public API
// surfaces routed run-ops rows. The migrated/straggler rows (run_newA/run_newB) live on BOTH
// DBs with the same id + friendlyId but a DISTINGUISHING taskIdentifier ("my-task-NEW" on PG17),
// so a row served from the threaded newClient is identifiable in the public payload.
replicationContainerTest(
"public payload lists run-ops rows served via the routed store (NEW + legacy union)",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const { url: newUrl } = await createPostgresContainer(network, {
imageTag: "docker.io/postgres:17",
});
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
legacyReplicaHolder.client = prisma;
clickhouseHolder.client = clickhouse;
// The routed store's default known-migrated probe reads `runOpsNewPrisma` -> PG17.
newClientHolder.client = prismaNew;
try {
const ctx = await seedParents(prisma, "hydrate");
await mirrorParents(prismaNew, ctx, "hydrate");
// All four runs land on PG14 (legacy + replication source -> CH gets the full id-set).
const legacyOnlyA = await createRun(prisma, ctx, { friendlyId: "run_legacyA" });
const legacyOnlyB = await createRun(prisma, ctx, { friendlyId: "run_legacyB" });
const migratedA = await createRun(prisma, ctx, { friendlyId: "run_newA" });
const migratedB = await createRun(prisma, ctx, { friendlyId: "run_newB" });
// The two "migrated" runs also live on NEW (authoritative during retention), same ids +
// friendlyIds, but a DISTINGUISHING taskIdentifier so a row served from PG17 is
// identifiable in the public payload.
await createRun(prismaNew, ctx, { friendlyId: "run_newA", taskIdentifier: "my-task-NEW" });
await createRun(prismaNew, ctx, { friendlyId: "run_newB", taskIdentifier: "my-task-NEW" });
await prismaNew.taskRun.update({
where: { friendlyId: "run_newA" },
data: { id: migratedA.id },
});
await prismaNew.taskRun.update({
where: { friendlyId: "run_newB" },
data: { id: migratedB.id },
});
// Wait for CH replication so the id-set page is non-empty.
await setTimeout(1500);
const presenter = new ApiRunListPresenter(prisma, prisma, {
newClient: prismaNew,
legacyReplica: prisma,
splitEnabled: true,
});
const result = await presenter.call(
{ id: ctx.projectId },
{ "page[size]": 10 } as any,
CURRENT_API_VERSION,
{ id: ctx.environmentId, organizationId: ctx.organizationId }
);
// The public payload lists runs by `id` = `run.friendlyId`, id-desc ordered.
const expectedFriendlyIds = [
{ id: migratedA.id, friendlyId: "run_newA" },
{ id: migratedB.id, friendlyId: "run_newB" },
{ id: legacyOnlyA.id, friendlyId: "run_legacyA" },
{ id: legacyOnlyB.id, friendlyId: "run_legacyB" },
]
.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0))
.map((r) => r.friendlyId);
expect(result.data.map((r) => r.id)).toEqual(expectedFriendlyIds);
// The migrated rows must carry the PG17-only taskIdentifier — only possible if the public
// path hydrated them through the threaded newClient (PG17). taskKind falls back to STANDARD.
const migratedRow = result.data.find((r) => r.id === "run_newA");
expect(migratedRow?.taskIdentifier).toBe("my-task-NEW");
expect(migratedRow?.taskKind).toBe("STANDARD");
// The legacy-only rows surface from PG14, proving the legacyReplica is also exercised.
expect(result.data.find((r) => r.id === "run_legacyA")?.taskIdentifier).toBe("my-task");
// Pagination shape is present.
expect(result.pagination).toHaveProperty("next");
expect(result.pagination).toHaveProperty("previous");
} finally {
await prismaNew.$disconnect();
}
}
);
// Genuinely-empty env returns { data: [], pagination } without error. Exercises the
// empty-state probe beneath NextRunListPresenter (no rows on either DB; empty CH page).
replicationContainerTest(
"genuinely-empty env returns { data: [], pagination } without error",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const { url: newUrl } = await createPostgresContainer(network, {
imageTag: "docker.io/postgres:17",
});
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
legacyReplicaHolder.client = prisma;
clickhouseHolder.client = clickhouse;
try {
const ctx = await seedParents(prisma, "empty");
await mirrorParents(prismaNew, ctx, "empty");
const presenter = new ApiRunListPresenter(prisma, prisma, {
newClient: prismaNew,
legacyReplica: prisma,
splitEnabled: true,
});
const result = await presenter.call(
{ id: ctx.projectId },
{ "page[size]": 10 } as any,
CURRENT_API_VERSION,
{ id: ctx.environmentId, organizationId: ctx.organizationId }
);
expect(result.data).toEqual([]);
expect(result.pagination).toHaveProperty("next");
expect(result.pagination).toHaveProperty("previous");
} finally {
await prismaNew.$disconnect();
}
}
);
// Env scoping unchanged: the control-plane runtimeEnvironment.findMany lookup
// resolves the requested env via the `_replica` handle (NOT routed), with the 4th `environment`
// arg omitted to force that branch. Result is scoped to the requested env only.
replicationContainerTest(
"env scoping resolves via the control-plane _replica handle (filter[env], 4th arg omitted)",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
legacyReplicaHolder.client = prisma;
clickhouseHolder.client = clickhouse;
const ctx = await seedParents(prisma, "scoping", "prod");
const stagingEnvId = await addEnvironment(prisma, ctx, "scoping", "staging");
// Runs in prod only; a run in staging must NOT surface when filter[env]=prod.
await createRun(prisma, ctx, { friendlyId: "run_prod1" });
await createRun(prisma, ctx, { friendlyId: "run_prod2" });
await createRun(prisma, ctx, {
friendlyId: "run_staging",
runtimeEnvironmentId: stagingEnvId,
});
await setTimeout(1500);
// Single-handle passthrough; the env lookup runs on `_replica` (= prisma) via findMany.
const presenter = new ApiRunListPresenter(prisma, prisma);
// 4th `environment` arg OMITTED -> forces the runtimeEnvironment.findMany branch.
const result = await presenter.call(
{ id: ctx.projectId },
{ "page[size]": 10, "filter[env]": ["prod"] } as any,
CURRENT_API_VERSION
);
// Scoped to the resolved prod env only.
expect(result.data.map((r) => r.id).sort()).toEqual(["run_prod1", "run_prod2"]);
}
);
// Passthrough (single-DB): two-arg-style construction (no readThroughDeps) ->
// NextRunListPresenter receives undefined deps -> byte-identical single-DB path. The public
// { data, pagination } shape is unchanged.
replicationContainerTest(
"single-DB passthrough: no readThroughDeps lists the seeded runs unchanged",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
legacyReplicaHolder.client = prisma;
clickhouseHolder.client = clickhouse;
const ctx = await seedParents(prisma, "passthrough");
await createRun(prisma, ctx, { friendlyId: "run_pt1" });
await createRun(prisma, ctx, { friendlyId: "run_pt2" });
await setTimeout(1500);
// No readThroughDeps -> passthrough, exactly as the routes construct it today.
const presenter = new ApiRunListPresenter(prisma, prisma);
const result = await presenter.call(
{ id: ctx.projectId },
{ "page[size]": 10 } as any,
CURRENT_API_VERSION,
{ id: ctx.environmentId, organizationId: ctx.organizationId }
);
expect(result.data.map((r) => r.id).sort()).toEqual(["run_pt1", "run_pt2"]);
expect(result).toHaveProperty("pagination");
expect(result.pagination).toHaveProperty("next");
expect(result.pagination).toHaveProperty("previous");
}
);
});
@@ -0,0 +1,389 @@
// Read-through proof for the public single-run result poll (ApiRunResultPresenter). The presenter
// routes its TaskRun(+attempts) lookup-by-friendlyId through readThroughRun: split mode resolves
// from new first then the legacy READ REPLICA on a new-probe miss (never a primary),
// past-retention → undefined → the route's normal 404; single-DB is one plain findFirst. NEVER mock
// the DB — the cross-version proof uses a heterogeneous legacy+new Postgres fixture; only pure
// boundaries (splitEnabled/isPastRetention) are injected.
import { heteroPostgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
import { customAlphabet } from "nanoid";
import { describe, expect, vi } from "vitest";
import { ApiRunResultPresenter } from "~/presenters/v3/ApiRunResultPresenter.server";
import type { PrismaReplicaClient } from "~/db.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
// Neutralize the db.server singleton so importing the presenter (via BasePresenter) and
// readThrough.server (which imports db.server defaults) does not try to connect to the env
// database. Every read in this file goes through clients we inject explicitly.
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
vi.setConfig({ testTimeout: 60_000 });
const idGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", 21);
// Residency by friendlyId shape (after stripping `run_`): a valid 26-char v1 body (version "1" at
// index 25, base32hex core) → NEW; a 25-char body → LEGACY (cuid analog). ownerEngine classifies on
// the public friendly id, so newFriendlyId uses the real generator to produce a NEW-classified body.
function newFriendlyId(): string {
return "run_" + generateRunOpsId();
}
function legacyFriendlyId(): string {
return "run_" + customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789", 25)();
}
function authEnv(environmentId: string): AuthenticatedEnvironment {
// The presenter only reads env.id (the runtimeEnvironmentId filter) and the tracing attrs.
return {
id: environmentId,
project: { id: "p", name: "p" },
organization: { id: "o", title: "o" },
orgMember: null,
} as unknown as AuthenticatedEnvironment;
}
type SeedContext = {
environmentId: string;
projectId: string;
organizationId: string;
backgroundWorkerId: string;
backgroundWorkerTaskId: string;
queueId: string;
};
async function seedEnv(prisma: PrismaClient, slug: string) {
const user = await prisma.user.create({
data: { email: `${slug}@test.com`, name: "t", authenticationMethod: "MAGIC_LINK" },
});
const organization = await prisma.organization.create({
data: {
title: "Org",
slug: `org-${slug}-${idGenerator()}`,
members: { create: { userId: user.id, role: "ADMIN" } },
},
});
const project = await prisma.project.create({
data: {
name: "Proj",
slug: `proj-${slug}-${idGenerator()}`,
organizationId: organization.id,
externalRef: `ext-${slug}-${idGenerator()}`,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}`,
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `api-${slug}-${idGenerator()}`,
pkApiKey: `pk-${slug}-${idGenerator()}`,
shortcode: `sc-${slug}-${idGenerator()}`,
},
});
return { organization, project, environment };
}
async function seedWorker(prisma: PrismaClient, ctx: { environmentId: string; projectId: string }) {
const queue = await prisma.taskQueue.create({
data: {
friendlyId: `queue_${idGenerator()}`,
name: "task/test-task",
projectId: ctx.projectId,
runtimeEnvironmentId: ctx.environmentId,
},
});
const worker = await prisma.backgroundWorker.create({
data: {
friendlyId: `worker_${idGenerator()}`,
contentHash: "hash",
projectId: ctx.projectId,
runtimeEnvironmentId: ctx.environmentId,
version: "20240101.1",
metadata: {},
},
});
const task = await prisma.backgroundWorkerTask.create({
data: {
friendlyId: `task_${idGenerator()}`,
slug: "test-task",
filePath: "src/test.ts",
exportName: "testTask",
workerId: worker.id,
projectId: ctx.projectId,
runtimeEnvironmentId: ctx.environmentId,
},
});
return { queueId: queue.id, backgroundWorkerId: worker.id, backgroundWorkerTaskId: task.id };
}
async function fullSeed(prisma: PrismaClient, slug: string): Promise<SeedContext> {
const { organization, project, environment } = await seedEnv(prisma, slug);
const worker = await seedWorker(prisma, {
environmentId: environment.id,
projectId: project.id,
});
return {
environmentId: environment.id,
projectId: project.id,
organizationId: organization.id,
...worker,
};
}
async function seedRunWithAttempt(
prisma: PrismaClient,
ctx: SeedContext,
friendlyId: string,
opts: {
status: "COMPLETED_SUCCESSFULLY" | "COMPLETED_WITH_ERRORS" | "CANCELED" | "EXECUTING";
attempt?: {
status: "COMPLETED" | "FAILED";
output?: string;
outputType?: string;
error?: unknown;
};
}
) {
const runInternalId = idGenerator();
const run = await prisma.taskRun.create({
data: {
id: runInternalId,
friendlyId,
taskIdentifier: "test-task",
payload: "{}",
payloadType: "application/json",
traceId: idGenerator(),
spanId: idGenerator(),
queue: "task/test-task",
runtimeEnvironmentId: ctx.environmentId,
projectId: ctx.projectId,
status: opts.status,
},
});
if (opts.attempt) {
await prisma.taskRunAttempt.create({
data: {
friendlyId: `attempt_${idGenerator()}`,
taskRunId: run.id,
backgroundWorkerId: ctx.backgroundWorkerId,
backgroundWorkerTaskId: ctx.backgroundWorkerTaskId,
runtimeEnvironmentId: ctx.environmentId,
queueId: ctx.queueId,
status: opts.attempt.status,
output: opts.attempt.output,
outputType: opts.attempt.outputType ?? "application/json",
error: opts.attempt.error as any,
},
});
}
return run;
}
// A legacy-replica closure that explodes if ever touched — used to prove the primary/legacy store
// is structurally unreachable when it must not be read.
function throwingLegacy(): PrismaReplicaClient {
return new Proxy(
{},
{
get() {
throw new Error("legacy replica must never be read in this case");
},
}
) as unknown as PrismaReplicaClient;
}
describe("ApiRunResultPresenter read-through (heterogeneous legacy + new Postgres)", () => {
heteroPostgresTest(
"split: a run living on the NEW DB resolves from new and never probes the legacy replica",
async ({ prisma14, prisma17 }) => {
const friendlyId = newFriendlyId();
const ctx = await fullSeed(prisma17 as unknown as PrismaClient, "new-only");
await seedRunWithAttempt(prisma17 as unknown as PrismaClient, ctx, friendlyId, {
status: "COMPLETED_SUCCESSFULLY",
attempt: { status: "COMPLETED", output: '"hello"', outputType: "application/json" },
});
const presenter = new ApiRunResultPresenter(
prisma17 as unknown as PrismaReplicaClient,
prisma17 as unknown as PrismaReplicaClient,
{
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: throwingLegacy(),
}
);
const result = await presenter.call(friendlyId, authEnv(ctx.environmentId));
expect(result).toBeDefined();
expect(result?.ok).toBe(true);
if (result?.ok) {
expect(result.id).toBe(friendlyId);
expect(result.taskIdentifier).toBe("test-task");
expect(result.output).toBe('"hello"');
expect(result.outputType).toBe("application/json");
}
}
);
// Old legacy-only run resolves from the legacy read replica (cross-version). The only legacy
// handle exposed is a read replica — no writer/primary field exists in this path.
heteroPostgresTest(
"split: an OLD legacy-only run resolves from the legacy read replica across the version boundary",
async ({ prisma14, prisma17 }) => {
const friendlyId = legacyFriendlyId();
// Seed only on legacy. New gets just an env so the new-probe runs but misses.
const legacyCtx = await fullSeed(prisma14 as unknown as PrismaClient, "legacy-only");
await fullSeed(prisma17 as unknown as PrismaClient, "new-empty");
await seedRunWithAttempt(prisma14 as unknown as PrismaClient, legacyCtx, friendlyId, {
status: "COMPLETED_SUCCESSFULLY",
attempt: { status: "COMPLETED", output: '"from-legacy"', outputType: "application/json" },
});
const presenter = new ApiRunResultPresenter(
prisma17 as unknown as PrismaReplicaClient,
prisma14 as unknown as PrismaReplicaClient,
{
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
}
);
const result = await presenter.call(friendlyId, authEnv(legacyCtx.environmentId));
expect(result).toBeDefined();
expect(result?.ok).toBe(true);
if (result?.ok) {
// friendlyId / taskIdentifier / output+outputType round-trip across the version boundary identically.
expect(result.id).toBe(friendlyId);
expect(result.taskIdentifier).toBe("test-task");
expect(result.output).toBe('"from-legacy"');
expect(result.outputType).toBe("application/json");
}
}
);
// Legacy-classified id present on neither store, isPastRetention=true → past-retention → undefined.
heteroPostgresTest(
"split: a past-retention id returns undefined (the route's normal 404 surface)",
async ({ prisma14, prisma17 }) => {
const friendlyId = legacyFriendlyId();
const ctx = await fullSeed(prisma17 as unknown as PrismaClient, "past-ret-new");
await fullSeed(prisma14 as unknown as PrismaClient, "past-ret-legacy");
const presenter = new ApiRunResultPresenter(
prisma17 as unknown as PrismaReplicaClient,
prisma14 as unknown as PrismaReplicaClient,
{
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
isPastRetention: () => true,
}
);
const result = await presenter.call(friendlyId, authEnv(ctx.environmentId));
// Identical surface to a genuinely missing run: the route maps undefined → normal 404.
expect(result).toBeUndefined();
}
);
heteroPostgresTest(
"single-DB passthrough: resolves from the one client; the legacy replica is never touched",
async ({ prisma14, prisma17 }) => {
const friendlyId = newFriendlyId();
const ctx = await fullSeed(prisma17 as unknown as PrismaClient, "passthrough");
await seedRunWithAttempt(prisma17 as unknown as PrismaClient, ctx, friendlyId, {
status: "COMPLETED_SUCCESSFULLY",
attempt: { status: "COMPLETED", output: '"single"', outputType: "application/json" },
});
// No read-through deps → passthrough (single plain findFirst).
const presenter = new ApiRunResultPresenter(
prisma17 as unknown as PrismaReplicaClient,
prisma17 as unknown as PrismaReplicaClient
);
const result = await presenter.call(friendlyId, authEnv(ctx.environmentId));
expect(result?.ok).toBe(true);
if (result?.ok) {
expect(result.id).toBe(friendlyId);
expect(result.output).toBe('"single"');
}
// splitEnabled:false with a throwing legacy proves no second store is touched.
const presenter2 = new ApiRunResultPresenter(
prisma17 as unknown as PrismaReplicaClient,
prisma17 as unknown as PrismaReplicaClient,
{
splitEnabled: false,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: throwingLegacy(),
}
);
const result2 = await presenter2.call(friendlyId, authEnv(ctx.environmentId));
expect(result2?.ok).toBe(true);
}
);
// executionResultForTaskRun mapping is identical across split and single-DB for every status.
heteroPostgresTest(
"status parity: success / failed / canceled map identically in split and single-DB",
async ({ prisma14, prisma17 }) => {
const ctx = await fullSeed(prisma17 as unknown as PrismaClient, "parity");
const successId = newFriendlyId();
const failedId = newFriendlyId();
const canceledId = newFriendlyId();
await seedRunWithAttempt(prisma17 as unknown as PrismaClient, ctx, successId, {
status: "COMPLETED_SUCCESSFULLY",
attempt: { status: "COMPLETED", output: '"ok"', outputType: "application/json" },
});
await seedRunWithAttempt(prisma17 as unknown as PrismaClient, ctx, failedId, {
status: "COMPLETED_WITH_ERRORS",
attempt: {
status: "FAILED",
error: { type: "BUILT_IN_ERROR", name: "Error", message: "boom", stackTrace: "boom" },
},
});
await seedRunWithAttempt(prisma17 as unknown as PrismaClient, ctx, canceledId, {
status: "CANCELED",
});
const splitPresenter = new ApiRunResultPresenter(
prisma17 as unknown as PrismaReplicaClient,
prisma17 as unknown as PrismaReplicaClient,
{
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: throwingLegacy(),
}
);
const passthroughPresenter = new ApiRunResultPresenter(
prisma17 as unknown as PrismaReplicaClient,
prisma17 as unknown as PrismaReplicaClient
);
for (const id of [successId, failedId, canceledId]) {
const split = await splitPresenter.call(id, authEnv(ctx.environmentId));
const single = await passthroughPresenter.call(id, authEnv(ctx.environmentId));
expect(split).toEqual(single);
}
const success = await splitPresenter.call(successId, authEnv(ctx.environmentId));
expect(success?.ok).toBe(true);
const failed = await splitPresenter.call(failedId, authEnv(ctx.environmentId));
expect(failed?.ok).toBe(false);
const canceled = await splitPresenter.call(canceledId, authEnv(ctx.environmentId));
expect(canceled?.ok).toBe(false);
if (canceled && !canceled.ok) {
expect(canceled.error.type).toBe("INTERNAL_ERROR");
}
}
);
});
@@ -0,0 +1,116 @@
import { describe, expect, vi } from "vitest";
var dbClientHolder: any = undefined;
function setDbClient(client: any) {
dbClientHolder = client;
}
vi.mock("~/v3/services/baseService.server", async () => {
const { ServiceValidationError } = await import("~/v3/services/common.server");
return { ServiceValidationError };
});
vi.mock("~/db.server", async () => {
const { Prisma } = await import("@trigger.dev/database");
return {
Prisma,
sqlDatabaseSchema: Prisma.sql(["public"]),
get prisma() {
return dbClientHolder;
},
get $replica() {
return dbClientHolder;
},
};
});
import { postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { ApiWaitpointListPresenter } from "~/presenters/v3/ApiWaitpointListPresenter.server";
vi.setConfig({ testTimeout: 120_000 });
const ENV_ID = "env0000000000000000t19";
const PROJ_ID = "proj00000000000000t19";
async function seedLegacyParents(prisma: PrismaClient, slug: string) {
const org = await prisma.organization.create({
data: { title: `org-${slug}`, slug: `org-${slug}` },
});
await prisma.project.create({
data: {
id: PROJ_ID,
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: org.id,
externalRef: `proj-${slug}`,
engine: "V2",
},
});
await prisma.runtimeEnvironment.create({
data: {
id: ENV_ID,
slug: `env-${slug}`,
type: "PRODUCTION",
projectId: PROJ_ID,
organizationId: org.id,
apiKey: `tr_prod_${slug}`,
pkApiKey: `pk_prod_${slug}`,
shortcode: `sc-${slug}`,
},
});
}
const baseEnv = {
id: ENV_ID,
type: "PRODUCTION" as const,
project: { id: PROJ_ID, engine: "V2" as const },
apiKey: "tr_prod_t19",
};
describe("ApiWaitpointListPresenter read-route threading", () => {
postgresTest(
"split enabled: waitpoint on NEW handle is returned via readRoute",
async ({ prisma }) => {
setDbClient(prisma);
await seedLegacyParents(prisma, "t19split");
await prisma.waitpoint.create({
data: {
id: "wp_t19_0000000000000000001",
friendlyId: "wpt_t19_001",
type: "MANUAL",
status: "PENDING",
idempotencyKey: "idem-t19-001",
userProvidedIdempotencyKey: false,
projectId: PROJ_ID,
environmentId: ENV_ID,
},
});
const presenter = new ApiWaitpointListPresenter(undefined, undefined, {
runOpsNew: prisma as any,
runOpsLegacyReplica: prisma as any,
splitEnabled: true,
});
const result = await presenter.call(baseEnv, {});
expect(result.data.length).toBeGreaterThan(0);
expect(result.data.some((t) => t.id === "wpt_t19_001")).toBe(true);
}
);
postgresTest(
"passthrough: no readRoute => _replica only, result empty (nothing seeded)",
async ({ prisma }) => {
setDbClient(prisma);
await seedLegacyParents(prisma, "t19pass");
const presenter = new ApiWaitpointListPresenter(prisma, prisma);
const result = await presenter.call(baseEnv, {});
expect(result.data).toEqual([]);
}
);
});
@@ -0,0 +1,333 @@
// Real heterogeneous legacy + new Postgres proof for the public waitpoint retrieve read.
// The DB is never mocked: reads hit the two real containers. Only pure boundaries
// (splitEnabled, isPastRetention) and recording client wrappers are
// injected. heteroPostgresTest runs the legacy and new databases on different major versions.
import {
heteroPostgresTest,
heteroRunOpsPostgresTest,
postgresTest,
} from "@internal/testcontainers";
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
import type { PrismaClient, WaitpointType } from "@trigger.dev/database";
import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
import { describe, expect, vi } from "vitest";
import type { PrismaReplicaClient } from "~/db.server";
import { ApiWaitpointPresenter } from "~/presenters/v3/ApiWaitpointPresenter.server";
vi.setConfig({ testTimeout: 60_000 });
// 25-char cuid body (no v1 version marker) → LEGACY residency.
function generateLegacyCuid() {
const suffix = Array.from(
{ length: 24 },
() => "0123456789abcdefghijklmnopqrstuvwxyz"[Math.floor(Math.random() * 36)]
).join("");
return `c${suffix}`;
}
// A read client whose waitpoint.findFirst is recorded; throws if used after being marked
// forbidden, so we can prove a store was NEVER read.
function recording(client: PrismaClient | RunOpsPrismaClient, opts: { forbidden?: boolean } = {}) {
const calls: unknown[] = [];
const waitpoint = {
findFirst: (args: unknown) => {
calls.push(args);
if (opts.forbidden) {
throw new Error("this store must never be read");
}
return (client as unknown as PrismaReplicaClient).waitpoint.findFirst(args as never);
},
};
return { handle: { ...client, waitpoint } as unknown as PrismaReplicaClient, calls };
}
async function seedOrgProjectEnv(prisma: PrismaClient, suffix: string) {
const organization = await prisma.organization.create({
data: { title: `test-${suffix}`, slug: `test-${suffix}` },
});
const project = await prisma.project.create({
data: {
name: `test-${suffix}`,
slug: `test-${suffix}`,
organizationId: organization.id,
externalRef: `test-${suffix}`,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: `test-${suffix}`,
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `apikey-${suffix}`,
pkApiKey: `pk-${suffix}`,
shortcode: `test-${suffix}`,
},
});
return { organization, project, environment };
}
async function seedWaitpoint(
prisma: PrismaClient,
id: string,
env: { id: string; projectId: string },
overrides: Partial<{
status: "PENDING" | "COMPLETED";
type: WaitpointType;
output: string;
outputType: string;
outputIsError: boolean;
completedAt: Date;
completedAfter: Date;
tags: string[];
}> = {}
) {
return prisma.waitpoint.create({
data: {
id,
friendlyId: `waitpoint_${id}`,
type: overrides.type ?? "MANUAL",
status: overrides.status ?? "COMPLETED",
idempotencyKey: `idem-${id}`,
userProvidedIdempotencyKey: false,
output: overrides.output ?? JSON.stringify({ hello: "world" }),
outputType: overrides.outputType ?? "application/json",
outputIsError: overrides.outputIsError ?? false,
completedAt: overrides.completedAt ?? new Date(),
completedAfter: overrides.completedAfter,
tags: overrides.tags ?? ["a", "b"],
projectId: env.projectId,
environmentId: env.id,
},
});
}
const environmentArg = (env: { id: string; projectId: string }) => ({
id: env.id,
type: "PRODUCTION" as const,
project: { id: env.projectId, engine: "V2" as const },
apiKey: "tr_test_apikey",
});
describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgres)", () => {
heteroPostgresTest(
"resolves on run-ops NEW (run-ops id), legacy replica never touched",
async ({ prisma17, prisma14 }) => {
const id = generateRunOpsId();
expect(id.length).toBe(26);
const { project, environment } = await seedOrgProjectEnv(prisma17, "new");
const seeded = await seedWaitpoint(
prisma17,
id,
{ id: environment.id, projectId: project.id },
{ tags: ["x", "y", "z"], output: JSON.stringify({ n: 42 }) }
);
const newClient = recording(prisma17);
const legacy = recording(prisma14, { forbidden: true });
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
splitEnabled: true,
newClient: newClient.handle,
legacyReplica: legacy.handle,
});
const result = await presenter.call(environmentArg(environment), id);
expect(result.id).toBe(seeded.friendlyId);
expect(result.tags).toEqual(["x", "y", "z"]);
expect(result.output).toBe(JSON.stringify({ n: 42 }));
expect(result.type).toBe("MANUAL");
// run-ops id → NEW: new store served the read, legacy never touched (fast-path).
expect(newClient.calls.length).toBe(1);
expect(legacy.calls.length).toBe(0);
}
);
heteroPostgresTest(
"resolves off the LEGACY replica (cuid), never a legacy primary",
async ({ prisma17, prisma14 }) => {
const id = generateLegacyCuid();
expect(id.length).toBe(25);
const { project, environment } = await seedOrgProjectEnv(prisma14, "legacy");
const seeded = await seedWaitpoint(prisma14, id, {
id: environment.id,
projectId: project.id,
});
const newClient = recording(prisma17);
// The deps expose only legacyReplica — there is NO legacy-primary handle at all.
const legacy = recording(prisma14);
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
splitEnabled: true,
newClient: newClient.handle,
legacyReplica: legacy.handle,
});
const result = await presenter.call(environmentArg(environment), id);
expect(result.id).toBe(seeded.friendlyId);
expect(result.tags).toEqual(["a", "b"]);
// NEW probed first (miss) → resolved off the LEGACY REPLICA handle.
expect(newClient.calls.length).toBe(1);
expect(legacy.calls.length).toBe(1);
}
);
heteroPostgresTest(
"not-found maps to the existing ServiceValidationError surface",
async ({ prisma17, prisma14 }) => {
const id = generateLegacyCuid();
const { environment } = await seedOrgProjectEnv(prisma14, "nf");
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
splitEnabled: true,
newClient: recording(prisma17).handle,
legacyReplica: recording(prisma14).handle,
});
await expect(presenter.call(environmentArg(environment), id)).rejects.toThrow(
"Waitpoint not found"
);
}
);
heteroPostgresTest(
"past-retention maps to the same not-found surface",
async ({ prisma17, prisma14 }) => {
const id = generateLegacyCuid();
const { environment } = await seedOrgProjectEnv(prisma14, "pr");
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
splitEnabled: true,
newClient: recording(prisma17).handle,
legacyReplica: recording(prisma14).handle,
isPastRetention: () => true,
});
await expect(presenter.call(environmentArg(environment), id)).rejects.toThrow(
"Waitpoint not found"
);
}
);
heteroPostgresTest(
"cross-seam — new-resident served from NEW (legacy untouched); in-retention served from legacy",
async ({ prisma17, prisma14 }) => {
// New-resident waitpoint: lives on NEW, the new probe hits, legacy must never be touched.
const newId = generateRunOpsId();
const newEnv = await seedOrgProjectEnv(prisma17, "x2new");
await seedWaitpoint(prisma17, newId, {
id: newEnv.environment.id,
projectId: newEnv.project.id,
});
const newLegacy = recording(prisma14, { forbidden: true });
const migratedPresenter = new ApiWaitpointPresenter(undefined, undefined, {
splitEnabled: true,
newClient: recording(prisma17).handle,
legacyReplica: newLegacy.handle,
});
const migratedResult = await migratedPresenter.call(
environmentArg(newEnv.environment),
newId
);
expect(migratedResult.id).toBe(`waitpoint_${newId}`);
expect(newLegacy.calls.length).toBe(0);
// In-retention waitpoint: lives on the legacy replica, served from it.
const oldId = generateLegacyCuid();
const oldEnv = await seedOrgProjectEnv(prisma14, "x2old");
await seedWaitpoint(prisma14, oldId, {
id: oldEnv.environment.id,
projectId: oldEnv.project.id,
});
const retentionPresenter = new ApiWaitpointPresenter(undefined, undefined, {
splitEnabled: true,
newClient: recording(prisma17).handle,
legacyReplica: recording(prisma14).handle,
});
const retentionResult = await retentionPresenter.call(
environmentArg(oldEnv.environment),
oldId
);
expect(retentionResult.id).toBe(`waitpoint_${oldId}`);
}
);
});
// Regression: the split-mode NEW client is the REAL scalar-only run-ops client (prisma17). A cuid
// classifies LEGACY, so readThroughRun probes NEW first — a relation in hydrate() (connectedRuns)
// throws PrismaClientValidationError there (the 500) before the legacy fallback runs.
describe("ApiWaitpointPresenter read-through (dedicated scalar-only run-ops NEW client)", () => {
heteroRunOpsPostgresTest(
"cuid token: hydrate() select is valid against the scalar-only run-ops client, resolves via legacy",
async ({ prisma17, prisma14 }) => {
const id = generateLegacyCuid();
expect(id.length).toBe(25);
const { project, environment } = await seedOrgProjectEnv(prisma14, "scalar-legacy");
const seeded = await seedWaitpoint(
prisma14,
id,
{ id: environment.id, projectId: project.id },
{ tags: ["p", "q"], output: JSON.stringify({ ok: true }) }
);
const newClient = recording(prisma17);
const legacy = recording(prisma14);
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
splitEnabled: true,
newClient: newClient.handle,
legacyReplica: legacy.handle,
});
// Must NOT throw PrismaClientValidationError; resolves the token off the legacy side.
const result = await presenter.call(environmentArg(environment), id);
expect(result.id).toBe(seeded.friendlyId);
expect(result.tags).toEqual(["p", "q"]);
expect(result.output).toBe(JSON.stringify({ ok: true }));
expect(newClient.calls.length).toBe(1);
expect(legacy.calls.length).toBe(1);
}
);
});
describe("ApiWaitpointPresenter passthrough (single-DB)", () => {
postgresTest(
"no read-through deps → one plain replica read; legacy never touched",
async ({ prisma }) => {
const id = generateRunOpsId();
const { project, environment } = await seedOrgProjectEnv(prisma, "pt");
const seeded = await seedWaitpoint(
prisma,
id,
{ id: environment.id, projectId: project.id },
{ tags: ["one"], output: JSON.stringify({ ok: true }) }
);
const single = recording(prisma);
const legacy = recording(prisma, { forbidden: true });
// No splitEnabled → passthrough. newClient defaults to the single recording handle so we
// can assert exactly one read against it; legacy must never fire.
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
newClient: single.handle,
legacyReplica: legacy.handle,
});
const result = await presenter.call(environmentArg(environment), id);
expect(result.id).toBe(seeded.friendlyId);
expect(result.tags).toEqual(["one"]);
expect(result.output).toBe(JSON.stringify({ ok: true }));
// Passthrough: exactly one read on the single client; legacy untouched.
expect(single.calls.length).toBe(1);
expect(legacy.calls.length).toBe(0);
}
);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,216 @@
// Cross-cutting auth-layer behaviours that aren't tied to a specific route
// family — see TRI-8743. Soft-deleted projects, revoked keys, expired JWTs,
// cross-env mismatch, force-fallback toggle.
//
// Strategy: pick one representative API-key route
// (GET /api/v1/runs/run_doesnotexist/result) and one representative JWT
// route (POST /api/v1/waitpoints/tokens/<id>/complete) and exercise the
// edge cases against those. The route choice doesn't matter — the
// auth layer is shared across every API route via apiBuilder.server.ts.
// Smoke matrix (api-auth.e2e.test.ts) already covers the trivial
// cases (missing/invalid key, basic JWT pass, soft-deleted project);
// this file adds cases that need explicit fixture setup.
import { generateJWT } from "@trigger.dev/core/v3/jwt";
import { SignJWT } from "jose";
import { describe, expect, it } from "vitest";
import { getTestServer } from "./helpers/sharedTestServer";
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
describe("Cross-cutting", () => {
it("shared prisma client can read from the postgres container", async () => {
const server = getTestServer();
const count = await server.prisma.user.count();
expect(count).toBeGreaterThanOrEqual(0);
});
// The auth path falls back to RevokedApiKey when a key isn't found
// in RuntimeEnvironment — letting customers continue to use a key
// for a configurable grace window after rotation. See
// models/runtimeEnvironment.server.ts. The grace lookup matches by
// (apiKey AND expiresAt > now) and rehydrates the env via the FK.
describe("Revoked API key grace window", () => {
const route = "/api/v1/runs/run_doesnotexist/result";
it("revoked key within grace (expiresAt > now): auth passes", async () => {
const server = getTestServer();
const { environment } = await seedTestEnvironment(server.prisma);
// Mint a fresh "rotated" key that doesn't exist on any env, then
// record it as recently revoked with a future grace expiry.
const rotatedKey = `tr_dev_rotated_${Math.random().toString(36).slice(2)}`;
await server.prisma.revokedApiKey.create({
data: {
apiKey: rotatedKey,
runtimeEnvironmentId: environment.id,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // +1 day
},
});
const res = await server.webapp.fetch(route, {
headers: { Authorization: `Bearer ${rotatedKey}` },
});
// Auth passed — the route's resource lookup just doesn't find
// run_doesnotexist. The point is NOT 401.
expect(res.status).not.toBe(401);
});
it("revoked key past grace (expiresAt < now): 401", async () => {
const server = getTestServer();
const { environment } = await seedTestEnvironment(server.prisma);
const expiredKey = `tr_dev_expired_${Math.random().toString(36).slice(2)}`;
await server.prisma.revokedApiKey.create({
data: {
apiKey: expiredKey,
runtimeEnvironmentId: environment.id,
expiresAt: new Date(Date.now() - 60 * 1000), // -1 minute
},
});
const res = await server.webapp.fetch(route, {
headers: { Authorization: `Bearer ${expiredKey}` },
});
expect(res.status).toBe(401);
});
});
// JWT edge cases beyond what the smoke matrix covers (which only
// checks "wrong key" and "missing scope"). All target the same
// representative JWT route — the JWT validator is shared across
// routes via apiBuilder, so coverage here generalises.
describe("JWT edge cases", () => {
const route = "/api/v1/waitpoints/tokens/wp_does_not_exist/complete";
async function postWithJwt(jwt: string) {
const server = getTestServer();
return server.webapp.fetch(route, {
method: "POST",
headers: {
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
}
it("JWT with expirationTime in the past: 401", async () => {
const server = getTestServer();
const { environment } = await seedTestEnvironment(server.prisma);
// generateJWT only accepts string expirationTimes (relative, like
// "15m"). To create a definitively-expired token use jose
// directly with an absolute past timestamp.
const secret = new TextEncoder().encode(environment.apiKey);
const jwt = await new SignJWT({
pub: true,
sub: environment.id,
scopes: ["write:waitpoints"],
})
.setIssuer("https://id.trigger.dev")
.setAudience("https://api.trigger.dev")
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt(0)
.setExpirationTime(1) // 1970-01-01 — definitively expired
.sign(secret);
const res = await postWithJwt(jwt);
expect(res.status).toBe(401);
});
it("JWT with pub: false: 401", async () => {
const server = getTestServer();
const { environment } = await seedTestEnvironment(server.prisma);
const jwt = await generateJWT({
secretKey: environment.apiKey,
payload: { pub: false, sub: environment.id, scopes: ["write:waitpoints"] },
expirationTime: "15m",
});
// pub: false means "this token isn't meant for client-side use"
// — the auth layer rejects it for the same-class JWT routes.
const res = await postWithJwt(jwt);
expect(res.status).toBe(401);
});
it("JWT with no sub claim: 401", async () => {
const server = getTestServer();
const { environment } = await seedTestEnvironment(server.prisma);
const jwt = await generateJWT({
secretKey: environment.apiKey,
payload: { pub: true, scopes: ["write:waitpoints"] },
expirationTime: "15m",
});
// No sub claim — auth can't resolve which env the token belongs
// to, so it must reject. (sub carries the env id.)
const res = await postWithJwt(jwt);
expect(res.status).toBe(401);
});
it("JWT signed with another env's apiKey (cross-env): 401", async () => {
const server = getTestServer();
// env A's id but signed with env B's apiKey — sub-vs-signature
// mismatch the auth layer must catch.
const a = await seedTestEnvironment(server.prisma);
const b = await seedTestEnvironment(server.prisma);
const jwt = await generateJWT({
secretKey: b.apiKey, // <-- WRONG key relative to the sub claim
payload: { pub: true, sub: a.environment.id, scopes: ["write:waitpoints"] },
expirationTime: "15m",
});
const res = await postWithJwt(jwt);
expect(res.status).toBe(401);
});
it("JWT malformed (three parts but invalid base64 in payload): 401", async () => {
// Three "."-separated parts so the JWT shape gate sees it as a
// candidate, but the payload segment is non-base64 garbage.
// Validator must surface this as 401, not 500.
const malformed = "eyJhbGciOiJIUzI1NiJ9.@@@notbase64@@@.signature";
const res = await postWithJwt(malformed);
expect(res.status).toBe(401);
});
});
// The auth layer resolves the JWT's env from the `sub` claim — NOT
// from the route path. So a JWT for env A hitting a route that
// fetches a resource from env B should never accidentally see env
// B's data. Test by minting a JWT for env A and asking for a
// resource that lives in env B — expect 404 (not 200).
describe("Cross-environment: JWT auth resolves env from sub, not URL", () => {
it("env A's JWT cannot read env B's resource: 404", async () => {
const server = getTestServer();
const a = await seedTestEnvironment(server.prisma);
const b = await seedTestEnvironment(server.prisma);
// Seed a real-ish run row in env B so the route would have
// something to find IF auth resolved the env from the URL.
const friendlyId = `run_${Math.random().toString(36).slice(2, 10)}`;
await server.prisma.taskRun.create({
data: {
friendlyId,
taskIdentifier: "test-task",
payload: "{}",
payloadType: "application/json",
traceId: `trace_${Math.random().toString(36).slice(2)}`,
spanId: `span_${Math.random().toString(36).slice(2)}`,
runtimeEnvironmentId: b.environment.id,
projectId: b.project.id,
organizationId: b.organization.id,
engine: "V2",
status: "COMPLETED_SUCCESSFULLY",
queue: "task/test-task",
},
});
const jwt = await generateJWT({
secretKey: a.apiKey,
payload: { pub: true, sub: a.environment.id, scopes: ["read:runs"] },
expirationTime: "15m",
});
const res = await server.webapp.fetch(`/api/v1/runs/${friendlyId}/result`, {
headers: { Authorization: `Bearer ${jwt}` },
});
// The route resolves runs scoped to the JWT's env (env A). The
// run lives in env B, so env A's view returns "not found" —
// critically, NOT 200.
expect(res.status).not.toBe(200);
expect([401, 404]).toContain(res.status);
});
});
});
@@ -0,0 +1,118 @@
// Comprehensive dashboard session-auth tests — see TRI-8742.
// Each test seeds a User + session cookie via seedTestUser / seedTestSession
// (helpers/seedTestSession.ts) and hits the shared webapp container.
import { describe, expect, it } from "vitest";
import { getTestServer } from "./helpers/sharedTestServer";
import { seedTestSession, seedTestUser } from "./helpers/seedTestSession";
describe("Dashboard", () => {
it("shared webapp container redirects /admin/concurrency to /login when unauthenticated", async () => {
const server = getTestServer();
const res = await server.webapp.fetch("/admin/concurrency", { redirect: "manual" });
expect(res.status).toBe(302);
});
// Admin pages migrated to dashboardLoader({ authorization: { requireSuper: true } })
// in TRI-8717. The dashboardLoader resolves auth in three stages:
// 1. No session → redirect to /login?redirectTo=<path>.
// 2. Session, user.admin === false → redirect to / (no path leakage).
// 3. Session, user.admin === true → run the loader handler.
//
// Coverage strategy: pick three representative routes (the index, a
// tabbed sub-page, and the back-office tree) rather than all 14 —
// they all share the same dashboardLoader config so testing every
// file would just confirm the wrapper works, which the harness
// already proves. If the wrapper config drifts per-route in the
// future, add targeted tests for the divergent ones.
describe("Admin pages — requireSuper gate", () => {
const adminRoutes = ["/admin", "/admin/concurrency", "/admin/back-office"];
for (const path of adminRoutes) {
describe(`GET ${path}`, () => {
it("no session: redirects to /login?redirectTo=<path>", async () => {
const server = getTestServer();
const res = await server.webapp.fetch(path, { redirect: "manual" });
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(location).toContain("/login");
// Path leaks deliberately so a successful login bounces the
// user back to where they were headed.
expect(location).toContain(`redirectTo=${encodeURIComponent(path)}`);
});
it("session for non-admin user: redirects to / (no path leakage)", async () => {
const server = getTestServer();
const user = await seedTestUser(server.prisma, { admin: false });
const cookie = await seedTestSession({ userId: user.id });
const res = await server.webapp.fetch(path, {
redirect: "manual",
headers: { Cookie: cookie },
});
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
// unauthorizedRedirect default in dashboardBuilder is "/".
// A non-admin landing on /admin shouldn't get redirectTo
// back to /admin once they upgrade — they're not getting in
// by re-auth.
expect(new URL(location, "http://localhost").pathname).toBe("/");
});
it("session for admin user: 2xx", async () => {
const server = getTestServer();
const user = await seedTestUser(server.prisma, { admin: true });
const cookie = await seedTestSession({ userId: user.id });
const res = await server.webapp.fetch(path, {
redirect: "manual",
headers: { Cookie: cookie },
});
// Loader handler ran — could be 200 (HTML) or 204 (Remix
// _data fetch). Either way, NOT a redirect.
expect(res.status).toBeLessThan(300);
});
});
}
});
// Action handlers behind requireSuper used to return 403 Unauthorized
// pre-RBAC — now they redirect to / via dashboardAction's
// unauthorizedRedirect. The ticket flagged this as a behaviour
// change worth locking in (any XHR fetcher that branched on 403
// would have regressed silently). Use admin.feature-flags POST as
// the canary — it's the simplest action of the bunch.
describe("Admin action — requireSuper gate (admin.feature-flags POST)", () => {
const path = "/admin/feature-flags";
it("no session: redirects to /login (POST)", async () => {
const server = getTestServer();
const res = await server.webapp.fetch(path, {
method: "POST",
body: JSON.stringify({}),
headers: { "Content-Type": "application/json" },
redirect: "manual",
});
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(location).toContain("/login");
});
it("session for non-admin user: redirects to / (was 403 pre-RBAC)", async () => {
const server = getTestServer();
const user = await seedTestUser(server.prisma, { admin: false });
const cookie = await seedTestSession({ userId: user.id });
const res = await server.webapp.fetch(path, {
method: "POST",
body: JSON.stringify({}),
headers: { "Content-Type": "application/json", Cookie: cookie },
redirect: "manual",
});
// Behaviour change from the TRI-8717 migration: the legacy
// path returned 403 Unauthorized; dashboardAction returns a
// 302 to "/" instead. Any client code branching on 403 needs
// updating — locking this in so a silent regression is loud.
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(new URL(location, "http://localhost").pathname).toBe("/");
});
});
});
@@ -0,0 +1,418 @@
import { redisTest } from "@internal/testcontainers";
import { describe, expect, vi, beforeEach } from "vitest";
vi.setConfig({ testTimeout: 30_000 }); // 30 seconds timeout
// Mock the logger
vi.mock("./logger.server", () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
import type { Express } from "express";
import express from "express";
import request from "supertest";
import { authorizationRateLimitMiddleware } from "../app/services/authorizationRateLimitMiddleware.server.js";
describe.skipIf(process.env.GITHUB_ACTIONS)("authorizationRateLimitMiddleware", () => {
let app: Express;
beforeEach(() => {
app = express();
});
redisTest("should allow requests within the rate limit", async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redisOptions,
keyPrefix: "test",
defaultLimiter: {
type: "tokenBucket",
refillRate: 10,
interval: "1m",
maxTokens: 100,
},
pathMatchers: [/^\/api/],
log: {
rejections: false,
requests: false,
},
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => {
res.status(200).json({ message: "Success" });
});
const response = await request(app).get("/api/test").set("Authorization", "Bearer test-token");
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: "Success" });
expect(response.headers["x-ratelimit-limit"]).toBeDefined();
expect(response.headers["x-ratelimit-remaining"]).toBeDefined();
expect(response.headers["x-ratelimit-reset"]).toBeDefined();
});
redisTest("should reject requests without an Authorization header", async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redisOptions,
keyPrefix: "test",
defaultLimiter: {
type: "tokenBucket",
refillRate: 10,
interval: "1m",
maxTokens: 100,
},
pathMatchers: [/^\/api/],
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => {
res.status(200).json({ message: "Success" });
});
const response = await request(app).get("/api/test");
expect(response.status).toBe(401);
expect(response.body).toHaveProperty("title", "Unauthorized");
});
redisTest("should reject requests that exceed the rate limit", async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redisOptions,
keyPrefix: "test",
defaultLimiter: {
type: "tokenBucket",
refillRate: 1,
interval: "1m",
maxTokens: 1,
},
pathMatchers: [/^\/api/],
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => {
res.status(200).json({ message: "Success" });
});
// First request should succeed
await request(app).get("/api/test").set("Authorization", "Bearer test-token");
// Second request should be rate limited
const response = await request(app).get("/api/test").set("Authorization", "Bearer test-token");
expect(response.status).toBe(429);
expect(response.body).toHaveProperty("title", "Rate Limit Exceeded");
});
redisTest("should not apply rate limiting to whitelisted paths", async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redisOptions,
keyPrefix: "test",
defaultLimiter: {
type: "tokenBucket",
refillRate: 10,
interval: "1m",
maxTokens: 100,
},
pathMatchers: [/^\/api/],
pathWhiteList: ["/api/whitelist"],
});
app.use(rateLimitMiddleware);
app.get("/api/whitelist", (req, res) => {
res.status(200).json({ message: "Whitelisted" });
});
const response = await request(app)
.get("/api/whitelist")
.set("Authorization", "Bearer test-token");
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: "Whitelisted" });
expect(response.headers["x-ratelimit-limit"]).toBeUndefined();
});
redisTest(
"should apply different rate limits based on limiterConfigOverride",
async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redisOptions,
keyPrefix: "test",
defaultLimiter: {
type: "tokenBucket",
refillRate: 1,
interval: "1m",
maxTokens: 1,
},
pathMatchers: [/^\/api/],
limiterConfigOverride: async (authorizationValue) => {
if (authorizationValue === "Bearer premium-token") {
return {
type: "tokenBucket",
refillRate: 10,
interval: "1m",
maxTokens: 100,
};
}
return undefined;
},
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => {
res.status(200).json({ message: "Success" });
});
// Regular user should be rate limited after 1 request
await request(app).get("/api/test").set("Authorization", "Bearer regular-token");
const regularResponse = await request(app)
.get("/api/test")
.set("Authorization", "Bearer regular-token");
expect(regularResponse.status).toBe(429);
// Premium user should be able to make multiple requests
const premiumResponse1 = await request(app)
.get("/api/test")
.set("Authorization", "Bearer premium-token");
expect(premiumResponse1.status).toBe(200);
const premiumResponse2 = await request(app)
.get("/api/test")
.set("Authorization", "Bearer premium-token");
expect(premiumResponse2.status).toBe(200);
}
);
describe("Advanced Cases", () => {
// 1. Test different rate limit configurations
redisTest("should enforce fixed window rate limiting", async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redisOptions,
keyPrefix: "test-fixed",
defaultLimiter: {
type: "fixedWindow",
window: "10s",
tokens: 3,
},
pathMatchers: [/^\/api/],
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
const makeRequest = () =>
request(app).get("/api/test").set("Authorization", "Bearer test-token");
// Should allow 3 requests
for (let i = 0; i < 3; i++) {
const response = await makeRequest();
expect(response.status).toBe(200);
}
// 4th request should be rate limited
const limitedResponse = await makeRequest();
expect(limitedResponse.status).toBe(429);
// Wait for the window to reset
await new Promise((resolve) => setTimeout(resolve, 10000));
// Should allow requests again
const newResponse = await makeRequest();
expect(newResponse.status).toBe(200);
});
redisTest("should enforce sliding window rate limiting", async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redisOptions,
keyPrefix: "test-sliding",
defaultLimiter: {
type: "slidingWindow",
window: "10s",
tokens: 3,
},
pathMatchers: [/^\/api/],
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
const makeRequest = () =>
request(app).get("/api/test").set("Authorization", "Bearer test-token");
// Should allow 3 requests
for (let i = 0; i < 3; i++) {
const response = await makeRequest();
expect(response.status).toBe(200);
}
// 4th request should be rate limited
const limitedResponse = await makeRequest();
expect(limitedResponse.status).toBe(429);
// Wait for part of the window to pass
await new Promise((resolve) => setTimeout(resolve, 1000));
// Should still be limited
const stillLimitedResponse = await makeRequest();
expect(stillLimitedResponse.status).toBe(429);
// Wait for the full window to pass
await new Promise((resolve) => setTimeout(resolve, 10000));
// Should allow requests again
const newResponse = await makeRequest();
expect(newResponse.status).toBe(200);
});
// 2. Test edge cases around rate limit calculations
redisTest("should handle token refill correctly", async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redisOptions,
keyPrefix: "test-refill",
defaultLimiter: {
type: "tokenBucket",
refillRate: 1,
interval: "5s",
maxTokens: 3,
},
pathMatchers: [/^\/api/],
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
const makeRequest = () =>
request(app).get("/api/test").set("Authorization", "Bearer test-token");
// Use up all tokens
for (let i = 0; i < 3; i++) {
const response = await makeRequest();
expect(response.status).toBe(200);
}
// Next request should be limited
const limitedResponse = await makeRequest();
expect(limitedResponse.status).toBe(429);
// Wait for one token to be refilled
await new Promise((resolve) => setTimeout(resolve, 5000));
// Should allow one request
const newResponse = await makeRequest();
expect(newResponse.status).toBe(200);
// But the next one should be limited again
const limitedAgainResponse = await makeRequest();
expect(limitedAgainResponse.status).toBe(429);
});
redisTest("should handle near-zero remaining tokens correctly", async ({ redisOptions }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redisOptions,
keyPrefix: "test-near-zero",
defaultLimiter: {
type: "tokenBucket",
refillRate: 1, // 1 token every 5 seconds
interval: "5s",
maxTokens: 1,
},
pathMatchers: [/^\/api/],
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
const makeRequest = () =>
request(app).get("/api/test").set("Authorization", "Bearer test-token");
// First request should succeed
const firstResponse = await makeRequest();
expect(firstResponse.status).toBe(200);
// Immediate second request should fail
const secondResponse = await makeRequest();
expect(secondResponse.status).toBe(429);
// Wait for almost one token to be refilled (4.9 seconds)
await new Promise((resolve) => setTimeout(resolve, 4900));
// This request should still fail as we're just shy of a full token
const thirdResponse = await makeRequest();
expect(thirdResponse.status).toBe(429);
// Wait for the full token to be refilled (additional 200ms)
await new Promise((resolve) => setTimeout(resolve, 200));
// This request should now succeed
const fourthResponse = await makeRequest();
expect(fourthResponse.status).toBe(200);
// Immediate next request should fail again
const fifthResponse = await makeRequest();
expect(fifthResponse.status).toBe(429);
});
// 3. Test the limiterCache functionality
redisTest("should use cached limiter configurations", async ({ redisOptions }) => {
let configOverrideCalls = 0;
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redisOptions,
keyPrefix: "test-cache",
defaultLimiter: {
type: "tokenBucket",
refillRate: 1,
interval: "1m",
maxTokens: 10,
},
pathMatchers: [/^\/api/],
limiterCache: {
fresh: 1000, // 1 second
stale: 2000, // 2 seconds
maxItems: 1000,
},
limiterConfigOverride: async (authorizationValue) => {
configOverrideCalls++;
if (authorizationValue === "Bearer premium-token") {
return {
type: "tokenBucket",
refillRate: 10,
interval: "1m",
maxTokens: 100,
};
}
return undefined;
},
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
const makeRequest = () =>
request(app).get("/api/test").set("Authorization", "Bearer premium-token");
// First request should call the override
await makeRequest();
expect(configOverrideCalls).toBe(1);
// Subsequent requests within 1 second should use the cache
await makeRequest();
await makeRequest();
expect(configOverrideCalls).toBe(1);
// Wait for the cache to become stale
await new Promise((resolve) => setTimeout(resolve, 1100));
// This should still use the cache, but also trigger a refresh
await makeRequest();
expect(configOverrideCalls).toBe(2);
// Wait for the cache to expire completely
await new Promise((resolve) => setTimeout(resolve, 1000));
// This should trigger a new override call
await makeRequest();
expect(configOverrideCalls).toBe(3);
});
});
});
@@ -0,0 +1,3 @@
{
"title": "❜ 𝐒 𝐏𝗈𝗌𝗍 . . . 𝐍𝖾𝗐 𝐂𝗈𝗇𝗍𝖾𝗇𝗍 ꒰ ⚔️ ꒱ 𝐒𝐋 ❜ 𝐔𝐋\n\n꒰ ❤️ ꒱ 𓃊 𝐋𝗲𝗮𝘃𝗲 𝖺 𝗹𝗶𝗸𝗲 𝖺𝗇\ud835"
}
@@ -0,0 +1,484 @@
import { Prisma } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
// BatchListPresenter imports `~/db.server` (for `sqlDatabaseSchema` + `PrismaClientOrTransaction`),
// `~/models/runtimeEnvironment.server`, and `~/components/*` at load — all of which pull
// `env.server` at import time. Stub `~/db.server` to break that chain (the runsRepository
// read-through test does the same). The presenter is driven entirely through injected real
// containers; the only thing it actually reads off this module is `sqlDatabaseSchema`, which we
// reproduce as the real `Prisma.sql(["public"])` value so the schema-qualified raw scan SQL is valid.
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
sqlDatabaseSchema: Prisma.sql(["public"]),
}));
import {
heteroPostgresTest,
heteroRunOpsPostgresTest,
postgresTest,
} from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
import {
type BatchListOptions,
BatchListPresenter,
} from "~/presenters/v3/BatchListPresenter.server";
vi.setConfig({ testTimeout: 120_000 });
type SeedContext = {
organizationId: string;
projectId: string;
environmentId: string;
userId: string;
};
// The exact presenter scan SQL, lifted verbatim, so tests can compare the presenter's output
// against a direct $queryRaw of the identical SQL on each DB version.
function rawScan(
prisma: PrismaClient,
opts: {
environmentId: string;
pageSize: number;
direction: "forward" | "backward";
cursor?: string;
}
) {
const { environmentId, pageSize, direction, cursor } = opts;
const sqlDatabaseSchema = Prisma.sql(["public"]);
return prisma.$queryRaw<
{
id: string;
friendlyId: string;
runtimeEnvironmentId: string;
status: any;
createdAt: Date;
updatedAt: Date;
completedAt: Date | null;
runCount: bigint;
batchVersion: string;
}[]
>`
SELECT
b.id,
b."friendlyId",
b."runtimeEnvironmentId",
b.status,
b."createdAt",
b."updatedAt",
b."completedAt",
b."runCount",
b."batchVersion"
FROM
${sqlDatabaseSchema}."BatchTaskRun" b
WHERE
b."runtimeEnvironmentId" = ${environmentId}
${
cursor
? direction === "forward"
? Prisma.sql`AND b.id < ${cursor}`
: Prisma.sql`AND b.id > ${cursor}`
: Prisma.empty
}
ORDER BY
${direction === "forward" ? Prisma.sql`b.id DESC` : Prisma.sql`b.id ASC`}
LIMIT ${pageSize + 1}`;
}
async function seedParents(prisma: PrismaClient, slug: string): Promise<SeedContext> {
const user = await prisma.user.create({
data: {
email: `user-${slug}@example.com`,
name: `User ${slug}`,
authenticationMethod: "MAGIC_LINK",
},
});
const organization = await prisma.organization.create({
data: { title: `org-${slug}`, slug: `org-${slug}` },
});
const project = await prisma.project.create({
data: {
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `proj-${slug}`,
},
});
const orgMember = await prisma.orgMember.create({
data: { organizationId: organization.id, userId: user.id },
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}`,
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
orgMemberId: orgMember.id,
apiKey: `tr_dev_${slug}`,
pkApiKey: `pk_dev_${slug}`,
shortcode: `sc-${slug}`,
},
});
return {
organizationId: organization.id,
projectId: project.id,
environmentId: runtimeEnvironment.id,
userId: user.id,
};
}
// Mirrors the org/project/env parents onto a second DB with the SAME ids (BatchTaskRun FK needs
// the runtimeEnvironment to exist on whichever DB the row lives on).
async function mirrorEnvParents(
prisma: PrismaClient,
ctx: SeedContext,
slug: string
): Promise<void> {
const organization = await prisma.organization.create({
data: { id: ctx.organizationId, title: `org-${slug}`, slug: `org-${slug}` },
});
const project = await prisma.project.create({
data: {
id: ctx.projectId,
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `proj-${slug}`,
},
});
await prisma.runtimeEnvironment.create({
data: {
id: ctx.environmentId,
slug: `env-${slug}`,
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_dev_${slug}_m`,
pkApiKey: `pk_dev_${slug}_m`,
shortcode: `sc-${slug}-m`,
},
});
}
async function createBatch(
prisma: PrismaClient,
ctx: SeedContext,
batch: {
id: string;
friendlyId: string;
status?: any;
batchVersion?: string;
runCount?: number;
createdAt?: Date;
}
) {
return prisma.batchTaskRun.create({
data: {
id: batch.id,
friendlyId: batch.friendlyId,
runtimeEnvironmentId: ctx.environmentId,
status: batch.status ?? "PENDING",
batchVersion: batch.batchVersion ?? "v3",
runCount: batch.runCount ?? 1,
...(batch.createdAt ? { createdAt: batch.createdAt } : {}),
},
});
}
const baseCall = (
ctx: SeedContext,
overrides: Partial<BatchListOptions> = {}
): BatchListOptions => ({
projectId: ctx.projectId,
environmentId: ctx.environmentId,
userId: ctx.userId,
...overrides,
});
// Wraps a prisma client so the test can assert whether/how often batchTaskRun.findMany or
// batchTaskRun.findFirst are invoked. Optionally throws if invoked (proves a handle is never touched).
function spyClient(
prisma: PrismaClient,
opts: { throwOnQueryRaw?: boolean; throwOnFindFirst?: boolean } = {}
) {
const counts = { queryRaw: 0, findMany: 0, findFirst: 0 };
const proxy = new Proxy(prisma, {
get(target, prop, receiver) {
if (prop === "batchTaskRun") {
const real = (target as any).batchTaskRun;
return new Proxy(real, {
get(trTarget, trProp) {
if (trProp === "findMany") {
return (...args: any[]) => {
counts.findMany++;
if (opts.throwOnQueryRaw)
throw new Error("batchTaskRun.findMany must not be invoked on this handle");
return (trTarget as any).findMany(...args);
};
}
if (trProp === "findFirst") {
return (...args: any[]) => {
counts.findFirst++;
if (opts.throwOnFindFirst)
throw new Error("batchTaskRun.findFirst must not be invoked on this handle");
return (trTarget as any).findFirst(...args);
};
}
return (trTarget as any)[trProp];
},
});
}
return Reflect.get(target, prop, receiver);
},
}) as unknown as PrismaClient;
return { client: proxy, counts };
}
const desc = (a: string, b: string) => (a < b ? 1 : a > b ? -1 : 0);
describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + PG17 new)", () => {
// Byte-identical scan + identical ORDER-BY across PG14/PG17.
heteroPostgresTest(
"raw paginated scan is byte-identical and identically ordered across PG14 and PG17 (both directions, with/without cursor)",
async ({ prisma14, prisma17 }) => {
const ctx14 = await seedParents(prisma14, "scan");
const ctx17 = await seedParents(prisma17, "scan");
// Identical corpus on both sides (same logical ids), exercising statuses + batchVersion +
// createdAt spanning a period, and keyset cursor boundaries.
const ids = ["aaaa", "bbbb", "cccc", "dddd", "eeee"];
const statuses = ["PENDING", "COMPLETED", "ABORTED", "PROCESSING", "COMPLETED"];
const versions = ["v3", "v3", "v1", "v2", "v3"];
for (let i = 0; i < ids.length; i++) {
await createBatch(prisma14, ctx14, {
id: `batch_${ids[i]}`,
friendlyId: `fr_${ids[i]}`,
status: statuses[i],
batchVersion: versions[i],
runCount: i + 1,
createdAt: new Date(Date.now() - i * 60_000),
});
await createBatch(prisma17, ctx17, {
id: `batch_${ids[i]}`,
friendlyId: `fr_${ids[i]}`,
status: statuses[i],
batchVersion: versions[i],
runCount: i + 1,
createdAt: new Date(Date.now() - i * 60_000),
});
}
for (const direction of ["forward", "backward"] as const) {
for (const cursor of [undefined, "batch_cccc"]) {
const rows14 = await rawScan(prisma14, {
environmentId: ctx14.environmentId,
pageSize: 2,
direction,
cursor,
});
const rows17 = await rawScan(prisma17, {
environmentId: ctx17.environmentId,
pageSize: 2,
direction,
cursor,
});
// ids are identical across both DBs; rows must match byte-for-byte and in order.
expect(rows14.map((r) => r.id)).toEqual(rows17.map((r) => r.id));
expect(rows14.map((r) => r.friendlyId)).toEqual(rows17.map((r) => r.friendlyId));
expect(rows14.map((r) => r.runCount)).toEqual(rows17.map((r) => r.runCount));
expect(rows14.map((r) => r.status)).toEqual(rows17.map((r) => r.status));
// ORDER-BY parity: forward => id DESC, backward => id ASC.
const order = rows14.map((r) => r.id);
const expected = [...order].sort(direction === "forward" ? desc : (a, b) => -desc(a, b));
expect(order).toEqual(expected);
}
}
// The TS codepoint comparator reproduces the DB ORDER BY over the seeded id set.
const allIds = ids.map((i) => `batch_${i}`);
const dbForward = (
await rawScan(prisma17, {
environmentId: ctx17.environmentId,
pageSize: 50,
direction: "forward",
})
).map((r) => r.id);
expect(dbForward).toEqual([...allIds].sort(desc));
}
);
// Split scan merge serves new + legacy in one keyset-ordered page.
heteroPostgresTest(
"split scan merges new (PG17) + legacy (PG14) rows under the keyset order; legacy read only when new does not fill the page",
async ({ prisma14, prisma17 }) => {
const ctx14 = await seedParents(prisma14, "merge");
await mirrorEnvParents(prisma17, ctx14, "merge");
// Interleaved ids across the keyset order. New (migrated) on PG17, legacy on PG14.
await createBatch(prisma17, ctx14, { id: "batch_a", friendlyId: "fr_a", runCount: 1 });
await createBatch(prisma14, ctx14, { id: "batch_b", friendlyId: "fr_b", runCount: 2 });
await createBatch(prisma17, ctx14, { id: "batch_c", friendlyId: "fr_c", runCount: 3 });
await createBatch(prisma14, ctx14, { id: "batch_d", friendlyId: "fr_d", runCount: 4 });
await createBatch(prisma17, ctx14, { id: "batch_e", friendlyId: "fr_e", runCount: 5 });
// Case A: small page fully served by new alone => legacy NOT read.
const legacySpyA = spyClient(prisma14);
const presenterA = new BatchListPresenter(prisma17, prisma17, {
runOpsNew: prisma17,
runOpsLegacyReplica: legacySpyA.client,
controlPlaneReplica: prisma14,
splitEnabled: true,
});
const pageA = await presenterA.call(baseCall(ctx14, { pageSize: 2 }));
// new ids are e, c, a -> DESC: e, c (pageSize 2). pageSize+1 = 3 rows from new fills the page.
expect(pageA.batches.map((b) => b.id)).toEqual(["batch_e", "batch_c"]);
expect(legacySpyA.counts.findMany).toBe(0);
// Case B: page needs legacy rows => legacy IS read and the merge is keyset-ordered union.
const legacySpyB = spyClient(prisma14);
const presenterB = new BatchListPresenter(prisma17, prisma17, {
runOpsNew: prisma17,
runOpsLegacyReplica: legacySpyB.client,
controlPlaneReplica: prisma14,
splitEnabled: true,
});
const pageB = await presenterB.call(baseCall(ctx14, { pageSize: 4 }));
// union DESC of all 5: e, d, c, b, a -> first 4.
expect(pageB.batches.map((b) => b.id)).toEqual(["batch_e", "batch_d", "batch_c", "batch_b"]);
expect(legacySpyB.counts.findMany).toBeGreaterThan(0);
// cursor parity: next is the 4th id (pageSize-th), previous undefined (no input cursor).
expect(pageB.pagination.next).toBe("batch_b");
expect(pageB.pagination.previous).toBeUndefined();
expect(pageB.hasAnyBatches).toBe(true);
}
);
// Project resolves on control-plane; no cross-seam join.
heteroPostgresTest(
"project resolves on the control-plane handle (PG14); BatchTaskRun scan reads run-ops only",
async ({ prisma14, prisma17 }) => {
// Project/env/orgMember/user only on PG14 (control-plane). BatchTaskRun env mirrored to PG17.
const ctx = await seedParents(prisma14, "cp");
await mirrorEnvParents(prisma17, ctx, "cp");
await createBatch(prisma17, ctx, { id: "batch_cp1", friendlyId: "fr_cp1", runCount: 7 });
// controlPlaneReplica must never run the BatchTaskRun raw scan.
const cpSpy = spyClient(prisma14, { throwOnQueryRaw: true });
// runOpsNew must never run a project lookup — guard by making project absent on PG17.
const presenter = new BatchListPresenter(prisma17, prisma17, {
runOpsNew: prisma17,
runOpsLegacyReplica: prisma14,
controlPlaneReplica: cpSpy.client,
splitEnabled: true,
});
const page = await presenter.call(baseCall(ctx, { pageSize: 10 }));
expect(page.batches.map((b) => b.id)).toEqual(["batch_cp1"]);
// displayableEnvironment mapped by in-memory id match.
expect(page.batches[0].environment.id).toBe(ctx.environmentId);
expect(page.batches[0].environment.type).toBe("DEVELOPMENT");
// control-plane handle was used (project read) but never for the batch scan.
expect(cpSpy.counts.findMany).toBe(0);
}
);
// Empty-state probe is dual-DB during the window.
heteroPostgresTest(
"empty-state probe reads new then legacy replica: true when legacy has a batch, false when both empty",
async ({ prisma14, prisma17 }) => {
const ctx = await seedParents(prisma14, "probe");
await mirrorEnvParents(prisma17, ctx, "probe");
// Zero batches on new (PG17), one on legacy (PG14). A filter that yields an empty page.
await createBatch(prisma14, ctx, { id: "batch_legacy_only", friendlyId: "fr_legacy_only" });
const presenter = new BatchListPresenter(prisma17, prisma17, {
runOpsNew: prisma17,
runOpsLegacyReplica: prisma14,
controlPlaneReplica: prisma14,
splitEnabled: true,
});
// friendlyId filter that matches nothing => empty page, probe must still find the legacy row.
const page = await presenter.call(baseCall(ctx, { friendlyId: "fr_does_not_exist" }));
expect(page.batches).toHaveLength(0);
expect(page.hasAnyBatches).toBe(true);
// Now wipe legacy too => both empty => hasAnyBatches false.
await prisma14.batchTaskRun.deleteMany({
where: { runtimeEnvironmentId: ctx.environmentId },
});
const page2 = await presenter.call(baseCall(ctx, { friendlyId: "fr_does_not_exist" }));
expect(page2.batches).toHaveLength(0);
expect(page2.hasAnyBatches).toBe(false);
}
);
// Single-DB passthrough collapses to one handle.
postgresTest(
"passthrough (no readRoute): scan + probe + project all read the single handle; legacy closures never invoked",
async ({ prisma }) => {
const ctx = await seedParents(prisma, "pass");
await createBatch(prisma, ctx, { id: "batch_p1", friendlyId: "fr_p1", runCount: 3 });
await createBatch(prisma, ctx, { id: "batch_p2", friendlyId: "fr_p2", runCount: 4 });
await createBatch(prisma, ctx, { id: "batch_p3", friendlyId: "fr_p3", runCount: 5 });
const presenter = new BatchListPresenter(prisma, prisma);
const page = await presenter.call(baseCall(ctx, { pageSize: 2 }));
// Page content + ordering + cursors equal a direct $queryRaw of the same SQL.
const direct = await rawScan(prisma, {
environmentId: ctx.environmentId,
pageSize: 2,
direction: "forward",
});
const hasMore = direct.length > 2;
const expectedPage = direct.slice(0, 2);
expect(page.batches.map((b) => b.id)).toEqual(expectedPage.map((r) => r.id));
expect(page.pagination.next).toBe(hasMore ? expectedPage[1].id : undefined);
expect(page.pagination.previous).toBeUndefined();
expect(page.hasAnyBatches).toBe(true);
// A throwing legacy handle proves the split branch is never entered in passthrough.
const throwingLegacy = spyClient(prisma, { throwOnQueryRaw: true, throwOnFindFirst: true });
const presenterWithUnusedLegacy = new BatchListPresenter(prisma, prisma, {
runOpsLegacyReplica: throwingLegacy.client,
// splitEnabled omitted => passthrough; legacy must never be touched.
});
const page2 = await presenterWithUnusedLegacy.call(baseCall(ctx, { pageSize: 2 }));
expect(page2.batches.map((b) => b.id)).toEqual(expectedPage.map((r) => r.id));
expect(throwingLegacy.counts.findMany).toBe(0);
expect(throwingLegacy.counts.findFirst).toBe(0);
}
);
heteroRunOpsPostgresTest(
"scan against dedicated RunOpsPrismaClient (splitEnabled): returns batches from new DB",
async ({ prisma14, prisma17 }) => {
const ctx = await seedParents(prisma14, "rawscan-batch14");
// runtimeEnvironmentId is a FK-free scalar in the run-ops schema — no parent row needed.
await (prisma17 as RunOpsPrismaClient).batchTaskRun.create({
data: {
id: "rbatch00000000000000001",
friendlyId: "fr_rbatch00000000000000001",
runtimeEnvironmentId: ctx.environmentId,
status: "PENDING",
batchVersion: "v3",
runCount: 2,
},
});
const presenter = new BatchListPresenter(prisma14 as any, prisma14 as any, {
runOpsNew: prisma17 as any,
runOpsLegacyReplica: prisma14 as any,
controlPlaneReplica: prisma14 as any,
splitEnabled: true,
});
const page = await presenter.call(baseCall(ctx, { pageSize: 10 }));
expect(page.batches.map((b) => b.id)).toContain("rbatch00000000000000001");
}
);
});
+360
View File
@@ -0,0 +1,360 @@
import { containerTest, heteroPostgresTest } from "@internal/testcontainers";
import { type PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import {
displayableEnvironment,
type DisplayableInputEnvironment,
} from "~/models/runtimeEnvironment.server";
import { BatchPresenter } from "~/presenters/v3/BatchPresenter.server";
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
vi.setConfig({ testTimeout: 90_000 });
type SeedContext = {
organizationId: string;
projectId: string;
environmentId: string;
};
/**
* Seeds the control-plane org/project/env on one DB. The env is control-plane; it is
* resolved separately from the run-ops batch row (the cross-seam FK is physically dropped),
* so this always lives on the same DB that the injected resolveDisplayableEnvironment reads.
*/
async function seedEnvironment(
prisma: PrismaClient,
slug: string,
type: "DEVELOPMENT" | "PRODUCTION" = "PRODUCTION"
): Promise<SeedContext> {
const organization = await prisma.organization.create({
data: { title: `org-${slug}`, slug: `org-${slug}` },
});
const project = await prisma.project.create({
data: {
name: `proj-${slug}`,
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `proj-${slug}`,
},
});
let orgMemberId: string | undefined;
if (type === "DEVELOPMENT") {
const user = await prisma.user.create({
data: {
email: `user-${slug}@example.com`,
name: `User ${slug}`,
displayName: `Display ${slug}`,
authenticationMethod: "MAGIC_LINK",
},
});
const member = await prisma.orgMember.create({
data: { organizationId: organization.id, userId: user.id, role: "ADMIN" },
});
orgMemberId = member.id;
}
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}`,
type,
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_${slug}`,
pkApiKey: `pk_${slug}`,
shortcode: `sc-${slug}`,
orgMemberId,
},
});
return {
organizationId: organization.id,
projectId: project.id,
environmentId: environment.id,
};
}
async function seedBatch(
prisma: PrismaClient,
environmentId: string,
opts: {
friendlyId: string;
status?: any;
batchVersion?: string;
runCount?: number;
withError?: boolean;
}
) {
return prisma.batchTaskRun.create({
data: {
friendlyId: opts.friendlyId,
runtimeEnvironmentId: environmentId,
status: opts.status ?? "COMPLETED",
batchVersion: opts.batchVersion ?? "v1",
runCount: opts.runCount ?? 0,
successfulRunCount: 3,
failedRunCount: 1,
idempotencyKey: `idem-${opts.friendlyId}`,
errors: opts.withError
? {
create: [
{
index: 0,
taskIdentifier: "my-task",
error: JSON.stringify({ message: "boom", stack: "x\ny" }),
errorCode: "TASK_RUN_FAILED",
},
],
}
: undefined,
},
});
}
/**
* Builds a resolveDisplayableEnvironment closure over a real control-plane container — exactly
* mirroring the production findDisplayableEnvironment (reads control-plane, returns the
* displayableEnvironment shape). This is the only injected boundary; the DB is never mocked.
*/
function makeEnvResolver(controlPlane: PrismaClient) {
return async (environmentId: string, userId: string | undefined) => {
const environment = await controlPlane.runtimeEnvironment.findFirst({
where: { id: environmentId },
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: { user: { select: { id: true, name: true, displayName: true } } },
},
},
});
if (!environment) {
return undefined;
}
return displayableEnvironment(environment as DisplayableInputEnvironment, userId);
};
}
describe("BatchPresenter read-through (PG14 legacy + PG17 new)", () => {
// Batch detail resolves on run-ops NEW (split on). Legacy replica is never probed.
heteroPostgresTest(
"resolves a NEW-resident batch and never probes the legacy replica",
async ({ prisma14, prisma17 }) => {
const ctx = await seedEnvironment(prisma17, "new1");
await seedBatch(prisma17, ctx.environmentId, {
friendlyId: "batch_new1",
withError: true,
runCount: 4,
});
// Real readThroughRun. A tripwire legacy client throws if batchTaskRun is ever accessed,
// proving a NEW-resident row is served without probing the legacy replica.
const tripwireLegacy = new Proxy(prisma14, {
get(target, prop) {
if (prop === "batchTaskRun") {
throw new Error("legacy replica must not be probed for a NEW-resident batch");
}
return (target as any)[prop];
},
}) as unknown as PrismaClient;
const presenter = new BatchPresenter(undefined, undefined, {
splitEnabled: true,
newClient: prisma17,
legacyReplica: tripwireLegacy,
readThrough: readThroughRun,
resolveDisplayableEnvironment: makeEnvResolver(prisma17),
});
const result = await presenter.call({
environmentId: ctx.environmentId,
batchId: "batch_new1",
});
expect(result.friendlyId).toBe("batch_new1");
expect(result.runCount).toBe(4);
expect(result.idempotencyKey).toBe("idem-batch_new1");
expect(result.errors).toHaveLength(1);
expect(result.errors[0].errorCode).toBe("TASK_RUN_FAILED");
expect(result.environment.id).toBe(ctx.environmentId);
expect(result.environment.type).toBe("PRODUCTION");
}
);
// Batch detail resolves on run-ops OLD/legacy READ REPLICA (split on, in-retention).
// Cross-version round-trip: PG14 legacy -> presenter, JSON error payload byte-identical.
heteroPostgresTest(
"resolves a legacy-only batch via the legacy READ REPLICA, byte-identical",
async ({ prisma14, prisma17 }) => {
// Env is control-plane (lives wherever the resolver reads); batch is run-ops, legacy-only.
const ctx = await seedEnvironment(prisma14, "legacy1");
const errorPayload = JSON.stringify({ message: "legacy boom", stack: "a\nb\nc" });
await prisma14.batchTaskRun.create({
data: {
friendlyId: "batch_legacy1",
runtimeEnvironmentId: ctx.environmentId,
status: "COMPLETED",
batchVersion: "v1",
runCount: 2,
successfulRunCount: 1,
failedRunCount: 1,
idempotencyKey: "idem-batch_legacy1",
errors: {
create: [
{
index: 0,
taskIdentifier: "legacy-task",
error: errorPayload,
errorCode: "LEGACY_CODE",
},
],
},
},
});
// The structural guarantee: there is no legacy-PRIMARY handle in readThroughRun; the only
// legacy handle threaded here is the read replica (prisma14).
const presenter = new BatchPresenter(undefined, undefined, {
splitEnabled: true,
newClient: prisma17, // NEW probe misses (nothing seeded there)
legacyReplica: prisma14,
// Real readThroughRun; the NEW miss falls through to the legacy replica.
readThrough: readThroughRun,
resolveDisplayableEnvironment: makeEnvResolver(prisma14),
});
const result = await presenter.call({
environmentId: ctx.environmentId,
batchId: "batch_legacy1",
});
expect(result.friendlyId).toBe("batch_legacy1");
expect(result.runCount).toBe(2);
expect(result.errors).toHaveLength(1);
// JSON error payload round-trips byte-identically across PG14 -> presenter.
expect(result.errors[0].error).toBe(errorPayload);
expect(result.errors[0].taskIdentifier).toBe("legacy-task");
expect(result.environment.id).toBe(ctx.environmentId);
}
);
// Post-termination / not-found yields the normal "Batch not found".
heteroPostgresTest(
"throws the normal not-found when the batch is absent from both stores",
async ({ prisma14, prisma17 }) => {
const ctx = await seedEnvironment(prisma14, "missing1");
const presenter = new BatchPresenter(undefined, undefined, {
splitEnabled: true,
newClient: prisma17,
legacyReplica: prisma14,
readThrough: readThroughRun,
resolveDisplayableEnvironment: makeEnvResolver(prisma14),
});
await expect(
presenter.call({ environmentId: ctx.environmentId, batchId: "batch_does_not_exist" })
).rejects.toThrow("Batch not found");
}
);
// Env decoupling parity for a DEVELOPMENT env (userName branch).
heteroPostgresTest(
"resolves the DEVELOPMENT env userName separately from the run-ops batch row",
async ({ prisma14, prisma17 }) => {
const ctx = await seedEnvironment(prisma17, "dev1", "DEVELOPMENT");
await seedBatch(prisma17, ctx.environmentId, { friendlyId: "batch_dev1" });
const presenter = new BatchPresenter(undefined, undefined, {
splitEnabled: true,
newClient: prisma17,
legacyReplica: prisma14,
readThrough: readThroughRun,
resolveDisplayableEnvironment: makeEnvResolver(prisma17),
});
// No userId passed -> userName resolves to the member's username (the orgMember branch).
const result = await presenter.call({
environmentId: ctx.environmentId,
batchId: "batch_dev1",
});
expect(result.environment.type).toBe("DEVELOPMENT");
expect(result.environment.userName).toBe("Display dev1");
}
);
});
describe("BatchPresenter single-DB passthrough", () => {
// Passthrough + self-host collapse: one plain read, legacy closure never invoked.
containerTest(
"single-DB resolves the batch with one plain read and never touches the legacy boundary",
async ({ prisma }) => {
const ctx = await seedEnvironment(prisma, "passthrough");
await seedBatch(prisma, ctx.environmentId, {
friendlyId: "batch_passthrough",
withError: true,
runCount: 5,
});
let legacyInvoked = false;
const presenter = new BatchPresenter(prisma, prisma, {
splitEnabled: false,
// Pass the single DB as both clients; the passthrough must read NEW only.
newClient: prisma,
legacyReplica: new Proxy(prisma, {
get(target, prop) {
if (prop === "batchTaskRun") {
legacyInvoked = true;
throw new Error("legacy boundary must not be touched in single-DB passthrough");
}
return (target as any)[prop];
},
}) as unknown as PrismaClient,
resolveDisplayableEnvironment: makeEnvResolver(prisma),
});
const result = await presenter.call({
environmentId: ctx.environmentId,
batchId: "batch_passthrough",
});
expect(result.friendlyId).toBe("batch_passthrough");
expect(result.runCount).toBe(5);
expect(result.errors).toHaveLength(1);
expect(result.environment.id).toBe(ctx.environmentId);
expect(legacyInvoked).toBe(false);
}
);
// e2e #3 scoped proxy: a batch whose members span migrated + abandoned runs still resolves
// at the batch-record level. Scope: BatchPresenter reads only the batch row, not its member
// TaskRuns — the dangling-reference gate over members is owned by the migration / dangling-gate
// units, not this presenter. This unit's contribution is "batch detail loads regardless of
// which run-ops store holds the batch row."
containerTest(
"e2e #3 proxy: a batch spanning migrated + abandoned runs still resolves",
async ({ prisma }) => {
const ctx = await seedEnvironment(prisma, "e2e3");
await seedBatch(prisma, ctx.environmentId, {
friendlyId: "batch_e2e3",
runCount: 10, // implies members spanning migrated + abandoned runs
});
const presenter = new BatchPresenter(prisma, prisma, {
splitEnabled: false,
newClient: prisma,
resolveDisplayableEnvironment: makeEnvResolver(prisma),
});
const result = await presenter.call({
environmentId: ctx.environmentId,
batchId: "batch_e2e3",
});
expect(result.friendlyId).toBe("batch_e2e3");
expect(result.runCount).toBe(10);
}
);
});
@@ -0,0 +1,224 @@
// REGRESSION: the 2-phase batch API (createBatch.server.ts + streamBatchItems.server.ts, wired
// through BatchQueue to setupBatchQueueCallbacks) must anchor each item's mint on the BATCH's own
// friendlyId, like batchTriggerV3.server.ts's mintChildFriendlyId does — not re-resolve the
// per-org mint flag, which can flip between batch creation and this async callback. Covers the
// happy path (both residency directions) and all three pre-failed-run branches: trigger returned
// undefined, pre-marked error item, and a thrown trigger error.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findEnvironmentById: vi.fn(),
triggerTaskServiceCall: vi.fn(),
triggerFailedTaskCall: vi.fn(),
setBatchProcessItemCallback: vi.fn(),
}));
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
runOpsNewPrisma: {},
runOpsLegacyPrisma: {},
runOpsNewReplica: {},
runOpsLegacyReplica: {},
}));
vi.mock("~/models/runtimeEnvironment.server", () => ({
findEnvironmentById: mocks.findEnvironmentById,
findEnvironmentFromRun: vi.fn(),
}));
vi.mock("~/v3/services/triggerTask.server", () => ({
TriggerTaskService: class {
call(...args: unknown[]) {
return mocks.triggerTaskServiceCall(...args);
}
},
}));
vi.mock("~/runEngine/services/triggerFailedTask.server", () => ({
TriggerFailedTaskService: class {
call(...args: unknown[]) {
return mocks.triggerFailedTaskCall(...args);
}
},
}));
vi.mock("~/v3/runEngine.server", () => ({
engine: {
setBatchProcessItemCallback: mocks.setBatchProcessItemCallback,
setBatchCompletionCallback: vi.fn(),
tryCompleteBatch: vi.fn(),
},
}));
vi.mock("~/v3/runOpsMigration/splitMode.server", () => ({ isSplitEnabled: async () => false }));
import {
BatchId,
classifyKind,
generateRunOpsId,
ownerEngine,
} from "@trigger.dev/core/v3/isomorphic";
import { setupBatchQueueCallbacks } from "~/v3/runEngineHandlers.server";
vi.setConfig({ testTimeout: 30_000 });
type ProcessItemCallback = (args: {
batchId: string;
friendlyId: string;
itemIndex: number;
item: Record<string, unknown>;
meta: Record<string, unknown>;
attempt: number;
isFinalAttempt: boolean;
}) => Promise<unknown>;
let processItemCallback: ProcessItemCallback;
beforeAll(() => {
setupBatchQueueCallbacks();
processItemCallback = mocks.setBatchProcessItemCallback.mock.calls[0][0] as ProcessItemCallback;
});
beforeEach(() => {
mocks.findEnvironmentById.mockReset().mockResolvedValue({
id: "env_1",
organizationId: "org_1",
organization: { featureFlags: {} },
});
mocks.triggerTaskServiceCall.mockReset();
mocks.triggerFailedTaskCall.mockReset();
});
async function runItem(
friendlyId: string,
isFinalAttempt = false,
itemOptions: Record<string, unknown> = {}
) {
await processItemCallback({
batchId: "batch_internal_1",
friendlyId,
itemIndex: 0,
item: {
task: "some-task",
payload: "{}",
payloadType: "application/json",
options: itemOptions,
},
meta: { environmentId: "env_1" },
attempt: 1,
isFinalAttempt,
});
}
describe("setupBatchQueueCallbacks — batch item residency anchoring", () => {
// Split is off in this test, so re-resolving the per-org flag would yield cuid — a NEW anchor
// yielding runOpsId proves the anchor wins over what the flag would say.
it("happy path: a run-ops (NEW) batch anchor mints a run-ops item", async () => {
const friendlyId = BatchId.toFriendlyId(generateRunOpsId());
expect(ownerEngine(friendlyId)).toBe("NEW");
mocks.triggerTaskServiceCall.mockResolvedValue({
run: { id: "run_internal_1", friendlyId: "run_fake", status: "PENDING" },
isCached: false,
});
await runItem(friendlyId);
expect(mocks.triggerTaskServiceCall).toHaveBeenCalledTimes(1);
const [, , , options] = mocks.triggerTaskServiceCall.mock.calls[0] as [
unknown,
unknown,
unknown,
{ runFriendlyId?: string } | undefined,
];
expect(options?.runFriendlyId).toBeDefined();
expect(classifyKind(options!.runFriendlyId!)).toBe("runOpsId");
});
// The cuid direction catches a "always mint run-ops id regardless of anchor" bug.
it("happy path: a cuid (LEGACY) batch anchor mints a cuid item", async () => {
const friendlyId = BatchId.generate().friendlyId;
expect(ownerEngine(friendlyId)).toBe("LEGACY");
mocks.triggerTaskServiceCall.mockResolvedValue({
run: { id: "run_internal_1", friendlyId: "run_fake", status: "PENDING" },
isCached: false,
});
await runItem(friendlyId);
expect(mocks.triggerTaskServiceCall).toHaveBeenCalledTimes(1);
const [, , , options] = mocks.triggerTaskServiceCall.mock.calls[0] as [
unknown,
unknown,
unknown,
{ runFriendlyId?: string } | undefined,
];
expect(options?.runFriendlyId).toBeDefined();
expect(classifyKind(options!.runFriendlyId!)).toBe("cuid");
});
// The pre-failed run (TriggerTaskService returned undefined on the final attempt) carries
// batchId (→ live TaskRun.batchId FK), so it too must be anchored to the batch's residency.
it("failure branch: a run-ops (NEW) batch anchors the pre-failed run to the batch, not the flag", async () => {
const friendlyId = BatchId.toFriendlyId(generateRunOpsId());
expect(ownerEngine(friendlyId)).toBe("NEW");
mocks.triggerTaskServiceCall.mockResolvedValue(undefined); // item trigger fails
mocks.triggerFailedTaskCall.mockResolvedValue("run_prefailed_fake");
await runItem(friendlyId, true);
expect(mocks.triggerFailedTaskCall).toHaveBeenCalledTimes(1);
const [request] = mocks.triggerFailedTaskCall.mock.calls[0] as [{ runFriendlyId?: string }];
expect(request.runFriendlyId).toBeDefined();
expect(classifyKind(request.runFriendlyId!)).toBe("runOpsId");
});
it("failure branch: a cuid (LEGACY) batch anchors the pre-failed run to a cuid id", async () => {
const friendlyId = BatchId.generate().friendlyId;
mocks.triggerTaskServiceCall.mockResolvedValue(undefined); // item trigger fails
mocks.triggerFailedTaskCall.mockResolvedValue("run_prefailed_fake");
await runItem(friendlyId, true);
expect(mocks.triggerFailedTaskCall).toHaveBeenCalledTimes(1);
const [request] = mocks.triggerFailedTaskCall.mock.calls[0] as [{ runFriendlyId?: string }];
expect(request.runFriendlyId).toBeDefined();
expect(classifyKind(request.runFriendlyId!)).toBe("cuid");
});
// Pre-marked error items (e.g. oversized payloads) are pre-failed before the trigger runs; that
// pre-failed run also carries batchId, so it must anchor to the batch too.
it("pre-marked error branch: a run-ops (NEW) batch anchors the pre-failed run to the batch", async () => {
const friendlyId = BatchId.toFriendlyId(generateRunOpsId());
expect(ownerEngine(friendlyId)).toBe("NEW");
mocks.triggerFailedTaskCall.mockResolvedValue("run_prefailed_fake");
await runItem(friendlyId, false, {
__error: "payload too large",
__errorCode: "PAYLOAD_TOO_LARGE",
});
expect(mocks.triggerTaskServiceCall).not.toHaveBeenCalled();
expect(mocks.triggerFailedTaskCall).toHaveBeenCalledTimes(1);
const [request] = mocks.triggerFailedTaskCall.mock.calls[0] as [{ runFriendlyId?: string }];
expect(request.runFriendlyId).toBeDefined();
expect(classifyKind(request.runFriendlyId!)).toBe("runOpsId");
});
// The catch branch (the item trigger throws) creates a pre-failed run on the final attempt; it
// carries batchId, so it must anchor to the batch too.
it("catch branch: a run-ops (NEW) batch anchors the pre-failed run when the trigger throws", async () => {
const friendlyId = BatchId.toFriendlyId(generateRunOpsId());
expect(ownerEngine(friendlyId)).toBe("NEW");
mocks.triggerTaskServiceCall.mockRejectedValue(new Error("trigger boom"));
mocks.triggerFailedTaskCall.mockResolvedValue("run_prefailed_fake");
await runItem(friendlyId, true);
expect(mocks.triggerFailedTaskCall).toHaveBeenCalledTimes(1);
const [request] = mocks.triggerFailedTaskCall.mock.calls[0] as [{ runFriendlyId?: string }];
expect(request.runFriendlyId).toBeDefined();
expect(classifyKind(request.runFriendlyId!)).toBe("runOpsId");
});
});
+56
View File
@@ -0,0 +1,56 @@
import { setupAuthenticatedEnvironment } from "@internal/run-engine/tests";
import { PostgresRunStore } from "@internal/run-store";
import { containerTest } from "@internal/testcontainers";
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
import { describe, expect, vi } from "vitest";
import { findBatchRunIdForUser } from "~/v3/services/batchRunAccess.server";
vi.setConfig({ testTimeout: 60_000 });
const rand = () => Math.random().toString(36).slice(2, 10);
// The batch-resume route was previously unauthenticated. This is the org-scoped
// ownership gate: a user may only resolve a batch in an org they belong to.
describe("findBatchRunIdForUser", () => {
containerTest(
"resolves a batch for an org member, by friendlyId and by internal id",
async ({ prisma }) => {
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const member = await prisma.user.create({
data: { email: `member_${rand()}@example.com`, authenticationMethod: "MAGIC_LINK" },
});
await prisma.orgMember.create({
data: { organizationId: env.organizationId, userId: member.id },
});
const batchId = BatchId.generate();
const batch = await prisma.batchTaskRun.create({
data: { id: batchId.id, friendlyId: batchId.friendlyId, runtimeEnvironmentId: env.id },
});
expect(await findBatchRunIdForUser(prisma, store, batch.friendlyId, member.id)).toBe(
batch.id
);
expect(await findBatchRunIdForUser(prisma, store, batch.id, member.id)).toBe(batch.id);
}
);
containerTest(
"returns null for a user who is not a member of the batch's org",
async ({ prisma }) => {
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const batchId = BatchId.generate();
const batch = await prisma.batchTaskRun.create({
data: { id: batchId.id, friendlyId: batchId.friendlyId, runtimeEnvironmentId: env.id },
});
// A user who exists but isn't a member of the org.
const stranger = await prisma.user.create({
data: { email: `stranger_${rand()}@example.com`, authenticationMethod: "MAGIC_LINK" },
});
expect(await findBatchRunIdForUser(prisma, store, batch.friendlyId, stranger.id)).toBeNull();
expect(await findBatchRunIdForUser(prisma, store, batch.id, stranger.id)).toBeNull();
}
);
});
@@ -0,0 +1,169 @@
// Proof for dropping the canonical BatchTaskRun -> RuntimeEnvironment FK
// (constraint "BatchTaskRun_runtimeEnvironmentId_fkey", onDelete: Cascade) while keeping the
// runtimeEnvironmentId scalar and its compound @@unique/@@index. BatchTaskRun is run-ops and
// RuntimeEnvironment is control-plane, so the two may live on different servers; create-time
// integrity is preserved app-side via the ControlPlaneResolver's assertEnvExists. Env-delete
// orphan cleanup is handled separately — here the batch row is tolerated.
import { heteroPostgresTest, postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import { ControlPlaneCache } from "~/v3/runOpsMigration/controlPlaneCache.server";
import {
ControlPlaneReferenceError,
ControlPlaneResolver,
} from "~/v3/runOpsMigration/controlPlaneResolver.server";
// Cross-DB testcontainer spin-up + queries can exceed the 5s default on the first test.
vi.setConfig({ testTimeout: 60_000 });
let seedCounter = 0;
async function seedEnvironment(prisma: PrismaClient) {
const n = seedCounter++;
const organization = await prisma.organization.create({
data: { title: `Org ${n}`, slug: `org-${n}` },
});
const project = await prisma.project.create({
data: {
name: `Project ${n}`,
slug: `project-${n}`,
externalRef: `proj_${n}`,
organizationId: organization.id,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
type: "PRODUCTION",
slug: `env-${n}`,
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_prod_${n}`,
pkApiKey: `pk_prod_${n}`,
shortcode: `short_${n}`,
},
});
return { organization, project, environment };
}
let batchCounter = 0;
async function createBatch(prisma: PrismaClient, runtimeEnvironmentId: string) {
const n = batchCounter++;
return prisma.batchTaskRun.create({
data: {
friendlyId: `batch_${n}`,
runtimeEnvironmentId,
runCount: 1,
runIds: [],
batchVersion: "runengine:v2",
},
});
}
// Asserts the post-migration state of BatchTaskRun on a given client: the FK is gone, but the
// scalar and both compound constraints are retained. Shared by the single-version and the
// cross-version suites.
async function assertSchemaState(prisma: PrismaClient) {
const foreignKeys = await prisma.$queryRaw<{ constraint_name: string }[]>`
SELECT constraint_name
FROM information_schema.table_constraints
WHERE table_name = 'BatchTaskRun'
AND constraint_type = 'FOREIGN KEY'
`;
expect(foreignKeys.map((c) => c.constraint_name)).not.toContain(
"BatchTaskRun_runtimeEnvironmentId_fkey"
);
const columns = await prisma.$queryRaw<{ column_name: string }[]>`
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'BatchTaskRun'
AND column_name = 'runtimeEnvironmentId'
`;
expect(columns).toHaveLength(1);
// The @@unique([runtimeEnvironmentId, idempotencyKey]) and
// @@index([runtimeEnvironmentId, id(sort: Desc)]) both survive the FK drop.
const indexes = await prisma.$queryRaw<{ indexdef: string }[]>`
SELECT indexdef FROM pg_indexes WHERE tablename = 'BatchTaskRun'
`;
const defs = indexes.map((i) => i.indexdef);
const hasUnique = defs.some(
(d) => /UNIQUE/i.test(d) && d.includes("runtimeEnvironmentId") && d.includes("idempotencyKey")
);
const hasIndex = defs.some(
(d) => !/UNIQUE/i.test(d) && d.includes("runtimeEnvironmentId") && /\bid\b/.test(d)
);
expect(hasUnique).toBe(true);
expect(hasIndex).toBe(true);
}
// Inserts an env + batch, deletes the env, and asserts the batch survives (cascade gone).
async function assertOrphanTolerated(prisma: PrismaClient) {
const { environment } = await seedEnvironment(prisma);
const batch = await createBatch(prisma, environment.id);
await prisma.runtimeEnvironment.delete({ where: { id: environment.id } });
const survivor = await prisma.batchTaskRun.findFirst({ where: { id: batch.id } });
expect(survivor).not.toBeNull();
expect(survivor?.runtimeEnvironmentId).toBe(environment.id);
}
describe("drop BatchTaskRun -> RuntimeEnvironment FK", () => {
postgresTest("FK constraint absent; scalar + unique + index retained", async ({ prisma }) => {
await assertSchemaState(prisma);
});
postgresTest(
"deleting the env leaves the BatchTaskRun row alive (no cascade; orphan cleanup handled separately)",
async ({ prisma }) => {
await assertOrphanTolerated(prisma);
}
);
postgresTest(
"app-side env validation: assertEnvExists rejects an invalid env and a valid-env create succeeds by scalar",
async ({ prisma }) => {
const { environment } = await seedEnvironment(prisma);
const resolver = new ControlPlaneResolver({
controlPlanePrimary: prisma,
controlPlaneReplica: prisma,
cache: new ControlPlaneCache(),
splitEnabled: () => true,
});
// The exact guard call the create services place before batchTaskRun.create.
await expect(resolver.assertEnvExists("env_does_not_exist")).rejects.toBeInstanceOf(
ControlPlaneReferenceError
);
await expect(resolver.assertEnvExists(environment.id)).resolves.toBeUndefined();
// Once the guard passes, the batch is linked by the runtimeEnvironmentId scalar (no FK).
const batch = await createBatch(prisma, environment.id);
expect(batch.runtimeEnvironmentId).toBe(environment.id);
}
);
});
// Cross-version gate: the migration applies and the post-state is identical across major versions.
describe("drop BatchTaskRun -> RuntimeEnvironment FK — cross-version (legacy + new Postgres)", () => {
heteroPostgresTest(
"migration applies and FK is absent on both the legacy and new databases",
async ({ prisma14, prisma17 }) => {
await assertSchemaState(prisma14);
await assertSchemaState(prisma17);
}
);
heteroPostgresTest(
"env delete leaves the batch orphaned on both the legacy and new databases",
async ({ prisma14, prisma17 }) => {
await assertOrphanTolerated(prisma14);
await assertOrphanTolerated(prisma17);
}
);
});
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from "vitest";
// Module-level db wiring is imported transitively by the service file. The mint
// helper under test never touches the DB (it is driven with injected deps), so
// these empty singletons only satisfy the import graph — same boundary pattern
// as triggerTask.server.test.ts and runEngineBatchTriggerStoreRouting.test.ts.
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
runOpsNewPrisma: {},
runOpsLegacyPrisma: {},
runOpsNewReplica: {},
runOpsLegacyReplica: {},
}));
import { BatchId, generateRunOpsId, ownerEngine, RunId } from "@trigger.dev/core/v3/isomorphic";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { BatchTriggerV3Service } from "~/v3/services/batchTriggerV3.server";
vi.setConfig({ testTimeout: 60_000 });
const CUID_LEN = 25;
const RUN_OPS_ID_LEN = 26;
// Minimal AuthenticatedEnvironment — only the fields the mint path reads
// (organizationId, id, organization.featureFlags) need to be real. A root batch
// (no parentRunId) with no run-ops id override mints cuid, which is the env-default
// branch we assert on below.
function fakeEnv(): AuthenticatedEnvironment {
return {
id: "env_123",
organizationId: "org_123",
organization: { featureFlags: {} },
} as unknown as AuthenticatedEnvironment;
}
// Build the service with resolveMintKind forced to "cuid" (its production default
// when split is off / org not cut over), proving the CHILD branch overrides the env
// default purely from the parent's id-shape.
function buildService() {
return new BatchTriggerV3Service(undefined, undefined, {} as any, {} as any, async () => "cuid");
}
describe("BatchTriggerV3Service child-residency inheritance", () => {
it("a run-ops parent yields run-ops id (NEW) child friendlyIds", async () => {
const service = buildService();
const parentFriendlyId = RunId.toFriendlyId(
// v1 internal id (version "1" at index 25) → NEW residency parent
"a".repeat(RUN_OPS_ID_LEN - 1) + "1"
);
expect(ownerEngine(RunId.fromFriendlyId(parentFriendlyId))).toBe("NEW");
const childFriendlyId = await (service as any).mintChildFriendlyId(fakeEnv(), parentFriendlyId);
expect(RunId.fromFriendlyId(childFriendlyId).length).toBe(RUN_OPS_ID_LEN);
expect(ownerEngine(RunId.fromFriendlyId(childFriendlyId))).toBe("NEW");
});
it("a cuid parent yields cuid (LEGACY) child friendlyIds", async () => {
const service = buildService();
const parentFriendlyId = RunId.generate().friendlyId; // cuid (25) → LEGACY parent
expect(ownerEngine(RunId.fromFriendlyId(parentFriendlyId))).toBe("LEGACY");
const childFriendlyId = await (service as any).mintChildFriendlyId(fakeEnv(), parentFriendlyId);
expect(RunId.fromFriendlyId(childFriendlyId).length).toBe(CUID_LEN);
expect(ownerEngine(RunId.fromFriendlyId(childFriendlyId))).toBe("LEGACY");
});
it("a ROOT batch (no parentRunId) mints by the env setting (cuid default here)", async () => {
const service = buildService();
const childFriendlyId = await (service as any).mintChildFriendlyId(fakeEnv(), undefined);
expect(RunId.fromFriendlyId(childFriendlyId).length).toBe(CUID_LEN);
expect(ownerEngine(RunId.fromFriendlyId(childFriendlyId))).toBe("LEGACY");
});
// A root batch's children are anchored to the batch's friendlyId, NOT to a
// re-resolution of the per-org flag. Even with the env flag forced to "cuid" (a flip
// away from the batch's residency), a run-ops batch anchor yields run-ops children — so
// batch + children stay co-resident and TaskRun.batchId never crosses the seam.
it("a run-ops batch anchor yields run-ops children even when the env flag resolves cuid", async () => {
const service = buildService(); // resolveMintKind forced to "cuid"
const batchFriendlyId = BatchId.toFriendlyId(generateRunOpsId()); // run-ops id (NEW) batch
expect(ownerEngine(batchFriendlyId)).toBe("NEW");
const childFriendlyId = await (service as any).mintChildFriendlyId(fakeEnv(), batchFriendlyId);
expect(RunId.fromFriendlyId(childFriendlyId).length).toBe(RUN_OPS_ID_LEN);
expect(ownerEngine(RunId.fromFriendlyId(childFriendlyId))).toBe("NEW");
});
// The cuid mirror: a cuid batch anchor yields cuid children even if the flag flipped ON.
it("a cuid batch anchor yields cuid children even when the env flag resolves 'runOpsId'", async () => {
const service = new BatchTriggerV3Service(
undefined,
undefined,
{} as any,
{} as any,
async () => "runOpsId" // env flag flipped ON mid-batch
);
const batchFriendlyId = BatchId.generate().friendlyId; // cuid (LEGACY) batch
expect(ownerEngine(batchFriendlyId)).toBe("LEGACY");
const childFriendlyId = await (service as any).mintChildFriendlyId(fakeEnv(), batchFriendlyId);
expect(RunId.fromFriendlyId(childFriendlyId).length).toBe(CUID_LEN);
expect(ownerEngine(RunId.fromFriendlyId(childFriendlyId))).toBe("LEGACY");
});
});
@@ -0,0 +1,252 @@
import { heteroPostgresTest } from "@internal/testcontainers";
import { PostgresRunStore } from "@internal/run-store";
import { isUniqueConstraintError, type PrismaClient } from "@trigger.dev/database";
import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
import { describe, expect, vi } from "vitest";
vi.setConfig({ testTimeout: 60_000 });
// Proves BatchTriggerV3's three store seams (cached-run lookup, expired-key clear,
// membership write) route correctly against real PG14 (legacy) + PG17 (run-ops)
// containers, using the service's exact query shapes. The service methods are
// JS #-private, so the seam is driven directly — same approach as the sibling
// legacy-authority test.
async function seedOrgProjectEnv(prisma: PrismaClient, suffix: string) {
const organization = await prisma.organization.create({
data: { title: `test-${suffix}`, slug: `test-${suffix}` },
});
const project = await prisma.project.create({
data: {
name: `test-${suffix}`,
slug: `test-${suffix}`,
organizationId: organization.id,
externalRef: `test-${suffix}`,
},
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: `test-${suffix}`,
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
apiKey: `test-${suffix}`,
pkApiKey: `test-${suffix}`,
shortcode: `test-${suffix}`,
},
});
return { organization, project, runtimeEnvironment };
}
async function seedRun(
prisma: PrismaClient,
args: {
runtimeEnvironmentId: string;
projectId: string;
organizationId: string;
taskIdentifier: string;
idempotencyKey?: string;
status?: "PENDING" | "EXECUTING" | "COMPLETED_SUCCESSFULLY" | "COMPLETED_WITH_ERRORS";
idempotencyKeyExpiresAt?: Date;
}
) {
const runId = generateRunOpsId();
return prisma.taskRun.create({
data: {
id: runId,
friendlyId: `run_${runId}`,
taskIdentifier: args.taskIdentifier,
idempotencyKey: args.idempotencyKey ?? null,
idempotencyKeyExpiresAt: args.idempotencyKeyExpiresAt ?? null,
status: args.status ?? "EXECUTING",
payload: JSON.stringify({ foo: "bar" }),
payloadType: "application/json",
traceId: "1234",
spanId: "1234",
queue: "test",
runtimeEnvironmentId: args.runtimeEnvironmentId,
projectId: args.projectId,
organizationId: args.organizationId,
environmentType: "DEVELOPMENT",
engine: "V2",
},
});
}
async function seedBatch(prisma: PrismaClient, runtimeEnvironmentId: string, suffix: string) {
const batchId = generateRunOpsId();
return prisma.batchTaskRun.create({
data: {
id: batchId,
friendlyId: `batch_${suffix}_${batchId}`,
runtimeEnvironmentId,
},
});
}
describe("BatchTriggerV3 · store-seam routing (cross-DB)", () => {
heteroPostgresTest(
"(A) cached-run reuse resolves via the legacy (PG14) authority; a PG17-only key is invisible",
async ({ prisma14, prisma17 }) => {
const { project, organization, runtimeEnvironment } = await seedOrgProjectEnv(
prisma14,
"batch-cached"
);
const newSide = await seedOrgProjectEnv(prisma17, "batch-cached-new");
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
const key1 = "idem-batch-1";
const key2 = "idem-batch-2";
const freshKey = "idem-batch-fresh";
const run1 = await seedRun(prisma14, {
runtimeEnvironmentId: runtimeEnvironment.id,
projectId: project.id,
organizationId: organization.id,
taskIdentifier: "my-task",
idempotencyKey: key1,
});
const run2 = await seedRun(prisma14, {
runtimeEnvironmentId: runtimeEnvironment.id,
projectId: project.id,
organizationId: organization.id,
taskIdentifier: "my-task",
idempotencyKey: key2,
});
// A row with one of the SAME keys lives only on PG17 (run-ops). The
// legacy-pinned read must NOT see it.
await seedRun(prisma17, {
runtimeEnvironmentId: newSide.runtimeEnvironment.id,
projectId: newSide.project.id,
organizationId: newSide.organization.id,
taskIdentifier: "my-task",
idempotencyKey: key1,
});
// The service's exact cached-run query shape, pinned to PG14.
const cachedRuns = await legacyStore.findRuns(
{
where: {
runtimeEnvironmentId: runtimeEnvironment.id,
taskIdentifier: "my-task",
idempotencyKey: { in: [key1, key2, freshKey] },
},
select: {
friendlyId: true,
idempotencyKey: true,
idempotencyKeyExpiresAt: true,
},
},
prisma14
);
// Exactly the 2 seeded rows; the fresh key matches nothing.
expect(cachedRuns).toHaveLength(2);
const friendlyIds = cachedRuns.map((r) => r.friendlyId).sort();
expect(friendlyIds).toEqual([run1.friendlyId, run2.friendlyId].sort());
// Each friendlyId distinct, exactly one row per seeded key.
expect(new Set(friendlyIds).size).toBe(2);
expect(cachedRuns.filter((r) => r.idempotencyKey === key1)).toHaveLength(1);
expect(cachedRuns.filter((r) => r.idempotencyKey === key2)).toHaveLength(1);
}
);
heteroPostgresTest(
"(B) expired-key clear is routed to the legacy (PG14) authority and does not touch PG17",
async ({ prisma14, prisma17 }) => {
const { project, organization, runtimeEnvironment } = await seedOrgProjectEnv(
prisma14,
"batch-expired"
);
const newSide = await seedOrgProjectEnv(prisma17, "batch-expired-new");
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
const expiredKey = "idem-batch-expired";
const legacyRun = await seedRun(prisma14, {
runtimeEnvironmentId: runtimeEnvironment.id,
projectId: project.id,
organizationId: organization.id,
taskIdentifier: "my-task",
idempotencyKey: expiredKey,
idempotencyKeyExpiresAt: new Date(Date.now() - 60_000),
});
// A PG17 row with the same key, to prove the clear does not reach it.
const newRun = await seedRun(prisma17, {
runtimeEnvironmentId: newSide.runtimeEnvironment.id,
projectId: newSide.project.id,
organizationId: newSide.organization.id,
taskIdentifier: "my-task",
idempotencyKey: expiredKey,
});
// The service's exact expired-key clear shape, pinned to PG14.
await legacyStore.clearIdempotencyKey({ byFriendlyIds: [legacyRun.friendlyId] }, prisma14);
const cleared = await prisma14.taskRun.findFirst({ where: { id: legacyRun.id } });
expect(cleared?.idempotencyKey).toBeNull();
// The PG17 row is untouched.
const untouched = await prisma17.taskRun.findFirst({ where: { id: newRun.id } });
expect(untouched?.idempotencyKey).toBe(expiredKey);
}
);
heteroPostgresTest(
"(C) membership write lands on the run-ops (PG17) store; duplicate raises a unique-constraint error",
async ({ prisma17 }) => {
const { project, organization, runtimeEnvironment } = await seedOrgProjectEnv(
prisma17,
"batch-membership"
);
const runOpsStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
const batch = await seedBatch(prisma17, runtimeEnvironment.id, "membership");
const run = await seedRun(prisma17, {
runtimeEnvironmentId: runtimeEnvironment.id,
projectId: project.id,
organizationId: organization.id,
taskIdentifier: "my-task",
});
await runOpsStore.createBatchTaskRunItem({
batchTaskRunId: batch.id,
taskRunId: run.id,
status: "PENDING",
});
const item = await prisma17.batchTaskRunItem.findFirst({
where: { batchTaskRunId: batch.id, taskRunId: run.id },
});
expect(item).not.toBeNull();
expect(item?.status).toBe("PENDING");
// Re-calling with the SAME pair raises a unique-constraint error at the
// store layer (the service's try/catch is what swallows it).
let caught: unknown;
try {
await runOpsStore.createBatchTaskRunItem({
batchTaskRunId: batch.id,
taskRunId: run.id,
status: "PENDING",
});
} catch (error) {
caught = error;
}
expect(caught).toBeDefined();
expect(isUniqueConstraintError(caught, ["batchTaskRunId", "taskRunId"])).toBe(true);
// Still exactly one row.
const count = await prisma17.batchTaskRunItem.count({
where: { batchTaskRunId: batch.id, taskRunId: run.id },
});
expect(count).toBe(1);
}
);
});
@@ -0,0 +1,428 @@
import { describe, expect, it } from "vitest";
import {
ABSOLUTE_ALERT_BASE_CENTS,
clearedAlertsPayload,
emailsMatchSaved,
getAlertPreviewLimitCents,
getBillingLimitMode,
getConfiguredBillingLimitCents,
getUsageBarBillingLimitDollars,
hadSavedAlertsToClearOnLimitChange,
hasConfiguredAlerts,
hasLegacySpikeAlertLevels,
isLegacyDollarAmountField,
normalizeBillingAlertsFromApi,
percentageAlertLevelsToUiThresholds,
previewDollarAmountForPercent,
shouldClearAlertsOnLimitChange,
shouldResetAlertsOnLimitChange,
storedAlertsToThresholds,
thresholdsMatchSaved,
thresholdsToAlertPayload,
thresholdValuesAreUnique,
} from "~/components/billing/billingAlertsFormat";
const legacyDefaultLevels = [0.75, 0.9, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0];
const hundredDollarLimitContext = {
planLimitCents: 10_000,
effectiveLimitCents: 10_000,
};
const fiveDollarLimitContext = {
planLimitCents: 500,
effectiveLimitCents: 500,
};
describe("billingAlertsFormat", () => {
it("uses percentage thresholds saved in the new format", () => {
expect(
storedAlertsToThresholds(
{ amount: 50, emails: [], alertLevels: [0.75, 0.9, 1.0] },
"plan",
5000,
5000
)
).toEqual([75, 90, 100]);
});
it("filters legacy spike multipliers above 100%", () => {
expect(
storedAlertsToThresholds(
{ amount: 50, emails: [], alertLevels: legacyDefaultLevels },
"plan",
5000,
5000
)
).toEqual([75, 90, 100]);
expect(
storedAlertsToThresholds(
{ amount: 50, emails: [], alertLevels: legacyDefaultLevels },
"none",
5000,
5000
)
).toEqual([]);
});
it("reads legacy alerts saved against plan included usage", () => {
expect(
storedAlertsToThresholds(
{ amount: 100, emails: [], alertLevels: [0.1, 0.5, 0.8, 2.0] },
"plan",
25_000,
10_000
)
).toEqual([10, 50, 80]);
expect(
storedAlertsToThresholds(
{ amount: 100, emails: [], alertLevels: [10, 50, 80, 200] },
"plan",
25_000,
10_000
)
).toEqual([10, 50, 80]);
expect(
getAlertPreviewLimitCents({ amount: 100, emails: [], alertLevels: [] }, 25_000, 10_000)
).toBe(10_000);
});
it("normalizes legacy API alerts with dollar amount field and whole percents", () => {
expect(
normalizeBillingAlertsFromApi(
{
amount: 10_000,
emails: ["a@example.com"],
alertLevels: [10, 50, 80, 200],
},
hundredDollarLimitContext
)
).toEqual({
amount: 100,
emails: ["a@example.com"],
alertLevels: [10, 50, 80, 200],
});
expect(percentageAlertLevelsToUiThresholds([10, 50, 80, 200])).toEqual([10, 50, 80]);
});
it("normalizes platform API alerts stored in cents", () => {
expect(
normalizeBillingAlertsFromApi(
{
amount: 10_000,
emails: [],
alertLevels: [0.75, 0.9],
},
hundredDollarLimitContext
)
).toEqual({
amount: 100,
emails: [],
alertLevels: [0.75, 0.9],
});
});
it("normalizes cents-based alerts for billing limits under $10", () => {
const normalized = normalizeBillingAlertsFromApi(
{
amount: 500,
emails: [],
alertLevels: [0.75, 0.9],
},
fiveDollarLimitContext
);
expect(normalized).toEqual({
amount: 5,
emails: [],
alertLevels: [0.75, 0.9],
});
expect(storedAlertsToThresholds(normalized, "plan", 500, 500)).toEqual([75, 90]);
expect(getAlertPreviewLimitCents(normalized, 500, 500)).toBe(500);
expect(previewDollarAmountForPercent(75, getAlertPreviewLimitCents(normalized, 500, 500))).toBe(
3.75
);
});
it("normalizes cents-based alerts with whole-number levels when amount matches limit", () => {
const normalized = normalizeBillingAlertsFromApi(
{
amount: 500,
emails: [],
alertLevels: [50, 75],
},
{ planLimitCents: 500, effectiveLimitCents: 500 }
);
expect(normalized).toEqual({
amount: 5,
emails: [],
alertLevels: [50, 75],
});
expect(storedAlertsToThresholds(normalized, "custom", 500, 500)).toEqual([50, 75]);
});
it("still normalizes legacy dollar alerts when amount matches plan dollars", () => {
expect(
normalizeBillingAlertsFromApi(
{
amount: 100,
emails: [],
alertLevels: [10, 50, 80],
},
{ planLimitCents: 10_000, effectiveLimitCents: 10_000 }
)
).toEqual({
amount: 100,
emails: [],
alertLevels: [10, 50, 80],
});
expect(
isLegacyDollarAmountField(500, [50, 75], {
planLimitCents: 50_000,
effectiveLimitCents: 50_000,
})
).toBe(true);
expect(
isLegacyDollarAmountField(250, [10, 50, 80], {
planLimitCents: 25_000,
effectiveLimitCents: 25_000,
})
).toBe(true);
expect(isLegacyDollarAmountField(250, [10, 50, 80], hundredDollarLimitContext)).toBe(false);
});
it("returns no default thresholds when alerts are empty", () => {
expect(
storedAlertsToThresholds({ amount: 50, emails: [], alertLevels: [] }, "plan", 5000, 5000)
).toEqual([]);
expect(
storedAlertsToThresholds({ amount: 1, emails: [], alertLevels: [] }, "none", 5000, 5000)
).toEqual([]);
});
it("uses dollar thresholds for none mode with absolute base", () => {
expect(
storedAlertsToThresholds(
{ amount: 1, emails: [], alertLevels: [100, 250] },
"none",
5000,
5000
)
).toEqual([100, 250]);
});
it("loads absolute dollar alerts after save with unlimited billing limit", () => {
const normalized = normalizeBillingAlertsFromApi(
{
amount: ABSOLUTE_ALERT_BASE_CENTS,
emails: ["a@example.com"],
alertLevels: [100],
},
{ planLimitCents: 5000, effectiveLimitCents: 5000 }
);
expect(normalized).toEqual({
amount: 1,
emails: ["a@example.com"],
alertLevels: [100],
});
expect(storedAlertsToThresholds(normalized, "none", 5000, 5000)).toEqual([100]);
});
it("converts percentage UI values to API payload", () => {
expect(thresholdsToAlertPayload([75, 90], "plan", 5000)).toEqual({
amount: 5000,
alertLevels: [0.75, 0.9],
});
});
it("converts absolute UI values to API payload", () => {
expect(thresholdsToAlertPayload([100, 250], "none", 5000)).toEqual({
amount: 100,
alertLevels: [100, 250],
});
});
it("previews dollar amount from percentage and limit", () => {
expect(previewDollarAmountForPercent(75, 5000)).toBe(37.5);
expect(previewDollarAmountForPercent(10, 10_000)).toBe(10);
});
it("defaults unconfigured billing limit to none mode", () => {
expect(getBillingLimitMode({ isConfigured: false, gracePeriodMs: 86_400_000 })).toBe("none");
});
it("detects configured alerts for the current billing limit mode", () => {
const billingLimit = {
isConfigured: true,
mode: "plan" as const,
effectiveAmountCents: 5000,
gracePeriodMs: 86_400_000,
};
expect(
hasConfiguredAlerts({ amount: 50, emails: [], alertLevels: [0.75, 0.9] }, billingLimit, 5000)
).toBe(true);
expect(
hasConfiguredAlerts({ amount: 50, emails: [], alertLevels: [] }, billingLimit, 5000)
).toBe(false);
});
it("clears percentage alerts when switching from plan or custom to none", () => {
expect(shouldClearAlertsOnLimitChange("plan", "none")).toBe(true);
expect(shouldClearAlertsOnLimitChange("custom", "none")).toBe(true);
expect(shouldClearAlertsOnLimitChange("none", "none")).toBe(false);
expect(shouldClearAlertsOnLimitChange("plan", "custom")).toBe(false);
});
it("resets alerts when switching between percentage and dollar alert modes", () => {
expect(shouldResetAlertsOnLimitChange("none", "plan")).toBe(true);
expect(shouldResetAlertsOnLimitChange("none", "custom")).toBe(true);
expect(shouldResetAlertsOnLimitChange("plan", "none")).toBe(true);
expect(shouldResetAlertsOnLimitChange("plan", "custom")).toBe(false);
});
it("builds a cleared alerts payload for none mode", () => {
expect(clearedAlertsPayload(["a@example.com"])).toEqual({
amount: 100,
alertLevels: [],
emails: ["a@example.com"],
});
});
it("detects legacy spike alert levels above 100%", () => {
expect(
hasLegacySpikeAlertLevels(
{ amount: 50, emails: [], alertLevels: [0.75, 0.9, 1.0, 2.0] },
"plan",
5000,
5000
)
).toBe(true);
expect(
hasLegacySpikeAlertLevels(
{ amount: 100, emails: [], alertLevels: [0.1, 0.5, 0.8, 2.0] },
"plan",
25_000,
10_000
)
).toBe(true);
expect(
hasLegacySpikeAlertLevels(
{ amount: 50, emails: [], alertLevels: [0.75, 0.9] },
"plan",
5000,
5000
)
).toBe(false);
expect(
hasLegacySpikeAlertLevels(
{ amount: 1, emails: [], alertLevels: [100, 250] },
"none",
5000,
5000
)
).toBe(false);
});
it("detects when saved alerts should be cleared on a limit format change", () => {
const billingLimit = {
isConfigured: true,
mode: "plan" as const,
effectiveAmountCents: 5000,
gracePeriodMs: 86_400_000,
};
expect(
hadSavedAlertsToClearOnLimitChange(
{ amount: 50, emails: [], alertLevels: [0.75, 0.9] },
billingLimit,
5000
)
).toBe(true);
expect(
hadSavedAlertsToClearOnLimitChange(
{ amount: 50, emails: ["a@example.com"], alertLevels: [] },
billingLimit,
5000
)
).toBe(false);
});
it("compares threshold and email values for dirty form state", () => {
expect(thresholdsMatchSaved([90, 75], [75, 90])).toBe(true);
expect(thresholdsMatchSaved([75], [75, 90])).toBe(false);
expect(emailsMatchSaved(["a@example.com", ""], ["a@example.com"])).toBe(true);
expect(emailsMatchSaved(["b@example.com"], ["a@example.com"])).toBe(false);
});
it("detects duplicate alert thresholds", () => {
expect(thresholdValuesAreUnique([75, 90, 100])).toBe(true);
expect(thresholdValuesAreUnique([75, 75])).toBe(false);
expect(thresholdValuesAreUnique([100, 250, 100])).toBe(false);
});
it("returns configured billing limit cents for plan and custom modes", () => {
expect(
getConfiguredBillingLimitCents(
{
isConfigured: true,
mode: "custom",
amountCents: 25_000,
cancelInProgressRuns: false,
limitState: { status: "ok" },
effectiveAmountCents: 25_000,
gracePeriodMs: 86_400_000,
},
5_000
)
).toBe(25_000);
expect(
getConfiguredBillingLimitCents(
{
isConfigured: true,
mode: "none",
cancelInProgressRuns: false,
limitState: { status: "ok" },
effectiveAmountCents: null,
gracePeriodMs: 86_400_000,
},
5_000
)
).toBeUndefined();
});
it("maps usage bar billing limit dollars and hides when same as plan limit", () => {
const customLimit = {
isConfigured: true as const,
mode: "custom" as const,
amountCents: 25_000,
cancelInProgressRuns: false,
limitState: { status: "ok" as const },
effectiveAmountCents: 25_000,
gracePeriodMs: 86_400_000,
};
expect(getUsageBarBillingLimitDollars(customLimit, 5_000)).toBe(250);
expect(getUsageBarBillingLimitDollars(customLimit, 25_000)).toBeUndefined();
expect(getUsageBarBillingLimitDollars(undefined, 5_000)).toBeUndefined();
});
});
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import {
BillingLimitResultSchema,
BillingLimitsPendingResolvesResultSchema,
EntitlementResultSchema,
ResolveBillingLimitRequestSchema,
} from "~/services/billingLimit.schemas";
describe("billingLimit.schemas", () => {
it("parses unconfigured billing limit", () => {
const result = BillingLimitResultSchema.parse({
isConfigured: false,
gracePeriodMs: 86_400_000,
});
expect(result.isConfigured).toBe(false);
expect(result.gracePeriodMs).toBe(86_400_000);
});
it("parses configured mode none with limitState ok — not the same as unconfigured", () => {
const result = BillingLimitResultSchema.parse({
isConfigured: true,
mode: "none",
cancelInProgressRuns: false,
effectiveAmountCents: null,
gracePeriodMs: 86_400_000,
limitState: { status: "ok" },
});
expect(result.isConfigured).toBe(true);
if (result.isConfigured) {
expect(result.mode).toBe("none");
expect(result.limitState.status).toBe("ok");
expect(result.effectiveAmountCents).toBeNull();
}
// UI must use !isConfigured for the no-limit org banner — not mode === "none".
const unconfigured = BillingLimitResultSchema.parse({
isConfigured: false,
gracePeriodMs: 86_400_000,
});
expect(unconfigured.isConfigured).toBe(false);
expect(result.isConfigured).not.toBe(unconfigured.isConfigured);
});
it("parses configured billing limit in grace", () => {
const result = BillingLimitResultSchema.parse({
isConfigured: true,
mode: "custom",
amountCents: 50_000,
cancelInProgressRuns: false,
effectiveAmountCents: 50_000,
gracePeriodMs: 86_400_000,
limitState: {
status: "grace",
hitAt: "2026-06-14T12:00:00.000Z",
graceEndsAt: "2026-06-15T12:00:00.000Z",
},
});
expect(result.isConfigured).toBe(true);
if (result.isConfigured) {
expect(result.mode).toBe("custom");
expect(result.limitState.status).toBe("grace");
}
});
it("parses entitlement with billing_limit reason", () => {
const result = EntitlementResultSchema.parse({
hasAccess: false,
reason: "billing_limit",
});
expect(result.hasAccess).toBe(false);
expect(result.reason).toBe("billing_limit");
});
it("parses entitlement with free_tier_exceeded reason", () => {
const result = EntitlementResultSchema.parse({
hasAccess: false,
reason: "free_tier_exceeded",
balance: 0,
usage: 100,
overage: 10,
});
expect(result.hasAccess).toBe(false);
expect(result.reason).toBe("free_tier_exceeded");
});
it("parses entitlement with grace limit state", () => {
const result = EntitlementResultSchema.parse({
hasAccess: true,
limitState: "grace",
});
expect(result.hasAccess).toBe(true);
expect(result.limitState).toBe("grace");
});
it("parses resolve payload", () => {
const result = ResolveBillingLimitRequestSchema.parse({
action: "increase",
newAmountCents: 150_000,
resumeMode: "queue",
});
expect(result.action).toBe("increase");
expect(result.newAmountCents).toBe(150_000);
});
it("parses pending billing limit resolves from billing platform", () => {
const result = BillingLimitsPendingResolvesResultSchema.parse({
orgs: [
{
organizationId: "org_123",
resumeMode: "new_only",
resolvedAt: "2026-06-17T12:00:00.000Z",
},
],
});
expect(result.orgs).toHaveLength(1);
expect(result.orgs[0]?.resumeMode).toBe("new_only");
});
});
@@ -0,0 +1,459 @@
import { describe, expect, vi } from "vitest";
import { setTimeout } from "node:timers/promises";
import { postgresTest, replicationContainerTest } from "@internal/testcontainers";
import { BulkActionStatus, BulkActionType } from "@trigger.dev/database";
import {
BILLING_LIMIT_IN_PROGRESS_CANCEL_SOURCE,
BILLING_LIMIT_RESOLVE_CANCEL_SOURCE,
BillingLimitBulkCancelIncompleteError,
BillingLimitBulkCancelService,
} from "~/v3/services/billingLimit/BillingLimitBulkCancelService.server";
import { countInProgressRunsForBillableEnvironment } from "~/v3/services/billingLimit/billingLimitQueuedRuns.server";
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import {
createRuntimeEnvironment,
createTestOrgProjectWithMember,
uniqueId,
} from "./fixtures/environmentVariablesFixtures";
import { setupClickhouseReplication } from "./utils/replicationUtils";
vi.setConfig({ testTimeout: 60_000 });
describe("BillingLimitBulkCancelService.cancelQueuedRuns", () => {
postgresTest(
"processes bulk cancel inline when waitForCompletion is true",
async ({ prisma }) => {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const productionEnv = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
slug: uniqueId("prod"),
});
const dedupeKey = "billing-limit-resolve:org:2026-06-16T12:00:00.000Z";
const enqueuedBulkActionIds: string[] = [];
const processedBulkActionIds: string[] = [];
const result = await BillingLimitBulkCancelService.cancelQueuedRuns(
organization.id,
{ dedupeKey, waitForCompletion: true },
{
prismaClient: prisma,
createRunsRepository: async () =>
({
countRuns: async () => 2,
}) as never,
enqueueProcessBulkAction: async (bulkActionId) => {
enqueuedBulkActionIds.push(bulkActionId);
},
processBulkActionToCompletion: async (bulkActionId) => {
processedBulkActionIds.push(bulkActionId);
return { completed: true };
},
}
);
expect(result.bulkActionIds).toHaveLength(1);
expect(enqueuedBulkActionIds).toEqual([]);
expect(processedBulkActionIds).toHaveLength(1);
const group = await prisma.bulkActionGroup.findFirst({
where: {
environmentId: productionEnv.id,
type: BulkActionType.CANCEL,
},
});
expect(group?.name).toBe("Billing limit resolve — cancel queued runs");
expect(group?.dedupeKey).toBe(dedupeKey);
expect(group?.params).toMatchObject({
source: BILLING_LIMIT_RESOLVE_CANCEL_SOURCE,
dedupeKey,
finalizeRun: true,
});
expect(processedBulkActionIds).toEqual([group?.id]);
}
);
postgresTest(
"reuses existing bulk cancel and processes inline when waitForCompletion is true",
async ({ prisma }) => {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const productionEnv = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
slug: uniqueId("prod"),
});
const dedupeKey = "billing-limit-resolve:org:2026-06-16T12:00:00.000Z";
await prisma.bulkActionGroup.create({
data: {
id: "bulk_existing_resolve",
friendlyId: "bulk_existing_resolve",
projectId: project.id,
environmentId: productionEnv.id,
name: "Existing resolve cancel",
type: BulkActionType.CANCEL,
dedupeKey,
params: {
statuses: ["PENDING"],
finalizeRun: true,
source: BILLING_LIMIT_RESOLVE_CANCEL_SOURCE,
dedupeKey,
},
queryName: "bulk_action_v1",
totalCount: 1,
},
});
const enqueuedBulkActionIds: string[] = [];
const processedBulkActionIds: string[] = [];
const result = await BillingLimitBulkCancelService.cancelQueuedRuns(
organization.id,
{ dedupeKey, waitForCompletion: true },
{
prismaClient: prisma,
// Stubbed so the dedupe path doesn't build the default ClickHouse-backed
// repository, which queries the global $replica and hangs in the
// unit-test CI job (no reachable database/ClickHouse there).
createRunsRepository: async () =>
({
countRuns: async () => 0,
}) as never,
enqueueProcessBulkAction: async (bulkActionId) => {
enqueuedBulkActionIds.push(bulkActionId);
},
processBulkActionToCompletion: async (bulkActionId) => {
processedBulkActionIds.push(bulkActionId);
return { completed: true };
},
}
);
expect(result.bulkActionIds).toEqual(["bulk_existing_resolve"]);
expect(enqueuedBulkActionIds).toEqual([]);
expect(processedBulkActionIds).toEqual(["bulk_existing_resolve"]);
}
);
postgresTest(
"skips enqueue and processing for completed deduped bulk actions",
async ({ prisma }) => {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const productionEnv = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
slug: uniqueId("prod"),
});
const dedupeKey = "billing-limit-resolve:org:2026-06-16T12:00:00.000Z";
await prisma.bulkActionGroup.create({
data: {
id: "bulk_completed_resolve",
friendlyId: "bulk_completed_resolve",
projectId: project.id,
environmentId: productionEnv.id,
name: "Completed resolve cancel",
type: BulkActionType.CANCEL,
status: BulkActionStatus.COMPLETED,
dedupeKey,
params: {
statuses: ["PENDING"],
finalizeRun: true,
source: BILLING_LIMIT_RESOLVE_CANCEL_SOURCE,
dedupeKey,
},
queryName: "bulk_action_v1",
totalCount: 1,
},
});
const enqueuedBulkActionIds: string[] = [];
const processedBulkActionIds: string[] = [];
const result = await BillingLimitBulkCancelService.cancelQueuedRuns(
organization.id,
{ dedupeKey, waitForCompletion: true },
{
prismaClient: prisma,
createRunsRepository: async () =>
({
countRuns: async () => 0,
}) as never,
enqueueProcessBulkAction: async (bulkActionId) => {
enqueuedBulkActionIds.push(bulkActionId);
},
processBulkActionToCompletion: async (bulkActionId) => {
processedBulkActionIds.push(bulkActionId);
return { completed: true };
},
}
);
expect(result.bulkActionIds).toEqual(["bulk_completed_resolve"]);
expect(enqueuedBulkActionIds).toEqual([]);
expect(processedBulkActionIds).toEqual([]);
}
);
postgresTest(
"creates a fresh bulk action when the deduped action was aborted",
async ({ prisma }) => {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const productionEnv = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
slug: uniqueId("prod"),
});
const dedupeKey = "billing-limit-resolve:org:2026-06-16T12:00:00.000Z";
await prisma.bulkActionGroup.create({
data: {
id: "bulk_aborted_resolve",
friendlyId: "bulk_aborted_resolve",
projectId: project.id,
environmentId: productionEnv.id,
name: "Aborted resolve cancel",
type: BulkActionType.CANCEL,
status: BulkActionStatus.ABORTED,
dedupeKey,
params: {
statuses: ["PENDING"],
finalizeRun: true,
source: BILLING_LIMIT_RESOLVE_CANCEL_SOURCE,
dedupeKey,
},
queryName: "bulk_action_v1",
totalCount: 1,
},
});
const enqueuedBulkActionIds: string[] = [];
const result = await BillingLimitBulkCancelService.cancelQueuedRuns(
organization.id,
{ dedupeKey },
{
prismaClient: prisma,
createRunsRepository: async () =>
({
countRuns: async () => 2,
}) as never,
enqueueProcessBulkAction: async (bulkActionId) => {
enqueuedBulkActionIds.push(bulkActionId);
},
}
);
expect(result.bulkActionIds).toHaveLength(1);
expect(result.bulkActionIds[0]).not.toBe("bulk_aborted_resolve");
expect(enqueuedBulkActionIds).toHaveLength(1);
const groups = await prisma.bulkActionGroup.findMany({
where: { environmentId: productionEnv.id, type: BulkActionType.CANCEL },
orderBy: { createdAt: "asc" },
});
expect(groups).toHaveLength(2);
expect(groups[1]?.status).toBe(BulkActionStatus.PENDING);
expect(groups[1]?.dedupeKey).toBe(dedupeKey);
}
);
postgresTest("throws when inline bulk cancel exceeds the time budget", async ({ prisma }) => {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
slug: uniqueId("prod"),
});
const dedupeKey = "billing-limit-resolve:org:2026-06-16T12:00:00.000Z";
await expect(
BillingLimitBulkCancelService.cancelQueuedRuns(
organization.id,
{ dedupeKey, waitForCompletion: true, bulkCancelDeadline: Date.now() },
{
prismaClient: prisma,
createRunsRepository: async () =>
({
countRuns: async () => 2,
}) as never,
processBulkActionToCompletion: async () => ({ completed: false }),
}
)
).rejects.toBeInstanceOf(BillingLimitBulkCancelIncompleteError);
});
});
describe("BillingLimitBulkCancelService.cancelInProgressRuns", () => {
postgresTest("dedupes bulk cancel by hitAt per environment", async ({ prisma }) => {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const productionEnv = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
slug: uniqueId("prod"),
});
const hitAt = "2026-06-16T12:00:00.000Z";
await prisma.bulkActionGroup.create({
data: {
id: "bulk_existing",
friendlyId: "bulk_existing",
projectId: project.id,
environmentId: productionEnv.id,
name: "Existing in-progress cancel",
type: BulkActionType.CANCEL,
dedupeKey: hitAt,
params: {
statuses: ["EXECUTING"],
finalizeRun: true,
source: BILLING_LIMIT_IN_PROGRESS_CANCEL_SOURCE,
dedupeKey: hitAt,
},
queryName: "bulk_action_v1",
totalCount: 1,
},
});
const enqueuedBulkActionIds: string[] = [];
const result = await BillingLimitBulkCancelService.cancelInProgressRuns(
organization.id,
{ hitAt },
{
prismaClient: prisma,
// Stubbed so the dedupe path doesn't build the default ClickHouse-backed
// repository, which queries the global $replica and hangs in the
// unit-test CI job (no reachable database/ClickHouse there).
createRunsRepository: async () =>
({
countRuns: async () => 0,
}) as never,
enqueueProcessBulkAction: async (bulkActionId) => {
enqueuedBulkActionIds.push(bulkActionId);
},
}
);
expect(result.bulkActionIds).toEqual(["bulk_existing"]);
expect(enqueuedBulkActionIds).toEqual(["bulk_existing"]);
const groups = await prisma.bulkActionGroup.findMany({
where: { environmentId: productionEnv.id, type: BulkActionType.CANCEL },
});
expect(groups).toHaveLength(1);
});
replicationContainerTest(
"creates bulk cancel for in-progress runs in billable environments",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const organization = await prisma.organization.create({
data: { title: "billing-limit-in-progress-runs", slug: "billing-limit-in-progress-runs" },
});
const project = await prisma.project.create({
data: {
name: "billing-limit-in-progress-runs",
slug: "billing-limit-in-progress-runs",
organizationId: organization.id,
externalRef: "billing-limit-in-progress-runs",
},
});
const productionEnv = await prisma.runtimeEnvironment.create({
data: {
slug: "prod",
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: "prod",
pkApiKey: "prod",
shortcode: "prod",
},
});
await prisma.taskRun.create({
data: {
friendlyId: "run_executing_prod",
taskIdentifier: "running-task",
status: "EXECUTING",
payload: JSON.stringify({}),
traceId: "trace",
spanId: "span",
queue: "main",
runtimeEnvironmentId: productionEnv.id,
projectId: project.id,
organizationId: organization.id,
environmentType: "PRODUCTION",
engine: "V2",
},
});
await setTimeout(1000);
const runsRepository = new RunsRepository({ prisma, clickhouse });
const count = await countInProgressRunsForBillableEnvironment(
runsRepository,
organization.id,
{ id: productionEnv.id, projectId: project.id }
);
expect(count).toBe(1);
const hitAt = "2026-06-16T12:00:00.000Z";
const enqueuedBulkActionIds: string[] = [];
const result = await BillingLimitBulkCancelService.cancelInProgressRuns(
organization.id,
{ hitAt },
{
prismaClient: prisma,
createRunsRepository: async () => runsRepository,
enqueueProcessBulkAction: async (bulkActionId) => {
enqueuedBulkActionIds.push(bulkActionId);
},
}
);
expect(result.bulkActionIds).toHaveLength(1);
expect(enqueuedBulkActionIds).toHaveLength(1);
const group = await prisma.bulkActionGroup.findFirst({
where: {
environmentId: productionEnv.id,
type: BulkActionType.CANCEL,
},
});
expect(group?.name).toBe("Billing limit hit — cancel in-progress runs");
expect(group?.dedupeKey).toBe(hitAt);
expect(group?.params).toMatchObject({
source: BILLING_LIMIT_IN_PROGRESS_CANCEL_SOURCE,
dedupeKey: hitAt,
finalizeRun: true,
});
}
);
});
@@ -0,0 +1,107 @@
import type { PrismaClient } from "@trigger.dev/database";
import { EnvironmentPauseSource } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import { postgresTest } from "@internal/testcontainers";
import { convergeBillingLimitEnvironmentsForOrg } from "~/v3/services/billingLimit/billingLimitConvergeEnvironments.server";
import {
createRuntimeEnvironment,
createTestOrgProjectWithMember,
uniqueId,
} from "./fixtures/environmentVariablesFixtures";
vi.setConfig({ testTimeout: 60_000 });
async function createBillingPausedProductionEnv(prisma: PrismaClient) {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const environment = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
slug: uniqueId("prod"),
});
await prisma.runtimeEnvironment.update({
where: { id: environment.id },
data: {
paused: true,
pauseSource: EnvironmentPauseSource.BILLING_LIMIT,
},
});
return { organization, environment };
}
describe("convergeBillingLimitEnvironmentsForOrg", () => {
postgresTest("unpauses billable environments paused by billing limit", async ({ prisma }) => {
const { organization, environment } = await createBillingPausedProductionEnv(prisma);
const result = await convergeBillingLimitEnvironmentsForOrg(organization.id, "ok", {
prismaClient: prisma,
updateConcurrency: async () => undefined,
});
expect(result).toEqual({ paused: 0, unpaused: 1 });
const envAfter = await prisma.runtimeEnvironment.findUniqueOrThrow({
where: { id: environment.id },
});
expect(envAfter.paused).toBe(false);
expect(envAfter.pauseSource).toBeNull();
});
postgresTest("rolls back pause when concurrency update fails", async ({ prisma }) => {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const environment = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
slug: uniqueId("prod"),
});
await expect(
convergeBillingLimitEnvironmentsForOrg(organization.id, "grace", {
prismaClient: prisma,
updateConcurrency: async () => {
throw new Error("run queue unavailable");
},
})
).rejects.toThrow("run queue unavailable");
const envAfter = await prisma.runtimeEnvironment.findUniqueOrThrow({
where: { id: environment.id },
});
expect(envAfter.paused).toBe(false);
expect(envAfter.pauseSource).toBeNull();
});
postgresTest("does not unpause environments paused for other reasons", async ({ prisma }) => {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const environment = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
slug: uniqueId("prod"),
});
await prisma.runtimeEnvironment.update({
where: { id: environment.id },
data: {
paused: true,
pauseSource: null,
},
});
const result = await convergeBillingLimitEnvironmentsForOrg(organization.id, "ok", {
prismaClient: prisma,
updateConcurrency: async () => undefined,
});
expect(result).toEqual({ paused: 0, unpaused: 0 });
const envAfter = await prisma.runtimeEnvironment.findUniqueOrThrow({
where: { id: environment.id },
});
expect(envAfter.paused).toBe(true);
expect(envAfter.pauseSource).toBeNull();
});
});
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { reconcileBillingLimitTarget } from "~/v3/services/billingLimit/billingLimitReconcileTarget.server";
describe("reconcileBillingLimitTarget", () => {
it("busts billing limit caches for rejected targets before enqueueing converge", async () => {
const bustedOrgIds: string[] = [];
const enqueued: Array<{ organizationId: string; targetState: string }> = [];
await reconcileBillingLimitTarget(
{ organizationId: "org_123", targetState: "rejected" },
{
bustCaches: (organizationId) => {
bustedOrgIds.push(organizationId);
},
enqueueConverge: async (organizationId, targetState) => {
enqueued.push({ organizationId, targetState });
},
}
);
expect(bustedOrgIds).toEqual(["org_123"]);
expect(enqueued).toEqual([{ organizationId: "org_123", targetState: "rejected" }]);
});
it("busts billing limit caches for ok targets before enqueueing converge", async () => {
const bustedOrgIds: string[] = [];
const enqueued: Array<{ organizationId: string; targetState: string }> = [];
await reconcileBillingLimitTarget(
{ organizationId: "org_123", targetState: "ok" },
{
bustCaches: (organizationId) => {
bustedOrgIds.push(organizationId);
},
enqueueConverge: async (organizationId, targetState) => {
enqueued.push({ organizationId, targetState });
},
}
);
expect(bustedOrgIds).toEqual(["org_123"]);
expect(enqueued).toEqual([{ organizationId: "org_123", targetState: "ok" }]);
});
it("does not bust caches for grace targets", async () => {
const bustedOrgIds: string[] = [];
await reconcileBillingLimitTarget(
{ organizationId: "org_123", targetState: "grace" },
{
bustCaches: (organizationId) => {
bustedOrgIds.push(organizationId);
},
enqueueConverge: async () => undefined,
}
);
expect(bustedOrgIds).toEqual([]);
});
});
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { buildBillingLimitResolveDedupeKey } from "~/v3/services/billingLimit/billingLimitConstants";
import { classifyPendingBillingLimitResolveConvergeFailure } from "~/v3/services/billingLimit/billingLimitPendingResolveFailure.server";
import { runPendingBillingLimitResolves } from "~/v3/services/billingLimit/billingLimitPendingResolveCoordinator.server";
import type { PendingBillingLimitResolve } from "~/v3/services/billingLimit/billingLimitPendingResolve.types";
describe("billingLimitConvergeResolve", () => {
it("builds a stable dedupe key from org id and resolvedAt", () => {
expect(buildBillingLimitResolveDedupeKey("org_123", "2026-06-16T12:00:00.000Z")).toBe(
"billing-limit-resolve:org_123:2026-06-16T12:00:00.000Z"
);
});
it("classifies converge failures for ops triage", () => {
expect(classifyPendingBillingLimitResolveConvergeFailure("new_only")).toBe("cancel-failing");
expect(classifyPendingBillingLimitResolveConvergeFailure("queue")).toBe("converge-failing");
});
});
describe("runPendingBillingLimitResolves", () => {
const pending: PendingBillingLimitResolve = {
organizationId: "org_123",
resumeMode: "new_only",
resolvedAt: "2026-06-17T12:00:00.000Z",
};
it("keeps org pending and skips ack when converge throws (cancel-failing path)", async () => {
const completeCalls: string[] = [];
const stillPending = await runPendingBillingLimitResolves([pending], {
converge: async () => {
throw new Error("bulk cancel failed");
},
complete: async (organizationId) => {
completeCalls.push(organizationId);
return { completed: true };
},
});
expect(stillPending).toEqual(new Set(["org_123"]));
expect(completeCalls).toEqual([]);
});
it("keeps org pending when converge succeeds but ack throws (ack-only path)", async () => {
let convergeCalls = 0;
let completeCalls = 0;
const stillPending = await runPendingBillingLimitResolves(
[{ ...pending, resumeMode: "queue" }],
{
converge: async () => {
convergeCalls += 1;
},
complete: async () => {
completeCalls += 1;
throw new Error("resolve-complete unavailable");
},
}
);
expect(stillPending).toEqual(new Set(["org_123"]));
expect(convergeCalls).toBe(1);
expect(completeCalls).toBe(1);
});
it("keeps org pending when ack returns completed: false", async () => {
const stillPending = await runPendingBillingLimitResolves([pending], {
converge: async () => undefined,
complete: async () => ({ completed: false }),
});
expect(stillPending).toEqual(new Set(["org_123"]));
});
it("clears org from pending set when converge and ack both succeed", async () => {
const stillPending = await runPendingBillingLimitResolves([pending], {
converge: async () => undefined,
complete: async () => ({ completed: true }),
});
expect(stillPending).toEqual(new Set());
});
it("retries ack on a later tick after a transient ack failure", async () => {
let ackAttempts = 0;
const deps = {
converge: async () => undefined,
complete: async () => {
ackAttempts += 1;
if (ackAttempts === 1) {
throw new Error("resolve-complete unavailable");
}
return { completed: true };
},
};
expect(await runPendingBillingLimitResolves([pending], deps)).toEqual(new Set(["org_123"]));
expect(await runPendingBillingLimitResolves([pending], deps)).toEqual(new Set());
expect(ackAttempts).toBe(2);
});
});
@@ -0,0 +1,78 @@
import { EnvironmentPauseSource } from "@trigger.dev/database";
import { describe, expect, it } from "vitest";
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
import { getInitialEnvPauseStateForBillingLimit } from "~/v3/services/billingLimit/getInitialEnvPauseStateForBillingLimit.server";
function configuredLimit(status: "grace" | "rejected" | "ok"): BillingLimitResult {
const hitAt = "2026-06-16T12:00:00.000Z";
const graceEndsAt = "2026-06-17T12:00:00.000Z";
return {
isConfigured: true,
mode: "custom",
amountCents: 50_000,
cancelInProgressRuns: false,
effectiveAmountCents: 50_000,
gracePeriodMs: 86_400_000,
limitState: status === "ok" ? { status: "ok" } : { status, hitAt, graceEndsAt },
};
}
describe("getInitialEnvPauseStateForBillingLimit", () => {
it("pauses billable environments when org is in grace", async () => {
const result = await getInitialEnvPauseStateForBillingLimit("org_123", "PRODUCTION", {
getBillingLimit: async () => configuredLimit("grace"),
});
expect(result).toEqual({
paused: true,
pauseSource: EnvironmentPauseSource.BILLING_LIMIT,
});
});
it("pauses billable environments when org is rejected", async () => {
const result = await getInitialEnvPauseStateForBillingLimit("org_123", "STAGING", {
getBillingLimit: async () => configuredLimit("rejected"),
});
expect(result).toEqual({
paused: true,
pauseSource: EnvironmentPauseSource.BILLING_LIMIT,
});
});
it("does not pause development environments", async () => {
const result = await getInitialEnvPauseStateForBillingLimit("org_123", "DEVELOPMENT", {
getBillingLimit: async () => configuredLimit("rejected"),
});
expect(result).toEqual({
paused: false,
pauseSource: null,
});
});
it("does not pause when billing limit lookup fails", async () => {
const result = await getInitialEnvPauseStateForBillingLimit("org_123", "PRODUCTION", {
getBillingLimit: async () => {
throw new Error("billing platform unavailable");
},
});
expect(result).toEqual({
paused: false,
pauseSource: null,
});
});
it("does not pause when billing limit is ok", async () => {
const result = await getInitialEnvPauseStateForBillingLimit("org_123", "PRODUCTION", {
getBillingLimit: async () => configuredLimit("ok"),
});
expect(result).toEqual({
paused: false,
pauseSource: null,
});
});
});
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { BillingLimitHitWebhookBodySchema } from "~/services/billingLimit.schemas";
import {
type BillingLimitHitDeps,
processBillingLimitHit,
} from "~/v3/services/billingLimit/billingLimitHit.server";
describe("billingLimitHit", () => {
it("busts caches, seeds reconcile, and enqueues grace converge", async () => {
const calls: string[] = [];
const deps: BillingLimitHitDeps = {
bustCaches: (organizationId) => {
calls.push(`bust:${organizationId}`);
},
seedReconcileQueue: async (organizationId) => {
calls.push(`seed:${organizationId}`);
},
enqueueConverge: async (organizationId, targetState) => {
calls.push(`converge:${organizationId}:${targetState}`);
},
enqueueCancelInProgressRuns: async () => {
calls.push("cancel");
},
};
await processBillingLimitHit(
{
organizationId: "org_123",
hitAt: "2026-06-16T12:00:00.000Z",
cancelInProgressRuns: false,
},
deps
);
expect(calls).toEqual(["bust:org_123", "seed:org_123", "converge:org_123:grace"]);
});
it("enqueues in-progress cancel when cancelInProgressRuns is true", async () => {
const cancelCalls: Array<{ organizationId: string; hitAt: string }> = [];
const deps: BillingLimitHitDeps = {
bustCaches: () => {},
seedReconcileQueue: async () => {},
enqueueConverge: async () => {},
enqueueCancelInProgressRuns: async (organizationId, hitAt) => {
cancelCalls.push({ organizationId, hitAt });
},
};
await processBillingLimitHit(
{
organizationId: "org_123",
hitAt: "2026-06-16T12:00:00.000Z",
cancelInProgressRuns: true,
},
deps
);
expect(cancelCalls).toEqual([{ organizationId: "org_123", hitAt: "2026-06-16T12:00:00.000Z" }]);
});
});
describe("BillingLimitHitWebhookBodySchema", () => {
it("parses the hit webhook body", () => {
expect(
BillingLimitHitWebhookBodySchema.parse({
hitAt: "2026-06-16T12:00:00.000Z",
cancelInProgressRuns: true,
limitState: "grace",
})
).toEqual({
hitAt: "2026-06-16T12:00:00.000Z",
cancelInProgressRuns: true,
limitState: "grace",
});
});
});
@@ -0,0 +1,26 @@
import { EnvironmentPauseSource } from "@trigger.dev/database";
import { describe, expect, it } from "vitest";
import { getManualPauseEnvironmentResult } from "~/v3/services/billingLimit/manualPauseEnvironmentGuard.server";
describe("manualPauseEnvironmentGuard", () => {
it("blocks resume and no-ops pause for billing-paused environments", () => {
expect(
getManualPauseEnvironmentResult("resumed", EnvironmentPauseSource.BILLING_LIMIT)
).toEqual({
proceed: false,
success: false,
error: expect.stringContaining("billing limit"),
});
expect(getManualPauseEnvironmentResult("paused", EnvironmentPauseSource.BILLING_LIMIT)).toEqual(
{
proceed: false,
success: true,
state: "paused",
}
);
expect(getManualPauseEnvironmentResult("resumed", null)).toEqual({ proceed: true });
expect(getManualPauseEnvironmentResult("paused", null)).toEqual({ proceed: true });
});
});
@@ -0,0 +1,112 @@
import { describe, expect, vi } from "vitest";
import { setTimeout } from "node:timers/promises";
import { replicationContainerTest } from "@internal/testcontainers";
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { countQueuedRunsForBillableEnvironment } from "~/v3/services/billingLimit/billingLimitQueuedRuns.server";
import { setupClickhouseReplication } from "./utils/replicationUtils";
vi.setConfig({ testTimeout: 60_000 });
describe("billingLimitQueuedRuns", () => {
replicationContainerTest(
"counts queued runs via RunsRepository.countRuns (same source as bulk cancel)",
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
const { clickhouse } = await setupClickhouseReplication({
prisma,
databaseUrl: postgresContainer.getConnectionUri(),
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
redisOptions,
});
const organization = await prisma.organization.create({
data: { title: "billing-limit-queued", slug: "billing-limit-queued" },
});
const project = await prisma.project.create({
data: {
name: "billing-limit-queued",
slug: "billing-limit-queued",
organizationId: organization.id,
externalRef: "billing-limit-queued",
},
});
const productionEnv = await prisma.runtimeEnvironment.create({
data: {
slug: "prod",
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: "prod",
pkApiKey: "prod",
shortcode: "prod",
},
});
const developmentEnv = await prisma.runtimeEnvironment.create({
data: {
slug: "dev",
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
apiKey: "dev",
pkApiKey: "dev",
shortcode: "dev",
},
});
await prisma.taskRun.create({
data: {
friendlyId: "run_queued_prod",
taskIdentifier: "queued-task",
status: "PENDING",
payload: JSON.stringify({}),
traceId: "trace",
spanId: "span",
queue: "main",
runtimeEnvironmentId: productionEnv.id,
projectId: project.id,
organizationId: organization.id,
environmentType: "PRODUCTION",
engine: "V2",
},
});
await prisma.taskRun.create({
data: {
friendlyId: "run_queued_dev",
taskIdentifier: "queued-task",
status: "PENDING",
payload: JSON.stringify({}),
traceId: "trace",
spanId: "span",
queue: "main",
runtimeEnvironmentId: developmentEnv.id,
projectId: project.id,
organizationId: organization.id,
environmentType: "DEVELOPMENT",
engine: "V2",
},
});
await setTimeout(1000);
const runsRepository = new RunsRepository({ prisma, clickhouse });
const productionCount = await countQueuedRunsForBillableEnvironment(
runsRepository,
organization.id,
{ id: productionEnv.id, projectId: project.id }
);
const developmentCount = await countQueuedRunsForBillableEnvironment(
runsRepository,
organization.id,
{ id: developmentEnv.id, projectId: project.id }
);
expect(productionCount).toBe(1);
expect(developmentCount).toBe(1);
}
);
});
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import { runBillingLimitReconcileTick } from "~/v3/services/billingLimit/runBillingLimitReconcileTick.server";
import type { PendingBillingLimitResolve } from "~/v3/services/billingLimit/billingLimitPendingResolve.types";
describe("runBillingLimitReconcileTick", () => {
const pending: PendingBillingLimitResolve = {
organizationId: "org_pending",
resumeMode: "queue",
resolvedAt: "2026-06-17T12:00:00.000Z",
};
it("runs pending resolves before collecting orgs and excludes still-pending orgs", async () => {
const order: string[] = [];
await runBillingLimitReconcileTick({
getPendingResolves: async () => ({ orgs: [pending] }),
runPendingResolves: async (pendingResolves) => {
order.push(`pending:${pendingResolves.map((row) => row.organizationId).join(",")}`);
return new Set(["org_pending"]);
},
collectOrgs: async (options) => {
order.push(`collect:${[...(options?.excludeOrgIds ?? [])].join(",")}`);
return {
targets: [{ organizationId: "org_active", targetState: "grace" }],
queuedOrgIds: ["org_active"],
};
},
reconcileTarget: async (target) => {
order.push(`reconcile:${target.organizationId}:${target.targetState}`);
},
clearProcessedQueue: async (queuedOrgIds, processedOrgIds) => {
order.push(`clear:${queuedOrgIds.join(",")}:${processedOrgIds.join(",")}`);
},
bustCaches: () => {},
enqueueConverge: async () => undefined,
});
expect(order).toEqual([
"pending:org_pending",
"collect:org_pending",
"reconcile:org_active:grace",
"clear:org_active:org_active",
]);
});
it("reconciles collected targets when no pending resolves remain", async () => {
const reconciled: Array<{ organizationId: string; targetState: string }> = [];
await runBillingLimitReconcileTick({
getPendingResolves: async () => ({ orgs: [] }),
runPendingResolves: async () => new Set(),
collectOrgs: async () => ({
targets: [
{ organizationId: "org_grace", targetState: "grace" },
{ organizationId: "org_ok", targetState: "ok" },
],
queuedOrgIds: ["org_grace", "org_ok"],
}),
reconcileTarget: async (target, deps) => {
reconciled.push(target);
await deps.enqueueConverge(target.organizationId, target.targetState);
},
clearProcessedQueue: async () => undefined,
bustCaches: () => {},
enqueueConverge: async (organizationId, targetState) => {
reconciled.push({ organizationId, targetState: `enqueued:${targetState}` });
},
});
expect(reconciled).toEqual([
{ organizationId: "org_grace", targetState: "grace" },
{ organizationId: "org_grace", targetState: "enqueued:grace" },
{ organizationId: "org_ok", targetState: "ok" },
{ organizationId: "org_ok", targetState: "enqueued:ok" },
]);
});
it("continues reconciling other targets and only clears successfully processed orgs", async () => {
const reconciled: string[] = [];
let clearedProcessedOrgIds: string[] = [];
await runBillingLimitReconcileTick({
getPendingResolves: async () => ({ orgs: [] }),
runPendingResolves: async () => new Set(),
collectOrgs: async () => ({
targets: [
{ organizationId: "org_fail", targetState: "grace" },
{ organizationId: "org_ok", targetState: "ok" },
],
queuedOrgIds: ["org_fail", "org_ok"],
}),
reconcileTarget: async (target) => {
if (target.organizationId === "org_fail") {
throw new Error("reconcile failed");
}
reconciled.push(target.organizationId);
},
clearProcessedQueue: async (_queuedOrgIds, processedOrgIds) => {
clearedProcessedOrgIds = processedOrgIds;
},
bustCaches: () => {},
enqueueConverge: async () => undefined,
});
expect(reconciled).toEqual(["org_ok"]);
expect(clearedProcessedOrgIds).toEqual(["org_ok"]);
});
});
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
import {
collectOrgIdsNeedingBillingLimitLookup,
resolveConvergeTargetFromBillingLimit,
resolveReconcileTargetFromBillingLimit,
resolveReconcileTargetsForOrgLookups,
} from "~/v3/services/billingLimit/billingLimitReconciliation.server";
const graceLimit: BillingLimitResult = {
isConfigured: true,
mode: "custom",
amountCents: 10_000,
cancelInProgressRuns: false,
limitState: {
status: "grace",
hitAt: "2026-01-01T00:00:00.000Z",
graceEndsAt: "2026-01-02T00:00:00.000Z",
},
effectiveAmountCents: 10_000,
gracePeriodMs: 86_400_000,
};
describe("billingLimitReconciliation", () => {
it("maps grace and rejected limits to converge targets", () => {
expect(resolveConvergeTargetFromBillingLimit(graceLimit)).toBe("grace");
expect(
resolveConvergeTargetFromBillingLimit({
...graceLimit,
limitState: {
status: "rejected",
hitAt: "2026-01-01T00:00:00.000Z",
graceEndsAt: "2026-01-02T00:00:00.000Z",
},
})
).toBe("rejected");
expect(
resolveConvergeTargetFromBillingLimit({
...graceLimit,
limitState: { status: "ok" },
})
).toBe("ok");
expect(resolveConvergeTargetFromBillingLimit(undefined)).toBe("ok");
expect(
resolveConvergeTargetFromBillingLimit({ isConfigured: false, gracePeriodMs: 86_400_000 })
).toBe("ok");
});
it("skips reconcile target when platform lookup returns undefined", () => {
expect(resolveReconcileTargetFromBillingLimit(undefined)).toBeUndefined();
expect(
resolveReconcileTargetFromBillingLimit({ isConfigured: false, gracePeriodMs: 86_400_000 })
).toBe("ok");
expect(resolveReconcileTargetFromBillingLimit(graceLimit)).toBe("grace");
});
it("dedupes stale and queued org ids and skips excluded or already-covered orgs", () => {
expect(
collectOrgIdsNeedingBillingLimitLookup({
staleOrgIds: ["org_a", "org_b", "org_c"],
queuedOrgIds: ["org_b", "org_d", "org_a"],
excludeOrgIds: new Set(["org_c"]),
coveredOrgIds: new Set(["org_d"]),
})
).toEqual(["org_a", "org_b"]);
});
it("resolves reconcile targets with bounded concurrency and isolates lookup failures", async () => {
const lookedUpOrgIds: string[] = [];
const targets = await resolveReconcileTargetsForOrgLookups(
["org_ok", "org_fail", "org_grace"],
{
concurrency: 2,
getBillingLimit: async (organizationId) => {
lookedUpOrgIds.push(organizationId);
if (organizationId === "org_fail") {
throw new Error("platform unavailable");
}
if (organizationId === "org_grace") {
return graceLimit;
}
return { isConfigured: false, gracePeriodMs: 86_400_000 };
},
}
);
expect(targets).toEqual(
new Map([
["org_ok", "ok"],
["org_grace", "grace"],
])
);
expect(new Set(lookedUpOrgIds)).toEqual(new Set(["org_ok", "org_fail", "org_grace"]));
});
});
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { processBillingLimitResolve } from "~/v3/services/billingLimit/billingLimitResolve.server";
import type { PendingBillingLimitResolve } from "~/v3/services/billingLimit/billingLimitPendingResolve.types";
describe("processBillingLimitResolve", () => {
const pending: PendingBillingLimitResolve = {
organizationId: "org_123",
resumeMode: "queue",
resolvedAt: "2026-06-17T12:00:00.000Z",
};
it("busts caches and enqueues resolve work", async () => {
const busted: string[] = [];
const enqueued: PendingBillingLimitResolve[] = [];
await processBillingLimitResolve(pending, {
bustCaches: (organizationId) => {
busted.push(organizationId);
},
enqueueResolve: async (payload) => {
enqueued.push(payload);
},
});
expect(busted).toEqual(["org_123"]);
expect(enqueued).toEqual([pending]);
});
});
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { validateProductionEntitlement } from "~/runEngine/validators/validateProductionEntitlement.server";
const productionEnv = {
type: "PRODUCTION" as const,
organizationId: "org_123",
};
const developmentEnv = {
type: "DEVELOPMENT" as const,
organizationId: "org_123",
};
describe("validateProductionEntitlement", () => {
it("allows development environments without checking entitlement", async () => {
const result = await validateProductionEntitlement(
{ environment: developmentEnv as never },
async () => ({ hasAccess: false, reason: "billing_limit" })
);
expect(result).toEqual({ ok: true });
});
it("rejects production triggers when entitlement has billing_limit denial", async () => {
const result = await validateProductionEntitlement(
{ environment: productionEnv as never },
async () => ({
hasAccess: false,
reason: "billing_limit",
plan: { type: "paid", code: "pro", isPaying: true },
})
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.name).toBe("OutOfEntitlementError");
}
});
it("allows production triggers when entitlement grants access", async () => {
const plan = { type: "paid" as const, code: "pro", isPaying: true };
const result = await validateProductionEntitlement(
{ environment: productionEnv as never },
async () => ({
hasAccess: true,
plan,
})
);
expect(result).toEqual({ ok: true, plan });
});
});
+349
View File
@@ -0,0 +1,349 @@
import { describe, expect, it } from "vitest";
import { parseWithZod } from "@conform-to/zod";
import { billingAlertsSchema } from "~/components/billing/BillingAlertsSection";
import {
billingLimitFormSchema,
getBillingLimitFormLastSubmission,
isBillingLimitFormDirty,
} from "~/components/billing/BillingLimitConfigSection";
import { billingLimitRecoveryFormSchema } from "~/components/billing/BillingLimitRecoveryPanel";
import { isBillingLimitSettingsFormSubmission } from "~/routes/_app.orgs.$organizationSlug.settings.billing-limits/billingLimitsRevalidation";
import { getSuggestedRecoveryLimitDollars } from "~/components/billing/billingLimitFormat";
import {
getAlertsResetRequested,
getEffectiveLimitCentsAfterLimitSave,
getResolveSubmitted,
getSubmittedResumeMode,
isEnforcementActive,
} from "~/routes/_app.orgs.$organizationSlug.settings.billing-limits/billingLimitsRoute.server";
import { loader as billingAlertsRedirectLoader } from "~/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route";
function billingLimitsRequest(search = ""): Request {
return new Request(`http://localhost:3030/orgs/acme/settings/billing-limits${search}`);
}
describe("billingLimitsRoute.server", () => {
describe("isEnforcementActive", () => {
it("returns true in grace", () => {
expect(
isEnforcementActive({
isConfigured: true,
mode: "plan",
cancelInProgressRuns: false,
limitState: { status: "grace", hitAt: "t", graceEndsAt: "t" },
effectiveAmountCents: 5000,
gracePeriodMs: 86_400_000,
})
).toBe(true);
});
it("returns true when rejected", () => {
expect(
isEnforcementActive({
isConfigured: true,
mode: "custom",
amountCents: 2500,
cancelInProgressRuns: false,
limitState: { status: "rejected", hitAt: "t", graceEndsAt: "t" },
effectiveAmountCents: 2500,
gracePeriodMs: 86_400_000,
})
).toBe(true);
});
it("returns false when unconfigured", () => {
expect(
isEnforcementActive({
isConfigured: false,
gracePeriodMs: 86_400_000,
})
).toBe(false);
});
it("returns false when configured and ok", () => {
expect(
isEnforcementActive({
isConfigured: true,
mode: "none",
cancelInProgressRuns: false,
limitState: { status: "ok" },
effectiveAmountCents: null,
gracePeriodMs: 86_400_000,
})
).toBe(false);
});
});
describe("getAlertsResetRequested", () => {
it("returns true when alertsReset=1 is present", () => {
expect(getAlertsResetRequested(billingLimitsRequest("?alertsReset=1"))).toBe(true);
});
it("returns false when the param is absent", () => {
expect(getAlertsResetRequested(billingLimitsRequest())).toBe(false);
});
it("returns false for other param values", () => {
expect(getAlertsResetRequested(billingLimitsRequest("?alertsReset=true"))).toBe(false);
});
});
describe("getResolveSubmitted", () => {
it("returns true when resolved=1 is present", () => {
expect(getResolveSubmitted(billingLimitsRequest("?resolved=1"))).toBe(true);
});
it("returns false when the param is absent", () => {
expect(getResolveSubmitted(billingLimitsRequest())).toBe(false);
});
});
describe("getSubmittedResumeMode", () => {
it("parses resumeMode from the query string", () => {
expect(getSubmittedResumeMode(billingLimitsRequest("?resumeMode=new_only"))).toBe("new_only");
});
it("returns null for invalid values", () => {
expect(getSubmittedResumeMode(billingLimitsRequest("?resumeMode=invalid"))).toBeNull();
});
});
describe("getSuggestedRecoveryLimitDollars", () => {
it("uses max(limit + $50, limit × 1.25, spend × 1.25) rounded up to $50", () => {
expect(getSuggestedRecoveryLimitDollars(5_000, 4_500)).toBe(100);
expect(getSuggestedRecoveryLimitDollars(50_000, 48_000)).toBe(650);
expect(getSuggestedRecoveryLimitDollars(50_000, 60_000)).toBe(750);
expect(getSuggestedRecoveryLimitDollars(1_000_000, 950_000)).toBe(12_500);
});
it("falls back to spend × 1.25 when there is no effective limit", () => {
expect(getSuggestedRecoveryLimitDollars(null, 4_500)).toBe(100);
});
});
describe("isBillingLimitSettingsFormSubmission", () => {
it("returns true for billing-limit POST", () => {
const formData = new FormData();
formData.set("intent", "billing-limit");
expect(isBillingLimitSettingsFormSubmission("post", formData)).toBe(true);
});
it("returns true for billing-alerts POST", () => {
const formData = new FormData();
formData.set("intent", "billing-alerts");
expect(isBillingLimitSettingsFormSubmission("POST", formData)).toBe(true);
});
it("returns true for billing-limit-resolve POST", () => {
const formData = new FormData();
formData.set("intent", "billing-limit-resolve");
expect(isBillingLimitSettingsFormSubmission("post", formData)).toBe(true);
});
it("returns false for unrelated POST", () => {
const formData = new FormData();
formData.set("intent", "other");
expect(isBillingLimitSettingsFormSubmission("post", formData)).toBe(false);
});
it("returns false without form data", () => {
expect(isBillingLimitSettingsFormSubmission("post", undefined)).toBe(false);
});
});
describe("getEffectiveLimitCentsAfterLimitSave", () => {
it("uses custom amount in cents for custom mode", () => {
expect(getEffectiveLimitCentsAfterLimitSave("custom", 5000, 42.5)).toBe(4250);
});
it("uses plan limit cents for plan mode", () => {
expect(getEffectiveLimitCentsAfterLimitSave("plan", 5000)).toBe(5000);
});
it("uses plan limit cents for none mode", () => {
expect(getEffectiveLimitCentsAfterLimitSave("none", 5000)).toBe(5000);
});
});
});
describe("billing-alerts redirect route", () => {
it("redirects to billing-limits", async () => {
const response = await billingAlertsRedirectLoader({
params: { organizationSlug: "acme" },
request: new Request("http://localhost:3030/orgs/acme/settings/billing-alerts"),
context: {},
});
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("/orgs/acme/settings/billing-limits");
});
});
describe("billing-limits form validation", () => {
it("rejects duplicate alert thresholds", () => {
const formData = new FormData();
formData.set("intent", "billing-alerts");
formData.append("emails", "a@example.com");
formData.append("alertLevels", "75");
formData.append("alertLevels", "75");
const submission = parseWithZod(formData, { schema: billingAlertsSchema });
expect(submission.error?.alertLevels).toBeTruthy();
});
it("rejects non-numeric alert thresholds", () => {
const formData = new FormData();
formData.set("intent", "billing-alerts");
formData.append("emails", "a@example.com");
formData.append("alertLevels", "75");
formData.append("alertLevels", "not-a-number");
const submission = parseWithZod(formData, { schema: billingAlertsSchema });
expect(submission.error?.["alertLevels[1]"]).toBeTruthy();
});
it("accepts a valid billing limit custom submission", () => {
const formData = new FormData();
formData.set("intent", "billing-limit");
formData.set("mode", "custom");
formData.set("amount", "100");
formData.set("cancelInProgressRuns", "on");
const submission = parseWithZod(formData, { schema: billingLimitFormSchema });
expect(submission.value).toEqual({
mode: "custom",
amount: 100,
cancelInProgressRuns: true,
});
});
it("parses none mode with cancelInProgressRuns from the form", () => {
const formData = new FormData();
formData.set("mode", "none");
formData.set("cancelInProgressRuns", "on");
const submission = parseWithZod(formData, { schema: billingLimitFormSchema });
expect(submission.value?.mode).toBe("none");
expect(submission.value?.cancelInProgressRuns).toBe(true);
});
it("accepts a valid billing limit resolve submission", () => {
const formData = new FormData();
formData.set("intent", "billing-limit-resolve");
formData.set("action", "increase");
formData.set("newAmount", "1500");
formData.set("resumeMode", "queue");
const submission = parseWithZod(formData, { schema: billingLimitRecoveryFormSchema });
expect(submission.value).toEqual({
action: "increase",
newAmount: 1500,
resumeMode: "queue",
});
});
it("accepts remove resolve with new_only resume mode", () => {
const formData = new FormData();
formData.set("action", "remove");
formData.set("resumeMode", "new_only");
const submission = parseWithZod(formData, { schema: billingLimitRecoveryFormSchema });
expect(submission.value).toEqual({
action: "remove",
resumeMode: "new_only",
});
});
});
describe("isBillingLimitFormDirty", () => {
const unconfiguredLimit = { isConfigured: false as const, gracePeriodMs: 86_400_000 };
const configuredPlanLimit = {
isConfigured: true as const,
mode: "plan" as const,
cancelInProgressRuns: false,
limitState: { status: "ok" as const },
effectiveAmountCents: 5000,
gracePeriodMs: 86_400_000,
};
it("is dirty when billing limit has never been saved", () => {
expect(
isBillingLimitFormDirty({
billingLimit: unconfiguredLimit,
mode: "none",
customAmount: "",
cancelInProgressRuns: false,
})
).toBe(true);
});
it("is clean when configured values match saved state", () => {
expect(
isBillingLimitFormDirty({
billingLimit: configuredPlanLimit,
mode: "plan",
customAmount: "",
cancelInProgressRuns: false,
})
).toBe(false);
});
it("is dirty when configured mode changes", () => {
expect(
isBillingLimitFormDirty({
billingLimit: configuredPlanLimit,
mode: "none",
customAmount: "",
cancelInProgressRuns: false,
})
).toBe(true);
});
});
describe("getBillingLimitFormLastSubmission", () => {
it("drops amount errors when the selected mode is not custom", () => {
const submission = parseWithZod(
(() => {
const formData = new FormData();
formData.set("mode", "custom");
formData.set("amount", "0");
return formData;
})(),
{ schema: billingLimitFormSchema }
).reply();
expect(
getBillingLimitFormLastSubmission(submission, "plan", true)?.error?.amount
).toBeUndefined();
});
it("keeps amount errors while custom mode is selected", () => {
const submission = parseWithZod(
(() => {
const formData = new FormData();
formData.set("mode", "custom");
formData.set("amount", "0");
return formData;
})(),
{ schema: billingLimitFormSchema }
).reply();
expect(
getBillingLimitFormLastSubmission(submission, "custom", true)?.error?.amount
).toBeTruthy();
});
it("returns undefined when the form is clean", () => {
const submission = parseWithZod(
(() => {
const formData = new FormData();
formData.set("mode", "custom");
formData.set("amount", "0");
return formData;
})(),
{ schema: billingLimitFormSchema }
).reply();
expect(getBillingLimitFormLastSubmission(submission, "custom", false)).toBeUndefined();
});
});
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import {
isBranchableEnvironment,
rootEnvironmentWhere,
toBranchableEnvironmentType,
} from "~/utils/branchableEnvironment";
describe("toBranchableEnvironmentType", () => {
it("maps the wire tokens to the canonical Prisma enum", () => {
expect(toBranchableEnvironmentType("preview")).toBe("PREVIEW");
expect(toBranchableEnvironmentType("development")).toBe("DEVELOPMENT");
});
});
describe("isBranchableEnvironment", () => {
it("treats any DEVELOPMENT root as branchable, ignoring the column", () => {
// The dev migration dropped the column-based approach: branchability for
// dev is derived structurally, so a root with the column unset is still
// branchable.
expect(
isBranchableEnvironment({
type: "DEVELOPMENT",
parentEnvironmentId: null,
isBranchableEnvironment: false,
})
).toBe(true);
});
it("never treats a dev BRANCH (one with a parent) as branchable", () => {
// Load-bearing guard: dev branches are also type DEVELOPMENT, so checking
// the type alone would misclassify them. The parentEnvironmentId guard is
// what prevents branches-of-branches.
expect(
isBranchableEnvironment({
type: "DEVELOPMENT",
parentEnvironmentId: "env_parent",
isBranchableEnvironment: true,
})
).toBe(false);
});
it("honors the column for PREVIEW roots (the long-standing source of truth)", () => {
expect(
isBranchableEnvironment({
type: "PREVIEW",
parentEnvironmentId: null,
isBranchableEnvironment: true,
})
).toBe(true);
expect(
isBranchableEnvironment({
type: "PREVIEW",
parentEnvironmentId: null,
isBranchableEnvironment: false,
})
).toBe(false);
});
it("never treats a preview branch as branchable, even with the column set", () => {
expect(
isBranchableEnvironment({
type: "PREVIEW",
parentEnvironmentId: "env_parent",
isBranchableEnvironment: true,
})
).toBe(false);
});
it("is false for STAGING and PRODUCTION", () => {
for (const type of ["STAGING", "PRODUCTION"] as const) {
expect(
isBranchableEnvironment({
type,
parentEnvironmentId: null,
isBranchableEnvironment: true,
})
).toBe(false);
}
});
});
describe("rootEnvironmentWhere", () => {
it("matches the root env of the type (never a branch)", () => {
expect(rootEnvironmentWhere("PREVIEW")).toEqual({
type: "PREVIEW",
parentEnvironmentId: null,
});
});
it("scopes DEVELOPMENT roots by org member when a userId is given", () => {
// Dev roots are per-org-member, so the same project has one root per user.
expect(rootEnvironmentWhere("DEVELOPMENT", { userId: "user_123" })).toEqual({
type: "DEVELOPMENT",
parentEnvironmentId: null,
orgMember: { userId: "user_123" },
});
});
it("omits the org-member filter for DEVELOPMENT when no userId is given", () => {
expect(rootEnvironmentWhere("DEVELOPMENT")).toEqual({
type: "DEVELOPMENT",
parentEnvironmentId: null,
});
});
it("ignores userId for non-development types", () => {
expect(rootEnvironmentWhere("PREVIEW", { userId: "user_123" })).toEqual({
type: "PREVIEW",
parentEnvironmentId: null,
});
});
});
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { buildBufferedTriggerPayload } from "~/v3/mollifier/bufferedTriggerPayload.server";
describe("buildBufferedTriggerPayload", () => {
const baseInput = {
runFriendlyId: "run_abc",
taskId: "my-task",
envId: "env_1",
envType: "DEVELOPMENT",
envSlug: "dev",
orgId: "org_1",
orgSlug: "acme",
projectId: "proj_db_id",
projectRef: "proj_xyz",
body: { payload: { hello: "world" }, options: { tags: ["t1"] } } as any,
idempotencyKey: null,
idempotencyKeyExpiresAt: null,
tags: ["t1"],
parentRunFriendlyId: null,
traceContext: { traceparent: "00-abc-def-01" },
triggerSource: "api" as const,
triggerAction: "trigger" as const,
serviceOptions: {} as any,
createdAt: new Date("2026-05-13T09:00:00.000Z"),
};
it("captures all routing identifiers without losing data", () => {
const payload = buildBufferedTriggerPayload(baseInput);
expect(payload.runFriendlyId).toBe("run_abc");
expect(payload.envId).toBe("env_1");
expect(payload.envType).toBe("DEVELOPMENT");
expect(payload.envSlug).toBe("dev");
expect(payload.orgId).toBe("org_1");
expect(payload.orgSlug).toBe("acme");
expect(payload.projectId).toBe("proj_db_id");
expect(payload.projectRef).toBe("proj_xyz");
expect(payload.taskId).toBe("my-task");
});
it("serialises idempotencyKeyExpiresAt to ISO string only when key is present", () => {
const withKey = buildBufferedTriggerPayload({
...baseInput,
idempotencyKey: "ik_1",
idempotencyKeyExpiresAt: new Date("2026-05-13T10:00:00.000Z"),
});
expect(withKey.idempotencyKey).toBe("ik_1");
expect(withKey.idempotencyKeyExpiresAt).toBe("2026-05-13T10:00:00.000Z");
const noKey = buildBufferedTriggerPayload(baseInput);
expect(noKey.idempotencyKey).toBeNull();
expect(noKey.idempotencyKeyExpiresAt).toBeNull();
// Defensive: an expiresAt without an accompanying key is an impossible
// idempotency state — drop the expiresAt rather than serialise it.
const orphanExpiry = buildBufferedTriggerPayload({
...baseInput,
idempotencyKey: null,
idempotencyKeyExpiresAt: new Date("2026-05-13T10:00:00.000Z"),
});
expect(orphanExpiry.idempotencyKey).toBeNull();
expect(orphanExpiry.idempotencyKeyExpiresAt).toBeNull();
});
it("preserves customer body byte-equivalent (drainer replay must match Postgres)", () => {
const body = {
payload: { quotes: 'a"b', newline: "x\ny", unicode: "🚀", nested: { n: 1 } },
options: { tags: ["a"], maxAttempts: 3, machine: "small-1x" },
} as any;
const payload = buildBufferedTriggerPayload({ ...baseInput, body });
expect(payload.body).toEqual(body);
// JSON round-trip is the storage path; verify no information loss.
const roundtripped = JSON.parse(JSON.stringify(payload.body));
expect(roundtripped).toEqual(body);
});
it("createdAt is serialised to ISO 8601", () => {
const payload = buildBufferedTriggerPayload(baseInput);
expect(payload.createdAt).toBe("2026-05-13T09:00:00.000Z");
});
it("preserves traceContext (OTel continuity across buffer→drain boundary)", () => {
const traceContext = { traceparent: "00-x-y-01", tracestate: "vendor=foo" };
const payload = buildBufferedTriggerPayload({ ...baseInput, traceContext });
expect(payload.traceContext).toEqual(traceContext);
});
it("nullable parentRunFriendlyId — present and absent", () => {
expect(buildBufferedTriggerPayload(baseInput).parentRunFriendlyId).toBeNull();
expect(
buildBufferedTriggerPayload({ ...baseInput, parentRunFriendlyId: "run_parent" })
.parentRunFriendlyId
).toBe("run_parent");
});
});
@@ -0,0 +1,322 @@
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);
}
@@ -0,0 +1,205 @@
// Service-level proof for bulk CANCEL/REPLAY member hydration across the run-ops seam.
//
// `BulkActionService.process()` builds its ClickHouse-backed RunsRepository internally and
// has no test seam to inject the member-id page, and driving it end-to-end would require a
// full ClickHouse replication stack just to make `listRunIds` return the seeded ids. The
// cross-DB hydration semantics — the DoD's core — are proven exhaustively at the adapter
// unit level (BulkActionV2.batchReadThrough.server.test.ts). Here we prove the SERVICE-level
// wiring by driving the exact closures `process()` passes to `hydrateRunsAcrossSeam` against
// REAL rows seeded on the two containers (PG14 legacy + PG17 new), so the PG14↔PG17 boundary
// is genuinely crossed and the full REPLAY row shape is exercised. We NEVER mock the DB.
import { heteroPostgresTest } from "@internal/testcontainers";
import { describe, expect, vi } from "vitest";
import type { PrismaClient } from "@trigger.dev/database";
import type { PrismaReplicaClient } from "~/db.server";
import { hydrateRunsAcrossSeam } from "~/v3/services/bulk/BulkActionV2.batchReadThrough.server";
vi.setConfig({ testTimeout: 60_000 });
// 26-char v1 body (version "1" at index 25) → NEW residency. 25-char body → LEGACY residency (cuid analog).
function newId(c: string) {
return "run_" + c.repeat(24) + "01";
}
function legacyId(c: string) {
return "run_" + c.repeat(25);
}
// The exact closures BulkActionService.process() uses for each branch.
const cancelSelect = {
id: true,
engine: true,
friendlyId: true,
status: true,
createdAt: true,
completedAt: true,
taskEventStore: true,
} as const;
function cancelReadNew(client: PrismaReplicaClient, ids: string[]) {
return client.taskRun.findMany({ where: { id: { in: ids } }, select: cancelSelect });
}
function cancelReadLegacy(replica: PrismaReplicaClient, ids: string[]) {
return replica.taskRun.findMany({ where: { id: { in: ids } }, select: cancelSelect });
}
function replayReadNew(client: PrismaReplicaClient, ids: string[]) {
return client.taskRun.findMany({ where: { id: { in: ids } } });
}
function replayReadLegacy(replica: PrismaReplicaClient, ids: string[]) {
return replica.taskRun.findMany({ where: { id: { in: ids } } });
}
async function seedEnv(prisma: PrismaClient, slug: string) {
const user = await prisma.user.create({
data: { email: `${slug}@test.com`, name: "t", authenticationMethod: "MAGIC_LINK" },
});
const organization = await prisma.organization.create({
data: {
title: "Org",
slug: `org-${slug}`,
members: { create: { userId: user.id, role: "ADMIN" } },
},
});
const project = await prisma.project.create({
data: {
name: "Proj",
slug: `proj-${slug}`,
organizationId: organization.id,
externalRef: `ext-${slug}`,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: `env-${slug}`,
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `api-${slug}`,
pkApiKey: `pk-${slug}`,
shortcode: `sc-${slug}`,
},
});
return { organization, project, environment };
}
async function seedRun(
prisma: PrismaClient,
ctx: { organization: { id: string }; project: { id: string }; environment: { id: string } },
id: string
) {
await prisma.taskRun.create({
data: {
id,
friendlyId: id,
taskIdentifier: "t",
status: "EXECUTING",
payload: JSON.stringify({}),
payloadType: "application/json",
traceId: id,
spanId: id,
queue: "main",
runtimeEnvironmentId: ctx.environment.id,
projectId: ctx.project.id,
organizationId: ctx.organization.id,
environmentType: "PRODUCTION",
engine: "V2",
},
});
}
describe("BulkActionService member hydration across the seam (PG14 legacy + PG17 new)", () => {
heteroPostgresTest(
"CANCEL across both DBs hydrates every member; the NEW id never hits the legacy replica",
async ({ prisma14, prisma17 }) => {
const newRunId = newId("a");
const legacyRunId = legacyId("b");
const newCtx = await seedEnv(prisma17 as unknown as PrismaClient, "cancel-new");
const legacyCtx = await seedEnv(prisma14 as unknown as PrismaClient, "cancel-legacy");
await seedRun(prisma17 as unknown as PrismaClient, newCtx, newRunId);
await seedRun(prisma14 as unknown as PrismaClient, legacyCtx, legacyRunId);
const legacySpy = vi.fn((replica: PrismaReplicaClient, ids: string[]) => {
if (ids.includes(newRunId)) {
throw new Error("legacy replica must never be probed for a NEW-residency id");
}
return cancelReadLegacy(replica, ids);
});
const runs = await hydrateRunsAcrossSeam({
runIds: [newRunId, legacyRunId],
readNew: cancelReadNew,
readLegacyReplica: legacySpy,
deps: {
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
},
});
// Every member hydrated → every member reaches cancel (none dropped).
expect(runs.map((r) => r.id).sort()).toEqual([newRunId, legacyRunId].sort());
expect(legacySpy.mock.calls[0][1]).toEqual([legacyRunId]);
}
);
heteroPostgresTest(
"REPLAY across both DBs hydrates every member as a FULL row",
async ({ prisma14, prisma17 }) => {
const newRunId = newId("c");
const legacyRunId = legacyId("d");
const newCtx = await seedEnv(prisma17 as unknown as PrismaClient, "replay-new");
const legacyCtx = await seedEnv(prisma14 as unknown as PrismaClient, "replay-legacy");
await seedRun(prisma17 as unknown as PrismaClient, newCtx, newRunId);
await seedRun(prisma14 as unknown as PrismaClient, legacyCtx, legacyRunId);
const runs = await hydrateRunsAcrossSeam({
runIds: [newRunId, legacyRunId],
readNew: replayReadNew,
readLegacyReplica: replayReadLegacy,
deps: {
splitEnabled: true,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
},
});
expect(runs.map((r) => r.id).sort()).toEqual([newRunId, legacyRunId].sort());
// Full row, not a select projection: a non-selected column is populated.
const newRow = runs.find((r) => r.id === newRunId)!;
const legacyRow = runs.find((r) => r.id === legacyRunId)!;
expect(newRow.runtimeEnvironmentId).toBe(newCtx.environment.id);
expect(legacyRow.runtimeEnvironmentId).toBe(legacyCtx.environment.id);
}
);
heteroPostgresTest(
"single-DB passthrough hydrates all members from one client; legacy never invoked",
async ({ prisma14, prisma17 }) => {
// In single-DB mode the service passes its _replica as newClient. Seed everything there.
const idA = newId("f");
const idB = legacyId("g");
const ctx = await seedEnv(prisma17 as unknown as PrismaClient, "passthrough");
await seedRun(prisma17 as unknown as PrismaClient, ctx, idA);
await seedRun(prisma17 as unknown as PrismaClient, ctx, idB);
const throwingLegacy = vi.fn(() => {
throw new Error("legacy replica must never run in single-DB mode");
});
const runs = await hydrateRunsAcrossSeam({
runIds: [idA, idB],
readNew: cancelReadNew,
readLegacyReplica: throwingLegacy as never,
deps: {
splitEnabled: false,
newClient: prisma17 as unknown as PrismaReplicaClient,
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
},
});
expect(runs.map((r) => r.id).sort()).toEqual([idA, idB].sort());
expect(throwingLegacy).not.toHaveBeenCalled();
}
);
});
@@ -0,0 +1,450 @@
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import { calculateNextScheduledTimestampFromNow } from "../app/v3/utils/calculateNextSchedule.server";
describe("calculateNextScheduledTimestampFromNow", () => {
beforeEach(() => {
// Mock the current time to make tests deterministic
vi.useFakeTimers();
vi.setSystemTime(new Date("2024-01-01T12:30:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
test("should calculate next run time for a recent timestamp", () => {
const schedule = "0 * * * *"; // Every hour
const _lastRun = new Date("2024-01-01T11:00:00.000Z"); // 1.5 hours ago
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
// Should be 13:00 (next hour after current time 12:30)
expect(nextRun).toEqual(new Date("2024-01-01T13:00:00.000Z"));
});
test("should handle timezone correctly", () => {
const schedule = "0 * * * *"; // Every hour
const _lastRun = new Date("2024-01-01T11:00:00.000Z");
const nextRun = calculateNextScheduledTimestampFromNow(schedule, "America/New_York");
// The exact time will depend on timezone calculation, but should be in the future
expect(nextRun).toBeInstanceOf(Date);
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
});
test("should efficiently handle very old timestamps (performance fix)", () => {
const schedule = "*/1 * * * *"; // Every minute
const _veryOldTimestamp = new Date("2020-01-01T00:00:00.000Z"); // 4 years ago
const startTime = performance.now();
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
const duration = performance.now() - startTime;
// Should complete quickly (under 10ms) instead of iterating millions of times
expect(duration).toBeLessThan(10);
// Should still return a valid future timestamp
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
// Should be the next minute after current time (12:31)
expect(nextRun).toEqual(new Date("2024-01-01T12:31:00.000Z"));
});
test("should still work correctly when timestamp is within threshold", () => {
const schedule = "0 */2 * * *"; // Every 2 hours
const _recentTimestamp = new Date("2024-01-01T10:00:00.000Z"); // 2.5 hours ago
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
// Should properly iterate: 10:00 -> 12:00 -> 14:00 (since current time is 12:30)
expect(nextRun).toEqual(new Date("2024-01-01T14:00:00.000Z"));
});
test("should handle frequent schedules with old timestamps efficiently", () => {
const schedule = "*/5 * * * *"; // Every 5 minutes
const _oldTimestamp = new Date("2023-12-01T00:00:00.000Z"); // Over a month ago
const startTime = performance.now();
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
const duration = performance.now() - startTime;
// Should be fast due to dynamic skip-ahead optimization
expect(duration).toBeLessThan(10);
// Should return next 5-minute interval after current time
expect(nextRun).toEqual(new Date("2024-01-01T12:35:00.000Z"));
});
test("should work with complex cron expressions", () => {
const schedule = "0 9 * * MON"; // Every Monday at 9 AM
const _oldTimestamp = new Date("2022-01-01T00:00:00.000Z"); // Very old (beyond 1hr threshold)
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
// Should return a valid future Monday at 9 AM
expect(nextRun.getHours()).toBe(9);
expect(nextRun.getMinutes()).toBe(0);
expect(nextRun.getDay()).toBe(1); // Monday
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
});
test("performance: dynamic optimization for extreme scenarios", () => {
// This test simulates the exact scenario that was causing event loop lag
const schedule = "* * * * *"; // Every minute (very frequent)
const _extremelyOldTimestamp = new Date("2000-01-01T00:00:00.000Z"); // 24 years ago
const startTime = performance.now();
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
const duration = performance.now() - startTime;
// Should complete extremely quickly due to dynamic skip-ahead
expect(duration).toBeLessThan(5);
// Should still return the correct next minute
expect(nextRun).toEqual(new Date("2024-01-01T12:31:00.000Z"));
});
test("dynamic optimization: 23h59m old now handled efficiently", () => {
// This should now be handled efficiently regardless of being "just under" a threshold
const schedule = "* * * * *"; // Every minute
const _oldTimestamp = new Date("2023-12-31T12:31:00.000Z"); // 23h59m ago
const startTime = performance.now();
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
const duration = performance.now() - startTime;
// Should be fast due to dynamic skip-ahead (1439 steps > 10 threshold)
expect(duration).toBeLessThan(10);
// Should return correct result
expect(nextRun).toEqual(new Date("2024-01-01T12:31:00.000Z"));
});
test("small intervals still use normal iteration", () => {
// This should use normal iteration since it's only a few steps
const schedule = "*/5 * * * *"; // Every 5 minutes
const _recentTimestamp = new Date("2024-01-01T12:00:00.000Z"); // 30 minutes ago (6 steps)
const startTime = performance.now();
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
const duration = performance.now() - startTime;
// Should still be reasonably fast with normal iteration
expect(duration).toBeLessThan(50);
// Should return next 5-minute interval
expect(nextRun).toEqual(new Date("2024-01-01T12:35:00.000Z"));
});
test("should work with weekly schedules and old timestamps", () => {
const schedule = "0 9 * * MON"; // Every Monday at 9 AM
const _oldTimestamp = new Date("2023-12-25T09:00:00.000Z"); // Old Monday
const startTime = performance.now();
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
const duration = performance.now() - startTime;
// Should be fast and still calculate correctly from the old timestamp
expect(duration).toBeLessThan(50);
// Should return a valid future Monday at 9 AM
expect(nextRun.getHours()).toBe(9);
expect(nextRun.getMinutes()).toBe(0);
expect(nextRun.getDay()).toBe(1); // Monday
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
});
test("weekly schedule with 2-hour old timestamp should calculate properly", () => {
// This tests your specific concern about weekly schedules
const schedule = "0 14 * * SUN"; // Every Sunday at 2 PM
const _twoHoursAgo = new Date("2024-01-01T10:30:00.000Z"); // 2 hours before current time (12:30)
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
// Should properly calculate the next Sunday at 2 PM, not skip to "now"
expect(nextRun.getHours()).toBe(14);
expect(nextRun.getMinutes()).toBe(0);
expect(nextRun.getDay()).toBe(0); // Sunday
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
});
});
describe("calculateNextScheduledTimestampFromNow - Fuzzy Testing", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2024-01-15T12:30:00.000Z")); // Monday, mid-day
});
afterEach(() => {
vi.useRealTimers();
});
// Helper function to generate random cron expressions
function generateRandomCronExpression(): string {
const patterns = [
// Minutes
"*/1 * * * *", // Every minute
"*/5 * * * *", // Every 5 minutes
"*/15 * * * *", // Every 15 minutes
"30 * * * *", // Every hour at 30 minutes
// Hours
"0 * * * *", // Every hour
"0 */2 * * *", // Every 2 hours
"0 */6 * * *", // Every 6 hours
"0 9 * * *", // Daily at 9 AM
"0 14 * * *", // Daily at 2 PM
// Days
"0 9 * * 1", // Every Monday at 9 AM
"0 14 * * 5", // Every Friday at 2 PM
"0 10 * * 1-5", // Weekdays at 10 AM
"0 0 * * 0", // Every Sunday at midnight
// Weekly/Monthly
"0 9 * * MON", // Every Monday at 9 AM
"0 12 1 * *", // First of every month at noon
"0 15 15 * *", // 15th of every month at 3 PM
// Complex patterns
"0 9,17 * * 1-5", // 9 AM and 5 PM on weekdays
"30 8-18/2 * * *", // Every 2 hours from 8:30 AM to 6:30 PM
];
return patterns[Math.floor(Math.random() * patterns.length)];
}
// Helper function to generate random timestamps
function generateRandomTimestamp(): Date {
const now = Date.now();
const possibilities = [
// Recent timestamps (within last few hours)
new Date(now - Math.random() * 4 * 60 * 60 * 1000),
// Old timestamps (days ago)
new Date(now - Math.random() * 30 * 24 * 60 * 60 * 1000),
// Very old timestamps (months/years ago)
new Date(now - Math.random() * 365 * 24 * 60 * 60 * 1000),
// Extremely old timestamps
new Date(now - Math.random() * 10 * 365 * 24 * 60 * 60 * 1000),
// Edge case: exactly now
new Date(now),
// Edge case: 1ms ago
new Date(now - 1),
// Edge case: future timestamp (should be handled gracefully)
new Date(now + Math.random() * 24 * 60 * 60 * 1000),
];
return possibilities[Math.floor(Math.random() * possibilities.length)];
}
test("fuzzy test: invariants should hold for random scenarios", () => {
const numTests = 50;
for (let i = 0; i < numTests; i++) {
const schedule = generateRandomCronExpression();
const lastTimestamp = generateRandomTimestamp();
const timezone = Math.random() > 0.7 ? "America/New_York" : null;
try {
const startTime = performance.now();
const nextRun = calculateNextScheduledTimestampFromNow(schedule, timezone);
const duration = performance.now() - startTime;
// Invariant 1: Result should always be a valid Date
expect(nextRun).toBeInstanceOf(Date);
expect(nextRun.getTime()).not.toBeNaN();
// Invariant 2: Result should be in the future (or equal to now if lastTimestamp was in future)
if (lastTimestamp.getTime() <= Date.now()) {
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
}
// Invariant 3: Performance should be reasonable (no event loop lag)
expect(duration).toBeLessThan(100); // Should complete within 100ms
// Invariant 4: Function should be deterministic
const nextRun2 = calculateNextScheduledTimestampFromNow(schedule, timezone);
expect(nextRun.getTime()).toBe(nextRun2.getTime());
} catch (error) {
// If there's an error, log the inputs for debugging
console.error(
`Failed with schedule: ${schedule}, lastTimestamp: ${lastTimestamp.toISOString()}, timezone: ${timezone}`
);
throw error;
}
}
});
test("fuzzy test: performance under stress with frequent schedules", () => {
const frequentSchedules = ["* * * * *", "*/2 * * * *", "*/5 * * * *"];
for (let i = 0; i < 20; i++) {
const schedule = frequentSchedules[Math.floor(Math.random() * frequentSchedules.length)];
// Generate very old timestamps that would cause many iterations without optimization
const _veryOldTimestamp = new Date(
Date.now() - Math.random() * 5 * 365 * 24 * 60 * 60 * 1000
);
const startTime = performance.now();
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
const duration = performance.now() - startTime;
// Should complete quickly even with very old timestamps
expect(duration).toBeLessThan(20);
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
}
});
test("fuzzy test: edge cases around daylight saving time", () => {
// Test around DST transition dates (spring forward, fall back)
const dstTestDates = [
"2024-03-10T06:00:00.000Z", // Around US spring DST
"2024-11-03T06:00:00.000Z", // Around US fall DST
"2024-03-31T01:00:00.000Z", // Around EU spring DST
"2024-10-27T01:00:00.000Z", // Around EU fall DST
];
const timezones = ["America/New_York", "Europe/London", "America/Los_Angeles"];
for (let i = 0; i < 15; i++) {
const schedule = generateRandomCronExpression();
const testDate = dstTestDates[Math.floor(Math.random() * dstTestDates.length)];
const timezone = timezones[Math.floor(Math.random() * timezones.length)];
vi.setSystemTime(new Date(testDate));
const _lastTimestamp = new Date(Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000);
const nextRun = calculateNextScheduledTimestampFromNow(schedule, timezone);
// Should handle DST transitions gracefully
expect(nextRun).toBeInstanceOf(Date);
expect(nextRun.getTime()).not.toBeNaN();
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
}
});
test("fuzzy test: boundary conditions", () => {
const boundaryTests = [
// End of month transitions
{ time: "2024-02-29T23:59:59.000Z", schedule: "0 0 1 * *" }, // Leap year to March 1st
{ time: "2024-04-30T23:59:59.000Z", schedule: "0 0 31 * *" }, // April 30th to May 31st
// End of year
{ time: "2024-12-31T23:59:59.000Z", schedule: "0 0 1 1 *" }, // New Year
// Weekday transitions
{ time: "2024-01-15T06:59:59.000Z", schedule: "0 7 * * MON" }, // Monday morning
// Hour boundaries
{ time: "2024-01-15T11:59:59.000Z", schedule: "0 12 * * *" }, // Noon
{ time: "2024-01-15T23:59:59.000Z", schedule: "0 0 * * *" }, // Midnight
];
for (const test of boundaryTests) {
vi.setSystemTime(new Date(test.time));
// Test with timestamps both before and after the boundary
const _beforeBoundary = new Date(Date.now() - 1000);
const _afterBoundary = new Date(Date.now() + 1000);
const nextRun1 = calculateNextScheduledTimestampFromNow(test.schedule, null);
const nextRun2 = calculateNextScheduledTimestampFromNow(test.schedule, null);
expect(nextRun1.getTime()).toBeGreaterThan(Date.now());
expect(nextRun2.getTime()).toBeGreaterThan(Date.now());
}
});
test("fuzzy test: complex cron expressions", () => {
const complexSchedules = [
"0 9,17 * * 1-5", // 9 AM and 5 PM on weekdays
"30 8-18/2 * * *", // Every 2 hours from 8:30 AM to 6:30 PM
"0 0 1,15 * *", // 1st and 15th of every month
"0 12 * * MON#2", // Second Monday of every month (if supported)
"0 0 L * *", // Last day of month (if supported)
"15,45 */2 * * *", // 15 and 45 minutes past every 2nd hour
];
for (let i = 0; i < 30; i++) {
const schedule = complexSchedules[Math.floor(Math.random() * complexSchedules.length)];
const lastTimestamp = generateRandomTimestamp();
try {
const startTime = performance.now();
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
const duration = performance.now() - startTime;
expect(nextRun).toBeInstanceOf(Date);
expect(duration).toBeLessThan(100);
if (lastTimestamp.getTime() <= Date.now()) {
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
}
} catch (error) {
// Some complex expressions might not be supported, that's okay
if (
!(error as Error).message.includes("not supported") &&
!(error as Error).message.includes("Invalid")
) {
console.error(`Unexpected error with schedule: ${schedule}`);
throw error;
}
}
}
});
test("fuzzy test: consistency across multiple calls", () => {
// Test that the function is consistent when called multiple times with same inputs
for (let i = 0; i < 20; i++) {
const schedule = generateRandomCronExpression();
const _lastTimestamp = generateRandomTimestamp();
const timezone = Math.random() > 0.5 ? "UTC" : "America/New_York";
const results: Date[] = [];
for (let j = 0; j < 5; j++) {
results.push(calculateNextScheduledTimestampFromNow(schedule, timezone));
}
// All results should be identical
for (let j = 1; j < results.length; j++) {
expect(results[j].getTime()).toBe(results[0].getTime());
}
}
});
test("fuzzy test: optimization threshold boundary (around 10 steps)", () => {
// Test cases specifically around the 10-step optimization threshold
const testCases = [
{ schedule: "*/5 * * * *", minutesAgo: 50 }, // Exactly 10 steps
{ schedule: "*/5 * * * *", minutesAgo: 55 }, // 11 steps (should optimize)
{ schedule: "*/5 * * * *", minutesAgo: 45 }, // 9 steps (should not optimize)
{ schedule: "*/10 * * * *", minutesAgo: 100 }, // Exactly 10 steps
{ schedule: "*/10 * * * *", minutesAgo: 110 }, // 11 steps (should optimize)
{ schedule: "*/15 * * * *", minutesAgo: 150 }, // Exactly 10 steps
{ schedule: "*/1 * * * *", minutesAgo: 10 }, // Exactly 10 steps
{ schedule: "*/1 * * * *", minutesAgo: 11 }, // 11 steps (should optimize)
];
for (const testCase of testCases) {
const _lastTimestamp = new Date(Date.now() - testCase.minutesAgo * 60 * 1000);
const startTime = performance.now();
const nextRun = calculateNextScheduledTimestampFromNow(testCase.schedule, null);
const duration = performance.now() - startTime;
// All cases should complete quickly and return valid results
expect(duration).toBeLessThan(50);
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
expect(nextRun).toBeInstanceOf(Date);
}
});
});
@@ -0,0 +1,249 @@
// Real PG14 (legacy) + PG17 (new) proof for the dev-session-cancel TaskRun read.
// The DB is never mocked: reads hit the two real containers. Only the pure
// splitEnabled boundary and recording client wrappers are injected.
import { heteroPostgresTest, postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
import { describe, expect, vi } from "vitest";
import type { PrismaReplicaClient } from "~/db.server";
import { CancelDevSessionRunsService } from "~/v3/services/cancelDevSessionRuns.server";
vi.setConfig({ testTimeout: 60_000 });
// 25-char cuid body (no v1 version marker) → LEGACY residency.
function generateLegacyCuid() {
const suffix = Array.from(
{ length: 24 },
() => "0123456789abcdefghijklmnopqrstuvwxyz"[Math.floor(Math.random() * 36)]
).join("");
return `c${suffix}`;
}
async function seedOrgProjectEnv(prisma: PrismaClient, suffix: string) {
const organization = await prisma.organization.create({
data: { title: `test-${suffix}`, slug: `test-${suffix}` },
});
const project = await prisma.project.create({
data: {
name: `test-${suffix}`,
slug: `test-${suffix}`,
organizationId: organization.id,
externalRef: `test-${suffix}`,
},
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: `test-${suffix}`,
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
apiKey: `test-${suffix}`,
pkApiKey: `test-${suffix}`,
shortcode: `test-${suffix}`,
},
});
return { organization, project, runtimeEnvironment };
}
async function seedRun(
prisma: PrismaClient,
ids: { id: string; friendlyId: string },
env: { runtimeEnvironmentId: string; projectId: string; organizationId: string }
) {
return prisma.taskRun.create({
data: {
id: ids.id,
friendlyId: ids.friendlyId,
taskIdentifier: "my-task",
payload: JSON.stringify({ foo: "bar" }),
payloadType: "application/json",
traceId: "1234",
spanId: "1234",
queue: "test",
runtimeEnvironmentId: env.runtimeEnvironmentId,
projectId: env.projectId,
organizationId: env.organizationId,
environmentType: "DEVELOPMENT",
// V1 so the (best-effort, error-swallowed) cancel does not require the V2 engine;
// the unit under test is the READ resolution, not the cancel side effect.
engine: "V1",
status: "EXECUTING",
},
});
}
// A read client whose taskRun.findFirst is recorded; throws if used after being marked
// forbidden, so we can prove a store was NEVER read.
function recording(client: PrismaClient, opts: { forbidden?: boolean } = {}) {
const calls: unknown[] = [];
const taskRun = {
findFirst: (args: unknown) => {
calls.push(args);
if (opts.forbidden) {
throw new Error("this store must never be read");
}
return (client as unknown as PrismaReplicaClient).taskRun.findFirst(args as never);
},
};
return { handle: { ...client, taskRun } as unknown as PrismaReplicaClient, calls };
}
describe("CancelDevSessionRunsService store routing (hetero)", () => {
heteroPostgresTest(
"a NEW run (run-ops id) resolves on the new store via read-through, by friendlyId and by id",
async ({ prisma17, prisma14 }) => {
const id = generateRunOpsId();
expect(id.length).toBe(26);
const friendlyId = `run_${id}`;
const { project, organization, runtimeEnvironment } = await seedOrgProjectEnv(
prisma17,
"new"
);
await seedRun(
prisma17,
{ id, friendlyId },
{
runtimeEnvironmentId: runtimeEnvironment.id,
projectId: project.id,
organizationId: organization.id,
}
);
// by friendlyId
{
const newClient = recording(prisma17);
const legacy = recording(prisma14, { forbidden: true });
const service = new CancelDevSessionRunsService({
prisma: prisma17,
readThroughDeps: {
splitEnabled: true,
newClient: newClient.handle,
legacyReplica: legacy.handle,
},
});
await service.call({
runIds: [friendlyId],
cancelledAt: new Date(),
reason: "test",
});
// run-ops id → NEW: new store served the read, legacy never touched.
expect(newClient.calls.length).toBe(1);
expect(legacy.calls.length).toBe(0);
}
// by internal id
{
const newClient = recording(prisma17);
const legacy = recording(prisma14, { forbidden: true });
const service = new CancelDevSessionRunsService({
prisma: prisma17,
readThroughDeps: {
splitEnabled: true,
newClient: newClient.handle,
legacyReplica: legacy.handle,
},
});
await service.call({
runIds: [id],
cancelledAt: new Date(),
reason: "test",
});
expect(newClient.calls.length).toBe(1);
expect(legacy.calls.length).toBe(0);
}
}
);
heteroPostgresTest(
"an OLD in-retention run (cuid) resolves off the LEGACY replica, never a legacy primary",
async ({ prisma17, prisma14 }) => {
const id = generateLegacyCuid();
expect(id.length).toBe(25);
const friendlyId = `run_${id}`;
const { project, organization, runtimeEnvironment } = await seedOrgProjectEnv(
prisma14,
"legacy"
);
await seedRun(
prisma14,
{ id, friendlyId },
{
runtimeEnvironmentId: runtimeEnvironment.id,
projectId: project.id,
organizationId: organization.id,
}
);
const newClient = recording(prisma17);
const legacy = recording(prisma14);
const service = new CancelDevSessionRunsService({
prisma: prisma14,
readThroughDeps: {
splitEnabled: true,
newClient: newClient.handle,
legacyReplica: legacy.handle,
},
});
await service.call({
runIds: [id],
cancelledAt: new Date(),
reason: "test",
});
// NEW first (miss) → resolved off the LEGACY REPLICA handle (no primary handle exists).
expect(newClient.calls.length).toBe(1);
expect(legacy.calls.length).toBe(1);
}
);
});
describe("CancelDevSessionRunsService passthrough (single-DB)", () => {
postgresTest(
"with no read-through deps, the run is read from the single DB and session reads stay on it",
async ({ prisma }) => {
const id = generateRunOpsId();
const friendlyId = `run_${id}`;
const { project, organization, runtimeEnvironment } = await seedOrgProjectEnv(prisma, "pt");
await seedRun(
prisma,
{ id, friendlyId },
{
runtimeEnvironmentId: runtimeEnvironment.id,
projectId: project.id,
organizationId: organization.id,
}
);
const session = await prisma.runtimeEnvironmentSession.create({
data: { environmentId: runtimeEnvironment.id, ipAddress: "127.0.0.1" },
});
// splitEnabled=false → single plain read against the one client; the session
// control-plane read runs on the same prisma.
const service = new CancelDevSessionRunsService({
prisma,
replica: prisma,
readThroughDeps: {
splitEnabled: false,
newClient: prisma as unknown as PrismaReplicaClient,
},
});
await service.call({
runIds: [id],
cancelledAt: new Date(),
reason: "test",
cancelledSessionId: session.id,
});
// Run found + handed to cancel against the single DB; confirm the row is present.
const row = await prisma.taskRun.findFirst({ where: { id } });
expect(row).not.toBeNull();
expect(row?.friendlyId).toBe(friendlyId);
}
);
});
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis";
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
function series(startISO: string, count: number, stepMs: number) {
const start = new Date(startISO).getTime();
return Array.from({ length: count }, (_, i) => ({ bucket: start + i * stepMs }));
}
describe("buildActivityTimeAxis", () => {
it("shows seconds in labels for sub-minute buckets", () => {
const data = series("2026-06-22T00:00:00.000Z", 6, 5 * SECOND); // 5s buckets, 25s span
const { tickFormatter } = buildActivityTimeAxis(data);
const label = tickFormatter(data[0].bucket);
// HH:MM:SS -> two colons
expect(label.split(":").length).toBe(3);
});
it("shows HH:MM (no seconds) for minute+ buckets within a day", () => {
const data = series("2026-06-22T00:00:00.000Z", 12, 30 * MINUTE); // 30m buckets, 6h span
const { tickFormatter } = buildActivityTimeAxis(data);
const label = tickFormatter(data[0].bucket);
expect(label.split(":").length).toBe(2);
});
it("shows the date for multi-day ranges", () => {
const data = series("2026-06-20T00:00:00.000Z", 4, DAY); // 4 days
const { tickFormatter } = buildActivityTimeAxis(data);
const label = tickFormatter(data[0].bucket);
// e.g. "Jun 20" — no time component
expect(label).toMatch(/[A-Za-z]{3}\s+\d{1,2}/);
expect(label).not.toContain(":");
});
it("formats labels in UTC", () => {
const data = series("2026-06-22T13:30:00.000Z", 4, 15 * MINUTE);
const { tickFormatter } = buildActivityTimeAxis(data);
expect(tickFormatter(data[0].bucket)).toBe("13:30");
});
it("tooltip formatter reads the bucket from payload and includes date + time for sub-day buckets", () => {
const data = series("2026-06-22T00:00:00.000Z", 12, 30 * MINUTE);
const { tooltipLabelFormatter } = buildActivityTimeAxis(data);
const label = tooltipLabelFormatter("", [{ payload: { bucket: data[0].bucket } }]);
expect(label).toContain("Jun");
expect(label).toContain(":");
});
it("tooltip formatter falls back to the raw label when no bucket is present", () => {
const data = series("2026-06-22T00:00:00.000Z", 2, 30 * MINUTE);
const { tooltipLabelFormatter } = buildActivityTimeAxis(data);
expect(tooltipLabelFormatter("fallback", [{}])).toBe("fallback");
});
});
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import {
dedupeTicksByLabel,
estimateMaxLabels,
selectEvenlySpacedIndices,
selectEvenlySpacedTicks,
} from "~/components/primitives/charts/useXAxisTicks";
describe("selectEvenlySpacedTicks", () => {
const values = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
it("always includes first and last", () => {
const ticks = selectEvenlySpacedTicks(values, 4);
expect(ticks[0]).toBe(0);
expect(ticks[ticks.length - 1]).toBe(9);
});
it("spaces ticks evenly", () => {
expect(selectEvenlySpacedTicks(values, 4)).toEqual([0, 3, 6, 9]);
});
it("returns all values when more labels than points are allowed", () => {
expect(selectEvenlySpacedTicks(values, 50)).toEqual(values);
});
it("returns just the endpoints for 2 labels", () => {
expect(selectEvenlySpacedTicks(values, 2)).toEqual([0, 9]);
});
it("returns a single value for 1 label", () => {
expect(selectEvenlySpacedTicks(values, 1)).toEqual([0]);
});
it("handles empty input", () => {
expect(selectEvenlySpacedTicks([], 5)).toEqual([]);
});
it("never produces duplicates", () => {
const ticks = selectEvenlySpacedTicks([0, 1, 2], 3);
expect(new Set(ticks).size).toBe(ticks.length);
});
});
describe("selectEvenlySpacedIndices", () => {
it("includes first and last, evenly spaced", () => {
expect(selectEvenlySpacedIndices(10, 4)).toEqual([0, 3, 6, 9]);
});
it("returns all indices when count >= n", () => {
expect(selectEvenlySpacedIndices(3, 10)).toEqual([0, 1, 2]);
});
it("returns endpoints for count 2", () => {
expect(selectEvenlySpacedIndices(50, 2)).toEqual([0, 49]);
});
it("returns [0] for count <= 1", () => {
expect(selectEvenlySpacedIndices(50, 1)).toEqual([0]);
});
it("handles empty range", () => {
expect(selectEvenlySpacedIndices(0, 5)).toEqual([]);
});
it("always ends at the last index (partial-period start stays even)", () => {
// 74 buckets, room for ~8 labels -> evenly spaced, last index present
const idx = selectEvenlySpacedIndices(74, 8);
expect(idx[0]).toBe(0);
expect(idx[idx.length - 1]).toBe(73);
// gaps are roughly uniform (no tiny first gap)
const gaps = idx.slice(1).map((v, i) => v - idx[i]);
expect(Math.min(...gaps)).toBeGreaterThanOrEqual(9);
});
});
describe("estimateMaxLabels", () => {
it("returns 0 when width is unknown", () => {
expect(estimateMaxLabels(0, 5)).toBe(0);
});
it("fits more labels in a wider chart", () => {
const narrow = estimateMaxLabels(200, 5);
const wide = estimateMaxLabels(800, 5);
expect(wide).toBeGreaterThan(narrow);
});
it("fits fewer labels when labels are wider", () => {
const shortLabels = estimateMaxLabels(400, 5);
const longLabels = estimateMaxLabels(400, 12);
expect(longLabels).toBeLessThanOrEqual(shortLabels);
});
it("always allows at least one label for a positive width", () => {
expect(estimateMaxLabels(10, 100)).toBeGreaterThanOrEqual(1);
});
});
describe("dedupeTicksByLabel", () => {
it("drops adjacent duplicate labels", () => {
const labels = ["A", "A", "B", "B", "C"];
const values = [0, 1, 2, 3, 4];
expect(dedupeTicksByLabel([0, 1, 2, 3, 4], labels, values)).toEqual([0, 2, 4]);
});
it("keeps the last index when its label repeats the previous tick (first+last contract)", () => {
// indices 6 and 9 render the same label; the naive loop would drop index 9
// and leave the right edge unlabeled. The last index must win.
const labels = ["a", "b", "c", "d", "e", "f", "X", "g", "h", "X"];
const values = labels.map((_, i) => i);
const ticks = dedupeTicksByLabel([0, 3, 6, 9], labels, values);
expect(ticks[ticks.length - 1]).toBe(9);
expect(ticks).toEqual([0, 3, 9]);
});
it("leaves already-unique labels untouched", () => {
const labels = ["Jan", "Feb", "Mar"];
const values = ["Jan", "Feb", "Mar"];
expect(dedupeTicksByLabel([0, 1, 2], labels, values)).toEqual(["Jan", "Feb", "Mar"]);
});
it("handles an empty selection", () => {
expect(dedupeTicksByLabel([], [], [])).toEqual([]);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { computeZoomRange } from "~/components/primitives/charts/ChartSyncContext";
describe("computeZoomRange", () => {
it("orders the selection ascending regardless of drag direction", () => {
expect(computeZoomRange(300, 100)).toEqual({ start: 100, end: 300 });
expect(computeZoomRange(100, 300)).toEqual({ start: 100, end: 300 });
});
it("adds the bucket width so the last selected bucket is included", () => {
expect(computeZoomRange(100, 300, 60)).toEqual({ start: 100, end: 360 });
});
it("returns null for a non-drag (start === current)", () => {
expect(computeZoomRange(100, 100)).toBeNull();
expect(computeZoomRange(100, 100, 60)).toBeNull();
});
it("returns null for non-numeric selections", () => {
expect(computeZoomRange("a", "b")).toBeNull();
});
it("handles numeric-string category values from recharts", () => {
expect(computeZoomRange("100", "300", 60)).toEqual({ start: 100, end: 360 });
});
});
@@ -0,0 +1,246 @@
// Plan F.3: integration test that round-trips a `ChatSnapshotV1` blob
// through the SDK's snapshot helpers + a real MinIO backing store. Mirrors
// the testcontainer pattern from `objectStore.test.ts`.
//
// What this verifies end-to-end:
// - SDK's `writeChatSnapshot` calls `apiClient.createUploadPayloadUrl`
// to mint a presigned PUT, then PUTs JSON to it.
// - SDK's `readChatSnapshot` calls `apiClient.getPayloadUrl` to mint a
// presigned GET, then fetches and parses.
// - The webapp's `generatePresignedUrl` produces URLs MinIO accepts.
// - The blob round-trips with `version: 1` shape preserved.
// - 404 (no snapshot for a fresh session) returns `undefined`, not an
// error.
//
// This is the integration safety net behind the unit tests in
// `packages/trigger-sdk/test/chat-snapshot.test.ts` — those tests mock
// `fetch`; this one drives a real S3-compatible backend.
import { postgresAndMinioTest } from "@internal/testcontainers";
import { apiClientManager } from "@trigger.dev/core/v3";
import {
__readChatSnapshotProductionPathForTests as readChatSnapshot,
__writeChatSnapshotProductionPathForTests as writeChatSnapshot,
type ChatSnapshotV1,
} from "@trigger.dev/sdk/ai";
import type { UIMessage } from "ai";
import { afterEach, describe, expect, vi } from "vitest";
import { env } from "~/env.server";
import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server";
import { generatePresignedUrl } from "~/v3/objectStore.server";
vi.setConfig({ testTimeout: 60_000 });
// ── Helpers ────────────────────────────────────────────────────────────
function makeSnapshot(
opts: { messages?: UIMessage[]; lastOutEventId?: string } = {}
): ChatSnapshotV1 {
return {
version: 1,
savedAt: 1_700_000_000_000,
messages: opts.messages ?? [
{
id: "u-1",
role: "user",
parts: [{ type: "text", text: "hello" }],
},
{
id: "a-1",
role: "assistant",
parts: [{ type: "text", text: "world" }],
},
],
lastOutEventId: opts.lastOutEventId ?? "evt-42",
};
}
/**
* Stub `apiClientManager.clientOrThrow()` so the SDK helpers see a fake
* api client. Mirrors the snapshot-url route: derive the canonical
* `sessions/{id}/snapshot.json` key (with optional default-protocol
* prefix) and sign it via `generatePresignedUrl` against MinIO.
*/
function stubApiClient(opts: { projectRef: string; envSlug: string }) {
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
async getChatSnapshotUrl(sessionId: string) {
const key = chatSnapshotStoragePathForSession(sessionId);
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "GET");
if (!result.success) throw new Error(result.error);
return { presignedUrl: result.url };
},
async createChatSnapshotUploadUrl(sessionId: string) {
const key = chatSnapshotStoragePathForSession(sessionId);
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "PUT");
if (!result.success) throw new Error(result.error);
return { presignedUrl: result.url };
},
} as never);
}
// Suppress noisy warnings from logger.warn during error-path tests.
let warnSpy: ReturnType<typeof vi.spyOn>;
afterEach(() => {
vi.restoreAllMocks();
warnSpy?.mockRestore();
});
// ── Tests ──────────────────────────────────────────────────────────────
describe("chat snapshot integration (MinIO + SDK helpers)", () => {
postgresAndMinioTest("round-trips a snapshot through real MinIO", async ({ minioConfig }) => {
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
env.OBJECT_STORE_REGION = minioConfig.region;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
stubApiClient({ projectRef: "proj_snap_rt", envSlug: "dev" });
const sessionId = "sess_round_trip_1";
const snapshot = makeSnapshot();
// Write through the SDK helper — should land in MinIO at
// `packets/proj_snap_rt/dev/sessions/sess_round_trip_1/snapshot.json`.
await writeChatSnapshot(sessionId, snapshot);
// Read back through the SDK helper — should reconstruct the original.
const result = await readChatSnapshot(sessionId);
expect(result).toEqual(snapshot);
});
postgresAndMinioTest(
"returns undefined for a fresh session with no snapshot",
async ({ minioConfig }) => {
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
env.OBJECT_STORE_REGION = minioConfig.region;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
stubApiClient({ projectRef: "proj_snap_404", envSlug: "dev" });
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
// Session never had a snapshot written — read returns undefined.
const result = await readChatSnapshot("sess_never_existed");
expect(result).toBeUndefined();
}
);
postgresAndMinioTest(
"overwrites a prior snapshot in place (single-writer)",
async ({ minioConfig }) => {
// The runtime guarantees one attempt alive at a time, and
// `writeChatSnapshot` runs awaited after `onTurnComplete`. Verify
// that a second write to the same key replaces the first cleanly —
// the read-after-write reflects the latest blob.
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
env.OBJECT_STORE_REGION = minioConfig.region;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
stubApiClient({ projectRef: "proj_snap_overwrite", envSlug: "dev" });
const sessionId = "sess_overwrite";
const turn1 = makeSnapshot({
messages: [{ id: "u-1", role: "user", parts: [{ type: "text", text: "first" }] }],
lastOutEventId: "evt-turn1",
});
const turn2 = makeSnapshot({
messages: [
{ id: "u-1", role: "user", parts: [{ type: "text", text: "first" }] },
{ id: "a-1", role: "assistant", parts: [{ type: "text", text: "reply-1" }] },
{ id: "u-2", role: "user", parts: [{ type: "text", text: "second" }] },
{ id: "a-2", role: "assistant", parts: [{ type: "text", text: "reply-2" }] },
],
lastOutEventId: "evt-turn2",
});
await writeChatSnapshot(sessionId, turn1);
await writeChatSnapshot(sessionId, turn2);
const result = await readChatSnapshot(sessionId);
expect(result).toEqual(turn2);
expect(result?.messages).toHaveLength(4);
expect(result?.lastOutEventId).toBe("evt-turn2");
}
);
postgresAndMinioTest(
"isolates snapshots by sessionId (no cross-talk)",
async ({ minioConfig }) => {
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
env.OBJECT_STORE_REGION = minioConfig.region;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
stubApiClient({ projectRef: "proj_snap_iso", envSlug: "dev" });
const sessA = "sess_iso_A";
const sessB = "sess_iso_B";
const snapA = makeSnapshot({ lastOutEventId: "evt-A" });
const snapB = makeSnapshot({ lastOutEventId: "evt-B" });
await writeChatSnapshot(sessA, snapA);
await writeChatSnapshot(sessB, snapB);
const readA = await readChatSnapshot(sessA);
const readB = await readChatSnapshot(sessB);
expect(readA?.lastOutEventId).toBe("evt-A");
expect(readB?.lastOutEventId).toBe("evt-B");
// Distinct objects — modifying one shouldn't affect the other.
expect(readA?.lastOutEventId).not.toBe(readB?.lastOutEventId);
}
);
postgresAndMinioTest(
"handles snapshots with large message lists (~50 messages)",
async ({ minioConfig }) => {
// Stress test: a 50-turn chat snapshot. Plan F.4 mentions the
// pre-change baseline grew past 512 KiB around turn 10-30 with tool
// use; the post-slim wire keeps wire payloads small but the snapshot
// itself can still get large. Verify the helpers handle a realistic
// payload size.
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
env.OBJECT_STORE_REGION = minioConfig.region;
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
stubApiClient({ projectRef: "proj_snap_big", envSlug: "dev" });
const messages: UIMessage[] = [];
for (let i = 0; i < 50; i++) {
messages.push({
id: `u-${i}`,
role: "user",
parts: [{ type: "text", text: `user message ${i}: ${"x".repeat(200)}` }],
});
messages.push({
id: `a-${i}`,
role: "assistant",
parts: [{ type: "text", text: `assistant reply ${i}: ${"y".repeat(500)}` }],
});
}
const snapshot = makeSnapshot({ messages, lastOutEventId: "evt-50" });
await writeChatSnapshot("sess_big_chat", snapshot);
const result = await readChatSnapshot("sess_big_chat");
expect(result).toBeDefined();
expect(result!.messages).toHaveLength(100);
expect(result!.lastOutEventId).toBe("evt-50");
// Spot-check ordering integrity — the messages array round-tripped
// in the same order.
expect(result!.messages[0]!.id).toBe("u-0");
expect(result!.messages[99]!.id).toBe("a-49");
}
);
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import type { RbacAbility } from "@trigger.dev/rbac";
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
const permissive: RbacAbility = { can: () => true, canSuper: () => false };
const denyAll: RbacAbility = { can: () => false, canSuper: () => false };
describe("checkPermissions", () => {
it("returns true for every check under a permissive ability (OSS path)", () => {
const result = checkPermissions(permissive, {
canCancelRun: { action: "write", resource: { type: "runs" } },
canManageMembers: { action: "manage", resource: { type: "members" } },
});
expect(result).toEqual({ canCancelRun: true, canManageMembers: true });
});
it("returns false for every check under a deny-all ability", () => {
const result = checkPermissions(denyAll, {
canCancelRun: { action: "write", resource: { type: "runs" } },
});
expect(result).toEqual({ canCancelRun: false });
});
it("evaluates each check independently against can()", () => {
const ability: RbacAbility = {
can: (action, resource) => {
const r = Array.isArray(resource) ? resource[0] : resource;
return action === "read" || r.type === "tasks";
},
canSuper: () => false,
};
const result = checkPermissions(ability, {
readRuns: { action: "read", resource: { type: "runs" } },
writeRuns: { action: "write", resource: { type: "runs" } },
writeTasks: { action: "write", resource: { type: "tasks" } },
});
expect(result).toEqual({ readRuns: true, writeRuns: false, writeTasks: true });
});
it("supports requireSuper checks via canSuper()", () => {
const admin: RbacAbility = { can: () => false, canSuper: () => true };
expect(checkPermissions(admin, { adminOnly: { requireSuper: true } })).toEqual({
adminOnly: true,
});
expect(checkPermissions(denyAll, { adminOnly: { requireSuper: true } })).toEqual({
adminOnly: false,
});
});
it("passes resource arrays straight through to can()", () => {
const seen: unknown[] = [];
const ability: RbacAbility = {
can: (_action, resource) => {
seen.push(resource);
return true;
},
canSuper: () => false,
};
checkPermissions(ability, {
x: { action: "read", resource: [{ type: "runs" }, { type: "tasks" }] },
});
expect(seen[0]).toEqual([{ type: "runs" }, { type: "tasks" }]);
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect } from "vitest";
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
import { postgresTest } from "@internal/testcontainers";
import { OrganizationDataStoresRegistry } from "~/services/dataStores/organizationDataStoresRegistry.server";
import { ClickhouseConnectionSchema } from "~/services/clickhouse/clickhouseSecretSchemas.server";
import { ClickhouseFactory } from "~/services/clickhouse/clickhouseFactory.server";
vi.setConfig({ testTimeout: 60_000 });
const TEST_URL = "https://default:password@ch-org.example.com:8443";
const TEST_URL_2 = "https://default:password@ch-other.example.com:8443";
describe("ClickHouse Factory", () => {
postgresTest("returns default client when org has no data store", async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma, prisma);
await registry.loadFromDatabase();
const factory = new ClickhouseFactory(registry);
const client = await factory.getClickhouseForOrganization("org-no-store", "standard");
expect(client).toBeDefined();
});
postgresTest(
"returns org-specific client when a data store is configured",
async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma, prisma);
await registry.addDataStore({
key: "factory-store",
kind: "CLICKHOUSE",
organizationIds: ["org-custom"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL }),
});
await registry.loadFromDatabase();
const factory = new ClickhouseFactory(registry);
const client = await factory.getClickhouseForOrganization("org-custom", "standard");
expect(client).toBeDefined();
// Default client is a different instance from the org-specific one
const defaultClient = await factory.getClickhouseForOrganization("org-no-store", "standard");
expect(client).not.toBe(defaultClient);
}
);
postgresTest(
"two orgs sharing the same data store get the same cached client",
async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma, prisma);
await registry.addDataStore({
key: "shared-factory-store",
kind: "CLICKHOUSE",
organizationIds: ["org-shared-1", "org-shared-2"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL }),
});
await registry.loadFromDatabase();
const factory = new ClickhouseFactory(registry);
const client1 = await factory.getClickhouseForOrganization("org-shared-1", "standard");
const client2 = await factory.getClickhouseForOrganization("org-shared-2", "standard");
// Same hostname → same cached client instance
expect(client1).toBe(client2);
}
);
postgresTest(
"two data stores with different URLs produce different clients",
async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma, prisma);
await registry.addDataStore({
key: "store-a",
kind: "CLICKHOUSE",
organizationIds: ["org-a"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL }),
});
await registry.addDataStore({
key: "store-b",
kind: "CLICKHOUSE",
organizationIds: ["org-b"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL_2 }),
});
await registry.loadFromDatabase();
const factory = new ClickhouseFactory(registry);
const clientA = await factory.getClickhouseForOrganization("org-a", "standard");
const clientB = await factory.getClickhouseForOrganization("org-b", "standard");
expect(clientA).not.toBe(clientB);
}
);
postgresTest(
"after reload with a deleted store, org falls back to default",
async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma, prisma);
await registry.addDataStore({
key: "removable-store",
kind: "CLICKHOUSE",
organizationIds: ["org-removable"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL }),
});
await registry.loadFromDatabase();
const factory = new ClickhouseFactory(registry);
const before = await factory.getClickhouseForOrganization("org-removable", "standard");
const defaultClient = await factory.getClickhouseForOrganization("org-no-store", "standard");
expect(before).not.toBe(defaultClient);
await registry.deleteDataStore({ key: "removable-store", kind: "CLICKHOUSE" });
await registry.reload();
const after = await factory.getClickhouseForOrganization("org-removable", "standard");
expect(after).toBe(defaultClient);
}
);
});
@@ -0,0 +1,96 @@
import { describe, it, expect } from "vitest";
import { formatDateTimeISO, formatUtcOffset } from "~/components/primitives/DateTime";
describe("formatDateTimeISO", () => {
it("should format UTC dates with Z suffix", () => {
const date = new Date("2025-04-29T14:01:19.000Z");
const result = formatDateTimeISO(date, "UTC");
expect(result).toBe("2025-04-29T14:01:19.000Z");
});
describe("British Time (Europe/London)", () => {
it("should format with +01:00 during BST (summer)", () => {
// BST - British Summer Time (last Sunday in March to last Sunday in October)
const summerDate = new Date("2025-07-15T14:01:19.000Z");
const result = formatDateTimeISO(summerDate, "Europe/London");
expect(result).toBe("2025-07-15T15:01:19.000+01:00");
});
it("should format with +00:00 during GMT (winter)", () => {
// GMT - Greenwich Mean Time (winter)
const winterDate = new Date("2025-01-15T14:01:19.000Z");
const result = formatDateTimeISO(winterDate, "Europe/London");
expect(result).toBe("2025-01-15T14:01:19.000+00:00");
});
});
describe("US Pacific Time (America/Los_Angeles)", () => {
it("should format with -07:00 during PDT (summer)", () => {
// PDT - Pacific Daylight Time (second Sunday in March to first Sunday in November)
const summerDate = new Date("2025-07-15T14:01:19.000Z");
const result = formatDateTimeISO(summerDate, "America/Los_Angeles");
expect(result).toBe("2025-07-15T07:01:19.000-07:00");
});
it("should format with -08:00 during PST (winter)", () => {
// PST - Pacific Standard Time (winter)
const winterDate = new Date("2025-01-15T14:01:19.000Z");
const result = formatDateTimeISO(winterDate, "America/Los_Angeles");
expect(result).toBe("2025-01-15T06:01:19.000-08:00");
});
});
it("should preserve milliseconds", () => {
const date = new Date("2025-04-29T14:01:19.123Z");
const result = formatDateTimeISO(date, "UTC");
expect(result).toBe("2025-04-29T14:01:19.123Z");
});
it("should preserve milliseconds, not UTC", () => {
const date = new Date("2025-04-29T14:01:19.123Z");
const result = formatDateTimeISO(date, "Europe/London");
expect(result).toBe("2025-04-29T15:01:19.123+01:00");
});
});
describe("formatUtcOffset", () => {
const date = new Date("2026-06-30T13:16:26.000Z");
it("returns an empty string for UTC", () => {
expect(formatUtcOffset(date, "UTC")).toBe("");
});
it("returns an empty offset for UTC-equivalent zones", () => {
expect(formatUtcOffset(date, "Atlantic/Reykjavik")).toBe("(UTC +0)");
});
// The reported bug: the offset label must reflect the displayed timezone, not the
// viewer's machine. A viewer on a UTC machine looking at a UTC+3 zone must see +3.
it("reflects the timezone being displayed, not the viewer's machine", () => {
expect(formatUtcOffset(date, "Europe/Moscow")).toBe("(UTC +3)");
});
it("formats half-hour offsets", () => {
expect(formatUtcOffset(date, "Asia/Kolkata")).toBe("(UTC +5:30)");
});
it("formats negative offsets", () => {
expect(formatUtcOffset(date, "America/Los_Angeles")).toBe("(UTC -7)");
});
// The offset is derived from the given instant, so it stays correct across DST
// boundaries regardless of what season the viewer is currently in.
describe("is DST-aware for the given instant", () => {
it("uses +0 for a London winter date", () => {
expect(formatUtcOffset(new Date("2026-01-15T12:00:00.000Z"), "Europe/London")).toBe(
"(UTC +0)"
);
});
it("uses +1 for a London summer date", () => {
expect(formatUtcOffset(new Date("2026-07-15T12:00:00.000Z"), "Europe/London")).toBe(
"(UTC +1)"
);
});
});
});
@@ -0,0 +1,327 @@
import { describe, it, expect } from "vitest";
import { createTSQLCompletion } from "~/components/code/tsql/tsqlCompletion";
import type { TableSchema } from "@internal/tsql";
// Helper to create a mock completion context
function createMockContext(doc: string, pos: number, explicit = false) {
return {
state: {
doc: {
toString: () => doc,
sliceString: (from: number, to: number) => doc.slice(from, to),
},
},
pos,
explicit,
matchBefore: (regex: RegExp) => {
const beforePos = doc.slice(0, pos);
const match = beforePos.match(new RegExp(regex.source + "$"));
if (match) {
return {
from: pos - match[0].length,
to: pos,
text: match[0],
};
}
return null;
},
} as any;
}
// Test schema with enum columns
const testSchema: TableSchema[] = [
{
name: "runs",
clickhouseName: "trigger_dev.task_runs_v2",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
description: "Task runs table",
columns: {
id: { name: "id", type: "String", description: "Run ID" },
status: {
name: "status",
type: "LowCardinality(String)",
description: "Run status",
allowedValues: ["Completed", "Failed", "Queued", "Executing"],
},
machine: {
name: "machine",
type: "LowCardinality(String)",
description: "Machine preset",
allowedValues: ["micro", "small-1x", "small-2x", "medium-1x"],
},
environment_type: {
name: "environment_type",
type: "LowCardinality(String)",
description: "Environment type",
allowedValues: ["PRODUCTION", "STAGING", "DEVELOPMENT", "PREVIEW"],
},
created_at: { name: "created_at", type: "DateTime64", description: "Creation time" },
organization_id: { name: "organization_id", type: "String" },
project_id: { name: "project_id", type: "String" },
environment_id: { name: "environment_id", type: "String" },
},
},
{
name: "logs",
clickhouseName: "trigger_dev.task_events_v2",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
description: "Task logs table",
columns: {
id: { name: "id", type: "String" },
run_id: { name: "run_id", type: "String" },
message: { name: "message", type: "String" },
level: { name: "level", type: "String" },
timestamp: { name: "timestamp", type: "DateTime64" },
organization_id: { name: "organization_id", type: "String" },
project_id: { name: "project_id", type: "String" },
environment_id: { name: "environment_id", type: "String" },
},
},
];
describe("createTSQLCompletion", () => {
const completionSource = createTSQLCompletion(testSchema);
it("should return null for empty input without explicit trigger", () => {
const context = createMockContext("", 0, false);
const result = completionSource(context);
expect(result).toBeNull();
});
it("should return completions when explicitly triggered", () => {
const context = createMockContext("", 0, true);
const result = completionSource(context);
expect(result).not.toBeNull();
expect(result?.options.length).toBeGreaterThan(0);
});
it("should include tables in completions", () => {
// When typing after FROM, tables should be available
const doc = "SELECT * FROM r";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const tableLabels = result?.options.map((o) => o.label);
// Tables should always be available in completions
expect(tableLabels).toContain("runs");
expect(tableLabels).toContain("logs");
});
it("should suggest columns after SELECT keyword", () => {
const doc = "SELECT FROM runs";
// Position cursor right after SELECT
const pos = 7;
const context = createMockContext(doc, pos, true);
const result = completionSource(context);
expect(result).not.toBeNull();
// Should include functions
const labels = result?.options.map((o) => o.label) || [];
expect(labels.some((l) => l === "count")).toBe(true);
expect(labels.some((l) => l === "sum")).toBe(true);
});
it("should suggest columns with table prefix for qualified references", () => {
const doc = "SELECT runs. FROM runs";
// Position cursor right after "runs."
const pos = 12;
const context = createMockContext(doc, pos, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const columnLabels = result?.options.map((o) => o.label);
expect(columnLabels).toContain("id");
expect(columnLabels).toContain("status");
expect(columnLabels).toContain("created_at");
});
it("should include SQL keywords in general context", () => {
const doc = "S";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const labels = result?.options.map((o) => o.label);
expect(labels).toContain("SELECT");
});
it("should include aggregate functions", () => {
const doc = "SELECT ";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const labels = result?.options.map((o) => o.label);
expect(labels).toContain("count");
expect(labels).toContain("sum");
expect(labels).toContain("avg");
expect(labels).toContain("min");
expect(labels).toContain("max");
});
it("should handle WHERE clause context", () => {
const doc = "SELECT * FROM runs WHERE ";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
// Should suggest columns
const labels = result?.options.map((o) => o.label) || [];
expect(labels).toContain("status");
// Should include conditional keywords
expect(labels).toContain("AND");
expect(labels).toContain("OR");
});
describe("enum value completions", () => {
it("should suggest enum values after = operator", () => {
const doc = "SELECT * FROM runs WHERE status = ";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const labels = result?.options.map((o) => o.label) || [];
expect(labels).toContain("'Completed'");
expect(labels).toContain("'Failed'");
expect(labels).toContain("'Queued'");
expect(labels).toContain("'Executing'");
});
it("should suggest enum values after = with opening quote", () => {
const doc = "SELECT * FROM runs WHERE status = '";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const labels = result?.options.map((o) => o.label) || [];
expect(labels).toContain("'Completed'");
expect(labels).toContain("'Failed'");
});
it("should suggest enum values with partial input", () => {
const doc = "SELECT * FROM runs WHERE status = 'Comp";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const labels = result?.options.map((o) => o.label) || [];
expect(labels).toContain("'Completed'");
});
it("should suggest enum values for machine column", () => {
const doc = "SELECT * FROM runs WHERE machine = ";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const labels = result?.options.map((o) => o.label) || [];
expect(labels).toContain("'micro'");
expect(labels).toContain("'small-1x'");
expect(labels).toContain("'medium-1x'");
});
it("should suggest enum values for environment_type column", () => {
const doc = "SELECT * FROM runs WHERE environment_type = ";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const labels = result?.options.map((o) => o.label) || [];
expect(labels).toContain("'PRODUCTION'");
expect(labels).toContain("'STAGING'");
expect(labels).toContain("'DEVELOPMENT'");
});
it("should suggest enum values after != operator", () => {
const doc = "SELECT * FROM runs WHERE status != ";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const labels = result?.options.map((o) => o.label) || [];
expect(labels).toContain("'Completed'");
expect(labels).toContain("'Failed'");
});
it("should suggest enum values after IN (", () => {
const doc = "SELECT * FROM runs WHERE status IN (";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const labels = result?.options.map((o) => o.label) || [];
expect(labels).toContain("'Completed'");
expect(labels).toContain("'Failed'");
});
it("should suggest enum values after comma in IN clause", () => {
const doc = "SELECT * FROM runs WHERE status IN ('Completed', ";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const labels = result?.options.map((o) => o.label) || [];
expect(labels).toContain("'Failed'");
expect(labels).toContain("'Queued'");
});
it("should include closing quote in replacement range when auto-paired", () => {
// Simulate cursor between auto-paired quotes: status = '|'
const doc = "SELECT * FROM runs WHERE status = ''";
const pos = doc.length - 1; // Cursor is between the quotes
const context = createMockContext(doc, pos, true);
const result = completionSource(context);
expect(result).not.toBeNull();
// The 'to' should extend past the cursor to include the closing quote
expect(result?.to).toBe(pos + 1);
});
it("should not extend 'to' when no closing quote present", () => {
const doc = "SELECT * FROM runs WHERE status = '";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
// 'to' should be undefined (or not set) since there's no closing quote
expect(result?.to).toBeUndefined();
});
it("should not suggest enum values for columns without allowedValues", () => {
const doc = "SELECT * FROM runs WHERE id = ";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
// Should return empty options for value context with non-enum column
const labels = result?.options.map((o) => o.label) || [];
expect(labels).not.toContain("'Completed'");
expect(labels.length).toBe(0);
});
});
});
@@ -0,0 +1,76 @@
import { describe, it, expect } from "vitest";
import { isValidTSQLQuery, getTSQLError } from "~/components/code/tsql/tsqlLinter";
describe("tsqlLinter", () => {
describe("isValidTSQLQuery", () => {
it("should return true for empty queries", () => {
expect(isValidTSQLQuery("")).toBe(true);
expect(isValidTSQLQuery(" ")).toBe(true);
});
it("should return true for valid SELECT queries", () => {
expect(isValidTSQLQuery("SELECT * FROM users")).toBe(true);
expect(isValidTSQLQuery("SELECT id, name FROM users WHERE status = 'active'")).toBe(true);
expect(isValidTSQLQuery("SELECT count(*) FROM users GROUP BY status")).toBe(true);
});
it("should return true for queries with ORDER BY", () => {
expect(isValidTSQLQuery("SELECT * FROM users ORDER BY created_at DESC")).toBe(true);
});
it("should return true for queries with LIMIT", () => {
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10")).toBe(true);
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10 OFFSET 20")).toBe(true);
});
it("should return true for queries with JOINs", () => {
expect(isValidTSQLQuery("SELECT * FROM users JOIN orders ON users.id = orders.user_id")).toBe(
true
);
expect(
isValidTSQLQuery("SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id")
).toBe(true);
});
it("should return false for invalid syntax", () => {
expect(isValidTSQLQuery("SELEC * FROM users")).toBe(false);
expect(isValidTSQLQuery("SELECT * FORM users")).toBe(false);
expect(isValidTSQLQuery("SELECT FROM users")).toBe(false);
});
it("should return false for incomplete queries", () => {
expect(isValidTSQLQuery("SELECT * FROM")).toBe(false);
expect(isValidTSQLQuery("SELECT")).toBe(false);
});
});
describe("getTSQLError", () => {
it("should return null for empty queries", () => {
expect(getTSQLError("")).toBeNull();
expect(getTSQLError(" ")).toBeNull();
});
it("should return null for valid queries", () => {
expect(getTSQLError("SELECT * FROM users")).toBeNull();
expect(getTSQLError("SELECT id, name FROM users WHERE id = 1")).toBeNull();
});
it("should return error message for invalid queries", () => {
const error = getTSQLError("SELEC * FROM users");
expect(error).not.toBeNull();
expect(typeof error).toBe("string");
});
it("should include position information in error", () => {
const error = getTSQLError("SELECT * FORM users");
expect(error).not.toBeNull();
// Error message should contain line/column info
expect(error).toContain("line");
});
it("should handle unclosed string literals", () => {
const error = getTSQLError("SELECT * FROM users WHERE name = 'test");
expect(error).not.toBeNull();
});
});
});
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { splitTag } from "~/components/runs/v3/RunTag";
describe("splitTag", () => {
it("should return the original string when no separator is found", () => {
expect(splitTag("simpletag")).toBe("simpletag");
expect(splitTag("tag-with-dashes")).toBe("tag-with-dashes");
expect(splitTag("tag.with.dots")).toBe("tag.with.dots");
});
it("should return the original string when key is longer than 12 characters", () => {
expect(splitTag("verylongcategory:prod")).toBe("verylongcategory:prod");
expect(splitTag("verylongcategory_prod")).toBe("verylongcategory_prod");
});
it("should split tag with underscore separator", () => {
expect(splitTag("env_prod")).toEqual({ key: "env", value: "prod" });
expect(splitTag("category_batch")).toEqual({ key: "category", value: "batch" });
});
it("should split tag with colon separator", () => {
expect(splitTag("env:prod")).toEqual({ key: "env", value: "prod" });
expect(splitTag("category:batch")).toEqual({ key: "category", value: "batch" });
expect(splitTag("customer:test_customer")).toEqual({ key: "customer", value: "test_customer" });
});
it("should handle mixed delimiters", () => {
expect(splitTag("category:batch_job")).toEqual({ key: "category", value: "batch_job" });
expect(splitTag("status_error:500")).toEqual({ key: "status", value: "error:500" });
});
it("should preserve common ID formats", () => {
expect(splitTag("job_123_456")).toBe("job_123_456");
expect(splitTag("run:123:456")).toBe("run:123:456");
expect(splitTag("task123_job_456")).toBe("task123_job_456");
});
it("should return original string when multiple separators are found", () => {
expect(splitTag("env:prod:test")).toBe("env:prod:test");
expect(splitTag("env_prod_test")).toBe("env_prod_test");
});
it("should handle edge case with exactly 12 character key", () => {
expect(splitTag("abcdefghijkl:value")).toEqual({ key: "abcdefghijkl", value: "value" });
expect(splitTag("exactlytwelv_chars")).toEqual({ key: "exactlytwelv", value: "chars" });
});
it("should handle empty values", () => {
expect(splitTag("empty:")).toEqual({ key: "empty", value: "" });
expect(splitTag("nothing_")).toEqual({ key: "nothing", value: "" });
});
it("should handle special characters in values", () => {
expect(splitTag("region:us-west-2")).toEqual({ key: "region", value: "us-west-2" });
expect(splitTag("query:SELECT * FROM users")).toEqual({
key: "query",
value: "SELECT * FROM users",
});
expect(splitTag("path:/api/v1/users")).toEqual({ key: "path", value: "/api/v1/users" });
});
it("should handle values containing numbers and special formats", () => {
expect(splitTag("uuid:123e4567-e89b-12d3-a456-426614174000")).toEqual({
key: "uuid",
value: "123e4567-e89b-12d3-a456-426614174000",
});
expect(splitTag("ip_192.168.1.1")).toEqual({ key: "ip", value: "192.168.1.1" });
expect(splitTag("date:2023-04-01T12:00:00Z")).toEqual({
key: "date",
value: "2023-04-01T12:00:00Z",
});
});
it("should handle keys with numbers", () => {
expect(splitTag("env2:staging")).toEqual({ key: "env2", value: "staging" });
expect(splitTag("v1_endpoint")).toEqual({ key: "v1", value: "endpoint" });
});
it("should handle particularly complex mixed cases", () => {
expect(splitTag("env:prod_us-west-2_replica")).toEqual({
key: "env",
value: "prod_us-west-2_replica",
});
expect(splitTag("status_error:connection:timeout")).toEqual({
key: "status",
value: "error:connection:timeout",
});
});
});
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { toSafeUrl } from "~/components/runs/v3/agent/AgentMessageView";
describe("toSafeUrl", () => {
it("allows http(s) and blob URLs", () => {
expect(toSafeUrl("https://example.com/x")).toBe("https://example.com/x");
expect(toSafeUrl("http://example.com/x")).toBe("http://example.com/x");
expect(toSafeUrl("blob:https://example.com/uuid")).toBe("blob:https://example.com/uuid");
});
it("rejects javascript: and other dangerous schemes", () => {
expect(toSafeUrl("javascript:alert(1)")).toBeNull();
expect(toSafeUrl("JavaScript:alert(1)")).toBeNull();
expect(toSafeUrl("vbscript:msgbox(1)")).toBeNull();
expect(toSafeUrl("file:///etc/passwd")).toBeNull();
});
it("rejects data: URLs unless inline images are explicitly allowed", () => {
const dataImage = "data:image/png;base64,iVBORw0KGgo=";
expect(toSafeUrl(dataImage)).toBeNull();
expect(toSafeUrl(dataImage, true)).toBe(dataImage);
// Only image data is allowed, even in image context — never data:text/html.
expect(toSafeUrl("data:text/html,<script>alert(1)</script>", true)).toBeNull();
});
it("rejects relative URLs and non-string/malformed input", () => {
expect(toSafeUrl("/relative/path")).toBeNull();
expect(toSafeUrl("not a url")).toBeNull();
expect(toSafeUrl(undefined)).toBeNull();
expect(toSafeUrl(null)).toBeNull();
expect(toSafeUrl(42)).toBeNull();
});
});
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import cuid from "cuid";
import { hashBucket } from "~/utils/computeBucket";
describe("hashBucket", () => {
it("returns a stable value in [0, 100) for the same id", () => {
const a = hashBucket("org_abc");
const b = hashBucket("org_abc");
expect(a).toBe(b);
expect(a).toBeGreaterThanOrEqual(0);
expect(a).toBeLessThan(100);
});
it("is nested: the set enrolled at 1% is a subset of the set at 5%", () => {
const ids = Array.from({ length: 5000 }, (_, i) => `org_${i}`);
const at1 = new Set(ids.filter((id) => hashBucket(id) < 1));
const at5 = ids.filter((id) => hashBucket(id) < 5);
for (const id of at1) {
expect(at5).toContain(id);
}
});
it("distributes roughly uniformly", () => {
const ids = Array.from({ length: 10000 }, (_, i) => `org_${i}`);
const under10 = ids.filter((id) => hashBucket(id) < 10).length;
expect(under10).toBeGreaterThan(700);
expect(under10).toBeLessThan(1300);
});
// Org ids are `@default(cuid())` primary keys (e.g. "cjld2cjxh0000qzrmn831i7rn"),
// not the synthetic sequential ids above. cuids share a "c" prefix + timestamp/counter
// structure, so verify the hash still spreads *real-shaped* ids evenly across deciles
// (so a percentage dial maps to ~that fraction of actual orgs, not just of the id space).
it("distributes cuids evenly across all 10 deciles", () => {
const ids = Array.from({ length: 20000 }, () => cuid());
const counts = new Array(10).fill(0);
for (const id of ids) {
counts[Math.floor(hashBucket(id) / 10)]++;
}
// Expected ~2000 per decile; allow a wide band so it isn't flaky.
for (const count of counts) {
expect(count).toBeGreaterThan(1700);
expect(count).toBeLessThan(2300);
}
});
});
+146
View File
@@ -0,0 +1,146 @@
import { describe, it, expect } from "vitest";
import {
isOrgMigrated,
resolveComputeMigration,
} from "~/runEngine/concerns/computeMigration.server";
describe("isOrgMigrated", () => {
const base = {
planType: "free" as string | undefined,
orgFeatureFlags: {} as Record<string, unknown>,
flags: { computeMigrationEnabled: true, computeMigrationFreePercentage: 100 },
};
it("migrates a free org at 100%", () => {
expect(isOrgMigrated({ ...base, orgId: "org_x" })).toBe(true);
});
it("does not migrate when globally disabled", () => {
expect(
isOrgMigrated({
...base,
orgId: "org_x",
flags: { computeMigrationEnabled: false, computeMigrationFreePercentage: 100 },
})
).toBe(false);
});
it("per-org override false excludes even at 100%", () => {
expect(
isOrgMigrated({
...base,
orgId: "org_x",
orgFeatureFlags: { computeMigrationEnabled: false },
})
).toBe(false);
});
it("per-org override true enrolls even when globally off", () => {
expect(
isOrgMigrated({
...base,
orgId: "org_x",
orgFeatureFlags: { computeMigrationEnabled: true },
flags: { computeMigrationEnabled: false, computeMigrationFreePercentage: 0 },
})
).toBe(true);
});
it("paid uses the paid dial", () => {
expect(
isOrgMigrated({
planType: "paid",
orgId: "org_x",
orgFeatureFlags: {},
flags: { computeMigrationEnabled: true, computeMigrationPaidPercentage: 100 },
})
).toBe(true);
});
it("enterprise is never enrolled by percentage", () => {
expect(
isOrgMigrated({
planType: "enterprise",
orgId: "org_x",
orgFeatureFlags: {},
flags: {
computeMigrationEnabled: true,
computeMigrationFreePercentage: 100,
computeMigrationPaidPercentage: 100,
},
})
).toBe(false);
});
it("undefined planType is not enrolled", () => {
expect(
isOrgMigrated({
planType: undefined,
orgId: "org_x",
orgFeatureFlags: {},
flags: { computeMigrationEnabled: true },
})
).toBe(false);
});
});
describe("resolveComputeMigration", () => {
const enrolled = {
planType: "free",
orgFeatureFlags: {},
flags: { computeMigrationEnabled: true, computeMigrationFreePercentage: 100 },
envType: "PRODUCTION",
baseEnableFastPath: false,
region: "us-east-1",
};
const backing = { workerQueue: "us-east-1-next", enableFastPath: true };
it("swaps to the compute backing for an enrolled free org", () => {
expect(
resolveComputeMigration({
...enrolled,
baseWorkerQueue: "us-east-1",
orgId: "org_x",
backing,
})
).toEqual({ workerQueue: "us-east-1-next", region: "us-east-1", enableFastPath: true });
});
it("leaves the queue unchanged when there is no backing for the region (EU)", () => {
expect(
resolveComputeMigration({
...enrolled,
baseWorkerQueue: "eu-central-1",
region: "eu-central-1",
orgId: "org_x",
backing: undefined,
})
).toEqual({ workerQueue: "eu-central-1", region: "eu-central-1", enableFastPath: false });
});
it("does not migrate DEVELOPMENT", () => {
expect(
resolveComputeMigration({
...enrolled,
baseWorkerQueue: "us-east-1",
orgId: "org_x",
backing,
envType: "DEVELOPMENT",
})
).toEqual({ workerQueue: "us-east-1", region: "us-east-1", enableFastPath: false });
});
it("leaves a non-enrolled org untouched", () => {
expect(
resolveComputeMigration({
...enrolled,
baseWorkerQueue: "us-east-1",
orgId: "org_x",
backing,
flags: { computeMigrationEnabled: false },
})
).toEqual({ workerQueue: "us-east-1", region: "us-east-1", enableFastPath: false });
});
it("undefined baseWorkerQueue passes through", () => {
expect(
resolveComputeMigration({
...enrolled,
baseWorkerQueue: undefined,
region: undefined,
orgId: "org_x",
backing,
})
).toEqual({ workerQueue: undefined, region: undefined, enableFastPath: false });
});
});
@@ -0,0 +1,161 @@
import { ConcurrentFlushScheduler } from "~/services/runsReplicationService.server";
vi.setConfig({ testTimeout: 10_000 });
type TestItem = {
id: string;
event: "insert" | "update";
version: number;
};
describe("ConcurrentFlushScheduler", () => {
it("should deduplicate items by key, keeping the latest version", async () => {
const flushedBatches: TestItem[][] = [];
const scheduler = new ConcurrentFlushScheduler<TestItem>({
batchSize: 100,
flushInterval: 50,
maxConcurrency: 1,
callback: async (_flushId, batch) => {
flushedBatches.push([...batch]);
},
getKey: (item) => `${item.event}_${item.id}`,
shouldReplace: (existing, incoming) => incoming.version >= existing.version,
});
scheduler.start();
// Add items with duplicate keys but different versions
scheduler.addToBatch([
{ id: "run_1", event: "insert", version: 1 },
{ id: "run_1", event: "update", version: 2 },
{ id: "run_2", event: "insert", version: 1 },
]);
// Add more items - should merge with existing
scheduler.addToBatch([
{ id: "run_1", event: "insert", version: 3 }, // Higher version, should replace
{ id: "run_1", event: "update", version: 1 }, // Lower version, should NOT replace
{ id: "run_2", event: "update", version: 4 },
]);
// Wait for flush
await new Promise((resolve) => setTimeout(resolve, 100));
scheduler.shutdown();
// Should have flushed once with deduplicated items
expect(flushedBatches.length).toBeGreaterThanOrEqual(1);
const allFlushed = flushedBatches.flat();
// Find items by their key
const insertRun1 = allFlushed.find((i) => i.id === "run_1" && i.event === "insert");
const updateRun1 = allFlushed.find((i) => i.id === "run_1" && i.event === "update");
const insertRun2 = allFlushed.find((i) => i.id === "run_2" && i.event === "insert");
const updateRun2 = allFlushed.find((i) => i.id === "run_2" && i.event === "update");
// Verify correct versions were kept
expect(insertRun1?.version).toBe(3); // Latest version for insert_run_1
expect(updateRun1?.version).toBe(2); // Original update_run_1 (v1 didn't replace v2)
expect(insertRun2?.version).toBe(1); // Only version for insert_run_2
expect(updateRun2?.version).toBe(4); // Only version for update_run_2
});
it("should skip items where getKey returns null", async () => {
const flushedBatches: TestItem[][] = [];
const scheduler = new ConcurrentFlushScheduler<TestItem>({
batchSize: 100,
flushInterval: 50,
maxConcurrency: 1,
callback: async (_flushId, batch) => {
flushedBatches.push([...batch]);
},
getKey: (item) => {
if (!item.id) {
return null;
}
return `${item.event}_${item.id}`;
},
shouldReplace: (existing, incoming) => incoming.version >= existing.version,
});
scheduler.start();
scheduler.addToBatch([
{ id: "run_1", event: "insert", version: 1 },
{ id: "", event: "insert", version: 2 }, // Should be skipped (null key)
{ id: "run_2", event: "insert", version: 1 },
]);
await new Promise((resolve) => setTimeout(resolve, 100));
scheduler.shutdown();
const allFlushed = flushedBatches.flat();
expect(allFlushed).toHaveLength(2);
expect(allFlushed.map((i) => i.id).sort()).toEqual(["run_1", "run_2"]);
});
it("should flush when batch size threshold is reached", async () => {
const flushedBatches: TestItem[][] = [];
const scheduler = new ConcurrentFlushScheduler<TestItem>({
batchSize: 3,
flushInterval: 10000, // Long interval so timer doesn't trigger
maxConcurrency: 1,
callback: async (_flushId, batch) => {
flushedBatches.push([...batch]);
},
getKey: (item) => `${item.event}_${item.id}`,
shouldReplace: (existing, incoming) => incoming.version >= existing.version,
});
scheduler.start();
// Add 3 unique items - should trigger flush
scheduler.addToBatch([
{ id: "run_1", event: "insert", version: 1 },
{ id: "run_2", event: "insert", version: 1 },
{ id: "run_3", event: "insert", version: 1 },
]);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(flushedBatches.length).toBe(1);
expect(flushedBatches[0]).toHaveLength(3);
scheduler.shutdown();
});
it("should respect shouldReplace returning false", async () => {
const flushedBatches: TestItem[][] = [];
const scheduler = new ConcurrentFlushScheduler<TestItem>({
batchSize: 100,
flushInterval: 50,
maxConcurrency: 1,
callback: async (_flushId, batch) => {
flushedBatches.push([...batch]);
},
getKey: (item) => `${item.event}_${item.id}`,
// Never replace - first item wins
shouldReplace: () => false,
});
scheduler.start();
scheduler.addToBatch([{ id: "run_1", event: "insert", version: 10 }]);
scheduler.addToBatch([{ id: "run_1", event: "insert", version: 999 }]);
await new Promise((resolve) => setTimeout(resolve, 100));
scheduler.shutdown();
const allFlushed = flushedBatches.flat();
const insertRun1 = allFlushed.find((i) => i.id === "run_1" && i.event === "insert");
expect(insertRun1?.version).toBe(10); // First one wins
});
});
@@ -0,0 +1,65 @@
// Unit red-green for the checkpoint WAIT_FOR_BATCH replica-lag fix (createCheckpoint.server.ts).
// The service decides whether to suspend a run on `batchRun.resumedAt`; reading it from a lagging
// replica makes a just-resumed batch look unresumed -> it suspends an already-resumed run -> stall.
// The fix threads the primary (`this._prisma`) into `runStore.findBatchTaskRunByFriendlyId`. Here a
// spy runStore records which client the service passed and simulates the lag (only the primary read
// sees the fresh resumedAt): RED = no client -> stale null -> no early return; GREEN = primary -> kept alive.
import { describe, expect, it, vi } from "vitest";
vi.mock("~/services/logger.server", () => ({
logger: { debug: vi.fn(), info: vi.fn(), log: vi.fn(), error: vi.fn(), warn: vi.fn() },
}));
vi.mock("~/v3/marqs/index.server", () => ({
marqs: { replaceMessage: vi.fn(), cancelHeartbeat: vi.fn() },
}));
import { CreateCheckpointService } from "~/v3/services/createCheckpoint.server";
describe("checkpoint WAIT_FOR_BATCH reads the primary, not a lagging replica", () => {
it("threads the primary so an already-resumed batch keeps the run alive", async () => {
// A freezable attempt so control reaches the WAIT_FOR_BATCH arm. This object IS the primary the
// fix must thread into the batch read.
const prisma = {
taskRunAttempt: {
findFirst: async () => ({
id: "attempt_1",
status: "EXECUTING",
taskRunId: "run_1",
taskRun: { id: "run_1", status: "EXECUTING", runtimeEnvironmentId: "env_1" },
backgroundWorker: { id: "bw_1", deployment: { imageReference: "img:1" } },
}),
},
};
let seenClient: unknown = "NOT_CALLED";
const runStore = {
findBatchTaskRunByFriendlyId: async (
_friendlyId: string,
_environmentId: string,
_args: unknown,
client?: unknown
) => {
seenClient = client;
// Lagging replica: only a read handed the primary sees the just-committed resumedAt.
return { resumedAt: client === prisma ? new Date() : null };
},
};
const service = new CreateCheckpointService(prisma as never, {} as never, runStore as never);
let result: unknown;
try {
result = await service.call({
attemptFriendlyId: "attempt_1",
reason: { type: "WAIT_FOR_BATCH", batchFriendlyId: "batch_1" },
} as never);
} catch {
// Buggy path falls through the pre-check into checkpoint creation (unstubbed) and throws; the
// recorded client below is what distinguishes RED from GREEN.
}
expect(seenClient).toBe(prisma); // the fix: primary threaded into the batch read
expect(result).toEqual({ success: false, keepRunAlive: true }); // early-return, run kept alive
});
});
@@ -0,0 +1,128 @@
import { containerTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import {
createDeploymentWithNextVersion,
DeploymentVersionCollisionError,
} from "~/v3/services/initializeDeployment/createDeploymentWithNextVersion.server";
vi.setConfig({ testTimeout: 30_000 });
async function seedEnvironment(prisma: PrismaClient) {
const slug = `s${Math.random().toString(36).slice(2, 10)}`;
const organization = await prisma.organization.create({
data: { title: slug, slug },
});
const project = await prisma.project.create({
data: {
name: slug,
slug,
organizationId: organization.id,
externalRef: slug,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug,
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: slug,
pkApiKey: slug,
shortcode: slug,
},
});
return { organization, project, environment };
}
describe("createDeploymentWithNextVersion", () => {
containerTest(
"assigns unique sequential versions for concurrent calls in the same environment",
async ({ prisma }) => {
const { project, environment } = await seedEnvironment(prisma);
const concurrency = 5;
const results = await Promise.all(
Array.from({ length: concurrency }, (_, i) =>
createDeploymentWithNextVersion(prisma, environment.id, (nextVersion) => ({
projectId: project.id,
friendlyId: `deployment_${i}_${nextVersion}`,
shortCode: `short_${i}_${nextVersion}`,
contentHash: `hash_${i}`,
}))
)
);
// The property we care about: N concurrent racers all end up with
// distinct, persistable versions. The retry path is exercised whenever
// collisions happen (visible in the `Worker deployment version collided`
// warn logs), and the exhaustion branch is covered deterministically by
// the maxRetries: 0 test below.
const versions = results.map((d) => d.version).sort();
expect(new Set(versions).size).toBe(concurrency);
}
);
containerTest("propagates non-P2002 errors immediately without retrying", async ({ prisma }) => {
const { environment } = await seedEnvironment(prisma);
let buildDataCalls = 0;
const buildData = () => {
buildDataCalls++;
throw new Error("builder boom");
};
await expect(
createDeploymentWithNextVersion(prisma, environment.id, buildData)
).rejects.toThrow("builder boom");
expect(buildDataCalls).toBe(1);
});
containerTest(
"wraps exhausted retries in DeploymentVersionCollisionError with the P2002 as cause",
async ({ prisma }) => {
const { project, environment } = await seedEnvironment(prisma);
const concurrency = 4;
// maxRetries: 0 → no retry path. Concurrent racers all attempt the same
// version; one wins, the rest must surface the wrapped collision error
// (not a raw P2002) so Sentry can distinguish exhaustion from any other
// unique-constraint violation.
const settled = await Promise.allSettled(
Array.from({ length: concurrency }, (_, i) =>
createDeploymentWithNextVersion(
prisma,
environment.id,
(nextVersion) => ({
projectId: project.id,
friendlyId: `deployment_${i}_${nextVersion}`,
shortCode: `short_${i}_${nextVersion}`,
contentHash: `hash_${i}`,
}),
{ maxRetries: 0 }
)
)
);
const fulfilled = settled.filter((s) => s.status === "fulfilled");
const rejected = settled.filter((s): s is PromiseRejectedResult => s.status === "rejected");
expect(fulfilled.length).toBeGreaterThanOrEqual(1);
expect(rejected.length).toBeGreaterThanOrEqual(1);
for (const r of rejected) {
expect(r.reason).toBeInstanceOf(DeploymentVersionCollisionError);
const err = r.reason as DeploymentVersionCollisionError;
expect(err.environmentId).toBe(environment.id);
expect(err.attempts).toBe(1);
expect(err.lastAttemptedVersion).toMatch(/^\d{8}\.\d+$/);
expect((err.cause as { code?: string }).code).toBe("P2002");
}
}
);
});
@@ -0,0 +1,213 @@
import { heteroPostgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
computeStoreForCompletion,
selectStoreForWaitpoint,
} from "~/v3/runOpsMigration/crossSeamGuard.server";
import {
expectedCompleteWaitpointCallSites,
UNBLOCK_ROUTES,
} from "~/v3/runOpsMigration/unblockRouteCatalog";
import { WaitpointId } from "@trigger.dev/core/v3/isomorphic";
const NEW_WP = WaitpointId.toFriendlyId("0".repeat(24) + "01"); // v1 internal body → NEW
const LEGACY_WP = WaitpointId.toFriendlyId("c".repeat(25)); // 25-char internal body → LEGACY
describe("cross-seam guard — exhaustive per-route store selection", () => {
for (const route of UNBLOCK_ROUTES) {
it(`routes ${route.id} (${route.kind}) to new store for a NEW waitpoint`, () => {
const d = selectStoreForWaitpoint({ waitpointId: NEW_WP, routeKind: route.kind });
expect(d.store).toBe("new");
expect(d.residency).toBe("NEW");
});
it(`routes ${route.id} (${route.kind}) to legacy store for a LEGACY waitpoint`, () => {
const d = selectStoreForWaitpoint({ waitpointId: LEGACY_WP, routeKind: route.kind });
expect(d.store).toBe("legacy");
expect(d.residency).toBe("LEGACY");
});
}
});
describe("cross-seam guard — single-DB no-op", () => {
for (const route of UNBLOCK_ROUTES) {
it(`${route.id}: single-DB returns legacy without consulting the classifier`, () => {
const calls: string[] = [];
const d = computeStoreForCompletion(
{ waitpointId: "anything-even-unclassifiable", routeKind: route.kind },
{ splitEnabled: false, classify: (id) => (calls.push(id), "NEW") }
);
expect(d.store).toBe("legacy"); // the single store
expect(calls).toEqual([]); // classifier never consulted
});
}
});
const ENGINE_FILES = [
"internal-packages/run-engine/src/engine/index.ts",
"internal-packages/run-engine/src/engine/systems/waitpointSystem.ts",
"internal-packages/run-engine/src/engine/systems/ttlSystem.ts",
"internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts",
"internal-packages/run-engine/src/engine/systems/batchSystem.ts",
];
function repoRoot(): string {
let dir = process.cwd();
while (!existsSync(path.join(dir, "pnpm-workspace.yaml"))) {
const parent = path.dirname(dir);
if (parent === dir) throw new Error("repo root (pnpm-workspace.yaml) not found");
dir = parent;
}
return dir;
}
function tally(sites: string[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const site of sites) counts[site] = (counts[site] ?? 0) + 1;
return counts;
}
describe("cross-seam guard — CI drift guard", () => {
it("per-file completeWaitpoint( tally in source matches the catalog", () => {
const root = repoRoot();
// The regex matches tokens inside comments too — deliberate. Any textual
// addition forces catalog reconciliation, so a new call site cannot land
// without a matching entry.
const liveSites: string[] = [];
for (const file of ENGINE_FILES) {
const src = readFileSync(path.join(root, file), "utf8");
const hits = (src.match(/completeWaitpoint\(/g) ?? []).length;
for (let i = 0; i < hits; i++) liveSites.push(file);
}
const cataloguedSites = expectedCompleteWaitpointCallSites().map((s) => s.site);
expect(tally(liveSites)).toEqual(tally(cataloguedSites));
});
});
// PG14+PG17 hetero-fixture proof. The pure-selection tests above prove the guard
// SELECTS the right store; this proves the selected store corresponds to the
// DB the Waitpoint row PHYSICALLY lives in, on a REAL heterogeneous PG14+PG17
// fixture. NEVER mock. Seed Org->Project->Env (parents before children, or the
// required Waitpoint.projectId/environmentId FKs abort the insert) then the
// Waitpoint, on the matching DB ONLY: NEW residency on PG17, LEGACY on PG14. The
// cross-DB toBeNull checks then prove no ghost row leaked to the other version.
// Seed pattern copied from
// internal-packages/run-engine/src/engine/tests/crossVersionCompat.test.ts.
const FIXED_TS = "2024-01-01 00:00:00+00";
async function seedOrgProjectEnv(
p: PrismaClient,
ids: { orgId: string; projectId: string; envId: string }
): Promise<void> {
await p.$executeRawUnsafe(
`INSERT INTO "Organization" ("id","slug","title","createdAt","updatedAt")
VALUES ($1,$2,$3,$4::timestamptz,$4::timestamptz)`,
ids.orgId,
`${ids.orgId}-slug`,
`${ids.orgId}-title`,
FIXED_TS
);
await p.$executeRawUnsafe(
`INSERT INTO "Project" ("id","slug","name","externalRef","organizationId","createdAt","updatedAt")
VALUES ($1,$2,$3,$4,$5,$6::timestamptz,$6::timestamptz)`,
ids.projectId,
`${ids.projectId}-slug`,
`${ids.projectId}-name`,
`${ids.projectId}-ref`,
ids.orgId,
FIXED_TS
);
await p.$executeRawUnsafe(
`INSERT INTO "RuntimeEnvironment"
("id","slug","apiKey","pkApiKey","shortcode","type","organizationId","projectId","createdAt","updatedAt")
VALUES ($1,$2,$3,$4,$5,'DEVELOPMENT',$6,$7,$8::timestamptz,$8::timestamptz)`,
ids.envId,
`${ids.envId}-slug`,
`${ids.envId}-apikey`,
`${ids.envId}-pkapikey`,
`${ids.envId}-short`,
ids.orgId,
ids.projectId,
FIXED_TS
);
}
async function seedWaitpoint(
p: PrismaClient,
ids: { waitpointId: string; projectId: string; envId: string; idempotencyKey: string }
): Promise<void> {
await p.$executeRawUnsafe(
`INSERT INTO "Waitpoint"
("id","friendlyId","type","status","idempotencyKey","userProvidedIdempotencyKey",
"projectId","environmentId","createdAt","updatedAt")
VALUES ($1,$2,'MANUAL','PENDING',$3,false,$4,$5,$6::timestamptz,$6::timestamptz)`,
ids.waitpointId,
`${ids.waitpointId}-friendly`,
ids.idempotencyKey,
ids.projectId,
ids.envId,
FIXED_TS
);
}
describe("cross-seam guard — PG14+PG17 hetero-fixture proof", () => {
heteroPostgresTest(
"exhaustive routes resolve to the physically-correct store on PG14+PG17",
async ({ prisma14, prisma17 }) => {
const newWp = WaitpointId.toFriendlyId("0".repeat(24) + "01"); // NEW → PG17
const legacyWp = WaitpointId.toFriendlyId("c".repeat(25)); // LEGACY → PG14
// Distinct parent chains per residency; each Waitpoint lives on its own DB
// ONLY so the cross-DB ghost assertions (toBeNull on the other version) hold.
await seedOrgProjectEnv(prisma17, {
orgId: "org_csm_new_0000000000000000000",
projectId: "proj_csm_new_00000000000000000",
envId: "env_csm_new_0000000000000000000",
});
await seedWaitpoint(prisma17, {
waitpointId: newWp,
projectId: "proj_csm_new_00000000000000000",
envId: "env_csm_new_0000000000000000000",
idempotencyKey: "idem_csm_new",
});
await seedOrgProjectEnv(prisma14, {
orgId: "org_csm_legacy_0000000000000000",
projectId: "proj_csm_legacy_000000000000000",
envId: "env_csm_legacy_0000000000000000",
});
await seedWaitpoint(prisma14, {
waitpointId: legacyWp,
projectId: "proj_csm_legacy_000000000000000",
envId: "env_csm_legacy_0000000000000000",
idempotencyKey: "idem_csm_legacy",
});
// Exhaustive over every unblock route: the selected store must match the
// DB the row physically lives in, with no cross-DB ghost in either direction.
for (const route of UNBLOCK_ROUTES) {
const dNew = selectStoreForWaitpoint({ waitpointId: newWp, routeKind: route.kind });
expect(dNew.store).toBe("new");
expect(await prisma17.waitpoint.findFirst({ where: { id: newWp } })).not.toBeNull();
expect(await prisma14.waitpoint.findFirst({ where: { id: newWp } })).toBeNull();
const dLegacy = selectStoreForWaitpoint({ waitpointId: legacyWp, routeKind: route.kind });
expect(dLegacy.store).toBe("legacy");
expect(await prisma14.waitpoint.findFirst({ where: { id: legacyWp } })).not.toBeNull();
expect(await prisma17.waitpoint.findFirst({ where: { id: legacyWp } })).toBeNull();
}
},
// First run boots two real Postgres containers (PG14 + PG17); the default
// 5s per-test timeout is far too short for the cold image pull + start.
120_000
);
});
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { dependentAttemptWhere } from "../app/v3/services/dependentAttemptScope.js";
// The dependent-attempt lookup must be scoped to the caller's environment via
// the related run — a where clause missing that constraint lets a foreign
// attempt friendlyId resolve (the cross-tenant bug). This pins the scope on the
// query itself.
describe("dependentAttemptWhere", () => {
it("scopes the attempt lookup to the environment via the related run", () => {
const where = dependentAttemptWhere("attempt_abc", "env_caller");
expect(where.friendlyId).toBe("attempt_abc");
expect(where.taskRun).toEqual({ runtimeEnvironmentId: "env_caller" });
});
it("threads the exact environment id through (no cross-env match)", () => {
const where = dependentAttemptWhere("attempt_abc", "env_A");
// The env constraint must reference the caller's env, not be absent/empty.
expect((where.taskRun as { runtimeEnvironmentId?: string })?.runtimeEnvironmentId).toBe(
"env_A"
);
});
});
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { detectQueryTables } from "../app/v3/detectQueryTables.js";
const allowed = new Set(["runs", "tasks"]);
const sorted = (xs: string[] | null) => (xs === null ? null : [...xs].sort());
// detectQueryTables backs per-table JWT-scope authorization for /api/v1/query.
// Key behaviours over the old FROM-only regex: it sees JOINed and subquery
// tables, and returns null for unparseable queries so the caller denies by
// default.
describe("detectQueryTables", () => {
it("detects the FROM table", () => {
expect(sorted(detectQueryTables("SELECT * FROM runs", allowed))).toEqual(["runs"]);
});
it("returns the canonical table name when query casing differs", () => {
expect(sorted(detectQueryTables("SELECT * FROM RUNS", allowed))).toEqual(["runs"]);
});
it("detects every JOINed table, not just FROM", () => {
expect(
sorted(detectQueryTables("SELECT * FROM runs JOIN tasks ON runs.id = tasks.run_id", allowed))
).toEqual(["runs", "tasks"]);
});
it("detects tables inside a FROM subquery", () => {
expect(sorted(detectQueryTables("SELECT * FROM (SELECT * FROM runs) AS r", allowed))).toEqual([
"runs",
]);
});
it("detects a table read only inside a CTE body", () => {
expect(
sorted(detectQueryTables("WITH r AS (SELECT * FROM runs) SELECT * FROM r", allowed))
).toEqual(["runs"]);
});
it("detects a table read only inside a WHERE subquery", () => {
expect(
sorted(
detectQueryTables("SELECT * FROM tasks WHERE id IN (SELECT run_id FROM runs)", allowed)
)
).toEqual(["runs", "tasks"]);
});
it("detects a table read only inside a SELECT-list subquery", () => {
expect(
sorted(detectQueryTables("SELECT (SELECT count() FROM runs) AS c FROM tasks", allowed))
).toEqual(["runs", "tasks"]);
});
it("ignores tables that aren't in the allowed schema set", () => {
expect(detectQueryTables("SELECT * FROM runs", new Set(["tasks"]))).toEqual([]);
});
it("returns null for an unparseable query (caller denies by default)", () => {
expect(detectQueryTables("definitely not a valid query !!!", allowed)).toBeNull();
});
});
@@ -0,0 +1,249 @@
import { describe, expect, it } from "vitest";
import { detectBadJsonStrings } from "~/utils/detectBadJsonStrings";
describe("detectBadJsonStrings", () => {
it("should not detect valid JSON string", () => {
const goodJson = `{"title": "hello"}`;
const result = detectBadJsonStrings(goodJson);
expect(result).toBe(false);
});
it("should detect incomplete Unicode escape sequences", () => {
const badJson = `{"title": "hello\\ud835"}`;
const result = detectBadJsonStrings(badJson);
expect(result).toBe(true);
});
it("should not detect complete Unicode escape sequences", () => {
const goodJson = `{"title": "hello\\ud835\\udc00"}`;
const result = detectBadJsonStrings(goodJson);
expect(result).toBe(false);
});
it("should detect incomplete low surrogate", () => {
const badJson = `{"title": "hello\\udc00"}`;
const result = detectBadJsonStrings(badJson);
expect(result).toBe(true);
});
it("should handle multiple Unicode sequences correctly", () => {
const goodJson = `{"title": "hello\\ud835\\udc00\\ud835\\udc01"}`;
const result = detectBadJsonStrings(goodJson);
expect(result).toBe(false);
});
it("should detect mixed complete and incomplete sequences", () => {
const badJson = `{"title": "hello\\ud835\\udc00\\ud835"}`;
const result = detectBadJsonStrings(badJson);
expect(result).toBe(true);
});
it("should have acceptable performance overhead", () => {
const longText = `hello world `.repeat(1_000);
const goodJson = `{"title": "hello", "text": "${longText}"}`;
const badJson = `{"title": "hello\\ud835", "text": "${longText}"}`;
const iterations = 100_000;
// Warm up
for (let i = 0; i < 1000; i++) {
detectBadJsonStrings(goodJson);
detectBadJsonStrings(badJson);
}
// Measure good JSON (most common case)
const goodStart = performance.now();
for (let i = 0; i < iterations; i++) {
detectBadJsonStrings(goodJson);
}
const goodTime = performance.now() - goodStart;
// Measure bad JSON (edge case)
const badStart = performance.now();
for (let i = 0; i < iterations; i++) {
detectBadJsonStrings(badJson);
}
const badTime = performance.now() - badStart;
// Measure baseline (just function call overhead)
const baselineStart = performance.now();
for (let i = 0; i < iterations; i++) {
// Empty function call to measure baseline
}
const baselineTime = performance.now() - baselineStart;
const goodOverhead = goodTime - baselineTime;
const badOverhead = badTime - baselineTime;
console.log(`Baseline (${iterations} iterations): ${baselineTime.toFixed(2)}ms`);
console.log(
`Good JSON (${iterations} iterations): ${goodTime.toFixed(
2
)}ms (overhead: ${goodOverhead.toFixed(2)}ms)`
);
console.log(
`Bad JSON (${iterations} iterations): ${badTime.toFixed(
2
)}ms (overhead: ${badOverhead.toFixed(2)}ms)`
);
console.log(
`Average per call - Good: ${(goodOverhead / iterations).toFixed(4)}ms, Bad: ${(
badOverhead / iterations
).toFixed(4)}ms`
);
// Assertions for performance expectations
// Good JSON should be reasonably fast (most common case)
expect(goodOverhead / iterations).toBeLessThan(0.01); // Less than 10 microseconds per call
// Bad JSON can be slower due to regex matching, but still reasonable
expect(badOverhead / iterations).toBeLessThan(0.01); // Less than 20 microseconds per call
// Total overhead for 100k calls should be reasonable
expect(goodOverhead).toBeLessThan(1000); // Less than 1 second for 100k calls
});
it("should handle various JSON sizes efficiently", () => {
const sizes = [100, 1000, 10000, 100000];
const iterations = 10_000;
for (const size of sizes) {
const text = `hello world `.repeat(size / 11); // Approximate size
const goodJson = `{"title": "hello", "text": "${text}"}`;
const start = performance.now();
for (let i = 0; i < iterations; i++) {
detectBadJsonStrings(goodJson);
}
const time = performance.now() - start;
console.log(
`Size ${size} chars (${iterations} iterations): ${time.toFixed(2)}ms (${(
time / iterations
).toFixed(4)}ms per call)`
);
// Performance should scale reasonably with size
expect(time / iterations).toBeLessThan(size / 1000); // Roughly linear scaling
}
});
it("should show significant performance improvement with quick rejection", () => {
const longText = `hello world `.repeat(1_000);
const goodJson = `{"title": "hello", "text": "${longText}"}`;
const badJson = `{"title": "hello\\ud835", "text": "${longText}"}`;
const noUnicodeJson = `{"title": "hello", "text": "${longText}"}`;
const iterations = 100_000;
// Warm up
for (let i = 0; i < 1000; i++) {
detectBadJsonStrings(goodJson);
detectBadJsonStrings(badJson);
detectBadJsonStrings(noUnicodeJson);
}
// Test strings with no Unicode escapes (99.9% case)
const noUnicodeStart = performance.now();
for (let i = 0; i < iterations; i++) {
detectBadJsonStrings(noUnicodeJson);
}
const noUnicodeTime = performance.now() - noUnicodeStart;
// Test strings with Unicode escapes (0.1% case)
const withUnicodeStart = performance.now();
for (let i = 0; i < iterations; i++) {
detectBadJsonStrings(badJson);
}
const withUnicodeTime = performance.now() - withUnicodeStart;
console.log(
`No Unicode escapes (${iterations} iterations): ${noUnicodeTime.toFixed(2)}ms (${(
noUnicodeTime / iterations
).toFixed(4)}ms per call)`
);
console.log(
`With Unicode escapes (${iterations} iterations): ${withUnicodeTime.toFixed(2)}ms (${(
withUnicodeTime / iterations
).toFixed(4)}ms per call)`
);
console.log(
`Performance ratio: ${(withUnicodeTime / noUnicodeTime).toFixed(
2
)}x slower for Unicode strings`
);
// Both cases should be extremely fast (under 1 microsecond per call)
expect(noUnicodeTime / iterations).toBeLessThan(0.001); // Less than 1 microsecond
expect(withUnicodeTime / iterations).toBeLessThan(0.001); // Less than 1 microsecond
// The difference should be reasonable (not more than 5x)
expect(noUnicodeTime / withUnicodeTime).toBeLessThan(5);
});
describe("full UTF-16 low-surrogate range coverage (U+DC00U+DFFF)", () => {
// Regression guard: a previous version of this scanner used `[cd]` to
// match the low-surrogate nibble, missing the entire U+DE00U+DFFF
// half of the range. Valid surrogate pairs with low surrogates in that
// upper half (which includes most common emoji) were falsely flagged,
// and lone surrogates in the upper half were falsely passed.
it("does NOT flag a valid pair with low surrogate in the c range (U+DC00U+DCFF)", () => {
// 🐍 SNAKE = U+1F40D = 🐍
expect(detectBadJsonStrings(`{"s":"\\ud83d\\udc0d"}`)).toBe(false);
});
it("does NOT flag a valid pair with low surrogate in the d range (U+DD00U+DDFF)", () => {
// U+1F540 = 🕀
expect(detectBadJsonStrings(`{"s":"\\ud83d\\udd40"}`)).toBe(false);
});
it("does NOT flag a valid pair with low surrogate in the e range (U+DE00U+DEFF)", () => {
// 😀 GRINNING FACE = U+1F600 = 😀 — previously false-flagged
expect(detectBadJsonStrings(`{"s":"\\ud83d\\ude00"}`)).toBe(false);
});
it("does NOT flag a valid pair with low surrogate in the f range (U+DF00U+DFFF)", () => {
// U+1F700 = 🜀 — previously false-flagged
expect(detectBadJsonStrings(`{"s":"\\ud83d\\udf00"}`)).toBe(false);
});
it("flags a lone low surrogate in the e range (\\uDE00)", () => {
// Previously this was NOT flagged because the forward scan only
// recognised low surrogates with third nibble === "c" || "d".
expect(detectBadJsonStrings(`{"s":"prefix \\ude00 suffix"}`)).toBe(true);
});
it("flags a lone low surrogate in the f range (\\uDFFF)", () => {
expect(detectBadJsonStrings(`{"s":"prefix \\udfff suffix"}`)).toBe(true);
});
it("flags a high surrogate followed by something that looks like a low surrogate but is in the e range with a missing prefix", () => {
// The previous high-surrogate-then-pair check used `[cd]` for the
// matching low surrogate nibble, so any high surrogate followed by
// \uDe.. would be falsely flagged as unpaired. Verify the fix works
// for the valid case AND still flags genuinely broken inputs.
expect(detectBadJsonStrings(`{"s":"\\ud800X"}`)).toBe(true); // truly broken
expect(detectBadJsonStrings(`{"s":"\\ud83d\\ude00"}`)).toBe(false); // valid, but used to flag
});
});
describe("integration with JSON.stringify", () => {
it("does NOT flag JSON.stringify of a valid emoji 😀", () => {
// V8 emits the raw character for valid surrogate pairs, so the
// fast-path returns false without exercising the regex.
expect(detectBadJsonStrings(JSON.stringify("😀"))).toBe(false);
});
it("flags JSON.stringify of a lone high surrogate", () => {
expect(detectBadJsonStrings(JSON.stringify("\uD800"))).toBe(true);
});
it("flags JSON.stringify of a lone low surrogate in each of c/d/e/f ranges", () => {
expect(detectBadJsonStrings(JSON.stringify("\uDC00"))).toBe(true);
expect(detectBadJsonStrings(JSON.stringify("\uDD00"))).toBe(true);
expect(detectBadJsonStrings(JSON.stringify("\uDE00"))).toBe(true);
expect(detectBadJsonStrings(JSON.stringify("\uDFFF"))).toBe(true);
});
});
});
+150
View File
@@ -0,0 +1,150 @@
import { postgresTest } from "@internal/testcontainers";
import { type PrismaClient } from "@trigger.dev/database";
import slug from "slug";
import { describe, expect, vi } from "vitest";
import { ArchiveBranchService } from "~/services/archiveBranch.server";
import { UpsertBranchService } from "~/services/upsertBranch.server";
import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures";
vi.setConfig({ testTimeout: 60_000 });
async function createDevRoot(
prisma: PrismaClient,
projectId: string,
organizationId: string,
orgMemberId: string
) {
return prisma.runtimeEnvironment.create({
data: {
slug: "dev",
apiKey: uniqueId("tr_dev"),
pkApiKey: uniqueId("pk_dev"),
shortcode: uniqueId("sc"),
projectId,
organizationId,
type: "DEVELOPMENT",
orgMemberId,
maximumConcurrencyLimit: 17,
},
});
}
describe("UpsertBranchService — DEVELOPMENT parent", () => {
postgresTest(
"creates a child branch that inherits the parent's ownership",
async ({ prisma }) => {
const { organization, project, user, orgMember } =
await createTestOrgProjectWithMember(prisma);
const devRoot = await createDevRoot(prisma, project.id, organization.id, orgMember.id);
const result = await new UpsertBranchService(prisma).call(
{ type: "userMembership", userId: user.id },
{ projectId: project.id, env: "development", branchName: "my-feature" }
);
expect(result.success).toBe(true);
if (!result.success) return;
const { branch } = result;
expect(branch.type).toBe("DEVELOPMENT");
expect(branch.parentEnvironmentId).toBe(devRoot.id);
expect(branch.branchName).toBe("my-feature");
// The key dev-vs-preview divergence: dev branches MUST copy the parent's
// orgMemberId (preview parents have none).
expect(branch.orgMemberId).toBe(orgMember.id);
// Children inherit the parent's concurrency limit at creation.
expect(branch.maximumConcurrencyLimit).toBe(17);
expect(branch.slug).toBe(slug(`${devRoot.slug}-my-feature`));
}
);
postgresTest(
"is idempotent — upserting the same branch returns the existing row",
async ({ prisma }) => {
const { organization, project, user, orgMember } =
await createTestOrgProjectWithMember(prisma);
await createDevRoot(prisma, project.id, organization.id, orgMember.id);
const orgFilter = { type: "userMembership" as const, userId: user.id };
const options = { projectId: project.id, env: "development" as const, branchName: "dup" };
const first = await new UpsertBranchService(prisma).call(orgFilter, options);
const second = await new UpsertBranchService(prisma).call(orgFilter, options);
expect(first.success && second.success).toBe(true);
if (!first.success || !second.success) return;
expect(second.alreadyExisted).toBe(true);
expect(second.branch.id).toBe(first.branch.id);
}
);
postgresTest(
"rejects an invalid branch name without touching the database",
async ({ prisma }) => {
const { organization, project, user, orgMember } =
await createTestOrgProjectWithMember(prisma);
await createDevRoot(prisma, project.id, organization.id, orgMember.id);
const result = await new UpsertBranchService(prisma).call(
{ type: "userMembership", userId: user.id },
{ projectId: project.id, env: "development", branchName: "bad branch name!" }
);
expect(result.success).toBe(false);
}
);
});
describe("ArchiveBranchService — DEVELOPMENT", () => {
postgresTest(
"archives a dev branch and frees its slug/shortcode for reuse",
async ({ prisma }) => {
const { organization, project, user, orgMember } =
await createTestOrgProjectWithMember(prisma);
await createDevRoot(prisma, project.id, organization.id, orgMember.id);
const orgFilter = { type: "userMembership" as const, userId: user.id };
const created = await new UpsertBranchService(prisma).call(orgFilter, {
projectId: project.id,
env: "development",
branchName: "reuse-me",
});
expect(created.success).toBe(true);
if (!created.success) return;
const originalSlug = created.branch.slug;
const archived = await new ArchiveBranchService(prisma).call(orgFilter, {
environmentId: created.branch.id,
});
expect(archived.success).toBe(true);
if (!archived.success) return;
expect(archived.branch.archivedAt).not.toBeNull();
// Slug + shortcode are randomized on archive so the name can be reused.
expect(archived.branch.slug).not.toBe(originalSlug);
// The same branch name can now be created again (new row, deterministic slug).
const recreated = await new UpsertBranchService(prisma).call(orgFilter, {
projectId: project.id,
env: "development",
branchName: "reuse-me",
});
expect(recreated.success).toBe(true);
if (!recreated.success) return;
expect(recreated.branch.id).not.toBe(created.branch.id);
expect(recreated.branch.slug).toBe(originalSlug);
}
);
postgresTest("refuses to archive the default branch (the dev root)", async ({ prisma }) => {
const { organization, project, user, orgMember } = await createTestOrgProjectWithMember(prisma);
const devRoot = await createDevRoot(prisma, project.id, organization.id, orgMember.id);
const result = await new ArchiveBranchService(prisma).call(
{ type: "userMembership", userId: user.id },
{ environmentId: devRoot.id }
);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBe("The default development branch cannot be archived.");
});
});
+134
View File
@@ -0,0 +1,134 @@
import { redisTest } from "@internal/testcontainers";
import { subDays } from "date-fns";
import Redis from "ioredis";
import { describe, expect, vi } from "vitest";
import { DevPresence } from "~/presenters/v3/DevPresence.server";
vi.setConfig({ testTimeout: 30_000 });
let seq = 0;
function ids() {
seq += 1;
return { userId: `user_${seq}`, projectId: `proj_${seq}` };
}
const recentKey = (userId: string, projectId: string) => `dev-recent:${userId}:${projectId}`;
describe("DevPresence — recency ZSET", () => {
redisTest(
"getRecentBranchIds returns an empty map when nothing has pinged",
async ({ redisOptions }) => {
const presence = new DevPresence(redisOptions);
const { userId, projectId } = ids();
const result = await presence.getRecentBranchIds(userId, projectId);
expect(result.size).toBe(0);
}
);
redisTest("a ping records the branch as recently active", async ({ redisOptions }) => {
const presence = new DevPresence(redisOptions);
const { userId, projectId } = ids();
await presence.setConnected({ userId, projectId, environmentId: "env_a", ttl: 60 });
const result = await presence.getRecentBranchIds(userId, projectId);
expect([...result.keys()]).toEqual(["env_a"]);
expect(result.get("env_a")).toBeInstanceOf(Date);
});
redisTest("debounces to at most one ZADD per env per minute", async ({ redisOptions }) => {
const presence = new DevPresence(redisOptions);
const redis = new Redis(redisOptions);
const { userId, projectId } = ids();
// First ping records env_a.
await presence.setConnected({ userId, projectId, environmentId: "env_a", ttl: 60 });
// Simulate the entry being removed (e.g. another reader pruned it) while the
// 60s debounce touch key is still live.
await redis.zrem(recentKey(userId, projectId), "env_a");
// A second ping within the debounce window must NOT re-add it.
await presence.setConnected({ userId, projectId, environmentId: "env_a", ttl: 60 });
const result = await presence.getRecentBranchIds(userId, projectId);
expect(result.has("env_a")).toBe(false);
await redis.quit();
});
redisTest("does not return entries older than the recency window", async ({ redisOptions }) => {
const presence = new DevPresence(redisOptions);
const redis = new Redis(redisOptions);
const { userId, projectId } = ids();
const key = recentKey(userId, projectId);
const fourDaysAgo = subDays(Date.now(), 4).getTime();
const oneHourAgo = Date.now() - 60 * 60 * 1000;
await redis.zadd(key, fourDaysAgo, "env_stale", oneHourAgo, "env_fresh");
const result = await presence.getRecentBranchIds(userId, projectId);
expect([...result.keys()]).toEqual(["env_fresh"]);
await redis.quit();
});
redisTest("physically prunes stale entries on the next ping", async ({ redisOptions }) => {
const presence = new DevPresence(redisOptions);
const redis = new Redis(redisOptions);
const { userId, projectId } = ids();
const key = recentKey(userId, projectId);
await redis.zadd(key, subDays(Date.now(), 4).getTime(), "env_stale");
// A fresh ping triggers the ZREMRANGEBYSCORE cleanup for this user/project.
await presence.setConnected({ userId, projectId, environmentId: "env_fresh", ttl: 60 });
expect(await redis.zscore(key, "env_stale")).toBeNull();
expect(await redis.zcard(key)).toBe(1);
await redis.quit();
});
redisTest(
"caps cardinality at 50 even under a flood of distinct branches",
async ({ redisOptions }) => {
const presence = new DevPresence(redisOptions);
const redis = new Redis(redisOptions);
const { userId, projectId } = ids();
// 60 distinct envs, each with its own debounce key, so each performs a ZADD.
for (let i = 0; i < 60; i++) {
// eslint-disable-next-line no-await-in-loop
await presence.setConnected({ userId, projectId, environmentId: `env_${i}`, ttl: 60 });
}
expect(await redis.zcard(recentKey(userId, projectId))).toBe(50);
await redis.quit();
}
);
redisTest("returns recent branches in most-recent-first order", async ({ redisOptions }) => {
const presence = new DevPresence(redisOptions);
const redis = new Redis(redisOptions);
const { userId, projectId } = ids();
const key = recentKey(userId, projectId);
const now = Date.now();
await redis.zadd(
key,
now - 3000,
"env_oldest",
now - 2000,
"env_middle",
now - 1000,
"env_newest"
);
const result = await presence.getRecentBranchIds(userId, projectId);
expect([...result.keys()]).toEqual(["env_newest", "env_middle", "env_oldest"]);
await redis.quit();
});
});
@@ -0,0 +1,64 @@
// Single-version proof for dropping the dead `_TaskRunToTaskRunTag` implicit join.
import { describe, expect } from "vitest";
import { postgresTest } from "@internal/testcontainers";
describe("drop _TaskRunToTaskRunTag implicit join", () => {
postgresTest("runTags scalar round-trips and the join table is gone", async ({ prisma }) => {
const organization = await prisma.organization.create({
data: {
title: "test",
slug: "test",
},
});
const project = await prisma.project.create({
data: {
name: "test",
slug: "test",
organizationId: organization.id,
externalRef: "test",
},
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: "test",
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
apiKey: "test",
pkApiKey: "test",
shortcode: "test",
},
});
const taskRun = await prisma.taskRun.create({
data: {
friendlyId: "run_1234",
taskIdentifier: "my-task",
payload: JSON.stringify({ foo: "bar" }),
payloadType: "application/json",
traceId: "1234",
spanId: "1234",
queue: "test",
runtimeEnvironmentId: runtimeEnvironment.id,
projectId: project.id,
organizationId: organization.id,
environmentType: "DEVELOPMENT",
engine: "V2",
runTags: ["alpha", "beta"],
},
});
const readBack = await prisma.taskRun.findFirstOrThrow({
where: { id: taskRun.id },
});
expect(readBack.runTags).toEqual(["alpha", "beta"]);
const result = await prisma.$queryRaw<{ t: string | null }[]>`
SELECT to_regclass('public._TaskRunToTaskRunTag')::text as t
`;
expect(result[0].t).toBeNull();
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import { ServiceValidationError } from "~/v3/services/common.server";
import { assertNoDuplicateTaskIds } from "~/v3/services/duplicateTaskIds.server";
function task(partial: {
id: string;
filePath?: string;
exportName?: string;
triggerSource?: string;
}) {
return {
filePath: "src/trigger/example.ts",
exportName: "exampleTask",
...partial,
} as any;
}
describe("assertNoDuplicateTaskIds", () => {
it("does not throw when all task ids are unique", () => {
const tasks = [task({ id: "a" }), task({ id: "b" }), task({ id: "c" })];
expect(() => assertNoDuplicateTaskIds(tasks)).not.toThrow();
});
it("throws a ServiceValidationError when a task id is duplicated", () => {
const tasks = [task({ id: "a" }), task({ id: "a" })];
expect(() => assertNoDuplicateTaskIds(tasks)).toThrow(ServiceValidationError);
});
it("reports a 400 and names the duplicate id and its files", () => {
const tasks = [
task({ id: "report", filePath: "src/trigger/report.ts" }),
task({ id: "report", filePath: "src/trigger/scheduled.ts" }),
];
let error: unknown;
try {
assertNoDuplicateTaskIds(tasks);
} catch (e) {
error = e;
}
expect(error).toBeInstanceOf(ServiceValidationError);
const validationError = error as ServiceValidationError;
expect(validationError.status).toBe(400);
expect(validationError.message).toContain("report");
expect(validationError.message).toContain("src/trigger/report.ts");
expect(validationError.message).toContain("src/trigger/scheduled.ts");
});
it("detects duplicates across different task types (a schedule and a regular task sharing an id)", () => {
const tasks = [
task({ id: "report", triggerSource: undefined, filePath: "src/trigger/report.ts" }),
task({ id: "report", triggerSource: "schedule", filePath: "src/trigger/scheduled.ts" }),
];
expect(() => assertNoDuplicateTaskIds(tasks)).toThrow(ServiceValidationError);
});
});
@@ -0,0 +1,133 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { DynamicFlushScheduler } from "~/v3/dynamicFlushScheduler.server";
import { createInMemoryMetrics } from "./utils/tracing";
import { gaugeValue, latestMetrics, metricSum } from "./otlpMetrics.helpers";
type Item = { id: number };
describe("DynamicFlushScheduler self-observability", () => {
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
for (const cleanup of cleanups.splice(0)) {
await cleanup();
}
});
it("records flush counters, histograms and gauges on a successful flush", async () => {
const metrics = createInMemoryMetrics();
const flushed: number[] = [];
const scheduler = new DynamicFlushScheduler<Item>({
name: "test_events",
batchSize: 5,
flushInterval: 50,
meter: metrics.meter,
loadSheddingEnabled: false,
callback: async (_flushId, batch) => {
flushed.push(batch.length);
},
});
cleanups.push(async () => {
await scheduler.shutdown();
await metrics.shutdown();
});
// Reaching batchSize triggers an immediate flush.
scheduler.addToBatch([{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }]);
await vi.waitFor(
async () => {
const rm = await latestMetrics(metrics);
expect(metricSum(rm, "ingest.flush.items", { scheduler: "test_events" })).toBe(5);
},
{ timeout: 4000, interval: 50 }
);
expect(flushed).toEqual([5]);
const rm = await latestMetrics(metrics);
expect(metricSum(rm, "ingest.flush.batches", { scheduler: "test_events", outcome: "ok" })).toBe(
1
);
expect(metricSum(rm, "ingest.flush.batch_size", { scheduler: "test_events" })).toBe(5);
// Gauges are pull-based; the export we just collected observed the current state.
expect(gaugeValue(rm, "ingest.flush.queue_depth", { scheduler: "test_events" })).toBeDefined();
expect(
gaugeValue(rm, "ingest.flush.concurrency", { scheduler: "test_events" })
).toBeGreaterThanOrEqual(1);
});
it("labels each scheduler instance separately", async () => {
const metrics = createInMemoryMetrics();
const makeScheduler = (name: string) => {
const s = new DynamicFlushScheduler<Item>({
name,
batchSize: 2,
flushInterval: 50,
meter: metrics.meter,
loadSheddingEnabled: false,
callback: async () => {},
});
cleanups.push(async () => s.shutdown());
return s;
};
const a = makeScheduler("task_events_v2");
const b = makeScheduler("llm_metrics");
cleanups.push(async () => metrics.shutdown());
a.addToBatch([{ id: 1 }, { id: 2 }]);
b.addToBatch([{ id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }]);
await vi.waitFor(
async () => {
const rm = await latestMetrics(metrics);
expect(metricSum(rm, "ingest.flush.items", { scheduler: "task_events_v2" })).toBe(2);
expect(metricSum(rm, "ingest.flush.items", { scheduler: "llm_metrics" })).toBe(4);
},
{ timeout: 4000, interval: 50 }
);
});
it("counts a permanently failing flush as a failed batch", async () => {
const metrics = createInMemoryMetrics();
const scheduler = new DynamicFlushScheduler<Item>({
name: "failing_events",
batchSize: 1,
flushInterval: 50,
meter: metrics.meter,
loadSheddingEnabled: false,
callback: async () => {
throw new Error("insert failed");
},
});
cleanups.push(async () => {
await scheduler.shutdown();
await metrics.shutdown();
});
scheduler.addToBatch([{ id: 1 }]);
// The scheduler retries 3x with a 500ms backoff before giving up, so allow ~2s.
await vi.waitFor(
async () => {
const rm = await latestMetrics(metrics);
expect(
metricSum(rm, "ingest.flush.batches", {
scheduler: "failing_events",
outcome: "failed",
})
).toBeGreaterThanOrEqual(1);
},
{ timeout: 8000, interval: 100 }
);
const rm = await latestMetrics(metrics);
expect(
metricSum(rm, "ingest.flush.batches", { scheduler: "failing_events", outcome: "ok" })
).toBe(0);
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { emailMatchesPattern } from "../app/utils/emailPattern.js";
// emailMatchesPattern backs the ADMIN_EMAILS and WHITELISTED_EMAILS gates.
// Property under test: a pattern matches the whole address, never a substring.
describe("emailMatchesPattern", () => {
it("matches an address that equals the operator pattern exactly", () => {
expect(emailMatchesPattern("admin@company.com", "admin@company.com")).toBe(true);
});
it("matches a leading-@ domain shorthand against addresses at that domain", () => {
expect(emailMatchesPattern("@company.com", "alice@company.com")).toBe(true);
expect(emailMatchesPattern("@company.com", "bob@company.com")).toBe(true);
});
it("rejects a look-alike address that merely contains the pattern", () => {
// A look-alike address embeds the pattern as a substring; an unanchored
// match would wrongly accept it.
expect(emailMatchesPattern("admin@company.com", "evil@admin@company.com.attacker.com")).toBe(
false
);
expect(emailMatchesPattern("@company.com", "evil@company.com.attacker.com")).toBe(false);
expect(emailMatchesPattern("@company.com", "alice@sub.company.com")).toBe(false);
});
it("rejects a trailing-garbage address (pattern is only a prefix)", () => {
expect(emailMatchesPattern("admin@company.com", "admin@company.computer-evil.com")).toBe(false);
});
it("rejects a leading-garbage address (pattern is only a suffix)", () => {
expect(emailMatchesPattern("admin@company.com", "not-admin@company.com")).toBe(false);
});
it("preserves top-level alternation as whole-string alternatives", () => {
// Guards against anchoring without the non-capturing group, which would
// turn `^a|b$` into anchored-a OR anchored-b and break multi-address configs.
const pattern = "alice@x.com|bob@x.com";
expect(emailMatchesPattern(pattern, "alice@x.com")).toBe(true);
expect(emailMatchesPattern(pattern, "bob@x.com")).toBe(true);
expect(emailMatchesPattern(pattern, "eve@x.com")).toBe(false);
// ...and alternation must not become a substring match either.
expect(emailMatchesPattern(pattern, "eve+alice@x.com.evil.com")).toBe(false);
});
it("expands domain shorthand inside simple top-level alternation", () => {
const pattern = "alice@x.com|@company.com";
expect(emailMatchesPattern(pattern, "alice@x.com")).toBe(true);
expect(emailMatchesPattern(pattern, "carol@company.com")).toBe(true);
expect(emailMatchesPattern(pattern, "carol@company.com.evil.com")).toBe(false);
});
it("accepts patterns that already carry their own anchors", () => {
// Operators who already wrote a fully-anchored pattern keep working:
// ^(?:^...$)$ accepts exactly the same strings.
expect(emailMatchesPattern("^ops@company\\.com$", "ops@company.com")).toBe(true);
expect(emailMatchesPattern("^ops@company\\.com$", "ops@company.com.evil.com")).toBe(false);
});
});
@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// --- Module mocks (must come before imports) ---
vi.mock("~/v3/objectStore.server", () => ({
hasObjectStoreClient: vi.fn().mockReturnValue(true),
uploadPacketToObjectStore: vi.fn(),
}));
// Threshold of 10 bytes so any non-trivial payload triggers offloading
vi.mock("~/env.server", () => ({
env: {
BATCH_PAYLOAD_OFFLOAD_THRESHOLD: 10,
TASK_PAYLOAD_OFFLOAD_THRESHOLD: 10,
OBJECT_STORE_DEFAULT_PROTOCOL: undefined,
},
}));
// Execute the span callback synchronously without real OTel
vi.mock("~/v3/tracer.server", () => ({
startActiveSpan: vi.fn(async (_name: string, fn: (span: any) => any) =>
fn({ setAttribute: vi.fn() })
),
}));
import * as objectStore from "~/v3/objectStore.server";
import { BatchPayloadProcessor } from "../../app/runEngine/concerns/batchPayloads.server";
vi.setConfig({ testTimeout: 30_000 });
// Minimal AuthenticatedEnvironment shape required by BatchPayloadProcessor
const mockEnvironment = {
id: "env-test",
slug: "production",
project: { externalRef: "proj-ext-ref" },
} as any;
describe("BatchPayloadProcessor", () => {
let mockUpload: ReturnType<typeof vi.mocked<typeof objectStore.uploadPacketToObjectStore>>;
beforeEach(() => {
mockUpload = vi.mocked(objectStore.uploadPacketToObjectStore);
mockUpload.mockReset();
});
it("offloads a large payload successfully on first attempt", async () => {
mockUpload.mockResolvedValueOnce("batch_abc/item_0/payload.json");
const processor = new BatchPayloadProcessor();
const result = await processor.process(
'{"message":"hello world"}',
"application/json",
"batch-internal-abc",
0,
mockEnvironment
);
expect(result.wasOffloaded).toBe(true);
expect(result.payloadType).toBe("application/store");
expect(result.payload).toBe("batch_abc/item_0/payload.json");
expect(mockUpload).toHaveBeenCalledTimes(1);
});
it("retries on transient fetch failure and succeeds on third attempt", async () => {
mockUpload
.mockRejectedValueOnce(new Error("fetch failed"))
.mockRejectedValueOnce(new Error("fetch failed"))
.mockResolvedValueOnce("batch_abc/item_0/payload.json");
const processor = new BatchPayloadProcessor();
const result = await processor.process(
'{"message":"hello world"}',
"application/json",
"batch-internal-abc",
0,
mockEnvironment
);
expect(result.wasOffloaded).toBe(true);
expect(mockUpload).toHaveBeenCalledTimes(3);
});
it("throws after exhausting all retry attempts", async () => {
mockUpload.mockRejectedValue(new Error("fetch failed"));
const processor = new BatchPayloadProcessor();
await expect(
processor.process(
'{"message":"hello world"}',
"application/json",
"batch-internal-abc",
0,
mockEnvironment
)
).rejects.toThrow("Failed to upload large payload to object store: fetch failed");
// 1 initial attempt + 3 retries = 4 total calls
expect(mockUpload).toHaveBeenCalledTimes(4);
});
it("does not offload when there is no payload data", async () => {
const processor = new BatchPayloadProcessor();
const result = await processor.process(
undefined,
"application/json",
"batch-internal-abc",
0,
mockEnvironment
);
expect(result.wasOffloaded).toBe(false);
expect(mockUpload).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,329 @@
import { describe, expect, vi } from "vitest";
// Mock the db prisma singleton so the real testcontainer prisma is used
// instead of the webapp's env-bound client (mirrors triggerTask.test.ts).
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
runOpsNewPrisma: {},
runOpsLegacyPrisma: {},
}));
vi.mock("~/services/platform.v3.server", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
getEntitlement: vi.fn(),
};
});
import { RunEngine } from "@internal/run-engine";
import {
setupAuthenticatedEnvironment,
setupBackgroundWorker,
type AuthenticatedEnvironment,
} from "@internal/run-engine/tests";
import { containerTest } from "@internal/testcontainers";
import { trace } from "@opentelemetry/api";
import type { IOPacket } from "@trigger.dev/core/v3";
import {
Decimal,
type PrismaClient,
type RuntimeEnvironmentType,
type TaskRun,
} from "@trigger.dev/database";
import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server";
import { DefaultQueueManager } from "~/runEngine/concerns/queues.server";
import type {
EntitlementValidationParams,
MaxAttemptsValidationParams,
ParentRunValidationParams,
PayloadProcessor,
TagValidationParams,
TraceEventConcern,
TracedEventSpan,
TriggerTaskRequest,
TriggerTaskValidator,
ValidationResult,
} from "~/runEngine/types";
import { RunEngineTriggerTaskService } from "../../app/runEngine/services/triggerTask.server";
import { setTimeout } from "node:timers/promises";
vi.setConfig({ testTimeout: 60_000 });
class MockPayloadProcessor implements PayloadProcessor {
async process(request: TriggerTaskRequest): Promise<IOPacket> {
return {
data: JSON.stringify(request.body.payload),
dataType: "application/json",
};
}
}
// Permissive validator: the main (non-cached) trigger path is not the
// subject here — we want the cached idempotency branch's own scoping
// guard to be the only thing standing between a caller and an
// arbitrary parent run.
class MockTriggerTaskValidator implements TriggerTaskValidator {
validateTags(_params: TagValidationParams): ValidationResult {
return { ok: true };
}
validateEntitlement(_params: EntitlementValidationParams): Promise<ValidationResult> {
return Promise.resolve({ ok: true });
}
validateMaxAttempts(_params: MaxAttemptsValidationParams): ValidationResult {
return { ok: true };
}
validateParentRun(_params: ParentRunValidationParams): ValidationResult {
return { ok: true };
}
}
const MOCK_TRACE_ID = "0123456789abcdef0123456789abcdef";
const MOCK_SPAN_ID = "fedcba9876543210";
const MOCK_TRACEPARENT = `00-${MOCK_TRACE_ID}-${MOCK_SPAN_ID}-01`;
class MockTraceEventConcern implements TraceEventConcern {
private span(): TracedEventSpan {
return {
traceId: MOCK_TRACE_ID,
spanId: MOCK_SPAN_ID,
traceContext: { traceparent: MOCK_TRACEPARENT },
traceparent: undefined,
setAttribute: () => {},
failWithError: () => {},
stop: () => {},
};
}
async traceRun<T>(
_request: TriggerTaskRequest,
_parentStore: string | undefined,
callback: (span: TracedEventSpan, store: string) => Promise<T>
): Promise<T> {
return callback(this.span(), "test");
}
async traceIdempotentRun<T>(
_request: TriggerTaskRequest,
_parentStore: string | undefined,
_options: {
existingRun: TaskRun;
idempotencyKey: string;
incomplete: boolean;
isError: boolean;
},
callback: (span: TracedEventSpan, store: string) => Promise<T>
): Promise<T> {
return callback(this.span(), "test");
}
async traceDebouncedRun<T>(
_request: TriggerTaskRequest,
_parentStore: string | undefined,
_options: { existingRun: TaskRun; debounceKey: string; incomplete: boolean; isError: boolean },
callback: (span: TracedEventSpan, store: string) => Promise<T>
): Promise<T> {
return callback(this.span(), "test");
}
}
// setupAuthenticatedEnvironment hardcodes every unique field (slug,
// apiKey, shortcode, ...), so it can only be called once per database.
// This builds a second, fully-distinct tenant for the cross-environment
// assertion.
async function createDistinctTenant(
prisma: PrismaClient,
type: RuntimeEnvironmentType,
suffix: string
): Promise<AuthenticatedEnvironment> {
const org = await prisma.organization.create({
data: { title: `Test Org ${suffix}`, slug: `test-organization-${suffix}` },
});
const workerGroup = await prisma.workerInstanceGroup.create({
data: {
name: `default-${suffix}`,
masterQueue: `default-${suffix}`,
type: "MANAGED",
token: { create: { tokenHash: `token_hash_${suffix}` } },
},
});
const project = await prisma.project.create({
data: {
name: `Test Project ${suffix}`,
slug: `test-project-${suffix}`,
externalRef: `proj_${suffix}`,
organizationId: org.id,
defaultWorkerGroupId: workerGroup.id,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
type,
slug: `slug-${suffix}`,
projectId: project.id,
organizationId: org.id,
apiKey: `api_key_${suffix}`,
pkApiKey: `pk_api_key_${suffix}`,
shortcode: `short_code_${suffix}`,
maximumConcurrencyLimit: 10,
concurrencyLimitBurstFactor: new Decimal(2.0),
},
});
return prisma.runtimeEnvironment.findUniqueOrThrow({
where: { id: environment.id },
include: { project: true, organization: true, orgMember: true },
});
}
describe("IdempotencyKeyConcern cached-branch parent-run scoping", () => {
containerTest(
"rejects a parentRunId from another environment, permits one from the caller's environment",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
queue: { redis: redisOptions },
runLock: { redis: redisOptions },
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": { name: "small-1x" as const, cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 },
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
const parentTask = "parent-task";
const childTask = "child-task";
// Two independent tenants.
const callerEnv = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const victimEnv = await createDistinctTenant(prisma, "PRODUCTION", "victim");
await setupBackgroundWorker(engine, callerEnv, [parentTask, childTask]);
await setupBackgroundWorker(engine, victimEnv, [parentTask, childTask]);
// Helper: trigger a parent run and start its attempt so it is a
// valid waitpoint target for resumeParentOnCompletion.
const triggerAndStart = async (
env: typeof callerEnv,
friendlyId: string,
idSuffix: string
) => {
const run = await engine.trigger(
{
number: 1,
friendlyId,
environment: env,
taskIdentifier: parentTask,
payload: "{}",
payloadType: "application/json",
context: {},
traceContext: {},
traceId: `t${idSuffix}`,
spanId: `s${idSuffix}`,
queue: `task/${parentTask}`,
isTest: false,
tags: [],
workerQueue: "main",
},
prisma
);
await setTimeout(500);
const dequeued = await engine.dequeueFromWorkerQueue({
consumerId: `consumer${idSuffix}`,
workerQueue: "main",
});
await engine.startRunAttempt({ runId: run.id, snapshotId: dequeued[0].snapshot.id });
return run;
};
// Victim parent lives in the OTHER environment.
const victimParent = await triggerAndStart(victimEnv, "run_victimp", "11111");
// A legitimate parent in the caller's own environment.
const callerParent = await triggerAndStart(callerEnv, "run_callerp", "22222");
const queuesManager = new DefaultQueueManager(prisma, engine);
const idempotencyKeyConcern = new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
);
const service = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1,
});
// Seed two cached idempotent child runs in the caller's env. The
// first call for each key takes the non-cached path and creates the
// run; the second call hits the cached branch under test.
const crossEnvKey = "cross-env-key";
const sameEnvKey = "same-env-key";
const seedCross = await service.call({
taskId: childTask,
environment: callerEnv,
body: { payload: { n: 1 }, options: { idempotencyKey: crossEnvKey } },
});
expect(seedCross?.isCached).toBe(false);
const seedSame = await service.call({
taskId: childTask,
environment: callerEnv,
body: { payload: { n: 2 }, options: { idempotencyKey: sameEnvKey } },
});
expect(seedSame?.isCached).toBe(false);
// ATTACK: cached call in the caller's env naming the victim's parent
// run (which belongs to victimEnv). Must be refused.
await expect(
service.call({
taskId: childTask,
environment: callerEnv,
body: {
payload: { n: 1 },
options: {
idempotencyKey: crossEnvKey,
parentRunId: victimParent.friendlyId,
resumeParentOnCompletion: true,
},
},
})
).rejects.toThrow(/Parent run not found in the calling environment/);
// CONTROL: same cached path, but the parent is in the caller's own
// env — the guard must let this through (isCached hit).
const sameEnvResult = await service.call({
taskId: childTask,
environment: callerEnv,
body: {
payload: { n: 2 },
options: {
idempotencyKey: sameEnvKey,
parentRunId: callerParent.friendlyId,
resumeParentOnCompletion: true,
},
},
});
expect(sameEnvResult?.isCached).toBe(true);
expect(sameEnvResult?.run.friendlyId).toBe(seedSame?.run.friendlyId);
// And the cross-tenant victim run must NOT have been blocked by the
// attacker's waitpoint — its execution snapshot stays in its own env.
const victimAfter = await prisma.taskRun.findFirst({
where: { id: victimParent.id },
select: { runtimeEnvironmentId: true },
});
expect(victimAfter?.runtimeEnvironmentId).toBe(victimEnv.id);
await engine.quit();
}
);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,271 @@
import { describe, expect, vi } from "vitest";
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
}));
vi.mock("~/services/taskIdentifierCache.server", () => ({
getTaskIdentifiersFromCache: vi.fn().mockResolvedValue(null),
populateTaskIdentifierCache: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("~/services/logger.server", () => ({
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
}));
vi.mock("~/models/task.server", () => ({
getAllTaskIdentifiers: vi.fn().mockResolvedValue([]),
}));
import { setupAuthenticatedEnvironment } from "@internal/run-engine/tests";
import { postgresTest } from "@internal/testcontainers";
import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic";
import type { PrismaClient } from "@trigger.dev/database";
import {
syncTaskIdentifiers,
getTaskIdentifiers,
} from "../../app/services/taskIdentifierRegistry.server";
import type { AuthenticatedEnvironment } from "@internal/run-engine/tests";
vi.setConfig({ testTimeout: 30_000 });
async function createWorker(prisma: PrismaClient, env: AuthenticatedEnvironment) {
return prisma.backgroundWorker.create({
data: {
friendlyId: generateFriendlyId("worker"),
contentHash: `hash-${Date.now()}-${Math.random()}`,
projectId: env.project.id,
runtimeEnvironmentId: env.id,
version: `${Date.now()}`,
metadata: {},
engine: "V2",
},
});
}
describe("TaskIdentifierRegistry", () => {
postgresTest("should create task identifiers on first sync", async ({ prisma }) => {
const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const worker = await createWorker(prisma, env);
await syncTaskIdentifiers(
env.id,
env.project.id,
worker.id,
[
{ id: "task-a", triggerSource: "STANDARD" },
{ id: "task-b", triggerSource: "SCHEDULED" },
],
prisma
);
const rows = await prisma.taskIdentifier.findMany({
where: { runtimeEnvironmentId: env.id },
orderBy: { slug: "asc" },
});
expect(rows).toHaveLength(2);
expect(rows[0].slug).toBe("task-a");
expect(rows[0].currentTriggerSource).toBe("STANDARD");
expect(rows[0].isInLatestDeployment).toBe(true);
expect(rows[1].slug).toBe("task-b");
expect(rows[1].currentTriggerSource).toBe("SCHEDULED");
expect(rows[1].isInLatestDeployment).toBe(true);
});
postgresTest("should update triggerSource on re-deploy", async ({ prisma }) => {
const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const worker1 = await createWorker(prisma, env);
// First deploy: STANDARD
await syncTaskIdentifiers(
env.id,
env.project.id,
worker1.id,
[{ id: "my-task", triggerSource: "STANDARD" }],
prisma
);
const worker2 = await createWorker(prisma, env);
// Second deploy: change to SCHEDULED
await syncTaskIdentifiers(
env.id,
env.project.id,
worker2.id,
[{ id: "my-task", triggerSource: "SCHEDULED" }],
prisma
);
const rows = await prisma.taskIdentifier.findMany({
where: { runtimeEnvironmentId: env.id },
});
expect(rows).toHaveLength(1);
expect(rows[0].slug).toBe("my-task");
expect(rows[0].currentTriggerSource).toBe("SCHEDULED");
expect(rows[0].currentWorkerId).toBe(worker2.id);
});
postgresTest("should archive tasks removed in a deploy", async ({ prisma }) => {
const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const worker1 = await createWorker(prisma, env);
// Deploy with both tasks
await syncTaskIdentifiers(
env.id,
env.project.id,
worker1.id,
[
{ id: "task-a", triggerSource: "STANDARD" },
{ id: "task-b", triggerSource: "STANDARD" },
],
prisma
);
const worker2 = await createWorker(prisma, env);
// Deploy with only task-a (task-b removed)
await syncTaskIdentifiers(
env.id,
env.project.id,
worker2.id,
[{ id: "task-a", triggerSource: "STANDARD" }],
prisma
);
const rows = await prisma.taskIdentifier.findMany({
where: { runtimeEnvironmentId: env.id },
orderBy: { slug: "asc" },
});
expect(rows).toHaveLength(2);
expect(rows[0].slug).toBe("task-a");
expect(rows[0].isInLatestDeployment).toBe(true);
expect(rows[1].slug).toBe("task-b");
expect(rows[1].isInLatestDeployment).toBe(false);
});
postgresTest("should resurrect archived tasks on re-deploy", async ({ prisma }) => {
const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const worker1 = await createWorker(prisma, env);
// Deploy with both
await syncTaskIdentifiers(
env.id,
env.project.id,
worker1.id,
[
{ id: "task-a", triggerSource: "STANDARD" },
{ id: "task-b", triggerSource: "STANDARD" },
],
prisma
);
const worker2 = await createWorker(prisma, env);
// Deploy without task-b
await syncTaskIdentifiers(
env.id,
env.project.id,
worker2.id,
[{ id: "task-a", triggerSource: "STANDARD" }],
prisma
);
const worker3 = await createWorker(prisma, env);
// Deploy with task-b again
await syncTaskIdentifiers(
env.id,
env.project.id,
worker3.id,
[
{ id: "task-a", triggerSource: "STANDARD" },
{ id: "task-b", triggerSource: "STANDARD" },
],
prisma
);
const rows = await prisma.taskIdentifier.findMany({
where: { runtimeEnvironmentId: env.id },
orderBy: { slug: "asc" },
});
expect(rows).toHaveLength(2);
expect(rows[0].isInLatestDeployment).toBe(true);
expect(rows[1].isInLatestDeployment).toBe(true);
});
postgresTest(
"should return identifiers sorted active-first from DB when cache misses",
async ({ prisma }) => {
const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const worker1 = await createWorker(prisma, env);
await syncTaskIdentifiers(
env.id,
env.project.id,
worker1.id,
[
{ id: "active-task", triggerSource: "STANDARD" },
{ id: "archived-task", triggerSource: "STANDARD" },
],
prisma
);
const worker2 = await createWorker(prisma, env);
// Archive one task
await syncTaskIdentifiers(
env.id,
env.project.id,
worker2.id,
[{ id: "active-task", triggerSource: "STANDARD" }],
prisma
);
// Read with cache miss (mocked to return null)
const result = await getTaskIdentifiers(env.id, prisma);
expect(result).toHaveLength(2);
// Active first
expect(result[0].slug).toBe("active-task");
expect(result[0].isInLatestDeployment).toBe(true);
// Archived second
expect(result[1].slug).toBe("archived-task");
expect(result[1].isInLatestDeployment).toBe(false);
}
);
postgresTest("should handle multiple triggerSource groups in one deploy", async ({ prisma }) => {
const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const worker = await createWorker(prisma, env);
// Note: AGENT enum value is missing from migrations (no ALTER TYPE migration exists),
// so we test with STANDARD + SCHEDULED only. AGENT works in prod because the enum
// was added via prisma db push or manual ALTER.
await syncTaskIdentifiers(
env.id,
env.project.id,
worker.id,
[
{ id: "standard-task", triggerSource: "STANDARD" },
{ id: "scheduled-task", triggerSource: "SCHEDULED" },
],
prisma
);
const rows = await prisma.taskIdentifier.findMany({
where: { runtimeEnvironmentId: env.id },
orderBy: { slug: "asc" },
});
expect(rows).toHaveLength(2);
expect(rows[0].slug).toBe("scheduled-task");
expect(rows[0].currentTriggerSource).toBe("SCHEDULED");
expect(rows[1].slug).toBe("standard-task");
expect(rows[1].currentTriggerSource).toBe("STANDARD");
});
});
@@ -0,0 +1,264 @@
import { describe, expect } from "vitest";
import { RunEngine } from "@internal/run-engine";
import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "@internal/run-engine/tests";
import { containerTest } from "@internal/testcontainers";
import { trace } from "@opentelemetry/api";
import { RunId, classifyKind, generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
import { TriggerFailedTaskService } from "../../app/runEngine/services/triggerFailedTask.server";
import { EventRepository } from "../../app/v3/eventRepository/eventRepository.server";
vi.setConfig?.({ testTimeout: 60_000 });
// Bind the service's trace-event writes to the testcontainer DB. Without this,
// call() resolves the repository via getEventRepository → global prisma, which
// points at a database that doesn't exist in CI.
function makeService(prisma: any, engine: RunEngine) {
return new TriggerFailedTaskService({
prisma,
engine,
// Read the parent through the same store the engine wrote it to.
runStore: engine.runStore,
eventRepository: {
repository: new EventRepository(prisma, prisma, {
batchSize: 100,
batchInterval: 1000,
retentionInDays: 30,
partitioningEnabled: false,
}),
store: "taskEvent",
},
});
}
function makeEngine(prisma: any, redisOptions: any) {
return new RunEngine({
prisma,
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
queue: { redis: redisOptions },
runLock: { redis: redisOptions },
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
}
describe("TriggerFailedTaskService — failed run residency", () => {
containerTest(
"root failed run mints cuid when split is off (call)",
async ({ prisma, redisOptions }) => {
const engine = makeEngine(prisma, redisOptions);
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "failed-residency-task";
await setupBackgroundWorker(engine, environment, taskIdentifier);
const service = makeService(prisma, engine);
const friendlyId = await service.call({
taskId: taskIdentifier,
environment,
payload: { test: "root" },
errorMessage: "boom",
});
expect(friendlyId).toBeTruthy();
expect(classifyKind(friendlyId!)).toBe("cuid");
// The failed run write must land (persistence) with no parent linkage.
const persisted = await prisma.taskRun.findFirst({ where: { friendlyId: friendlyId! } });
expect(persisted).not.toBeNull();
expect(persisted!.status).toBe("SYSTEM_FAILURE");
expect(persisted!.depth).toBe(0);
expect(persisted!.parentTaskRunId).toBeNull();
await engine.quit();
}
);
containerTest(
"failed child of a NEW (run-ops id) parent mints run-ops id (call)",
async ({ prisma, redisOptions }) => {
const engine = makeEngine(prisma, redisOptions);
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "failed-residency-task";
await setupBackgroundWorker(engine, environment, taskIdentifier);
const parentFriendlyId = RunId.toFriendlyId(generateRunOpsId());
expect(classifyKind(parentFriendlyId)).toBe("runOpsId");
await engine.trigger(
{
friendlyId: parentFriendlyId,
environment,
taskIdentifier,
payload: "{}",
payloadType: "application/json",
traceId: "00000000000000000000000000000000",
spanId: "0000000000000000",
workerQueue: "main",
queue: `task/${taskIdentifier}`,
isTest: false,
tags: [],
} as any,
prisma
);
const service = makeService(prisma, engine);
const friendlyId = await service.call({
taskId: taskIdentifier,
environment,
payload: { test: "child" },
errorMessage: "boom",
parentRunId: parentFriendlyId,
});
expect(classifyKind(friendlyId!)).toBe("runOpsId");
// The failed run write must land (persistence) and link to the resolved parent.
const persisted = await prisma.taskRun.findFirst({ where: { friendlyId: friendlyId! } });
expect(persisted).not.toBeNull();
expect(persisted!.status).toBe("SYSTEM_FAILURE");
const parent = await prisma.taskRun.findFirst({ where: { friendlyId: parentFriendlyId } });
expect(persisted!.parentTaskRunId).toBe(parent!.id);
expect(persisted!.depth).toBe(parent!.depth + 1);
expect(persisted!.rootTaskRunId).toBe(parent!.rootTaskRunId ?? parent!.id);
await engine.quit();
}
);
containerTest(
"failed child of a LEGACY (cuid) parent mints cuid (call)",
async ({ prisma, redisOptions }) => {
const engine = makeEngine(prisma, redisOptions);
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "failed-residency-task";
await setupBackgroundWorker(engine, environment, taskIdentifier);
const parentFriendlyId = RunId.generate().friendlyId; // cuid → LEGACY
expect(classifyKind(parentFriendlyId)).toBe("cuid");
await engine.trigger(
{
friendlyId: parentFriendlyId,
environment,
taskIdentifier,
payload: "{}",
payloadType: "application/json",
traceId: "00000000000000000000000000000000",
spanId: "0000000000000000",
workerQueue: "main",
queue: `task/${taskIdentifier}`,
isTest: false,
tags: [],
} as any,
prisma
);
const service = makeService(prisma, engine);
const friendlyId = await service.call({
taskId: taskIdentifier,
environment,
payload: { test: "child" },
errorMessage: "boom",
parentRunId: parentFriendlyId,
});
expect(classifyKind(friendlyId!)).toBe("cuid");
await engine.quit();
}
);
containerTest(
"failed child of a NEW parent mints run-ops id (callWithoutTraceEvents)",
async ({ prisma, redisOptions }) => {
const engine = makeEngine(prisma, redisOptions);
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "failed-residency-task";
await setupBackgroundWorker(engine, environment, taskIdentifier);
const parentFriendlyId = RunId.toFriendlyId(generateRunOpsId());
await engine.trigger(
{
friendlyId: parentFriendlyId,
environment,
taskIdentifier,
payload: "{}",
payloadType: "application/json",
traceId: "00000000000000000000000000000000",
spanId: "0000000000000000",
workerQueue: "main",
queue: `task/${taskIdentifier}`,
isTest: false,
tags: [],
} as any,
prisma
);
const service = makeService(prisma, engine);
const friendlyId = await service.callWithoutTraceEvents({
environmentId: environment.id,
environmentType: environment.type,
projectId: environment.projectId,
organizationId: environment.organizationId,
taskId: taskIdentifier,
payload: { test: "child" },
errorMessage: "boom",
parentRunId: parentFriendlyId,
});
expect(classifyKind(friendlyId!)).toBe("runOpsId");
await engine.quit();
}
);
containerTest(
"callWithoutTraceEvents returns null (best-effort) when the derived parent row is absent",
async ({ prisma, redisOptions }) => {
const engine = makeEngine(prisma, redisOptions);
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "failed-residency-task";
await setupBackgroundWorker(engine, environment, taskIdentifier);
const service = makeService(prisma, engine);
// A well-formed run-ops parent friendlyId that was NEVER triggered → no row.
// Exercises the missing-parent fallback in callWithoutTraceEvents.
const absentParentFriendlyId = RunId.toFriendlyId(generateRunOpsId());
const friendlyId = await service.callWithoutTraceEvents({
environmentId: environment.id,
environmentType: environment.type,
projectId: environment.projectId,
organizationId: environment.organizationId,
taskId: taskIdentifier,
payload: { test: "absent-parent" },
errorMessage: "boom",
parentRunId: absentParentFriendlyId,
});
// Fallback derives parentTaskRunId from an id with no row; the parentTaskRunId FK rejects the create, so the method returns null instead of throwing.
expect(friendlyId).toBeNull();
const orphan = await prisma.taskRun.findFirst({
where: { parentTaskRunId: RunId.fromFriendlyId(absentParentFriendlyId) },
});
expect(orphan).toBeNull();
await engine.quit();
}
);
});
@@ -0,0 +1,462 @@
import { describe, expect, onTestFinished, vi } from "vitest";
// db.server + splitMode are mocked so the idempotency dedup client resolves to
// the container prisma passed into the concern (split stays off).
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
runOpsNewPrisma: {},
runOpsLegacyPrisma: {},
}));
vi.mock("~/v3/runOpsMigration/splitMode.server", () => ({ isSplitEnabled: async () => false }));
vi.mock("~/services/platform.v3.server", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
getEntitlement: vi.fn(),
};
});
import { RunEngine } from "@internal/run-engine";
import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "@internal/run-engine/tests";
import { containerTest } from "@internal/testcontainers";
import { trace } from "@opentelemetry/api";
import type { IOPacket } from "@trigger.dev/core/v3";
import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server";
import { DefaultQueueManager } from "~/runEngine/concerns/queues.server";
import type { PayloadProcessor, TriggerTaskRequest } from "~/runEngine/types";
import { RunEngineTriggerTaskService } from "../../app/runEngine/services/triggerTask.server";
import { setTimeout } from "node:timers/promises";
import {
MockPayloadProcessor,
MockTraceEventConcern,
MockTriggerRacepointSystem,
MockTriggerTaskValidator,
} from "./triggerTaskTestHelpers";
vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
describe("RunEngineTriggerTaskService", () => {
containerTest(
"should preserve runFriendlyId across retries when RunDuplicateIdempotencyKeyError is thrown",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
logLevel: "debug",
});
onTestFinished(() => engine.quit());
const parentTask = "parent-task";
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "test-task";
// Create background worker
await setupBackgroundWorker(engine, authenticatedEnvironment, [parentTask, taskIdentifier]);
// Create parent runs and start their attempts (required for resumeParentOnCompletion)
const parentRun1 = await engine.trigger(
{
number: 1,
friendlyId: "run_cmqxvncxq0000kaulzpafkicv",
environment: authenticatedEnvironment,
taskIdentifier: parentTask,
payload: "{}",
payloadType: "application/json",
context: {},
traceContext: {},
traceId: "t12345",
spanId: "s12345",
queue: `task/${parentTask}`,
isTest: false,
tags: [],
workerQueue: "main",
},
prisma
);
await setTimeout(500);
const dequeued = await engine.dequeueFromWorkerQueue({
consumerId: "test_12345",
workerQueue: "main",
});
await engine.startRunAttempt({
runId: parentRun1.id,
snapshotId: dequeued[0].snapshot.id,
});
const parentRun2 = await engine.trigger(
{
number: 2,
friendlyId: "run_cmqxvncxr0001kauldv9mqa9z",
environment: authenticatedEnvironment,
taskIdentifier: parentTask,
payload: "{}",
payloadType: "application/json",
context: {},
traceContext: {},
traceId: "t12346",
spanId: "s12346",
queue: `task/${parentTask}`,
isTest: false,
tags: [],
workerQueue: "main",
},
prisma
);
await setTimeout(500);
const dequeued2 = await engine.dequeueFromWorkerQueue({
consumerId: "test_12345",
workerQueue: "main",
});
await engine.startRunAttempt({
runId: parentRun2.id,
snapshotId: dequeued2[0].snapshot.id,
});
const queuesManager = new DefaultQueueManager(prisma, engine);
const idempotencyKeyConcern = new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
);
const triggerRacepointSystem = new MockTriggerRacepointSystem();
// Track all friendlyIds passed to the payload processor
const processedFriendlyIds: string[] = [];
class TrackingPayloadProcessor implements PayloadProcessor {
async process(request: TriggerTaskRequest): Promise<IOPacket> {
processedFriendlyIds.push(request.friendlyId);
return {
data: JSON.stringify(request.body.payload),
dataType: "application/json",
};
}
}
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new TrackingPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1, // 1MB
triggerRacepointSystem,
});
const idempotencyKey = "test-preserve-friendly-id";
const racepoint = triggerRacepointSystem.registerRacepoint("idempotencyKey", idempotencyKey);
// Trigger two concurrent requests with same idempotency key
// One will succeed, one will fail with RunDuplicateIdempotencyKeyError and retry
const childTriggerPromise1 = triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test1" },
options: {
idempotencyKey,
parentRunId: parentRun1.friendlyId,
resumeParentOnCompletion: true,
},
},
});
const childTriggerPromise2 = triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test2" },
options: {
idempotencyKey,
parentRunId: parentRun2.friendlyId,
resumeParentOnCompletion: true,
},
},
});
await setTimeout(500);
// Resolve the racepoint to allow both requests to proceed
racepoint.resolve();
const result1 = await childTriggerPromise1;
const result2 = await childTriggerPromise2;
// Both should return the same run (one created, one cached)
expect(result1).toBeDefined();
expect(result2).toBeDefined();
expect(result1?.run.friendlyId).toBe(result2?.run.friendlyId);
// The key assertion: When a retry happens due to RunDuplicateIdempotencyKeyError,
// the same friendlyId should be used. We expect exactly 2 calls to payloadProcessor
// (one for each concurrent request), not 3 (which would indicate a new friendlyId on retry)
// Since the retry returns early from the idempotency cache, payloadProcessor is not called again.
expect(processedFriendlyIds.length).toBe(2);
// Verify that we have exactly 2 unique friendlyIds (one per original request)
const uniqueFriendlyIds = new Set(processedFriendlyIds);
expect(uniqueFriendlyIds.size).toBe(2);
}
);
containerTest(
"should reject invalid debounce.delay when no explicit delay is provided",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
onTestFinished(() => engine.quit());
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "test-task";
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
const queuesManager = new DefaultQueueManager(prisma, engine);
const idempotencyKeyConcern = new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
);
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1,
});
// Invalid debounce.delay format (ms not supported)
await expect(
triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test" },
options: {
debounce: {
key: "test-key",
delay: "300ms", // Invalid - ms not supported
},
},
},
})
).rejects.toThrow("Debounce requires a valid delay duration");
}
);
containerTest(
"should reject invalid debounce.delay even when explicit delay is valid",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
onTestFinished(() => engine.quit());
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "test-task";
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
const queuesManager = new DefaultQueueManager(prisma, engine);
const idempotencyKeyConcern = new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
);
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1,
});
// Valid explicit delay but invalid debounce.delay
// This is the bug case: the explicit delay passes validation,
// but debounce.delay would fail later when rescheduling
await expect(
triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test" },
options: {
delay: "5m", // Valid explicit delay
debounce: {
key: "test-key",
delay: "invalid-delay", // Invalid debounce delay
},
},
},
})
).rejects.toThrow("Invalid debounce delay");
}
);
containerTest("should accept valid debounce.delay formats", async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
onTestFinished(() => engine.quit());
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "test-task";
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
const queuesManager = new DefaultQueueManager(prisma, engine);
const idempotencyKeyConcern = new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
);
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1,
});
// Valid debounce.delay format
const result = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test" },
options: {
debounce: {
key: "test-key",
delay: "5s", // Valid format
},
},
},
});
expect(result).toBeDefined();
expect(result?.run.friendlyId).toBeDefined();
});
});
@@ -0,0 +1,651 @@
import { describe, expect, onTestFinished, vi } from "vitest";
// db.server + splitMode are mocked so the idempotency dedup client resolves to
// the container prisma passed into the concern (split stays off).
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
runOpsNewPrisma: {},
runOpsLegacyPrisma: {},
}));
vi.mock("~/v3/runOpsMigration/splitMode.server", () => ({ isSplitEnabled: async () => false }));
vi.mock("~/services/platform.v3.server", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
getEntitlement: vi.fn(),
};
});
import { RunEngine } from "@internal/run-engine";
import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "@internal/run-engine/tests";
import { assertNonNullable, containerTest } from "@internal/testcontainers";
import { trace } from "@opentelemetry/api";
import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server";
import { DefaultQueueManager } from "~/runEngine/concerns/queues.server";
import { NoopTaskMetadataCache } from "~/services/taskMetadataCache.server";
import { RunEngineTriggerTaskService } from "../../app/runEngine/services/triggerTask.server";
import { setTimeout } from "node:timers/promises";
import {
MockPayloadProcessor,
MockTraceEventConcern,
MockTriggerRacepointSystem,
MockTriggerTaskValidator,
} from "./triggerTaskTestHelpers";
vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
describe("RunEngineTriggerTaskService", () => {
containerTest("should handle idempotency keys correctly", async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
onTestFinished(() => engine.quit());
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "test-task";
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
const queuesManager = new DefaultQueueManager(prisma, engine);
const idempotencyKeyConcern = new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
);
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1, // 1MB
});
const result = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test" },
options: {
idempotencyKey: "test-idempotency-key",
},
},
});
expect(result).toBeDefined();
expect(result?.run.friendlyId).toBeDefined();
expect(result?.run.status).toBe("PENDING");
expect(result?.isCached).toBe(false);
const run = await prisma.taskRun.findFirst({
where: {
id: result?.run.id,
},
});
expect(run).toBeDefined();
expect(run?.friendlyId).toBe(result?.run.friendlyId);
expect(run?.engine).toBe("V2");
expect(run?.queuedAt).toBeDefined();
expect(run?.queue).toBe(`task/${taskIdentifier}`);
// Lets make sure the task is in the queue
const queueLength = await engine.runQueue.lengthOfQueue(
authenticatedEnvironment,
`task/${taskIdentifier}`
);
expect(queueLength).toBe(1);
// Now lets try to trigger the same task with the same idempotency key
const cachedResult = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test" },
options: {
idempotencyKey: "test-idempotency-key",
},
},
});
expect(cachedResult).toBeDefined();
expect(cachedResult?.run.friendlyId).toBe(result?.run.friendlyId);
expect(cachedResult?.isCached).toBe(true);
});
containerTest(
"should handle idempotency keys when the engine throws an RunDuplicateIdempotencyKeyError",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
logLevel: "debug",
});
onTestFinished(() => engine.quit());
const parentTask = "parent-task";
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "test-task";
await setupBackgroundWorker(engine, authenticatedEnvironment, [parentTask, taskIdentifier]);
const parentRun1 = await engine.trigger(
{
number: 1,
friendlyId: "run_cmqxvncxq0000kaulzpafkicv",
environment: authenticatedEnvironment,
taskIdentifier: parentTask,
payload: "{}",
payloadType: "application/json",
context: {},
traceContext: {},
traceId: "t12345",
spanId: "s12345",
queue: `task/${parentTask}`,
isTest: false,
tags: [],
workerQueue: "main",
},
prisma
);
//dequeue parent and create the attempt
await setTimeout(500);
const dequeued = await engine.dequeueFromWorkerQueue({
consumerId: "test_12345",
workerQueue: "main",
});
await engine.startRunAttempt({
runId: parentRun1.id,
snapshotId: dequeued[0].snapshot.id,
});
const parentRun2 = await engine.trigger(
{
number: 2,
friendlyId: "run_cmqxvncxr0001kauldv9mqa9z",
environment: authenticatedEnvironment,
taskIdentifier: parentTask,
payload: "{}",
payloadType: "application/json",
context: {},
traceContext: {},
traceId: "t12346",
spanId: "s12346",
queue: `task/${parentTask}`,
isTest: false,
tags: [],
workerQueue: "main",
},
prisma
);
await setTimeout(500);
const dequeued2 = await engine.dequeueFromWorkerQueue({
consumerId: "test_12345",
workerQueue: "main",
});
await engine.startRunAttempt({
runId: parentRun2.id,
snapshotId: dequeued2[0].snapshot.id,
});
const queuesManager = new DefaultQueueManager(prisma, engine);
const idempotencyKeyConcern = new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
);
const triggerRacepointSystem = new MockTriggerRacepointSystem();
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1, // 1MB
triggerRacepointSystem,
});
const idempotencyKey = "test-idempotency-key";
const racepoint = triggerRacepointSystem.registerRacepoint("idempotencyKey", idempotencyKey);
const childTriggerPromise1 = triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test" },
options: {
idempotencyKey,
parentRunId: parentRun1.friendlyId,
resumeParentOnCompletion: true,
},
},
});
const childTriggerPromise2 = triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test" },
options: {
idempotencyKey,
parentRunId: parentRun2.friendlyId,
resumeParentOnCompletion: true,
},
},
});
await setTimeout(500);
// Now we can resolve the racepoint
racepoint.resolve();
const result = await childTriggerPromise1;
const result2 = await childTriggerPromise2;
expect(result).toBeDefined();
expect(result?.run.friendlyId).toBeDefined();
expect(result?.run.status).toBe("PENDING");
const run = await prisma.taskRun.findFirst({
where: {
id: result?.run.id,
},
});
expect(run).toBeDefined();
expect(run?.friendlyId).toBe(result?.run.friendlyId);
expect(run?.engine).toBe("V2");
expect(run?.queuedAt).toBeDefined();
expect(run?.queue).toBe(`task/${taskIdentifier}`);
expect(result2).toBeDefined();
expect(result2?.run.friendlyId).toBe(result?.run.friendlyId);
const parent1ExecutionData = await engine.getRunExecutionData({ runId: parentRun1.id });
assertNonNullable(parent1ExecutionData);
expect(parent1ExecutionData.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS");
const parent2ExecutionData = await engine.getRunExecutionData({ runId: parentRun2.id });
assertNonNullable(parent2ExecutionData);
expect(parent2ExecutionData.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS");
const parent1RunWaitpoint = await prisma.taskRunWaitpoint.findFirst({
where: {
taskRunId: parentRun1.id,
},
include: {
waitpoint: true,
},
});
assertNonNullable(parent1RunWaitpoint);
expect(parent1RunWaitpoint.waitpoint.type).toBe("RUN");
expect(parent1RunWaitpoint.waitpoint.completedByTaskRunId).toBe(result?.run.id);
const parent2RunWaitpoint = await prisma.taskRunWaitpoint.findFirst({
where: {
taskRunId: parentRun2.id,
},
include: {
waitpoint: true,
},
});
assertNonNullable(parent2RunWaitpoint);
expect(parent2RunWaitpoint.waitpoint.type).toBe("RUN");
expect(parent2RunWaitpoint.waitpoint.completedByTaskRunId).toBe(result2?.run.id);
}
);
containerTest(
"should resolve queue names correctly when locked to version",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
onTestFinished(() => engine.quit());
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "test-task";
// Create a background worker with a specific version
const worker = await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier, {
preset: "small-1x",
});
// Create a specific queue for this worker
const specificQueue = await prisma.taskQueue.create({
data: {
name: "specific-queue",
friendlyId: "specific-queue",
projectId: authenticatedEnvironment.projectId,
runtimeEnvironmentId: authenticatedEnvironment.id,
workers: {
connect: {
id: worker.worker.id,
},
},
},
});
// Associate the task with the queue
await prisma.backgroundWorkerTask.update({
where: {
workerId_slug: {
workerId: worker.worker.id,
slug: taskIdentifier,
},
},
data: {
queueId: specificQueue.id,
},
});
const queuesManager = new DefaultQueueManager(prisma, engine);
const idempotencyKeyConcern = new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
);
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1, // 1MB
});
// Test case 1: Trigger with lockToVersion but no specific queue
const result1 = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test" },
options: {
lockToVersion: worker.worker.version,
},
},
});
expect(result1).toBeDefined();
expect(result1?.run.queue).toBe("specific-queue");
// Test case 2: Trigger with lockToVersion and specific queue
const result2 = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test" },
options: {
lockToVersion: worker.worker.version,
queue: {
name: "specific-queue",
},
},
},
});
expect(result2).toBeDefined();
expect(result2?.run.queue).toBe("specific-queue");
expect(result2?.run.lockedQueueId).toBe(specificQueue.id);
// Test case 3: Try to use non-existent queue with locked version (should throw)
await expect(
triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test" },
options: {
lockToVersion: worker.worker.version,
queue: {
name: "non-existent-queue",
},
},
},
})
).rejects.toThrow(
`Specified queue 'non-existent-queue' not found or not associated with locked version '${worker.worker.version}'`
);
// Test case 4: Trigger with a non-existent queue without a locked version
const result4 = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test" },
options: {
queue: {
name: "non-existent-queue",
},
},
},
});
expect(result4).toBeDefined();
expect(result4?.run.queue).toBe("non-existent-queue");
expect(result4?.run.status).toBe("PENDING");
}
);
containerTest(
"should fall back to the writer when a stale replica returns no row for a locked task",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
onTestFinished(() => engine.quit());
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "test-task";
const worker = await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
// A read replica that has not yet caught up to the BackgroundWorkerTask
// row: it is the real database for every query except the locked-task
// lookup, which comes back empty (the TRI-10868 false-negative window).
const staleReplica = new Proxy(prisma, {
get(target, prop, receiver) {
if (prop === "backgroundWorkerTask") {
const delegate = Reflect.get(target, prop, receiver);
return new Proxy(delegate, {
get(taskTarget, taskProp, taskReceiver) {
if (taskProp === "findFirst") {
return async () => null;
}
const value = Reflect.get(taskTarget, taskProp, taskReceiver);
return typeof value === "function" ? value.bind(taskTarget) : value;
},
});
}
const value = Reflect.get(target, prop, receiver);
return typeof value === "function" ? value.bind(target) : value;
},
}) as typeof prisma;
// Noop cache so every resolve misses the cache and exercises the
// replica -> writer fallback. The writer is the real `prisma`.
const queuesManager = new DefaultQueueManager(
prisma,
engine,
staleReplica,
new NoopTaskMetadataCache()
);
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern: new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
),
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1,
});
// The task IS registered on the locked worker, but the replica returns
// nothing. Before the fix this threw "not found on locked version"; now
// the writer fallback resolves the registered row.
const result = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: {
payload: { test: "test" },
options: {
lockToVersion: worker.worker.version,
},
},
});
expect(result).toBeDefined();
expect(result?.run.status).toBe("PENDING");
expect(result?.run.queue).toBe(`task/${taskIdentifier}`);
// A genuinely unregistered task must still throw, even with the writer
// fallback — the writer has no row either, so the 422 is correct.
await expect(
triggerTaskService.call({
taskId: "not-a-registered-task",
environment: authenticatedEnvironment,
body: {
payload: { test: "test" },
options: {
lockToVersion: worker.worker.version,
},
},
})
).rejects.toThrow(
`Task 'not-a-registered-task' not found on locked version '${worker.worker.version}'`
);
}
);
});
@@ -0,0 +1,342 @@
import { describe, expect, onTestFinished, vi } from "vitest";
// db.server + splitMode are mocked so the idempotency dedup client resolves to
// the container prisma passed into the concern (split stays off).
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
runOpsNewPrisma: {},
runOpsLegacyPrisma: {},
}));
vi.mock("~/v3/runOpsMigration/splitMode.server", () => ({ isSplitEnabled: async () => false }));
vi.mock("~/services/platform.v3.server", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
getEntitlement: vi.fn(),
};
});
import { RunEngine } from "@internal/run-engine";
import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "@internal/run-engine/tests";
import { assertNonNullable, containerTest } from "@internal/testcontainers";
import { trace } from "@opentelemetry/api";
import { Redis } from "ioredis";
import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server";
import { DefaultQueueManager } from "~/runEngine/concerns/queues.server";
import { RedisTaskMetadataCache } from "~/services/taskMetadataCache.server";
import { RunEngineTriggerTaskService } from "../../app/runEngine/services/triggerTask.server";
import { setTimeout } from "node:timers/promises";
import {
MockPayloadProcessor,
MockTraceEventConcern,
MockTriggerTaskValidator,
} from "./triggerTaskTestHelpers";
vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
describe("DefaultQueueManager task metadata cache", () => {
containerTest(
"warm cache returns metadata without falling through to PG",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
queue: { redis: redisOptions },
runLock: { redis: redisOptions },
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": { name: "small-1x", cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 },
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
onTestFinished(() => engine.quit());
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "cached-task";
const setup = await setupBackgroundWorker(engine, environment, taskIdentifier);
const redis = new Redis(redisOptions);
onTestFinished(() => redis.quit());
const cache = new RedisTaskMetadataCache({ redis });
// Pre-populate cache with AGENT triggerSource; DB row has the default STANDARD.
// If the read path hits the cache, the resulting TaskRun.taskKind reflects the
// cached value. If it falls through to PG, it reflects STANDARD.
await cache.populateByCurrentWorker(environment.id, setup.worker.id, [
{
slug: taskIdentifier,
ttl: null,
triggerSource: "AGENT",
queueId: null,
queueName: `task/${taskIdentifier}`,
},
]);
const queuesManager = new DefaultQueueManager(prisma, engine, undefined, cache);
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern: new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
),
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024,
});
const result = await triggerTaskService.call({
taskId: taskIdentifier,
environment,
body: { payload: { test: "x" } },
});
assertNonNullable(result);
expect(result.run.taskIdentifier).toBe(taskIdentifier);
expect((result.run.annotations as { taskKind?: string } | null)?.taskKind).toBe("AGENT");
}
);
containerTest(
"cache miss falls through to PG and back-fills the cache",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
queue: { redis: redisOptions },
runLock: { redis: redisOptions },
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": { name: "small-1x", cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 },
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
onTestFinished(() => engine.quit());
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "miss-task";
await setupBackgroundWorker(engine, environment, taskIdentifier);
const redis = new Redis(redisOptions);
onTestFinished(() => redis.quit());
const cache = new RedisTaskMetadataCache({ redis });
// Cache starts empty. Sanity-check both keyspaces.
expect(await cache.getCurrent(environment.id, taskIdentifier)).toBeNull();
const queuesManager = new DefaultQueueManager(prisma, engine, undefined, cache);
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern: new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
),
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024,
});
const result = await triggerTaskService.call({
taskId: taskIdentifier,
environment,
body: { payload: { test: "x" } },
});
assertNonNullable(result);
expect((result.run.annotations as { taskKind?: string } | null)?.taskKind).toBe("STANDARD");
// Back-fill is fire-and-forget; poll with a bounded timeout to avoid CI flakes.
let backfilled = await cache.getCurrent(environment.id, taskIdentifier);
for (let i = 0; i < 40 && !backfilled; i++) {
await setTimeout(25);
backfilled = await cache.getCurrent(environment.id, taskIdentifier);
}
expect(backfilled).not.toBeNull();
expect(backfilled?.triggerSource).toBe("STANDARD");
expect(backfilled?.queueName).toBe(`task/${taskIdentifier}`);
}
);
containerTest(
"queue-override + ttl path returns taskKind from cache without a BWT lookup",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
queue: { redis: redisOptions },
runLock: { redis: redisOptions },
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": { name: "small-1x", cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 },
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
onTestFinished(() => engine.quit());
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "override-task";
const setup = await setupBackgroundWorker(engine, environment, taskIdentifier);
const redis = new Redis(redisOptions);
onTestFinished(() => redis.quit());
const cache = new RedisTaskMetadataCache({ redis });
// Cache says AGENT; DB row says STANDARD. Caller provides both a queue
// override and an explicit TTL — the hot path the PR regressed.
await cache.populateByCurrentWorker(environment.id, setup.worker.id, [
{
slug: taskIdentifier,
ttl: null,
triggerSource: "AGENT",
queueId: null,
queueName: `task/${taskIdentifier}`,
},
]);
const queuesManager = new DefaultQueueManager(prisma, engine, undefined, cache);
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern: new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
),
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024,
});
const result = await triggerTaskService.call({
taskId: taskIdentifier,
environment,
body: {
payload: { test: "x" },
options: {
queue: { name: "caller-queue" },
ttl: "5m",
},
},
});
assertNonNullable(result);
expect(result.run.queue).toBe("caller-queue");
expect((result.run.annotations as { taskKind?: string } | null)?.taskKind).toBe("AGENT");
}
);
containerTest(
"locked-version trigger reads from by-worker keyspace, not env keyspace",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
queue: { redis: redisOptions },
runLock: { redis: redisOptions },
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": { name: "small-1x", cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 },
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
onTestFinished(() => engine.quit());
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "keyspace-task";
const worker = await setupBackgroundWorker(engine, environment, taskIdentifier);
const redis = new Redis(redisOptions);
onTestFinished(() => redis.quit());
const cache = new RedisTaskMetadataCache({ redis });
// Populate the two keyspaces with conflicting triggerSource values so we
// can tell which keyspace the read used. The real worker's by-worker
// hash gets AGENT; the env hash gets SCHEDULED (seeded via a throwaway
// worker id since `populateByCurrentWorker` writes both keyspaces and
// we want the real worker's by-worker hash untouched).
await cache.populateByWorker(worker.worker.id, [
{
slug: taskIdentifier,
ttl: null,
triggerSource: "AGENT",
queueId: null,
queueName: `task/${taskIdentifier}`,
},
]);
await cache.populateByCurrentWorker(environment.id, "dummy-worker-for-env-seed", [
{
slug: taskIdentifier,
ttl: null,
triggerSource: "SCHEDULED",
queueId: null,
queueName: `task/${taskIdentifier}`,
},
]);
const queuesManager = new DefaultQueueManager(prisma, engine, undefined, cache);
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern: new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
),
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024,
});
// Locked → by-worker keyspace → AGENT
const locked = await triggerTaskService.call({
taskId: taskIdentifier,
environment,
body: {
payload: { test: "x" },
options: { lockToVersion: worker.worker.version },
},
});
assertNonNullable(locked);
expect((locked.run.annotations as { taskKind?: string } | null)?.taskKind).toBe("AGENT");
// Not locked → env keyspace → SCHEDULED
const current = await triggerTaskService.call({
taskId: taskIdentifier,
environment,
body: { payload: { test: "y" } },
});
assertNonNullable(current);
expect((current.run.annotations as { taskKind?: string } | null)?.taskKind).toBe("SCHEDULED");
}
);
});
@@ -0,0 +1,507 @@
import { describe, expect, onTestFinished, vi } from "vitest";
// db.server + splitMode are mocked so the idempotency dedup client resolves to
// the container prisma passed into the concern (split stays off).
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
runOpsNewPrisma: {},
runOpsLegacyPrisma: {},
}));
vi.mock("~/v3/runOpsMigration/splitMode.server", () => ({ isSplitEnabled: async () => false }));
vi.mock("~/services/platform.v3.server", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
getEntitlement: vi.fn(),
};
});
import { RunEngine } from "@internal/run-engine";
import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "@internal/run-engine/tests";
import { containerTest } from "@internal/testcontainers";
import { trace } from "@opentelemetry/api";
import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server";
import { DefaultQueueManager } from "~/runEngine/concerns/queues.server";
import type { ValidationResult } from "~/runEngine/types";
import { RunEngineTriggerTaskService } from "../../app/runEngine/services/triggerTask.server";
import {
MOCK_SPAN_ID,
MOCK_TRACE_ID,
MockPayloadProcessor,
MockTraceEventConcern,
MockTriggerTaskValidator,
} from "./triggerTaskTestHelpers";
vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
describe("RunEngineTriggerTaskService", () => {
// ─── Mollifier integration ──────────────────────────────────────────────────
//
// These tests pin the call-site behaviour of the mollifier hooks inside
// RunEngineTriggerTaskService.call. They use the optional DI ports
// (`evaluateGate`, `getMollifierBuffer`) added on the service constructor —
// production wiring is unchanged (defaults to the live module-level imports).
// Each test's regression intent lives in its own setup comment.
class CapturingMollifierBuffer {
public accepted: Array<{ runId: string; envId: string; orgId: string; payload: string }> = [];
async accept(input: { runId: string; envId: string; orgId: string; payload: string }) {
this.accepted.push(input);
return true;
}
async pop() {
return null;
}
async ack() {}
async requeue() {}
async fail() {
return false;
}
async getEntry() {
return null;
}
async listEnvs(): Promise<string[]> {
return [];
}
async getEntryTtlSeconds(): Promise<number> {
return -1;
}
async evaluateTrip() {
return { tripped: false, count: 0 };
}
async close() {}
}
containerTest(
"mollifier · validation throws before the gate is consulted; no buffer write",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
queue: { redis: redisOptions },
runLock: { redis: redisOptions },
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": { name: "small-1x" as const, cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 },
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
onTestFinished(() => engine.quit());
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "test-task";
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
// Validator that fails on maxAttempts. Any validation throw must abort
// the call BEFORE the gate runs — otherwise the gate could leak a
// buffer write for an invalid request.
class FailingMaxAttemptsValidator extends MockTriggerTaskValidator {
validateMaxAttempts(): ValidationResult {
return { ok: false, error: new Error("synthetic max-attempts failure") };
}
}
const buffer = new CapturingMollifierBuffer();
const evaluateGateSpy = vi.fn(async () => ({
action: "mollify" as const,
decision: {
divert: true as const,
reason: "per_env_rate" as const,
count: 99,
threshold: 1,
windowMs: 200,
holdMs: 500,
},
}));
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: new DefaultQueueManager(prisma, engine),
idempotencyKeyConcern: new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
),
validator: new FailingMaxAttemptsValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024,
evaluateGate: evaluateGateSpy,
getMollifierBuffer: () => buffer as never,
isMollifierGloballyEnabled: () => true,
});
await expect(
triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: { payload: { test: "x" } },
})
).rejects.toThrow(/synthetic max-attempts failure/);
// Critical: the gate must NEVER be consulted when validation fails.
// If this assertion fires, validation has been re-ordered after the
// mollifier gate — a regression that would let invalid triggers land
// in the buffer.
expect(evaluateGateSpy).not.toHaveBeenCalled();
expect(buffer.accepted).toHaveLength(0);
}
);
containerTest(
"mollifier · mollify action writes to buffer and returns synthetic result (no Postgres row)",
async ({ prisma, redisOptions }) => {
// When the gate decides mollify, the call site
// invokes `mollifyTrigger` which writes the engine.trigger snapshot
// to the buffer and returns a synthesised `MollifySyntheticResult`
// (run.friendlyId + notice + isCached:false). `engine.trigger` is
// NEVER invoked on this path — the run materialises in Postgres
// later, when the drainer replays the snapshot. The replay is
// covered by `mollifierDrainerHandler.test.ts`; this test pins the
// call-site integration: synthetic result + buffer write + no
// Postgres side effect.
const engine = new RunEngine({
prisma,
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
queue: { redis: redisOptions },
runLock: { redis: redisOptions },
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": { name: "small-1x" as const, cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 },
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
onTestFinished(() => engine.quit());
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "test-task";
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
// Buffer override records the time of the accept call so we can
// assert that traceRun fired strictly before the buffer was
// touched. If a future change re-introduces the "skip traceRun on
// mollify" shortcut, traceConcern.traceRunEnteredAt stays
// undefined and the ordering assertion fails.
class TimestampedBuffer extends CapturingMollifierBuffer {
public acceptedAt: number | undefined;
override async accept(input: {
runId: string;
envId: string;
orgId: string;
payload: string;
}) {
this.acceptedAt = Date.now();
return await super.accept(input);
}
}
const buffer = new TimestampedBuffer();
const trippedDecision = {
divert: true as const,
reason: "per_env_rate" as const,
count: 150,
threshold: 100,
windowMs: 200,
holdMs: 500,
};
const traceConcern = new MockTraceEventConcern();
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: new DefaultQueueManager(prisma, engine),
idempotencyKeyConcern: new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
),
validator: new MockTriggerTaskValidator(),
traceEventConcern: traceConcern,
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024,
evaluateGate: async () => ({ action: "mollify", decision: trippedDecision }),
getMollifierBuffer: () => buffer as never,
isMollifierGloballyEnabled: () => true,
});
const result = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: { payload: { hello: "world" } },
});
// Pre-modifier span creation: traceRun must run BEFORE the buffer
// is touched. Customer-visible effect — the run span lands in
// ClickHouse from the moment the trigger returns, even when the
// drainer is offline, so buffered runs are visible in the trace
// view immediately rather than only after drain.
expect(traceConcern.traceRunEnteredAt).toBeDefined();
expect(buffer.acceptedAt).toBeDefined();
expect(traceConcern.traceRunEnteredAt!).toBeLessThanOrEqual(buffer.acceptedAt!);
// Synthetic result is returned with the `mollifier.queued` notice
// (the call-site casts the synthetic shape to `TriggerTaskServiceResult`;
// at runtime the `notice` and `isCached: false` fields are present
// and read by the api.v1.tasks.$taskId.trigger.ts route handler).
expect(result).toBeDefined();
expect(result?.run.friendlyId).toBeDefined();
const synthetic = result as unknown as {
run: { friendlyId: string };
isCached: false;
notice: { code: string; message: string; docs: string };
};
expect(synthetic.isCached).toBe(false);
expect(synthetic.notice.code).toBe("mollifier.queued");
expect(synthetic.notice.message).toBeTypeOf("string");
expect(synthetic.notice.docs).toBeTypeOf("string");
// The mollify branch must flag `isMollified: true` on the result so
// the trigger route can skip `saveRequestIdempotency`. Caching the
// synthetic runId in the request-idempotency table would mean a
// lost-response SDK retry (same `x-trigger-request-idempotency-key`
// header) hits a PG miss in `handleRequestIdempotency` and falls
// through to a fresh trigger — producing a duplicate buffer entry
// for trigger calls without a task-level idempotency key. The
// bounded behaviour (accept retry-as-fresh-trigger during the
// buffer window) is the deliberate choice; a stale-cache lookup
// returning null is not.
expect(result?.isMollified).toBe(true);
// buffer.accept ran — Redis has the canonical engine.trigger snapshot
// under the synthesised friendlyId. The drainer will read this and
// replay it through engine.trigger to materialise the run.
expect(buffer.accepted).toHaveLength(1);
expect(buffer.accepted[0]!.runId).toBe(result!.run.friendlyId);
expect(buffer.accepted[0]!.envId).toBe(authenticatedEnvironment.id);
expect(buffer.accepted[0]!.orgId).toBe(authenticatedEnvironment.organizationId);
// Payload is a JSON-serialised MollifierSnapshot (the engine.trigger
// input). Schema is internal to the engine, so we only assert that
// it parses and references the friendlyId — anything more specific
// would couple the mollifier-layer test to engine-layer fields.
const snapshot = JSON.parse(buffer.accepted[0]!.payload) as {
traceId?: string;
spanId?: string;
traceContext?: { traceparent?: string };
};
// Regression guard for the dashboard trace-tree bug: the mollifier
// snapshot MUST carry a W3C `traceparent` in `traceContext`,
// seeded from the same span traceRun opened. Without it, the
// drainer replays through engine.trigger with empty traceContext
// and every downstream `recordRunDebugLog`
// (QUEUED/EXECUTING/FINISHED/run:notify…) gets a fresh traceId +
// null parentId — the run-detail page can only show the root
// span. Both the mollify and pass-through paths now flow through
// `traceEventConcern.traceRun`; this assertion pins the
// seeding-from-the-run-span contract.
expect(snapshot.traceContext?.traceparent).toMatch(
/^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/
);
expect(snapshot.traceContext!.traceparent).toContain(snapshot.traceId);
expect(snapshot.traceContext!.traceparent).toContain(snapshot.spanId);
// The snapshot inherits the *run span's* traceId/spanId (from the
// event handed in by traceRun), not a separately-generated OTel
// span. This is what lets the drainer's `mollifier.drained` span
// and downstream engine.trigger materialisation parent on the
// same ClickHouse trace the customer sees from the moment trigger
// returns.
expect(snapshot.traceId).toBe(MOCK_TRACE_ID);
expect(snapshot.spanId).toBe(MOCK_SPAN_ID);
// Postgres has NOT been written: engine.trigger was never called on
// the mollify path. The run materialises only when the drainer
// replays the snapshot. Regression intent: if a future change makes
// the mollify branch fall through to engine.trigger (re-introducing
// phase-1 dual-write), this assertion fails loudly.
const pgRun = await prisma.taskRun.findFirst({
where: { friendlyId: result!.run.friendlyId },
});
expect(pgRun).toBeNull();
}
);
containerTest(
"mollifier · pass_through action does NOT call buffer.accept",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
queue: { redis: redisOptions },
runLock: { redis: redisOptions },
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": { name: "small-1x" as const, cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 },
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
onTestFinished(() => engine.quit());
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "test-task";
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
const buffer = new CapturingMollifierBuffer();
const getBufferSpy = vi.fn(() => buffer as never);
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: new DefaultQueueManager(prisma, engine),
idempotencyKeyConcern: new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
),
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024,
evaluateGate: async () => ({ action: "pass_through" }),
getMollifierBuffer: getBufferSpy,
isMollifierGloballyEnabled: () => true,
});
const result = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: { payload: { test: "x" } },
});
expect(result).toBeDefined();
// Postgres has the run, no buffer side-effects
expect(buffer.accepted).toHaveLength(0);
// getMollifierBuffer must not be called either — the call site short-circuits
// before touching the singleton when the gate says pass_through.
expect(getBufferSpy).not.toHaveBeenCalled();
// Pass-through must NOT set `isMollified` — `result.run` is a real
// PG row, and the trigger route's `saveRequestIdempotency` is
// safe to call. Setting the flag here would silently skip the
// request-idempotency cache for every non-mollified trigger on a
// mollifier-enabled org, breaking lost-response retry dedup.
expect(result?.isMollified).toBeFalsy();
}
);
containerTest(
"mollifier · idempotency-key match short-circuits BEFORE the gate is consulted",
async ({ prisma, redisOptions }) => {
// SCENARIO: a trigger arrives with an idempotency key matching an
// already-created run. `IdempotencyKeyConcern.handleTriggerRequest`
// (line 236 of triggerTask.server.ts) detects the match BEFORE the
// mollifier gate runs and returns `{ isCached: true, run }`. The
// service early-returns. The gate is never consulted, buffer.accept
// never fires, no orphan entry is created.
//
// Regression intent: if IdempotencyKeyConcern were re-ordered to run
// AFTER evaluateGate, every idempotent retry on a flagged org would
// produce an orphan buffer entry — the audit-trail invariant ("every
// buffered runId has a matching TaskRun") would silently start failing
// for retries. This test pins the current order.
const engine = new RunEngine({
prisma,
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
queue: { redis: redisOptions },
runLock: { redis: redisOptions },
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": { name: "small-1x" as const, cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 },
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
onTestFinished(() => engine.quit());
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "test-task";
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
const idempotencyKeyConcern = new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
);
// Setup: normal trigger to create the cached run (no mollifier).
const baseline = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: new DefaultQueueManager(prisma, engine),
idempotencyKeyConcern,
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024,
});
const first = await baseline.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: { payload: { test: "x" }, options: { idempotencyKey: "regression-key-5" } },
});
expect(first?.isCached).toBe(false);
// Action: same idempotency key, with a mollify-stub gate that WOULD
// create an orphan if reached. The concern must short-circuit first.
const buffer = new CapturingMollifierBuffer();
const evaluateGateSpy = vi.fn(async () => ({
action: "mollify" as const,
decision: {
divert: true as const,
reason: "per_env_rate" as const,
count: 150,
threshold: 100,
windowMs: 200,
holdMs: 500,
},
}));
const mollifierService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: new DefaultQueueManager(prisma, engine),
idempotencyKeyConcern,
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024,
evaluateGate: evaluateGateSpy,
getMollifierBuffer: () => buffer as never,
isMollifierGloballyEnabled: () => true,
});
const cached = await mollifierService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: { payload: { test: "x" }, options: { idempotencyKey: "regression-key-5" } },
});
// Customer sees the cached run, isCached=true
expect(cached).toBeDefined();
expect(cached?.isCached).toBe(true);
expect(cached?.run.friendlyId).toBe(first?.run.friendlyId);
// Critical: the gate must NEVER be consulted on a cached-idempotency replay.
expect(evaluateGateSpy).not.toHaveBeenCalled();
expect(buffer.accepted).toHaveLength(0);
}
);
});
@@ -0,0 +1,193 @@
import { describe, expect, onTestFinished, vi } from "vitest";
// db.server + splitMode are mocked so the idempotency dedup client resolves to
// the container prisma passed into the concern (split stays off).
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
runOpsNewPrisma: {},
runOpsLegacyPrisma: {},
}));
vi.mock("~/v3/runOpsMigration/splitMode.server", () => ({ isSplitEnabled: async () => false }));
vi.mock("~/services/platform.v3.server", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
getEntitlement: vi.fn(),
};
});
import { RunEngine } from "@internal/run-engine";
import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "@internal/run-engine/tests";
import { containerTest } from "@internal/testcontainers";
import { trace } from "@opentelemetry/api";
import {
RunId,
classifyKind,
generateInternalId,
generateRunOpsId,
} from "@trigger.dev/core/v3/isomorphic";
import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server";
import { DefaultQueueManager } from "~/runEngine/concerns/queues.server";
import { RunEngineTriggerTaskService } from "../../app/runEngine/services/triggerTask.server";
import {
MockPayloadProcessor,
MockTraceEventConcern,
MockTriggerTaskValidator,
} from "./triggerTaskTestHelpers";
vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
describe("RunEngineTriggerTaskService — child run residency inheritance", () => {
// Helper: stand up an engine + service wired for a single (real) Postgres/Redis
// pair. Returns the service plus the authenticated environment and a registered
// task identifier.
async function setupResidencyService(prisma: any, redisOptions: any) {
const engine = new RunEngine({
prisma,
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
queue: { redis: redisOptions },
runLock: { redis: redisOptions },
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
onTestFinished(() => engine.quit());
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "residency-task";
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
const queuesManager = new DefaultQueueManager(prisma, engine);
const idempotencyKeyConcern = new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
);
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1,
});
return { engine, authenticatedEnvironment, taskIdentifier, triggerTaskService };
}
containerTest(
"root run mints by the env flag (cuid when split is off)",
async ({ prisma, redisOptions }) => {
const { authenticatedEnvironment, taskIdentifier, triggerTaskService } =
await setupResidencyService(prisma, redisOptions);
const result = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: { payload: { test: "root" } },
});
expect(result?.run.friendlyId).toBeDefined();
// Split disabled in CI ⇒ flag resolves "cuid".
expect(classifyKind(result!.run.friendlyId)).toBe("cuid");
}
);
containerTest(
"child of a LEGACY (cuid) parent is minted cuid (born LEGACY)",
async ({ prisma, redisOptions }) => {
const { authenticatedEnvironment, taskIdentifier, triggerTaskService } =
await setupResidencyService(prisma, redisOptions);
// Root parent — cuid in CI (split off).
const parent = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: { payload: { test: "parent" } },
});
expect(classifyKind(parent!.run.friendlyId)).toBe("cuid");
const child = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: { payload: { test: "child" }, options: { parentRunId: parent!.run.friendlyId } },
});
expect(classifyKind(child!.run.friendlyId)).toBe("cuid");
}
);
containerTest(
"child of a NEW (run-ops id) parent is minted run-ops id (born NEW)",
async ({ prisma, redisOptions }) => {
const { authenticatedEnvironment, taskIdentifier, triggerTaskService } =
await setupResidencyService(prisma, redisOptions);
// Construct a NEW-resident parent directly by minting a run-ops id friendlyId
// and creating its run row, so the child inherits NEW by id-shape alone
// (no marker needed). We trigger the parent with an explicit run-ops id via
// the runFriendlyId option so the row physically exists for the parent
// lookup the child path performs.
// v1 id (version "1" at index 25) → classifies NEW
const parentFriendlyId = RunId.toFriendlyId(generateRunOpsId());
expect(classifyKind(parentFriendlyId)).toBe("runOpsId");
const parent = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: { payload: { test: "parent" } },
options: { runFriendlyId: parentFriendlyId },
});
expect(parent!.run.friendlyId).toBe(parentFriendlyId);
const child = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: { payload: { test: "child" }, options: { parentRunId: parentFriendlyId } },
});
expect(classifyKind(child!.run.friendlyId)).toBe("runOpsId");
}
);
containerTest(
"caller-supplied runFriendlyId wins verbatim and skips residency inheritance",
async ({ prisma, redisOptions }) => {
const { authenticatedEnvironment, taskIdentifier, triggerTaskService } =
await setupResidencyService(prisma, redisOptions);
// Explicit cuid id for the run, and a run-ops id/NEW parent id.
const explicitFriendlyId = RunId.toFriendlyId(generateInternalId());
const parentFriendlyId = RunId.toFriendlyId(generateRunOpsId());
expect(classifyKind(explicitFriendlyId)).toBe("cuid");
expect(classifyKind(parentFriendlyId)).toBe("runOpsId");
const result = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: { payload: { test: "explicit" }, options: { parentRunId: parentFriendlyId } },
options: { runFriendlyId: explicitFriendlyId },
});
// Caller-supplied id wins verbatim — NOT re-minted to run-ops id despite the NEW parent.
expect(result!.run.friendlyId).toBe(explicitFriendlyId);
}
);
});
+313
View File
@@ -0,0 +1,313 @@
import { describe, expect, onTestFinished, vi } from "vitest";
// db.server + splitMode are mocked so the idempotency dedup client resolves to
// the container prisma passed into the concern (split stays off).
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
runOpsNewPrisma: {},
runOpsLegacyPrisma: {},
}));
vi.mock("~/v3/runOpsMigration/splitMode.server", () => ({ isSplitEnabled: async () => false }));
vi.mock("~/services/platform.v3.server", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
getEntitlement: vi.fn(),
};
});
import { RunEngine } from "@internal/run-engine";
import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "@internal/run-engine/tests";
import { assertNonNullable, containerTest } from "@internal/testcontainers";
import { trace } from "@opentelemetry/api";
import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server";
import { DefaultQueueManager } from "~/runEngine/concerns/queues.server";
import { RunEngineTriggerTaskService } from "../../app/runEngine/services/triggerTask.server";
import { setTimeout } from "node:timers/promises";
import {
MockPayloadProcessor,
MockTraceEventConcern,
MockTriggerTaskValidator,
} from "./triggerTaskTestHelpers";
vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
describe("RunEngineTriggerTaskService", () => {
containerTest("should trigger a task with minimal options", async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: {
redis: redisOptions,
},
runLock: {
redis: redisOptions,
},
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
onTestFinished(() => engine.quit());
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "test-task";
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
const queuesManager = new DefaultQueueManager(prisma, engine);
const idempotencyKeyConcern = new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
);
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: queuesManager,
idempotencyKeyConcern,
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1, // 1MB
});
const result = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: { payload: { test: "test" } },
});
expect(result).toBeDefined();
expect(result?.run.friendlyId).toBeDefined();
expect(result?.run.status).toBe("PENDING");
expect(result?.isCached).toBe(false);
const run = await prisma.taskRun.findFirst({
where: {
id: result?.run.id,
},
});
expect(run).toBeDefined();
expect(run?.friendlyId).toBe(result?.run.friendlyId);
expect(run?.engine).toBe("V2");
expect(run?.queuedAt).toBeDefined();
expect(run?.queue).toBe(`task/${taskIdentifier}`);
// Lets make sure the task is in the queue
const queueLength = await engine.runQueue.lengthOfQueue(
authenticatedEnvironment,
`task/${taskIdentifier}`
);
expect(queueLength).toBe(1);
});
containerTest(
"routes scheduled-lineage runs to a separate worker queue that dequeues independently",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: {
redis: redisOptions,
// Disable the background master-queue consumers so our manual
// processMasterQueueForEnvironment + dequeue calls are deterministic.
masterQueueConsumersDisabled: true,
processWorkerQueueDebounceMs: 50,
},
runLock: { redis: redisOptions },
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
try {
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
// Turn the per-org split flag on in-memory — the resolver reads this
// object directly (no DB round-trip on the trigger hot path).
(authenticatedEnvironment.organization as { featureFlags?: unknown }).featureFlags = {
workerQueueScheduledSplitEnabled: true,
};
const taskIdentifier = "test-task";
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: new DefaultQueueManager(prisma, engine),
idempotencyKeyConcern: new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
),
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1,
});
// A standard run (default triggerSource) stays on the region queue.
const standardResult = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: { payload: { kind: "standard" } },
});
assertNonNullable(standardResult);
// A scheduled run routes to the `<region>:scheduled` queue. Descendants
// would too, via rootTriggerSource propagation.
const scheduledResult = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
body: { payload: { kind: "scheduled" } },
options: { triggerSource: "schedule" },
});
assertNonNullable(scheduledResult);
const standardRun = await prisma.taskRun.findUniqueOrThrow({
where: { id: standardResult.run.id },
});
const scheduledRun = await prisma.taskRun.findUniqueOrThrow({
where: { id: scheduledResult.run.id },
});
// Producer routing: the persisted worker queue carries the class.
const baseWorkerQueue = standardRun.workerQueue;
expect(scheduledRun.workerQueue).toBe(`${baseWorkerQueue}:scheduled`);
// Move both runs from the env queue onto their respective worker queues.
await engine.runQueue.processMasterQueueForEnvironment(authenticatedEnvironment.id, 10);
await setTimeout(500);
// Dequeue isolation: the scheduled queue yields only the scheduled run...
const dequeuedScheduled = await engine.dequeueFromWorkerQueue({
consumerId: "test-scheduled-consumer",
workerQueue: `${baseWorkerQueue}:scheduled`,
});
expect(dequeuedScheduled.length).toBe(1);
assertNonNullable(dequeuedScheduled[0]);
expect(dequeuedScheduled[0].run.id).toBe(scheduledResult.run.id);
// ...and the base queue yields only the standard run.
const dequeuedStandard = await engine.dequeueFromWorkerQueue({
consumerId: "test-standard-consumer",
workerQueue: baseWorkerQueue,
});
expect(dequeuedStandard.length).toBe(1);
assertNonNullable(dequeuedStandard[0]);
expect(dequeuedStandard[0].run.id).toBe(standardResult.run.id);
} finally {
await engine.quit();
}
}
);
// The BatchQueue worker rebuilds body.options from Redis-stored items
// (Record<string, unknown>), so the Phase-2 schema coercion doesn't apply
// to in-flight items enqueued before the schema fix. The defensive
// `typeof === "number"` coercion at the engine.trigger call site is what
// prevents these from failing at prisma.taskRun.create with
// "Argument concurrencyKey: Expected String or Null, provided Int".
containerTest(
"coerces a numeric concurrencyKey to a string at the engine.trigger boundary",
async ({ prisma, redisOptions }) => {
const engine = new RunEngine({
prisma,
worker: {
redis: redisOptions,
workers: 1,
tasksPerWorker: 10,
pollIntervalMs: 100,
},
queue: { redis: redisOptions },
runLock: { redis: redisOptions },
machines: {
defaultMachine: "small-1x",
machines: {
"small-1x": {
name: "small-1x" as const,
cpu: 0.5,
memory: 0.5,
centsPerMs: 0.0001,
},
},
baseCostInCents: 0.0005,
},
tracer: trace.getTracer("test", "0.0.0"),
});
onTestFinished(() => engine.quit());
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const taskIdentifier = "test-task";
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
const triggerTaskService = new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: new DefaultQueueManager(prisma, engine),
idempotencyKeyConcern: new IdempotencyKeyConcern(
prisma,
engine,
new MockTraceEventConcern()
),
validator: new MockTriggerTaskValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1,
});
const result = await triggerTaskService.call({
taskId: taskIdentifier,
environment: authenticatedEnvironment,
// Cast through `any` to simulate the in-flight Redis batch-item shape
// (Record<string, unknown>) that bypasses the BatchItemNDJSON schema.
body: { payload: { userId: 51262 }, options: { concurrencyKey: 51262 as any } },
});
expect(result).toBeDefined();
const run = await prisma.taskRun.findFirst({ where: { id: result!.run.id } });
expect(run?.concurrencyKey).toBe("51262");
}
);
});
@@ -0,0 +1,153 @@
// Shared mock implementations for the triggerTask engine test suite. These are
// extracted so the suite can be split across several *.test.ts files (vitest
// shards by whole file) without duplicating the mocks. No `vi.mock` lives here:
// module mocks are hoisted per-file and must stay in each test file.
import { promiseWithResolvers } from "@trigger.dev/core";
import type { IOPacket } from "@trigger.dev/core/v3";
import type { TaskRun } from "@trigger.dev/database";
import type {
EntitlementValidationParams,
MaxAttemptsValidationParams,
ParentRunValidationParams,
PayloadProcessor,
TagValidationParams,
TracedEventSpan,
TraceEventConcern,
TriggerRacepoints,
TriggerRacepointSystem,
TriggerTaskRequest,
TriggerTaskValidator,
ValidationResult,
} from "~/runEngine/types";
export class MockPayloadProcessor implements PayloadProcessor {
async process(request: TriggerTaskRequest): Promise<IOPacket> {
return {
data: JSON.stringify(request.body.payload),
dataType: "application/json",
};
}
}
export class MockTriggerTaskValidator implements TriggerTaskValidator {
validateTags(params: TagValidationParams): ValidationResult {
return { ok: true };
}
validateEntitlement(params: EntitlementValidationParams): Promise<ValidationResult> {
return Promise.resolve({ ok: true });
}
validateMaxAttempts(params: MaxAttemptsValidationParams): ValidationResult {
return { ok: true };
}
validateParentRun(params: ParentRunValidationParams): ValidationResult {
return { ok: true };
}
}
// Mirror the production ClickhouseEventRepository.traceEvent shape so
// callers that read `event.traceContext.traceparent` (e.g. the
// mollifier branch seeding the snapshot) get the same W3C-formatted
// value they'd get against a real event repository.
export const MOCK_TRACE_ID = "0123456789abcdef0123456789abcdef";
export const MOCK_SPAN_ID = "fedcba9876543210";
const MOCK_TRACEPARENT = `00-${MOCK_TRACE_ID}-${MOCK_SPAN_ID}-01`;
export class MockTraceEventConcern implements TraceEventConcern {
// Records the start time of the most recent traceRun callback entry.
// Used by ordering assertions that verify traceRun fires before
// downstream side effects (e.g. mollifier buffer writes).
public traceRunEnteredAt: number | undefined;
async traceRun<T>(
request: TriggerTaskRequest,
parentStore: string | undefined,
callback: (span: TracedEventSpan, store: string) => Promise<T>
): Promise<T> {
this.traceRunEnteredAt = Date.now();
return await callback(
{
traceId: MOCK_TRACE_ID,
spanId: MOCK_SPAN_ID,
traceContext: { traceparent: MOCK_TRACEPARENT },
traceparent: undefined,
setAttribute: () => {},
failWithError: () => {},
stop: () => {},
},
"test"
);
}
async traceIdempotentRun<T>(
request: TriggerTaskRequest,
parentStore: string | undefined,
options: {
existingRun: TaskRun;
idempotencyKey: string;
incomplete: boolean;
isError: boolean;
},
callback: (span: TracedEventSpan, store: string) => Promise<T>
): Promise<T> {
return await callback(
{
traceId: "test",
spanId: "test",
traceContext: {},
traceparent: undefined,
setAttribute: () => {},
failWithError: () => {},
stop: () => {},
},
"test"
);
}
async traceDebouncedRun<T>(
request: TriggerTaskRequest,
parentStore: string | undefined,
options: {
existingRun: TaskRun;
debounceKey: string;
incomplete: boolean;
isError: boolean;
},
callback: (span: TracedEventSpan, store: string) => Promise<T>
): Promise<T> {
return await callback(
{
traceId: "test",
spanId: "test",
traceContext: {},
traceparent: undefined,
setAttribute: () => {},
failWithError: () => {},
stop: () => {},
},
"test"
);
}
}
type TriggerRacepoint = { promise: Promise<void>; resolve: (value: void) => void };
export class MockTriggerRacepointSystem implements TriggerRacepointSystem {
private racepoints: Record<string, TriggerRacepoint | undefined> = {};
async waitForRacepoint({ id }: { racepoint: TriggerRacepoints; id: string }): Promise<void> {
const racepoint = this.racepoints[id];
if (racepoint) {
return racepoint.promise;
}
return Promise.resolve();
}
registerRacepoint(racepoint: TriggerRacepoints, id: string): TriggerRacepoint {
const { promise, resolve } = promiseWithResolvers<void>();
this.racepoints[id] = { promise, resolve };
return { promise, resolve };
}
}
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import {
exceptDevEnvironments,
filterOrphanedEnvironments,
onlyDevEnvironments,
sortEnvironments,
} from "~/utils/environmentSort";
describe("sortEnvironments", () => {
it("orders by environment type first (dev, staging, preview, prod)", () => {
const sorted = sortEnvironments([
{ type: "PRODUCTION" },
{ type: "PREVIEW" },
{ type: "DEVELOPMENT" },
{ type: "STAGING" },
]);
expect(sorted.map((e) => e.type)).toEqual(["DEVELOPMENT", "STAGING", "PREVIEW", "PRODUCTION"]);
});
it("sorts same-type rows by lastActivity desc when both have it", () => {
const older = new Date("2026-06-01T00:00:00Z");
const newer = new Date("2026-06-20T00:00:00Z");
const sorted = sortEnvironments([
{ type: "DEVELOPMENT", userName: "a", lastActivity: older },
{ type: "DEVELOPMENT", userName: "b", lastActivity: newer },
]);
// Most recently active branch first.
expect(sorted.map((e) => e.userName)).toEqual(["b", "a"]);
});
it("falls back to updatedAt desc when neither row has lastActivity", () => {
const older = new Date("2026-06-01T00:00:00Z");
const newer = new Date("2026-06-20T00:00:00Z");
const sorted = sortEnvironments([
{ type: "DEVELOPMENT", userName: "a", updatedAt: older },
{ type: "DEVELOPMENT", userName: "b", updatedAt: newer },
]);
// Most recently updated branch first.
expect(sorted.map((e) => e.userName)).toEqual(["b", "a"]);
});
it("uses a row's lastActivity over its own stale updatedAt", () => {
const staleUpdate = new Date("2026-06-01T00:00:00Z");
const recentActivity = new Date("2026-06-26T00:00:00Z");
const otherUpdate = new Date("2026-06-10T00:00:00Z");
// 'a' has a stale updatedAt but recent dev activity; 'b' has only a (more
// recent than a's update) updatedAt. If activity weren't preferred, a's
// stale 06-01 would lose to b's 06-10; instead a's 06-26 activity wins.
const sorted = sortEnvironments([
{ type: "DEVELOPMENT", userName: "b", updatedAt: otherUpdate },
{ type: "DEVELOPMENT", userName: "a", updatedAt: staleUpdate, lastActivity: recentActivity },
]);
expect(sorted.map((e) => e.userName)).toEqual(["a", "b"]);
});
it("orders rows with any timestamp ahead of rows with none", () => {
const sorted = sortEnvironments([
{ type: "DEVELOPMENT", userName: "no-timestamp" },
{ type: "DEVELOPMENT", userName: "has-update", updatedAt: new Date("2026-06-10T00:00:00Z") },
]);
expect(sorted.map((e) => e.userName)).toEqual(["has-update", "no-timestamp"]);
});
it("falls back to username order when lastActivity is absent (the ZSET-missing case)", () => {
// When the recency ZSET is missing/evicted, lastActivity is undefined for
// every branch, and the list must still render in a stable order.
const sorted = sortEnvironments([
{ type: "DEVELOPMENT", userName: "charlie" },
{ type: "DEVELOPMENT", userName: "alice" },
{ type: "DEVELOPMENT", userName: "bob" },
]);
expect(sorted.map((e) => e.userName)).toEqual(["alice", "bob", "charlie"]);
});
});
describe("filterOrphanedEnvironments", () => {
it("drops DEVELOPMENT envs with no owning org member", () => {
const result = filterOrphanedEnvironments([
{ type: "DEVELOPMENT", orgMemberId: "om_1" },
{ type: "DEVELOPMENT", orgMemberId: undefined },
{ type: "PRODUCTION" } as any,
]);
expect(result).toEqual([{ type: "DEVELOPMENT", orgMemberId: "om_1" }, { type: "PRODUCTION" }]);
});
it("keeps DEVELOPMENT envs whose orgMember relation is loaded", () => {
const result = filterOrphanedEnvironments([
{ type: "DEVELOPMENT", orgMember: { id: "om_1" } },
{ type: "DEVELOPMENT", orgMember: undefined } as any,
]);
expect(result).toEqual([{ type: "DEVELOPMENT", orgMember: { id: "om_1" } }]);
});
it("never filters non-development environments", () => {
const envs = [{ type: "PREVIEW" }, { type: "STAGING" }, { type: "PRODUCTION" }] as any[];
expect(filterOrphanedEnvironments(envs)).toEqual(envs);
});
});
describe("onlyDevEnvironments / exceptDevEnvironments", () => {
const envs = [{ type: "DEVELOPMENT" }, { type: "PREVIEW" }, { type: "PRODUCTION" }] as const;
it("partitions on the development type", () => {
expect(onlyDevEnvironments([...envs])).toEqual([{ type: "DEVELOPMENT" }]);
expect(exceptDevEnvironments([...envs])).toEqual([{ type: "PREVIEW" }, { type: "PRODUCTION" }]);
});
});
@@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import type { EnvironmentVariable } from "../app/v3/environmentVariables/repository";
import { deduplicateVariableArray } from "~/v3/deduplicateVariableArray.server";
describe("Deduplicate variables", () => {
it("should keep later variables when there are duplicates", () => {
const variables: EnvironmentVariable[] = [
{ key: "API_KEY", value: "old_value" },
{ key: "API_KEY", value: "new_value" },
];
const result = deduplicateVariableArray(variables);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ key: "API_KEY", value: "new_value" });
});
it("should preserve order of unique variables", () => {
const variables: EnvironmentVariable[] = [
{ key: "FIRST", value: "first" },
{ key: "SECOND", value: "second" },
{ key: "THIRD", value: "third" },
];
const result = deduplicateVariableArray(variables);
expect(result).toHaveLength(3);
expect(result[0].key).toBe("FIRST");
expect(result[1].key).toBe("SECOND");
expect(result[2].key).toBe("THIRD");
});
it("should handle multiple duplicates with later values taking precedence", () => {
const variables: EnvironmentVariable[] = [
{ key: "DB_URL", value: "old_db" },
{ key: "API_KEY", value: "old_key" },
{ key: "DB_URL", value: "new_db" },
{ key: "API_KEY", value: "new_key" },
];
const result = deduplicateVariableArray(variables);
expect(result).toHaveLength(2);
expect(result.find((v) => v.key === "DB_URL")?.value).toBe("new_db");
expect(result.find((v) => v.key === "API_KEY")?.value).toBe("new_key");
});
it("should handle empty array", () => {
const result = deduplicateVariableArray([]);
expect(result).toEqual([]);
});
it("should handle array with no duplicates", () => {
const variables: EnvironmentVariable[] = [
{ key: "VAR1", value: "value1" },
{ key: "VAR2", value: "value2" },
];
const result = deduplicateVariableArray(variables);
expect(result).toHaveLength(2);
expect(result).toEqual(variables);
});
});
@@ -0,0 +1,103 @@
import { describe, it, expect } from "vitest";
import type { EnvironmentVariable } from "../app/v3/environmentVariables/repository";
import {
isBlacklistedVariable,
isReservedForExternalSync,
removeBlacklistedVariables,
} from "~/v3/environmentVariableRules.server";
describe("removeBlacklistedVariables", () => {
it("should remove exact match blacklisted variables", () => {
const variables: EnvironmentVariable[] = [
{ key: "TRIGGER_SECRET_KEY", value: "secret123" },
{ key: "TRIGGER_API_URL", value: "https://api.example.com" },
{ key: "NORMAL_VAR", value: "normal" },
];
const result = removeBlacklistedVariables(variables);
expect(result).toEqual([{ key: "NORMAL_VAR", value: "normal" }]);
});
it("should handle empty input array", () => {
const variables: EnvironmentVariable[] = [];
const result = removeBlacklistedVariables(variables);
expect(result).toEqual([]);
});
it("should handle mixed case variables", () => {
const variables: EnvironmentVariable[] = [
{ key: "trigger_secret_key", value: "secret123" }, // Different case
{ key: "NORMAL_VAR", value: "normal" },
];
const result = removeBlacklistedVariables(variables);
// Should keep only the whitelisted OTEL_LOG_LEVEL and NORMAL_VAR
// Note: The function is case-sensitive, so different case variables should pass through
expect(result).toEqual([
{ key: "trigger_secret_key", value: "secret123" },
{ key: "NORMAL_VAR", value: "normal" },
]);
});
it("should handle variables with empty values", () => {
const variables: EnvironmentVariable[] = [
{ key: "TRIGGER_SECRET_KEY", value: "" },
{ key: "NORMAL_VAR", value: "" },
];
const result = removeBlacklistedVariables(variables);
expect(result).toEqual([{ key: "NORMAL_VAR", value: "" }]);
});
it("should handle all types of rules in a single array", () => {
const variables: EnvironmentVariable[] = [
// Exact matches (should be removed)
{ key: "TRIGGER_SECRET_KEY", value: "secret123" },
{ key: "TRIGGER_API_URL", value: "https://api.example.com" },
// Normal variables (should be kept)
{ key: "NORMAL_VAR", value: "normal" },
{ key: "DATABASE_URL", value: "postgres://..." },
];
const result = removeBlacklistedVariables(variables);
expect(result).toEqual([
{ key: "NORMAL_VAR", value: "normal" },
{ key: "DATABASE_URL", value: "postgres://..." },
]);
});
});
describe("isBlacklistedVariable", () => {
it("blacklists the platform-managed keys", () => {
expect(isBlacklistedVariable("TRIGGER_SECRET_KEY")).toBe(true);
expect(isBlacklistedVariable("TRIGGER_API_URL")).toBe(true);
});
it("allows ordinary user keys", () => {
expect(isBlacklistedVariable("DATABASE_URL")).toBe(false);
expect(isBlacklistedVariable("MY_API_KEY")).toBe(false);
});
});
describe("isReservedForExternalSync", () => {
it("reserves every key the repository would reject", () => {
expect(isReservedForExternalSync("TRIGGER_SECRET_KEY")).toBe(true);
expect(isReservedForExternalSync("TRIGGER_API_URL")).toBe(true);
});
it("reserves deploy-managed keys that are not blacklisted", () => {
expect(isReservedForExternalSync("TRIGGER_VERSION")).toBe(true);
expect(isReservedForExternalSync("TRIGGER_PREVIEW_BRANCH")).toBe(true);
});
it("does not reserve ordinary user keys", () => {
expect(isReservedForExternalSync("DATABASE_URL")).toBe(false);
expect(isReservedForExternalSync("MY_API_KEY")).toBe(false);
});
});
@@ -0,0 +1,147 @@
import { describe, expect, vi } from "vitest";
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
$transaction: async (
prismaClient: {
$transaction: (fn: (tx: unknown) => Promise<unknown>) => Promise<unknown>;
},
nameOrFn: string | ((tx: unknown) => Promise<unknown>),
fnOrOptions?: ((tx: unknown) => Promise<unknown>) | unknown
) => {
const fn =
typeof nameOrFn === "string" ? (fnOrOptions as (tx: unknown) => Promise<unknown>) : nameOrFn;
return prismaClient.$transaction(fn);
},
}));
import { postgresTest } from "@internal/testcontainers";
import { loadEnvironmentVariablesEnvironments } from "~/presenters/v3/environmentVariablesEnvironments.server";
import {
createRuntimeEnvironment,
createTestOrgProjectWithMember,
createTestUser,
} from "./fixtures/environmentVariablesFixtures";
vi.setConfig({ testTimeout: 60_000 });
describe("loadEnvironmentVariablesEnvironments", () => {
postgresTest("returns environments for a project member", async ({ prisma }) => {
const { user, organization, project } = await createTestOrgProjectWithMember(prisma);
const production = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
});
const result = await loadEnvironmentVariablesEnvironments(prisma, {
userId: user.id,
projectId: project.id,
});
expect(result.environments.map((environment) => environment.id)).toContain(production.id);
expect(result.environments.every((environment) => typeof environment.id === "string")).toBe(
true
);
});
postgresTest("rejects users who are not project members", async ({ prisma }) => {
const { project } = await createTestOrgProjectWithMember(prisma);
const outsider = await createTestUser(prisma);
await expect(
loadEnvironmentVariablesEnvironments(prisma, {
userId: outsider.id,
projectId: project.id,
})
).rejects.toThrow("Project not found");
});
postgresTest("filters shared, personal, and inaccessible environments", async ({ prisma }) => {
const { user, organization, project, orgMember } = await createTestOrgProjectWithMember(prisma);
const otherUser = await createTestUser(prisma);
const otherOrgMember = await prisma.orgMember.create({
data: {
organizationId: organization.id,
userId: otherUser.id,
role: "MEMBER",
},
});
const sharedProduction = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
});
const currentUserDev = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "DEVELOPMENT",
orgMemberId: orgMember.id,
});
const otherUserDev = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "DEVELOPMENT",
orgMemberId: otherOrgMember.id,
});
const orphanedDev = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "DEVELOPMENT",
orgMemberId: null,
});
const result = await loadEnvironmentVariablesEnvironments(prisma, {
userId: user.id,
projectId: project.id,
});
const environmentIds = result.environments.map((environment) => environment.id);
expect(environmentIds).toContain(sharedProduction.id);
expect(environmentIds).toContain(currentUserDev.id);
expect(environmentIds).not.toContain(otherUserDev.id);
expect(environmentIds).not.toContain(orphanedDev.id);
});
postgresTest("returns hasStaging true when a staging environment exists", async ({ prisma }) => {
const { user, organization, project } = await createTestOrgProjectWithMember(prisma);
await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "STAGING",
});
const result = await loadEnvironmentVariablesEnvironments(prisma, {
userId: user.id,
projectId: project.id,
});
expect(result.hasStaging).toBe(true);
});
postgresTest(
"returns hasStaging false when no staging environment exists",
async ({ prisma }) => {
const { user, organization, project } = await createTestOrgProjectWithMember(prisma);
await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
});
const result = await loadEnvironmentVariablesEnvironments(prisma, {
userId: user.id,
projectId: project.id,
});
expect(result.hasStaging).toBe(false);
}
);
});
@@ -0,0 +1,183 @@
import { describe, expect, vi } from "vitest";
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
$transaction: async (
prismaClient: {
$transaction: (fn: (tx: unknown) => Promise<unknown>) => Promise<unknown>;
},
nameOrFn: string | ((tx: unknown) => Promise<unknown>),
fnOrOptions?: ((tx: unknown) => Promise<unknown>) | unknown
) => {
const fn =
typeof nameOrFn === "string" ? (fnOrOptions as (tx: unknown) => Promise<unknown>) : nameOrFn;
return prismaClient.$transaction(fn);
},
}));
import { postgresTest } from "@internal/testcontainers";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import {
createEnvironmentVariable,
createRuntimeEnvironment,
createTestOrgProjectWithMember,
} from "./fixtures/environmentVariablesFixtures";
vi.setConfig({ testTimeout: 60_000 });
describe("EnvironmentVariablesRepository.getVariableValuesForKeys", () => {
postgresTest("returns an empty map for an empty items array", async ({ prisma }) => {
const { project } = await createTestOrgProjectWithMember(prisma);
const repository = new EnvironmentVariablesRepository(prisma, prisma);
const result = await repository.getVariableValuesForKeys(project.id, []);
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(0);
});
postgresTest("omits missing keys from the result without throwing", async ({ prisma }) => {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const environment = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
});
const repository = new EnvironmentVariablesRepository(prisma, prisma);
const result = await repository.getVariableValuesForKeys(project.id, [
{ environmentId: environment.id, key: "DOES_NOT_EXIST" },
]);
expect(result.size).toBe(0);
expect(result.has(`${environment.id}:DOES_NOT_EXIST`)).toBe(false);
});
postgresTest(
"returns requested values with correct map keys and decrypted values",
async ({ prisma }) => {
const { user, organization, project } = await createTestOrgProjectWithMember(prisma);
const environment = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
});
const repository = new EnvironmentVariablesRepository(prisma, prisma);
await createEnvironmentVariable(repository, project.id, {
environmentId: environment.id,
key: "VAR_A",
value: "value-a",
userId: user.id,
});
await createEnvironmentVariable(repository, project.id, {
environmentId: environment.id,
key: "VAR_B",
value: "value-b",
userId: user.id,
});
await createEnvironmentVariable(repository, project.id, {
environmentId: environment.id,
key: "VAR_C",
value: "value-c",
userId: user.id,
});
const result = await repository.getVariableValuesForKeys(project.id, [
{ environmentId: environment.id, key: "VAR_A" },
{ environmentId: environment.id, key: "VAR_C" },
]);
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(2);
expect(result.get(`${environment.id}:VAR_A`)).toBe("value-a");
expect(result.get(`${environment.id}:VAR_C`)).toBe("value-c");
expect(result.has(`${environment.id}:VAR_B`)).toBe(false);
}
);
postgresTest("deduplicates duplicate environmentId and key requests", async ({ prisma }) => {
const { user, organization, project } = await createTestOrgProjectWithMember(prisma);
const environment = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
});
const repository = new EnvironmentVariablesRepository(prisma, prisma);
await createEnvironmentVariable(repository, project.id, {
environmentId: environment.id,
key: "DEDUP_KEY",
value: "dedup-value",
userId: user.id,
});
const request = { environmentId: environment.id, key: "DEDUP_KEY" };
const result = await repository.getVariableValuesForKeys(project.id, [
request,
request,
request,
]);
expect(result.size).toBe(1);
expect(result.get(`${environment.id}:DEDUP_KEY`)).toBe("dedup-value");
});
postgresTest("isolates values by project", async ({ prisma }) => {
const { user, organization, project: projectA } = await createTestOrgProjectWithMember(prisma);
const projectB = await prisma.project.create({
data: {
name: "Project B",
slug: `proj-b-${Date.now()}`,
organizationId: organization.id,
externalRef: `ext-b-${Date.now()}`,
},
});
const envA = await createRuntimeEnvironment(prisma, {
projectId: projectA.id,
organizationId: organization.id,
type: "PRODUCTION",
});
const envB = await createRuntimeEnvironment(prisma, {
projectId: projectB.id,
organizationId: organization.id,
type: "PRODUCTION",
});
const repository = new EnvironmentVariablesRepository(prisma, prisma);
await createEnvironmentVariable(repository, projectA.id, {
environmentId: envA.id,
key: "SHARED_KEY",
value: "project-a-value",
userId: user.id,
});
await createEnvironmentVariable(repository, projectB.id, {
environmentId: envB.id,
key: "SHARED_KEY",
value: "project-b-value",
userId: user.id,
});
const resultForProjectA = await repository.getVariableValuesForKeys(projectA.id, [
{ environmentId: envA.id, key: "SHARED_KEY" },
]);
expect(resultForProjectA.size).toBe(1);
expect(resultForProjectA.get(`${envA.id}:SHARED_KEY`)).toBe("project-a-value");
expect(resultForProjectA.get(`${envB.id}:SHARED_KEY`)).toBeUndefined();
const crossProjectRequest = await repository.getVariableValuesForKeys(projectA.id, [
{ environmentId: envB.id, key: "SHARED_KEY" },
]);
expect(crossProjectRequest.size).toBe(0);
});
});
@@ -0,0 +1,449 @@
import { describe, it, expect } from "vitest";
import {
calculateErrorFingerprint,
normalizeErrorMessage,
normalizeStackTrace,
} from "~/utils/errorFingerprinting";
describe("normalizeErrorMessage", () => {
it("should normalize UUIDs", () => {
const message = "Error processing user 550e8400-e29b-41d4-a716-446655440000";
const normalized = normalizeErrorMessage(message);
expect(normalized).toBe("Error processing user <uuid>");
});
it("should normalize run IDs", () => {
const message = "Failed to execute run_abcd1234xyz";
const normalized = normalizeErrorMessage(message);
expect(normalized).toBe("Failed to execute <run-id>");
});
it("should normalize task friendly IDs", () => {
const message = "Task task_abc12345678 failed";
const normalized = normalizeErrorMessage(message);
expect(normalized).toBe("Task <id> failed");
});
it("should normalize numeric IDs (4+ digits)", () => {
const message = "User 12345 not found";
const normalized = normalizeErrorMessage(message);
expect(normalized).toBe("User <id> not found");
});
it("should not normalize short numbers", () => {
const message = "Retry attempt 3 of 5";
const normalized = normalizeErrorMessage(message);
expect(normalized).toBe("Retry attempt 3 of 5");
});
it("should normalize ISO 8601 timestamps", () => {
const message = "Event at 2024-03-01T15:30:45Z failed";
const normalized = normalizeErrorMessage(message);
expect(normalized).toBe("Event at <timestamp> failed");
});
it("should normalize ISO timestamps with milliseconds", () => {
const message = "Timeout at 2024-03-01T15:30:45.123Z";
const normalized = normalizeErrorMessage(message);
expect(normalized).toBe("Timeout at <timestamp>");
});
it("should normalize Unix timestamps", () => {
const message = "Created at 1234567890";
const normalized = normalizeErrorMessage(message);
expect(normalized).toBe("Created at <timestamp>");
});
it("should normalize Unix timestamps (milliseconds)", () => {
const message = "Created at 1234567890123";
const normalized = normalizeErrorMessage(message);
expect(normalized).toBe("Created at <timestamp>");
});
it("should normalize Unix file paths", () => {
const message = "Cannot read /home/user/project/file.ts";
const normalized = normalizeErrorMessage(message);
expect(normalized).toBe("Cannot read <path>");
});
it("should normalize Windows file paths", () => {
const message = "Cannot read C:\\Users\\John\\project\\file.ts";
const normalized = normalizeErrorMessage(message);
expect(normalized).toBe("Cannot read <path>");
});
it("should normalize email addresses", () => {
const message = "Email user@example.com already exists";
const normalized = normalizeErrorMessage(message);
expect(normalized).toBe("Email <email> already exists");
});
it("should normalize URLs", () => {
const message = "Failed to fetch https://api.example.com/users/123";
const normalized = normalizeErrorMessage(message);
expect(normalized).toBe("Failed to fetch <url>");
});
it("should normalize HTTP URLs", () => {
const message = "Request to http://localhost:3000/api failed";
const normalized = normalizeErrorMessage(message);
expect(normalized).toBe("Request to <url> failed");
});
it("should normalize memory addresses", () => {
const message = "Segfault at 0x7fff5fbffab0";
const normalized = normalizeErrorMessage(message);
expect(normalized).toBe("Segfault at <addr>");
});
it("should normalize long quoted strings", () => {
const message = 'Error: "this is a very long error message with dynamic content that changes"';
const normalized = normalizeErrorMessage(message);
expect(normalized).toBe('Error: "<string>"');
});
it("should handle multiple replacements", () => {
const message =
"User 12345 at user@example.com failed to access run_abc123 at 2024-03-01T15:30:45Z";
const normalized = normalizeErrorMessage(message);
expect(normalized).toBe("User <id> at <email> failed to access <run-id> at <timestamp>");
});
it("should return empty string for empty input", () => {
expect(normalizeErrorMessage("")).toBe("");
});
it("should handle messages with no dynamic content", () => {
const message = "Connection timeout";
const normalized = normalizeErrorMessage(message);
expect(normalized).toBe("Connection timeout");
});
describe("ordering: specific patterns before generic ones", () => {
it("ISO timestamp year should not be consumed by numeric ID regex", () => {
const message = "Deadline was 2025-12-31T23:59:59Z";
expect(normalizeErrorMessage(message)).toBe("Deadline was <timestamp>");
});
it("ISO timestamp without trailing Z should normalize correctly", () => {
const message = "Started at 2024-01-15T08:00:00";
expect(normalizeErrorMessage(message)).toBe("Started at <timestamp>");
});
it("Unix timestamp (10 digits) should not become <id>", () => {
const message = "Token expires 1700000000";
expect(normalizeErrorMessage(message)).toBe("Token expires <timestamp>");
});
it("Unix timestamp (13 digits) should not become <id>", () => {
const message = "Sent at 1700000000000";
expect(normalizeErrorMessage(message)).toBe("Sent at <timestamp>");
});
it("URL path should not be stripped before URL regex runs", () => {
const message = "Webhook failed for https://hooks.example.com/webhook/abc";
expect(normalizeErrorMessage(message)).toBe("Webhook failed for <url>");
});
it("URL with port and path should normalize to <url>", () => {
const message = "Cannot reach http://localhost:8080/health/ready";
expect(normalizeErrorMessage(message)).toBe("Cannot reach <url>");
});
it("URL with query string should normalize to <url>", () => {
const message = "GET https://api.example.com/v2/users?page=1&limit=50 returned 500";
expect(normalizeErrorMessage(message)).toBe("GET <url> returned 500");
});
it("message with both a URL and a timestamp", () => {
const message = "Request to https://api.example.com/data failed at 2025-06-15T10:30:00Z";
expect(normalizeErrorMessage(message)).toBe("Request to <url> failed at <timestamp>");
});
it("message with a URL and a unix timestamp", () => {
const message = "Callback to https://example.com/hook timed out after 1700000000";
expect(normalizeErrorMessage(message)).toBe("Callback to <url> timed out after <timestamp>");
});
it("path-like string that is NOT a URL should still become <path>", () => {
const message = "Cannot read /var/log/app/error.log";
expect(normalizeErrorMessage(message)).toBe("Cannot read <path>");
});
});
describe("fingerprint stability: same error class groups together despite dynamic values", () => {
it("errors differing only in ISO timestamp should share a fingerprint", () => {
const e1 = { type: "TimeoutError", message: "Timed out at 2025-01-01T00:00:00Z" };
const e2 = { type: "TimeoutError", message: "Timed out at 2026-06-15T12:30:00Z" };
expect(calculateErrorFingerprint(e1)).toBe(calculateErrorFingerprint(e2));
});
it("errors differing only in URL path should share a fingerprint", () => {
const e1 = {
type: "FetchError",
message: "Failed to fetch https://api.example.com/users/123",
};
const e2 = {
type: "FetchError",
message: "Failed to fetch https://api.example.com/orders/456",
};
expect(calculateErrorFingerprint(e1)).toBe(calculateErrorFingerprint(e2));
});
it("errors differing only in unix timestamp should share a fingerprint", () => {
const e1 = { type: "ExpiredError", message: "Token expired at 1700000000" };
const e2 = { type: "ExpiredError", message: "Token expired at 1800000000" };
expect(calculateErrorFingerprint(e1)).toBe(calculateErrorFingerprint(e2));
});
});
});
describe("normalizeStackTrace", () => {
it("should normalize line and column numbers", () => {
const stack = `Error: Test error
at functionName (file.ts:123:45)
at anotherFunction (other.ts:67:89)`;
const normalized = normalizeStackTrace(stack);
expect(normalized).toContain(":_:_");
expect(normalized).not.toContain(":123:45");
});
it("should remove standalone numbers", () => {
const stack = `Error: Test
at Object.<anonymous> (/path/to/file.ts:123:45)
at Module._compile (node:internal/modules/cjs/loader:456:78)`;
const normalized = normalizeStackTrace(stack);
expect(normalized).not.toMatch(/\b\d+\b/);
});
it("should keep only first 5 frames", () => {
const stack = `Error: Test
at frame1 (file1.ts:1:1)
at frame2 (file2.ts:2:2)
at frame3 (file3.ts:3:3)
at frame4 (file4.ts:4:4)
at frame5 (file5.ts:5:5)
at frame6 (file6.ts:6:6)
at frame7 (file7.ts:7:7)`;
const normalized = normalizeStackTrace(stack);
const frames = normalized.split("|");
expect(frames.length).toBeLessThanOrEqual(5);
});
it("should remove file paths but keep filenames", () => {
const stack = `Error: Test
at functionName (/home/user/project/src/file.ts:123:45)`;
const normalized = normalizeStackTrace(stack);
expect(normalized).toContain("file.ts");
expect(normalized).not.toContain("/home/user/project/src/");
});
it("should filter out empty lines", () => {
const stack = `Error: Test
at functionName (file.ts:123:45)
at anotherFunction (other.ts:67:89)`;
const normalized = normalizeStackTrace(stack);
const frames = normalized.split("|").filter((f) => f.length > 0);
expect(frames.length).toBeLessThanOrEqual(3);
});
it("should return empty string for empty stack", () => {
expect(normalizeStackTrace("")).toBe("");
});
it("should join frames with pipe delimiter", () => {
const stack = `Error: Test
at frame1 (file1.ts:1:1)
at frame2 (file2.ts:2:2)`;
const normalized = normalizeStackTrace(stack);
expect(normalized).toContain("|");
});
});
describe("calculateErrorFingerprint", () => {
it("should generate consistent fingerprints for same error", () => {
const error = {
type: "DatabaseError",
message: "Connection timeout",
stack: "at db.connect (db.ts:123:45)",
};
const fp1 = calculateErrorFingerprint(error);
const fp2 = calculateErrorFingerprint(error);
expect(fp1).toBe(fp2);
expect(fp1.length).toBe(16);
});
it("should generate same fingerprint for errors with different IDs", () => {
const error1 = {
type: "NotFoundError",
message: "User 12345 not found",
stack: "at findUser (user.ts:50:10)",
};
const error2 = {
type: "NotFoundError",
message: "User 67890 not found",
stack: "at findUser (user.ts:50:10)",
};
const fp1 = calculateErrorFingerprint(error1);
const fp2 = calculateErrorFingerprint(error2);
expect(fp1).toBe(fp2);
});
it("should generate same fingerprint for errors with different UUIDs", () => {
const error1 = {
type: "ValidationError",
message: "Invalid token 550e8400-e29b-41d4-a716-446655440000",
};
const error2 = {
type: "ValidationError",
message: "Invalid token 123e4567-e89b-12d3-a456-426614174000",
};
expect(calculateErrorFingerprint(error1)).toBe(calculateErrorFingerprint(error2));
});
it("should generate same fingerprint for errors with different run IDs", () => {
const error1 = {
type: "TaskError",
message: "Failed to execute run_abc123",
};
const error2 = {
type: "TaskError",
message: "Failed to execute run_xyz789",
};
expect(calculateErrorFingerprint(error1)).toBe(calculateErrorFingerprint(error2));
});
it("should generate different fingerprints for different error types", () => {
const error1 = {
type: "DatabaseError",
message: "Connection failed",
};
const error2 = {
type: "NetworkError",
message: "Connection failed",
};
expect(calculateErrorFingerprint(error1)).not.toBe(calculateErrorFingerprint(error2));
});
it("should generate different fingerprints for different error messages", () => {
const error1 = {
type: "Error",
message: "Connection timeout",
};
const error2 = {
type: "Error",
message: "Connection refused",
};
expect(calculateErrorFingerprint(error1)).not.toBe(calculateErrorFingerprint(error2));
});
it("should handle error with name instead of type", () => {
const error = {
name: "TypeError",
message: "Cannot read property 'foo' of undefined",
};
const fp = calculateErrorFingerprint(error);
expect(fp).toBeTruthy();
expect(fp.length).toBe(16);
});
it("should handle error with stacktrace instead of stack", () => {
const error = {
type: "Error",
message: "Test error",
stacktrace: "at test (file.ts:1:1)",
};
const fp = calculateErrorFingerprint(error);
expect(fp).toBeTruthy();
});
it("should return empty string for non-object error", () => {
expect(calculateErrorFingerprint(null)).toBe("");
expect(calculateErrorFingerprint(undefined)).toBe("");
expect(calculateErrorFingerprint("error string")).toBe("");
expect(calculateErrorFingerprint(123)).toBe("");
});
it("should handle errors with no message or stack", () => {
const error = {
type: "Error",
};
const fp = calculateErrorFingerprint(error);
expect(fp).toBeTruthy();
expect(fp.length).toBe(16);
});
it("should generate fingerprints using stack trace when available", () => {
const error1 = {
type: "Error",
message: "Test",
stack: "at funcA (a.ts:1:1)\nat funcB (b.ts:2:2)",
};
const error2 = {
type: "Error",
message: "Test",
stack: "at funcX (x.ts:1:1)\nat funcY (y.ts:2:2)",
};
expect(calculateErrorFingerprint(error1)).not.toBe(calculateErrorFingerprint(error2));
});
it("should normalize line numbers in stack traces for same code location", () => {
const error1 = {
type: "Error",
message: "Test",
stack: "at func (file.ts:123:45)",
};
const error2 = {
type: "Error",
message: "Test",
stack: "at func (file.ts:456:78)",
};
expect(calculateErrorFingerprint(error1)).toBe(calculateErrorFingerprint(error2));
});
describe("message fallback to name / raw", () => {
it("distinguishes messageless built-in errors by their class name", () => {
const error1 = { type: "BUILT_IN_ERROR", name: "ListMessagesError", message: "" };
const error2 = { type: "BUILT_IN_ERROR", name: "SendMessageError", message: "" };
expect(calculateErrorFingerprint(error1)).not.toBe(calculateErrorFingerprint(error2));
});
it("groups two messageless errors of the same class together", () => {
const error1 = { type: "BUILT_IN_ERROR", name: "ListMessagesError", message: "" };
const error2 = { type: "BUILT_IN_ERROR", name: "ListMessagesError", message: "" };
expect(calculateErrorFingerprint(error1)).toBe(calculateErrorFingerprint(error2));
});
it("does not change the fingerprint of a message-bearing error", () => {
// Fingerprint must stay stable for the common path, otherwise existing
// error groups would split on deploy.
const withMessage = {
type: "BUILT_IN_ERROR",
name: "SomeError",
message: "Connection timeout",
};
const withoutName = { type: "BUILT_IN_ERROR", message: "Connection timeout" };
expect(calculateErrorFingerprint(withMessage)).toBe(calculateErrorFingerprint(withoutName));
});
it("distinguishes string errors by their raw value", () => {
const error1 = { type: "STRING_ERROR", raw: "rate limited" };
const error2 = { type: "STRING_ERROR", raw: "connection refused" };
expect(calculateErrorFingerprint(error1)).not.toBe(calculateErrorFingerprint(error2));
});
it("distinguishes custom errors by their raw value", () => {
const error1 = { type: "CUSTOM_ERROR", raw: '{"code":"E_LIMIT"}' };
const error2 = { type: "CUSTOM_ERROR", raw: '{"code":"E_AUTH"}' };
expect(calculateErrorFingerprint(error1)).not.toBe(calculateErrorFingerprint(error2));
});
it("prefers message over name and raw when all are present", () => {
const withMessage = { type: "BUILT_IN_ERROR", name: "Ignored", message: "real message" };
const messageOnly = { type: "BUILT_IN_ERROR", message: "real message" };
expect(calculateErrorFingerprint(withMessage)).toBe(calculateErrorFingerprint(messageOnly));
});
});
});
+248
View File
@@ -0,0 +1,248 @@
import { describe, test, expect } from "vitest";
import { Webhook } from "@trigger.dev/core/v3/schemas";
import {
generateErrorGroupWebhookPayload,
type ErrorGroupAlertData,
} from "~/v3/services/alerts/errorGroupWebhook.server";
function createMockAlertData(overrides: Partial<ErrorGroupAlertData> = {}): ErrorGroupAlertData {
const now = Date.now();
const earlier = now - 3600000; // 1 hour ago
return {
classification: "new_issue",
error: {
fingerprint: "fp_test_12345",
environmentId: "env_abc123",
environmentName: "Production",
taskIdentifier: "process-payment",
errorType: "TypeError",
errorMessage: "Cannot read property 'id' of undefined",
sampleStackTrace: `TypeError: Cannot read property 'id' of undefined
at processPayment (src/tasks/payment.ts:42:15)
at Object.run (src/tasks/payment.ts:15:20)`,
firstSeen: String(earlier),
lastSeen: String(now),
occurrenceCount: 5,
},
organization: {
id: "org_xyz789",
slug: "acme-corp",
name: "Acme Corp",
},
project: {
id: "proj_123",
externalRef: "proj_abc",
slug: "my-project",
name: "My Project",
},
dashboardUrl:
"https://cloud.trigger.dev/orgs/acme-corp/projects/my-project/errors/fp_test_12345",
...overrides,
};
}
describe("generateErrorGroupWebhookPayload", () => {
test("generates a valid webhook payload", () => {
const alertData = createMockAlertData();
const payload = generateErrorGroupWebhookPayload(alertData);
expect(payload).toMatchObject({
type: "alert.error",
object: {
classification: "new_issue",
error: {
fingerprint: "fp_test_12345",
type: "TypeError",
message: "Cannot read property 'id' of undefined",
taskIdentifier: "process-payment",
occurrenceCount: 5,
},
environment: {
id: "env_abc123",
name: "Production",
},
organization: {
id: "org_xyz789",
slug: "acme-corp",
name: "Acme Corp",
},
project: {
id: "proj_123",
ref: "proj_abc",
slug: "my-project",
name: "My Project",
},
dashboardUrl:
"https://cloud.trigger.dev/orgs/acme-corp/projects/my-project/errors/fp_test_12345",
},
});
expect(payload.id).toBeDefined();
expect(payload.created).toBeInstanceOf(Date);
expect(payload.webhookVersion).toBe("2025-01-01");
});
test("payload is valid according to Webhook schema", () => {
const alertData = createMockAlertData();
const payload = generateErrorGroupWebhookPayload(alertData);
const parsed = Webhook.parse(payload);
expect(parsed.type).toBe("alert.error");
});
test("payload can be serialized and deserialized", () => {
const alertData = createMockAlertData();
const payload = generateErrorGroupWebhookPayload(alertData);
// Serialize to JSON (simulating sending over HTTP)
const serialized = JSON.stringify(payload);
const deserialized = JSON.parse(serialized);
// Verify it can still be parsed by the schema
const parsed = Webhook.parse(deserialized);
expect(parsed.type).toBe("alert.error");
if (parsed.type === "alert.error") {
expect(parsed.object.classification).toBe("new_issue");
expect(parsed.object.error.fingerprint).toBe("fp_test_12345");
}
});
test("handles new_issue classification", () => {
const alertData = createMockAlertData({ classification: "new_issue" });
const payload = generateErrorGroupWebhookPayload(alertData);
const parsed = Webhook.parse(payload);
if (parsed.type === "alert.error") {
expect(parsed.object.classification).toBe("new_issue");
}
});
test("handles regression classification", () => {
const alertData = createMockAlertData({ classification: "regression" });
const payload = generateErrorGroupWebhookPayload(alertData);
const parsed = Webhook.parse(payload);
if (parsed.type === "alert.error") {
expect(parsed.object.classification).toBe("regression");
}
});
test("handles unignored classification", () => {
const alertData = createMockAlertData({ classification: "unignored" });
const payload = generateErrorGroupWebhookPayload(alertData);
const parsed = Webhook.parse(payload);
if (parsed.type === "alert.error") {
expect(parsed.object.classification).toBe("unignored");
}
});
test("handles empty stack trace", () => {
const alertData = createMockAlertData({
error: {
...createMockAlertData().error,
sampleStackTrace: "",
},
});
const payload = generateErrorGroupWebhookPayload(alertData);
const parsed = Webhook.parse(payload);
if (parsed.type === "alert.error") {
expect(parsed.object.error.stackTrace).toBeUndefined();
}
});
test("includes stack trace when present", () => {
const stackTrace = "Error at line 42";
const alertData = createMockAlertData({
error: {
...createMockAlertData().error,
sampleStackTrace: stackTrace,
},
});
const payload = generateErrorGroupWebhookPayload(alertData);
const parsed = Webhook.parse(payload);
if (parsed.type === "alert.error") {
expect(parsed.object.error.stackTrace).toBe(stackTrace);
}
});
test("preserves date fields correctly", () => {
const firstSeen = new Date("2024-01-01T00:00:00Z");
const lastSeen = new Date("2024-01-02T12:00:00Z");
const alertData = createMockAlertData({
error: {
...createMockAlertData().error,
firstSeen: String(firstSeen.getTime()),
lastSeen: String(lastSeen.getTime()),
},
});
const payload = generateErrorGroupWebhookPayload(alertData);
const parsed = Webhook.parse(payload);
if (parsed.type === "alert.error") {
expect(parsed.object.error.firstSeen).toEqual(firstSeen);
expect(parsed.object.error.lastSeen).toEqual(lastSeen);
}
});
test("handles special characters in error messages", () => {
const alertData = createMockAlertData({
error: {
...createMockAlertData().error,
errorMessage: "Unexpected token `<` in JSON at position 0",
sampleStackTrace: `SyntaxError: Unexpected token \`<\` in JSON
at JSON.parse (<anonymous>)
at fetch("https://api.example.com/data?query=test&limit=10")`,
},
});
const payload = generateErrorGroupWebhookPayload(alertData);
const serialized = JSON.stringify(payload);
const deserialized = JSON.parse(serialized);
const parsed = Webhook.parse(deserialized);
if (parsed.type === "alert.error") {
expect(parsed.object.error.message).toBe("Unexpected token `<` in JSON at position 0");
}
});
test("handles unicode and emoji in error messages", () => {
const alertData = createMockAlertData({
error: {
...createMockAlertData().error,
errorMessage: "Failed to process emoji 🔥 in message: Hello 世界",
},
});
const payload = generateErrorGroupWebhookPayload(alertData);
const serialized = JSON.stringify(payload);
const deserialized = JSON.parse(serialized);
const parsed = Webhook.parse(deserialized);
if (parsed.type === "alert.error") {
expect(parsed.object.error.message).toBe("Failed to process emoji 🔥 in message: Hello 世界");
}
});
test("handles large occurrence counts", () => {
const alertData = createMockAlertData({
error: {
...createMockAlertData().error,
occurrenceCount: 999999,
},
});
const payload = generateErrorGroupWebhookPayload(alertData);
const parsed = Webhook.parse(payload);
if (parsed.type === "alert.error") {
expect(parsed.object.error.occurrenceCount).toBe(999999);
}
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,164 @@
import { postgresTest } from "@internal/testcontainers";
import { type PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import { findEnvironmentByApiKey } from "~/models/runtimeEnvironment.server";
import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures";
vi.setConfig({ testTimeout: 60_000 });
type EnvOverrides = {
type: "DEVELOPMENT" | "PREVIEW" | "PRODUCTION";
orgMemberId?: string | null;
parentEnvironmentId?: string | null;
branchName?: string | null;
isBranchableEnvironment?: boolean;
archivedAt?: Date | null;
};
async function createEnv(
prisma: PrismaClient,
projectId: string,
organizationId: string,
overrides: EnvOverrides
) {
return prisma.runtimeEnvironment.create({
data: {
slug: uniqueId("env"),
apiKey: uniqueId("tr"),
pkApiKey: uniqueId("pk"),
shortcode: uniqueId("sc"),
projectId,
organizationId,
type: overrides.type,
orgMemberId: overrides.orgMemberId ?? null,
parentEnvironmentId: overrides.parentEnvironmentId ?? null,
branchName: overrides.branchName ?? null,
isBranchableEnvironment: overrides.isBranchableEnvironment ?? false,
archivedAt: overrides.archivedAt ?? null,
},
});
}
describe("findEnvironmentByApiKey — DEVELOPMENT branch resolution", () => {
postgresTest(
"resolves the full dev auth matrix from the parent's api key",
async ({ prisma }) => {
const { organization, project, orgMember } = await createTestOrgProjectWithMember(prisma);
// The existing per-member dev env IS the default branch (no branchName).
const devRoot = await createEnv(prisma, project.id, organization.id, {
type: "DEVELOPMENT",
orgMemberId: orgMember.id,
});
const namedBranch = await createEnv(prisma, project.id, organization.id, {
type: "DEVELOPMENT",
orgMemberId: orgMember.id,
parentEnvironmentId: devRoot.id,
branchName: "my-feature",
});
await createEnv(prisma, project.id, organization.id, {
type: "DEVELOPMENT",
orgMemberId: orgMember.id,
parentEnvironmentId: devRoot.id,
branchName: "archived-feature",
archivedAt: new Date(),
});
// No header → the root dev env (unchanged, day-one behaviour).
const noHeader = await findEnvironmentByApiKey(devRoot.apiKey, undefined, prisma);
expect(noHeader?.id).toBe(devRoot.id);
// "default" sentinel → also the root dev env.
const defaultHeader = await findEnvironmentByApiKey(devRoot.apiKey, "default", prisma);
expect(defaultHeader?.id).toBe(devRoot.id);
// A named branch that exists → the child env...
const child = await findEnvironmentByApiKey(devRoot.apiKey, "my-feature", prisma);
expect(child?.id).toBe(namedBranch.id);
expect(child?.branchName).toBe("my-feature");
// ...but carrying the PARENT's api key and ownership, not the child's own key.
expect(child?.apiKey).toBe(devRoot.apiKey);
expect(child?.orgMemberId).toBe(orgMember.id);
// A named branch that doesn't exist → null (not a silent fall-through to root).
const missing = await findEnvironmentByApiKey(devRoot.apiKey, "does-not-exist", prisma);
expect(missing).toBeNull();
// An archived branch → null (archivedAt filter on the child include).
const archived = await findEnvironmentByApiKey(devRoot.apiKey, "archived-feature", prisma);
expect(archived).toBeNull();
}
);
postgresTest("a branch name is sanitized before lookup", async ({ prisma }) => {
const { organization, project, orgMember } = await createTestOrgProjectWithMember(prisma);
const devRoot = await createEnv(prisma, project.id, organization.id, {
type: "DEVELOPMENT",
orgMemberId: orgMember.id,
});
const namedBranch = await createEnv(prisma, project.id, organization.id, {
type: "DEVELOPMENT",
orgMemberId: orgMember.id,
parentEnvironmentId: devRoot.id,
branchName: "feature/login",
});
// refs/heads/ prefix is stripped to match the stored branch name.
const resolved = await findEnvironmentByApiKey(
devRoot.apiKey,
"refs/heads/feature/login",
prisma
);
expect(resolved?.id).toBe(namedBranch.id);
});
});
describe("findEnvironmentByApiKey — PREVIEW (regression guard)", () => {
postgresTest(
"preview still requires a branch and never resolves the parent",
async ({ prisma }) => {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const previewParent = await createEnv(prisma, project.id, organization.id, {
type: "PREVIEW",
isBranchableEnvironment: true,
});
const previewBranch = await createEnv(prisma, project.id, organization.id, {
type: "PREVIEW",
parentEnvironmentId: previewParent.id,
branchName: "pr-123",
});
// No header on a preview key → null (preview has no default).
const noHeader = await findEnvironmentByApiKey(previewParent.apiKey, undefined, prisma);
expect(noHeader).toBeNull();
// With a branch → the child, carrying the parent's api key.
const resolved = await findEnvironmentByApiKey(previewParent.apiKey, "pr-123", prisma);
expect(resolved?.id).toBe(previewBranch.id);
expect(resolved?.apiKey).toBe(previewParent.apiKey);
}
);
});
describe("findEnvironmentByApiKey — non-branchable", () => {
postgresTest(
"a production key ignores the branch header and returns itself",
async ({ prisma }) => {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const prod = await createEnv(prisma, project.id, organization.id, { type: "PRODUCTION" });
const resolved = await findEnvironmentByApiKey(prod.apiKey, "some-branch", prisma);
expect(resolved?.id).toBe(prod.id);
}
);
postgresTest("an unknown api key returns null", async ({ prisma }) => {
const resolved = await findEnvironmentByApiKey("tr_dev_nonexistent", undefined, prisma);
expect(resolved).toBeNull();
});
});
@@ -0,0 +1,135 @@
// Real PG14 (control-plane) + PG17 (run-ops) proof for findEnvironmentFromRun.
// The env (slug/project/org) lives on PG14; the run-ops scalar row on PG17 with cross-seam
// FKs dropped. A PostgresRunStore over PG17 reads run scalars; the ControlPlaneResolver over
// PG14 resolves the env. The DB is never mocked. The .count() proof shows neither DB joins
// the other.
import { heteroPostgresTest } from "@internal/testcontainers";
import { PostgresRunStore } from "@internal/run-store";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import { ControlPlaneCache } from "~/v3/runOpsMigration/controlPlaneCache.server";
import { ControlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
vi.setConfig({ testTimeout: 120_000, hookTimeout: 120_000 });
const TASK_RUN_CROSS_SEAM_FKS = [
"TaskRun_runtimeEnvironmentId_fkey",
"TaskRun_projectId_fkey",
"TaskRun_organizationId_fkey",
] as const;
async function dropTaskRunCrossSeamFks(prisma: PrismaClient) {
for (const constraint of TASK_RUN_CROSS_SEAM_FKS) {
await prisma.$executeRawUnsafe(
`ALTER TABLE "TaskRun" DROP CONSTRAINT IF EXISTS "${constraint}"`
);
}
}
let seedCounter = 0;
async function seedControlPlane(prisma: PrismaClient) {
const n = seedCounter++;
const organization = await prisma.organization.create({
data: { title: `Org ${n}`, slug: `org-${n}` },
});
const project = await prisma.project.create({
data: {
name: `Project ${n}`,
slug: `project-${n}`,
externalRef: `proj_${n}`,
organizationId: organization.id,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
type: "PRODUCTION",
slug: `env-${n}`,
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_prod_${n}`,
pkApiKey: `pk_prod_${n}`,
shortcode: `short_${n}`,
},
});
return { organization, project, environment };
}
async function seedRun(
prisma: PrismaClient,
ids: { runtimeEnvironmentId: string; projectId: string; organizationId: string },
opts?: { runTags?: string[] }
) {
const n = seedCounter++;
return prisma.taskRun.create({
data: {
id: `run_${n}_pg17`,
engine: "V2",
status: "PENDING",
friendlyId: `run_friendly_${n}`,
runtimeEnvironmentId: ids.runtimeEnvironmentId,
organizationId: ids.organizationId,
projectId: ids.projectId,
taskIdentifier: "fefr-task",
payload: "{}",
payloadType: "application/json",
queue: "task/fefr-task",
traceId: `trace_${n}`,
spanId: `span_${n}`,
workerQueue: "main",
runTags: opts?.runTags ?? ["a", "b"],
},
});
}
function buildResolver(controlPlane: PrismaClient) {
return new ControlPlaneResolver({
controlPlanePrimary: controlPlane,
controlPlaneReplica: controlPlane,
cache: new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 }),
splitEnabled: () => false,
});
}
describe("findEnvironmentFromRun cross-DB read-through", () => {
heteroPostgresTest(
"resolves env from PG14 while run scalars resolve from PG17 (no cross-DB join)",
async ({ prisma14, prisma17 }) => {
await dropTaskRunCrossSeamFks(prisma17 as unknown as PrismaClient);
const cp = await seedControlPlane(prisma14 as unknown as PrismaClient);
const run = await seedRun(
prisma17 as unknown as PrismaClient,
{
runtimeEnvironmentId: cp.environment.id,
projectId: cp.project.id,
organizationId: cp.organization.id,
},
{ runTags: ["x", "y"] }
);
const runStore = new PostgresRunStore({
prisma: prisma17 as unknown as PrismaClient,
readOnlyPrisma: prisma17 as unknown as PrismaClient,
});
const resolver = buildResolver(prisma14 as unknown as PrismaClient);
// The decomposed findEnvironmentFromRun: run scalars from the store + env from the resolver.
const taskRun = await runStore.findRun(
{ id: run.id },
{ select: { runtimeEnvironmentId: true, runTags: true, batchId: true } },
prisma17 as unknown as PrismaClient
);
expect(taskRun).not.toBeNull();
const environment = await resolver.resolveAuthenticatedEnv(taskRun!.runtimeEnvironmentId);
expect(environment).not.toBeNull();
expect(environment!.id).toBe(cp.environment.id);
expect(environment!.slug).toBe(cp.environment.slug);
expect(environment!.project.id).toBe(cp.project.id);
expect(taskRun!.runTags).toEqual(["x", "y"]);
// Inversion proof: PG17 (run-ops) has no env rows; PG14 (control-plane) has no run rows.
expect(await (prisma17 as unknown as PrismaClient).runtimeEnvironment.count()).toBe(0);
expect(await (prisma14 as unknown as PrismaClient).taskRun.count()).toBe(0);
}
);
});
@@ -0,0 +1,183 @@
import { containerTest } from "@internal/testcontainers";
import type { CreateBackgroundWorkerRequestBody } from "@trigger.dev/core/v3";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { ServiceValidationError } from "~/v3/services/common.server";
import { findOrCreateBackgroundWorker } from "~/v3/services/createDeploymentBackgroundWorkerV4/findOrCreateBackgroundWorker.server";
vi.setConfig({ testTimeout: 30_000 });
async function seedDeployment(prisma: PrismaClient, version = "20260528.1") {
const slug = `s${Math.random().toString(36).slice(2, 10)}`;
const organization = await prisma.organization.create({
data: { title: slug, slug },
});
const project = await prisma.project.create({
data: {
name: slug,
slug,
organizationId: organization.id,
externalRef: slug,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug,
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: slug,
pkApiKey: slug,
shortcode: slug,
},
});
const deployment = await prisma.workerDeployment.create({
data: {
friendlyId: `deployment_${slug}`,
shortCode: slug,
contentHash: "h_initial",
status: "BUILDING",
version,
projectId: project.id,
environmentId: environment.id,
},
});
// The helper only reads `id` and `projectId`; the rest of the type is
// unused here so we cast a partial.
const authEnv = { id: environment.id, projectId: project.id } as AuthenticatedEnvironment;
return { project, environment, deployment, authEnv };
}
function bodyWithHash(contentHash: string): CreateBackgroundWorkerRequestBody {
return {
localOnly: false,
metadata: {
contentHash,
packageVersion: "0.0.0",
cliPackageVersion: "0.0.0",
tasks: [],
queues: [],
sourceFiles: [],
runtime: "node",
runtimeVersion: "21.0.0",
},
engine: "V2",
supportsLazyAttempts: true,
};
}
describe("findOrCreateBackgroundWorker", () => {
containerTest("creates a new BackgroundWorker on the first call", async ({ prisma }) => {
const { authEnv, deployment } = await seedDeployment(prisma);
const worker = await findOrCreateBackgroundWorker(
authEnv,
deployment,
bodyWithHash("h1"),
prisma
);
expect(worker.projectId).toBe(authEnv.projectId);
expect(worker.runtimeEnvironmentId).toBe(authEnv.id);
expect(worker.version).toBe(deployment.version);
expect(worker.contentHash).toBe("h1");
const rowCount = await prisma.backgroundWorker.count({
where: { projectId: authEnv.projectId, version: deployment.version },
});
expect(rowCount).toBe(1);
});
containerTest(
"returns the existing row on a second call with the same contentHash (no duplicate)",
async ({ prisma }) => {
const { authEnv, deployment } = await seedDeployment(prisma);
const first = await findOrCreateBackgroundWorker(
authEnv,
deployment,
bodyWithHash("h1"),
prisma
);
const second = await findOrCreateBackgroundWorker(
authEnv,
deployment,
bodyWithHash("h1"),
prisma
);
expect(second.id).toBe(first.id);
const rowCount = await prisma.backgroundWorker.count({
where: { projectId: authEnv.projectId, version: deployment.version },
});
expect(rowCount).toBe(1);
}
);
containerTest(
"throws 409 ServiceValidationError when an existing row has a different contentHash",
async ({ prisma }) => {
const { authEnv, deployment } = await seedDeployment(prisma);
await findOrCreateBackgroundWorker(authEnv, deployment, bodyWithHash("h1"), prisma);
await expect(
findOrCreateBackgroundWorker(authEnv, deployment, bodyWithHash("h2"), prisma)
).rejects.toMatchObject({
name: "ServiceValidationError",
status: 409,
});
// Also assert the constructor so callers can `catch (e instanceof ServiceValidationError)`.
await expect(
findOrCreateBackgroundWorker(authEnv, deployment, bodyWithHash("h2"), prisma)
).rejects.toBeInstanceOf(ServiceValidationError);
}
);
containerTest(
"concurrent create race surfaces as plain Error (not ServiceValidationError)",
async ({ prisma }) => {
// The class distinction matters: the V4 service uses `instanceof ServiceValidationError`
// to decide whether to fail-deploy. A transient race must not fail-deploy.
const { authEnv, deployment } = await seedDeployment(prisma);
const [first, second] = await Promise.allSettled([
findOrCreateBackgroundWorker(authEnv, deployment, bodyWithHash("h-race"), prisma),
findOrCreateBackgroundWorker(authEnv, deployment, bodyWithHash("h-race"), prisma),
]);
const fulfilled = [first, second].filter((r) => r.status === "fulfilled");
const rejected = [first, second].filter(
(r): r is PromiseRejectedResult => r.status === "rejected"
);
// Exactly one row in the database regardless of who won.
const rowCount = await prisma.backgroundWorker.count({
where: { projectId: authEnv.projectId, version: deployment.version },
});
expect(rowCount).toBe(1);
// If the schedule produced an actual race (one wins, one loses), the loser
// must surface a non-SVE error. If the schedule serialised them by accident,
// both fulfilled is also acceptable — this test is about the error *class*,
// not the rate of races.
if (rejected.length > 0) {
expect(fulfilled).toHaveLength(1);
for (const r of rejected) {
expect(r.reason).not.toBeInstanceOf(ServiceValidationError);
expect((r.reason as Error).message).toMatch(/concurrent/i);
}
} else {
expect(fulfilled).toHaveLength(2);
}
}
);
});
@@ -0,0 +1,104 @@
import type { PrismaClient, RuntimeEnvironmentType } from "@trigger.dev/database";
import type { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
let idCounter = 0;
export function uniqueId(prefix: string) {
idCounter += 1;
return `${prefix}-${idCounter}-${Date.now()}`;
}
export async function createTestUser(prisma: PrismaClient, email?: string) {
return prisma.user.create({
data: {
email: email ?? `${uniqueId("user")}@test.com`,
authenticationMethod: "MAGIC_LINK",
},
});
}
export async function createTestOrgProjectWithMember(
prisma: PrismaClient,
options?: { userId?: string }
) {
const user = options?.userId
? await prisma.user.findUniqueOrThrow({ where: { id: options.userId } })
: await createTestUser(prisma);
const orgSlug = uniqueId("org");
const organization = await prisma.organization.create({
data: {
title: "Test Org",
slug: orgSlug,
members: { create: { userId: user.id, role: "ADMIN" } },
},
include: { members: true },
});
const projectSlug = uniqueId("proj");
const project = await prisma.project.create({
data: {
name: "Test Project",
slug: projectSlug,
organizationId: organization.id,
externalRef: uniqueId("ext"),
},
});
return {
user,
organization,
project,
orgMember: organization.members[0]!,
projectSlug,
};
}
export async function createRuntimeEnvironment(
prisma: PrismaClient,
options: {
projectId: string;
organizationId: string;
type: RuntimeEnvironmentType;
orgMemberId?: string | null;
slug?: string;
}
) {
const slug = options.slug ?? uniqueId("env");
return prisma.runtimeEnvironment.create({
data: {
slug,
type: options.type,
projectId: options.projectId,
organizationId: options.organizationId,
orgMemberId: options.orgMemberId ?? null,
apiKey: uniqueId("api"),
pkApiKey: uniqueId("pk"),
shortcode: uniqueId("sc"),
},
});
}
export async function createEnvironmentVariable(
repository: EnvironmentVariablesRepository,
projectId: string,
options: {
environmentId: string;
key: string;
value: string;
isSecret?: boolean;
userId: string;
}
) {
const result = await repository.create(projectId, {
override: true,
environmentIds: [options.environmentId],
variables: [{ key: options.key, value: options.value }],
isSecret: options.isSecret ?? false,
lastUpdatedBy: { type: "user", userId: options.userId },
});
if (!result.success) {
throw new Error(result.error);
}
}
+7
View File
@@ -0,0 +1,7 @@
// Minimal real worker for OtlpWorkerPool metric tests: echoes an ok result with a compute time.
const { parentPort } = require("node:worker_threads");
parentPort.on("message", (message) => {
if (message && message.type === "pricing") return;
parentPort.postMessage({ id: message.id, ok: true, result: { rows: [] }, computeMs: 2 });
});

Some files were not shown because too many files have changed in this diff Show More