T-HUB/apps/api/src/video/video.status.http.test.ts
glass beadian 31c97a0b0a fix: jobless video assets no longer 500 the library click
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.
2026-10-18 08:19:42 +00:00

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);
});
});