feat(media): add Convex media archive with backfill and mixed-media library

This commit is contained in:
2026-04-10 15:15:44 +02:00
parent ddb2412349
commit a1df097f9c
26 changed files with 2664 additions and 122 deletions

View File

@@ -0,0 +1,478 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/convex/helpers", () => ({
requireAuth: vi.fn(),
optionalAuth: vi.fn(),
}));
import type { Id } from "@/convex/_generated/dataModel";
import { finalizeImageSuccess, finalizeVideoSuccess } from "@/convex/ai";
import {
collectOwnedMediaStorageIds,
listMediaArchiveItems,
upsertMediaItemByOwnerAndDedupe,
} from "@/convex/media";
import { verifyOwnedStorageIds } from "@/convex/storage";
import { registerUploadedImageMedia } from "@/convex/storage";
import { buildStoredMediaDedupeKey } from "@/lib/media-archive";
import { requireAuth } from "@/convex/helpers";
type MockMediaItem = {
_id: Id<"mediaItems">;
ownerId: string;
dedupeKey: string;
kind: "image" | "video" | "asset";
source: "upload" | "ai-image" | "ai-video" | "freepik-asset" | "pexels-video";
storageId?: Id<"_storage">;
previewStorageId?: Id<"_storage">;
createdAt: number;
updatedAt: number;
lastUsedAt: number;
};
function createMockDb(initialRows: MockMediaItem[] = []) {
const rows = [...initialRows];
return {
rows,
db: {
query: (table: "mediaItems") => {
expect(table).toBe("mediaItems");
return {
withIndex: (
index: "by_owner_dedupe",
apply: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
expect(index).toBe("by_owner_dedupe");
const clauses: Array<{ field: string; value: unknown }> = [];
const queryBuilder = {
eq(field: string, value: unknown) {
clauses.push({ field, value });
return this;
},
};
apply(queryBuilder);
return {
unique: async () => {
const ownerId = clauses.find((clause) => clause.field === "ownerId")?.value;
const dedupeKey = clauses.find((clause) => clause.field === "dedupeKey")?.value;
return (
rows.find((row) => row.ownerId === ownerId && row.dedupeKey === dedupeKey) ?? null
);
},
};
},
};
},
insert: async (_table: "mediaItems", value: Omit<MockMediaItem, "_id">) => {
const inserted = {
_id: `media_${rows.length + 1}` as Id<"mediaItems">,
...value,
};
rows.push(inserted);
return inserted._id;
},
patch: async (id: Id<"mediaItems">, patch: Partial<MockMediaItem>) => {
const row = rows.find((entry) => entry._id === id);
if (!row) {
throw new Error("row missing");
}
Object.assign(row, patch);
},
get: async (id: Id<"mediaItems">) => rows.find((entry) => entry._id === id) ?? null,
},
};
}
describe("media archive", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("lists media ordered by updatedAt desc and supports kind filter", () => {
const media = listMediaArchiveItems(
[
{
_id: "media_1" as Id<"mediaItems">,
_creationTime: 1,
ownerId: "user_1",
dedupeKey: "storage:s1",
kind: "image",
source: "upload",
createdAt: 1,
updatedAt: 10,
lastUsedAt: 10,
},
{
_id: "media_2" as Id<"mediaItems">,
_creationTime: 2,
ownerId: "user_1",
dedupeKey: "storage:s2",
kind: "video",
source: "ai-video",
createdAt: 2,
updatedAt: 50,
lastUsedAt: 50,
},
{
_id: "media_3" as Id<"mediaItems">,
_creationTime: 3,
ownerId: "user_1",
dedupeKey: "storage:s3",
kind: "image",
source: "ai-image",
createdAt: 3,
updatedAt: 30,
lastUsedAt: 30,
},
],
{ kind: "image", limit: 2 },
);
expect(media.map((item) => item.id)).toEqual(["media_3", "media_1"]);
expect(media.every((item) => item.kind === "image")).toBe(true);
});
it("upserts idempotently by owner+dedupe", async () => {
const now = 1700000000000;
const { rows, db } = createMockDb();
const first = await upsertMediaItemByOwnerAndDedupe(
{ db } as never,
{
ownerId: "user_1",
now,
input: {
kind: "image",
source: "upload",
dedupeKey: "storage:abc",
storageId: "storage_abc" as Id<"_storage">,
},
},
);
const second = await upsertMediaItemByOwnerAndDedupe(
{ db } as never,
{
ownerId: "user_1",
now: now + 5,
input: {
kind: "image",
source: "upload",
dedupeKey: "storage:abc",
storageId: "storage_abc" as Id<"_storage">,
},
},
);
expect(rows).toHaveLength(1);
expect(second._id).toBe(first._id);
expect(second.updatedAt).toBe(now + 5);
expect(second.lastUsedAt).toBe(now + 5);
});
it("verifies user media ownership over storage and preview ids", () => {
const ownedSet = collectOwnedMediaStorageIds([
{
storageId: "storage_original" as Id<"_storage">,
previewStorageId: "storage_preview" as Id<"_storage">,
},
]);
const result = verifyOwnedStorageIds(
[
"storage_preview" as Id<"_storage">,
"storage_original" as Id<"_storage">,
"storage_unowned" as Id<"_storage">,
],
ownedSet,
);
expect(result.verifiedStorageIds).toEqual([
"storage_original" as Id<"_storage">,
"storage_preview" as Id<"_storage">,
]);
expect(result.rejectedStorageIds).toBe(1);
});
it("registerUploadedImageMedia persists upload archive entry", async () => {
vi.mocked(requireAuth).mockResolvedValue({ userId: "user_1" } as never);
const rows: MockMediaItem[] = [];
const canvasId = "canvas_1" as Id<"canvases">;
const nodeId = "node_1" as Id<"nodes">;
const storageId = "storage_upload_1" as Id<"_storage">;
const docs = new Map<string, unknown>([
[canvasId, { _id: canvasId, ownerId: "user_1" }],
[nodeId, { _id: nodeId, canvasId }],
]);
const db = {
get: vi.fn(async (id: string) => {
return rows.find((row) => row._id === id) ?? docs.get(id) ?? null;
}),
query: vi.fn((table: "mediaItems") => {
expect(table).toBe("mediaItems");
return {
withIndex: vi.fn(
(
index: "by_owner_dedupe",
apply: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
expect(index).toBe("by_owner_dedupe");
const clauses: Array<{ field: string; value: unknown }> = [];
const queryBuilder = {
eq(field: string, value: unknown) {
clauses.push({ field, value });
return this;
},
};
apply(queryBuilder);
return {
unique: async () => {
const ownerId = clauses.find((clause) => clause.field === "ownerId")?.value;
const dedupeKey = clauses.find((clause) => clause.field === "dedupeKey")?.value;
return rows.find((row) => row.ownerId === ownerId && row.dedupeKey === dedupeKey) ?? null;
},
};
},
),
};
}),
insert: vi.fn(async (_table: "mediaItems", value: Omit<MockMediaItem, "_id">) => {
const inserted = {
_id: `media_${rows.length + 1}` as Id<"mediaItems">,
...value,
};
rows.push(inserted);
return inserted._id;
}),
patch: vi.fn(async (id: Id<"mediaItems">, patch: Partial<MockMediaItem>) => {
const row = rows.find((entry) => entry._id === id);
if (!row) throw new Error("row missing");
Object.assign(row, patch);
}),
};
await (registerUploadedImageMedia as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> })._handler(
{ db } as never,
{
canvasId,
nodeId,
storageId,
filename: "sunset.png",
mimeType: "image/png",
width: 1920,
height: 1080,
},
);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
ownerId: "user_1",
kind: "image",
source: "upload",
dedupeKey: buildStoredMediaDedupeKey(storageId),
storageId,
filename: "sunset.png",
mimeType: "image/png",
width: 1920,
height: 1080,
firstSourceCanvasId: canvasId,
firstSourceNodeId: nodeId,
});
});
it("finalizeImageSuccess writes ai-image archive entry", async () => {
const rows: MockMediaItem[] = [];
const now = 1700000000000;
const nodeId = "node_ai_image_1" as Id<"nodes">;
const canvasId = "canvas_1" as Id<"canvases">;
const storageId = "storage_ai_image_1" as Id<"_storage">;
const nodeDoc = {
_id: nodeId,
canvasId,
data: {},
status: "executing",
retryCount: 0,
};
const docs = new Map<string, unknown>([
[nodeId, nodeDoc],
[canvasId, { _id: canvasId, ownerId: "user_1" }],
]);
vi.spyOn(Date, "now").mockReturnValue(now);
const db = {
get: vi.fn(async (id: string) => {
return rows.find((row) => row._id === id) ?? docs.get(id) ?? null;
}),
patch: vi.fn(async (id: string, patch: Record<string, unknown>) => {
const doc = docs.get(id) as Record<string, unknown> | undefined;
if (!doc) throw new Error("missing doc");
Object.assign(doc, patch);
}),
query: vi.fn((table: "mediaItems") => {
expect(table).toBe("mediaItems");
return {
withIndex: vi.fn(
(
index: "by_owner_dedupe",
apply: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
expect(index).toBe("by_owner_dedupe");
const clauses: Array<{ field: string; value: unknown }> = [];
const queryBuilder = {
eq(field: string, value: unknown) {
clauses.push({ field, value });
return this;
},
};
apply(queryBuilder);
return {
unique: async () => {
const ownerId = clauses.find((clause) => clause.field === "ownerId")?.value;
const dedupeKey = clauses.find((clause) => clause.field === "dedupeKey")?.value;
return rows.find((row) => row.ownerId === ownerId && row.dedupeKey === dedupeKey) ?? null;
},
};
},
),
};
}),
insert: vi.fn(async (_table: "mediaItems", value: Omit<MockMediaItem, "_id">) => {
const inserted = {
_id: `media_${rows.length + 1}` as Id<"mediaItems">,
...value,
};
rows.push(inserted);
return inserted._id;
}),
};
await (finalizeImageSuccess as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<{ creditCost: number }> })._handler(
{ db } as never,
{
nodeId,
prompt: "cinematic sunset over lake",
modelId: "google/gemini-2.5-flash-image",
storageId,
retryCount: 1,
},
);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
ownerId: "user_1",
kind: "image",
source: "ai-image",
dedupeKey: buildStoredMediaDedupeKey(storageId),
storageId,
firstSourceCanvasId: canvasId,
firstSourceNodeId: nodeId,
});
});
it("finalizeVideoSuccess writes ai-video archive entry", async () => {
const rows: MockMediaItem[] = [];
const now = 1700000000000;
const nodeId = "node_ai_video_1" as Id<"nodes">;
const canvasId = "canvas_1" as Id<"canvases">;
const storageId = "storage_ai_video_1" as Id<"_storage">;
const nodeDoc = {
_id: nodeId,
canvasId,
data: {},
status: "executing",
retryCount: 0,
};
const docs = new Map<string, unknown>([
[nodeId, nodeDoc],
[canvasId, { _id: canvasId, ownerId: "user_1" }],
]);
vi.spyOn(Date, "now").mockReturnValue(now);
const db = {
get: vi.fn(async (id: string) => {
return rows.find((row) => row._id === id) ?? docs.get(id) ?? null;
}),
patch: vi.fn(async (id: string, patch: Record<string, unknown>) => {
const doc = docs.get(id) as Record<string, unknown> | undefined;
if (!doc) throw new Error("missing doc");
Object.assign(doc, patch);
}),
query: vi.fn((table: "mediaItems") => {
expect(table).toBe("mediaItems");
return {
withIndex: vi.fn(
(
index: "by_owner_dedupe",
apply: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
expect(index).toBe("by_owner_dedupe");
const clauses: Array<{ field: string; value: unknown }> = [];
const queryBuilder = {
eq(field: string, value: unknown) {
clauses.push({ field, value });
return this;
},
};
apply(queryBuilder);
return {
unique: async () => {
const ownerId = clauses.find((clause) => clause.field === "ownerId")?.value;
const dedupeKey = clauses.find((clause) => clause.field === "dedupeKey")?.value;
return rows.find((row) => row.ownerId === ownerId && row.dedupeKey === dedupeKey) ?? null;
},
};
},
),
};
}),
insert: vi.fn(async (_table: "mediaItems", value: Omit<MockMediaItem, "_id">) => {
const inserted = {
_id: `media_${rows.length + 1}` as Id<"mediaItems">,
...value,
};
rows.push(inserted);
return inserted._id;
}),
};
await (finalizeVideoSuccess as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> })._handler(
{ db } as never,
{
nodeId,
prompt: "camera truck left",
modelId: "wan-2-2-720p",
durationSeconds: 5,
storageId,
retryCount: 3,
creditCost: 52,
},
);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
ownerId: "user_1",
kind: "video",
source: "ai-video",
dedupeKey: buildStoredMediaDedupeKey(storageId),
storageId,
durationSeconds: 5,
firstSourceCanvasId: canvasId,
firstSourceNodeId: nodeId,
});
});
});

View File

@@ -0,0 +1,356 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/convex/helpers", () => ({
requireAuth: vi.fn(),
optionalAuth: vi.fn(),
}));
import type { Id } from "@/convex/_generated/dataModel";
import {
backfillLegacyMediaForCanvas,
mapLegacyNodeToMediaArchiveInput,
} from "@/convex/media";
import { backfillMediaArchiveBatch } from "@/convex/migrations";
import {
buildFreepikAssetDedupeKey,
buildPexelsVideoDedupeKey,
buildStoredMediaDedupeKey,
} from "@/lib/media-archive";
type MockCanvas = {
_id: Id<"canvases">;
ownerId: string;
};
type MockNode = {
_id: Id<"nodes">;
canvasId: Id<"canvases">;
type: string;
data: Record<string, unknown>;
};
type MockMediaItem = {
_id: Id<"mediaItems">;
ownerId: string;
dedupeKey: string;
kind: "image" | "video" | "asset";
source: "upload" | "ai-image" | "ai-video" | "freepik-asset" | "pexels-video";
storageId?: Id<"_storage">;
providerAssetId?: string;
createdAt: number;
updatedAt: number;
lastUsedAt: number;
};
function createMockDb(args: {
canvases: MockCanvas[];
nodes: MockNode[];
mediaItems?: MockMediaItem[];
}) {
const rows = [...(args.mediaItems ?? [])];
const db = {
query: (table: "canvases" | "nodes" | "mediaItems") => {
if (table === "canvases") {
return {
order: (direction: "asc" | "desc") => {
expect(direction).toBe("asc");
return {
collect: async () => [...args.canvases],
};
},
};
}
if (table === "nodes") {
return {
withIndex: (
index: "by_canvas",
apply: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
expect(index).toBe("by_canvas");
const clauses: Array<{ field: string; value: unknown }> = [];
const queryBuilder = {
eq(field: string, value: unknown) {
clauses.push({ field, value });
return this;
},
};
apply(queryBuilder);
return {
collect: async () => {
const canvasId = clauses.find((clause) => clause.field === "canvasId")?.value;
return args.nodes.filter((node) => node.canvasId === canvasId);
},
};
},
};
}
return {
withIndex: (
index: "by_owner_dedupe",
apply: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
expect(index).toBe("by_owner_dedupe");
const clauses: Array<{ field: string; value: unknown }> = [];
const queryBuilder = {
eq(field: string, value: unknown) {
clauses.push({ field, value });
return this;
},
};
apply(queryBuilder);
return {
unique: async () => {
const ownerId = clauses.find((clause) => clause.field === "ownerId")?.value;
const dedupeKey = clauses.find((clause) => clause.field === "dedupeKey")?.value;
return rows.find((row) => row.ownerId === ownerId && row.dedupeKey === dedupeKey) ?? null;
},
};
},
};
},
insert: async (_table: "mediaItems", value: Omit<MockMediaItem, "_id">) => {
const inserted = {
_id: `media_${rows.length + 1}` as Id<"mediaItems">,
...value,
};
rows.push(inserted);
return inserted._id;
},
patch: async (id: Id<"mediaItems">, patchValue: Partial<MockMediaItem>) => {
const row = rows.find((entry) => entry._id === id);
if (!row) {
throw new Error("missing row");
}
Object.assign(row, patchValue);
},
get: async (id: Id<"mediaItems">) => {
return rows.find((entry) => entry._id === id) ?? null;
},
};
return { db, rows };
}
describe("media backfill", () => {
it("converts supported legacy node types into media archive inputs", () => {
const imageStorageId = "storage_image_1" as Id<"_storage">;
const aiImageStorageId = "storage_ai_image_1" as Id<"_storage">;
const aiVideoStorageId = "storage_ai_video_1" as Id<"_storage">;
expect(
mapLegacyNodeToMediaArchiveInput({
_id: "node_image" as Id<"nodes">,
canvasId: "canvas_1" as Id<"canvases">,
type: "image",
data: {
storageId: imageStorageId,
mimeType: "image/png",
width: 1920,
height: 1080,
},
}),
).toMatchObject({
kind: "image",
source: "upload",
dedupeKey: buildStoredMediaDedupeKey(imageStorageId),
storageId: imageStorageId,
mimeType: "image/png",
width: 1920,
height: 1080,
});
expect(
mapLegacyNodeToMediaArchiveInput({
_id: "node_ai_image" as Id<"nodes">,
canvasId: "canvas_1" as Id<"canvases">,
type: "ai-image",
data: {
storageId: aiImageStorageId,
},
}),
).toMatchObject({
kind: "image",
source: "ai-image",
dedupeKey: buildStoredMediaDedupeKey(aiImageStorageId),
storageId: aiImageStorageId,
});
expect(
mapLegacyNodeToMediaArchiveInput({
_id: "node_ai_video" as Id<"nodes">,
canvasId: "canvas_1" as Id<"canvases">,
type: "ai-video",
data: {
storageId: aiVideoStorageId,
durationSeconds: 10,
},
}),
).toMatchObject({
kind: "video",
source: "ai-video",
dedupeKey: buildStoredMediaDedupeKey(aiVideoStorageId),
storageId: aiVideoStorageId,
durationSeconds: 10,
});
expect(
mapLegacyNodeToMediaArchiveInput({
_id: "node_asset" as Id<"nodes">,
canvasId: "canvas_1" as Id<"canvases">,
type: "asset",
data: {
assetId: 123,
assetType: "photo",
sourceUrl: "https://www.freepik.com/asset/123",
previewUrl: "https://cdn.freepik.com/preview/123.jpg",
},
}),
).toMatchObject({
kind: "asset",
source: "freepik-asset",
dedupeKey: buildFreepikAssetDedupeKey("photo", 123),
providerAssetId: "123",
sourceUrl: "https://www.freepik.com/asset/123",
previewUrl: "https://cdn.freepik.com/preview/123.jpg",
});
expect(
mapLegacyNodeToMediaArchiveInput({
_id: "node_video" as Id<"nodes">,
canvasId: "canvas_1" as Id<"canvases">,
type: "video",
data: {
pexelsId: 987,
mp4Url: "https://videos.pexels.com/video-files/987.mp4",
thumbnailUrl: "https://images.pexels.com/videos/987.jpg",
duration: 42,
attribution: {
videoUrl: "https://www.pexels.com/video/987/",
},
},
}),
).toMatchObject({
kind: "video",
source: "pexels-video",
dedupeKey: buildPexelsVideoDedupeKey(987),
providerAssetId: "987",
originalUrl: "https://videos.pexels.com/video-files/987.mp4",
previewUrl: "https://images.pexels.com/videos/987.jpg",
sourceUrl: "https://www.pexels.com/video/987/",
durationSeconds: 42,
});
});
it("collapses duplicates through upsert and remains idempotent", async () => {
const storageId = "storage_shared" as Id<"_storage">;
const canvas: MockCanvas = { _id: "canvas_1" as Id<"canvases">, ownerId: "user_1" };
const nodes: MockNode[] = [
{
_id: "node_1" as Id<"nodes">,
canvasId: canvas._id,
type: "image",
data: { storageId },
},
{
_id: "node_2" as Id<"nodes">,
canvasId: canvas._id,
type: "ai-image",
data: { storageId },
},
];
const { db, rows } = createMockDb({ canvases: [canvas], nodes });
await backfillLegacyMediaForCanvas(
{ db } as never,
{
canvas,
nodes,
now: 1700000000000,
},
);
await backfillLegacyMediaForCanvas(
{ db } as never,
{
canvas,
nodes,
now: 1700000000100,
},
);
expect(rows).toHaveLength(1);
expect(rows[0].dedupeKey).toBe(buildStoredMediaDedupeKey(storageId));
expect(rows[0].updatedAt).toBe(1700000000100);
});
it("supports resumable cursor progression across batches", async () => {
const canvases: MockCanvas[] = [
{ _id: "canvas_1" as Id<"canvases">, ownerId: "user_1" },
{ _id: "canvas_2" as Id<"canvases">, ownerId: "user_1" },
{ _id: "canvas_3" as Id<"canvases">, ownerId: "user_2" },
];
const nodes: MockNode[] = [
{
_id: "node_1" as Id<"nodes">,
canvasId: canvases[0]._id,
type: "image",
data: { storageId: "storage_1" as Id<"_storage"> },
},
{
_id: "node_2" as Id<"nodes">,
canvasId: canvases[1]._id,
type: "image",
data: { storageId: "storage_2" as Id<"_storage"> },
},
{
_id: "node_3" as Id<"nodes">,
canvasId: canvases[2]._id,
type: "image",
data: { storageId: "storage_3" as Id<"_storage"> },
},
];
const { db, rows } = createMockDb({ canvases, nodes });
const first = await backfillMediaArchiveBatch(
{ db } as never,
{ batchSize: 1, now: 1700000000000 },
);
expect(first).toMatchObject({
processedCanvasCount: 1,
done: false,
nextCursor: canvases[0]._id,
});
expect(rows).toHaveLength(1);
const second = await backfillMediaArchiveBatch(
{ db } as never,
{ batchSize: 1, cursor: first.nextCursor, now: 1700000000100 },
);
expect(second).toMatchObject({
processedCanvasCount: 1,
done: false,
nextCursor: canvases[1]._id,
});
expect(rows).toHaveLength(2);
const third = await backfillMediaArchiveBatch(
{ db } as never,
{ batchSize: 1, cursor: second.nextCursor, now: 1700000000200 },
);
expect(third).toMatchObject({
processedCanvasCount: 1,
done: true,
nextCursor: canvases[2]._id,
});
expect(rows).toHaveLength(3);
});
});

