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/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
129
apps/api/src/video/video.integration.test.ts
Normal file
129
apps/api/src/video/video.integration.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
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 } 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",
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue