Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions __tests__/commands/content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("../../src/client.js", () => ({
get: vi.fn(),
post: vi.fn(),
}));

import { contentCommand } from "../../src/commands/content.js";
import { get, post } from "../../src/client.js";

let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
});

afterEach(() => {
vi.restoreAllMocks();
});

describe("content command", () => {
it("lists templates", async () => {
vi.mocked(get).mockResolvedValue({
status: "success",
templates: [
{ name: "artist-caption-bedroom", description: "Moody purple bedroom setting" },
],
});

await contentCommand.parseAsync(["templates"], { from: "user" });

expect(get).toHaveBeenCalledWith("/api/content/templates");
expect(logSpy).toHaveBeenCalledWith(
"- artist-caption-bedroom: Moody purple bedroom setting",
);
});

it("validates an artist", async () => {
vi.mocked(get).mockResolvedValue({
status: "success",
artist_account_id: "550e8400-e29b-41d4-a716-446655440000",
ready: true,
missing: [],
});

await contentCommand.parseAsync(["validate", "--artist", "550e8400-e29b-41d4-a716-446655440000"], { from: "user" });

expect(get).toHaveBeenCalledWith("/api/content/validate", {
artist_account_id: "550e8400-e29b-41d4-a716-446655440000",
});
expect(logSpy).toHaveBeenCalledWith("Ready: yes");
});

it("estimates content cost", async () => {
vi.mocked(get).mockResolvedValue({
status: "success",
per_video_estimate_usd: 0.82,
total_estimate_usd: 1.64,
});

await contentCommand.parseAsync(["estimate", "--batch", "2"], { from: "user" });

expect(get).toHaveBeenCalledWith("/api/content/estimate", {
lipsync: "false",
batch: "2",
compare: "false",
});
expect(logSpy).toHaveBeenCalledWith("Per video: $0.82");
});

it("creates content run", async () => {
vi.mocked(post).mockResolvedValue({
runIds: ["run_abc123"],
status: "triggered",
});

await contentCommand.parseAsync(
["create", "--artist", "550e8400-e29b-41d4-a716-446655440000", "--template", "artist-caption-bedroom"],
{ from: "user" },
);

expect(post).toHaveBeenCalledWith("/api/content/create", {
artist_account_id: "550e8400-e29b-41d4-a716-446655440000",
template: "artist-caption-bedroom",
lipsync: false,
caption_length: "short",
upscale: false,
batch: 1,
});
expect(logSpy).toHaveBeenCalledWith(`Run started: run_abc123`);
});

it("shows tasks status hint after create", async () => {
vi.mocked(post).mockResolvedValue({
runIds: ["run_abc123"],
status: "triggered",
});

await contentCommand.parseAsync(
["create", "--artist", "550e8400-e29b-41d4-a716-446655440000"],
{ from: "user" },
);

expect(logSpy).toHaveBeenCalledWith(
"Use `recoup tasks status --run <runId>` to check progress.",
);
});

it("handles non-Error thrown values gracefully", async () => {
vi.mocked(get).mockRejectedValue("plain string error");

await contentCommand.parseAsync(["templates"], { from: "user" });

expect(errorSpy).toHaveBeenCalledWith("Error: plain string error");
expect(exitSpy).toHaveBeenCalledWith(1);
});

it("prints error when API call fails", async () => {
vi.mocked(get).mockRejectedValue(new Error("Request failed"));

await contentCommand.parseAsync(["templates"], { from: "user" });

expect(errorSpy).toHaveBeenCalledWith("Error: Request failed");
expect(exitSpy).toHaveBeenCalledWith(1);
});

it("errors when --batch is not a positive integer", async () => {
await contentCommand.parseAsync(
["create", "--artist", "test-artist", "--batch", "abc"],
{ from: "user" },
);

expect(errorSpy).toHaveBeenCalledWith("Error: --batch must be a positive integer");
expect(exitSpy).toHaveBeenCalledWith(1);
});

it("errors when --caption-length is invalid", async () => {
await contentCommand.parseAsync(
["create", "--artist", "test-artist", "--caption-length", "huge"],
{ from: "user" },
);

expect(errorSpy).toHaveBeenCalledWith(
"Error: --caption-length must be one of: short, medium, long",
);
expect(exitSpy).toHaveBeenCalledWith(1);
});

it("errors when API returns no runIds", async () => {
vi.mocked(post).mockResolvedValue({
status: "error",
});

await contentCommand.parseAsync(
["create", "--artist", "test-artist"],
{ from: "user" },
);

expect(errorSpy).toHaveBeenCalledWith(
"Error: Response did not include any run IDs",
);
expect(exitSpy).toHaveBeenCalledWith(1);
});

it("creates content run with batch flag and shows batch output", async () => {
vi.mocked(post).mockResolvedValue({
runIds: ["run_1", "run_2", "run_3"],
status: "triggered",
});

await contentCommand.parseAsync(
["create", "--artist", "test-artist", "--caption-length", "long", "--upscale", "--batch", "3"],
{ from: "user" },
);

expect(post).toHaveBeenCalledWith("/api/content/create", {
artist_account_id: "test-artist",
template: "artist-caption-bedroom",
lipsync: false,
caption_length: "long",
upscale: true,
batch: 3,
});
expect(logSpy).toHaveBeenCalledWith("Batch started: 3 videos");
});
});

94 changes: 94 additions & 0 deletions __tests__/commands/tasks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("../../src/client.js", () => ({
get: vi.fn(),
post: vi.fn(),
}));

