T-HUB/apps/api/src/video/video.service.test.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

108 lines
6.8 KiB
TypeScript

import { readFile, stat } from "node:fs/promises";
import { afterEach, describe, expect, it, vi } from "vitest";
import { VideoService } from "./video.service.js";
import { assetId, serviceWithDatabase, workspaceId, type FakeVideoDatabase } from "./test-fixtures.js";
import { MAX_VIDEO_UPLOAD_BYTES } from "./upload-limits.js";
afterEach(() => vi.unstubAllEnvs());
describe("video byte ingest and processing-job state machine", () => {
it("stores bytes, probes the decrypted stored object, and completes through a no-op transcode", async () => {
const database: FakeVideoDatabase = {};
const bytes = Buffer.from("actual video bytes");
let inputPath = "";
const probe = vi.fn(async (path: string) => {
inputPath = path;
expect(await readFile(path)).toEqual(bytes);
expect((await stat(path)).mode & 0o777).toBe(0o600);
expect(database.job?.state).toBe("probing");
return { width: 1280, height: 720, durationMs: 42_000, frameRate: 30, codec: "h264", audioCodec: "aac" };
});
const { service, appendAudit, storage } = serviceWithDatabase(database, probe);
const uploaded = await service.uploadVideoBytes(workspaceId, bytes, "video/mp4");
expect(storage.putContent).toHaveBeenCalledWith(bytes, "video/mp4");
expect(uploaded.videoAsset.storageKey).toMatch(/^sha256\/[a-f0-9]{64}\.mp4$/);
expect(storage.getObject).toHaveBeenCalledWith(uploaded.videoAsset.storageKey);
expect(uploaded.videoAsset).toMatchObject({ sizeBytes: bytes.length, width: 1280, height: 720, durationMs: 42_000, codec: "h264" });
expect(uploaded.processingJob).toMatchObject({ state: "complete", progressPercent: 100, outputKey: uploaded.videoAsset.storageKey });
expect(uploaded.processingJob.processingNote).toContain("Transcode skipped");
expect(database.transitions).toEqual(["queued", "probing", "transcoding", "complete"]);
await expect(stat(inputPath)).rejects.toMatchObject({ code: "ENOENT" });
expect(appendAudit).toHaveBeenCalledWith(workspaceId, expect.objectContaining({ action: "video.uploaded", targetId: assetId }));
expect(appendAudit).toHaveBeenCalledWith(workspaceId, expect.objectContaining({ action: "video.probed", diff: expect.objectContaining({ processingNote: expect.stringContaining("Transcode skipped") }) }));
await expect(service.probeVideo(assetId, workspaceId)).rejects.toThrow("cannot probe video in processing state complete");
expect(probe).toHaveBeenCalledTimes(1);
});
it("returns a failed job with the ffprobe error in status, and removes the temporary file", async () => {
const database: FakeVideoDatabase = {};
let inputPath = "";
const { service } = serviceWithDatabase(database, async (path) => {
inputPath = path;
throw new Error("unreadable media");
});
const uploaded = await service.uploadVideoBytes(workspaceId, Buffer.from("broken"), "video/mp4");
expect(uploaded.processingJob).toMatchObject({ state: "failed", error: "unreadable media" });
expect(database.transitions).toEqual(["queued", "probing", "failed"]);
expect((await service.getVideoStatus(assetId, workspaceId)).processingJob.error).toBe("unreadable media");
await expect(stat(inputPath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("fails with 503 before DB registration without S3 configuration", async () => {
vi.stubEnv("S3_ACCESS_KEY", "");
vi.stubEnv("S3_SECRET_KEY", "");
const transaction = vi.fn();
const service = new VideoService({ tenantTransaction: transaction });
await expect(service.uploadVideoBytes(workspaceId, Buffer.from("video"), "video/mp4")).rejects.toMatchObject({ status: 503, message: expect.stringContaining("Video storage is not configured") });
expect(transaction).not.toHaveBeenCalled();
});
it("fails with 503 on a configured storage outage, without creating a queued row", async () => {
const database: FakeVideoDatabase = {};
const { service, storage } = serviceWithDatabase(database, async () => ({}));
vi.mocked(storage.putContent).mockRejectedValueOnce(new Error("ECONNREFUSED"));
await expect(service.uploadVideoBytes(workspaceId, Buffer.from("video"), "video/mp4")).rejects.toMatchObject({ status: 503 });
expect(database.asset).toBeUndefined();
expect(database.job).toBeUndefined();
});
it("marks fetch/integrity failures after put as failed, rather than leaving a queued job", async () => {
const database: FakeVideoDatabase = {};
const probe = vi.fn(async () => ({}));
const { service, storage } = serviceWithDatabase(database, probe);
vi.mocked(storage.getObject).mockRejectedValueOnce(new Error("storage: integrity failure"));
const uploaded = await service.uploadVideoBytes(workspaceId, Buffer.from("video"), "video/mp4");
expect(uploaded.processingJob).toMatchObject({ state: "failed", error: "storage: integrity failure" });
expect(probe).not.toHaveBeenCalled();
});
it("keeps metadata registration compatible only for existing objects with matching size", async () => {
const database: FakeVideoDatabase = {};
const { service, storage } = serviceWithDatabase(database, async () => ({ width: 640 }));
const stored = await storage.putContent(Buffer.from("video"), "video/mp4");
await expect(service.uploadVideo(workspaceId, stored.key, "video/mp4", 1)).rejects.toThrow("sizeBytes does not match");
await expect(service.uploadVideo(workspaceId, "sha256/missing.mp4", "video/mp4", 5)).rejects.toThrow("existing readable encrypted object");
const uploaded = await service.uploadVideo(workspaceId, stored.key, "video/mp4", stored.sizeBytes);
expect(uploaded.processingJob.state).toBe("complete");
expect(uploaded.videoAsset.width).toBe(640);
});
it("deletes the asset/job while retaining shared content-addressed bytes for GC", async () => {
const database: FakeVideoDatabase = {};
const { service, objects } = serviceWithDatabase(database, async () => ({}));
const bytes = Buffer.from("shared source");
const uploaded = await service.uploadVideoBytes(workspaceId, bytes, "video/mp4");
await service.deleteVideo(assetId, workspaceId);
expect(database.asset).toBeUndefined();
expect(database.job).toBeUndefined();
expect(objects.get(uploaded.videoAsset.storageKey)).toEqual(bytes);
});
it("rejects empty or non-video uploads before storage", async () => {
const { service, storage } = serviceWithDatabase({}, async () => ({}));
await expect(service.uploadVideoBytes(workspaceId, Buffer.alloc(0), "video/mp4")).rejects.toMatchObject({ status: 400 });
await expect(service.uploadVideoBytes(workspaceId, Buffer.from("image"), "image/png")).rejects.toMatchObject({ status: 400 });
await expect(service.uploadVideoBytes(workspaceId, Buffer.allocUnsafe(MAX_VIDEO_UPLOAD_BYTES + 1), "video/mp4")).rejects.toThrow("100 MiB upload limit");
expect(storage.putContent).not.toHaveBeenCalled();
});
});