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.
99 lines
4.5 KiB
TypeScript
99 lines
4.5 KiB
TypeScript
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);
|
|
});
|
|
});
|