Skip to content
Merged

Test #692

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f99277e
feat(measurement-jobs): free-tier card gate (setup mode) + instant ba…
sweetmantech Jun 16, 2026
158a1d4
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 16, 2026
5fa5e3a
fix(songstats-backfill): backoff on 429 + defer instead of churn (cha…
sweetmantech Jun 16, 2026
1e9deac
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 16, 2026
5f45946
refactor(songstats): remove local quota ledger + budget gate (chat#17…
sweetmantech Jun 16, 2026
5472795
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 16, 2026
f24d4c7
feat: POST /api/catalogs (create + materialize from valuation snapsho…
sweetmantech Jun 18, 2026
ace0b01
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
a520a1d
fix: LEFT-join artists in catalog-songs read (materialized tracks wer…
sweetmantech Jun 18, 2026
76b5739
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
709ea0c
feat: add X (Twitter) + LinkedIn to the Composio connector whitelist …
sweetmantech Jun 18, 2026
8a3c3cb
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
66cc2fe
chore: remove unused ALLOWED_ARTIST_CONNECTORS from api (chat#1793) (…
sweetmantech Jun 18, 2026
79c22da
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
6fc10cc
fix: enrich valuation-captured songs (artists + notes) so they render…
sweetmantech Jun 18, 2026
0e42d02
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
2fa7029
fix(tasks): let admins fetch any task by id alone (cross-account read…
sweetmantech Jun 19, 2026
bd2ae10
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 19, 2026
cdb34d0
feat(connectors): POST /api/connectors/files — stage images for Linke…
sweetmantech Jun 20, 2026
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
35 changes: 35 additions & 0 deletions app/api/connectors/files/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { uploadConnectorFileHandler } from "@/lib/composio/connectors/uploadConnectorFileHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns A 200 NextResponse carrying the CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: getCorsHeaders(),
});
}

/**
* POST /api/connectors/files
*
* Stages an image into Composio storage so it can be attached to a connector
* action that accepts a `file_uploadable` field (e.g.
* `LINKEDIN_CREATE_LINKED_IN_POST.images[]`, `TWITTER_CREATION_OF_A_POST`).
* Requires `x-api-key` or `Authorization: Bearer`.
*
* @param request - The incoming request. JSON body: `url` (required — a
* publicly reachable image URL) and `toolSlug` (required — the
* UPPERCASE_SNAKE_CASE action slug the file will be attached to).
* @returns A 200 NextResponse with `{ success, name, mimetype, s3key }`, 400 on
* missing/invalid body, 401 when unauthenticated, or 502 when Composio fails
* to fetch or store the image.
*/
export async function POST(request: NextRequest) {
return uploadConnectorFileHandler(request);
}
16 changes: 16 additions & 0 deletions lib/composio/connectors/__tests__/deriveToolkitSlug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, it, expect } from "vitest";
import { deriveToolkitSlug } from "../deriveToolkitSlug";

describe("deriveToolkitSlug", () => {
it("derives linkedin from LINKEDIN_CREATE_LINKED_IN_POST", () => {
expect(deriveToolkitSlug("LINKEDIN_CREATE_LINKED_IN_POST")).toBe("linkedin");
});

it("derives twitter from TWITTER_CREATION_OF_A_POST", () => {
expect(deriveToolkitSlug("TWITTER_CREATION_OF_A_POST")).toBe("twitter");
});

it("lowercases the leading toolkit token for other slugs", () => {
expect(deriveToolkitSlug("GMAIL_SEND_EMAIL")).toBe("gmail");
});
});
52 changes: 52 additions & 0 deletions lib/composio/connectors/__tests__/uploadConnectorFile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { uploadConnectorFile } from "../uploadConnectorFile";
import { getComposioClient } from "../../client";

vi.mock("../../client", () => ({
getComposioClient: vi.fn(),
}));

describe("uploadConnectorFile", () => {
const upload = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getComposioClient).mockResolvedValue({
files: { upload },
} as unknown as Awaited<ReturnType<typeof getComposioClient>>);
});

it("uploads the url scoped to the tool/toolkit and returns the flat descriptor", async () => {
upload.mockResolvedValue({
name: "post.png",
mimetype: "image/png",
s3key: "composio/abc123",
});

const result = await uploadConnectorFile({
url: "https://cdn.example.com/post.png",
toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST",
});

expect(upload).toHaveBeenCalledWith({
file: "https://cdn.example.com/post.png",
toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST",
toolkitSlug: "linkedin",
});
expect(result).toEqual({
name: "post.png",
mimetype: "image/png",
s3key: "composio/abc123",
});
});

it("propagates upstream upload failures", async () => {
upload.mockRejectedValue(new Error("storage returned HTTP 404"));
await expect(
uploadConnectorFile({
url: "https://cdn.example.com/post.png",
toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST",
}),
).rejects.toThrow("storage returned HTTP 404");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { uploadConnectorFileHandler } from "../uploadConnectorFileHandler";
import { validateUploadConnectorFileRequest } from "../validateUploadConnectorFileRequest";
import { uploadConnectorFile } from "../uploadConnectorFile";

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

vi.mock("../validateUploadConnectorFileRequest", () => ({
validateUploadConnectorFileRequest: vi.fn(),
}));

vi.mock("../uploadConnectorFile", () => ({
uploadConnectorFile: vi.fn(),
}));

const buildRequest = () =>
new NextRequest("http://localhost/api/connectors/files", {
method: "POST",
body: JSON.stringify({
url: "https://cdn.example.com/a.png",
toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST",
}),
});

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

it("returns 200 with the flat file descriptor on success", async () => {
vi.mocked(validateUploadConnectorFileRequest).mockResolvedValue({
url: "https://cdn.example.com/a.png",
toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST",
});
vi.mocked(uploadConnectorFile).mockResolvedValue({
name: "a.png",
mimetype: "image/png",
s3key: "composio/xyz",
});

const response = await uploadConnectorFileHandler(buildRequest());
const body = await response.json();

expect(response.status).toBe(200);
expect(body).toEqual({
success: true,
name: "a.png",
mimetype: "image/png",
s3key: "composio/xyz",
});
});

it("returns the validation/auth error response unchanged", async () => {
const errorResponse = NextResponse.json({ status: "error" }, { status: 401 });
vi.mocked(validateUploadConnectorFileRequest).mockResolvedValue(errorResponse);

const response = await uploadConnectorFileHandler(buildRequest());

expect(response).toBe(errorResponse);
expect(uploadConnectorFile).not.toHaveBeenCalled();
});

it("returns 502 when the Composio upload fails", async () => {
vi.mocked(validateUploadConnectorFileRequest).mockResolvedValue({
url: "https://cdn.example.com/a.png",
toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST",
});
vi.mocked(uploadConnectorFile).mockRejectedValue(new Error("storage returned HTTP 404"));

const response = await uploadConnectorFileHandler(buildRequest());
const body = await response.json();

expect(response.status).toBe(502);
expect(body.error).toContain("storage returned HTTP 404");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextResponse } from "next/server";
import { validateUploadConnectorFileBody } from "../validateUploadConnectorFileBody";

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

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

it("returns the validated body for a valid url + toolSlug", () => {
const result = validateUploadConnectorFileBody({
url: "https://cdn.example.com/post.png",
toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST",
});
expect(result).toEqual({
url: "https://cdn.example.com/post.png",
toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST",
});
});

it("returns 400 when url is missing", () => {
const result = validateUploadConnectorFileBody({ toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST" });
expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) expect(result.status).toBe(400);
});

it("returns 400 when url is not a valid URL", () => {
const result = validateUploadConnectorFileBody({
url: "not-a-url",
toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST",
});
expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) expect(result.status).toBe(400);
});

it("returns 400 when toolSlug is missing", () => {
const result = validateUploadConnectorFileBody({ url: "https://cdn.example.com/post.png" });
expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) expect(result.status).toBe(400);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { validateUploadConnectorFileRequest } from "../validateUploadConnectorFileRequest";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";

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

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

const buildRequest = (body: unknown) =>
new NextRequest("http://localhost/api/connectors/files", {
method: "POST",
body: JSON.stringify(body),
});

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

it("returns the auth error when authentication fails", async () => {
vi.mocked(validateAuthContext).mockResolvedValue(
NextResponse.json({ status: "error" }, { status: 401 }),
);

const result = await validateUploadConnectorFileRequest(
buildRequest({
url: "https://cdn.example.com/a.png",
toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST",
}),
);

expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) expect(result.status).toBe(401);
});

it("returns 400 when the body is invalid", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc_1",
orgId: null,
authToken: "t",
});

const result = await validateUploadConnectorFileRequest(buildRequest({ toolSlug: "X" }));

expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) expect(result.status).toBe(400);
});

