412 lines
16 KiB
TypeScript
412 lines
16 KiB
TypeScript
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");
|
||
}
|
||
);
|
||
});
|