Files
roboco/panel/src/components/dashboard/__tests__/video-post-queue.test.tsx
T

192 lines
6.5 KiB
TypeScript
Raw Normal View History

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { VideoPost } from "@/lib/api/video";
const { resolveApproveRef } = vi.hoisted(() => ({
resolveApproveRef: { current: null as null | ((v: unknown) => void) },
}));
const { listPosts, approve, reject, requestVideo, getMediaBlob } = vi.hoisted(
() => ({
listPosts: vi.fn(
async () =>
[
{
task_id: "v-1",
source: "video_post",
title: "Video: release v0.19.0",
status: "pending",
occasion: "release",
script: "RoboCo v0.19.0 just shipped!",
platforms: ["x", "tiktok"],
x_caption: "RoboCo v0.19.0 is here!",
tiktok_caption: "New RoboCo drop!",
},
] as VideoPost[],
),
// Deferred so the test can freeze the approve mid-flight.
approve: vi.fn(
() =>
new Promise((r) => {
resolveApproveRef.current = r as (v: unknown) => void;
}),
),
reject: vi.fn(async () => ({})),
requestVideo: vi.fn(async () => ({
status: "opened",
task_id: "v-2",
detail: "Video-authoring task opened.",
})),
getMediaBlob: vi.fn(
async () => new Blob(["fake-mp4-bytes"], { type: "video/mp4" }),
),
}),
);
vi.mock("@/lib/api", () => ({
videoApi: { listPosts, approve, reject, requestVideo, getMediaBlob },
}));
import { VideoPostQueue } from "../video-post-queue";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
describe("VideoPostQueue", () => {
beforeEach(() => {
listPosts.mockClear();
approve.mockClear();
reject.mockClear();
requestVideo.mockClear();
getMediaBlob.mockClear();
resolveApproveRef.current = null;
// jsdom has no Blob URL implementation. Distinct URLs per call so a
// revoke can be asserted against the specific (stale) one it replaced.
let objectUrlCount = 0;
globalThis.URL.createObjectURL = vi.fn(
() => `blob:mock-url-${++objectUrlCount}`,
);
globalThis.URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
it("renders a draft with its occasion badge and both platform captions", async () => {
render(withQueryClient(<VideoPostQueue />));
expect(await screen.findByText("release")).toBeInTheDocument();
expect(
screen.getByDisplayValue("RoboCo v0.19.0 is here!"),
).toBeInTheDocument();
expect(screen.getByDisplayValue("New RoboCo drop!")).toBeInTheDocument();
});
it("fetches the preview clip as a blob via axios and drives <video> off an object URL", async () => {
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
await waitFor(() =>
expect(getMediaBlob).toHaveBeenCalledWith("v-1", "vertical"),
);
await waitFor(() =>
expect(document.querySelector("video")?.getAttribute("src")).toBe(
"blob:mock-url-1",
),
);
fireEvent.click(screen.getByRole("button", { name: "1:1" }));
await waitFor(() =>
expect(getMediaBlob).toHaveBeenCalledWith("v-1", "square"),
);
await waitFor(() =>
expect(document.querySelector("video")?.getAttribute("src")).toBe(
"blob:mock-url-2",
),
);
// The stale cut's object URL is revoked once the new one takes over —
// this is the leak-prevention path FIX 1 exists for.
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith(
"blob:mock-url-1",
);
});
it("disables Approve when the edited X caption exceeds 280 characters", async () => {
render(withQueryClient(<VideoPostQueue />));
const textarea = await screen.findByDisplayValue("RoboCo v0.19.0 is here!");
fireEvent.change(textarea, { target: { value: "x".repeat(281) } });
expect(screen.getByText("281/280")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Approve/ }),
).toBeDisabled();
});
it("only sends captions for platforms left toggled on", async () => {
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
// Un-toggle TikTok — its stored caption should NOT be sent on approve.
fireEvent.click(screen.getByLabelText("Edit TikTok caption"));
fireEvent.click(screen.getByRole("button", { name: /Approve/ }));
await waitFor(() =>
expect(approve).toHaveBeenCalledWith("v-1", {
x_caption: "RoboCo v0.19.0 is here!",
}),
);
resolveApproveRef.current?.({ status: "posted", posted: {}, detail: "ok" });
});
it("rejects a draft with a reason", async () => {
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
const reasonBox = await screen.findByLabelText("Reason");
fireEvent.change(reasonBox, { target: { value: "wrong occasion" } });
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
await waitFor(() =>
expect(reject).toHaveBeenCalledWith("v-1", "wrong occasion"),
);
});
it("requests an on-demand video with the chosen occasion, brief, and platforms", async () => {
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
fireEvent.click(screen.getByRole("button", { name: /Request a video/ }));
fireEvent.change(screen.getByLabelText("Occasion"), {
target: { value: "Founder's Day" },
});
fireEvent.change(screen.getByLabelText("Brief"), {
target: { value: "Celebrate the founding." },
});
// Both platforms are checked by default — leave as-is and submit.
fireEvent.click(screen.getByRole("button", { name: "Request" }));
await waitFor(() =>
expect(requestVideo).toHaveBeenCalledWith({
occasion: "Founder's Day",
brief: "Celebrate the founding.",
platforms: ["x", "tiktok"],
}),
);
});
it("shows an empty-state card (with the request action) when there are no drafts", async () => {
listPosts.mockResolvedValueOnce([]);
render(withQueryClient(<VideoPostQueue />));
expect(await screen.findByText(/No drafts yet/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Request a video/ }),
).toBeInTheDocument();
});
});