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
47 changes: 47 additions & 0 deletions lib/agents/content/__tests__/downloadVideoBuffer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { downloadVideoBuffer } from "../downloadVideoBuffer";

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

it("returns a Buffer with the video data on success", async () => {
const fakeData = new Uint8Array([0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70]);
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(fakeData, {
status: 200,
headers: { "content-type": "video/mp4" },
}),
);

const result = await downloadVideoBuffer("https://example.com/video.mp4");
expect(result).toBeInstanceOf(Buffer);
expect(result!.length).toBe(fakeData.length);
});

it("returns null when fetch returns a non-ok status", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Not Found", { status: 404 }));

const result = await downloadVideoBuffer("https://example.com/missing.mp4");
expect(result).toBeNull();
});

it("returns null when fetch throws a network error", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error"));

const result = await downloadVideoBuffer("https://example.com/video.mp4");
expect(result).toBeNull();
});

it("extracts the filename from the URL path", async () => {
const fakeData = new Uint8Array([0x01, 0x02]);
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(fakeData, { status: 200 }));

const result = await downloadVideoBuffer(
"https://cdn.example.com/path/to/my-video.mp4?token=abc",
);
expect(result).toBeInstanceOf(Buffer);
});
});
40 changes: 40 additions & 0 deletions lib/agents/content/__tests__/getFilenameFromUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { getFilenameFromUrl } from "../getFilenameFromUrl";

describe("getFilenameFromUrl", () => {
it("extracts filename from a simple URL", () => {
expect(getFilenameFromUrl("https://cdn.example.com/path/to/video.mp4")).toBe("video.mp4");
});

it("extracts filename from URL with query params", () => {
expect(getFilenameFromUrl("https://cdn.example.com/video.mp4?token=abc&t=123")).toBe(
"video.mp4",
);
});

it("handles URL-encoded characters", () => {
expect(getFilenameFromUrl("https://cdn.example.com/my%20video%20file.mp4")).toBe(
"my%20video%20file.mp4",
);
});

it("falls back to video.mp4 when URL has no extension", () => {
expect(getFilenameFromUrl("https://cdn.example.com/path/to/video")).toBe("video.mp4");
});

it("falls back to video.mp4 when URL path ends with slash", () => {
expect(getFilenameFromUrl("https://cdn.example.com/path/")).toBe("video.mp4");
});

it("falls back to video.mp4 for invalid URLs", () => {
expect(getFilenameFromUrl("not-a-url")).toBe("video.mp4");
});

it("handles fal.ai storage URLs", () => {
expect(
getFilenameFromUrl(
"https://v3b.fal.media/files/b/0a9486c8/sjfeqG-MFh_3aG213aIU2_final-video.mp4",
),
).toBe("sjfeqG-MFh_3aG213aIU2_final-video.mp4");
});
});
110 changes: 110 additions & 0 deletions lib/agents/content/__tests__/handleContentAgentCallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ vi.mock("@/lib/agents/getThread", () => ({
getThread: vi.fn(),
}));

vi.mock("../postVideoResults", () => ({
postVideoResults: vi.fn().mockResolvedValue(undefined),
}));

const { validateContentAgentCallback } = await import("../validateContentAgentCallback");
const { getThread } = await import("@/lib/agents/getThread");
const { postVideoResults } = await import("../postVideoResults");

const mockedValidate = vi.mocked(validateContentAgentCallback);
const mockedGetThread = vi.mocked(getThread);
const mockedPostVideos = vi.mocked(postVideoResults);

