Skip to content

Commit ea27f6f

Browse files
authored
Merge pull request #292 from recoupable/test
merge: test → main
2 parents 693df79 + f1d9035 commit ea27f6f

36 files changed

+1782
-56
lines changed

app/api/content/create/route.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
3+
import { createContentHandler } from "@/lib/content/createContentHandler";
4+
5+
/**
6+
* OPTIONS handler for CORS preflight requests.
7+
*
8+
* @returns Empty 204 response with CORS headers.
9+
*/
10+
export async function OPTIONS() {
11+
return new NextResponse(null, {
12+
status: 204,
13+
headers: getCorsHeaders(),
14+
});
15+
}
16+
17+
/**
18+
* POST /api/content/create
19+
*
20+
* Triggers the background content-creation pipeline and returns a run ID.
21+
*
22+
* @param request - Incoming API request.
23+
* @returns Trigger response for the created task run.
24+
*/
25+
export async function POST(request: NextRequest): Promise<NextResponse> {
26+
return createContentHandler(request);
27+
}
28+
29+
export const dynamic = "force-dynamic";
30+
export const fetchCache = "force-no-store";
31+
export const revalidate = 0;
32+

app/api/content/estimate/route.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
3+
import { getContentEstimateHandler } from "@/lib/content/getContentEstimateHandler";
4+
5+
/**
6+
* OPTIONS handler for CORS preflight requests.
7+
*
8+
* @returns Empty 204 response with CORS headers.
9+
*/
10+
export async function OPTIONS() {
11+
return new NextResponse(null, {
12+
status: 204,
13+
headers: getCorsHeaders(),
14+
});
15+
}
16+
17+
/**
18+
* GET /api/content/estimate
19+
*
20+
* Returns estimated content-creation costs.
21+
*
22+
* @param request - Incoming API request.
23+
* @returns Cost estimate response.
24+
*/
25+
export async function GET(request: NextRequest): Promise<NextResponse> {
26+
return getContentEstimateHandler(request);
27+
}
28+
29+
export const dynamic = "force-dynamic";
30+
export const fetchCache = "force-no-store";
31+
export const revalidate = 0;
32+

app/api/content/templates/route.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
3+
import { getContentTemplatesHandler } from "@/lib/content/getContentTemplatesHandler";
4+
5+
/**
6+
* OPTIONS handler for CORS preflight requests.
7+
*
8+
* @returns Empty 204 response with CORS headers.
9+
*/
10+
export async function OPTIONS() {
11+
return new NextResponse(null, {
12+
status: 204,
13+
headers: getCorsHeaders(),
14+
});
15+
}
16+
17+
/**
18+
* GET /api/content/templates
19+
*
20+
* Lists available templates for the content-creation pipeline.
21+
*
22+
* @param request - Incoming API request.
23+
* @returns Template list response.
24+
*/
25+
export async function GET(request: NextRequest): Promise<NextResponse> {
26+
return getContentTemplatesHandler(request);
27+
}
28+
29+
export const dynamic = "force-dynamic";
30+
export const fetchCache = "force-no-store";
31+
export const revalidate = 0;
32+

app/api/content/validate/route.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
3+
import { getContentValidateHandler } from "@/lib/content/getContentValidateHandler";
4+
5+
/**
6+
* OPTIONS handler for CORS preflight requests.
7+
*
8+
* @returns Empty 204 response with CORS headers.
9+
*/
10+
export async function OPTIONS() {
11+
return new NextResponse(null, {
12+
status: 204,
13+
headers: getCorsHeaders(),
14+
});
15+
}
16+
17+
/**
18+
* GET /api/content/validate
19+
*
20+
* Validates whether an artist is ready for content creation.
21+
*
22+
* @param request - Incoming API request.
23+
* @returns Artist readiness response.
24+
*/
25+
export async function GET(request: NextRequest): Promise<NextResponse> {
26+
return getContentValidateHandler(request);
27+
}
28+
29+
export const dynamic = "force-dynamic";
30+
export const fetchCache = "force-no-store";
31+
export const revalidate = 0;
32+

lib/const.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.com";
2222
export const RECOUP_FROM_EMAIL = `Agent by Recoup <agent${OUTBOUND_EMAIL_DOMAIN}>`;
2323

2424
export const SUPABASE_STORAGE_BUCKET = "user-files";
25+
export const CREATE_CONTENT_TASK_ID = "create-content";
2526