View File

@@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import {
buildFreepikAssetDedupeKey,
buildPexelsVideoDedupeKey,
buildStoredMediaDedupeKey,
mapMediaArchiveRowToListItem,
normalizeMediaArchiveInput,
} from "@/lib/media-archive";
describe("media archive helpers", () => {
it("builds storage dedupe keys", () => {
expect(buildStoredMediaDedupeKey("storage_123")).toBe("storage:storage_123");
});
it("builds freepik dedupe keys", () => {
expect(buildFreepikAssetDedupeKey("photo", 42)).toBe("freepik:photo:42");
});
it("builds pexels dedupe keys", () => {
expect(buildPexelsVideoDedupeKey(77)).toBe("pexels:video:77");
});
it("normalizes stored media input and drops external-only fields", () => {
const normalized = normalizeMediaArchiveInput({
kind: "image",
source: "upload",
dedupeKey: "storage:storage_1",
storageId: "storage_1",
previewStorageId: "preview_1",
filename: "photo.png",
mimeType: "image/png",
title: "Photo",
width: 1024,
height: 768,
durationSeconds: 12,
providerAssetId: "asset_1",
originalUrl: "https://cdn.example.com/original.png",
previewUrl: "https://cdn.example.com/preview.png",
sourceUrl: "https://example.com/origin",
metadata: { license: "custom" },
firstSourceCanvasId: "canvas_1",
firstSourceNodeId: "node_1",
unknownField: "drop-me",
});
expect(normalized).toEqual({
kind: "image",
source: "upload",
dedupeKey: "storage:storage_1",
storageId: "storage_1",
previewStorageId: "preview_1",
filename: "photo.png",
mimeType: "image/png",
title: "Photo",
width: 1024,
height: 768,
durationSeconds: 12,
metadata: { license: "custom" },
firstSourceCanvasId: "canvas_1",
firstSourceNodeId: "node_1",
});
});
it("normalizes external media input and drops storage-only fields", () => {
const normalized = normalizeMediaArchiveInput({
kind: "asset",
source: "freepik-asset",
dedupeKey: "freepik:photo:42",
providerAssetId: "42",
title: "Palm Tree",
previewUrl: "https://cdn.freepik.com/preview.jpg",
originalUrl: "https://cdn.freepik.com/original.jpg",
sourceUrl: "https://www.freepik.com/asset/42",
storageId: "storage_1",
previewStorageId: "preview_1",
metadata: { license: "freepik-standard" },
unknownField: "drop-me",
});
expect(normalized).toEqual({
kind: "asset",
source: "freepik-asset",
dedupeKey: "freepik:photo:42",
providerAssetId: "42",
title: "Palm Tree",
previewUrl: "https://cdn.freepik.com/preview.jpg",
originalUrl: "https://cdn.freepik.com/original.jpg",
sourceUrl: "https://www.freepik.com/asset/42",
metadata: { license: "freepik-standard" },
});
});
it("maps media archive rows to one ui-facing card shape", () => {
const item = mapMediaArchiveRowToListItem({
_id: "media_1",
kind: "video",
source: "pexels-video",
dedupeKey: "pexels:video:77",
title: "Ocean clip",
filename: "ocean.mp4",
mimeType: "video/mp4",
durationSeconds: 8,
previewUrl: "https://images.pexels.com/preview.jpg",
originalUrl: "https://videos.pexels.com/video.mp4",
sourceUrl: "https://www.pexels.com/video/77",
providerAssetId: "77",
width: 1920,
height: 1080,
updatedAt: 200,
createdAt: 100,
lastUsedAt: 200,
});
expect(item).toEqual({
id: "media_1",
kind: "video",
source: "pexels-video",
dedupeKey: "pexels:video:77",
title: "Ocean clip",
filename: "ocean.mp4",
mimeType: "video/mp4",
durationSeconds: 8,
previewUrl: "https://images.pexels.com/preview.jpg",
originalUrl: "https://videos.pexels.com/video.mp4",
sourceUrl: "https://www.pexels.com/video/77",
providerAssetId: "77",
width: 1920,
height: 1080,
updatedAt: 200,
createdAt: 100,
lastUsedAt: 200,
});
});
});

