test(media): cover marketing plans, brand kits, bundles, publishing and learning

This commit is contained in:
Claude 2026-10-03 18:43:37 +00:00
parent 872fdd2194
commit 03e4961c9f
No known key found for this signature in database
4 changed files with 1417 additions and 4 deletions

View File

@ -149,16 +149,30 @@ export async function generateCopy(
.filter((chapter) => chapter.atMs < durationMs && chapter.title.trim())
.sort((a, b) => a.atMs - b.atMs)
: [];
const hook = truncateText(draft.hook, 200);
const description = draft.description ? truncateText(draft.description, 5000) : undefined;
const altText = draft.altText?.trim() ? truncateText(draft.altText, 500) : undefined;
// The other fields get the same banned-pattern and link checks as the caption.
const extraIssues = checkCopy(profile, {
title: null,
caption: [hook, description, altText].filter(Boolean).join("\n"),
tags: [],
})
.filter((issue) => issue.code === "banned_pattern" || issue.code.startsWith("link_"))
.map((issue) => ({
...issue,
message: issue.message.replace(/^caption/, "hook or description"),
}));
return MarketingCopySchema.parse({
profileId: profile.id,
language,
title,
hook: truncateText(draft.hook, 200),
hook,
caption,
hashtags,
...(draft.description ? { description: truncateText(draft.description, 5000) } : {}),
...(description ? { description } : {}),
...(chapterList.length > 0 ? { chapters: chapterList } : {}),
...(draft.altText?.trim() ? { altText: truncateText(draft.altText, 500) } : {}),
issues: checkCopy(profile, { title, caption, tags: hashtags }),
...(altText ? { altText } : {}),
issues: [...checkCopy(profile, { title, caption, tags: hashtags }), ...extraIssues],
});
}

View File

