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
32 changes: 32 additions & 0 deletions app/api/content/create/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { createContentHandler } from "@/lib/content/createContentHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns Empty 204 response with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: getCorsHeaders(),
});
}

/**
* POST /api/content/create
*
* Triggers the background content-creation pipeline and returns a run ID.
*
* @param request - Incoming API request.
* @returns Trigger response for the created task run.
*/
Comment on lines +20 to +24
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix JSDoc to reflect runIds array response.

Line 20 and Line 23 describe a singular run ID, but this endpoint returns multiple IDs through the delegated handler. Please align the JSDoc wording to avoid contract confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/content/create/route.ts` around lines 20 - 24, Update the JSDoc in
app/api/content/create/route.ts so it documents that the endpoint returns
multiple run IDs (an array) rather than a singular run ID: change the
description and `@returns` to reference runIds (string[]) and plural wording for
the response from the route/handler (the POST handler in this file) so the
comment matches the actual delegated handler behavior.

export async function POST(request: NextRequest): Promise<NextResponse> {
return createContentHandler(request);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;

32 changes: 32 additions & 0 deletions app/api/content/estimate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { getContentEstimateHandler } from "@/lib/content/getContentEstimateHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns Empty 204 response with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: getCorsHeaders(),
});
}

/**
* GET /api/content/estimate
*
* Returns estimated content-creation costs.
*
* @param request - Incoming API request.
* @returns Cost estimate response.
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
return getContentEstimateHandler(request);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;

32 changes: 32 additions & 0 deletions app/api/content/templates/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { getContentTemplatesHandler } from "@/lib/content/getContentTemplatesHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns Empty 204 response with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: getCorsHeaders(),
});
}

/**
* GET /api/content/templates
*
* Lists available templates for the content-creation pipeline.
*
* @param request - Incoming API request.
* @returns Template list response.
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
return getContentTemplatesHandler(request);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;

32 changes: 32 additions & 0 deletions app/api/content/validate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { getContentValidateHandler } from "@/lib/content/getContentValidateHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns Empty 204 response with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: getCorsHeaders(),
});
}

/**
* GET /api/content/validate
*
* Validates whether an artist is ready for content creation.
*
* @param request - Incoming API request.
* @returns Artist readiness response.
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
return getContentValidateHandler(request);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;

9 changes: 2 additions & 7 deletions lib/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.com";
export const RECOUP_FROM_EMAIL = `Agent by Recoup <agent${OUTBOUND_EMAIL_DOMAIN}>`;

export const SUPABASE_STORAGE_BUCKET = "user-files";
export const CREATE_CONTENT_TASK_ID = "create-content";

/**
* UUID of the Recoup admin organization.
Expand All @@ -41,10 +42,4 @@ export const SNAPSHOT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
// EVALS
export const EVAL_ACCOUNT_ID = "fb678396-a68f-4294-ae50-b8cacf9ce77b";
export const EVAL_ACCESS_TOKEN = process.env.EVAL_ACCESS_TOKEN || "";
export const EVAL_ARTISTS = [
"Gliiico",
"Mac Miller",
"Wiz Khalifa",
"Mod Sun",
"Julius Black",
];
export const EVAL_ARTISTS = ["Gliiico", "Mac Miller", "Wiz Khalifa", "Mod Sun", "Julius Black"];
131 changes: 131 additions & 0 deletions lib/content/__tests__/createContentHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { createContentHandler } from "@/lib/content/createContentHandler";
import { validateCreateContentBody } from "@/lib/content/validateCreateContentBody";
import { triggerCreateContent } from "@/lib/trigger/triggerCreateContent";
import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));

vi.mock("@/lib/content/validateCreateContentBody", () => ({
validateCreateContentBody: vi.fn(),
}));

vi.mock("@/lib/trigger/triggerCreateContent", () => ({
triggerCreateContent: vi.fn(),
}));

vi.mock("@/lib/content/getArtistContentReadiness", () => ({
getArtistContentReadiness: vi.fn(),
}));

vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({
selectAccountSnapshots: vi.fn(),
}));

describe("createContentHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getArtistContentReadiness).mockResolvedValue({
artist_account_id: "art_456",
ready: true,
missing: [],
warnings: [],
githubRepo: "https://github.com/test/repo",
});
});

it("returns validation/auth error when validation fails", async () => {
const errorResponse = NextResponse.json(
{ status: "error", error: "Unauthorized" },
{ status: 401 },
);
vi.mocked(validateCreateContentBody).mockResolvedValue(errorResponse);
const request = new NextRequest("http://localhost/api/content/create", { method: "POST" });

const result = await createContentHandler(request);

expect(result).toBe(errorResponse);
});

it("returns 202 with runIds when trigger succeeds", async () => {
vi.mocked(validateCreateContentBody).mockResolvedValue({
accountId: "acc_123",
artistAccountId: "art_456",
artistSlug: "gatsby-grace",
template: "artist-caption-bedroom",
lipsync: false,
captionLength: "short",
upscale: false,
batch: 1,
});
vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run_abc123" } as never);
const request = new NextRequest("http://localhost/api/content/create", { method: "POST" });

const result = await createContentHandler(request);
const body = await result.json();

expect(result.status).toBe(202);
expect(body.runIds).toEqual(["run_abc123"]);
expect(body.status).toBe("triggered");
expect(body.artist_account_id).toBe("art_456");
});

it("returns 202 with empty runIds and failed count when trigger fails", async () => {
vi.mocked(validateCreateContentBody).mockResolvedValue({
accountId: "acc_123",
artistAccountId: "art_456",
artistSlug: "gatsby-grace",
template: "artist-caption-bedroom",
lipsync: false,
captionLength: "short",
upscale: false,
batch: 1,
});
vi.mocked(triggerCreateContent).mockRejectedValue(new Error("Trigger unavailable"));
const request = new NextRequest("http://localhost/api/content/create", { method: "POST" });

const result = await createContentHandler(request);
const body = await result.json();

expect(result.status).toBe(202);
expect(body.runIds).toEqual([]);
expect(body.failed).toBe(1);
});

it("still triggers when readiness check finds missing files (best-effort)", async () => {
vi.mocked(validateCreateContentBody).mockResolvedValue({
accountId: "acc_123",
artistAccountId: "art_456",
artistSlug: "gatsby-grace",
template: "artist-caption-bedroom",
lipsync: false,
captionLength: "short",
upscale: false,
batch: 1,
});
vi.mocked(getArtistContentReadiness).mockResolvedValue({
artist_account_id: "art_456",
ready: false,
missing: [
{
file: "context/images/face-guide.png",
severity: "required",
fix: "Generate a face guide image before creating content.",
},
],
warnings: [],
});
const request = new NextRequest("http://localhost/api/content/create", { method: "POST" });

const result = await createContentHandler(request);
const body = await result.json();

// Best-effort: validation doesn't block, task handles its own file discovery
expect(result.status).toBe(202);
expect(body.runIds).toBeDefined();
});
});

89 changes: 89 additions & 0 deletions lib/content/__tests__/getArtistContentReadiness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness";
import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots";
import { getRepoFileTree } from "@/lib/github/getRepoFileTree";

vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({
selectAccountSnapshots: vi.fn(),
}));

vi.mock("@/lib/github/getRepoFileTree", () => ({
getRepoFileTree: vi.fn(),
}));

describe("getArtistContentReadiness", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(selectAccountSnapshots).mockResolvedValue([
{
github_repo: "https://github.com/test-org/test-repo",
},
] as never);
});

it("returns ready=true when required files exist", async () => {
vi.mocked(getRepoFileTree).mockResolvedValue([
{ path: "artists/gatsby-grace/context/images/face-guide.png", type: "blob", sha: "1" },
{ path: "artists/gatsby-grace/config/content-creation/config.json", type: "blob", sha: "2" },
{ path: "artists/gatsby-grace/songs/song-a.mp3", type: "blob", sha: "3" },
{ path: "artists/gatsby-grace/context/artist.md", type: "blob", sha: "4" },
{ path: "artists/gatsby-grace/context/audience.md", type: "blob", sha: "5" },
{ path: "artists/gatsby-grace/context/era.json", type: "blob", sha: "6" },
]);

const result = await getArtistContentReadiness({
accountId: "acc_123",
artistAccountId: "art_456",
artistSlug: "gatsby-grace",
});

expect(result.ready).toBe(true);
expect(result.missing).toEqual([]);
});

it("returns ready=false with required issues when core files are missing", async () => {
vi.mocked(getRepoFileTree).mockResolvedValue([
{ path: "artists/gatsby-grace/context/artist.md", type: "blob", sha: "1" },
]);

const result = await getArtistContentReadiness({
accountId: "acc_123",
artistAccountId: "art_456",
artistSlug: "gatsby-grace",
});

expect(result.ready).toBe(false);
expect(result.missing.some(item => item.file === "context/images/face-guide.png")).toBe(true);
expect(result.missing.some(item => item.file === "songs/*.mp3")).toBe(true);
});

it("returns ready=true when only face-guide and mp3 exist (config.json is optional)", async () => {
vi.mocked(getRepoFileTree).mockResolvedValue([
{ path: "artists/gatsby-grace/context/images/face-guide.png", type: "blob", sha: "1" },
{ path: "artists/gatsby-grace/songs/track.mp3", type: "blob", sha: "2" },
]);

const result = await getArtistContentReadiness({
accountId: "acc_123",
artistAccountId: "art_456",
artistSlug: "gatsby-grace",
});

expect(result.ready).toBe(true);
expect(result.missing).toEqual([]);
// config.json appears as a warning, not a blocker
expect(result.warnings.some(item => item.file === "config/content-creation/config.json")).toBe(true);
});

it("throws when account has no github repo", async () => {
vi.mocked(selectAccountSnapshots).mockResolvedValue([] as never);

await expect(
getArtistContentReadiness({
accountId: "acc_123",
artistSlug: "gatsby-grace",
}),
).rejects.toThrow("No GitHub repository configured for this account");
});
});

Loading
Loading