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
136 changes: 136 additions & 0 deletions lib/tasks/__tests__/createTask.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { beforeEach, describe, expect, it, vi } 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.

P1: Custom agent: Code Structure and Size Limits for Readability and Single Responsibility

This test file is 136 lines, exceeding the 100-line limit. Split the error-case tests (HTTP error, API error status, empty tasks) into a separate file like createTask.errors.test.ts to keep each file under 100 lines.

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

<comment>This test file is 136 lines, exceeding the 100-line limit. Split the error-case tests (HTTP error, API error status, empty tasks) into a separate file like `createTask.errors.test.ts` to keep each file under 100 lines.</comment>

<file context>
@@ -0,0 +1,136 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createTask } from "@/lib/tasks/createTask";
+import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl";
</file context>
Fix with Cubic

import { createTask } from "@/lib/tasks/createTask";
import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl";

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

describe("createTask", () => {
const accessToken = "test-token";

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getClientApiBaseUrl).mockReturnValue("https://api.recoupable.com");
});

it("calls POST /api/tasks with bearer auth and required payload", async () => {
const createdTask = { id: "task-1" };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
status: "success",
tasks: [createdTask],
}),
}) as unknown as typeof fetch;

const result = await createTask(accessToken, {
title: "Daily summary",
prompt: "Summarize fan growth",
schedule: "0 9 * * *",
artist_account_id: "artist-1",
});

expect(fetch).toHaveBeenCalledWith("https://api.recoupable.com/api/tasks", {
method: "POST",
headers: {
Authorization: "Bearer test-token",
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "Daily summary",
prompt: "Summarize fan growth",
schedule: "0 9 * * *",
artist_account_id: "artist-1",
}),
});
expect(result).toEqual(createdTask);
});

it("includes optional account_id and model when provided", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
status: "success",
tasks: [{ id: "task-2" }],
}),
}) as unknown as typeof fetch;

await createTask(accessToken, {
title: "Weekly sync",
prompt: "Generate weekly report",
schedule: "0 9 * * 1",
artist_account_id: "artist-2",
account_id: "account-2",
model: "anthropic/claude-sonnet-4.5",
});

const call = vi.mocked(fetch).mock.calls[0];
const init = call[1] as RequestInit;
expect(init.body).toBe(
JSON.stringify({
title: "Weekly sync",
prompt: "Generate weekly report",
schedule: "0 9 * * 1",
artist_account_id: "artist-2",
account_id: "account-2",
model: "anthropic/claude-sonnet-4.5",
}),
);
});

it("throws on non-ok HTTP response", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
text: vi.fn().mockResolvedValue('{"status":"error","error":"Access denied"}'),
}) as unknown as typeof fetch;

await expect(
createTask(accessToken, {
title: "Denied",
prompt: "Denied",
schedule: "0 9 * * *",
artist_account_id: "artist-3",
}),
).rejects.toThrow('HTTP 403: {"status":"error","error":"Access denied"}');
});

it("throws when API returns status:error", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
status: "error",
error: "Validation failed",
}),
}) as unknown as typeof fetch;

await expect(
createTask(accessToken, {
title: "Invalid",
prompt: "Invalid",
schedule: "0 9 * * *",
artist_account_id: "artist-4",
}),
).rejects.toThrow("Validation failed");
});

it("throws when success response has no created task", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
status: "success",
tasks: [],
}),
}) as unknown as typeof fetch;

await expect(
createTask(accessToken, {
title: "No task",
prompt: "No task",
schedule: "0 9 * * *",
artist_account_id: "artist-5",
}),
).rejects.toThrow("API returned success but no task was created");
});
});
55 changes: 55 additions & 0 deletions lib/tasks/createTask.ts
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

YAGNI principle - Where is this lib being used?

Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Tables } from "@/types/database.types";
import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl";
import { GetTasksResponse } from "./getTasks";

type ScheduledAction = Tables<"scheduled_actions">;

export interface CreateTaskParams {
title: string;
prompt: string;
schedule: string;
artist_account_id: string;
account_id?: string;
model?: string | null;
}

