T-HUB/apps/api/src/video/video.integration.test.ts
glass beadian 31c97a0b0a fix: jobless video assets no longer 500 the library click
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.
2026-10-18 08:19:42 +00:00

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);
});
});