View File

@@ -40,9 +40,13 @@ type RunCreateNodeOnlineOnly = Parameters<typeof useCanvasDrop>[0]["runCreateNod
type HarnessProps = {
runCreateNodeOnlineOnly: RunCreateNodeOnlineOnly;
registerUploadedImageMedia?: Parameters<typeof useCanvasDrop>[0]["registerUploadedImageMedia"];
};
function HookHarness({ runCreateNodeOnlineOnly }: HarnessProps) {
function HookHarness({
runCreateNodeOnlineOnly,
registerUploadedImageMedia = async () => ({ ok: true }),
}: HarnessProps) {
const value = useCanvasDrop({
canvasId: "canvas_1" as Id<"canvases">,
isSyncOnline: true,
@@ -50,7 +54,7 @@ function HookHarness({ runCreateNodeOnlineOnly }: HarnessProps) {
edges: [],
screenToFlowPosition: ({ x, y }) => ({ x, y }),
generateUploadUrl: async () => "https://upload.example.com",
registerUploadedImageMedia: async () => ({ ok: true }),
registerUploadedImageMedia,
runCreateNodeOnlineOnly,
runCreateNodeWithEdgeSplitOnlineOnly: async () => "node_split_1" as Id<"nodes">,
notifyOfflineUnsupported: () => {},
@@ -116,6 +120,7 @@ describe("useCanvasDrop image upload path", () => {
const runCreateNodeOnlineOnly = vi
.fn<HarnessProps["runCreateNodeOnlineOnly"]>()
.mockResolvedValue("node_1" as Id<"nodes">);
const registerUploadedImageMedia = vi.fn(async () => ({ ok: true as const }));
container = document.createElement("div");
document.body.appendChild(container);
@@ -125,6 +130,7 @@ describe("useCanvasDrop image upload path", () => {
root?.render(
React.createElement(HookHarness, {
runCreateNodeOnlineOnly,
registerUploadedImageMedia,
}),
);
});
@@ -149,6 +155,15 @@ describe("useCanvasDrop image upload path", () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(runCreateNodeOnlineOnly).toHaveBeenCalledTimes(1);
expect(registerUploadedImageMedia).toHaveBeenCalledWith({
canvasId: "canvas_1",
nodeId: "node_1",
storageId: "storage_1",
filename: "drop.png",
mimeType: "image/png",
width: 640,
height: 480,
});
expect(invalidateDashboardSnapshotForLastSignedInUserMock).toHaveBeenCalledTimes(1);
expect(emitDashboardSnapshotCacheInvalidationSignalMock).toHaveBeenCalledTimes(1);
});

View File

@@ -49,7 +49,31 @@ function createCachedSnapshot() {
},
],
canvases: [],
mediaPreview: [],
mediaPreview: [
{
kind: "image",
storageId: "storage_1",
filename: "preview.jpg",
createdAt: 1,
},
],
};
}
function createLegacyCachedSnapshotWithoutKind() {
return {
balance: { available: 120 },
subscription: null,
usageStats: null,
recentTransactions: [],
canvases: [],
mediaPreview: [
{
storageId: "storage_legacy",
filename: "legacy.jpg",
createdAt: 1,
},
],
};
}
@@ -116,4 +140,23 @@ describe("useDashboardSnapshot", () => {
expect(latestHookValue.current?.source).toBe("cache");
expect(firstSnapshot).toBe(secondSnapshot);
});
it("ignores legacy cached snapshots that miss media item kind", async () => {
useAuthQueryMock.mockReturnValue(undefined);
getDashboardSnapshotCacheInvalidationSignalKeyMock.mockReturnValue("dashboard:invalidate");
readDashboardSnapshotCacheMock.mockReturnValue({
snapshot: createLegacyCachedSnapshotWithoutKind(),
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness, { userId: "user_legacy" }));
});
expect(latestHookValue.current?.source).toBe("none");
expect(latestHookValue.current?.snapshot).toBeUndefined();
});
});