Legacy/edge-case video_assets rows without a processing job threw 'has no processing job' from getVideoStatus — the library detail click 500ed. VideoStatus.processingJob is now nullable end to end (api type, DTO, web consumers null-guarded); a jobless asset renders as queued.
130 lines
6 KiB
TypeScript
130 lines
6 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { join } from "node:path";
|
|
import { Pool, type PoolClient } from "pg";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { runMigrations } from "../db/migrate.js";
|
|
import { sha256, keyForHash, type StorageService, directStorageDisabled } from "../storage/storage.js";
|
|
import { VideoService, type VideoServiceDependencies } from "./video.service.js";
|
|
|
|
const dbAvailable = process.env.DATABASE_URL !== undefined;
|
|
const ownerPool = new Pool({ connectionString: process.env.SUPER_DATABASE_URL ?? process.env.DATABASE_URL });
|
|
const appPool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
|
|
async function asTenant<T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
|
const client = await appPool.connect();
|
|
try {
|
|
await client.query("BEGIN");
|
|
await client.query("SELECT set_config('app.workspace_id', $1, true)", [workspaceId]);
|
|
const value = await fn(client);
|
|
await client.query("COMMIT");
|
|
return value;
|
|
} catch (error) {
|
|
await client.query("ROLLBACK").catch(() => undefined);
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
describe.skipIf(!dbAvailable)("video library (live DB)", () => {
|
|
let workspaceId: string;
|
|
let service: VideoService;
|
|
const objects = new Map<string, Buffer>();
|
|
const storage: StorageService = {
|
|
putContent: async (bytes, contentType) => {
|
|
const hash = sha256(bytes);
|
|
const key = keyForHash(hash, contentType);
|
|
const deduplicated = objects.has(key);
|
|
objects.set(key, Buffer.from(bytes));
|
|
return { key, hash, sizeBytes: bytes.length, contentType, deduplicated };
|
|
},
|
|
getObject: async (key) => {
|
|
const bytes = objects.get(key);
|
|
if (bytes === undefined) throw new Error("missing stored object");
|
|
return Buffer.from(bytes);
|
|
},
|
|
hasContent: async (hash, type) => objects.has(keyForHash(hash, type)),
|
|
presign: async () => "unused",
|
|
...directStorageDisabled(),
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
await runMigrations(process.env.MIGRATIONS_DIR ?? join(process.cwd(), "migrations"));
|
|
const workspace = await ownerPool.query(
|
|
"INSERT INTO workspaces (name, slug) VALUES ('Video integration', $1) RETURNING id",
|
|
[`video-${randomUUID()}`],
|
|
);
|
|
workspaceId = String(workspace.rows[0]?.id);
|
|
const dependencies: VideoServiceDependencies = {
|
|
tenantTransaction: asTenant,
|
|
appendAudit: async (tenantId, event) => asTenant(tenantId, async (client) => {
|
|
const audit = await client.query(
|
|
"SELECT audit_append($1, $2, $3, $4, $5, $6, $7) AS id",
|
|
[
|
|
tenantId,
|
|
event.actorUserId,
|
|
event.actorRole,
|
|
event.action,
|
|
event.targetType,
|
|
event.targetId,
|
|
event.diff ?? null,
|
|
],
|
|
);
|
|
return String(audit.rows[0]?.id);
|
|
}),
|
|
probe: async () => ({ width: 640, height: 360, durationMs: 1000, frameRate: 30, codec: "h264" }),
|
|
storage,
|
|
};
|
|
service = new VideoService(dependencies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (workspaceId !== undefined) await ownerPool.query("DELETE FROM workspaces WHERE id = $1", [workspaceId]);
|
|
await appPool.end();
|
|
await ownerPool.end();
|
|
});
|
|
|
|
it("uploads, pages, reads status, and deletes a tenant video", async () => {
|
|
const uploaded = await service.uploadVideoBytes(workspaceId, Buffer.from("integration video bytes"), "video/mp4");
|
|
const page = await service.listVideos(workspaceId, undefined, 20);
|
|
expect(page.items).toHaveLength(1);
|
|
expect(page.items[0]?.videoAsset.id).toBe(uploaded.videoAsset.id);
|
|
expect(page.items[0]?.processingState).toBe("complete");
|
|
|
|
const status = await service.getVideoStatus(uploaded.videoAsset.id, workspaceId);
|
|
expect(status.processingJob!.state).toBe("complete");
|
|
expect(status.processingJob!.processingNote).toContain("Transcode skipped");
|
|
expect(status.videoAsset).toMatchObject({ width: 640, height: 360, durationMs: 1000, codec: "h264" });
|
|
await expect(service.getVideoStatus(uploaded.videoAsset.id, randomUUID())).rejects.toThrow(/not found/);
|
|
|
|
await service.deleteVideo(uploaded.videoAsset.id, workspaceId);
|
|
await expect(service.getVideoStatus(uploaded.videoAsset.id, workspaceId)).rejects.toThrow(/not found/);
|
|
expect(objects.has(uploaded.videoAsset.storageKey)).toBe(true);
|
|
const jobs = await asTenant(workspaceId, (client) => client.query("SELECT id FROM video_processing_jobs WHERE video_asset_id = $1", [uploaded.videoAsset.id]));
|
|
expect(jobs.rowCount).toBe(0);
|
|
});
|
|
|
|
it("persists probe failure and exposes the error through status", async () => {
|
|
const failedService = new VideoService({
|
|
tenantTransaction: asTenant,
|
|
appendAudit: async () => "test-audit",
|
|
storage,
|
|
probe: async () => { throw new Error("ffprobe: invalid media"); },
|
|
});
|
|
const uploaded = await failedService.uploadVideoBytes(workspaceId, Buffer.from("invalid video"), "video/mp4");
|
|
expect((await failedService.getVideoStatus(uploaded.videoAsset.id, workspaceId)).processingJob).toMatchObject({ state: "failed", error: "ffprobe: invalid media" });
|
|
await failedService.deleteVideo(uploaded.videoAsset.id, workspaceId);
|
|
});
|
|
|
|
it("shares content-addressed bytes between assets and retains them when one asset is deleted", async () => {
|
|
const bytes = Buffer.from("same video twice");
|
|
const first = await service.uploadVideoBytes(workspaceId, bytes, "video/mp4");
|
|
const second = await service.uploadVideoBytes(workspaceId, bytes, "video/mp4");
|
|
expect(second.videoAsset.id).not.toBe(first.videoAsset.id);
|
|
expect(second.videoAsset.storageKey).toBe(first.videoAsset.storageKey);
|
|
await service.deleteVideo(first.videoAsset.id, workspaceId);
|
|
expect((await service.getVideoStatus(second.videoAsset.id, workspaceId)).processingJob!.state).toBe("complete");
|
|
expect(await storage.getObject(second.videoAsset.storageKey)).toEqual(bytes);
|
|
await service.deleteVideo(second.videoAsset.id, workspaceId);
|
|
});
|
|
});
|