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
99
apps/api/src/video/video.status.http.test.ts
Normal file
99
apps/api/src/video/video.status.http.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import "reflect-metadata";
|
||||
import { Module, NotFoundException, UnauthorizedException, ForbiddenException, type ExecutionContext, type INestApplication } from "@nestjs/common";
|
||||
import { NestFactory, Reflector } from "@nestjs/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { injectHttp } from "../connections/http-test-transport.js";
|
||||
import { RbacGuard, PERMISSION_KEY } from "../rbac/rbac.guard.js";
|
||||
import { VideoController } from "./video.controller.js";
|
||||
import { VideoService, type VideoStatus } from "./video.service.js";
|
||||
import { assetId, jobId, serviceWithDatabase, workspaceId } from "./test-fixtures.js";
|
||||
|
||||
@Module({})
|
||||
class TestModule {}
|
||||
|
||||
function statusService(database: Parameters<typeof serviceWithDatabase>[0]): ReturnType<typeof serviceWithDatabase> {
|
||||
// A probe that never runs: status reads do not invoke ffprobe.
|
||||
return serviceWithDatabase(database, async () => { throw new Error("probe must not run for status reads"); });
|
||||
}
|
||||
|
||||
describe("GET /video/:id/status", () => {
|
||||
let app: INestApplication | undefined;
|
||||
let canView = true;
|
||||
|
||||
beforeEach(() => {
|
||||
canView = true;
|
||||
vi.spyOn(RbacGuard.prototype, "canActivate").mockImplementation(async (context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest<{ headers: Record<string, string>; session?: { workspaceId: string } }>();
|
||||
if (request.headers.authorization !== "Bearer test-session") throw new UnauthorizedException();
|
||||
if (!canView && new Reflector().get<string>(PERMISSION_KEY, context.getHandler()) === "media.view") {
|
||||
throw new ForbiddenException("missing permission: media.view");
|
||||
}
|
||||
request.session = { workspaceId };
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app?.close();
|
||||
app = undefined;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function start(service: VideoService): Promise<void> {
|
||||
app = await NestFactory.create({
|
||||
module: TestModule,
|
||||
controllers: [VideoController],
|
||||
providers: [{ provide: VideoService, useValue: service }],
|
||||
}, { logger: false });
|
||||
await app.init();
|
||||
}
|
||||
|
||||
function status(id: string = assetId, authenticated = true): Promise<Response> {
|
||||
return injectHttp(app?.getHttpAdapter().getInstance(), `http://api.test/video/${id}/status`, {
|
||||
headers: { ...(authenticated ? { authorization: "Bearer test-session" } : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
it("returns the asset with its latest processing job (the shape the drawer polls)", async () => {
|
||||
const { service } = statusService({});
|
||||
await start(service);
|
||||
// Seed one asset + job through the service so the joined row exists.
|
||||
await service.uploadVideoBytes(workspaceId, Buffer.from("status fixture"), "video/mp4");
|
||||
const response = await status();
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json() as VideoStatus;
|
||||
expect(body.videoAsset).toMatchObject({ id: assetId, sizeBytes: Buffer.byteLength("status fixture") });
|
||||
expect(body.processingJob).toMatchObject({ id: jobId, state: expect.stringMatching(/^(probing|transcoding|complete|failed)$/) });
|
||||
});
|
||||
|
||||
it("reports an in-flight job state without completing it", async () => {
|
||||
const { service } = statusService({});
|
||||
await start(service);
|
||||
await service.uploadVideoBytes(workspaceId, Buffer.from("in flight"), "video/mp4");
|
||||
// The fake DB only advances probing → transcoding when the service runs it,
|
||||
// so a bare status read must observe an earlier state, not a fabricated one.
|
||||
const before = await service.getVideoStatus(assetId, workspaceId);
|
||||
const response = await status();
|
||||
const body = await response.json() as VideoStatus;
|
||||
expect(body.processingJob.state).toBe(before.processingJob.state);
|
||||
expect(body.processingJob.state).not.toBe("complete");
|
||||
});
|
||||
|
||||
it("returns 404 for an unknown asset in the workspace", async () => {
|
||||
const { service } = statusService({});
|
||||
await start(service);
|
||||
const response = await status(assetId);
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toMatchObject({ message: expect.stringContaining("not found") });
|
||||
// And the service-level route contract is a NotFoundException, not a crash.
|
||||
await expect(service.getVideoStatus(assetId, workspaceId)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it("returns 401 without a session and 403 without media.view", async () => {
|
||||
const { service } = statusService({});
|
||||
await start(service);
|
||||
expect((await status(assetId, false)).status).toBe(401);
|
||||
canView = false;
|
||||
expect((await status()).status).toBe(403);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue