Skip to content
6 changes: 0 additions & 6 deletions apps/sim/app/api/tools/instagram/download-media/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,11 +303,6 @@ describe('POST /api/tools/instagram/download-media', () => {
})

describe('instagramDownloadMediaTool', () => {
it('documents the canonical User File size limit', () => {
expect(instagramDownloadMediaTool.description).toContain('100 MB max per file')
expect(instagramDownloadMediaTool.outputs?.files.description).toContain('100 MB max each')
})

it('forwards execution context and returns canonical file-array output', async () => {
const body = instagramDownloadMediaTool.request.body?.({
accessToken: 'instagram-token',
Expand Down Expand Up @@ -354,6 +349,5 @@ describe('instagramDownloadMediaTool', () => {
downloadedCount: 1,
},
})
expect(instagramDownloadMediaTool.outputs?.files.type).toBe('file[]')
})
})
4 changes: 2 additions & 2 deletions apps/sim/app/api/tools/instagram/publish-carousel/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import { instagramPublishCarouselContract } from '@/lib/api/contracts/tools/inst
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveInstagramCarouselMedia } from '@/app/api/tools/instagram/resolve-media'
import {
createMediaContainer,
publishMediaContainer,
resolveIgUserId,
resolveInstagramCarouselMedia,
waitForContainerReady,
} from '@/tools/instagram/utils'
} from '@/app/api/tools/instagram/server-utils'

export const dynamic = 'force-dynamic'
/**
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/api/tools/instagram/publish-image/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import { instagramPublishImageContract } from '@/lib/api/contracts/tools/instagr
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveInstagramMedia } from '@/app/api/tools/instagram/resolve-media'
import {
createMediaContainer,
publishMediaContainer,
resolveIgUserId,
resolveInstagramMedia,
waitForContainerReady,
} from '@/tools/instagram/utils'
} from '@/app/api/tools/instagram/server-utils'

export const dynamic = 'force-dynamic'
/** Meta may poll container status once per minute for up to five minutes. */
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/app/api/tools/instagram/publish-reel/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import { instagramPublishReelContract } from '@/lib/api/contracts/tools/instagra
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveInstagramMedia } from '@/app/api/tools/instagram/resolve-media'
import {
createMediaContainer,
publishMediaContainer,
resolveIgUserId,
resolveInstagramMedia,
waitForContainerReady,
} from '@/tools/instagram/utils'
} from '@/app/api/tools/instagram/server-utils'

export const dynamic = 'force-dynamic'
/** Meta may poll container status once per minute for up to five minutes. */
Expand Down Expand Up @@ -56,7 +56,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}

let coverUrl: string | undefined
if (body.cover != null && body.cover !== '') {
if (body.cover != null) {
const resolvedCover = await resolveInstagramMedia({
input: body.cover,
userId: authResult.userId,
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/api/tools/instagram/publish-story/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import { instagramPublishStoryContract } from '@/lib/api/contracts/tools/instagr
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveInstagramMedia } from '@/app/api/tools/instagram/resolve-media'
import {
createMediaContainer,
publishMediaContainer,
resolveIgUserId,
resolveInstagramMedia,
waitForContainerReady,
} from '@/tools/instagram/utils'
} from '@/app/api/tools/instagram/server-utils'

export const dynamic = 'force-dynamic'
/** Meta may poll container status once per minute for up to five minutes. */
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/app/api/tools/instagram/publish-video/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import { instagramPublishVideoContract } from '@/lib/api/contracts/tools/instagr
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveInstagramMedia } from '@/app/api/tools/instagram/resolve-media'
import {
createMediaContainer,
publishMediaContainer,
resolveIgUserId,
resolveInstagramMedia,
waitForContainerReady,
} from '@/tools/instagram/utils'
} from '@/app/api/tools/instagram/server-utils'

export const dynamic = 'force-dynamic'
/** Meta may poll container status once per minute for up to five minutes. */
Expand Down Expand Up @@ -56,7 +56,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}

