Files
triggerdotdev--trigger.dev/apps/webapp/test/nextRunListPresenter.readthrough.test.ts
T
2026-07-13 13:32:57 +08:00

243 lines
9.9 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 `$replica`
// stub delegates to the per-test REAL legacy container so the env-lookup + task-identifier
// reads hit a real database. Everything asserted runs against real containers.
//
// `legacyReplicaHolder.client` is set by each test to its real legacy `prisma` handle before
// calling the presenter; the proxy forwards every property access to it lazily. Created via
// vi.hoisted so it exists when the hoisted vi.mock factory runs.
// `legacyReplicaHolder.client` -> the legacy handle backing the `prisma`/`$replica`
// singletons; `newClientHolder.client` -> the new handle backing `runOpsNewPrisma`
// (used by the routed store's default known-migrated probe). Each test sets both before calling.
const legacyReplicaHolder = vi.hoisted(() => ({ client: undefined as any }));
const newClientHolder = vi.hoisted(() => ({ client: undefined as any }));
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 { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
import { setupClickhouseReplication } from "./utils/replicationUtils";
vi.setConfig({ testTimeout: 90_000 });
type SeedContext = {
organizationId: string;
projectId: string;
environmentId: 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 and new databases.
*/
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: "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,
};
}
/** 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: `env-${slug}`,
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; runTags?: 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: run.runTags ?? [],
runtimeEnvironmentId: ctx.environmentId,
projectId: ctx.projectId,
organizationId: ctx.organizationId,
environmentType: "DEVELOPMENT",
engine: "V2",
},
});
}
const callOptions = (ctx: SeedContext) => ({
projectId: ctx.projectId,
pageSize: 10,
});
describe("NextRunListPresenter routed hydrate (legacy + new Postgres)", () => {
// list hydrate flows through the routed store: split, non-empty CH id-set whose rows are
// split across NEW + the legacy replica. result.runs must be the union, id-desc ordered. This
// proves the deps are threaded so the routed store is actually used.
// We assert the rows that DO surface (the full union, since legacy is probed for any id that
// misses on NEW).
// The migrated runs (run_newA/run_newB) live on BOTH DBs with the same id + friendlyId but a
// DISTINGUISHING taskIdentifier: "my-task" on legacy, "my-task-NEW" on new. #hydrateRunsByIds
// takes NEW rows first and only probes legacy for ids NOT on NEW, so a migrated row can only
// carry "my-task-NEW" if it was served from the threaded newClient (new DB) — asserted below.
replicationContainerTest(
"list hydrate flows through the routed store: result.runs is the NEW + legacy union, id-desc",
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;
// The routed store's default known-migrated probe reads `runOpsNewPrisma` -> the new DB.
newClientHolder.client = prismaNew;
try {
const ctx = await seedParents(prisma, "hydrate");
await mirrorParents(prismaNew, ctx, "hydrate");
// All four runs land on the legacy DB (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 the new DB is
// identifiable: "my-task-NEW" here vs the default "my-task" on the legacy DB.
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 NextRunListPresenter(prisma, clickhouse, {
newClient: prismaNew,
legacyReplica: prisma,
splitEnabled: true,
});
const result = await presenter.call(
ctx.organizationId,
ctx.environmentId,
callOptions(ctx)
);
const expectedIds = [migratedA.id, migratedB.id, legacyOnlyA.id, legacyOnlyB.id].sort(
(a, b) => (a < b ? 1 : a > b ? -1 : 0)
);
expect(result.runs.map((r) => r.id)).toEqual(expectedIds);
// The migrated rows must carry the new-DB-only taskIdentifier — this can only hold if they
// were hydrated from the threaded newClient (new DB), proving the routed store used it.
expect(result.runs.find((r) => r.id === migratedA.id)?.friendlyId).toBe("run_newA");
expect(result.runs.find((r) => r.id === migratedA.id)?.taskIdentifier).toBe("my-task-NEW");
expect(result.runs.find((r) => r.id === migratedB.id)?.taskIdentifier).toBe("my-task-NEW");
// The legacy-only rows surface from the legacy DB with the legacy taskIdentifier — proving the
// legacyReplica (legacy DB) is also exercised for ids absent from the new DB.
expect(result.runs.find((r) => r.id === legacyOnlyA.id)?.friendlyId).toBe("run_legacyA");
expect(result.runs.find((r) => r.id === legacyOnlyA.id)?.taskIdentifier).toBe("my-task");
expect(result.runs.find((r) => r.id === legacyOnlyB.id)?.taskIdentifier).toBe("my-task");
// Non-empty page -> the empty-state probe is not consulted, but it's still true.
expect(result.hasAnyRuns).toBe(true);
} finally {
await prismaNew.$disconnect();
}
}
);
});