/**
* Creates a new scheduled task via the Recoup API.
* Requires a valid bearer token because POST /api/tasks is auth-enforced.
*/
export async function createTask(
accessToken: string,
params: CreateTaskParams,
): Promise<ScheduledAction> {
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: Return type should be Task instead of ScheduledAction to match the parsed response type (GetTasksResponse.tasks is Task[]) and stay consistent with getTasks, which returns Task[]. The current ScheduledAction return type discards the optional recent_runs and upcoming fields, which callers can't access without casting.

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

<comment>Return type should be `Task` instead of `ScheduledAction` to match the parsed response type (`GetTasksResponse.tasks` is `Task[]`) and stay consistent with `getTasks`, which returns `Task[]`. The current `ScheduledAction` return type discards the optional `recent_runs` and `upcoming` fields, which callers can't access without casting.</comment>

<file context>
@@ -0,0 +1,55 @@
+export async function createTask(
+  accessToken: string,
+  params: CreateTaskParams,
+): Promise<ScheduledAction> {
+  const response = await fetch(`${getClientApiBaseUrl()}/api/tasks`, {
+    method: "POST",
</file context>
Fix with Cubic

const response = await fetch(`${getClientApiBaseUrl()}/api/tasks`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: params.title,
prompt: params.prompt,
schedule: params.schedule,
artist_account_id: params.artist_account_id,
...(params.account_id ? { account_id: params.account_id } : {}),
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.

P3: Inconsistent conditional patterns for optional parameters: account_id uses a truthiness check (excluding undefined and "") while model uses !== undefined (allowing null and ""). If both should follow the same exclusion logic, align them. If the difference is intentional (e.g., model deliberately allows null to clear the field), add a brief comment to make that intent explicit.

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

<comment>Inconsistent conditional patterns for optional parameters: `account_id` uses a truthiness check (excluding `undefined` and `""`) while `model` uses `!== undefined` (allowing `null` and `""`). If both should follow the same exclusion logic, align them. If the difference is intentional (e.g., `model` deliberately allows `null` to clear the field), add a brief comment to make that intent explicit.</comment>

<file context>
@@ -0,0 +1,55 @@
+      prompt: params.prompt,
+      schedule: params.schedule,
+      artist_account_id: params.artist_account_id,
+      ...(params.account_id ? { account_id: params.account_id } : {}),
+      ...(params.model !== undefined ? { model: params.model } : {}),
+    }),
</file context>
Fix with Cubic

...(params.model !== undefined ? { model: params.model } : {}),
Comment on lines +35 to +36
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Inconsistent handling of optional parameters.

Line 35 uses a truthiness check for account_id, which excludes both undefined and empty string "". Line 36 uses an explicit !== undefined check for model, which would include empty strings and null values.

If account_id should never be an empty string, this is fine. However, for consistency and to make the intent explicit, consider aligning both patterns:

♻️ Suggested alignment for consistency
-      ...(params.account_id ? { account_id: params.account_id } : {}),
+      ...(params.account_id !== undefined ? { account_id: params.account_id } : {}),
       ...(params.model !== undefined ? { model: params.model } : {}),

Or if empty strings should truly be excluded for account_id, add a comment explaining why:

+      // Exclude empty strings - account_id must be a valid non-empty ID
       ...(params.account_id ? { account_id: params.account_id } : {}),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/tasks/createTask.ts` around lines 35 - 36, The spread construction in
createTask.ts is inconsistent: it uses a truthy check for params.account_id but
an explicit !== undefined check for params.model, which treats empty string and
null differently; update both spreads to use the same explicit presence check
(e.g., params.account_id !== undefined and params.model !== undefined) or, if
you intend to exclude empty strings for account_id, add a clarifying comment
above the account_id spread explaining that empty strings must be excluded—refer
to the params object fields account_id and model and make the checks consistent
across the two spreads.

}),
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}

const data: GetTasksResponse = await response.json();
if (data.status === "error") {
throw new Error(data.error || "Unknown error occurred");
}

if (!data.tasks || data.tasks.length === 0) {
throw new Error("API returned success but no task was created");
}

return data.tasks[0];
}
Loading