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[0]): ReturnType { // 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; session?: { workspaceId: string } }>(); if (request.headers.authorization !== "Bearer test-session") throw new UnauthorizedException(); if (!canView && new Reflector().get(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 { 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 { 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); }); });