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

158 lines
5.7 KiB
TypeScript

// SPLIT-NEUTRAL VERIFICATION: getActivity reads ClickHouse
// (task_runs_v2) ONLY and never touches the run-ops Prisma client. A
// heterogeneous legacy/new Postgres fixture is deliberately not applicable
// here — there is no run-ops Postgres read to validate cross-version. The
// throwing Proxy passed as `replica` proves it: any access throws.
import { describe, expect, vi } from "vitest";
import { clickhouseTest } from "@internal/testcontainers";
import { ClickHouse, type TaskRunV2 } from "@internal/clickhouse";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { TaskDetailPresenter } from "~/presenters/v3/TaskDetailPresenter.server";
vi.setConfig({ testTimeout: 60_000 });
const organizationId = "org_activity_test";
const projectId = "project_activity_test";
const environmentId = "env_activity_test";
const taskSlug = "my-activity-task";
function makeRun(overrides: Partial<TaskRunV2>): TaskRunV2 {
const createdAt = overrides.created_at ?? Date.now();
return {
environment_id: environmentId,
organization_id: organizationId,
project_id: projectId,
run_id: `run_${randomUUID()}`,
friendly_id: `friendly_${randomUUID()}`,
updated_at: createdAt,
created_at: createdAt,
status: "COMPLETED_SUCCESSFULLY",
environment_type: "PRODUCTION",
attempt: 1,
engine: "V2",
task_identifier: taskSlug,
queue: "my-queue",
schedule_id: "",
batch_id: "",
task_version: "",
sdk_version: "",
cli_version: "",
machine_preset: "",
root_run_id: "",
parent_run_id: "",
span_id: "",
trace_id: "",
idempotency_key: "",
expiration_ttl: "",
_version: "1",
_is_deleted: 0,
...overrides,
};
}
describe("TaskDetailPresenter.getActivity (ClickHouse-only)", () => {
clickhouseTest(
"buckets task_runs_v2 activity by status group, excludes deleted, never reads Postgres",
async ({ clickhouseContainer }) => {
const clickhouse = new ClickHouse({
url: clickhouseContainer.getConnectionUrl(),
name: "task-detail-activity-test",
compression: { request: true },
});
const insert = clickhouse.writer.insert({
name: "insertTaskRunsActivityTest",
table: "trigger_dev.task_runs_v2",
schema: z.any(),
settings: { async_insert: 0, enable_json_type: 1, type_json_skip_duplicated_paths: 1 },
});
// 6h window => 300s (5-minute) buckets => 72 buckets (chooseBucketSeconds targets ~72).
const from = new Date("2026-01-01T00:00:00Z");
const to = new Date("2026-01-01T06:00:00Z");
const BUCKET_MS = 5 * 60 * 1000;
// 00:30 bucket: 1 COMPLETED, 1 FAILED (+ 1 deleted, excluded).
// 02:30 bucket: 1 CANCELED, RUNNING = EXECUTING + unknown-status = 2.
const bucket0 = new Date("2026-01-01T00:30:00Z").getTime();
const bucket2 = new Date("2026-01-01T02:30:00Z").getTime();
const rows = [
makeRun({ created_at: bucket0, status: "COMPLETED_SUCCESSFULLY" }),
makeRun({ created_at: bucket0, status: "CRASHED" }), // FAILED group
makeRun({ created_at: bucket2, status: "CANCELED" }), // CANCELED group
makeRun({ created_at: bucket2, status: "EXECUTING" }), // RUNNING group
makeRun({ created_at: bucket2, status: "SOME_UNKNOWN_STATUS" }), // folds into RUNNING
// Deleted row — distinct run, _is_deleted = 1, must NOT be counted.
makeRun({ created_at: bucket0, status: "COMPLETED_SUCCESSFULLY", _is_deleted: 1 }),
];
const [insertError] = await insert(rows);
expect(insertError).toBeNull();
const throwingReplica = new Proxy(
{},
{
get() {
throw new Error("getActivity must not touch the run-ops Prisma client");
},
}
) as never;
const presenter = new TaskDetailPresenter(throwingReplica, clickhouse);
const activity = await presenter.getActivity({
organizationId,
projectId,
environmentId,
taskSlug,
from,
to,
});
// Stable legend, fixed group order.
expect(activity.statuses).toEqual(["COMPLETED", "FAILED", "CANCELED", "RUNNING"]);
// 72 five-minute buckets, every bucket carries all four group keys.
expect(activity.data).toHaveLength(72);
for (const point of activity.data) {
expect(typeof point.bucket).toBe("number");
expect(point).toHaveProperty("COMPLETED");
expect(point).toHaveProperty("FAILED");
expect(point).toHaveProperty("CANCELED");
expect(point).toHaveProperty("RUNNING");
}
// Buckets are epoch MILLISECONDS aligned to the 5-minute interval.
const byBucket = new Map(activity.data.map((p) => [p.bucket, p]));
const p0 = byBucket.get(Math.floor(bucket0 / BUCKET_MS) * BUCKET_MS)!;
const p2 = byBucket.get(Math.floor(bucket2 / BUCKET_MS) * BUCKET_MS)!;
expect(p0).toBeDefined();
expect(p2).toBeDefined();
// 00:30 bucket: 1 COMPLETED, 1 FAILED, deleted row excluded.
expect(p0.COMPLETED).toBe(1);
expect(p0.FAILED).toBe(1);
expect(p0.CANCELED).toBe(0);
expect(p0.RUNNING).toBe(0);
// 02:30 bucket: 1 CANCELED, RUNNING = EXECUTING (1) + unknown status (1) = 2.
expect(p2.COMPLETED).toBe(0);
expect(p2.FAILED).toBe(0);
expect(p2.CANCELED).toBe(1);
expect(p2.RUNNING).toBe(2);
// Every other bucket is all-zero for every group.
for (const point of activity.data) {
if (point.bucket === p0.bucket || point.bucket === p2.bucket) continue;
expect(point.COMPLETED).toBe(0);
expect(point.FAILED).toBe(0);
expect(point.CANCELED).toBe(0);
expect(point.RUNNING).toBe(0);
}
}
);
});