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/.
108 lines
6.8 KiB
TypeScript
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();
|
|
});
|
|
});
|