Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
463 lines
17 KiB
TypeScript
463 lines
17 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import type { MediaRepos, NewMediaPublication } from "./media.js";
|
|
import { createMediaRepos } from "./media.js";
|
|
import type { MediaTestDb } from "./media-fixtures.js";
|
|
import {
|
|
BASE_TIME,
|
|
openMediaTestDb,
|
|
sourceItem,
|
|
testBrandKit,
|
|
testEdl,
|
|
testTeaserWeights,
|
|
} from "./media-fixtures.js";
|
|
import { InvalidMediaInputError, MediaStateConflictError } from "./media-shared.js";
|
|
import { IsolationError } from "./scope.js";
|
|
|
|
const databaseUrl = process.env.DATABASE_URL;
|
|
const describePostgres =
|
|
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
|
|
|
const PROFILE = "example-short-vertical";
|
|
|
|
describePostgres("media production, publishing and metrics (PostgreSQL)", () => {
|
|
let db: MediaTestDb;
|
|
let media: MediaRepos;
|
|
|
|
beforeAll(() => {
|
|
db = openMediaTestDb(databaseUrl!);
|
|
media = createMediaRepos(db.prisma);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db?.cleanup();
|
|
});
|
|
|
|
async function newProject() {
|
|
const { spaceId, userId } = await db.newUser();
|
|
const source = await media.createSource(spaceId, {
|
|
kind: "emulator",
|
|
label: "Emulator",
|
|
ownerUserId: userId,
|
|
config: {},
|
|
});
|
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, source.id, [sourceItem(1)]);
|
|
const assetId = changedAssetIds[0] as string;
|
|
const project = await media.createProject(spaceId, userId, {
|
|
title: "Launch",
|
|
goal: "teaser",
|
|
profileIds: [PROFILE, "example-paid-video"],
|
|
assetIds: [assetId],
|
|
});
|
|
return { spaceId, userId, assetId, projectId: project.id };
|
|
}
|
|
|
|
async function doneRender(spaceId: string, userId: string, projectId: string, assetId: string) {
|
|
const plan = await media.createPlanVersion(spaceId, {
|
|
projectId,
|
|
profileId: PROFILE,
|
|
edl: testEdl(assetId),
|
|
createdBy: "user",
|
|
rationale: null,
|
|
});
|
|
const render = await media.createRender(spaceId, {
|
|
planId: plan.id,
|
|
quality: "final",
|
|
createdBy: userId,
|
|
});
|
|
await media.claimRender(spaceId, render.id);
|
|
await media.updateRender(spaceId, render.id, { status: "done", progress: 1 });
|
|
return render.id;
|
|
}
|
|
|
|
const publicationInput = (renderId: string, content: string): NewMediaPublication => ({
|
|
renderId,
|
|
destination: "bundle",
|
|
targetUrl: "https://example.com/offer",
|
|
trackingUrl: `https://example.com/offer?utm_content=${content}`,
|
|
utm: { source: "shorts", medium: "video", campaign: "launch", content },
|
|
title: null,
|
|
caption: null,
|
|
tags: [],
|
|
thumbnailVariant: `thumb-${content}`,
|
|
scheduledAt: null,
|
|
});
|
|
|
|
it("validates and stores brand kits", async () => {
|
|
const { spaceId } = await db.newUser();
|
|
await expect(
|
|
media.upsertBrandKit(spaceId, { kit: { ...testBrandKit, colors: { primary: "red" } } }),
|
|
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
|
const kit = await media.upsertBrandKit(spaceId, { kit: testBrandKit });
|
|
const renamed = await media.upsertBrandKit(spaceId, {
|
|
id: kit.id,
|
|
kit: { ...testBrandKit, name: "Renamed" },
|
|
});
|
|
expect(renamed).toMatchObject({ id: kit.id, kit: { name: "Renamed" } });
|
|
expect(await media.listBrandKits(spaceId)).toHaveLength(1);
|
|
await media.removeBrandKit(spaceId, kit.id);
|
|
expect(await media.listBrandKits(spaceId)).toEqual([]);
|
|
});
|
|
|
|
it("creates, pages, updates and removes projects", async () => {
|
|
const { spaceId, userId } = await db.newUser();
|
|
await expect(
|
|
media.createProject(spaceId, userId, {
|
|
title: "Bad",
|
|
goal: "teaser",
|
|
profileIds: [PROFILE],
|
|
assetIds: ["missing"],
|
|
}),
|
|
).rejects.toBeInstanceOf(IsolationError);
|
|
const created = [];
|
|
for (let index = 0; index < 5; index++) {
|
|
created.push(
|
|
await media.createProject(spaceId, userId, {
|
|
title: `Project ${index}`,
|
|
goal: "highlight",
|
|
profileIds: [PROFILE, PROFILE],
|
|
}),
|
|
);
|
|
}
|
|
expect(created[0]?.profileIds).toEqual([PROFILE]);
|
|
const seen: string[] = [];
|
|
let cursor: string | null = null;
|
|
do {
|
|
const page = await media.listProjects(spaceId, { cursor, limit: 2 });
|
|
seen.push(...page.items.map((project) => project.title));
|
|
cursor = page.nextCursor;
|
|
} while (cursor);
|
|
expect(seen).toEqual(["Project 4", "Project 3", "Project 2", "Project 1", "Project 0"]);
|
|
|
|
const updated = await media.updateProject(spaceId, created[0]?.id as string, {
|
|
title: "Renamed",
|
|
status: "review",
|
|
});
|
|
expect(updated).toMatchObject({ title: "Renamed", status: "review" });
|
|
expect((await media.listProjects(spaceId, { limit: 1 })).items[0]?.id).toBe(created[0]?.id);
|
|
await media.removeProject(spaceId, created[0]?.id as string);
|
|
await expect(media.getProject(spaceId, created[0]?.id as string)).rejects.toBeInstanceOf(
|
|
IsolationError,
|
|
);
|
|
});
|
|
|
|
it("numbers concurrent plan versions without gaps or duplicates", async () => {
|
|
const { spaceId, assetId, projectId } = await newProject();
|
|
await expect(
|
|
media.createPlanVersion(spaceId, {
|
|
projectId,
|
|
profileId: PROFILE,
|
|
edl: { edlVersion: 1 },
|
|
createdBy: "agent",
|
|
rationale: null,
|
|
}),
|
|
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
|
await expect(
|
|
media.createPlanVersion(spaceId, {
|
|
projectId,
|
|
profileId: "example-paid-video",
|
|
edl: testEdl(assetId),
|
|
createdBy: "agent",
|
|
rationale: null,
|
|
}),
|
|
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
|
|
|
const plans = await Promise.all(
|
|
Array.from({ length: 12 }, (_, index) =>
|
|
media.createPlanVersion(spaceId, {
|
|
projectId,
|
|
profileId: PROFILE,
|
|
edl: testEdl(assetId),
|
|
createdBy: index % 2 ? "agent" : "user",
|
|
rationale: `try ${index}`,
|
|
}),
|
|
),
|
|
);
|
|
expect(plans.map((plan) => plan.version).sort((a, b) => a - b)).toEqual(
|
|
Array.from({ length: 12 }, (_, index) => index + 1),
|
|
);
|
|
await media.createPlanVersion(spaceId, {
|
|
projectId,
|
|
profileId: "example-paid-video",
|
|
edl: testEdl(assetId, "example-paid-video"),
|
|
createdBy: "agent",
|
|
rationale: null,
|
|
});
|
|
const latest = await media.latestPlans(spaceId, projectId);
|
|
expect(latest.map((plan) => [plan.profileId, plan.version])).toEqual([
|
|
["example-paid-video", 1],
|
|
[PROFILE, 12],
|
|
]);
|
|
expect((await media.latestPlan(spaceId, projectId, PROFILE))?.version).toBe(12);
|
|
expect(await media.listPlanVersions(spaceId, projectId, PROFILE)).toHaveLength(12);
|
|
expect(latest[1]?.edl.tracks.video[0]?.speed).toBe(1);
|
|
});
|
|
|
|
it("lets exactly one worker claim a render and never revives a canceled one", async () => {
|
|
const { spaceId, userId, assetId, projectId } = await newProject();
|
|
const plan = await media.createPlanVersion(spaceId, {
|
|
projectId,
|
|
profileId: PROFILE,
|
|
edl: testEdl(assetId),
|
|
createdBy: "agent",
|
|
rationale: null,
|
|
});
|
|
const render = await media.createRender(spaceId, {
|
|
planId: plan.id,
|
|
quality: "draft",
|
|
createdBy: userId,
|
|
});
|
|
expect(render).toMatchObject({ status: "queued", projectId, profileId: PROFILE, progress: 0 });
|
|
|
|
const claims = await Promise.all(
|
|
Array.from({ length: 5 }, () => media.claimRender(spaceId, render.id)),
|
|
);
|
|
expect(claims.filter((claim) => claim !== null)).toHaveLength(1);
|
|
|
|
const progressed = await media.updateRender(spaceId, render.id, { progress: 0.4 });
|
|
expect(progressed?.progress).toBe(0.4);
|
|
await expect(
|
|
media.updateRender(spaceId, render.id, { autoFixRound: 4 }),
|
|
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
|
const canceled = await media.cancelRender(spaceId, render.id);
|
|
expect(canceled.status).toBe("canceled");
|
|
expect(await media.updateRender(spaceId, render.id, { status: "done" })).toBeNull();
|
|
expect((await media.getRender(spaceId, render.id)).status).toBe("canceled");
|
|
await expect(media.cancelRender(spaceId, render.id)).rejects.toBeInstanceOf(
|
|
MediaStateConflictError,
|
|
);
|
|
|
|
const second = await media.createRender(spaceId, {
|
|
planId: plan.id,
|
|
quality: "final",
|
|
createdBy: userId,
|
|
});
|
|
await media.claimRender(spaceId, second.id);
|
|
const gated = await media.updateRender(spaceId, second.id, {
|
|
status: "blocked",
|
|
progress: 1,
|
|
autoFixRound: 3,
|
|
outputs: [
|
|
{
|
|
kind: "video",
|
|
artifactId: null,
|
|
fileName: "out.mp4",
|
|
sizeBytes: 1234,
|
|
path: "renders/x/out.mp4",
|
|
},
|
|
],
|
|
gateReport: {
|
|
profileId: PROFILE,
|
|
passed: false,
|
|
checkedAt: new Date(BASE_TIME).toISOString(),
|
|
sampledFrames: 10,
|
|
issues: [{ check: "consent", severity: "block", code: "missing_consent", message: "x" }],
|
|
autoFixRound: 3,
|
|
},
|
|
ffprobe: {
|
|
durationMs: 5000,
|
|
width: 1080,
|
|
height: 1920,
|
|
fps: 30,
|
|
videoCodec: "h264",
|
|
audioCodec: "aac",
|
|
sizeBytes: 1234,
|
|
integratedLufs: -14,
|
|
truePeakDb: -1,
|
|
tags: {},
|
|
},
|
|
});
|
|
expect(gated).toMatchObject({
|
|
status: "blocked",
|
|
outputs: [{ kind: "video", fileName: "out.mp4", sizeBytes: 1234, artifactId: null }],
|
|
gateReport: { passed: false },
|
|
ffprobe: { width: 1080 },
|
|
});
|
|
expect(gated?.outputs[0]).not.toHaveProperty("path");
|
|
const record = await media.getRenderRecord(spaceId, second.id);
|
|
expect(record.outputs).toEqual([expect.objectContaining({ path: "renders/x/out.mp4" })]);
|
|
expect(record.autoFixRound).toBe(3);
|
|
await expect(media.cancelRender(spaceId, second.id)).rejects.toBeInstanceOf(
|
|
MediaStateConflictError,
|
|
);
|
|
expect((await media.listRenders(spaceId, projectId)).map((r) => r.id)).toEqual([
|
|
second.id,
|
|
render.id,
|
|
]);
|
|
const detail = await media.getProject(spaceId, projectId);
|
|
expect(detail.plans).toHaveLength(1);
|
|
expect(detail.renders).toHaveLength(2);
|
|
});
|
|
|
|
it("publishes only finished renders and moves publications through their states", async () => {
|
|
const { spaceId, userId, assetId, projectId } = await newProject();
|
|
const plan = await media.createPlanVersion(spaceId, {
|
|
projectId,
|
|
profileId: PROFILE,
|
|
edl: testEdl(assetId),
|
|
createdBy: "agent",
|
|
rationale: null,
|
|
});
|
|
const queued = await media.createRender(spaceId, {
|
|
planId: plan.id,
|
|
quality: "final",
|
|
createdBy: userId,
|
|
});
|
|
await expect(
|
|
media.createPublication(spaceId, userId, publicationInput(queued.id, "a")),
|
|
).rejects.toBeInstanceOf(MediaStateConflictError);
|
|
const renderId = await doneRender(spaceId, userId, projectId, assetId);
|
|
|
|
const draft = await media.createPublication(spaceId, userId, publicationInput(renderId, "a"));
|
|
expect(draft).toMatchObject({ status: "draft", profileId: PROFILE, utm: { content: "a" } });
|
|
const scheduledAt = new Date(BASE_TIME + 86_400_000);
|
|
const scheduled = await media.schedulePublication(spaceId, draft.id, scheduledAt);
|
|
expect(scheduled).toMatchObject({
|
|
status: "scheduled",
|
|
scheduledAt: scheduledAt.toISOString(),
|
|
});
|
|
const bundled = await media.markPublicationBundled(spaceId, draft.id, "artifact-bundle");
|
|
expect(bundled).toMatchObject({ status: "bundled", bundleArtifactId: "artifact-bundle" });
|
|
const published = await media.markPublicationPublished(
|
|
spaceId,
|
|
draft.id,
|
|
"https://video.example.com/v/1",
|
|
);
|
|
expect(published.status).toBe("published");
|
|
expect(published.publishedAt).not.toBeNull();
|
|
await expect(media.cancelPublication(spaceId, draft.id)).rejects.toBeInstanceOf(
|
|
MediaStateConflictError,
|
|
);
|
|
|
|
const other = await media.createPublication(spaceId, userId, {
|
|
...publicationInput(renderId, "b"),
|
|
scheduledAt,
|
|
});
|
|
expect(other.status).toBe("scheduled");
|
|
expect((await media.markPublicationFailed(spaceId, other.id)).status).toBe("failed");
|
|
expect((await media.cancelPublication(spaceId, other.id)).status).toBe("canceled");
|
|
|
|
const page1 = await media.listPublications(spaceId, { projectId, limit: 1 });
|
|
expect(page1.items.map((item) => item.id)).toEqual([other.id]);
|
|
const page2 = await media.listPublications(spaceId, { projectId, cursor: page1.nextCursor });
|
|
expect(page2.items.map((item) => item.id)).toEqual([draft.id]);
|
|
expect(page2.nextCursor).toBeNull();
|
|
const elsewhere = await newProject();
|
|
expect(
|
|
(await media.listPublications(spaceId, { projectId: elsewhere.projectId })).items,
|
|
).toEqual([]);
|
|
|
|
const byContent = await media.resolvePublicationKeys(spaceId, "utm_content", ["a", "b", "z"]);
|
|
expect(Object.fromEntries(byContent)).toEqual({ a: draft.id, b: other.id });
|
|
const byUrl = await media.resolvePublicationKeys(spaceId, "external_url", [
|
|
"https://video.example.com/v/1",
|
|
]);
|
|
expect(byUrl.get("https://video.example.com/v/1")).toBe(draft.id);
|
|
const byTracking = await media.resolvePublicationKeys(spaceId, "tracking_url", [
|
|
"https://example.com/offer?utm_content=b",
|
|
]);
|
|
expect([...byTracking.values()]).toEqual([other.id]);
|
|
});
|
|
|
|
it("counts the funnel from the latest observation per source without double counting", async () => {
|
|
const { spaceId, userId, assetId, projectId } = await newProject();
|
|
const renderId = await doneRender(spaceId, userId, projectId, assetId);
|
|
const a = await media.createPublication(spaceId, userId, publicationInput(renderId, "a"));
|
|
const b = await media.createPublication(spaceId, userId, publicationInput(renderId, "b"));
|
|
const day = (n: number) => new Date(BASE_TIME + n * 86_400_000).toISOString();
|
|
const report = [
|
|
{
|
|
publicationId: a.id,
|
|
observedAt: day(1),
|
|
views: 100,
|
|
clicks: 10,
|
|
conversions: 1,
|
|
revenueCents: 500,
|
|
},
|
|
{
|
|
publicationId: a.id,
|
|
observedAt: day(2),
|
|
views: 250,
|
|
clicks: 30,
|
|
conversions: 3,
|
|
revenueCents: 1500,
|
|
},
|
|
{
|
|
publicationId: b.id,
|
|
observedAt: day(2),
|
|
views: 40,
|
|
clicks: 4,
|
|
conversions: 0,
|
|
revenueCents: 0,
|
|
},
|
|
].map((row) => ({ ...row, currency: "EUR", source: "csv_import" as const }));
|
|
|
|
expect(await media.recordMetrics(spaceId, report)).toBe(3);
|
|
expect(await media.recordMetrics(spaceId, report)).toBe(3);
|
|
await media.recordMetric(spaceId, {
|
|
publicationId: a.id,
|
|
observedAt: day(1),
|
|
views: 0,
|
|
clicks: 0,
|
|
conversions: 2,
|
|
revenueCents: 1000,
|
|
currency: "EUR",
|
|
source: "manual",
|
|
});
|
|
expect(await media.listMetrics(spaceId, a.id)).toHaveLength(3);
|
|
|
|
const rows = await media.funnel(spaceId, projectId);
|
|
const byId = new Map(rows.map((row) => [row.publicationId, row]));
|
|
expect(byId.get(a.id)).toEqual({
|
|
publicationId: a.id,
|
|
projectId,
|
|
profileId: PROFILE,
|
|
variant: "a",
|
|
thumbnailVariant: "thumb-a",
|
|
views: 250,
|
|
clicks: 30,
|
|
conversions: 5,
|
|
revenueCents: 2500,
|
|
currency: "EUR",
|
|
});
|
|
expect(byId.get(b.id)).toMatchObject({ views: 40, clicks: 4, conversions: 0 });
|
|
expect(await media.funnel(spaceId)).toHaveLength(2);
|
|
expect(await media.publicationsWithMetricsCount(spaceId)).toBe(2);
|
|
|
|
await expect(media.recordMetric(spaceId, { ...report[0]!, views: -1 })).rejects.toBeInstanceOf(
|
|
InvalidMediaInputError,
|
|
);
|
|
});
|
|
|
|
it("saves CSV mappings and the teaser model", async () => {
|
|
const { spaceId } = await db.newUser();
|
|
const mapping = {
|
|
name: "Platform export",
|
|
delimiter: ";" as const,
|
|
columns: { publication: "Link", observedAt: "Date", views: "Views", revenue: "Revenue" },
|
|
publicationKey: "tracking_url" as const,
|
|
revenueUnit: "major" as const,
|
|
defaultCurrency: "USD",
|
|
};
|
|
const saved = await media.saveMetricMapping(spaceId, mapping);
|
|
expect(saved).toMatchObject({ ...mapping, id: expect.any(String) });
|
|
const again = await media.saveMetricMapping(spaceId, { ...mapping, delimiter: "," });
|
|
expect(again.id).toBe(saved.id);
|
|
const other = await media.saveMetricMapping(spaceId, { ...mapping, name: "Other" });
|
|
await expect(
|
|
media.saveMetricMapping(spaceId, { ...mapping, id: other.id, name: "Platform export" }),
|
|
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
|
expect((await media.listMetricMappings(spaceId)).map((m) => m.name)).toEqual([
|
|
"Other",
|
|
"Platform export",
|
|
]);
|
|
expect((await media.getMetricMapping(spaceId, saved.id as string)).delimiter).toBe(",");
|
|
|
|
expect(await media.getTeaserModel(spaceId)).toBeNull();
|
|
const model = await media.putTeaserModel(spaceId, testTeaserWeights, 21);
|
|
expect(model).toMatchObject({ sampleCount: 21, weights: testTeaserWeights });
|
|
expect((await media.getTeaserModel(spaceId))?.sampleCount).toBe(21);
|
|
await media.resetTeaserModel(spaceId);
|
|
expect(await media.getTeaserModel(spaceId)).toBeNull();
|
|
});
|
|
});
|