describe("handleContentAgentCallback", () => {
const originalEnv = { ...process.env };

Expand Down Expand Up @@ -70,4 +82,102 @@ describe("handleContentAgentCallback", () => {
// Should get past auth and fail on invalid JSON (400), not auth (401)
expect(response.status).toBe(400);
});

describe("completed callback with videos", () => {
function makeAuthRequest(body: object) {
return new Request("http://localhost/api/content-agent/callback", {
method: "POST",
headers: { "x-callback-secret": "test-secret" },
body: JSON.stringify(body),
});
}

function mockThread() {
const thread = {
post: vi.fn().mockResolvedValue(undefined),
state: Promise.resolve({ status: "running" }),
setState: vi.fn().mockResolvedValue(undefined),
};
mockedGetThread.mockReturnValue(thread as never);
return thread;
}

it("calls postVideoResults with videos and failed count", async () => {
const thread = mockThread();
mockedValidate.mockReturnValue({
threadId: "slack:C123:T456",
status: "completed",
results: [
{
runId: "run-1",
status: "completed",
videoUrl: "https://cdn.example.com/video.mp4",
captionText: "Test",
},
{ runId: "run-2", status: "failed", error: "render error" },
],
});

const response = await handleContentAgentCallback(makeAuthRequest({}));

expect(response.status).toBe(200);
expect(mockedPostVideos).toHaveBeenCalledWith(
thread,
[expect.objectContaining({ videoUrl: "https://cdn.example.com/video.mp4" })],
1,
);
});

it("posts fallback message when no videos produced", async () => {
const thread = mockThread();
mockedValidate.mockReturnValue({
threadId: "slack:C123:T456",
status: "completed",
results: [{ runId: "run-1", status: "failed", error: "render error" }],
});

const response = await handleContentAgentCallback(makeAuthRequest({}));

expect(response.status).toBe(200);
expect(thread.post).toHaveBeenCalledWith(
"Content generation finished but no videos were produced.",
);
expect(mockedPostVideos).not.toHaveBeenCalled();
});

it("skips duplicate delivery when thread status is not running", async () => {
const thread = {
post: vi.fn(),
state: Promise.resolve({ status: "completed" }),
setState: vi.fn(),
};
mockedGetThread.mockReturnValue(thread as never);
mockedValidate.mockReturnValue({
threadId: "slack:C123:T456",
status: "completed",
results: [],
});

const response = await handleContentAgentCallback(makeAuthRequest({}));
const body = await response.json();

expect(body.skipped).toBe(true);
expect(thread.post).not.toHaveBeenCalled();
});

it("sets thread state to completed after posting", async () => {
const thread = mockThread();
mockedValidate.mockReturnValue({
threadId: "slack:C123:T456",
status: "completed",
results: [
{ runId: "run-1", status: "completed", videoUrl: "https://cdn.example.com/v.mp4" },
],
});

await handleContentAgentCallback(makeAuthRequest({}));

expect(thread.setState).toHaveBeenCalledWith({ status: "completed" });
});
});
});
117 changes: 117 additions & 0 deletions lib/agents/content/__tests__/postVideoResults.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { postVideoResults } from "../postVideoResults";

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

const { downloadVideoBuffer } = await import("../downloadVideoBuffer");
const mockedDownload = vi.mocked(downloadVideoBuffer);

