test(media): Postgres and unit tests for the media repositories
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
This commit is contained in:
parent
c1c39b6e46
commit
5e155a0850
265
packages/db/src/media-fixtures.ts
Normal file
265
packages/db/src/media-fixtures.ts
Normal file
@ -0,0 +1,265 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { MediaSourceItem } from "@rakazo/adapter-kit";
|
||||
import type { BrandKit, TeaserWeights } from "@rakazo/contracts";
|
||||
import { TEASER_FEATURES } from "@rakazo/contracts";
|
||||
import { bootstrapUserSpace } from "./bootstrap-user.js";
|
||||
import type { PrismaClient } from "./client.js";
|
||||
import { createDb } from "./client.js";
|
||||
import type { MediaRepos } from "./media.js";
|
||||
|
||||
/*
|
||||
* Synthetic fixtures for the Media Studio Postgres tests. Nothing here describes a
|
||||
* real person, place, or file.
|
||||
*/
|
||||
|
||||
const signupEnv = { signupsEnabled: "true", signupAllowlist: "" };
|
||||
|
||||
export interface MediaTestDb {
|
||||
prisma: PrismaClient;
|
||||
newUser(): Promise<{ userId: string; spaceId: string }>;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
export function openMediaTestDb(databaseUrl: string): MediaTestDb {
|
||||
const db = createDb(databaseUrl, { poolMax: 8 });
|
||||
const userIds: string[] = [];
|
||||
return {
|
||||
prisma: db.prisma,
|
||||
async newUser() {
|
||||
// bootstrapUserSpace derives the organization slug from the first 12 characters.
|
||||
const id = `md${randomUUID().replaceAll("-", "")}`;
|
||||
userIds.push(id);
|
||||
await db.prisma.user.create({ data: { id, name: "Test", email: `${id}@rakazo.test` } });
|
||||
const { spaceId } = await bootstrapUserSpace(db.prisma, { id }, signupEnv, {
|
||||
claimDeploymentOwner: false,
|
||||
});
|
||||
return { userId: id, spaceId };
|
||||
},
|
||||
async cleanup() {
|
||||
const memberships = await db.prisma.member.findMany({
|
||||
where: { userId: { in: userIds } },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
await db.prisma.organization.deleteMany({
|
||||
where: { id: { in: memberships.map((member) => member.organizationId) } },
|
||||
});
|
||||
await db.prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||
await db.prisma.$disconnect();
|
||||
await db.pool.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const BASE_TIME = Date.UTC(2026, 0, 1, 8, 0, 0);
|
||||
|
||||
export function sourceItem(
|
||||
index: number,
|
||||
overrides: Partial<MediaSourceItem> = {},
|
||||
): MediaSourceItem {
|
||||
return {
|
||||
externalId: `clip-${String(index).padStart(5, "0")}.mp4`,
|
||||
kind: index % 3 === 0 ? "photo" : "video",
|
||||
capturedAt: new Date(BASE_TIME + index * 60_000),
|
||||
durationMs: index % 3 === 0 ? null : 10_000,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 30,
|
||||
gpsLat: null,
|
||||
gpsLon: null,
|
||||
deviceModel: "Test Camera",
|
||||
albumNames: [],
|
||||
isFavorite: false,
|
||||
fileName: `clip-${index}.mp4`,
|
||||
sizeBytes: 1000 + index,
|
||||
revision: "r1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function testEdl(assetId: string, profileId = "example-short-vertical") {
|
||||
return {
|
||||
edlVersion: 1,
|
||||
output: {
|
||||
profileId,
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
fps: 30,
|
||||
durationTargetMs: 5000,
|
||||
durationHardMinMs: 1000,
|
||||
durationHardMaxMs: 60_000,
|
||||
},
|
||||
tracks: {
|
||||
video: [{ id: "c1", assetId, inMs: 0, outMs: 5000, atMs: 0, reframe: "auto" }],
|
||||
},
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
export const testBrandKit: BrandKit = {
|
||||
name: "Test Kit",
|
||||
colors: {
|
||||
primary: "#101010",
|
||||
secondary: "#202020",
|
||||
accent: "#303030",
|
||||
text: "#f0f0f0",
|
||||
background: "#000000",
|
||||
},
|
||||
fonts: [],
|
||||
logoArtifactIds: [],
|
||||
captionPresets: [],
|
||||
musicBeds: [],
|
||||
};
|
||||
|
||||
export const testTeaserWeights = Object.fromEntries(
|
||||
TEASER_FEATURES.map((feature) => [feature, 1]),
|
||||
) as TeaserWeights;
|
||||
|
||||
export function vectorBytes(values: number[]): Uint8Array {
|
||||
return new Uint8Array(new Float32Array(values).buffer);
|
||||
}
|
||||
|
||||
/** One of every media record in a space, linked the way the product links them. */
|
||||
export async function seedMediaSpace(media: MediaRepos, spaceId: string, userId: string) {
|
||||
const source = await media.createSource(spaceId, {
|
||||
kind: "folder",
|
||||
label: "Footage",
|
||||
ownerUserId: userId,
|
||||
config: { path: "/media/footage" },
|
||||
});
|
||||
const synced = await media.upsertSourceItems(spaceId, source.id, [
|
||||
sourceItem(1, { albumNames: ["Holiday"] }),
|
||||
sourceItem(2),
|
||||
]);
|
||||
const [assetId, secondAssetId] = synced.changedAssetIds as [string, string];
|
||||
const analysis = await media.upsertAnalysis(spaceId, assetId, {
|
||||
kind: "embeddings",
|
||||
modelId: "test-embedder",
|
||||
modelVersion: "1",
|
||||
vectors: vectorBytes([1, 0, 0]),
|
||||
});
|
||||
await media.replaceSegments(spaceId, assetId, 1, [
|
||||
{ kind: "shot", startMs: 0, endMs: 4000, labels: ["beach"], transcriptExcerpt: "hello" },
|
||||
]);
|
||||
const segments = await media.listSegments(spaceId, [assetId]);
|
||||
const tracks = await media.replaceFaceTracks(spaceId, assetId, "faces-1", [
|
||||
{
|
||||
startMs: 0,
|
||||
endMs: 3000,
|
||||
confidenceMin: 0.9,
|
||||
keyframesPath: `faces/${assetId}/t1.json`,
|
||||
embedding: vectorBytes([0.1, 0.2, 0.3]),
|
||||
},
|
||||
]);
|
||||
const [person] = await media.createPersonSuggestions(spaceId, [
|
||||
{ centroid: vectorBytes([0.1, 0.2, 0.3]), trackIds: tracks.map((track) => track.id) },
|
||||
]);
|
||||
const mark = await media.createMark(spaceId, {
|
||||
kind: "tattoo",
|
||||
label: "arm",
|
||||
personId: person?.id ?? null,
|
||||
assetId,
|
||||
prompt: { tMs: 1000, point: { x: 0.5, y: 0.5 } },
|
||||
treatment: "blur",
|
||||
});
|
||||
await media.replaceClusters(spaceId, "session", [
|
||||
{
|
||||
key: "s1",
|
||||
title: "Morning",
|
||||
labelSource: "rule",
|
||||
startAt: new Date(BASE_TIME),
|
||||
endAt: new Date(BASE_TIME + 120_000),
|
||||
centroid: null,
|
||||
coverAssetId: assetId,
|
||||
assetIds: [assetId, secondAssetId],
|
||||
},
|
||||
]);
|
||||
const collection = (await media.listCollections(spaceId, {})).items[0];
|
||||
const brandKit = await media.upsertBrandKit(spaceId, { kit: testBrandKit });
|
||||
const project = await media.createProject(spaceId, userId, {
|
||||
title: "Teaser",
|
||||
goal: "teaser",
|
||||
profileIds: ["example-short-vertical"],
|
||||
collectionIds: collection ? [collection.id] : [],
|
||||
brandKitId: brandKit.id,
|
||||
});
|
||||
const plan = await media.createPlanVersion(spaceId, {
|
||||
projectId: project.id,
|
||||
profileId: "example-short-vertical",
|
||||
edl: testEdl(assetId),
|
||||
createdBy: "agent",
|
||||
rationale: null,
|
||||
});
|
||||
const render = await media.createRender(spaceId, {
|
||||
planId: plan.id,
|
||||
quality: "draft",
|
||||
createdBy: userId,
|
||||
});
|
||||
await media.claimRender(spaceId, render.id);
|
||||
await media.updateRender(spaceId, render.id, { status: "done", progress: 1 });
|
||||
const queuedRender = await media.createRender(spaceId, {
|
||||
planId: plan.id,
|
||||
quality: "final",
|
||||
createdBy: userId,
|
||||
});
|
||||
const publication = await media.createPublication(spaceId, userId, {
|
||||
renderId: render.id,
|
||||
destination: "bundle",
|
||||
targetUrl: "https://example.com/offer",
|
||||
trackingUrl: "https://example.com/offer?utm_content=a",
|
||||
utm: { source: "shorts", medium: "video", campaign: "launch", content: "a" },
|
||||
title: "Title",
|
||||
caption: "Caption",
|
||||
tags: ["test"],
|
||||
thumbnailVariant: "t1",
|
||||
scheduledAt: null,
|
||||
});
|
||||
const metric = await media.recordMetric(spaceId, {
|
||||
publicationId: publication.id,
|
||||
observedAt: new Date(BASE_TIME).toISOString(),
|
||||
views: 100,
|
||||
clicks: 10,
|
||||
conversions: 1,
|
||||
revenueCents: 999,
|
||||
currency: "EUR",
|
||||
source: "manual",
|
||||
});
|
||||
const mapping = await media.saveMetricMapping(spaceId, {
|
||||
name: "Default",
|
||||
delimiter: ",",
|
||||
columns: { publication: "id", observedAt: "date", views: "views" },
|
||||
publicationKey: "id",
|
||||
revenueUnit: "major",
|
||||
defaultCurrency: "EUR",
|
||||
});
|
||||
await media.putTeaserModel(spaceId, testTeaserWeights, 3);
|
||||
const upload = await media.beginUpload(spaceId, {
|
||||
assetId: secondAssetId,
|
||||
userId,
|
||||
sizeBytes: 10,
|
||||
sha256: "a".repeat(64),
|
||||
fileName: "clip.mp4",
|
||||
chunkBytes: 4,
|
||||
});
|
||||
return {
|
||||
sourceId: source.id,
|
||||
assetId,
|
||||
secondAssetId,
|
||||
analysisId: analysis.id,
|
||||
segmentId: segments[0]?.id as string,
|
||||
trackId: tracks[0]?.id as string,
|
||||
personId: person?.id as string,
|
||||
markId: mark.id,
|
||||
collectionId: collection?.id as string,
|
||||
brandKitId: brandKit.id,
|
||||
projectId: project.id,
|
||||
planId: plan.id,
|
||||
renderId: render.id,
|
||||
queuedRenderId: queuedRender.id,
|
||||
publicationId: publication.id,
|
||||
metricId: metric.id,
|
||||
mappingId: mapping.id as string,
|
||||
uploadId: upload.id,
|
||||
};
|
||||
}
|
||||
|
||||
export type SeededMediaSpace = Awaited<ReturnType<typeof seedMediaSpace>>;
|
||||
428
packages/db/src/media-isolation.postgres.test.ts
Normal file
428
packages/db/src/media-isolation.postgres.test.ts
Normal file
@ -0,0 +1,428 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { MediaRepos } from "./media.js";
|
||||
import { createMediaRepos } from "./media.js";
|
||||
import type { MediaTestDb, SeededMediaSpace } from "./media-fixtures.js";
|
||||
import {
|
||||
openMediaTestDb,
|
||||
seedMediaSpace,
|
||||
sourceItem,
|
||||
testBrandKit,
|
||||
testEdl,
|
||||
vectorBytes,
|
||||
} from "./media-fixtures.js";
|
||||
import { IsolationError } from "./scope.js";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const describePostgres =
|
||||
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
||||
|
||||
describePostgres("media repositories: space isolation (PostgreSQL)", () => {
|
||||
let db: MediaTestDb;
|
||||
let media: MediaRepos;
|
||||
let a: SeededMediaSpace;
|
||||
let owner: { userId: string; spaceId: string };
|
||||
let intruder: { userId: string; spaceId: string };
|
||||
|
||||
beforeAll(async () => {
|
||||
db = openMediaTestDb(databaseUrl!);
|
||||
media = createMediaRepos(db.prisma);
|
||||
owner = await db.newUser();
|
||||
intruder = await db.newUser();
|
||||
a = await seedMediaSpace(media, owner.spaceId, owner.userId);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.cleanup();
|
||||
});
|
||||
|
||||
async function snapshot() {
|
||||
const where = { spaceId: owner.spaceId };
|
||||
const p = db.prisma;
|
||||
return {
|
||||
sources: await p.mediaSource.findMany({ where, orderBy: { id: "asc" } }),
|
||||
assets: await p.mediaAsset.findMany({ where, orderBy: { id: "asc" } }),
|
||||
segments: await p.mediaSegment.findMany({ where, orderBy: { id: "asc" } }),
|
||||
analyses: await p.mediaAnalysis.findMany({ where, orderBy: { id: "asc" } }),
|
||||
collections: await p.mediaCollection.findMany({ where, orderBy: { id: "asc" } }),
|
||||
items: await p.mediaCollectionItem.findMany({
|
||||
where: { collection: where },
|
||||
orderBy: [{ collectionId: "asc" }, { assetId: "asc" }],
|
||||
}),
|
||||
people: await p.mediaPerson.findMany({ where, orderBy: { id: "asc" } }),
|
||||
tracks: await p.mediaFaceTrack.findMany({ where, orderBy: { id: "asc" } }),
|
||||
marks: await p.mediaMark.findMany({ where, orderBy: { id: "asc" } }),
|
||||
kits: await p.mediaBrandKit.findMany({ where, orderBy: { id: "asc" } }),
|
||||
projects: await p.mediaProject.findMany({ where, orderBy: { id: "asc" } }),
|
||||
plans: await p.mediaEditPlan.findMany({ where, orderBy: { id: "asc" } }),
|
||||
renders: await p.mediaRender.findMany({ where, orderBy: { id: "asc" } }),
|
||||
publications: await p.mediaPublication.findMany({ where, orderBy: { id: "asc" } }),
|
||||
metrics: await p.mediaMetric.findMany({ where, orderBy: { id: "asc" } }),
|
||||
mappings: await p.mediaMetricMapping.findMany({ where, orderBy: { id: "asc" } }),
|
||||
teaser: await p.mediaTeaserModel.findMany({ where }),
|
||||
uploads: await p.mediaUpload.findMany({ where, orderBy: { id: "asc" } }),
|
||||
};
|
||||
}
|
||||
|
||||
it("rejects every read and write of another space's ids and leaves its rows untouched", async () => {
|
||||
const before = await snapshot();
|
||||
const b = intruder.spaceId;
|
||||
const denied: Array<[string, () => Promise<unknown>]> = [
|
||||
["getSource", () => media.getSource(b, a.sourceId)],
|
||||
["updateSourceSync", () => media.updateSourceSync(b, a.sourceId, { status: "error" })],
|
||||
["removeSource", () => media.removeSource(b, a.sourceId)],
|
||||
["upsertSourceItems", () => media.upsertSourceItems(b, a.sourceId, [sourceItem(99)])],
|
||||
["getAsset", () => media.getAsset(b, a.assetId)],
|
||||
["getAssetRecord", () => media.getAssetRecord(b, a.assetId)],
|
||||
[
|
||||
"transitionIngestState",
|
||||
() => media.transitionIngestState(b, a.assetId, { from: ["indexed"], to: "proxying" }),
|
||||
],
|
||||
[
|
||||
"recordProxy",
|
||||
() => media.recordProxy(b, a.assetId, { contentHash: "x", proxyPath: "p", thumbPath: "t" }),
|
||||
],
|
||||
["recordAnalysisVersion", () => media.recordAnalysisVersion(b, a.assetId, 9, 1)],
|
||||
["requestUpload", () => media.requestUpload(b, [a.assetId])],
|
||||
[
|
||||
"upsertAnalysis",
|
||||
() =>
|
||||
media.upsertAnalysis(b, a.assetId, {
|
||||
kind: "embeddings",
|
||||
modelId: "m",
|
||||
modelVersion: "1",
|
||||
vectors: vectorBytes([9]),
|
||||
}),
|
||||
],
|
||||
["latestAnalysis", () => media.latestAnalysis(b, a.assetId, "embeddings")],
|
||||
[
|
||||
"replaceSegments",
|
||||
() => media.replaceSegments(b, a.assetId, 2, [{ kind: "shot", startMs: 0, endMs: 1 }]),
|
||||
],
|
||||
["setSegmentsPaidOnly", () => media.setSegmentsPaidOnly(b, [a.segmentId], true)],
|
||||
[
|
||||
"beginUpload",
|
||||
() =>
|
||||
media.beginUpload(b, {
|
||||
assetId: a.assetId,
|
||||
userId: intruder.userId,
|
||||
sizeBytes: 1,
|
||||
sha256: "b".repeat(64),
|
||||
fileName: "x",
|
||||
}),
|
||||
],
|
||||
["uploadStatus", () => media.uploadStatus(b, a.uploadId)],
|
||||
["getUploadRecord", () => media.getUploadRecord(b, a.uploadId)],
|
||||
["markChunkReceived", () => media.markChunkReceived(b, a.uploadId, 0)],
|
||||
["completeUpload", () => media.completeUpload(b, a.uploadId, { storagePath: "x" })],
|
||||
["failUpload", () => media.failUpload(b, a.uploadId)],
|
||||
[
|
||||
"replaceClusters",
|
||||
() =>
|
||||
media.replaceClusters(b, "session", [
|
||||
{
|
||||
key: "s1",
|
||||
title: "Stolen",
|
||||
labelSource: "rule",
|
||||
startAt: null,
|
||||
endAt: null,
|
||||
centroid: null,
|
||||
coverAssetId: null,
|
||||
assetIds: [a.assetId],
|
||||
},
|
||||
]),
|
||||
],
|
||||
["getCollection", () => media.getCollection(b, a.collectionId)],
|
||||
["collectionAssetIds", () => media.collectionAssetIds(b, [a.collectionId])],
|
||||
["renameCollection", () => media.renameCollection(b, a.collectionId, "Mine")],
|
||||
["mergeCollections", () => media.mergeCollections(b, [a.collectionId, `${a.collectionId}x`])],
|
||||
["splitCollection", () => media.splitCollection(b, a.collectionId, a.secondAssetId)],
|
||||
["createManualCollection", () => media.createManualCollection(b, "Mine", [a.assetId])],
|
||||
["getPerson", () => media.getPerson(b, a.personId)],
|
||||
[
|
||||
"createPersonSuggestions",
|
||||
() =>
|
||||
media.createPersonSuggestions(b, [{ centroid: vectorBytes([1]), trackIds: [a.trackId] }]),
|
||||
],
|
||||
["updatePerson", () => media.updatePerson(b, a.personId, { displayName: "Mine" })],
|
||||
["mergePeople", () => media.mergePeople(b, [a.personId, `${a.personId}x`])],
|
||||
["assignTrack", () => media.assignTrack(b, a.trackId, null)],
|
||||
[
|
||||
"recordConsent (foreign person)",
|
||||
() =>
|
||||
media.recordConsent(
|
||||
{ spaceId: b, recordedByUserId: intruder.userId },
|
||||
{
|
||||
personId: a.personId,
|
||||
consentArtifactId: null,
|
||||
consentRecordedAt: new Date().toISOString(),
|
||||
ageVerificationMethod: "none",
|
||||
ageVerifiedAt: null,
|
||||
},
|
||||
),
|
||||
],
|
||||
[
|
||||
"recordConsent (foreign recorder)",
|
||||
() =>
|
||||
media.recordConsent(
|
||||
{ spaceId: owner.spaceId, recordedByUserId: intruder.userId },
|
||||
{
|
||||
personId: a.personId,
|
||||
consentArtifactId: null,
|
||||
consentRecordedAt: new Date().toISOString(),
|
||||
ageVerificationMethod: "none",
|
||||
ageVerifiedAt: null,
|
||||
},
|
||||
),
|
||||
],
|
||||
["removePersonCompletely", () => media.removePersonCompletely(b, a.personId)],
|
||||
["replaceFaceTracks", () => media.replaceFaceTracks(b, a.assetId, "faces-2", [])],
|
||||
[
|
||||
"createMark",
|
||||
() =>
|
||||
media.createMark(b, {
|
||||
kind: "scar",
|
||||
label: null,
|
||||
personId: null,
|
||||
assetId: a.assetId,
|
||||
prompt: { tMs: 0 },
|
||||
treatment: "blur",
|
||||
}),
|
||||
],
|
||||
["getMark", () => media.getMark(b, a.markId)],
|
||||
["updateMark", () => media.updateMark(b, a.markId, { treatment: "cover" })],
|
||||
[
|
||||
"recordMarkResult",
|
||||
() =>
|
||||
media.recordMarkResult(b, a.markId, {
|
||||
qualityScore: 0,
|
||||
suggestedFallback: "cover",
|
||||
maskDir: "x",
|
||||
}),
|
||||
],
|
||||
["removeMark", () => media.removeMark(b, a.markId)],
|
||||
["upsertBrandKit", () => media.upsertBrandKit(b, { id: a.brandKitId, kit: testBrandKit })],
|
||||
["getBrandKit", () => media.getBrandKit(b, a.brandKitId)],
|
||||
["removeBrandKit", () => media.removeBrandKit(b, a.brandKitId)],
|
||||
[
|
||||
"createProject (foreign brand kit)",
|
||||
() =>
|
||||
media.createProject(b, intruder.userId, {
|
||||
title: "x",
|
||||
goal: "teaser",
|
||||
profileIds: ["example-short-vertical"],
|
||||
brandKitId: a.brandKitId,
|
||||
}),
|
||||
],
|
||||
[
|
||||
"createProject (foreign collection)",
|
||||
() =>
|
||||
media.createProject(b, intruder.userId, {
|
||||
title: "x",
|
||||
goal: "teaser",
|
||||
profileIds: ["example-short-vertical"],
|
||||
collectionIds: [a.collectionId],
|
||||
}),
|
||||
],
|
||||
["updateProject", () => media.updateProject(b, a.projectId, { title: "Mine" })],
|
||||
["getProject", () => media.getProject(b, a.projectId)],
|
||||
["getProjectRecord", () => media.getProjectRecord(b, a.projectId)],
|
||||
["removeProject", () => media.removeProject(b, a.projectId)],
|
||||
[
|
||||
"createPlanVersion",
|
||||
() =>
|
||||
media.createPlanVersion(b, {
|
||||
projectId: a.projectId,
|
||||
profileId: "example-short-vertical",
|
||||
edl: testEdl(a.assetId),
|
||||
createdBy: "agent",
|
||||
rationale: null,
|
||||
}),
|
||||
],
|
||||
["getPlan", () => media.getPlan(b, a.planId)],
|
||||
["latestPlan", () => media.latestPlan(b, a.projectId, "example-short-vertical")],
|
||||
["listPlanVersions", () => media.listPlanVersions(b, a.projectId, "example-short-vertical")],
|
||||
[
|
||||
"createRender",
|
||||
() => media.createRender(b, { planId: a.planId, quality: "draft", createdBy: "x" }),
|
||||
],
|
||||
["getRender", () => media.getRender(b, a.renderId)],
|
||||
["getRenderRecord", () => media.getRenderRecord(b, a.renderId)],
|
||||
["claimRender", () => media.claimRender(b, a.queuedRenderId)],
|
||||
["updateRender", () => media.updateRender(b, a.queuedRenderId, { progress: 0.5 })],
|
||||
["cancelRender", () => media.cancelRender(b, a.queuedRenderId)],
|
||||
[
|
||||
"createPublication",
|
||||
() =>
|
||||
media.createPublication(b, intruder.userId, {
|
||||
renderId: a.renderId,
|
||||
destination: "bundle",
|
||||
targetUrl: null,
|
||||
trackingUrl: null,
|
||||
utm: null,
|
||||
title: null,
|
||||
caption: null,
|
||||
tags: [],
|
||||
thumbnailVariant: null,
|
||||
scheduledAt: null,
|
||||
}),
|
||||
],
|
||||
["getPublication", () => media.getPublication(b, a.publicationId)],
|
||||
["getPublicationRecord", () => media.getPublicationRecord(b, a.publicationId)],
|
||||
["schedulePublication", () => media.schedulePublication(b, a.publicationId, new Date())],
|
||||
["cancelPublication", () => media.cancelPublication(b, a.publicationId)],
|
||||
["markPublicationBundled", () => media.markPublicationBundled(b, a.publicationId, null)],
|
||||
["markPublicationPublished", () => media.markPublicationPublished(b, a.publicationId, null)],
|
||||
["markPublicationFailed", () => media.markPublicationFailed(b, a.publicationId)],
|
||||
[
|
||||
"recordMetric",
|
||||
() =>
|
||||
media.recordMetric(b, {
|
||||
publicationId: a.publicationId,
|
||||
observedAt: new Date().toISOString(),
|
||||
views: 1,
|
||||
clicks: 0,
|
||||
conversions: 0,
|
||||
revenueCents: 0,
|
||||
currency: "EUR",
|
||||
source: "manual",
|
||||
}),
|
||||
],
|
||||
[
|
||||
"recordMetrics",
|
||||
() =>
|
||||
media.recordMetrics(b, [
|
||||
{
|
||||
publicationId: a.publicationId,
|
||||
observedAt: new Date().toISOString(),
|
||||
views: 1,
|
||||
clicks: 0,
|
||||
conversions: 0,
|
||||
revenueCents: 0,
|
||||
currency: "EUR",
|
||||
source: "csv_import",
|
||||
},
|
||||
]),
|
||||
],
|
||||
["listMetrics", () => media.listMetrics(b, a.publicationId)],
|
||||
[
|
||||
"saveMetricMapping (foreign id)",
|
||||
() =>
|
||||
media.saveMetricMapping(b, {
|
||||
id: a.mappingId,
|
||||
name: "Stolen",
|
||||
delimiter: ",",
|
||||
columns: { publication: "id", observedAt: "date" },
|
||||
publicationKey: "id",
|
||||
revenueUnit: "major",
|
||||
defaultCurrency: "EUR",
|
||||
}),
|
||||
],
|
||||
["getMetricMapping", () => media.getMetricMapping(b, a.mappingId)],
|
||||
["funnel (foreign project)", () => media.funnel(b, a.projectId)],
|
||||
];
|
||||
for (const [name, call] of denied) {
|
||||
await expect(call(), name).rejects.toBeInstanceOf(IsolationError);
|
||||
}
|
||||
|
||||
// Reads without ids return nothing of the other space.
|
||||
expect(await media.listSources(b)).toEqual([]);
|
||||
expect((await media.searchAssets(b, {})).items).toEqual([]);
|
||||
expect((await media.searchAssets(b, { text: "clip" })).items).toEqual([]);
|
||||
expect((await media.searchAssets(b, { personId: a.personId })).items).toEqual([]);
|
||||
expect((await media.searchAssets(b, { collectionId: a.collectionId })).items).toEqual([]);
|
||||
expect((await media.searchAssets(b, { sourceId: a.sourceId })).items).toEqual([]);
|
||||
expect(await media.listAssetRecords(b, [a.assetId])).toEqual([]);
|
||||
expect(await media.listAssetsForClustering(b)).toEqual([]);
|
||||
expect(await media.assetsNeedingWork(b, "indexed", 10)).toEqual([]);
|
||||
expect((await media.latestAnalysesByKind(b, "embeddings")).size).toBe(0);
|
||||
expect(await media.listSegments(b, [a.assetId])).toEqual([]);
|
||||
expect((await media.listCollections(b, {})).items).toEqual([]);
|
||||
expect(await media.listPeople(b)).toEqual([]);
|
||||
expect(await media.listPersonRecords(b, [a.personId])).toEqual([]);
|
||||
expect(await media.personCentroids(b)).toEqual([]);
|
||||
expect(await media.listFaceTracks(b, { assetId: a.assetId })).toEqual([]);
|
||||
expect(await media.faceTrackRecords(b, [a.assetId])).toEqual([]);
|
||||
expect(await media.faceTrackEmbeddings(b)).toEqual([]);
|
||||
expect(await media.listMarks(b, { assetId: a.assetId })).toEqual([]);
|
||||
expect(await media.marksForAsset(b, a.assetId)).toEqual([]);
|
||||
expect(await media.listBrandKits(b)).toEqual([]);
|
||||
expect((await media.listProjects(b)).items).toEqual([]);
|
||||
expect(await media.latestPlans(b, a.projectId)).toEqual([]);
|
||||
expect(await media.listRenders(b, a.projectId)).toEqual([]);
|
||||
expect((await media.listPublications(b, { projectId: a.projectId })).items).toEqual([]);
|
||||
expect((await media.resolvePublicationKeys(b, "id", [a.publicationId])).size).toBe(0);
|
||||
expect(await media.listMetricMappings(b)).toEqual([]);
|
||||
expect(await media.funnel(b)).toEqual([]);
|
||||
expect(await media.publicationsWithMetricsCount(b)).toBe(0);
|
||||
expect(await media.getTeaserModel(b)).toBeNull();
|
||||
await media.resetTeaserModel(b);
|
||||
|
||||
expect(await snapshot()).toEqual(before);
|
||||
// The owner still sees everything.
|
||||
expect((await media.searchAssets(owner.spaceId, {})).items).toHaveLength(2);
|
||||
expect(await media.getTeaserModel(owner.spaceId)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("resolves the owning space for worker jobs", async () => {
|
||||
expect(await media.mediaSpaceOf("source", a.sourceId)).toBe(owner.spaceId);
|
||||
expect(await media.mediaSpaceOf("asset", a.assetId)).toBe(owner.spaceId);
|
||||
expect(await media.mediaSpaceOf("render", a.renderId)).toBe(owner.spaceId);
|
||||
expect(await media.mediaSpaceOf("publication", a.publicationId)).toBe(owner.spaceId);
|
||||
expect(await media.mediaSpaceOf("asset", "missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("deleting a space removes every media row of it and nothing else", async () => {
|
||||
const other = await db.newUser();
|
||||
const seeded = await seedMediaSpace(media, other.spaceId, other.userId);
|
||||
expect(seeded.assetId).toBeTruthy();
|
||||
const tables = [
|
||||
"media_sources",
|
||||
"media_assets",
|
||||
"media_uploads",
|
||||
"media_segments",
|
||||
"media_analyses",
|
||||
"media_collections",
|
||||
"media_people",
|
||||
"media_face_tracks",
|
||||
"media_marks",
|
||||
"media_brand_kits",
|
||||
"media_projects",
|
||||
"media_edit_plans",
|
||||
"media_renders",
|
||||
"media_publications",
|
||||
"media_metrics",
|
||||
"media_metric_mappings",
|
||||
"media_teaser_models",
|
||||
];
|
||||
async function counts(spaceId: string) {
|
||||
const out: Record<string, number> = {};
|
||||
for (const table of tables) {
|
||||
const rows = await db.prisma.$queryRawUnsafe<Array<{ count: number }>>(
|
||||
`SELECT count(*)::int AS count FROM ${table} WHERE "spaceId" = $1`,
|
||||
spaceId,
|
||||
);
|
||||
out[table] = rows[0]?.count ?? -1;
|
||||
}
|
||||
const items = await db.prisma.$queryRawUnsafe<Array<{ count: number }>>(
|
||||
`SELECT count(*)::int AS count FROM media_collection_items ci
|
||||
JOIN media_assets a ON a.id = ci."assetId" WHERE a."spaceId" = $1`,
|
||||
spaceId,
|
||||
);
|
||||
out.media_collection_items = items[0]?.count ?? -1;
|
||||
return out;
|
||||
}
|
||||
const populated = await counts(other.spaceId);
|
||||
for (const table of tables) expect(populated[table], table).toBeGreaterThan(0);
|
||||
const ownerBefore = await counts(owner.spaceId);
|
||||
|
||||
const itemCountBefore = await db.prisma.mediaCollectionItem.count();
|
||||
await db.prisma.space.delete({ where: { id: other.spaceId } });
|
||||
|
||||
const after = await counts(other.spaceId);
|
||||
for (const [table, count] of Object.entries(after)) expect(count, table).toBe(0);
|
||||
expect(await db.prisma.mediaCollectionItem.count()).toBe(
|
||||
itemCountBefore - (populated.media_collection_items ?? 0),
|
||||
);
|
||||
expect(await counts(owner.spaceId)).toEqual(ownerBefore);
|
||||
});
|
||||
});
|
||||
462
packages/db/src/media-production.postgres.test.ts
Normal file
462
packages/db/src/media-production.postgres.test.ts
Normal file
@ -0,0 +1,462 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
782
packages/db/src/media.postgres.test.ts
Normal file
782
packages/db/src/media.postgres.test.ts
Normal file
@ -0,0 +1,782 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { MediaRepos } from "./media.js";
|
||||
import { createMediaRepos } from "./media.js";
|
||||
import type { MediaTestDb } from "./media-fixtures.js";
|
||||
import { BASE_TIME, openMediaTestDb, sourceItem, vectorBytes } 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;
|
||||
|
||||
describePostgres("media library and organization (PostgreSQL)", () => {
|
||||
let db: MediaTestDb;
|
||||
let media: MediaRepos;
|
||||
|
||||
beforeAll(() => {
|
||||
db = openMediaTestDb(databaseUrl!);
|
||||
media = createMediaRepos(db.prisma);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.cleanup();
|
||||
});
|
||||
|
||||
async function newLibrary(kind: "folder" | "ios_library" = "folder") {
|
||||
const user = await db.newUser();
|
||||
const source = await media.createSource(user.spaceId, {
|
||||
kind,
|
||||
label: "Library",
|
||||
ownerUserId: user.userId,
|
||||
config: kind === "folder" ? { path: "/media/library" } : { deviceId: "device-1" },
|
||||
});
|
||||
return { ...user, sourceId: source.id };
|
||||
}
|
||||
|
||||
it("indexes source items once and reports only real changes on the next sync", async () => {
|
||||
const { spaceId, sourceId } = await newLibrary();
|
||||
const items = Array.from({ length: 1000 }, (_, index) =>
|
||||
sourceItem(index, { gpsLat: 48.208_49, gpsLon: 16.372_08 }),
|
||||
);
|
||||
|
||||
const first = await media.upsertSourceItems(spaceId, sourceId, items);
|
||||
expect(first).toMatchObject({ created: 1000, updated: 0, unchanged: 0 });
|
||||
expect(new Set(first.changedAssetIds).size).toBe(1000);
|
||||
|
||||
const second = await media.upsertSourceItems(spaceId, sourceId, items);
|
||||
expect(second).toEqual({ created: 0, updated: 0, unchanged: 1000, changedAssetIds: [] });
|
||||
|
||||
const edited = items.map((item, index) =>
|
||||
index < 3 ? { ...item, revision: "r2", isFavorite: true } : item,
|
||||
);
|
||||
const third = await media.upsertSourceItems(spaceId, sourceId, edited);
|
||||
expect(third).toMatchObject({ created: 0, updated: 3, unchanged: 997 });
|
||||
expect(third.changedAssetIds).toEqual(first.changedAssetIds.slice(0, 3));
|
||||
|
||||
const asset = await media.getAssetRecord(spaceId, first.changedAssetIds[0] as string);
|
||||
expect(asset).toMatchObject({
|
||||
gpsLat: 48.21,
|
||||
gpsLon: 16.37,
|
||||
isFavorite: true,
|
||||
revision: "r2",
|
||||
storage: "referenced",
|
||||
ingestState: "indexed",
|
||||
});
|
||||
const sources = await media.listSources(spaceId);
|
||||
expect(sources).toHaveLength(1);
|
||||
expect(sources[0]).toMatchObject({ id: sourceId, assetCount: 1000, status: "idle" });
|
||||
});
|
||||
|
||||
it("serializes concurrent syncs of one source without duplicate assets", async () => {
|
||||
const { spaceId, sourceId } = await newLibrary();
|
||||
const items = Array.from({ length: 50 }, (_, index) => sourceItem(index));
|
||||
const results = await Promise.all([
|
||||
media.upsertSourceItems(spaceId, sourceId, items),
|
||||
media.upsertSourceItems(spaceId, sourceId, items),
|
||||
]);
|
||||
expect(results.map((result) => result.created).sort()).toEqual([0, 50]);
|
||||
expect(await db.prisma.mediaAsset.count({ where: { sourceId } })).toBe(50);
|
||||
});
|
||||
|
||||
it("marks client-pushed assets as remote and waiting for their upload", async () => {
|
||||
const { spaceId, sourceId } = await newLibrary("ios_library");
|
||||
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
||||
const asset = await media.getAssetRecord(spaceId, changedAssetIds[0] as string);
|
||||
expect(asset).toMatchObject({ storage: "remote", ingestState: "awaiting_upload" });
|
||||
});
|
||||
|
||||
it("keyset pagination covers 1,200 assets exactly once while new assets arrive", async () => {
|
||||
const { spaceId, sourceId } = await newLibrary();
|
||||
// Repeated timestamps and undated assets exercise the (capturedAt, id) tie-break.
|
||||
const items = Array.from({ length: 1200 }, (_, index) =>
|
||||
sourceItem(index, {
|
||||
capturedAt: index % 10 === 0 ? null : new Date(BASE_TIME + Math.floor(index / 4) * 1000),
|
||||
}),
|
||||
);
|
||||
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, items);
|
||||
const original = new Set(changedAssetIds);
|
||||
|
||||
const seen: string[] = [];
|
||||
const order: Array<number | null> = [];
|
||||
let cursor: string | null = null;
|
||||
let page = 0;
|
||||
do {
|
||||
const result = await media.searchAssets(spaceId, { cursor, limit: 97 });
|
||||
for (const asset of result.items) {
|
||||
seen.push(asset.id);
|
||||
order.push(asset.capturedAt ? Date.parse(asset.capturedAt) : null);
|
||||
}
|
||||
cursor = result.nextCursor;
|
||||
page += 1;
|
||||
// Newer, older and undated assets land between page reads.
|
||||
await media.upsertSourceItems(spaceId, sourceId, [
|
||||
sourceItem(10_000 + page * 3, { capturedAt: new Date(BASE_TIME + 10_000_000 + page) }),
|
||||
sourceItem(10_001 + page * 3, { capturedAt: new Date(BASE_TIME - 10_000_000 - page) }),
|
||||
sourceItem(10_002 + page * 3, { capturedAt: null }),
|
||||
]);
|
||||
} while (cursor);
|
||||
|
||||
expect(new Set(seen).size).toBe(seen.length);
|
||||
for (const id of original) expect(seen).toContain(id);
|
||||
// Order: capturedAt descending, undated last.
|
||||
const firstNull = order.indexOf(null);
|
||||
expect(order.slice(firstNull).every((value) => value === null)).toBe(true);
|
||||
const dated = order.slice(0, firstNull) as number[];
|
||||
for (let index = 1; index < dated.length; index++) {
|
||||
expect(dated[index - 1]).toBeGreaterThanOrEqual(dated[index] as number);
|
||||
}
|
||||
await expect(media.searchAssets(spaceId, { cursor: "not-a-cursor" })).rejects.toBeInstanceOf(
|
||||
InvalidMediaInputError,
|
||||
);
|
||||
});
|
||||
|
||||
it("filters search by kind, time, favorites, quality, place, text, person and collection", async () => {
|
||||
const { spaceId, sourceId } = await newLibrary();
|
||||
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [
|
||||
sourceItem(1, {
|
||||
kind: "video",
|
||||
gpsLat: 48.2085,
|
||||
gpsLon: 16.3721,
|
||||
albumNames: ["Summer Trip"],
|
||||
isFavorite: true,
|
||||
}),
|
||||
sourceItem(2, { kind: "photo", gpsLat: 48.137, gpsLon: 11.575, fileName: "beach_day.jpg" }),
|
||||
sourceItem(3, { kind: "video", gpsLat: 48.25, gpsLon: 16.4 }),
|
||||
sourceItem(4, { kind: "video", capturedAt: null, gpsLat: -16.5, gpsLon: 179.99 }),
|
||||
sourceItem(5, { kind: "photo", gpsLat: -16.5, gpsLon: -179.99, fileName: "100%_done.jpg" }),
|
||||
]);
|
||||
const [vienna, munich, nearVienna, dateline, otherSide] = changedAssetIds as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const ids = async (query: Parameters<MediaRepos["searchAssets"]>[1]) =>
|
||||
(await media.searchAssets(spaceId, query)).items.map((asset) => asset.id).sort();
|
||||
|
||||
expect(await ids({ kinds: ["photo"] })).toEqual([munich, otherSide].sort());
|
||||
expect(await ids({ favoritesOnly: true })).toEqual([vienna]);
|
||||
expect(
|
||||
await ids({
|
||||
from: new Date(BASE_TIME + 2 * 60_000).toISOString(),
|
||||
to: new Date(BASE_TIME + 3 * 60_000).toISOString(),
|
||||
}),
|
||||
).toEqual([munich, nearVienna].sort());
|
||||
expect(await ids({ near: { lat: 48.2082, lon: 16.3738, radiusKm: 2 } })).toEqual([vienna]);
|
||||
expect(await ids({ near: { lat: 48.2082, lon: 16.3738, radiusKm: 10 } })).toEqual(
|
||||
[vienna, nearVienna].sort(),
|
||||
);
|
||||
expect(await ids({ near: { lat: -16.5, lon: 180, radiusKm: 5 } })).toEqual(
|
||||
[dateline, otherSide].sort(),
|
||||
);
|
||||
|
||||
await media.recordAnalysisVersion(spaceId, munich, 1, 0.9);
|
||||
await media.recordAnalysisVersion(spaceId, vienna, 1, 0.3);
|
||||
expect(await ids({ minQuality: 0.5 })).toEqual([munich]);
|
||||
|
||||
expect(await ids({ text: "summer" })).toEqual([vienna]);
|
||||
expect(await ids({ text: "BEACH_DAY" })).toEqual([munich]);
|
||||
expect(await ids({ text: "100%" })).toEqual([otherSide]);
|
||||
expect(await ids({ text: "%" })).toEqual([otherSide]);
|
||||
|
||||
await media.replaceSegments(spaceId, nearVienna, 1, [
|
||||
{ kind: "shot", startMs: 0, endMs: 1000, labels: ["Sunset"], transcriptExcerpt: "welcome" },
|
||||
]);
|
||||
expect(await ids({ text: "sunset" })).toEqual([nearVienna]);
|
||||
expect(await ids({ text: "WELCOME" })).toEqual([nearVienna]);
|
||||
|
||||
const collection = await media.createManualCollection(spaceId, "Harbour walk", [dateline]);
|
||||
expect(await ids({ text: "harbour" })).toEqual([dateline]);
|
||||
expect(await ids({ collectionId: collection.id })).toEqual([dateline]);
|
||||
|
||||
const [track] = await media.replaceFaceTracks(spaceId, munich, "faces-1", [
|
||||
{ startMs: 0, endMs: 1000, confidenceMin: 0.8, keyframesPath: "k.json", embedding: null },
|
||||
]);
|
||||
const [person] = await media.createPersonSuggestions(spaceId, [
|
||||
{ centroid: vectorBytes([1, 2]), trackIds: [track?.id as string] },
|
||||
]);
|
||||
expect(await ids({ personId: person?.id as string })).toEqual([munich]);
|
||||
expect(await ids({ sourceId })).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("returns asset details and moves ingest state only from the expected state", async () => {
|
||||
const { spaceId, sourceId } = await newLibrary();
|
||||
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
||||
const assetId = changedAssetIds[0] as string;
|
||||
|
||||
const claims = await Promise.all([
|
||||
media.transitionIngestState(spaceId, assetId, { from: ["indexed"], to: "proxying" }),
|
||||
media.transitionIngestState(spaceId, assetId, { from: ["indexed"], to: "proxying" }),
|
||||
]);
|
||||
expect(claims.filter(Boolean)).toHaveLength(1);
|
||||
expect((await media.assetsNeedingWork(spaceId, "proxying", 10)).map((row) => row.id)).toEqual([
|
||||
assetId,
|
||||
]);
|
||||
|
||||
const proxied = await media.recordProxy(spaceId, assetId, {
|
||||
contentHash: "f".repeat(64),
|
||||
proxyPath: "proxy.mp4",
|
||||
thumbPath: "thumb.jpg",
|
||||
durationMs: 12_000,
|
||||
});
|
||||
expect(proxied).toMatchObject({ ingestState: "proxied", hasProxy: true, hasThumbnail: true });
|
||||
expect(
|
||||
await media.transitionIngestState(spaceId, assetId, {
|
||||
from: ["proxied"],
|
||||
to: "failed",
|
||||
error: "sidecar offline",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(await media.getAssetRecord(spaceId, assetId)).toMatchObject({
|
||||
ingestState: "failed",
|
||||
ingestError: "sidecar offline",
|
||||
durationMs: 12_000,
|
||||
});
|
||||
|
||||
await media.upsertAnalysis(spaceId, assetId, {
|
||||
kind: "transcript",
|
||||
modelId: "whisper",
|
||||
modelVersion: "1",
|
||||
data: { language: "en", words: [{ text: "hello" }, { text: "there" }] },
|
||||
});
|
||||
await media.replaceSegments(spaceId, assetId, 1, [
|
||||
{ kind: "scene", startMs: 2000, endMs: 4000, scores: { motion: 0.5 } },
|
||||
{ kind: "shot", startMs: 0, endMs: 2000, teaserScore: 0.7 },
|
||||
]);
|
||||
await media.replaceFaceTracks(spaceId, assetId, "faces-1", [
|
||||
{ startMs: 0, endMs: 500, confidenceMin: 0.6, keyframesPath: "a.json", embedding: null },
|
||||
]);
|
||||
const detail = await media.getAsset(spaceId, assetId);
|
||||
expect(detail.transcript).toBe("hello there");
|
||||
expect(detail.segments.map((segment) => segment.startMs)).toEqual([0, 2000]);
|
||||
expect(detail.segments[1]?.scores).toEqual({ motion: 0.5 });
|
||||
expect(detail.faceTracks).toHaveLength(1);
|
||||
expect(await media.recordAnalysisVersion(spaceId, assetId, 2)).toMatchObject({
|
||||
ingestState: "analyzed",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps each analysis model version and returns the latest per kind", async () => {
|
||||
const { spaceId, sourceId } = await newLibrary();
|
||||
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
||||
const assetId = changedAssetIds[0] as string;
|
||||
const v1 = await media.upsertAnalysis(spaceId, assetId, {
|
||||
kind: "embeddings",
|
||||
modelId: "siglip",
|
||||
modelVersion: "1",
|
||||
vectors: vectorBytes([1, 2, 3]),
|
||||
});
|
||||
const again = await media.upsertAnalysis(spaceId, assetId, {
|
||||
kind: "embeddings",
|
||||
modelId: "siglip",
|
||||
modelVersion: "1",
|
||||
vectors: vectorBytes([4, 5, 6]),
|
||||
});
|
||||
expect(again.id).toBe(v1.id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await media.upsertAnalysis(spaceId, assetId, {
|
||||
kind: "embeddings",
|
||||
modelId: "siglip",
|
||||
modelVersion: "2",
|
||||
vectors: vectorBytes([7, 8, 9]),
|
||||
});
|
||||
expect(await db.prisma.mediaAnalysis.count({ where: { assetId } })).toBe(2);
|
||||
const latest = await media.latestAnalysis(spaceId, assetId, "embeddings");
|
||||
expect(latest?.modelVersion).toBe("2");
|
||||
expect([...new Float32Array(new Uint8Array(latest?.vectors as Uint8Array).buffer)]).toEqual([
|
||||
7, 8, 9,
|
||||
]);
|
||||
expect(
|
||||
(await media.latestAnalysesByKind(spaceId, "embeddings")).get(assetId)?.modelVersion,
|
||||
).toBe("2");
|
||||
});
|
||||
|
||||
it("replaces segments atomically and flags paid-only segments all or nothing", async () => {
|
||||
const { spaceId, sourceId } = await newLibrary();
|
||||
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
||||
const assetId = changedAssetIds[0] as string;
|
||||
await media.replaceSegments(spaceId, assetId, 1, [
|
||||
{ kind: "shot", startMs: 0, endMs: 1000 },
|
||||
{ kind: "shot", startMs: 1000, endMs: 2000 },
|
||||
]);
|
||||
await expect(
|
||||
media.replaceSegments(spaceId, assetId, 2, [
|
||||
{ kind: "shot", startMs: 0, endMs: 1000 },
|
||||
{ kind: "shot", startMs: 1000, endMs: 1000 },
|
||||
]),
|
||||
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
||||
expect(await media.listSegments(spaceId, [assetId])).toHaveLength(2);
|
||||
|
||||
await media.replaceSegments(spaceId, assetId, 2, [{ kind: "scene", startMs: 0, endMs: 3000 }]);
|
||||
const segments = await media.listSegments(spaceId, [assetId]);
|
||||
expect(segments).toHaveLength(1);
|
||||
const segmentId = segments[0]?.id as string;
|
||||
|
||||
await expect(
|
||||
media.setSegmentsPaidOnly(spaceId, [segmentId, "missing"], true),
|
||||
).rejects.toBeInstanceOf(IsolationError);
|
||||
expect((await media.listSegments(spaceId, [assetId]))[0]?.paidOnly).toBe(false);
|
||||
expect(await media.setSegmentsPaidOnly(spaceId, [segmentId], true)).toBe(1);
|
||||
expect((await media.listSegments(spaceId, [assetId]))[0]?.paidOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("re-clustering keeps user titles and manual collections and drops stale clusters", async () => {
|
||||
const { spaceId, sourceId } = await newLibrary();
|
||||
const { changedAssetIds } = await media.upsertSourceItems(
|
||||
spaceId,
|
||||
sourceId,
|
||||
Array.from({ length: 6 }, (_, index) => sourceItem(index + 1)),
|
||||
);
|
||||
const [a1, a2, a3, a4, a5, a6] = changedAssetIds as string[];
|
||||
const cluster = (key: string, title: string, assetIds: string[]) => ({
|
||||
key,
|
||||
title,
|
||||
labelSource: "rule" as const,
|
||||
startAt: new Date(BASE_TIME),
|
||||
endAt: new Date(BASE_TIME + 1000),
|
||||
centroid: { lat: 48.20849, lon: 16.37208 },
|
||||
coverAssetId: assetIds[0] ?? null,
|
||||
assetIds,
|
||||
});
|
||||
expect(
|
||||
await media.replaceClusters(spaceId, "session", [
|
||||
cluster("s1", "Morning", [a1 as string, a2 as string]),
|
||||
cluster("s2", "Noon", [a3 as string]),
|
||||
cluster("s3", "Evening", [a4 as string]),
|
||||
]),
|
||||
).toEqual({ created: 3, updated: 0, deleted: 0 });
|
||||
const manual = await media.createManualCollection(spaceId, "Favourites", [
|
||||
a6 as string,
|
||||
a1 as string,
|
||||
]);
|
||||
const first = (await media.listCollections(spaceId, { kind: "session" })).items;
|
||||
const morning = first.find((collection) => collection.title === "Morning");
|
||||
const evening = first.find((collection) => collection.title === "Evening");
|
||||
await media.renameCollection(spaceId, morning?.id as string, "Market visit");
|
||||
await media.renameCollection(spaceId, evening?.id as string, "Dinner");
|
||||
|
||||
expect(
|
||||
await media.replaceClusters(spaceId, "session", [
|
||||
cluster("s1", "Morning again", [a1 as string, a3 as string, a5 as string]),
|
||||
cluster("s4", "Night", [a2 as string]),
|
||||
]),
|
||||
).toEqual({ created: 1, updated: 1, deleted: 1 });
|
||||
|
||||
const after = await media.listCollections(spaceId, {});
|
||||
const byTitle = new Map(after.items.map((collection) => [collection.title, collection]));
|
||||
expect([...byTitle.keys()].sort()).toEqual(
|
||||
["Dinner", "Favourites", "Market visit", "Night"].sort(),
|
||||
);
|
||||
expect(byTitle.get("Market visit")).toMatchObject({
|
||||
id: morning?.id,
|
||||
labelSource: "user",
|
||||
assetCount: 3,
|
||||
videoCount: 2,
|
||||
totalDurationMs: 20_000,
|
||||
});
|
||||
const manualAfter = await media.getCollection(spaceId, manual.id);
|
||||
expect(manualAfter.collection).toMatchObject({ kind: "manual", title: "Favourites" });
|
||||
expect(manualAfter.assets.map((asset) => asset.id)).toEqual([a1, a6]);
|
||||
const stored = await db.prisma.mediaCollection.findUniqueOrThrow({
|
||||
where: { id: morning?.id as string },
|
||||
});
|
||||
expect(stored).toMatchObject({ centroidLat: 48.21, centroidLon: 16.37 });
|
||||
|
||||
await expect(
|
||||
media.replaceClusters(spaceId, "session", [cluster("x", "Bad", ["foreign-asset"])]),
|
||||
).rejects.toBeInstanceOf(IsolationError);
|
||||
});
|
||||
|
||||
it("lists, merges, splits and creates collections", async () => {
|
||||
const { spaceId, sourceId } = await newLibrary();
|
||||
const { changedAssetIds } = await media.upsertSourceItems(
|
||||
spaceId,
|
||||
sourceId,
|
||||
Array.from({ length: 5 }, (_, index) => sourceItem(index + 1)),
|
||||
);
|
||||
const ids = changedAssetIds as string[];
|
||||
const clusters = ids.map((assetId, index) => ({
|
||||
key: `t${index}`,
|
||||
title: `Theme ${index}`,
|
||||
labelSource: "model" as const,
|
||||
startAt: index === 4 ? null : new Date(BASE_TIME + index * 1000),
|
||||
endAt: null,
|
||||
centroid: null,
|
||||
coverAssetId: assetId,
|
||||
assetIds: [assetId],
|
||||
}));
|
||||
await media.replaceClusters(spaceId, "theme", clusters);
|
||||
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null = null;
|
||||
do {
|
||||
const page = await media.listCollections(spaceId, { kind: "theme", cursor, limit: 2 });
|
||||
seen.push(...page.items.map((collection) => collection.title));
|
||||
cursor = page.nextCursor;
|
||||
} while (cursor);
|
||||
expect(seen).toEqual(["Theme 3", "Theme 2", "Theme 1", "Theme 0", "Theme 4"]);
|
||||
|
||||
const all = (await media.listCollections(spaceId, { kind: "theme" })).items;
|
||||
const merged = await media.mergeCollections(
|
||||
spaceId,
|
||||
all.slice(0, 3).map((collection) => collection.id),
|
||||
);
|
||||
expect(merged).toMatchObject({ id: all[0]?.id, assetCount: 3, labelSource: "user" });
|
||||
expect((await media.listCollections(spaceId, { kind: "theme" })).items).toHaveLength(3);
|
||||
const mergedAssets = (await media.getCollection(spaceId, merged.id)).assets.map(
|
||||
(asset) => asset.id,
|
||||
);
|
||||
expect(mergedAssets).toEqual([ids[1], ids[2], ids[3]]);
|
||||
|
||||
await expect(
|
||||
media.splitCollection(spaceId, merged.id, ids[1] as string),
|
||||
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
||||
const [kept, split] = await media.splitCollection(spaceId, merged.id, ids[2] as string);
|
||||
expect(kept).toMatchObject({ id: merged.id, assetCount: 1 });
|
||||
expect(split).toMatchObject({ assetCount: 2, kind: "theme" });
|
||||
expect(
|
||||
await media.collectionAssetIds(spaceId, [split?.id as string, kept?.id as string]),
|
||||
).toEqual([ids[2], ids[3], ids[1]]);
|
||||
});
|
||||
|
||||
it("suggests, merges and assigns people and records consent only for a member", async () => {
|
||||
const { spaceId, userId, sourceId } = await newLibrary();
|
||||
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [
|
||||
sourceItem(1),
|
||||
sourceItem(2),
|
||||
]);
|
||||
const [assetA, assetB] = changedAssetIds as [string, string];
|
||||
const tracksA = await media.replaceFaceTracks(spaceId, assetA, "faces-1", [
|
||||
{
|
||||
startMs: 0,
|
||||
endMs: 1000,
|
||||
confidenceMin: 0.9,
|
||||
keyframesPath: "a1",
|
||||
embedding: vectorBytes([1, 0]),
|
||||
},
|
||||
{
|
||||
startMs: 0,
|
||||
endMs: 1000,
|
||||
confidenceMin: 0.7,
|
||||
keyframesPath: "a2",
|
||||
embedding: vectorBytes([0, 1]),
|
||||
},
|
||||
]);
|
||||
const tracksB = await media.replaceFaceTracks(spaceId, assetB, "faces-1", [
|
||||
{
|
||||
startMs: 0,
|
||||
endMs: 1000,
|
||||
confidenceMin: 0.8,
|
||||
keyframesPath: "b1",
|
||||
embedding: vectorBytes([1, 0]),
|
||||
},
|
||||
]);
|
||||
expect(await media.faceTrackEmbeddings(spaceId, { unassignedOnly: true })).toHaveLength(3);
|
||||
|
||||
const people = await media.createPersonSuggestions(spaceId, [
|
||||
{
|
||||
centroid: vectorBytes([1, 0]),
|
||||
trackIds: [tracksA[0]?.id as string, tracksB[0]?.id as string],
|
||||
},
|
||||
{ centroid: vectorBytes([0, 1]), trackIds: [tracksA[1]?.id as string] },
|
||||
]);
|
||||
expect(people.map((person) => person.displayName)).toEqual(["Person 1", "Person 2"]);
|
||||
expect(people[0]).toMatchObject({
|
||||
faceTrackCount: 2,
|
||||
confirmed: false,
|
||||
coverThumbAssetId: assetA,
|
||||
});
|
||||
expect(await media.faceTrackEmbeddings(spaceId, { unassignedOnly: true })).toHaveLength(0);
|
||||
expect(await media.personCentroids(spaceId)).toHaveLength(2);
|
||||
|
||||
const [first, second] = people as [(typeof people)[0], (typeof people)[0]];
|
||||
await media.updatePerson(spaceId, first.id, { isSelf: true });
|
||||
await media.updatePerson(spaceId, second.id, { isSelf: true, displayName: "Me" });
|
||||
const listed = await media.listPeople(spaceId);
|
||||
expect(listed.filter((person) => person.isSelf).map((person) => person.id)).toEqual([
|
||||
second.id,
|
||||
]);
|
||||
|
||||
const mark = await media.createMark(spaceId, {
|
||||
kind: "tattoo",
|
||||
label: null,
|
||||
personId: second.id,
|
||||
assetId: assetA,
|
||||
prompt: { tMs: 0, box: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 } },
|
||||
treatment: "inpaint",
|
||||
});
|
||||
const merged = await media.mergePeople(spaceId, [first.id, second.id]);
|
||||
expect(merged).toMatchObject({ id: first.id, faceTrackCount: 3, confirmed: true });
|
||||
expect((await media.getMark(spaceId, mark.id)).personId).toBe(first.id);
|
||||
expect(await media.listPeople(spaceId)).toHaveLength(1);
|
||||
|
||||
const reassigned = await media.assignTrack(spaceId, tracksB[0]?.id as string, null);
|
||||
expect(reassigned.personId).toBeNull();
|
||||
await expect(
|
||||
media.assignTrack(spaceId, tracksB[0]?.id as string, "missing-person"),
|
||||
).rejects.toBeInstanceOf(IsolationError);
|
||||
|
||||
const consent = {
|
||||
personId: first.id,
|
||||
consentArtifactId: "artifact-1",
|
||||
consentRecordedAt: new Date(BASE_TIME).toISOString(),
|
||||
ageVerificationMethod: "document_on_file" as const,
|
||||
ageVerifiedAt: new Date(BASE_TIME).toISOString(),
|
||||
};
|
||||
await expect(
|
||||
media.recordConsent({ spaceId, recordedByUserId: "" }, consent),
|
||||
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
||||
await expect(
|
||||
media.recordConsent({ spaceId, recordedByUserId: "not-a-member" }, consent),
|
||||
).rejects.toBeInstanceOf(IsolationError);
|
||||
await expect(
|
||||
media.recordConsent(
|
||||
{ spaceId, recordedByUserId: userId },
|
||||
{ ...consent, ageVerifiedAt: null },
|
||||
),
|
||||
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
||||
const withConsent = await media.recordConsent({ spaceId, recordedByUserId: userId }, consent);
|
||||
expect(withConsent).toMatchObject({
|
||||
consentArtifactId: "artifact-1",
|
||||
ageVerificationMethod: "document_on_file",
|
||||
consentRecordedAt: consent.consentRecordedAt,
|
||||
});
|
||||
const record = await db.prisma.mediaPerson.findUniqueOrThrow({ where: { id: first.id } });
|
||||
expect(record.consentRecordedBy).toBe(userId);
|
||||
});
|
||||
|
||||
it("removing a person leaves no biometric bytes behind", async () => {
|
||||
const { spaceId, sourceId } = await newLibrary();
|
||||
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
||||
const assetId = changedAssetIds[0] as string;
|
||||
const tracks = await media.replaceFaceTracks(spaceId, assetId, "faces-1", [
|
||||
{
|
||||
startMs: 0,
|
||||
endMs: 1000,
|
||||
confidenceMin: 0.9,
|
||||
keyframesPath: "k1",
|
||||
embedding: vectorBytes([1, 2]),
|
||||
},
|
||||
{
|
||||
startMs: 0,
|
||||
endMs: 1000,
|
||||
confidenceMin: 0.9,
|
||||
keyframesPath: "k2",
|
||||
embedding: vectorBytes([3, 4]),
|
||||
},
|
||||
{
|
||||
startMs: 0,
|
||||
endMs: 1000,
|
||||
confidenceMin: 0.9,
|
||||
keyframesPath: "k3",
|
||||
embedding: vectorBytes([5, 6]),
|
||||
},
|
||||
]);
|
||||
const [person, bystander] = await media.createPersonSuggestions(spaceId, [
|
||||
{
|
||||
centroid: vectorBytes([2, 3]),
|
||||
trackIds: [tracks[0]?.id as string, tracks[1]?.id as string],
|
||||
},
|
||||
{ centroid: vectorBytes([5, 6]), trackIds: [tracks[2]?.id as string] },
|
||||
]);
|
||||
const personId = person?.id as string;
|
||||
const mark = await media.createMark(spaceId, {
|
||||
kind: "birthmark",
|
||||
label: null,
|
||||
personId,
|
||||
assetId,
|
||||
prompt: { tMs: 0 },
|
||||
treatment: "blur",
|
||||
});
|
||||
await media.recordMarkResult(spaceId, mark.id, {
|
||||
qualityScore: 0.4,
|
||||
suggestedFallback: "cover",
|
||||
maskDir: "masks/m1",
|
||||
});
|
||||
const assetMark = await media.createMark(spaceId, {
|
||||
kind: "license_plate",
|
||||
label: null,
|
||||
personId: null,
|
||||
assetId,
|
||||
prompt: { tMs: 0 },
|
||||
treatment: "blur",
|
||||
});
|
||||
|
||||
const removed = await media.removePersonCompletely(spaceId, personId);
|
||||
expect(removed).toEqual({
|
||||
deletedFaceTracks: 2,
|
||||
deletedMarks: 1,
|
||||
keyframesPaths: expect.arrayContaining(["k1", "k2"]),
|
||||
maskDirs: ["masks/m1"],
|
||||
});
|
||||
|
||||
const people = await db.prisma.$queryRaw<Array<{ id: string; centroid: Uint8Array | null }>>`
|
||||
SELECT id, centroid FROM media_people WHERE "spaceId" = ${spaceId}`;
|
||||
expect(people).toEqual([{ id: bystander?.id, centroid: expect.anything() }]);
|
||||
const personRows = await db.prisma.$queryRaw<Array<{ count: number }>>`
|
||||
SELECT count(*)::int AS count FROM media_people WHERE id = ${personId}`;
|
||||
expect(personRows[0]?.count).toBe(0);
|
||||
const linked = await db.prisma.$queryRaw<Array<{ count: number }>>`
|
||||
SELECT count(*)::int AS count FROM media_face_tracks
|
||||
WHERE "personId" = ${personId} OR id IN (${tracks[0]?.id}, ${tracks[1]?.id})`;
|
||||
expect(linked[0]?.count).toBe(0);
|
||||
const embeddings = await db.prisma.$queryRaw<Array<{ id: string }>>`
|
||||
SELECT id FROM media_face_tracks WHERE "spaceId" = ${spaceId} AND embedding IS NOT NULL`;
|
||||
expect(embeddings.map((row) => row.id)).toEqual([tracks[2]?.id]);
|
||||
const marks = await db.prisma.$queryRaw<Array<{ id: string }>>`
|
||||
SELECT id FROM media_marks WHERE "spaceId" = ${spaceId}`;
|
||||
expect(marks.map((row) => row.id)).toEqual([assetMark.id]);
|
||||
await expect(media.removePersonCompletely(spaceId, personId)).rejects.toBeInstanceOf(
|
||||
IsolationError,
|
||||
);
|
||||
});
|
||||
|
||||
it("manages marks per asset and per person", async () => {
|
||||
const { spaceId, sourceId } = await newLibrary();
|
||||
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [
|
||||
sourceItem(1),
|
||||
sourceItem(2),
|
||||
]);
|
||||
const [assetA, assetB] = changedAssetIds as [string, string];
|
||||
const [trackB] = await media.replaceFaceTracks(spaceId, assetB, "faces-1", [
|
||||
{ startMs: 0, endMs: 1000, confidenceMin: 0.9, keyframesPath: "k", embedding: null },
|
||||
]);
|
||||
const [person] = await media.createPersonSuggestions(spaceId, [
|
||||
{ centroid: vectorBytes([1]), trackIds: [trackB?.id as string] },
|
||||
]);
|
||||
const personMark = await media.createMark(spaceId, {
|
||||
kind: "tattoo",
|
||||
label: "left forearm",
|
||||
personId: person?.id as string,
|
||||
assetId: assetA,
|
||||
prompt: { tMs: 500, point: { x: 0.2, y: 0.3 } },
|
||||
treatment: "inpaint",
|
||||
});
|
||||
const plate = await media.createMark(spaceId, {
|
||||
kind: "license_plate",
|
||||
label: null,
|
||||
personId: null,
|
||||
assetId: assetB,
|
||||
prompt: { tMs: 0, box: { x: 0, y: 0, w: 0.1, h: 0.1 } },
|
||||
treatment: "blur",
|
||||
});
|
||||
expect((await media.listMarks(spaceId, { personId: person?.id })).map((m) => m.id)).toEqual([
|
||||
personMark.id,
|
||||
]);
|
||||
expect((await media.listMarks(spaceId, { assetId: assetB })).map((m) => m.id)).toEqual([
|
||||
plate.id,
|
||||
]);
|
||||
// Person marks follow the person into every asset they appear in.
|
||||
expect((await media.marksForAsset(spaceId, assetB)).map((m) => m.id).sort()).toEqual(
|
||||
[personMark.id, plate.id].sort(),
|
||||
);
|
||||
const updated = await media.updateMark(spaceId, plate.id, { treatment: "cover", label: "car" });
|
||||
expect(updated).toMatchObject({ treatment: "cover", label: "car" });
|
||||
const result = await media.recordMarkResult(spaceId, personMark.id, {
|
||||
qualityScore: 0.2,
|
||||
suggestedFallback: "cover",
|
||||
maskDir: "masks/tattoo",
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
qualityScore: 0.2,
|
||||
suggestedFallback: "cover",
|
||||
maskDir: "masks/tattoo",
|
||||
prompt: { tMs: 500, point: { x: 0.2, y: 0.3 } },
|
||||
});
|
||||
expect(await media.removeMark(spaceId, personMark.id)).toEqual({ maskDir: "masks/tattoo" });
|
||||
expect(await media.listMarks(spaceId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uploads chunks idempotently, resumes, and completes only when every chunk arrived", async () => {
|
||||
const { spaceId, userId, sourceId } = await newLibrary("ios_library");
|
||||
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
||||
const assetId = changedAssetIds[0] as string;
|
||||
const begin = {
|
||||
assetId,
|
||||
userId,
|
||||
sizeBytes: 10,
|
||||
sha256: "c".repeat(64),
|
||||
fileName: "clip.mov",
|
||||
chunkBytes: 4,
|
||||
};
|
||||
const upload = await media.beginUpload(spaceId, begin);
|
||||
expect(upload).toMatchObject({
|
||||
sizeBytes: 10,
|
||||
chunkBytes: 4,
|
||||
receivedChunks: [],
|
||||
status: "open",
|
||||
});
|
||||
expect((await media.beginUpload(spaceId, begin)).id).toBe(upload.id);
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 6 }, () => media.markChunkReceived(spaceId, upload.id, 1)),
|
||||
);
|
||||
await media.markChunkReceived(spaceId, upload.id, 0);
|
||||
expect((await media.uploadStatus(spaceId, upload.id)).receivedChunks).toEqual([0, 1]);
|
||||
await expect(media.markChunkReceived(spaceId, upload.id, 3)).rejects.toBeInstanceOf(
|
||||
InvalidMediaInputError,
|
||||
);
|
||||
await expect(
|
||||
media.completeUpload(spaceId, upload.id, { storagePath: "uploads/clip.mov" }),
|
||||
).rejects.toBeInstanceOf(MediaStateConflictError);
|
||||
|
||||
await media.markChunkReceived(spaceId, upload.id, 2);
|
||||
const done = await media.completeUpload(spaceId, upload.id, {
|
||||
storagePath: "uploads/clip.mov",
|
||||
});
|
||||
expect(done).toMatchObject({ status: "complete", receivedChunks: [0, 1, 2] });
|
||||
expect(await media.getAssetRecord(spaceId, assetId)).toMatchObject({
|
||||
storage: "uploaded",
|
||||
storagePath: "uploads/clip.mov",
|
||||
contentHash: "c".repeat(64),
|
||||
ingestState: "indexed",
|
||||
});
|
||||
await expect(media.markChunkReceived(spaceId, upload.id, 0)).rejects.toBeInstanceOf(
|
||||
MediaStateConflictError,
|
||||
);
|
||||
expect((await media.beginUpload(spaceId, begin)).id).not.toBe(upload.id);
|
||||
});
|
||||
|
||||
it("requests uploads only for remote assets", async () => {
|
||||
const remote = await newLibrary("ios_library");
|
||||
const { changedAssetIds } = await media.upsertSourceItems(remote.spaceId, remote.sourceId, [
|
||||
sourceItem(1),
|
||||
]);
|
||||
const remoteId = changedAssetIds[0] as string;
|
||||
await media.transitionIngestState(remote.spaceId, remoteId, {
|
||||
from: ["awaiting_upload"],
|
||||
to: "failed",
|
||||
});
|
||||
expect(await media.requestUpload(remote.spaceId, [remoteId])).toEqual([remoteId]);
|
||||
|
||||
const folder = await newLibrary();
|
||||
const local = await media.upsertSourceItems(folder.spaceId, folder.sourceId, [sourceItem(1)]);
|
||||
expect(await media.requestUpload(folder.spaceId, local.changedAssetIds)).toEqual([]);
|
||||
});
|
||||
|
||||
it("updates and removes a source with everything indexed from it", async () => {
|
||||
const { spaceId, sourceId } = await newLibrary();
|
||||
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
||||
const synced = await media.updateSourceSync(spaceId, sourceId, {
|
||||
status: "syncing",
|
||||
lastSyncCursor: "page-2",
|
||||
lastSyncedAt: new Date(BASE_TIME),
|
||||
error: null,
|
||||
});
|
||||
expect(synced).toMatchObject({
|
||||
status: "syncing",
|
||||
lastSyncCursor: "page-2",
|
||||
config: { path: "/media/library" },
|
||||
assetCount: 1,
|
||||
});
|
||||
await media.replaceSegments(spaceId, changedAssetIds[0] as string, 1, [
|
||||
{ kind: "shot", startMs: 0, endMs: 1 },
|
||||
]);
|
||||
await media.removeSource(spaceId, sourceId);
|
||||
expect(await media.listSources(spaceId)).toEqual([]);
|
||||
expect(await db.prisma.mediaAsset.count({ where: { sourceId } })).toBe(0);
|
||||
expect(await db.prisma.mediaSegment.count({ where: { spaceId } })).toBe(0);
|
||||
});
|
||||
});
|
||||
272
packages/db/src/media.test.ts
Normal file
272
packages/db/src/media.test.ts
Normal file
@ -0,0 +1,272 @@
|
||||
import type { ClientMediaItem } from "@rakazo/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
MediaAsset as MediaAssetRow,
|
||||
MediaMark as MediaMarkRow,
|
||||
MediaRender as MediaRenderRow,
|
||||
MediaUpload as MediaUploadRow,
|
||||
} from "./client.js";
|
||||
import {
|
||||
clientMediaItemToSourceItem,
|
||||
decodeKeysetCursor,
|
||||
encodeKeysetCursor,
|
||||
InvalidMediaInputError,
|
||||
likeContains,
|
||||
mapMediaAsset,
|
||||
mapMediaMark,
|
||||
mapMediaRender,
|
||||
mapMediaSegment,
|
||||
mapMediaUpload,
|
||||
pageLimit,
|
||||
toPage,
|
||||
} from "./media.js";
|
||||
|
||||
const at = new Date("2026-01-01T08:00:00.000Z");
|
||||
|
||||
describe("media keyset cursors", () => {
|
||||
it("round-trips dated and undated positions as opaque base64url", () => {
|
||||
for (const cursor of [
|
||||
{ at, id: "asset-1" },
|
||||
{ at: null, id: "asset-2" },
|
||||
]) {
|
||||
const token = encodeKeysetCursor(cursor);
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(decodeKeysetCursor(token)).toEqual(cursor);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects tampered or malformed cursors", () => {
|
||||
const bad = [
|
||||
"not json",
|
||||
Buffer.from("{}").toString("base64url"),
|
||||
Buffer.from(JSON.stringify(["2026-01-01T00:00:00Z"])).toString("base64url"),
|
||||
Buffer.from(JSON.stringify(["yesterday", "id"])).toString("base64url"),
|
||||
Buffer.from(JSON.stringify([null, ""])).toString("base64url"),
|
||||
Buffer.from(JSON.stringify([42, "id"])).toString("base64url"),
|
||||
];
|
||||
for (const token of bad) {
|
||||
expect(() => decodeKeysetCursor(token), token).toThrow(InvalidMediaInputError);
|
||||
}
|
||||
});
|
||||
|
||||
it("slices a limit+1 result into a page with the last row as cursor", () => {
|
||||
const rows = [
|
||||
{ id: "c", at },
|
||||
{ id: "b", at: null },
|
||||
{ id: "a", at: null },
|
||||
];
|
||||
const page = toPage(
|
||||
rows,
|
||||
2,
|
||||
(row) => ({ at: row.at, id: row.id }),
|
||||
(row) => row.id,
|
||||
);
|
||||
expect(page.items).toEqual(["c", "b"]);
|
||||
expect(decodeKeysetCursor(page.nextCursor as string)).toEqual({ at: null, id: "b" });
|
||||
expect(
|
||||
toPage(
|
||||
rows,
|
||||
3,
|
||||
(row) => ({ at: row.at, id: row.id }),
|
||||
(row) => row.id,
|
||||
).nextCursor,
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("clamps page sizes and escapes LIKE wildcards", () => {
|
||||
expect(pageLimit(undefined)).toBe(100);
|
||||
expect(pageLimit(0)).toBe(1);
|
||||
expect(pageLimit(10_000)).toBe(500);
|
||||
expect(likeContains("50%_off\\")).toBe("%50\\%\\_off\\\\%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("media DTO mappers", () => {
|
||||
const asset: MediaAssetRow = {
|
||||
id: "asset-1",
|
||||
spaceId: "space-1",
|
||||
sourceId: "source-1",
|
||||
externalId: "a.mp4",
|
||||
revision: "r1",
|
||||
contentHash: "hash",
|
||||
kind: "video",
|
||||
storage: "referenced",
|
||||
storagePath: "originals/a.mp4",
|
||||
fileName: "a.mp4",
|
||||
sizeBytes: 10n,
|
||||
capturedAt: at,
|
||||
durationMs: 1000,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 30,
|
||||
codec: "h264",
|
||||
orientation: 0,
|
||||
gpsLat: 48.21,
|
||||
gpsLon: 16.37,
|
||||
deviceModel: "Camera",
|
||||
albumNames: ["Trip"],
|
||||
isFavorite: true,
|
||||
proxyPath: "proxy.mp4",
|
||||
thumbPath: null,
|
||||
spritePath: null,
|
||||
qualityScore: 0.5,
|
||||
analysisVersion: 1,
|
||||
ingestState: "proxied",
|
||||
ingestError: null,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
};
|
||||
|
||||
it("maps assets without leaking paths or hashes", () => {
|
||||
const dto = mapMediaAsset(asset);
|
||||
expect(dto).toEqual({
|
||||
id: "asset-1",
|
||||
sourceId: "source-1",
|
||||
kind: "video",
|
||||
storage: "referenced",
|
||||
capturedAt: at.toISOString(),
|
||||
durationMs: 1000,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 30,
|
||||
gpsLat: 48.21,
|
||||
gpsLon: 16.37,
|
||||
deviceModel: "Camera",
|
||||
albumNames: ["Trip"],
|
||||
isFavorite: true,
|
||||
ingestState: "proxied",
|
||||
hasProxy: true,
|
||||
hasThumbnail: false,
|
||||
qualityScore: 0.5,
|
||||
createdAt: at.toISOString(),
|
||||
});
|
||||
expect(JSON.stringify(dto)).not.toContain("proxy.mp4");
|
||||
expect(JSON.stringify(dto)).not.toContain("originals");
|
||||
});
|
||||
|
||||
it("keeps only numeric segment scores", () => {
|
||||
const segment = mapMediaSegment({
|
||||
id: "seg-1",
|
||||
spaceId: "space-1",
|
||||
assetId: "asset-1",
|
||||
kind: "shot",
|
||||
startMs: 0,
|
||||
endMs: 100,
|
||||
scores: { motion: 0.4, faces: "many", sharpness: null },
|
||||
teaserScore: null,
|
||||
transcriptExcerpt: null,
|
||||
labels: [],
|
||||
paidOnly: true,
|
||||
analysisVersion: 1,
|
||||
createdAt: at,
|
||||
});
|
||||
expect(segment.scores).toEqual({ motion: 0.4 });
|
||||
expect(segment.paidOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("drops internal render output paths and invalid reports", () => {
|
||||
const row: MediaRenderRow = {
|
||||
id: "render-1",
|
||||
spaceId: "space-1",
|
||||
projectId: "project-1",
|
||||
planId: "plan-1",
|
||||
profileId: "example-short-vertical",
|
||||
quality: "draft",
|
||||
status: "done",
|
||||
progress: 1.5,
|
||||
outputs: [
|
||||
{ kind: "video", artifactId: null, fileName: "a.mp4", sizeBytes: 1, path: "/abs/a.mp4" },
|
||||
{ kind: "nonsense" },
|
||||
],
|
||||
gateReport: { passed: "maybe" },
|
||||
ffprobe: null,
|
||||
autoFixRound: 0,
|
||||
error: null,
|
||||
createdBy: "user-1",
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
};
|
||||
const dto = mapMediaRender(row);
|
||||
expect(dto.progress).toBe(1);
|
||||
expect(dto.outputs).toEqual([
|
||||
{ kind: "video", artifactId: null, fileName: "a.mp4", sizeBytes: 1 },
|
||||
]);
|
||||
expect(dto.gateReport).toBeNull();
|
||||
});
|
||||
|
||||
it("sorts upload chunks and converts sizes", () => {
|
||||
const row: MediaUploadRow = {
|
||||
id: "upload-1",
|
||||
spaceId: "space-1",
|
||||
assetId: "asset-1",
|
||||
userId: "user-1",
|
||||
sizeBytes: 21n * 1024n * 1024n * 1024n,
|
||||
chunkBytes: 8,
|
||||
sha256: "a".repeat(64),
|
||||
fileName: "a.mov",
|
||||
receivedChunks: [3, 0, 2],
|
||||
status: "open",
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
};
|
||||
expect(mapMediaUpload(row)).toEqual({
|
||||
id: "upload-1",
|
||||
assetId: "asset-1",
|
||||
sizeBytes: 21 * 1024 * 1024 * 1024,
|
||||
chunkBytes: 8,
|
||||
receivedChunks: [0, 2, 3],
|
||||
status: "open",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps mark prompts and drops malformed ones", () => {
|
||||
const row: MediaMarkRow = {
|
||||
id: "mark-1",
|
||||
spaceId: "space-1",
|
||||
kind: "tattoo",
|
||||
label: null,
|
||||
personId: null,
|
||||
assetId: "asset-1",
|
||||
prompt: { tMs: 10, point: { x: 0.5, y: 0.5 } },
|
||||
treatment: "blur",
|
||||
maskDir: "masks/mark-1",
|
||||
qualityScore: null,
|
||||
suggestedFallback: null,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
};
|
||||
expect(mapMediaMark(row).prompt).toEqual({ tMs: 10, point: { x: 0.5, y: 0.5 } });
|
||||
expect(mapMediaMark({ ...row, prompt: { tMs: -1 } }).prompt).toBeNull();
|
||||
expect(mapMediaMark(row)).not.toHaveProperty("maskDir");
|
||||
});
|
||||
});
|
||||
|
||||
describe("client media items", () => {
|
||||
const item: ClientMediaItem = {
|
||||
externalId: "local-1",
|
||||
kind: "video",
|
||||
capturedAt: at.toISOString(),
|
||||
durationMs: 1000,
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
fps: 30,
|
||||
gpsLat: 48.2085,
|
||||
gpsLon: 16.3721,
|
||||
deviceModel: "Phone",
|
||||
albumNames: ["Trip"],
|
||||
isFavorite: false,
|
||||
fileName: "IMG_0001.MOV",
|
||||
};
|
||||
|
||||
it("derives a revision that changes with any metadata change", () => {
|
||||
const base = clientMediaItemToSourceItem(item);
|
||||
expect(base).toMatchObject({ externalId: "local-1", capturedAt: at, sizeBytes: null });
|
||||
expect(clientMediaItemToSourceItem({ ...item }).revision).toBe(base.revision);
|
||||
expect(clientMediaItemToSourceItem({ ...item, isFavorite: true }).revision).not.toBe(
|
||||
base.revision,
|
||||
);
|
||||
expect(clientMediaItemToSourceItem({ ...item, albumNames: [] }).revision).not.toBe(
|
||||
base.revision,
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user