323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
// Real-PG proof for the RunPresenter run-detail read seam. The DB is never mocked:
|
|
// the only vi.mock redirects the `~/db.server` module handle (captured by the
|
|
// `runStore` singleton at load) to the live testcontainer client via a delegating
|
|
// Proxy. Seeding `logsDeletedAt` + `showDeletedLogs: false` makes `showLogs` false,
|
|
// so the presenter returns the header EARLY — before the trace path and the
|
|
// `user.findFirst` admin read — keeping the test off ClickHouse.
|
|
import { 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";
|
|
|
|
vi.setConfig({ testTimeout: 60_000 });
|
|
|
|
// Hoisted alongside the vi.mock factory. `setCurrentPrisma` points the delegating
|
|
// Proxy at each test's container. The RunStore singleton is built ONCE at import and
|
|
// its error-normalizing wrapper memoizes each Prisma model delegate on first access;
|
|
// returning `current.taskRun` directly would freeze the store onto the first test's
|
|
// container ("Database test_0 does not exist" on later tests). So object-valued
|
|
// delegates return a STABLE per-key sub-proxy the store can safely cache, which
|
|
// re-delegates to the live `current[key]` on every access; functions/scalars pass
|
|
// through to the live client.
|
|
const { delegating, setCurrentPrisma } = vi.hoisted(() => {
|
|
let current: any = undefined;
|
|
const subProxyCache = new Map<string, unknown>();
|
|
|
|
const getSubProxy = (prop: string) => {
|
|
const cached = subProxyCache.get(prop);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
const subProxy = new Proxy(
|
|
{},
|
|
{
|
|
get(_st, key) {
|
|
if (!current) {
|
|
throw new Error("currentPrisma not set");
|
|
}
|
|
const delegate = current[prop];
|
|
const value = delegate?.[key];
|
|
return typeof value === "function" ? value.bind(delegate) : value;
|
|
},
|
|
}
|
|
);
|
|
subProxyCache.set(prop, subProxy);
|
|
return subProxy;
|
|
};
|
|
|
|
const proxy = new Proxy(
|
|
{},
|
|
{
|
|
get(_t, prop) {
|
|
if (!current) {
|
|
throw new Error("currentPrisma not set");
|
|
}
|
|
if (typeof prop === "string") {
|
|
const value = current[prop];
|
|
if (value != null && typeof value === "object") {
|
|
return getSubProxy(prop);
|
|
}
|
|
return value;
|
|
}
|
|
return current[prop];
|
|
},
|
|
}
|
|
);
|
|
return {
|
|
delegating: proxy,
|
|
setCurrentPrisma: (p: unknown) => {
|
|
current = p;
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("~/db.server", () => ({
|
|
prisma: delegating,
|
|
$replica: delegating,
|
|
}));
|
|
|
|
// Imported AFTER the hoisted vi.mock so the singleton (built from `~/db.server`)
|
|
// captures the Proxy as its `readOnlyPrisma`.
|
|
import {
|
|
RunEnvironmentMismatchError,
|
|
RunNotInPgError,
|
|
RunPresenter,
|
|
} from "~/presenters/v3/RunPresenter.server";
|
|
|
|
let suffixCounter = 0;
|
|
function uniqueSuffix(prefix: string) {
|
|
suffixCounter += 1;
|
|
return `${prefix}-${suffixCounter}-${Date.now()}`;
|
|
}
|
|
|
|
async function seedOrgProjectEnvMember(prisma: PrismaClient, suffix: string) {
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
email: `${suffix}@test.com`,
|
|
authenticationMethod: "MAGIC_LINK",
|
|
},
|
|
});
|
|
|
|
const organization = await prisma.organization.create({
|
|
data: {
|
|
title: `org-${suffix}`,
|
|
slug: `org-${suffix}`,
|
|
members: { create: { userId: user.id, role: "ADMIN" } },
|
|
},
|
|
include: { members: true },
|
|
});
|
|
|
|
const project = await prisma.project.create({
|
|
data: {
|
|
name: `proj-${suffix}`,
|
|
slug: `proj-${suffix}`,
|
|
organizationId: organization.id,
|
|
externalRef: `ext-${suffix}`,
|
|
},
|
|
});
|
|
|
|
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
|
data: {
|
|
slug: `env-${suffix}`,
|
|
type: "DEVELOPMENT",
|
|
projectId: project.id,
|
|
organizationId: organization.id,
|
|
orgMemberId: organization.members[0]!.id,
|
|
apiKey: `tr_dev_${suffix}`,
|
|
pkApiKey: `pk_dev_${suffix}`,
|
|
shortcode: `sc-${suffix}`,
|
|
},
|
|
});
|
|
|
|
return { user, 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: "trace-1234",
|
|
spanId: "span-1234",
|
|
queue: "test",
|
|
runtimeEnvironmentId: env.runtimeEnvironmentId,
|
|
projectId: env.projectId,
|
|
organizationId: env.organizationId,
|
|
environmentType: "DEVELOPMENT",
|
|
engine: "V2",
|
|
status: "COMPLETED_SUCCESSFULLY",
|
|
// logsDeletedAt set → showLogs is false → presenter returns the header
|
|
// EARLY, before the trace path and before the `user.findFirst` admin read.
|
|
logsDeletedAt: new Date(),
|
|
},
|
|
});
|
|
}
|
|
|
|
describe("RunPresenter run read seam (single-DB, real PG)", () => {
|
|
postgresTest(
|
|
"passthrough resolves the run-detail header via the singleton",
|
|
async ({ prisma }) => {
|
|
setCurrentPrisma(prisma);
|
|
|
|
const suffix = uniqueSuffix("pt");
|
|
const { user, organization, project, runtimeEnvironment } = await seedOrgProjectEnvMember(
|
|
prisma,
|
|
suffix
|
|
);
|
|
|
|
const id = generateRunOpsId();
|
|
const friendlyId = `run_${id}`;
|
|
const run = await seedRun(
|
|
prisma,
|
|
{ id, friendlyId },
|
|
{
|
|
runtimeEnvironmentId: runtimeEnvironment.id,
|
|
projectId: project.id,
|
|
organizationId: organization.id,
|
|
}
|
|
);
|
|
|
|
const presenter = new RunPresenter(prisma);
|
|
const result = await presenter.call({
|
|
userId: user.id,
|
|
projectSlug: project.slug,
|
|
environmentSlug: runtimeEnvironment.slug,
|
|
runFriendlyId: friendlyId,
|
|
showDeletedLogs: false,
|
|
showDebug: false,
|
|
});
|
|
|
|
// Header served through the store seam (singleton → Proxy → real container).
|
|
expect(result.run.id).toBe(id);
|
|
expect(result.run.friendlyId).toBe(friendlyId);
|
|
expect(result.run.number).toBe(run.number);
|
|
expect(result.run.status).toBe("COMPLETED_SUCCESSFULLY");
|
|
expect(result.run.environment.slug).toBe(runtimeEnvironment.slug);
|
|
// logsDeletedAt is set → early return, no trace.
|
|
expect(result.trace).toBeUndefined();
|
|
}
|
|
);
|
|
|
|
postgresTest(
|
|
"run read is NOT pinned to the constructor client (served via the seam)",
|
|
async ({ prisma }) => {
|
|
setCurrentPrisma(prisma);
|
|
|
|
const suffix = uniqueSuffix("unpinned");
|
|
const { user, organization, project, runtimeEnvironment } = await seedOrgProjectEnvMember(
|
|
prisma,
|
|
suffix
|
|
);
|
|
|
|
const id = generateRunOpsId();
|
|
const friendlyId = `run_${id}`;
|
|
await seedRun(
|
|
prisma,
|
|
{ id, friendlyId },
|
|
{
|
|
runtimeEnvironmentId: runtimeEnvironment.id,
|
|
projectId: project.id,
|
|
organizationId: organization.id,
|
|
}
|
|
);
|
|
|
|
// Sentinel constructor client whose taskRun.findFirst THROWS: if the run read
|
|
// were pinned to this.#prismaClient the call would reject — it resolves because
|
|
// the run read flows through the store seam/singleton instead. The control-plane
|
|
// lookups (project-scope + membership auth; the trace-path admin user.findFirst)
|
|
// are NOT split and correctly use the constructor client, so they are stubbed to
|
|
// succeed while taskRun stays throwing.
|
|
const sentinel = {
|
|
taskRun: {
|
|
findFirst: () => {
|
|
throw new Error("run read must not use constructor client");
|
|
},
|
|
},
|
|
project: {
|
|
findFirst: async () => ({ id: project.id }),
|
|
},
|
|
user: {
|
|
findFirst: async () => user,
|
|
},
|
|
} as unknown as PrismaClient;
|
|
|
|
const presenter = new RunPresenter(sentinel);
|
|
const result = await presenter.call({
|
|
userId: user.id,
|
|
projectSlug: project.slug,
|
|
environmentSlug: runtimeEnvironment.slug,
|
|
runFriendlyId: friendlyId,
|
|
showDeletedLogs: false,
|
|
showDebug: false,
|
|
});
|
|
|
|
expect(result.run.id).toBe(id);
|
|
expect(result.run.friendlyId).toBe(friendlyId);
|
|
expect(result.trace).toBeUndefined();
|
|
}
|
|
);
|
|
|
|
postgresTest("missing run maps to RunNotInPgError", async ({ prisma }) => {
|
|
setCurrentPrisma(prisma);
|
|
|
|
const suffix = uniqueSuffix("notfound");
|
|
const { user, project, runtimeEnvironment } = await seedOrgProjectEnvMember(prisma, suffix);
|
|
|
|
const missingFriendlyId = `run_${generateRunOpsId()}`;
|
|
|
|
const presenter = new RunPresenter(prisma);
|
|
await expect(
|
|
presenter.call({
|
|
userId: user.id,
|
|
projectSlug: project.slug,
|
|
environmentSlug: runtimeEnvironment.slug,
|
|
runFriendlyId: missingFriendlyId,
|
|
showDeletedLogs: false,
|
|
showDebug: false,
|
|
})
|
|
).rejects.toThrow(RunNotInPgError);
|
|
});
|
|
|
|
postgresTest("environment mismatch maps to RunEnvironmentMismatchError", async ({ prisma }) => {
|
|
setCurrentPrisma(prisma);
|
|
|
|
const suffix = uniqueSuffix("mismatch");
|
|
const { user, organization, project, runtimeEnvironment } = await seedOrgProjectEnvMember(
|
|
prisma,
|
|
suffix
|
|
);
|
|
|
|
const id = generateRunOpsId();
|
|
const friendlyId = `run_${id}`;
|
|
await seedRun(
|
|
prisma,
|
|
{ id, friendlyId },
|
|
{
|
|
runtimeEnvironmentId: runtimeEnvironment.id,
|
|
projectId: project.id,
|
|
organizationId: organization.id,
|
|
}
|
|
);
|
|
|
|
const presenter = new RunPresenter(prisma);
|
|
await expect(
|
|
presenter.call({
|
|
userId: user.id,
|
|
projectSlug: project.slug,
|
|
// Seeded env slug is `env-${suffix}`; pass a different slug.
|
|
environmentSlug: `other-${suffix}`,
|
|
runFriendlyId: friendlyId,
|
|
showDeletedLogs: false,
|
|
showDebug: false,
|
|
})
|
|
).rejects.toThrow(RunEnvironmentMismatchError);
|
|
});
|
|
});
|