Files
erika/lib/video/video-publishing.test.ts
T

181 lines
5.8 KiB
TypeScript

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