import { readFile, stat } from "node:fs/promises"; import { afterEach, describe, expect, it, vi } from "vitest"; import { VideoService } from "./video.service.js"; import { assetId, serviceWithDatabase, workspaceId, type FakeVideoDatabase } from "./test-fixtures.js"; import { MAX_VIDEO_UPLOAD_BYTES } from "./upload-limits.js"; afterEach(() => vi.unstubAllEnvs()); describe("video byte ingest and processing-job state machine", () => { it("stores bytes, probes the decrypted stored object, and completes through a no-op transcode", async () => { const database: FakeVideoDatabase = {}; const bytes = Buffer.from("actual video bytes"); let inputPath = ""; const probe = vi.fn(async (path: string) => { inputPath = path; expect(await readFile(path)).toEqual(bytes); expect((await stat(path)).mode & 0o777).toBe(0o600); expect(database.job?.state).toBe("probing"); return { width: 1280, height: 720, durationMs: 42_000, frameRate: 30, codec: "h264", audioCodec: "aac" }; }); const { service, appendAudit, storage } = serviceWithDatabase(database, probe); const uploaded = await service.uploadVideoBytes(workspaceId, bytes, "video/mp4"); expect(storage.putContent).toHaveBeenCalledWith(bytes, "video/mp4"); expect(uploaded.videoAsset.storageKey).toMatch(/^sha256\/[a-f0-9]{64}\.mp4$/); expect(storage.getObject).toHaveBeenCalledWith(uploaded.videoAsset.storageKey); expect(uploaded.videoAsset).toMatchObject({ sizeBytes: bytes.length, width: 1280, height: 720, durationMs: 42_000, codec: "h264" }); expect(uploaded.processingJob).toMatchObject({ state: "complete", progressPercent: 100, outputKey: uploaded.videoAsset.storageKey }); expect(uploaded.processingJob.processingNote).toContain("Transcode skipped"); expect(database.transitions).toEqual(["queued", "probing", "transcoding", "complete"]); await expect(stat(inputPath)).rejects.toMatchObject({ code: "ENOENT" }); expect(appendAudit).toHaveBeenCalledWith(workspaceId, expect.objectContaining({ action: "video.uploaded", targetId: assetId })); expect(appendAudit).toHaveBeenCalledWith(workspaceId, expect.objectContaining({ action: "video.probed", diff: expect.objectContaining({ processingNote: expect.stringContaining("Transcode skipped") }) })); await expect(service.probeVideo(assetId, workspaceId)).rejects.toThrow("cannot probe video in processing state complete"); expect(probe).toHaveBeenCalledTimes(1); }); it("returns a failed job with the ffprobe error in status, and removes the temporary file", async () => { const database: FakeVideoDatabase = {}; let inputPath = ""; const { service } = serviceWithDatabase(database, async (path) => { inputPath = path; throw new Error("unreadable media"); }); const uploaded = await service.uploadVideoBytes(workspaceId, Buffer.from("broken"), "video/mp4"); expect(uploaded.processingJob).toMatchObject({ state: "failed", error: "unreadable media" }); expect(database.transitions).toEqual(["queued", "probing", "failed"]); expect((await service.getVideoStatus(assetId, workspaceId)).processingJob.error).toBe("unreadable media"); await expect(stat(inputPath)).rejects.toMatchObject({ code: "ENOENT" }); }); it("fails with 503 before DB registration without S3 configuration", async () => { vi.stubEnv("S3_ACCESS_KEY", ""); vi.stubEnv("S3_SECRET_KEY", ""); const transaction = vi.fn(); const service = new VideoService({ tenantTransaction: transaction }); await expect(service.uploadVideoBytes(workspaceId, Buffer.from("video"), "video/mp4")).rejects.toMatchObject({ status: 503, message: expect.stringContaining("Video storage is not configured") }); expect(transaction).not.toHaveBeenCalled(); }); it("fails with 503 on a configured storage outage, without creating a queued row", async () => { const database: FakeVideoDatabase = {}; const { service, storage } = serviceWithDatabase(database, async () => ({})); vi.mocked(storage.putContent).mockRejectedValueOnce(new Error("ECONNREFUSED")); await expect(service.uploadVideoBytes(workspaceId, Buffer.from("video"), "video/mp4")).rejects.toMatchObject({ status: 503 }); expect(database.asset).toBeUndefined(); expect(database.job).toBeUndefined(); }); it("marks fetch/integrity failures after put as failed, rather than leaving a queued job", async () => { const database: FakeVideoDatabase = {}; const probe = vi.fn(async () => ({})); const { service, storage } = serviceWithDatabase(database, probe); vi.mocked(storage.getObject).mockRejectedValueOnce(new Error("storage: integrity failure")); const uploaded = await service.uploadVideoBytes(workspaceId, Buffer.from("video"), "video/mp4"); expect(uploaded.processingJob).toMatchObject({ state: "failed", error: "storage: integrity failure" }); expect(probe).not.toHaveBeenCalled(); }); it("keeps metadata registration compatible only for existing objects with matching size", async () => { const database: FakeVideoDatabase = {}; const { service, storage } = serviceWithDatabase(database, async () => ({ width: 640 })); const stored = await storage.putContent(Buffer.from("video"), "video/mp4"); await expect(service.uploadVideo(workspaceId, stored.key, "video/mp4", 1)).rejects.toThrow("sizeBytes does not match"); await expect(service.uploadVideo(workspaceId, "sha256/missing.mp4", "video/mp4", 5)).rejects.toThrow("existing readable encrypted object"); const uploaded = await service.uploadVideo(workspaceId, stored.key, "video/mp4", stored.sizeBytes); expect(uploaded.processingJob.state).toBe("complete"); expect(uploaded.videoAsset.width).toBe(640); }); it("deletes the asset/job while retaining shared content-addressed bytes for GC", async () => { const database: FakeVideoDatabase = {}; const { service, objects } = serviceWithDatabase(database, async () => ({})); const bytes = Buffer.from("shared source"); const uploaded = await service.uploadVideoBytes(workspaceId, bytes, "video/mp4"); await service.deleteVideo(assetId, workspaceId); expect(database.asset).toBeUndefined(); expect(database.job).toBeUndefined(); expect(objects.get(uploaded.videoAsset.storageKey)).toEqual(bytes); }); it("rejects empty or non-video uploads before storage", async () => { const { service, storage } = serviceWithDatabase({}, async () => ({})); await expect(service.uploadVideoBytes(workspaceId, Buffer.alloc(0), "video/mp4")).rejects.toMatchObject({ status: 400 }); await expect(service.uploadVideoBytes(workspaceId, Buffer.from("image"), "image/png")).rejects.toMatchObject({ status: 400 }); await expect(service.uploadVideoBytes(workspaceId, Buffer.allocUnsafe(MAX_VIDEO_UPLOAD_BYTES + 1), "video/mp4")).rejects.toThrow("100 MiB upload limit"); expect(storage.putContent).not.toHaveBeenCalled(); }); });