let coverUrl: string | undefined
if (body.cover != null && body.cover !== '') {
if (body.cover != null) {
const resolvedCover = await resolveInstagramMedia({
input: body.cover,
userId: authResult.userId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* @vitest-environment node
*/
import type { Logger } from '@sim/logger'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockHasCloudStorage, mockResolveFileInputToUrl } = vi.hoisted(() => ({
mockHasCloudStorage: vi.fn(),
Expand All @@ -18,10 +18,13 @@ vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
}))

import {
createMediaContainer,
INSTAGRAM_MEDIA_URL_TTL_SECONDS,
publishMediaContainer,
resolveIgUserId,
resolveInstagramCarouselMedia,
resolveInstagramMedia,
} from '@/app/api/tools/instagram/resolve-media'
} from '@/app/api/tools/instagram/server-utils'

const logger = {} as Logger
const context = {
Expand All @@ -44,40 +47,26 @@ function uploadedFile(overrides: Record<string, unknown> = {}) {
beforeEach(() => {
vi.clearAllMocks()
mockHasCloudStorage.mockReturnValue(true)
mockResolveFileInputToUrl.mockImplementation(
async ({ file, filePath }: { file?: { name?: string }; filePath?: string }) => ({
fileUrl: filePath || `https://signed.example.com/${file?.name || 'media'}`,
})
)
mockResolveFileInputToUrl.mockImplementation(async ({ file }: { file?: { name?: string } }) => ({
fileUrl: `https://signed.example.com/${file?.name || 'media'}`,
}))
})

afterEach(() => {
vi.unstubAllGlobals()
})

describe('resolveInstagramMedia', () => {
it('accepts a public HTTPS media URL', async () => {
it('rejects non-file inputs before resolving them', async () => {
const result = await resolveInstagramMedia({
...context,
input: 'https://cdn.example.com/photo.jpg',
role: 'image',
})

expect(result).toEqual({
media: expect.objectContaining({
url: 'https://cdn.example.com/photo.jpg',
kind: 'image',
mimeType: 'image/jpeg',
}),
})
})

it('rejects a public HTTP media URL before resolving it', async () => {
const result = await resolveInstagramMedia({
...context,
input: 'http://cdn.example.com/photo.jpg',
role: 'image',
})

expect(result.error).toEqual({
status: 400,
message: 'Instagram media URLs must use HTTPS so Meta can download them',
message: 'Media must be a Sim file',
})
expect(mockResolveFileInputToUrl).not.toHaveBeenCalled()
})
Expand All @@ -95,22 +84,19 @@ describe('resolveInstagramMedia', () => {
})
expect(mockResolveFileInputToUrl).toHaveBeenCalledWith({
file,
filePath: undefined,
...context,
presignExpirySeconds: INSTAGRAM_MEDIA_URL_TTL_SECONDS,
})
})

it('requires cloud storage for uploaded files and internal URLs', async () => {
it('requires cloud storage for publishing files', async () => {
mockHasCloudStorage.mockReturnValue(false)

for (const input of [uploadedFile(), '/api/files/serve/execution/photo.jpg']) {
const result = await resolveInstagramMedia({ ...context, input, role: 'image' })
expect(result.error).toEqual({
status: 400,
message: expect.stringContaining('Cloud storage is required'),
})
}
const result = await resolveInstagramMedia({ ...context, input: uploadedFile(), role: 'image' })
expect(result.error).toEqual({
status: 400,
message: expect.stringContaining('Cloud storage is required'),
})
expect(mockResolveFileInputToUrl).not.toHaveBeenCalled()
})

Expand Down Expand Up @@ -164,71 +150,40 @@ describe('resolveInstagramMedia', () => {
})

describe('resolveInstagramCarouselMedia', () => {
it('parses JSON input and infers image and video item types sequentially', async () => {
it('resolves canonical files in order and infers image and video types sequentially', async () => {
let activeResolutions = 0
let maxActiveResolutions = 0
mockResolveFileInputToUrl.mockImplementation(async ({ filePath }: { filePath?: string }) => {
mockResolveFileInputToUrl.mockImplementation(async ({ file }: { file?: { name?: string } }) => {
activeResolutions += 1
maxActiveResolutions = Math.max(maxActiveResolutions, activeResolutions)
await Promise.resolve()
activeResolutions -= 1
return { fileUrl: filePath }
return { fileUrl: `https://signed.example.com/${file?.name}` }
})

const result = await resolveInstagramCarouselMedia(
JSON.stringify([
'https://cdn.example.com/carousel-1.jpg',
'https://cdn.example.com/carousel-2.mp4',
]),
[
uploadedFile({ name: 'carousel-1.jpg' }),
uploadedFile({ name: 'carousel-2.mp4', type: 'video/mp4' }),
],
context.userId,
context.requestId,
logger
)

expect(result.items?.map(({ url, kind }) => ({ url, kind }))).toEqual([
{ url: 'https://cdn.example.com/carousel-1.jpg', kind: 'image' },
{ url: 'https://cdn.example.com/carousel-2.mp4', kind: 'video' },
{ url: 'https://signed.example.com/carousel-1.jpg', kind: 'image' },
{ url: 'https://signed.example.com/carousel-2.mp4', kind: 'video' },
])
expect(maxActiveResolutions).toBe(1)
})

it('supports legacy comma-separated URLs with an explicit video prefix', async () => {
const result = await resolveInstagramCarouselMedia(
'https://cdn.example.com/carousel-1.jpg, video:https://cdn.example.com/carousel-2.mp4',
context.userId,
context.requestId,
logger
)

expect(result.items?.map(({ kind }) => kind)).toEqual(['image', 'video'])
})

it('infers media types for uploaded file arrays', async () => {
const result = await resolveInstagramCarouselMedia(
[
uploadedFile(),
uploadedFile({
id: 'file-2',
key: 'execution/workflow-1/execution-1/video.mov',
name: 'video.mov',
type: 'video/quicktime',
}),
],
context.userId,
context.requestId,
logger
)

expect(result.items?.map(({ kind }) => kind)).toEqual(['image', 'video'])
})

it.each([
{ count: 1, label: 'too few' },
{ count: 11, label: 'too many' },
])('rejects $label carousel items before resolving them', async ({ count }) => {
const input = Array.from(
{ length: count },
(_, index) => `https://cdn.example.com/carousel-${index + 1}.jpg`
const input = Array.from({ length: count }, (_, index) =>
uploadedFile({ id: `file-${index + 1}`, name: `carousel-${index + 1}.jpg` })
)

const result = await resolveInstagramCarouselMedia(
Expand All @@ -245,14 +200,46 @@ describe('resolveInstagramCarouselMedia', () => {
expect(mockResolveFileInputToUrl).not.toHaveBeenCalled()
})

it('rejects malformed carousel JSON', async () => {
it('rejects non-file string inputs', async () => {
const result = await resolveInstagramCarouselMedia(
'[not-json',
'https://example.com/one.jpg,https://example.com/two.jpg',
context.userId,
context.requestId,
logger
)

expect(result.error).toEqual({ status: 400, message: 'Carousel media JSON is invalid' })
expect(result.error).toEqual({ status: 400, message: 'Carousel media is required' })
})
})

describe('Instagram publishing requests', () => {
it('resolves the connected account when no override is supplied', async () => {
const fetchMock = vi.fn().mockResolvedValue(Response.json({ user_id: 123 }, { status: 200 }))
vi.stubGlobal('fetch', fetchMock)

await expect(resolveIgUserId('token')).resolves.toBe('123')
expect(fetchMock).toHaveBeenCalledOnce()
})

it('creates and publishes form-encoded containers', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(Response.json({ id: 'container-1' }, { status: 200 }))
.mockResolvedValueOnce(Response.json({ id: 'media-1' }, { status: 200 }))
vi.stubGlobal('fetch', fetchMock)

await expect(
createMediaContainer('token', 'user-1', { image_url: 'https://signed.example/image.jpg' })
).resolves.toBe('container-1')
await expect(publishMediaContainer('token', 'user-1', 'container-1')).resolves.toBe('media-1')

expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'POST',
body: 'image_url=https%3A%2F%2Fsigned.example%2Fimage.jpg',
})
expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
method: 'POST',
body: 'creation_id=container-1',
})
})
})
Loading
Loading