2627
/**
2728
* UUID of the Recoup admin organization.
@@ -41,10 +42,4 @@ export const SNAPSHOT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
4142
// EVALS
4243
export const EVAL_ACCOUNT_ID = "fb678396-a68f-4294-ae50-b8cacf9ce77b";
4344
export const EVAL_ACCESS_TOKEN = process.env.EVAL_ACCESS_TOKEN || "";
44-
export const EVAL_ARTISTS = [
45-
"Gliiico",
46-
"Mac Miller",
47-
"Wiz Khalifa",
48-
"Mod Sun",
49-
"Julius Black",
50-
];
45+
export const EVAL_ARTISTS = ["Gliiico", "Mac Miller", "Wiz Khalifa", "Mod Sun", "Julius Black"];
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { NextRequest, NextResponse } from "next/server";
3+
import { createContentHandler } from "@/lib/content/createContentHandler";
4+
import { validateCreateContentBody } from "@/lib/content/validateCreateContentBody";
5+
import { triggerCreateContent } from "@/lib/trigger/triggerCreateContent";
6+
import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness";
7+
8+
vi.mock("@/lib/networking/getCorsHeaders", () => ({
9+
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
10+
}));
11+
12+
vi.mock("@/lib/content/validateCreateContentBody", () => ({
13+
validateCreateContentBody: vi.fn(),
14+
}));
15+
16+
vi.mock("@/lib/trigger/triggerCreateContent", () => ({
17+
triggerCreateContent: vi.fn(),
18+
}));
19+
20+
vi.mock("@/lib/content/getArtistContentReadiness", () => ({
21+
getArtistContentReadiness: vi.fn(),
22+
}));
23+
24+
vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({
25+
selectAccountSnapshots: vi.fn(),
26+
}));
27+
28+
describe("createContentHandler", () => {
29+
beforeEach(() => {
30+
vi.clearAllMocks();
31+
vi.mocked(getArtistContentReadiness).mockResolvedValue({
32+
artist_account_id: "art_456",
33+
ready: true,
34+
missing: [],
35+
warnings: [],
36+
githubRepo: "https://github.com/test/repo",
37+
});
38+
});
39+
40+
it("returns validation/auth error when validation fails", async () => {
41+
const errorResponse = NextResponse.json(
42+
{ status: "error", error: "Unauthorized" },
43+
{ status: 401 },
44+
);
45+
vi.mocked(validateCreateContentBody).mockResolvedValue(errorResponse);
46+
const request = new NextRequest("http://localhost/api/content/create", { method: "POST" });
47+
48+
const result = await createContentHandler(request);
49+
50+
expect(result).toBe(errorResponse);
51+
});
52+
53+
it("returns 202 with runIds when trigger succeeds", async () => {
54+
vi.mocked(validateCreateContentBody).mockResolvedValue({
55+
accountId: "acc_123",
56+
artistAccountId: "art_456",
57+
artistSlug: "gatsby-grace",
58+
template: "artist-caption-bedroom",
59+
lipsync: false,
60+
captionLength: "short",
61+
upscale: false,
62+
batch: 1,
63+
});
64+
vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run_abc123" } as never);
65+
const request = new NextRequest("http://localhost/api/content/create", { method: "POST" });
66+
67+
const result = await createContentHandler(request);
68+
const body = await result.json();
69+
70+
expect(result.status).toBe(202);
71+
expect(body.runIds).toEqual(["run_abc123"]);
72+
expect(body.status).toBe("triggered");
73+
expect(body.artist_account_id).toBe("art_456");
74+
});
75+
76+
it("returns 202 with empty runIds and failed count when trigger fails", async () => {
77+
vi.mocked(validateCreateContentBody).mockResolvedValue({
78+
accountId: "acc_123",
79+
artistAccountId: "art_456",
80+
artistSlug: "gatsby-grace",
81+
template: "artist-caption-bedroom",
82+
lipsync: false,
83+
captionLength: "short",
84+
upscale: false,
85+
batch: 1,
86+
});
87+
vi.mocked(triggerCreateContent).mockRejectedValue(new Error("Trigger unavailable"));
88+
const request = new NextRequest("http://localhost/api/content/create", { method: "POST" });
89+
90+
const result = await createContentHandler(request);
91+
const body = await result.json();
92+
93+
expect(result.status).toBe(202);
94+
expect(body.runIds).toEqual([]);
95+
expect(body.failed).toBe(1);
96+
});
97+
98+
it("still triggers when readiness check finds missing files (best-effort)", async () => {
99+
vi.mocked(validateCreateContentBody).mockResolvedValue({
100+
accountId: "acc_123",
101+
artistAccountId: "art_456",
102+
artistSlug: "gatsby-grace",
103+
template: "artist-caption-bedroom",
104+
lipsync: false,
105+
captionLength: "short",
106+
upscale: false,
107+
batch: 1,
108+
});
109+
vi.mocked(getArtistContentReadiness).mockResolvedValue({
110+
artist_account_id: "art_456",
111+
ready: false,
112+
missing: [
113+
{
114+
file: "context/images/face-guide.png",
115+
severity: "required",
116+
fix: "Generate a face guide image before creating content.",
117+
},
118+
],
119+
warnings: [],
120+
});
121+
const request = new NextRequest("http://localhost/api/content/create", { method: "POST" });
122+
123+
const result = await createContentHandler(request);
124+
const body = await result.json();
125+
126+
// Best-effort: validation doesn't block, task handles its own file discovery
127+
expect(result.status).toBe(202);
128+
expect(body.runIds).toBeDefined();
129+
});
130+
});
131+
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness";
3+
import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots";
4+
import { getRepoFileTree } from "@/lib/github/getRepoFileTree";
5+
6+
vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({
7+
selectAccountSnapshots: vi.fn(),
8+
}));
9+
10+
vi.mock("@/lib/github/getRepoFileTree", () => ({
11+
getRepoFileTree: vi.fn(),
12+
}));
13+
14+
describe("getArtistContentReadiness", () => {
15+
beforeEach(() => {
16+
vi.clearAllMocks();
17+
vi.mocked(selectAccountSnapshots).mockResolvedValue([
18+
{
19+
github_repo: "https://github.com/test-org/test-repo",
20+
},
21+
] as never);
22+
});
23+
24+
it("returns ready=true when required files exist", async () => {
25+
vi.mocked(getRepoFileTree).mockResolvedValue([
26+
{ path: "artists/gatsby-grace/context/images/face-guide.png", type: "blob", sha: "1" },
27+
{ path: "artists/gatsby-grace/config/content-creation/config.json", type: "blob", sha: "2" },
28+
{ path: "artists/gatsby-grace/songs/song-a.mp3", type: "blob", sha: "3" },
29+
{ path: "artists/gatsby-grace/context/artist.md", type: "blob", sha: "4" },
30+
{ path: "artists/gatsby-grace/context/audience.md", type: "blob", sha: "5" },
31+
{ path: "artists/gatsby-grace/context/era.json", type: "blob", sha: "6" },
32+
]);
33+
34+
const result = await getArtistContentReadiness({
35+
accountId: "acc_123",
36+
artistAccountId: "art_456",
37+
artistSlug: "gatsby-grace",
38+
});
39+
40+
expect(result.ready).toBe(true);
41+
expect(result.missing).toEqual([]);
42+
});
43+
44+
it("returns ready=false with required issues when core files are missing", async () => {
45+
vi.mocked(getRepoFileTree).mockResolvedValue([
46+
{ path: "artists/gatsby-grace/context/artist.md", type: "blob", sha: "1" },
47+
]);
48+
49+
const result = await getArtistContentReadiness({
50+
accountId: "acc_123",
51+
artistAccountId: "art_456",
52+
artistSlug: "gatsby-grace",
53+
});
54+
55+
expect(result.ready).toBe(false);
56+
expect(result.missing.some(item => item.file === "context/images/face-guide.png")).toBe(true);
57+
expect(result.missing.some(item => item.file === "songs/*.mp3")).toBe(true);
58+
});
59+
60+
it("returns ready=true when only face-guide and mp3 exist (config.json is optional)", async () => {
61+
vi.mocked(getRepoFileTree).mockResolvedValue([
62+
{ path: "artists/gatsby-grace/context/images/face-guide.png", type: "blob", sha: "1" },
63+
{ path: "artists/gatsby-grace/songs/track.mp3", type: "blob", sha: "2" },
64+
]);
65+
66+
const result = await getArtistContentReadiness({
67+
accountId: "acc_123",
68+
artistAccountId: "art_456",
69+
artistSlug: "gatsby-grace",
70+
});
71+
72+
expect(result.ready).toBe(true);
73+
expect(result.missing).toEqual([]);
74+
// config.json appears as a warning, not a blocker
75+
expect(result.warnings.some(item => item.file === "config/content-creation/config.json")).toBe(true);
76+
});
77+
78+
it("throws when account has no github repo", async () => {
79+
vi.mocked(selectAccountSnapshots).mockResolvedValue([] as never);
80+
81+
await expect(
82+
getArtistContentReadiness({
83+
accountId: "acc_123",
84+
artistSlug: "gatsby-grace",
85+
}),
86+
).rejects.toThrow("No GitHub repository configured for this account");
87+
});
88+
});
89+

0 commit comments

Comments
 (0)