describe("postVideoResults", () => {
let thread: { post: ReturnType<typeof vi.fn> };

beforeEach(() => {
vi.clearAllMocks();
thread = { post: vi.fn().mockResolvedValue(undefined) };
});

it("downloads videos in parallel and posts each as a file upload", async () => {
const buf1 = Buffer.from([0x01]);
const buf2 = Buffer.from([0x02]);
mockedDownload.mockResolvedValueOnce(buf1).mockResolvedValueOnce(buf2);

const videos = [
{ runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v1.mp4" },
{ runId: "r2", status: "completed" as const, videoUrl: "https://cdn.example.com/v2.mp4" },
];

await postVideoResults(thread as never, videos, 0);

expect(mockedDownload).toHaveBeenCalledTimes(2);
expect(thread.post).toHaveBeenCalledTimes(2);
expect(thread.post).toHaveBeenCalledWith(
expect.objectContaining({
files: [expect.objectContaining({ filename: "v1.mp4" })],
}),
);
});

it("falls back to URL link when download fails", async () => {
mockedDownload.mockResolvedValue(null);

const videos = [
{ runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v.mp4" },
];

await postVideoResults(thread as never, videos, 0);

expect(thread.post).toHaveBeenCalledWith(
expect.stringContaining("https://cdn.example.com/v.mp4"),
);
});

it("includes caption in markdown when present", async () => {
mockedDownload.mockResolvedValue(Buffer.from([0x01]));

const videos = [
{
runId: "r1",
status: "completed" as const,
videoUrl: "https://cdn.example.com/v.mp4",
captionText: "great song",
},
];

await postVideoResults(thread as never, videos, 0);

expect(thread.post).toHaveBeenCalledWith(
expect.objectContaining({
markdown: expect.stringContaining("great song"),
}),
);
});

it("posts failed run count when failedCount > 0", async () => {
mockedDownload.mockResolvedValue(Buffer.from([0x01]));

const videos = [
{ runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v.mp4" },
];

await postVideoResults(thread as never, videos, 2);

const lastCall = thread.post.mock.calls[thread.post.mock.calls.length - 1][0];
expect(lastCall).toContain("2");
expect(lastCall).toContain("failed");
});

it("does not post failed message when failedCount is 0", async () => {
mockedDownload.mockResolvedValue(Buffer.from([0x01]));

const videos = [
{ runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v.mp4" },
];

await postVideoResults(thread as never, videos, 0);

expect(thread.post).toHaveBeenCalledTimes(1);
});

it("labels videos when there are multiple", async () => {
mockedDownload.mockResolvedValue(Buffer.from([0x01]));

const videos = [
{ runId: "r1", status: "completed" as const, videoUrl: "https://cdn.example.com/v1.mp4" },
{ runId: "r2", status: "completed" as const, videoUrl: "https://cdn.example.com/v2.mp4" },
];

await postVideoResults(thread as never, videos, 0);

expect(thread.post).toHaveBeenCalledWith(
expect.objectContaining({
markdown: expect.stringContaining("Video 1"),
}),
);
});
});
22 changes: 22 additions & 0 deletions lib/agents/content/downloadVideoBuffer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Downloads a video from a URL and returns the data as a Buffer.
*
* @param url - The video URL to download
* @returns The video data as a Buffer, or null if the download fails
*/
export async function downloadVideoBuffer(url: string): Promise<Buffer | null> {
try {
const response = await fetch(url);

if (!response.ok) {
console.error(`Failed to download video: HTTP ${response.status} from ${url}`);
return null;
Comment on lines +11 to +13
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Redact the asset URL in the error log.

Line 12 logs the full download URL. These asset URLs are commonly signed, so writing the query string to logs can leak temporary credentials. Log the status plus a redacted host/path instead.

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

In `@lib/agents/content/downloadVideoBuffer.ts` around lines 11 - 13, The error
log in downloadVideoBuffer.ts currently prints the full signed URL (variable
url) on failure; change the console.error in the response.ok check to redact the
query string by parsing url with the URL constructor and logging only the origin
and pathname (or host+pathname) plus the HTTP status. Locate the failing-block
that checks response.ok (references: response, url, downloadVideoBuffer) and
replace the message so it does not include url.search/query parameters but still
includes clear context and the status code.

}

const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
Comment on lines +9 to +17
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's find and read the downloadVideoBuffer.ts file
fd downloadVideoBuffer.ts

Repository: recoupable/api

Length of output: 99


🏁 Script executed:

# Also check for the file that uses it
fd handleContentAgentCallback.ts

Repository: recoupable/api

Length of output: 106


🏁 Script executed:

# Get the file structure to understand the context better
git ls-files lib/agents/content/

Repository: recoupable/api

Length of output: 1105


🏁 Script executed:

cat -n lib/agents/content/downloadVideoBuffer.ts

Repository: recoupable/api

Length of output: 854


🏁 Script executed:

cat -n lib/agents/content/handleContentAgentCallback.ts

Repository: recoupable/api

Length of output: 5572


🏁 Script executed:

# Check if there are any existing timeout or size guards elsewhere in the codebase
rg "timeout|maxSize|max-size|MAX_SIZE" lib/agents/content/ -A 2 -B 2

Repository: recoupable/api

Length of output: 1861


🏁 Script executed:

# Check if there are any fetch configuration or timeout utilities elsewhere in the codebase
rg "AbortController|AbortSignal|timeout" lib/ -t ts -t tsx | head -20

Repository: recoupable/api

Length of output: 85


🏁 Script executed:

# Check if there's a configured fetch wrapper or utility function
fd -e ts -e tsx lib/networking/ lib/utils/ | head -20

Repository: recoupable/api

Length of output: 155


🏁 Script executed:

# Look for any environment variables or constants related to request timeouts
rg "TIMEOUT|timeout" lib/agents/content/ -t ts

Repository: recoupable/api

Length of output: 523


🏁 Script executed:

# Look for AbortController or timeout patterns in the broader codebase
rg "AbortController|AbortSignal" lib/ -t ts | head -20

Repository: recoupable/api

Length of output: 219


🏁 Script executed:

# Check if there's a networking utilities directory
fd -type d networking

Repository: recoupable/api

Length of output: 228


🏁 Script executed:

# Look for any fetch wrappers or similar patterns
rg "fetch\(" lib/ -t ts -B 2 -A 2 | head -40

Repository: recoupable/api

Length of output: 2324


🏁 Script executed:

# Check the test file to understand expected behavior
cat -n lib/agents/content/__tests__/downloadVideoBuffer.test.ts

Repository: recoupable/api

Length of output: 2072


🏁 Script executed:

# Check what URLs are being downloaded - look at the context that calls downloadVideoBuffer
rg "videoUrl" lib/agents/content/ -B 2 -A 2

Repository: recoupable/api

Length of output: 4883


🏁 Script executed:

# Check if there are other similar download functions in the codebase and how they handle this
rg "arrayBuffer\(\)" lib/ -B 5 -A 2

Repository: recoupable/api

Length of output: 3235


🏁 Script executed:

# Look at the examples of AbortController usage to see the pattern
cat -n lib/serpapi/searchGoogleImages.ts | grep -A 10 "AbortController"

Repository: recoupable/api

Length of output: 385


🏁 Script executed:

# Check if there are any environment constants for max sizes
rg "MAX_.*SIZE|maxSize" lib/ -t ts

Repository: recoupable/api

Length of output: 288


Add timeout and max-size guards to this download.

This runs on the callback request path and blocks the handler until complete. fetch() has no timeout and arrayBuffer() loads the entire response into memory without bounds. A slow or oversized video can stall the route and trigger Trigger.dev retries instead of gracefully falling back to the URL post. Add an AbortController timeout (following the pattern in lib/serpapi/searchGoogleImages.ts) and a max-size check (similar to lib/youtube/getResizedImageBuffer.ts).

Additionally, redact the URL from the error log at line 12 to avoid exposing signed query parameters.

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

In `@lib/agents/content/downloadVideoBuffer.ts` around lines 9 - 17, The
downloadVideoBuffer function currently uses fetch(url) and
response.arrayBuffer() without timeout or size limits and logs the full URL; add
an AbortController with a timeout (same pattern used in
lib/serpapi/searchGoogleImages.ts) and pass controller.signal into fetch(url, {
signal }), and enforce a MAX_BYTES limit (like
lib/youtube/getResizedImageBuffer.ts) by streaming response.body with a reader,
accumulating chunks until MAX_BYTES and aborting/returning null if exceeded;
replace the console.error that prints the full URL with a redacted message that
omits query params (log HTTP status and that the URL was redacted) and ensure
you clear the timeout on success or failure.

} catch (error) {
console.error("Failed to download video:", error);
return null;
}
}
16 changes: 16 additions & 0 deletions lib/agents/content/getFilenameFromUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Extracts the filename from a URL path, falling back to "video.mp4".
*
* @param url - The video URL
* @returns The extracted filename
*/
export function getFilenameFromUrl(url: string): string {
try {
const pathname = new URL(url).pathname;
const segments = pathname.split("/");
const last = segments[segments.length - 1];
return last && last.includes(".") ? last : "video.mp4";
} catch {
return "video.mp4";
}
}
13 changes: 2 additions & 11 deletions lib/agents/content/handleContentAgentCallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateContentAgentCallback } from "./validateContentAgentCallback";
import { getThread } from "@/lib/agents/getThread";
import { postVideoResults } from "./postVideoResults";
import type { ContentAgentThreadState } from "./types";

/**
Expand Down Expand Up @@ -62,17 +63,7 @@ export async function handleContentAgentCallback(request: Request): Promise<Next
const failed = results.filter(r => r.status === "failed");

if (videos.length > 0) {
const lines = videos.map((v, i) => {
const label = videos.length > 1 ? `**Video ${i + 1}:** ` : "";
const caption = v.captionText ? `\n> ${v.captionText}` : "";
return `${label}${v.videoUrl}${caption}`;
});

if (failed.length > 0) {
lines.push(`\n_${failed.length} run(s) failed._`);
}

await thread.post(lines.join("\n\n"));
await postVideoResults(thread, videos, failed.length);
} else {
await thread.post("Content generation finished but no videos were produced.");
}
Expand Down
Loading
Loading