Skip to content
Open
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
120 changes: 120 additions & 0 deletions lib/tasks/__tests__/createTaskHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 8, 2026

Choose a reason for hiding this comment

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

P2: Custom agent: Enforce Clear Code Style and Maintainability Practices

Newly added test file exceeds the rule’s 100-line maximum (120 lines), violating the maintainability size limit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/tasks/__tests__/createTaskHandler.test.ts, line 1:

<comment>Newly added test file exceeds the rule’s 100-line maximum (120 lines), violating the maintainability size limit.</comment>

<file context>
@@ -0,0 +1,120 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { NextRequest, NextResponse } from "next/server";
+import { createTaskHandler } from "@/lib/tasks/createTaskHandler";
</file context>
Fix with Cubic

import { NextRequest, NextResponse } from "next/server";
import { createTaskHandler } from "@/lib/tasks/createTaskHandler";
import { validateCreateTaskRequest } from "@/lib/tasks/validateCreateTaskBody";
import { createTask } from "@/lib/tasks/createTask";

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

vi.mock("@/lib/tasks/validateCreateTaskBody", () => ({
validateCreateTaskRequest: vi.fn(),
}));

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

const ACCOUNT_A = "123e4567-e89b-12d3-a456-426614174000";
const ARTIST_ID = "323e4567-e89b-12d3-a456-426614174000";

function validValidatedBody() {
return {
title: "Daily report",
prompt: "Summarize fans",
schedule: "0 9 * * *",
account_id: ACCOUNT_A,
artist_account_id: ARTIST_ID,
};
}

describe("createTaskHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => undefined);
});

afterEach(() => {
vi.mocked(console.error).mockRestore();
});

it("returns validation/auth response from validateCreateTaskRequest unchanged", async () => {
const err = NextResponse.json({ status: "error", error: "nope" }, { status: 403 });
vi.mocked(validateCreateTaskRequest).mockResolvedValue(err);

const request = new NextRequest("http://localhost/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": "k" },
body: JSON.stringify({}),
});

const res = await createTaskHandler(request);

expect(res).toBe(err);
expect(vi.mocked(createTask)).not.toHaveBeenCalled();
});

it("returns 200 and created task when validateCreateTaskRequest succeeds", async () => {
const validated = validValidatedBody();
vi.mocked(validateCreateTaskRequest).mockResolvedValue(validated);
const created = {
id: "sched-1",
...validated,
} as Awaited<ReturnType<typeof createTask>>;
vi.mocked(createTask).mockResolvedValue(created);

const request = new NextRequest("http://localhost/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": "k" },
body: JSON.stringify({}),
});

const res = await createTaskHandler(request);

expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
status: "success",
tasks: [created],
});
expect(createTask).toHaveBeenCalledWith(validated);
});

it("returns 500 when createTask throws", async () => {
vi.mocked(validateCreateTaskRequest).mockResolvedValue(validValidatedBody());
vi.mocked(createTask).mockRejectedValue(new Error("Trigger failure"));

const request = new NextRequest("http://localhost/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": "k" },
body: "{}",
});

const res = await createTaskHandler(request);

expect(res.status).toBe(500);
await expect(res.json()).resolves.toMatchObject({
status: "error",
error: "Trigger failure",
});
});

it("returns 500 when createTask throws non-Error", async () => {
vi.mocked(validateCreateTaskRequest).mockResolvedValue(validValidatedBody());
vi.mocked(createTask).mockRejectedValue("boom");

const request = new NextRequest("http://localhost/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": "k" },
body: "{}",
});

const res = await createTaskHandler(request);

expect(res.status).toBe(500);
await expect(res.json()).resolves.toMatchObject({
status: "error",
error: "Internal server error",
});
});
});
257 changes: 257 additions & 0 deletions lib/tasks/__tests__/validateCreateTaskRequest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 8, 2026

Choose a reason for hiding this comment

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

P2: Custom agent: Enforce Clear Code Style and Maintainability Practices

New test module exceeds the Rule 3 file-length limit (<100 lines), reducing readability and maintainability.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/tasks/__tests__/validateCreateTaskRequest.test.ts, line 1:

<comment>New test module exceeds the Rule 3 file-length limit (<100 lines), reducing readability and maintainability.</comment>

<file context>
@@ -0,0 +1,257 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest, NextResponse } from "next/server";
+import { validateCreateTaskRequest } from "@/lib/tasks/validateCreateTaskBody";
</file context>
Fix with Cubic

import { NextRequest, NextResponse } from "next/server";
import { validateCreateTaskRequest } from "@/lib/tasks/validateCreateTaskBody";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";

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

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

const ACCOUNT_A = "123e4567-e89b-12d3-a456-426614174000";
const ACCOUNT_B = "223e4567-e89b-12d3-a456-426614174000";
const ARTIST_ID = "323e4567-e89b-12d3-a456-426614174000";

function validCreateBody(overrides: Record<string, unknown> = {}) {
return {
title: "Daily report",
prompt: "Summarize fans",
schedule: "0 9 * * *",
account_id: ACCOUNT_A,
artist_account_id: ARTIST_ID,
...overrides,
};
}

describe("validateCreateTaskRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("returns 400 when JSON body is invalid", async () => {
const request = new NextRequest("http://localhost/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": "test-key",
},
body: "not-json{",
});

const res = await validateCreateTaskRequest(request);