import { tasksCommand } from "../../src/commands/tasks.js";
import { get } from "../../src/client.js";

let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
});

afterEach(() => {
vi.restoreAllMocks();
});

describe("tasks command", () => {
it("shows run status", async () => {
vi.mocked(get).mockResolvedValue({
status: "success",
runs: [
{
id: "run_abc123",
status: "COMPLETED",
output: {},
},
],
});

await tasksCommand.parseAsync(["status", "--run", "run_abc123"], { from: "user" });

expect(get).toHaveBeenCalledWith("/api/tasks/runs", { runId: "run_abc123" });
expect(logSpy).toHaveBeenCalledWith("Run: run_abc123");
expect(logSpy).toHaveBeenCalledWith("Status: COMPLETED");
});

it("shows video URL when available", async () => {
vi.mocked(get).mockResolvedValue({
status: "success",
runs: [
{
id: "run_abc123",
status: "COMPLETED",
output: {
video: {
signedUrl: "https://example.com/video.mp4",
},
},
},
],
});

await tasksCommand.parseAsync(["status", "--run", "run_abc123"], { from: "user" });

expect(logSpy).toHaveBeenCalledWith("Video URL: https://example.com/video.mp4");
});

it("shows run not found", async () => {
vi.mocked(get).mockResolvedValue({
status: "success",
runs: [],
});

await tasksCommand.parseAsync(["status", "--run", "run_missing"], { from: "user" });

expect(logSpy).toHaveBeenCalledWith("Run not found.");
});

it("prints error when API call fails", async () => {
vi.mocked(get).mockRejectedValue(new Error("Request failed"));

await tasksCommand.parseAsync(["status", "--run", "run_abc123"], { from: "user" });

expect(errorSpy).toHaveBeenCalledWith("Error: Request failed");
expect(exitSpy).toHaveBeenCalledWith(1);
});

it("handles non-Error thrown values gracefully", async () => {
vi.mocked(get).mockRejectedValue("plain string error");

await tasksCommand.parseAsync(["status", "--run", "run_abc123"], { from: "user" });

expect(errorSpy).toHaveBeenCalledWith("Error: plain string error");
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
4 changes: 4 additions & 0 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { sandboxesCommand } from "./commands/sandboxes.js";
import { songsCommand } from "./commands/songs.js";
import { notificationsCommand } from "./commands/notifications.js";
import { orgsCommand } from "./commands/orgs.js";
import { contentCommand } from "./commands/content.js";
import { tasksCommand } from "./commands/tasks.js";

const pkgPath = join(__dirname, "..", "package.json");
const { version } = JSON.parse(readFileSync(pkgPath, "utf-8"));
Expand All @@ -26,5 +28,7 @@ program.addCommand(songsCommand);
program.addCommand(notificationsCommand);
program.addCommand(sandboxesCommand);
program.addCommand(orgsCommand);
program.addCommand(tasksCommand);
program.addCommand(contentCommand);

program.parse();
13 changes: 13 additions & 0 deletions src/commands/content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Command } from "commander";
import { templatesCommand } from "./content/templatesCommand.js";
import { validateCommand } from "./content/validateCommand.js";
import { estimateCommand } from "./content/estimateCommand.js";
import { createCommand } from "./content/createCommand.js";

export const contentCommand = new Command("content")
.description("Content-creation pipeline commands");

contentCommand.addCommand(templatesCommand);
contentCommand.addCommand(validateCommand);
contentCommand.addCommand(estimateCommand);
contentCommand.addCommand(createCommand);
58 changes: 58 additions & 0 deletions src/commands/content/createCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Command } from "commander";
import { post } from "../../client.js";
import { getErrorMessage } from "../../getErrorMessage.js";
import { printError, printJson } from "../../output.js";
import { parsePositiveInt } from "./parsePositiveInt.js";

const ALLOWED_CAPTION_LENGTHS = new Set(["short", "medium", "long"]);

export const createCommand = new Command("create")
.description("Trigger content creation pipeline")
.requiredOption("--artist <id>", "Artist account ID")
.option("--template <name>", "Template name", "artist-caption-bedroom")
.option("--lipsync", "Enable lipsync mode")
.option("--caption-length <length>", "Caption length: short, medium, long", "short")
.option("--upscale", "Upscale image and video for higher quality")
.option("--batch <count>", "Generate multiple videos in parallel", "1")
.option("--json", "Output as JSON")
.action(async opts => {
try {
const batch = parsePositiveInt(String(opts.batch ?? "1"), "--batch");
if (!ALLOWED_CAPTION_LENGTHS.has(opts.captionLength)) {
throw new Error("--caption-length must be one of: short, medium, long");
}

const data = await post("/api/content/create", {
artist_account_id: opts.artist,
template: opts.template,
lipsync: !!opts.lipsync,
caption_length: opts.captionLength,
upscale: !!opts.upscale,
batch,
});

if (opts.json) {
printJson(data);
return;
}

const runIds = Array.isArray(data.runIds)
? data.runIds.filter((id): id is string => typeof id === "string")
: [];
if (runIds.length === 0) {
throw new Error("Response did not include any run IDs");
}

if (runIds.length === 1) {
console.log(`Run started: ${runIds[0]}`);
} else {
console.log(`Batch started: ${runIds.length} videos`);
for (const id of runIds) {
console.log(` - ${id}`);
}
}
console.log("Use `recoup tasks status --run <runId>` to check progress.");
} catch (err) {
printError(getErrorMessage(err));
}
});
Loading
Loading