it("returns 400 (not a throw) when the body is malformed JSON", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc_1",
orgId: null,
authToken: "t",
});

const malformed = new NextRequest("http://localhost/api/connectors/files", {
method: "POST",
body: "{not json",
});

const result = await validateUploadConnectorFileRequest(malformed);

expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) expect(result.status).toBe(400);
});

it("returns the validated { url, toolSlug } on success", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc_1",
orgId: null,
authToken: "t",
});

const result = await validateUploadConnectorFileRequest(
buildRequest({
url: "https://cdn.example.com/a.png",
toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST",
}),
);

expect(result).toEqual({
url: "https://cdn.example.com/a.png",
toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST",
});
});
});
15 changes: 15 additions & 0 deletions lib/composio/connectors/deriveToolkitSlug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Derives the Composio toolkit slug from an action slug.
*
* Action slugs are `UPPERCASE_SNAKE_CASE` and prefixed with their toolkit
* (e.g. `LINKEDIN_CREATE_LINKED_IN_POST` → `linkedin`,
* `TWITTER_CREATION_OF_A_POST` → `twitter`). Composio's file upload is scoped
* to a `{ toolSlug, toolkitSlug }`, so we recover the toolkit from the leading
* token of the action slug.
*
* @param toolSlug - The action slug (e.g. `LINKEDIN_CREATE_LINKED_IN_POST`)
* @returns The lowercase toolkit slug (e.g. `linkedin`)
*/
export function deriveToolkitSlug(toolSlug: string): string {
return toolSlug.split("_")[0].toLowerCase();
}
42 changes: 42 additions & 0 deletions lib/composio/connectors/uploadConnectorFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { getComposioClient } from "../client";
import { deriveToolkitSlug } from "./deriveToolkitSlug";