expect(res).toBeInstanceOf(NextResponse);
expect((res as NextResponse).status).toBe(400);
await expect((res as NextResponse).json()).resolves.toMatchObject({
status: "error",
error: "Invalid JSON body",
});
expect(vi.mocked(validateAuthContext)).not.toHaveBeenCalled();
});

it("returns 400 when body fails Zod validation (empty title)", async () => {
const request = new NextRequest("http://localhost/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": "test-key",
},
body: JSON.stringify({ title: "" }),
});

const res = await validateCreateTaskRequest(request);

expect(res).toBeInstanceOf(NextResponse);
expect((res as NextResponse).status).toBe(400);
expect(vi.mocked(validateAuthContext)).not.toHaveBeenCalled();
});

it("returns 400 when required fields are missing", async () => {
const request = new NextRequest("http://localhost/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": "test-key",
},
body: JSON.stringify({ title: "x" }),
});

const res = await validateCreateTaskRequest(request);

expect(res).toBeInstanceOf(NextResponse);
expect((res as NextResponse).status).toBe(400);
expect(vi.mocked(validateAuthContext)).not.toHaveBeenCalled();
});

it("calls validateAuthContext with body account_id after Zod passes", async () => {
const body = validCreateBody();
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: ACCOUNT_A,
orgId: null,
authToken: "token",
});

const request = new NextRequest("http://localhost/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": "test-key",
},
body: JSON.stringify(body),
});

await validateCreateTaskRequest(request);

expect(validateAuthContext).toHaveBeenCalledTimes(1);
expect(validateAuthContext).toHaveBeenCalledWith(request, {
accountId: ACCOUNT_A,
});
});

it("returns 401 when validateAuthContext returns 401", async () => {
const authError = NextResponse.json(
{
status: "error",
error: "Exactly one of x-api-key or Authorization must be provided",
},
{ status: 401 },
);
vi.mocked(validateAuthContext).mockResolvedValue(authError);

const request = new NextRequest("http://localhost/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": "test-key",
},
body: JSON.stringify(validCreateBody()),
});

const res = await validateCreateTaskRequest(request);

expect(res).toBe(authError);
expect((res as NextResponse).status).toBe(401);
});

it("returns 403 when validateAuthContext returns 403", async () => {
const forbidden = NextResponse.json(
{ status: "error", error: "Access denied to specified account_id" },
{ status: 403 },
);
vi.mocked(validateAuthContext).mockResolvedValue(forbidden);

const request = new NextRequest("http://localhost/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer test.jwt",
},
body: JSON.stringify(validCreateBody({ account_id: ACCOUNT_B })),
});

const res = await validateCreateTaskRequest(request);

expect(res).toBe(forbidden);
expect((res as NextResponse).status).toBe(403);
});

it("returns CreateTaskBody with resolved auth account_id on success", async () => {
const body = validCreateBody({ account_id: ACCOUNT_A });
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: ACCOUNT_A,
orgId: null,
authToken: "key",
});

const request = new NextRequest("http://localhost/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": "test-key",
},
body: JSON.stringify(body),
});

const res = await validateCreateTaskRequest(request);

expect(res).not.toBeInstanceOf(NextResponse);
expect(res).toEqual({
...body,
account_id: ACCOUNT_A,
});
});

it("returns CreateTaskBody with org-resolved account_id", async () => {
const body = validCreateBody({ account_id: ACCOUNT_B });
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: ACCOUNT_B,
orgId: "org-1",
authToken: "key",
});

const request = new NextRequest("http://localhost/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": "test-key",
},
body: JSON.stringify(body),
});

const res = await validateCreateTaskRequest(request);

expect(res).toEqual({
...body,
account_id: ACCOUNT_B,
});
});

it("does not call auth when account_id fails validation (empty string)", async () => {
const request = new NextRequest("http://localhost/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": "test-key",
},
body: JSON.stringify(
validCreateBody({
account_id: "",
}),
),
});

const res = await validateCreateTaskRequest(request);

expect(res).toBeInstanceOf(NextResponse);
expect((res as NextResponse).status).toBe(400);
expect(vi.mocked(validateAuthContext)).not.toHaveBeenCalled();
});

it("preserves optional model in returned body", async () => {
const body = validCreateBody({ model: "anthropic/claude-sonnet-4.5" });
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: ACCOUNT_A,
orgId: null,
authToken: "key",
});

const request = new NextRequest("http://localhost/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": "test-key",
},
body: JSON.stringify(body),
});

const res = await validateCreateTaskRequest(request);

expect(res).toMatchObject({
model: "anthropic/claude-sonnet-4.5",
account_id: ACCOUNT_A,
});
});
});
6 changes: 2 additions & 4 deletions lib/tasks/createTaskHandler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateCreateTaskBody } from "@/lib/tasks/validateCreateTaskBody";
import { validateCreateTaskRequest } from "@/lib/tasks/validateCreateTaskBody";
import { createTask } from "@/lib/tasks/createTask";

/**
Expand All @@ -20,9 +20,7 @@ import { createTask } from "@/lib/tasks/createTask";
*/
export async function createTaskHandler(request: NextRequest): Promise<NextResponse> {
try {
const body = await request.json();

const validatedBody = validateCreateTaskBody(body);
const validatedBody = await validateCreateTaskRequest(request);
if (validatedBody instanceof NextResponse) {
return validatedBody;
}
Expand Down
Loading
Loading