chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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+DC00–U+DFFF)", () => {
|
||||
// Regression guard: a previous version of this scanner used `[cd]` to
|
||||
// match the low-surrogate nibble, missing the entire U+DE00–U+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+DC00–U+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+DD00–U+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+DE00–U+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+DF00–U+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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user