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 { VideoPipelineItem, VideoPost } from "@/lib/api/video"; const { resolveApproveRef } = vi.hoisted(() => ({ resolveApproveRef: { current: null as null | ((v: unknown) => void) }, })); const { listPosts, listPipeline, 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!", mp4_paths: { vertical: "/fake/vertical.mp4", square: "/fake/square.mp4", }, }, ] as VideoPost[], ), listPipeline: vi.fn(async (): Promise => []), // 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, listPipeline, 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 {ui}; } describe("VideoPostQueue", () => { beforeEach(() => { listPosts.mockClear(); listPipeline.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()); expect(await screen.findByText("release")).toBeInTheDocument(); expect( screen.getByDisplayValue("RoboCo v0.19.0 is here!"), ).toBeInTheDocument(); expect(screen.getByDisplayValue("New RoboCo drop!")).toBeInTheDocument(); }); it("shows the fetched title and script instead of dropping them", async () => { render(withQueryClient()); expect( await screen.findByText("Video: release v0.19.0"), ).toBeInTheDocument(); expect( screen.getByText("RoboCo v0.19.0 just shipped!"), ).toBeInTheDocument(); }); it("flags a missing cut as disabled instead of silently blanking the player", async () => { listPosts.mockResolvedValueOnce([ { 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!", mp4_paths: { vertical: "/fake/vertical.mp4" }, // square never rendered }, ] as VideoPost[]); render(withQueryClient()); await screen.findByText("release"); const squareButton = screen.getByRole("button", { name: /1:1/ }); expect(squareButton).toHaveTextContent("(missing)"); expect(squareButton).toBeDisabled(); // The present cut is unaffected — no "(missing)" suffix, not disabled. const verticalButton = screen.getByRole("button", { name: /9:16/ }); expect(verticalButton).not.toHaveTextContent("(missing)"); expect(verticalButton).not.toBeDisabled(); await waitFor(() => expect(getMediaBlob).toHaveBeenCalledWith("v-1", "vertical"), ); }); // H15: the 30s refetchInterval produces a new `post` prop, but useState // initializes once — so a server-side re-draft between the CEO opening the // card and approving would be silently overwritten by the stale initial // caption. The displayed value must track the server until the CEO edits. it("tracks the server caption until the CEO edits, then holds the edit (mirrors x-post-queue)", async () => { const basePost = { 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"], }; listPosts.mockResolvedValueOnce([ { ...basePost, x_caption: "old", tiktok_caption: "old-tik" }, ] as VideoPost[]); const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); render( , ); const xTextarea = await screen.findByDisplayValue("old"); expect(xTextarea).toBeInTheDocument(); // Simulate a 30s refetch producing a re-drafted server caption. listPosts.mockResolvedValueOnce([ { ...basePost, x_caption: "new server text", tiktok_caption: "new-tik" }, ] as VideoPost[]); await client.invalidateQueries({ queryKey: ["video", "posts"] }); await waitFor(() => expect(screen.getByDisplayValue("new server text")).toBeInTheDocument(), ); // CEO types an edit — the derived value should now follow the user. fireEvent.change(screen.getByDisplayValue("new server text"), { target: { value: "my edit" }, }); expect(screen.getByDisplayValue("my edit")).toBeInTheDocument(); // Another refetch with a newer server caption — the user's edit holds. listPosts.mockResolvedValueOnce([ { ...basePost, x_caption: "even newer server text", tiktok_caption: "newer-tik", }, ] as VideoPost[]); await client.invalidateQueries({ queryKey: ["video", "posts"] }); await waitFor(() => expect(screen.getByDisplayValue("my edit")).toBeInTheDocument(), ); expect( screen.queryByDisplayValue("even newer server text"), ).not.toBeInTheDocument(); }); it("fetches the preview clip as a blob via axios and drives