/**
* A Composio file descriptor, ready to embed in a `file_uploadable` action
* field (e.g. `LINKEDIN_CREATE_LINKED_IN_POST.images[]`).
*/
export interface UploadedConnectorFile {
name: string;
mimetype: string;
s3key: string;
}

/**
* Stages an image into Composio storage and returns its `{ name, mimetype, s3key }`
* descriptor.
*
* Composio's SDK fetches the URL server-side, uploads the bytes to storage
* (deduplicated by content hash), and returns a storage key that can then be
* passed verbatim into a `file_uploadable` action parameter. The upload is
* scoped to the action's tool/toolkit, derived from `toolSlug`.
*
* @param params.url - Publicly reachable image URL to stage
* @param params.toolSlug - The action slug the file will be attached to
* (e.g. `LINKEDIN_CREATE_LINKED_IN_POST`)
* @returns The Composio file descriptor
* @throws when Composio fails to fetch or store the file (surfaced as 502 upstream)
*/
export async function uploadConnectorFile(params: {
url: string;
toolSlug: string;
}): Promise<UploadedConnectorFile> {
const composio = await getComposioClient();

const { name, mimetype, s3key } = await composio.files.upload({
file: params.url,
toolSlug: params.toolSlug,
toolkitSlug: deriveToolkitSlug(params.toolSlug),
});

return { name, mimetype, s3key };
}
Loading
Loading