@ -0,0 +1,941 @@
import { execFile, execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import type { BackgroundJob, JobPublisher, MediaRenderer } from "@rakazo/adapter-kit";
import type { BrandKit, FfprobeReport, PlatformProfile, TeaserWeights } from "@rakazo/contracts";
import { PlatformProfileSchema, TEASER_FEATURES } from "@rakazo/contracts";
import type { MediaRepos, PrismaClient } from "@rakazo/db";
import { bootstrapUserSpace, createDb, createMediaRepos } from "@rakazo/db";
import type { ProfileSet } from "@rakazo/media";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { LocalArtifactStore } from "./artifacts.js";
import { isCubeLut, saveBrandKit } from "./media-brand.js";
import type {
MediaActor,
MediaModelClient,
MediaRealtimeEvent,
MediaServiceDeps,
} from "./media-context.js";
import { EmulatorMediaInferenceProvider } from "./media-emulator.js";
import { createMediaMarketingJobHandlers, createMediaMarketingService } from "./media-marketing.js";
import { storeMediaArtifact } from "./media-marketing-files.js";
import { recordMetric, refitTeaserModel, resetTeaserModel } from "./media-metrics.js";
import { ResvgMotionGraphicsRenderer } from "./media-motion-renderer.js";
import { bundleRelativeDir } from "./media-publish.js";
import { BundleMediaPublisher } from "./media-publisher-bundle.js";
/*
* Marketing slice against real repositories: plans with the teaser judge,
* brand kits and intro variants, thumbnails, publications, bundles, the
* scheduled publish job, and results import with teaser learning. All media is
* synthetic (ffmpeg testsrc and sine); the model is a recording fake.
*/
const run = promisify(execFile);
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
function onPath(bin: string): boolean {
try {
execFileSync(bin, ["-version"], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
const hasFfmpeg = onPath("ffmpeg") && onPath("ffprobe");
async function ffmpeg(args: string[]): Promise<void> {
await run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-nostdin", "-y", ...args]);
}
function profile(overrides: Record<string, unknown>): PlatformProfile {
return PlatformProfileSchema.parse({
id: "example-short-vertical",
name: "Example short vertical",
kind: "free_social",
reviewedAt: "2026-09-01",
video: {
aspectRatios: ["9:16"],
width: 1080,
height: 1920,
fps: 30,
codec: "h264",
maxBitrateKbps: 8000,
maxFileSizeMb: 500,
},
duration: { hardMinMs: 1000, hardMaxMs: 60_000, recommended: [7000, 15_000, 30_000] },
safeZones: { topPct: 10, bottomPct: 20, leftPct: 5, rightPct: 12 },
captions: { recommended: true, maxLines: 2 },
audio: {},
text: { titleMaxChars: 100, captionMaxChars: 150, hashtagLimit: 3, linkPolicy: "bio_only" },
content: { thresholds: { explicit_nudity: 0.2 } },
disclosure: { aiAlteredLabelRequired: true },
consent: { requirePerformerConsent: false, requireAgeVerification: false },
export: {},
...overrides,
});
}
const SHORT = profile({});
const LANDSCAPE = profile({
id: "example-landscape",
name: "Example landscape",
video: {
aspectRatios: ["16:9"],
width: 1920,
height: 1080,
fps: 30,
codec: "h264",
maxBitrateKbps: 12_000,
maxFileSizeMb: 2000,
},
duration: { hardMinMs: 1000, hardMaxMs: 600_000, recommended: [30_000], hookWindowMs: 5000 },
text: { titleMaxChars: 100, captionMaxChars: 5000, hashtagLimit: 15, linkPolicy: "allowed" },
});
const PAID = profile({
id: "example-paid-video",
name: "Example paid video",
kind: "paid_video",
video: LANDSCAPE.video,
duration: { hardMinMs: 1000, hardMaxMs: 3_600_000, recommended: [] },
text: { captionMaxChars: 2000, hashtagLimit: 5, linkPolicy: "forbidden" },
disclosure: { aiAlteredLabelRequired: true, ageRestrictedFlag: true },
consent: { requirePerformerConsent: true, requireAgeVerification: true },
export: { previewClipMs: 2000 },
});
const profileSet: ProfileSet = {
profiles: new Map(
[SHORT, LANDSCAPE, PAID].map((p) => [p.id, { profile: p, origin: "builtin" as const }]),
),
errors: [],
};
const KIT: BrandKit = {
name: "Lake Studio",
colors: {
primary: "#1a1a1a",
secondary: "#333333",
accent: "#777777",
text: "#f5f5f5",
background: "#0a0a0a",
},
fonts: [],
logoArtifactIds: [],
captionPresets: [],
musicBeds: [],
tone: "calm and premium",
intro: { templateId: "intro_logo_reveal", params: {}, durationMs: 2500 },
};
function gatePassed(profileId: string) {
return {
profileId,
passed: true,
checkedAt: new Date().toISOString(),
sampledFrames: 8,
issues: [],
autoFixRound: 0,
};
}
describePostgres("media marketing on Postgres", () => {
let prisma: PrismaClient;
let pool: { end(): Promise<void> };
let repos: MediaRepos;
let root: string;
let mediaRoot: string;
const userIds: string[] = [];
const jobs: BackgroundJob[] = [];
const canceledJobs: string[] = [];
const events: MediaRealtimeEvent[] = [];
let modelReply: ((input: Parameters<MediaModelClient["generate"]>[0]) => unknown) | null = null;
const modelCalls: Array<Parameters<MediaModelClient["generate"]>[0]> = [];
let deps: MediaServiceDeps;
async function newActor(): Promise<MediaActor> {
const id = `mk${randomUUID().replaceAll("-", "")}`;
userIds.push(id);
await prisma.user.create({ data: { id, name: "Test", email: `${id}@rakazo.test` } });
const { spaceId } = await bootstrapUserSpace(
prisma,
{ id },
{ signupsEnabled: "true", signupAllowlist: "" },
{ claimDeploymentOwner: false },
);
return { spaceId, userId: id };
}
const renderer: MediaRenderer = {
id: "test-ffmpeg",
hardwareEncoders: async () => [],
render: async () => {
throw new Error("not used by the marketing slice");
},
probe: async () => ({ durationMs: 4000 }) as FfprobeReport,
async extractFrame(input) {
await ffmpeg([
"-ss",
(input.tMs / 1000).toFixed(3),
"-i",
input.path,
"-frames:v",
"1",
...(input.width ? ["-vf", `scale=${input.width}:-2`] : []),
"-map_metadata",
"-1",
input.outPath,
]);
},
};
beforeAll(async () => {
const db = createDb(databaseUrl as string, { poolMax: 4 });
prisma = db.prisma;
pool = db.pool;
repos = createMediaRepos(prisma);
root = await mkdtemp(path.join(tmpdir(), "media-marketing-"));
mediaRoot = path.join(root, "media");
await mkdir(mediaRoot, { recursive: true });
const jobPublisher: JobPublisher = {
enqueue: async (job) => {
jobs.push(job);
},
cancel: async (key) => {
canceledJobs.push(key);
},
close: async () => {},
};
deps = {
prisma,
repos,
jobs: jobPublisher,
inference: new EmulatorMediaInferenceProvider({ root: mediaRoot }),
renderer,
motion: new ResvgMotionGraphicsRenderer({ root: mediaRoot }),
publisher: new BundleMediaPublisher(),
shortener: null,
sources: {},
mediaRoot,
profiles: async () => profileSet,
artifacts: new LocalArtifactStore(path.join(root, "artifacts")),
model: {
async generate(input) {
modelCalls.push(input);
return modelReply ? input.schema.parse(modelReply(input)) : null;
},
},
publish: async (_spaceId, event) => {
events.push(event);
},
now: () => new Date(),
};
});
afterAll(async () => {
if (!prisma) return;
const memberships = await prisma.member.findMany({
where: { userId: { in: userIds } },
select: { organizationId: true },
});
await prisma.organization.deleteMany({
where: { id: { in: memberships.map((member) => member.organizationId) } },
});
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.$disconnect();
await pool.end();
await rm(root, { recursive: true, force: true });
});
/** A space with two 20 s landscape clips, scored segments, and a project. */
async function seedLibrary(actor: MediaActor, options: { kit?: BrandKit } = {}) {
const source = await repos.createSource(actor.spaceId, {
kind: "folder",
label: "Footage",
ownerUserId: actor.userId,
config: { path: "/media/footage" },
});
const item = (index: number) => ({
externalId: `clip-${index}.mp4`,
kind: "video" as const,
capturedAt: new Date(Date.UTC(2026, 0, 1, 8, index)),
durationMs: 20_000,
width: 1920,
height: 1080,
fps: 30,
gpsLat: null,
gpsLon: null,
deviceModel: null,
albumNames: [],
isFavorite: false,
fileName: `clip-${index}.mp4`,
sizeBytes: 1000,
revision: "r1",
});
const synced = await repos.upsertSourceItems(actor.spaceId, source.id, [item(1), item(2)]);
const [assetA, assetB] = synced.changedAssetIds as [string, string];
const scores = (motion: number, speechHook = 0.2) => ({
motion,
sharpness: 0.7,
faces: 0.2,
audioPeak: 0.4,
speechHook,
novelty: 0.5,
aesthetics: 0.6,
});
await repos.replaceSegments(actor.spaceId, assetA, 1, [
{
kind: "shot",
startMs: 0,
endMs: 3000,
scores: scores(0.9, 0.9),
transcriptExcerpt: "Did you know these 3 tricks?",
},
{ kind: "shot", startMs: 3000, endMs: 8000, scores: scores(0.6) },
{ kind: "shot", startMs: 8000, endMs: 12_000, scores: scores(1, 1), paidOnly: true },
{ kind: "shot", startMs: 12_000, endMs: 16_000, scores: scores(0.95) },
{ kind: "shot", startMs: 16_000, endMs: 20_000, scores: scores(0.5) },
]);
await repos.replaceSegments(actor.spaceId, assetB, 1, [
{ kind: "shot", startMs: 0, endMs: 5000, scores: scores(0.85) },
{ kind: "shot", startMs: 5000, endMs: 10_000, scores: scores(0.4) },
]);
// Frame 12.5 s of clip A exceeds the short profile's nudity threshold.
const frames = Array.from({ length: 20 }, (_, i) => ({
tMs: i * 1000 + 500,
scores: { explicit_nudity: i === 12 ? 0.9 : 0.01 },
}));
for (const assetId of [assetA, assetB]) {
await repos.upsertAnalysis(actor.spaceId, assetId, {
kind: "classifier",
modelId: "emulator",
modelVersion: "1",
data: { frames },
});
}
// A treated tattoo on clip B keeps every frame of it away from the model.
await repos.createMark(actor.spaceId, {
kind: "tattoo",
label: "arm",
personId: null,
assetId: assetB,
prompt: { tMs: 1000, point: { x: 0.5, y: 0.5 } },
treatment: "blur",
});
if (hasFfmpeg) {
for (const assetId of [assetA, assetB]) {
const relative = path.join(actor.spaceId, assetId, "proxy.mp4");
await mkdir(path.join(mediaRoot, path.dirname(relative)), { recursive: true });
await ffmpeg([
"-f",
"lavfi",
"-i",
"testsrc2=s=160x90:r=10:d=20",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
path.join(mediaRoot, relative),
]);
await repos.recordProxy(actor.spaceId, assetId, {
contentHash: assetId.padEnd(64, "0").slice(0, 64),
proxyPath: relative,
thumbPath: null,
});
}
}
const kit = options.kit ? (await saveBrandKit(deps, actor, { kit: options.kit })).record : null;
const project = await repos.createProject(actor.spaceId, actor.userId, {
title: "Lake Day Launch",
goal: "teaser",
profileIds: [SHORT.id, LANDSCAPE.id],
assetIds: [assetA, assetB],
brandKitId: kit?.id ?? null,
});
const segments = await repos.listSegments(actor.spaceId, [assetA, assetB]);
return { assetA, assetB, project, segments };
}
describe("edit plans", () => {
it("puts the hook first on shorts, keeps paid-only segments out, and ends on a CTA", async () => {
const actor = await newActor();
const { project, segments, assetA } = await seedLibrary(actor, { kit: KIT });
modelReply = null;
const marketing = createMediaMarketingService(deps);
const plans = await marketing.autoPlan(actor, { projectId: project.id });
expect(plans.map((plan) => plan.profileId).sort()).toEqual([LANDSCAPE.id, SHORT.id]);
const short = plans.find((plan) => plan.profileId === SHORT.id)!;
const landscape = plans.find((plan) => plan.profileId === LANDSCAPE.id)!;
// Shorts: hook before branding; the long-form profile may open with the intro.
expect(short.edl.intro).toBeUndefined();
expect(landscape.edl.intro?.templateId).toBe("intro_logo_reveal");
const firstShort = [...short.edl.tracks.video].sort((a, b) => a.atMs - b.atMs)[0]!;
expect(firstShort.atMs).toBeLessThan(SHORT.duration.hookWindowMs);
expect(short.rationale).toContain("Brand intro left out so the hook comes first.");
const paid = segments.find((segment) => segment.paidOnly)!;
for (const plan of plans) {
for (const clip of plan.edl.tracks.video) {
expect(clip.segmentId).not.toBe(paid.id);
if (clip.assetId === assetA) {
expect(clip.outMs <= paid.startMs || clip.inMs >= paid.endMs).toBe(true);
}
}
}
const cta = short.edl.overlays.find((overlay) => overlay.kind === "cta_end_card");
expect(cta && "text" in cta ? cta.text : null).toBe("Link in bio");
expect(short.rationale).toContain("Default teaser weights.");
});
it("sends only gate-safe candidates to the model and applies its ranking", async () => {
if (!hasFfmpeg) return;
const actor = await newActor();
const { project, segments, assetB } = await seedLibrary(actor);
modelCalls.length = 0;
modelReply = (input) => {
const ids = [...input.prompt.matchAll(/id=(\S+)/g)].map((match) => match[1]!);
return {
ranking: ids.reverse().map((segmentId) => ({ segmentId, reason: "clear opener" })),
};
};
const plans = await createMediaMarketingService(deps).autoPlan(actor, {
projectId: project.id,
profileIds: [SHORT.id],
});
modelReply = null;
expect(modelCalls).toHaveLength(1);
const call = modelCalls[0]!;
const sentIds = [...call.prompt.matchAll(/id=(\S+)/g)].map((match) => match[1]!);
const byId = new Map(segments.map((segment) => [segment.id, segment]));
expect(sentIds.length).toBeGreaterThanOrEqual(2);
expect(call.images?.map((image) => image.name)).toEqual(sentIds.map((id) => `${id}.png`));
for (const id of sentIds) {
const segment = byId.get(id)!;
expect(segment.paidOnly).toBe(false);
expect(segment.assetId).not.toBe(assetB);
expect(segment.startMs).not.toBe(12_000);
}
expect(call.prompt).not.toContain(actor.userId);
expect(plans[0]?.rationale).toContain("Model ranking applied");
});
it("uses learned weights only from 20 publications and validates submitted plans", async () => {
const actor = await newActor();
const { project, assetA } = await seedLibrary(actor);
const marketing = createMediaMarketingService(deps);
const weights = Object.fromEntries(TEASER_FEATURES.map((f) => [f, 1])) as TeaserWeights;
await repos.putTeaserModel(actor.spaceId, weights, 5);
const [few] = await marketing.autoPlan(actor, {
projectId: project.id,
profileIds: [SHORT.id],
});
expect(few?.rationale).toContain("Default teaser weights.");
await repos.putTeaserModel(actor.spaceId, weights, 20);
const [many] = await marketing.autoPlan(actor, {
projectId: project.id,
profileIds: [SHORT.id],
});
expect(many?.rationale).toContain("Weights learned");
await resetTeaserModel(deps, actor);
expect(await repos.getTeaserModel(actor.spaceId)).toBeNull();
const wrongProfile = await marketing.submitPlan(actor, {
projectId: project.id,
edl: { ...many!.edl, output: { ...many!.edl.output, profileId: PAID.id } },
rationale: null,
createdBy: "agent",
});
expect(wrongProfile.ok).toBe(false);
const outOfRange = await marketing.submitPlan(actor, {
projectId: project.id,
edl: {
...many!.edl,
tracks: {
...many!.edl.tracks,
video: [
{ id: "c1", assetId: assetA, inMs: 0, outMs: 90_000, atMs: 0, reframe: "none" },
],
},
},
rationale: null,
createdBy: "user",
});
expect(outOfRange.ok).toBe(false);
const resubmitted = await marketing.submitPlan(actor, {
projectId: project.id,
edl: many!.edl,
rationale: "kept",
createdBy: "user",
});
expect(resubmitted.ok && resubmitted.plan.version).toBe(3);
});
});
describe("brand kits and intros", () => {
it("accepts only files of the space with usable types and real templates", async () => {
const actor = await newActor();
const other = await newActor();
const png = await sharp({
create: {
width: 64,
height: 64,
channels: 4,
background: { r: 255, g: 255, b: 255, alpha: 1 },
},
})
.png()
.toBuffer();
const logo = await storeMediaArtifact(deps, actor, {
name: "logo.png",
mimeType: "image/png",
bytes: new Uint8Array(png),
});
const lutText = `LUT_3D_SIZE 2\n${Array.from({ length: 8 }, () => "0 0.5 1").join("\n")}\n`;
expect(isCubeLut(lutText)).toBe(true);
expect(isCubeLut("LUT_3D_SIZE 2\n0 0 0\n")).toBe(false);
const lut = await storeMediaArtifact(deps, actor, {
name: "look.cube",
mimeType: "text/plain",
bytes: new TextEncoder().encode(lutText),
});
const foreign = await storeMediaArtifact(deps, other, {
name: "logo.png",
mimeType: "image/png",
bytes: new Uint8Array(png),
});
const saved = await saveBrandKit(deps, actor, {
kit: {
...KIT,
logoArtifactIds: [logo],
lutArtifactId: lut,
colors: { ...KIT.colors, text: "#111111" },
},
});
expect(saved.record.kit.logoArtifactIds).toEqual([logo]);
expect(saved.warnings.some((warning) => warning.includes("contrast"))).toBe(true);
await expect(
saveBrandKit(deps, actor, { kit: { ...KIT, logoArtifactIds: [foreign] } }),
).rejects.toThrow(/not found in this space/);
await expect(
saveBrandKit(deps, actor, { kit: { ...KIT, logoArtifactIds: [lut] } }),
).rejects.toThrow(/PNG or SVG/);
await expect(
saveBrandKit(deps, actor, {
kit: { ...KIT, intro: { templateId: "endcard_cta", params: {}, durationMs: 3000 } },
}),
).rejects.toThrow(/not a intro template/);
await expect(
saveBrandKit(deps, actor, {
kit: { ...KIT, intro: { templateId: "intro_glitch", params: {}, durationMs: 5000 } },
}),
).rejects.toThrow(/duration/);
});
it("proposes three intro variants with previews", async () => {
const actor = await newActor();
const { record } = await saveBrandKit(deps, actor, { kit: KIT });
const variants = await createMediaMarketingService(deps).introVariants(actor, {
brandKitId: record.id,
count: 3,
});
expect(variants).toHaveLength(3);
expect(new Set(variants.map((v) => v.template.templateId)).size).toBe(3);
for (const variant of variants) {
expect(variant.template.durationMs).toBeGreaterThanOrEqual(1500);
expect(variant.template.durationMs).toBeLessThanOrEqual(4000);
if (hasFfmpeg) {
const artifact = await prisma.artifact.findFirst({
where: { id: variant.previewArtifactId ?? "", spaceId: actor.spaceId },
});
expect(artifact?.mimeType).toBe("video/mp4");
}
}
expect(variants[0]?.template.templateId).toBe("intro_elegant_fade");
});
});
describe.skipIf(!hasFfmpeg)("thumbnails, publications and bundles", () => {
async function finishedRender(
actor: MediaActor,
input: { profile: PlatformProfile; quality?: "draft" | "final"; projectId?: string },
) {
const assetIds = input.projectId ? [] : [(await seedLibrary(actor)).assetA];
const projectId =
input.projectId ??
(
await repos.createProject(actor.spaceId, actor.userId, {
title: "Lake Day Launch",
goal: "teaser",
profileIds: [input.profile.id],
assetIds,
})
).id;
const vertical = input.profile.video.aspectRatios.includes("9:16");
const plan = await repos.createPlanVersion(actor.spaceId, {
projectId,
profileId: input.profile.id,
edl: {
edlVersion: 1,
output: {
profileId: input.profile.id,
width: input.profile.video.width,
height: input.profile.video.height,
fps: 30,
durationTargetMs: 4000,
durationHardMinMs: 1000,
durationHardMaxMs: 60_000,
},
tracks: {
video: [
{
id: "c1",
assetId: assetIds[0] ?? "a",
inMs: 0,
outMs: 4000,
atMs: 0,
reframe: "none",
},
],
},
metadata: { title: "Lake day", aiAltered: true, aiAlterations: ["inpaint"] },
},
createdBy: "agent",
rationale: null,
});
const render = await repos.createRender(actor.spaceId, {
planId: plan.id,
quality: input.quality ?? "final",
createdBy: actor.userId,
});
const dir = path.join(mediaRoot, actor.spaceId, "renders", render.id);
await mkdir(dir, { recursive: true });
await ffmpeg([
"-f",
"lavfi",
"-i",
`testsrc2=s=${vertical ? "180x320" : "320x180"}:r=30:d=4`,
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=4",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
"-g",
"30",
"-c:a",
"aac",
"-shortest",
path.join(dir, "video.mp4"),
]);
await ffmpeg([
"-i",
path.join(dir, "video.mp4"),
"-frames:v",
"1",
path.join(dir, "poster.png"),
]);
const srt = "1\n00:00:00,000 --> 00:00:02,000\nHello\n";
await writeFile(path.join(dir, "subtitles.srt"), srt);
await repos.claimRender(actor.spaceId, render.id);
await repos.updateRender(actor.spaceId, render.id, {
status: "done",
progress: 1,
gateReport: gatePassed(input.profile.id),
outputs: [
{
kind: "video",
artifactId: null,
fileName: "video.mp4",
sizeBytes: (await stat(path.join(dir, "video.mp4"))).size,
},
{ kind: "poster", artifactId: null, fileName: "poster.png", sizeBytes: 1 },
{
kind: "subtitles_srt",
artifactId: null,
fileName: "subtitles.srt",
sizeBytes: srt.length,
},
],
});
return { render, projectId };
}
it("creates three EXIF-free thumbnail variants", async () => {
const actor = await newActor();
const { render } = await finishedRender(actor, { profile: SHORT });
const thumbs = await createMediaMarketingService(deps).thumbnails(actor, {
renderId: render.id,
count: 3,
});
expect(thumbs.map((thumb) => thumb.fileName)).toEqual([
"thumb-a.png",
"thumb-b.png",
"thumb-c.png",
]);
const stored = await repos.getRender(actor.spaceId, render.id);
expect(stored.outputs.filter((output) => output.kind === "thumbnail")).toHaveLength(3);
const file = await readFile(
path.join(mediaRoot, actor.spaceId, "renders", render.id, "thumbs", "thumb-a.png"),
);
for (const chunk of ["eXIf", "tEXt", "iTXt", "zTXt"]) {
expect(file.includes(Buffer.from(chunk, "ascii"))).toBe(false);
}
});
it("refuses drafts and builds a complete bundle with tracking and checklist", async () => {
const actor = await newActor();
const marketing = createMediaMarketingService(deps);
const draft = await finishedRender(actor, { profile: SHORT, quality: "draft" });
const base = {
destination: "bundle",
targetUrl: "https://example.com/full-video",
utm: null,
title: "Lake day",
caption: "A calm morning at the lake",
tags: ["lake", "#morning", "calm", "extra"],
thumbnailVariant: null,
scheduledAt: null,
};
await expect(
marketing.createPublication(actor, { ...base, renderId: draft.render.id }),
).rejects.toThrow(/final render/);
const { render } = await finishedRender(actor, {
profile: SHORT,
projectId: draft.projectId,
});
await marketing.thumbnails(actor, { renderId: render.id, count: 3 });
const publication = await marketing.createPublication(actor, {
...base,
renderId: render.id,
thumbnailVariant: "thumb-b",
});
const tracking = new URL(publication.trackingUrl ?? "");
expect(tracking.searchParams.get("utm_source")).toBe(SHORT.id);
expect(tracking.searchParams.get("utm_medium")).toBe("video-9x16");
expect(tracking.searchParams.get("utm_campaign")).toBe("lake-day-launch");
expect(tracking.searchParams.get("utm_content")).toBe("thumb-b");
const bundled = await marketing.bundle(actor, publication.id);
expect(bundled.status).toBe("bundled");
expect(bundled.bundleArtifactId).not.toBeNull();
const dir = path.join(mediaRoot, bundleRelativeDir(actor.spaceId, publication.id));
expect((await readdir(dir)).sort()).toEqual(
[
"AI-DISCLOSURE.txt",
"UPLOAD-CHECKLIST.md",
"bundle.zip",
"caption.txt",
"metadata.json",
"poster.png",
"subtitles.srt",
"thumb-a.png",
"thumb-b.png",
"thumb-c.png",
"video.mp4",
].sort(),
);
const checklist = await readFile(path.join(dir, "UPLOAD-CHECKLIST.md"), "utf8");
expect(checklist).toContain("AI-generated or altered content label");
expect(checklist).toContain("profile bio");
const caption = await readFile(path.join(dir, "caption.txt"), "utf8");
expect(caption).not.toContain("https://");
expect(caption.match(/#/g)).toHaveLength(3);
const metadata = JSON.parse(await readFile(path.join(dir, "metadata.json"), "utf8"));
expect(metadata).toMatchObject({ aiAltered: true, aiAlterations: ["inpaint"] });
expect(
(await readFile(path.join(dir, "poster.png"))).equals(
await readFile(path.join(dir, "thumb-b.png")),
),
).toBe(true);
});
it("schedules the publish job, bundles at the time, and reminds instead of uploading paid videos", async () => {
const actor = await newActor();
const marketing = createMediaMarketingService(deps);
const { render } = await finishedRender(actor, { profile: PAID });
const at = new Date(Date.now() + 3_600_000).toISOString();
const publication = await marketing.createPublication(actor, {
renderId: render.id,
destination: "bundle",
targetUrl: null,
utm: null,
title: "Full release",
caption: null,
tags: [],
thumbnailVariant: null,
scheduledAt: at,
});
expect(publication.status).toBe("scheduled");
const job = jobs.find((entry) => entry.replaceKey === `media.publish:${publication.id}`);
expect(job?.availableAt?.toISOString()).toBe(at);
const publish = vi.spyOn(deps.publisher, "publish");
const remind = vi.fn(async () => {});
const handlers = createMediaMarketingJobHandlers(deps, marketing, { remind });
await handlers["media.publish"]({ publicationId: publication.id });
expect(publish).not.toHaveBeenCalled();
expect(remind).toHaveBeenCalledTimes(1);
const after = await repos.getPublication(actor.spaceId, publication.id);
expect(after.status).toBe("bundled");
const dir = path.join(mediaRoot, bundleRelativeDir(actor.spaceId, publication.id));
const files = await readdir(dir);
expect(files).toContain("preview.mp4");
const checklist = await readFile(path.join(dir, "UPLOAD-CHECKLIST.md"), "utf8");
expect(checklist).toContain("age-restricted");
const canceled = await marketing.cancelPublication(actor, publication.id);
expect(canceled.status).toBe("canceled");
expect(canceledJobs).toContain(`media.publish:${publication.id}`);
await handlers["media.publish"]({ publicationId: publication.id });
expect(remind).toHaveBeenCalledTimes(1);
publish.mockRestore();
});
});
describe("results", () => {
it("imports CSV through a saved mapping without double counting and learns from 20 publications", async () => {
const actor = await newActor();
const { project, segments, assetA } = await seedLibrary(actor);
const marketing = createMediaMarketingService(deps);
const strong = segments.find((s) => s.assetId === assetA && s.startMs === 0)!;
const weak = segments.find((s) => s.assetId === assetA && s.startMs === 16_000)!;
const renders: string[] = [];
for (const segment of [strong, weak]) {
const plan = await repos.createPlanVersion(actor.spaceId, {
projectId: project.id,
profileId: SHORT.id,
edl: {
edlVersion: 1,
output: {
profileId: SHORT.id,
width: 1080,
height: 1920,
fps: 30,
durationTargetMs: 3000,
durationHardMinMs: 1000,
durationHardMaxMs: 60_000,
},
tracks: {
video: [
{
id: "c1",
assetId: assetA,
inMs: segment.startMs,
outMs: segment.startMs + 3000,
atMs: 0,
reframe: "none",
segmentId: segment.id,
},
],
},
metadata: {},
},
createdBy: "agent",
rationale: null,
});
const render = await repos.createRender(actor.spaceId, {
planId: plan.id,
quality: "final",
createdBy: actor.userId,
});
await repos.claimRender(actor.spaceId, render.id);
await repos.updateRender(actor.spaceId, render.id, { status: "done", progress: 1 });
renders.push(render.id);
}
const publicationIds: string[] = [];
for (let i = 0; i < 20; i++) {
const publication = await repos.createPublication(actor.spaceId, actor.userId, {
renderId: renders[i % 2]!,
destination: "bundle",
targetUrl: null,
trackingUrl: null,
utm: null,
title: null,
caption: null,
tags: [],
thumbnailVariant: null,
scheduledAt: null,
});
publicationIds.push(publication.id);
}
const mapping = await repos.saveMetricMapping(actor.spaceId, {
name: "Shorts export",
delimiter: ";",
columns: {
publication: "Post",
observedAt: "Date",
views: "Views",
clicks: "Clicks",
conversions: "Sales",
revenue: "Revenue",
},
publicationKey: "id",
revenueUnit: "major",
defaultCurrency: "EUR",
});
const csv = (ids: string[]) =>
[
"Post;Date;Views;Clicks;Sales;Revenue",
...ids.map((id, i) => {
const strongPost = i % 2 === 0;
return `${id};2026-09-0${(i % 9) + 1};1000;${strongPost ? 120 : 40};${strongPost ? 30 : 2};${strongPost ? "299,70" : "19,98"}`;
}),
"unknown-post;2026-09-01;10;1;0;0",
].join("\n");
const first19 = await marketing.importMetricsCsv(actor, {
mappingId: mapping.id as string,
csv: csv(publicationIds.slice(0, 19)),
});
expect(first19.imported).toBe(19);
expect(first19.skipped).toEqual([{ row: 21, reason: "publication not found" }]);
expect(await repos.getTeaserModel(actor.spaceId)).toBeNull();
const again = await marketing.importMetricsCsv(actor, {
mappingId: mapping.id as string,
csv: csv(publicationIds.slice(0, 19)),
});
expect(again.imported).toBe(19);
const funnel = await repos.funnel(actor.spaceId, project.id);
expect(funnel.reduce((sum, row) => sum + row.views, 0)).toBe(19_000);
expect(await refitTeaserModel(deps, actor.spaceId)).toBeNull();
await recordMetric(deps, actor, {
publicationId: publicationIds[19]!,
observedAt: "2026-09-10T00:00:00.000Z",
views: 1000,
clicks: 40,
conversions: 2,
revenueCents: 1998,
currency: "EUR",
});
const model = await repos.getTeaserModel(actor.spaceId);
expect(model?.sampleCount).toBe(20);
const totals = funnel.find((row) => row.publicationId === publicationIds[0]);
expect(totals).toMatchObject({
views: 1000,
clicks: 120,
conversions: 30,
revenueCents: 29_970,
});
await resetTeaserModel(deps, actor);
expect(await repos.getTeaserModel(actor.spaceId)).toBeNull();
});
});
});

View File

@ -0,0 +1,350 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Edl, MediaSegment, PlatformProfile } from "@rakazo/contracts";
import { EdlSchema, PlatformProfileSchema } from "@rakazo/contracts";
import type { MediaRepos } from "@rakazo/db";
import type { ProfileSet, ScoredSegmentInput } from "@rakazo/media";
import { escapeFilterText } from "@rakazo/media";
import { describe, expect, it } from "vitest";
import { isCubeLut } from "./media-brand.js";
import type { MediaModelClient, MediaServiceDeps } from "./media-context.js";
import { generateCopy, truncateText } from "./media-marketing-copy.js";
import { applyRanking, defaultCtaText, showsProtectedDetail } from "./media-marketing-plan.js";
import {
measureGrayFrame,
thumbnailCandidateTimes,
titleOverlayFilter,
} from "./media-marketing-thumbnails.js";
import { planFeatures } from "./media-metrics.js";
import { assertPublishable } from "./media-publish.js";
import { BundleMediaPublisher } from "./media-publisher-bundle.js";
function profile(text: Record<string, unknown>, overrides: Record<string, unknown> = {}) {
return PlatformProfileSchema.parse({
id: "example-short-vertical",
name: "Example short vertical",
kind: "free_social",
reviewedAt: "2026-09-01",
video: {
aspectRatios: ["9:16"],
width: 1080,
height: 1920,
fps: 30,
codec: "h264",
maxBitrateKbps: 8000,
maxFileSizeMb: 500,
},
duration: { hardMinMs: 1000, hardMaxMs: 60_000 },
safeZones: { topPct: 10, bottomPct: 20, leftPct: 5, rightPct: 12 },
captions: { recommended: true },
audio: {},
text: { captionMaxChars: 150, hashtagLimit: 3, linkPolicy: "bio_only", ...text },
content: { thresholds: {} },
disclosure: { aiAlteredLabelRequired: true },
consent: { requirePerformerConsent: false, requireAgeVerification: false },
export: {},
...overrides,
});
}
function edl(overrides: Partial<Edl> = {}): Edl {
return EdlSchema.parse({
edlVersion: 1,
output: {
profileId: "example-short-vertical",
width: 1080,
height: 1920,
fps: 30,
durationTargetMs: 9000,
durationHardMinMs: 1000,
durationHardMaxMs: 60_000,
},
tracks: {
video: [
{
id: "c1",
assetId: "a1",
inMs: 0,
outMs: 3000,
atMs: 0,
reframe: "none",
segmentId: "s1",
},
{ id: "c2", assetId: "a1", inMs: 6000, outMs: 12_000, atMs: 3000, reframe: "none" },
],
},
metadata: { title: "Lake day", tags: ["lake"] },
...overrides,
});
}
function segment(id: string, startMs: number, endMs: number, extra: Partial<MediaSegment> = {}) {
return {
id,
assetId: "a1",
kind: "shot",
startMs,
endMs,
scores: {},
teaserScore: null,
transcriptExcerpt: null,
labels: [],
paidOnly: false,
...extra,
} as MediaSegment;
}
describe("teaser plans", () => {
it("ends teasers on a call to action that follows the link policy", () => {
expect(defaultCtaText("teaser", profile({ linkPolicy: "bio_only" }))).toBe("Link in bio");
expect(defaultCtaText("teaser", profile({ linkPolicy: "allowed" }))).toBe("Link below");
expect(defaultCtaText("teaser", profile({ linkPolicy: "forbidden" }))).not.toMatch(/link/i);
expect(defaultCtaText("full_release", profile({}))).toBeNull();
});
it("re-weights by the model order and leaves paid-only segments alone", () => {
const input = (id: string, paidOnly = false): ScoredSegmentInput => ({
id,
assetId: "a1",
startMs: 0,
endMs: 1000,
capturedAt: null,
features: { motion: 1, sharpness: 0.5 },
paidOnly,
transcript: null,
});
const ranked = applyRanking([input("a"), input("b"), input("c"), input("p", true)], ["b", "a"]);
const byId = new Map(ranked.map((s) => [s.id, s.features]));
expect(byId.get("b")).toEqual({ motion: 1, sharpness: 0.5 });
expect(byId.get("a")).toEqual({ motion: 0.75, sharpness: 0.375 });
expect(byId.get("c")).toEqual({ motion: 0.5, sharpness: 0.25 });
expect(byId.get("p")).toEqual({ motion: 1, sharpness: 0.5 });
});
it("keeps protected faces and treated marks away from the model", () => {
const track = (personId: string | null) => ({
assetId: "a1",
startMs: 1000,
endMs: 2000,
personId,
});
const ctx = (tracks: unknown[], marks: unknown[] = []) =>
({ faceTracks: tracks, marks }) as Parameters<typeof showsProtectedDetail>[0];
const span = { assetId: "a1", startMs: 0, endMs: 1500 };
expect(showsProtectedDetail(ctx([track("p1")]), new Set(), span)).toBe(false);
expect(showsProtectedDetail(ctx([track("p1")]), new Set(["p1"]), span)).toBe(true);
expect(showsProtectedDetail(ctx([track(null)]), new Set(["p2"]), span)).toBe(true);
expect(
showsProtectedDetail(ctx([track("p1")]), new Set(), { ...span, startMs: 2000, endMs: 3000 }),
).toBe(false);
const tattoo = { treatment: "blur", assetId: null, personId: "p1" };
expect(showsProtectedDetail(ctx([track("p1")], [tattoo]), new Set(), span)).toBe(true);
expect(
showsProtectedDetail(ctx([track("p1")], [{ ...tattoo, treatment: "none" }]), new Set(), span),
).toBe(false);
const plate = { treatment: "cover", assetId: "a1", personId: null };
expect(showsProtectedDetail(ctx([], [plate]), new Set(), span)).toBe(true);
});
it("learns from the clip-length-weighted features of the segments a plan used", () => {
const features = planFeatures(edl(), [
segment("s1", 0, 3000, { scores: { motion: 1 } }),
segment("s2", 6000, 12_000, { scores: { motion: 0.25 }, transcriptExcerpt: "Wow!" }),
segment("s3", 15_000, 18_000, { scores: { motion: 0 } }),
]);
expect(features?.motion).toBeCloseTo((1 * 3000 + 0.25 * 6000) / 9000);
expect(features?.speechHook).toBeGreaterThan(0);
expect(planFeatures(edl(), [])).toBeNull();
});
});
describe("thumbnails", () => {
it("measures sharpness, contrast and thirds of a grey frame", () => {
const width = 30;
const height = 30;
const flat = new Uint8Array(width * height).fill(128);
const checker = Uint8Array.from({ length: width * height }, (_, i) =>
(i % width) % 2 === Math.floor(i / width) % 2 ? 255 : 0,
);
expect(measureGrayFrame(flat, width, height)).toEqual({
laplacianVariance: 0,
contrast: 0,
thirds: 0,
});
const busy = measureGrayFrame(checker, width, height);
expect(busy.laplacianVariance).toBeGreaterThan(1000);
expect(busy.contrast).toBeGreaterThan(100);
});
it("samples the main program after the intro and just after each cut", () => {
const withIntro = edl({ intro: { templateId: "intro_glitch", params: {}, durationMs: 2000 } });
const times = thumbnailCandidateTimes(withIntro, 11_000);
expect(times.length).toBeGreaterThanOrEqual(24);
expect(Math.min(...times)).toBeGreaterThan(2000);
expect(Math.max(...times)).toBeLessThan(11_000);
expect(times).toContain(2000 + 3000 + 200);
});
it("escapes the title and falls back to neutral colours", () => {
const hostile = "Hi': drawtext=text='x' ;[out] %{pts}";
const filter = titleOverlayFilter({
text: hostile,
width: 1080,
height: 1920,
profile: profile({}),
kit: null,
fontFile: "/fonts/My Font:Bold.ttf",
});
expect(filter).toContain(`text=${escapeFilterText(hostile)}`);
expect(filter).not.toContain(hostile);
expect(filter).toContain("fontcolor=white");
expect(filter).toContain(`y=${Math.round(1920 * 0.14)}`);
});
});
describe("copy", () => {
const render = {
id: "r1",
planId: "p1",
projectId: "j1",
profileId: "example-short-vertical",
};
function copyDeps(reply: unknown, text: Record<string, unknown> = {}) {
const short = profile({
titleMaxChars: 20,
bannedPatterns: [{ pattern: "guaranteed", reason: "false promise" }],
...text,
});
const profiles: ProfileSet = {
profiles: new Map([[short.id, { profile: short, origin: "builtin" as const }]]),
errors: [],
};
const calls: Array<Parameters<MediaModelClient["generate"]>[0]> = [];
const deps = {
repos: {
getRender: async () => render,
getPlan: async () => ({ edl: edl() }),
getProjectRecord: async () => ({ title: "Lake Day Launch" }),
listSegments: async () => [
segment("s1", 0, 3000, { transcriptExcerpt: "Welcome back. Today we film at the lake." }),
],
} as unknown as MediaRepos,
profiles: async () => profiles,
model: {
async generate(input) {
calls.push(input);
return reply === null ? null : input.schema.parse(reply);
},
} as MediaModelClient,
} as unknown as MediaServiceDeps;
return { deps, calls };
}
const actor = { spaceId: "s", userId: "u" };
it("falls back to the plan metadata without a model", async () => {
const { deps, calls } = copyDeps(null);
const copy = await generateCopy(deps, actor, { renderId: "r1", language: "de" });
expect(calls[0]?.instructions).toContain('language "de"');
expect(calls[0]?.prompt).toContain("Today we film at the lake");
expect(copy).toMatchObject({
title: "Lake day",
hook: "Welcome back.",
caption: "Lake day",
hashtags: ["#lake"],
issues: [],
});
});
it("cuts to the profile limits and reports banned patterns and links in every field", async () => {
const { deps } = copyDeps({
title: "The calmest lake morning you will ever see",
hook: "Guaranteed calm",
caption: `See https://example.com/video now ${"x".repeat(200)}`,
hashtags: ["lake", "calm", "morning", "travel", "nature"],
description: "More at https://example.com",
altText: "A lake at sunrise",
});
const copy = await generateCopy(deps, actor, { renderId: "r1", language: "en" });
expect([...copy.title].length).toBeLessThanOrEqual(20);
expect([...copy.caption].length).toBeLessThanOrEqual(150);
expect(copy.hashtags).toEqual(["#lake", "#calm", "#morning"]);
const codes = copy.issues.map((issue) => `${issue.code}:${issue.message}`);
expect(codes.some((code) => code.startsWith("link_bio_only:caption"))).toBe(true);
expect(codes.some((code) => code.startsWith("link_bio_only:hook or description"))).toBe(true);
expect(codes.some((code) => code.startsWith("banned_pattern:hook or description"))).toBe(true);
expect(copy.chapters).toBeUndefined();
});
it("rejects malformed language codes", async () => {
const { deps } = copyDeps(null);
await expect(
generateCopy(deps, actor, { renderId: "r1", language: "en; drop" }),
).rejects.toThrow(/language/);
});
it("cuts long text at a word boundary", () => {
expect(truncateText("one two three four", 15)).toBe("one two three");
expect(truncateText("one two three four", 12)).toBe("one two thre");
expect(truncateText("short", 12)).toBe("short");
});
});
describe("publishing", () => {
it("publishes only final renders whose gate passed", () => {
const gate = { passed: true } as never;
expect(() =>
assertPublishable({ status: "done", quality: "final", gateReport: gate }),
).not.toThrow();
expect(() => assertPublishable({ status: "done", quality: "draft", gateReport: gate })).toThrow(
/final/,
);
expect(() =>
assertPublishable({ status: "blocked", quality: "final", gateReport: gate }),
).toThrow(/finished/);
expect(() => assertPublishable({ status: "done", quality: "final", gateReport: null })).toThrow(
/gate/,
);
expect(() =>
assertPublishable({
status: "done",
quality: "final",
gateReport: { passed: false } as never,
}),
).toThrow(/gate/);
});
it("reports a bundle only when the files are really there", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "media-bundle-"));
try {
const publisher = new BundleMediaPublisher();
const request = {
bundleDir: dir,
profile: profile({}) as PlatformProfile,
publicationId: "pub1",
title: null,
caption: null,
tags: [],
};
await expect(publisher.publish(request)).rejects.toThrow(/missing video.mp4/);
await writeFile(path.join(dir, "video.mp4"), "v");
await expect(publisher.publish(request)).rejects.toThrow(/missing bundle.zip/);
await writeFile(path.join(dir, "bundle.zip"), "z");
expect(await publisher.publish(request)).toEqual({ status: "bundled", externalUrl: null });
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
describe("brand kits", () => {
it("accepts only complete 3D .cube LUTs", () => {
const rows = (n: number) => Array.from({ length: n }, () => "0.1 0.2 0.3").join("\n");
expect(isCubeLut(`TITLE "look"\nLUT_3D_SIZE 2\n${rows(8)}\n`)).toBe(true);
expect(isCubeLut(`LUT_3D_SIZE 2\n${rows(7)}\n`)).toBe(false);
expect(isCubeLut(`LUT_1D_SIZE 2\n${rows(2)}\n`)).toBe(false);
expect(isCubeLut("LUT_3D_SIZE 2\n0 0 zero\n")).toBe(false);
expect(isCubeLut("not a lut")).toBe(false);
});
});

View File

@ -0,0 +1,108 @@
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { crc32 } from "node:zlib";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isSafeZipEntryName, writeStoreZip } from "./media-zip.js";
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), "media-zip-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
/** Read a store-only archive through its central directory, checking every CRC. */
function readStoreZip(bytes: Buffer): Array<{ name: string; data: Buffer; mode: number }> {
const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
expect(end).toBeGreaterThan(0);
const count = bytes.readUInt16LE(end + 10);
let at = bytes.readUInt32LE(end + 16);
const entries: Array<{ name: string; data: Buffer; mode: number }> = [];
for (let index = 0; index < count; index++) {
expect(bytes.readUInt32LE(at)).toBe(0x02014b50);
const crc = bytes.readUInt32LE(at + 16);
const size = bytes.readUInt32LE(at + 20);
const nameLength = bytes.readUInt16LE(at + 28);
const mode = bytes.readUInt32LE(at + 38) >>> 16;
const offset = bytes.readUInt32LE(at + 42);
const name = bytes.subarray(at + 46, at + 46 + nameLength).toString("ascii");
expect(bytes.readUInt32LE(offset)).toBe(0x04034b50);
expect(bytes.readUInt16LE(offset + 8)).toBe(0);
const localName = bytes.readUInt16LE(offset + 26);
const start = offset + 30 + localName + bytes.readUInt16LE(offset + 28);
const data = bytes.subarray(start, start + size);
expect(crc32(data) >>> 0).toBe(crc);
entries.push({ name, data, mode });
at += 46 + nameLength;
}
return entries;
}
describe("writeStoreZip", () => {
it("stores flat entries byte for byte and deterministically", async () => {
const video = path.join(dir, "v");
const notes = path.join(dir, "n");
await writeFile(video, Buffer.alloc(200_000, 7));
await writeFile(notes, "# Upload checklist\n");
const entries = [
{ name: "video.mp4", path: video },
{ name: "UPLOAD-CHECKLIST.md", path: notes },
];
const first = path.join(dir, "a.zip");
const size = await writeStoreZip(first, entries);
expect((await stat(first)).size).toBe(size);
const read = readStoreZip(await readFile(first));
expect(read.map((entry) => entry.name)).toEqual(["video.mp4", "UPLOAD-CHECKLIST.md"]);
expect(read[0]?.data.equals(Buffer.alloc(200_000, 7))).toBe(true);
expect(read[1]?.data.toString()).toBe("# Upload checklist\n");
expect(read[0]?.mode).toBe(0o100644);
const second = path.join(dir, "b.zip");
await writeStoreZip(second, entries);
expect((await readFile(second)).equals(await readFile(first))).toBe(true);
});
it("refuses names that could escape the extraction folder", async () => {
const file = path.join(dir, "x");
await writeFile(file, "x");
for (const name of [
"../evil.sh",
"..",
"/etc/passwd",
"a/b.txt",
"a\\b.txt",
".hidden",
"C:evil",
"name\0.txt",
"",
`${"a".repeat(101)}`,
]) {
expect(isSafeZipEntryName(name), name).toBe(false);
await expect(writeStoreZip(path.join(dir, "out.zip"), [{ name, path: file }])).rejects.toThrow(
/unsafe zip entry/,
);
}
expect(isSafeZipEntryName("thumb-a.png")).toBe(true);
expect(isSafeZipEntryName("UPLOAD-CHECKLIST.md")).toBe(true);
});
it("rejects duplicate names and leaves no partial archive behind", async () => {
const file = path.join(dir, "x");
await writeFile(file, "x");
const out = path.join(dir, "dup.zip");
await expect(
writeStoreZip(out, [
{ name: "caption.txt", path: file },
{ name: "caption.txt", path: file },
]),
).rejects.toThrow(/duplicate/);
await expect(
writeStoreZip(out, [{ name: "missing.txt", path: path.join(dir, "missing") }]),
).rejects.toThrow();
await expect(stat(out)).rejects.toThrow();
});
});