import { describe, expect, test } from "bun:test"; import { createVideoPublishingModule, type VideoPublishJobRecord, type VideoPublishUploadRecord, type VideoPublishingEvent, } from "@/lib/video/video-publishing"; type TestUpload = VideoPublishUploadRecord & { title: string }; type TestJob = VideoPublishJobRecord; function fixture() { const upload: TestUpload = { id: "upload-1", title: "Test upload", status: "processing", scheduledAt: null, createdAt: new Date("2026-08-09T00:00:00Z"), }; const job: TestJob = { id: "job-1", uploadId: upload.id, platform: "youtube", status: "pending", attempts: 0, }; const events: VideoPublishingEvent[] = []; const updates: string[] = []; const jobs = [job]; const publishing = createVideoPublishingModule({ store: { claimJob: async (_jobId, startedAt) => { if (job.status === "completed" || job.status === "processing") return null; job.status = "processing"; job.attempts += 1; void startedAt; return job; }, findUpload: async () => upload, markCompleted: async (nextJob) => { nextJob.status = "completed"; }, markFailed: async (nextJob) => { nextJob.status = "failed"; }, findDueUploads: async () => [upload], findJobs: async () => jobs, updateUploadStatus: async (_upload, status) => { updates.push(status); }, }, publishers: { youtube: async (nextUpload) => ({ id: nextUpload.id, url: "https://youtube.test/video" }), tiktok: async (nextUpload) => ({ id: nextUpload.id, url: "https://tiktok.test/video" }), }, notify: async (event) => { events.push(event); }, now: () => new Date("2026-08-09T00:01:00Z"), }); return { events, job, jobs, publishing, updates, upload }; } describe("video publishing job module", () => { test("keeps provider result and lifecycle notifications separate", async () => { const { events, job, publishing } = fixture(); await publishing.publishJob(job.id); expect(job).toMatchObject({ status: "completed", attempts: 1 }); expect(events.map((event) => event.status)).toEqual(["processing", "completed"]); }); test("marks provider failures without hiding the original error", async () => { const { events, job } = fixture(); const publishing = createVideoPublishingModule({ store: { claimJob: async () => { if (job.status === "processing" || job.status === "completed") return null; job.status = "processing"; return job; }, findUpload: async () => ({ id: "upload-1", title: "Test upload", status: "processing", scheduledAt: null, createdAt: new Date(), }), markCompleted: async () => {}, markFailed: async (nextJob) => { nextJob.status = "failed"; }, findDueUploads: async () => [], findJobs: async () => [], updateUploadStatus: async () => {}, }, publishers: { youtube: async () => { throw new Error("provider offline"); }, tiktok: async () => ({ id: "id", url: "url" }), }, notify: async (event) => { events.push(event); }, }); await expect(publishing.publishJob(job.id)).rejects.toThrow("provider offline"); expect(job.status).toBe("failed"); expect(events.at(-1)?.status).toBe("failed"); }); test("allows only one caller to claim a job", async () => { const { job, publishing } = fixture(); await Promise.all([publishing.publishJob(job.id), publishing.publishJob(job.id)]); expect(job).toMatchObject({ status: "completed", attempts: 1 }); }); test("derives partial status from all platform jobs", async () => { const { updates, upload } = fixture(); const jobs: TestJob[] = [ { id: "job-1", uploadId: upload.id, platform: "youtube", status: "completed", attempts: 1, }, { id: "job-2", uploadId: upload.id, platform: "tiktok", status: "processing", attempts: 1, }, ]; const publishing = createVideoPublishingModule({ store: { claimJob: async () => null, findUpload: async () => upload, markCompleted: async () => {}, markFailed: async () => {}, findDueUploads: async () => [upload], findJobs: async () => jobs, updateUploadStatus: async (_upload, status) => { updates.push(status); }, }, publishers: { youtube: async () => ({ id: "video", url: "url" }), tiktok: async () => ({ id: "video", url: "url" }), }, notify: async () => {}, }); await publishing.processDue(); expect(updates).toEqual(["partial"]); }); test("does not fail publishing when lifecycle notification fails", async () => { const { job, upload } = fixture(); const notificationErrors: unknown[] = []; const publishing = createVideoPublishingModule({ store: { claimJob: async () => { job.status = "processing"; return job; }, findUpload: async () => upload, markCompleted: async (nextJob) => { nextJob.status = "completed"; }, markFailed: async (nextJob) => { nextJob.status = "failed"; }, findDueUploads: async () => [], findJobs: async () => [job], updateUploadStatus: async () => {}, }, publishers: { youtube: async () => ({ id: "video", url: "url" }), tiktok: async () => ({ id: "video", url: "url" }), }, notify: async () => { throw new Error("redis offline"); }, reportNotificationFailure: (error) => notificationErrors.push(error), }); await publishing.publishJob(job.id); expect(job.status).toBe("completed"); expect(notificationErrors).toHaveLength(2); }); });