181 lines
5.8 KiB
TypeScript
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);
|
|
});
|
|
});
|