diff --git a/packages/db/src/media-fixtures.ts b/packages/db/src/media-fixtures.ts new file mode 100644 index 0000000..89b3c1e --- /dev/null +++ b/packages/db/src/media-fixtures.ts @@ -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; +} + +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 { + 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>; diff --git a/packages/db/src/media-isolation.postgres.test.ts b/packages/db/src/media-isolation.postgres.test.ts new file mode 100644 index 0000000..a0b76a8 --- /dev/null +++ b/packages/db/src/media-isolation.postgres.test.ts @@ -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]> = [ + ["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 = {}; + for (const table of tables) { + const rows = await db.prisma.$queryRawUnsafe>( + `SELECT count(*)::int AS count FROM ${table} WHERE "spaceId" = $1`, + spaceId, + ); + out[table] = rows[0]?.count ?? -1; + } + const items = await db.prisma.$queryRawUnsafe>( + `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); + }); +}); diff --git a/packages/db/src/media-production.postgres.test.ts b/packages/db/src/media-production.postgres.test.ts new file mode 100644 index 0000000..5f326f8 --- /dev/null +++ b/packages/db/src/media-production.postgres.test.ts @@ -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(); + }); +}); diff --git a/packages/db/src/media.postgres.test.ts b/packages/db/src/media.postgres.test.ts new file mode 100644 index 0000000..c1e233e --- /dev/null +++ b/packages/db/src/media.postgres.test.ts @@ -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 = []; + 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[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>` + SELECT id, centroid FROM media_people WHERE "spaceId" = ${spaceId}`; + expect(people).toEqual([{ id: bystander?.id, centroid: expect.anything() }]); + const personRows = await db.prisma.$queryRaw>` + SELECT count(*)::int AS count FROM media_people WHERE id = ${personId}`; + expect(personRows[0]?.count).toBe(0); + const linked = await db.prisma.$queryRaw>` + 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>` + 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>` + 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); + }); +}); diff --git a/packages/db/src/media.test.ts b/packages/db/src/media.test.ts new file mode 100644 index 0000000..c859369 --- /dev/null +++ b/packages/db/src/media.test.ts @@ -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, + ); + }); +});