|
| 1 | +/** |
| 2 | + * @vitest-environment node |
| 3 | + */ |
| 4 | +import type { Logger } from '@sim/logger' |
| 5 | +import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 6 | + |
| 7 | +const { mockHasCloudStorage, mockResolveFileInputToUrl } = vi.hoisted(() => ({ |
| 8 | + mockHasCloudStorage: vi.fn(), |
| 9 | + mockResolveFileInputToUrl: vi.fn(), |
| 10 | +})) |
| 11 | + |
| 12 | +vi.mock('@/lib/uploads/core/storage-service', () => ({ |
| 13 | + hasCloudStorage: mockHasCloudStorage, |
| 14 | +})) |
| 15 | + |
| 16 | +vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ |
| 17 | + resolveFileInputToUrl: mockResolveFileInputToUrl, |
| 18 | +})) |
| 19 | + |
| 20 | +import { |
| 21 | + INSTAGRAM_MEDIA_URL_TTL_SECONDS, |
| 22 | + resolveInstagramCarouselMedia, |
| 23 | + resolveInstagramMedia, |
| 24 | +} from '@/app/api/tools/instagram/resolve-media' |
| 25 | + |
| 26 | +const logger = {} as Logger |
| 27 | +const context = { |
| 28 | + userId: 'user-1', |
| 29 | + requestId: 'request-1', |
| 30 | + logger, |
| 31 | +} |
| 32 | + |
| 33 | +function uploadedFile(overrides: Record<string, unknown> = {}) { |
| 34 | + return { |
| 35 | + id: 'file-1', |
| 36 | + key: 'execution/workflow-1/execution-1/photo.jpg', |
| 37 | + name: 'photo.jpg', |
| 38 | + size: 1024, |
| 39 | + type: 'image/jpeg', |
| 40 | + ...overrides, |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +beforeEach(() => { |
| 45 | + vi.clearAllMocks() |
| 46 | + mockHasCloudStorage.mockReturnValue(true) |
| 47 | + mockResolveFileInputToUrl.mockImplementation( |
| 48 | + async ({ file, filePath }: { file?: { name?: string }; filePath?: string }) => ({ |
| 49 | + fileUrl: filePath || `https://signed.example.com/${file?.name || 'media'}`, |
| 50 | + }) |
| 51 | + ) |
| 52 | +}) |
| 53 | + |
| 54 | +describe('resolveInstagramMedia', () => { |
| 55 | + it('accepts a public HTTPS media URL', async () => { |
| 56 | + const result = await resolveInstagramMedia({ |
| 57 | + ...context, |
| 58 | + input: 'https://cdn.example.com/photo.jpg', |
| 59 | + role: 'image', |
| 60 | + }) |
| 61 | + |
| 62 | + expect(result).toEqual({ |
| 63 | + media: expect.objectContaining({ |
| 64 | + url: 'https://cdn.example.com/photo.jpg', |
| 65 | + kind: 'image', |
| 66 | + mimeType: 'image/jpeg', |
| 67 | + }), |
| 68 | + }) |
| 69 | + }) |
| 70 | + |
| 71 | + it('rejects a public HTTP media URL before resolving it', async () => { |
| 72 | + const result = await resolveInstagramMedia({ |
| 73 | + ...context, |
| 74 | + input: 'http://cdn.example.com/photo.jpg', |
| 75 | + role: 'image', |
| 76 | + }) |
| 77 | + |
| 78 | + expect(result.error).toEqual({ |
| 79 | + status: 400, |
| 80 | + message: 'Instagram media URLs must use HTTPS so Meta can download them', |
| 81 | + }) |
| 82 | + expect(mockResolveFileInputToUrl).not.toHaveBeenCalled() |
| 83 | + }) |
| 84 | + |
| 85 | + it('resolves an uploaded file with the Instagram publishing URL lifetime', async () => { |
| 86 | + const file = uploadedFile() |
| 87 | + const result = await resolveInstagramMedia({ ...context, input: file, role: 'image' }) |
| 88 | + |
| 89 | + expect(result.media).toEqual({ |
| 90 | + url: 'https://signed.example.com/photo.jpg', |
| 91 | + kind: 'image', |
| 92 | + mimeType: 'image/jpeg', |
| 93 | + size: 1024, |
| 94 | + name: 'photo.jpg', |
| 95 | + }) |
| 96 | + expect(mockResolveFileInputToUrl).toHaveBeenCalledWith({ |
| 97 | + file, |
| 98 | + filePath: undefined, |
| 99 | + ...context, |
| 100 | + presignExpirySeconds: INSTAGRAM_MEDIA_URL_TTL_SECONDS, |
| 101 | + }) |
| 102 | + }) |
| 103 | + |
| 104 | + it('requires cloud storage for uploaded files and internal URLs', async () => { |
| 105 | + mockHasCloudStorage.mockReturnValue(false) |
| 106 | + |
| 107 | + for (const input of [uploadedFile(), '/api/files/serve/execution/photo.jpg']) { |
| 108 | + const result = await resolveInstagramMedia({ ...context, input, role: 'image' }) |
| 109 | + expect(result.error).toEqual({ |
| 110 | + status: 400, |
| 111 | + message: expect.stringContaining('Cloud storage is required'), |
| 112 | + }) |
| 113 | + } |
| 114 | + expect(mockResolveFileInputToUrl).not.toHaveBeenCalled() |
| 115 | + }) |
| 116 | + |
| 117 | + it('validates JPEG MIME type and size without loading file bytes', async () => { |
| 118 | + const invalidType = await resolveInstagramMedia({ |
| 119 | + ...context, |
| 120 | + input: uploadedFile({ name: 'photo.png', type: 'image/png' }), |
| 121 | + role: 'image', |
| 122 | + label: 'Image', |
| 123 | + }) |
| 124 | + const oversized = await resolveInstagramMedia({ |
| 125 | + ...context, |
| 126 | + input: uploadedFile({ size: 8 * 1024 * 1024 + 1 }), |
| 127 | + role: 'image', |
| 128 | + label: 'Image', |
| 129 | + }) |
| 130 | + |
| 131 | + expect(invalidType.error?.message).toBe('Image must be a JPEG image (got image/png)') |
| 132 | + expect(oversized.error?.message).toContain("Instagram's 8MB JPEG limit") |
| 133 | + }) |
| 134 | + |
| 135 | + it.each([ |
| 136 | + { role: 'video' as const, maxBytes: 300 * 1024 * 1024, label: 'Video' }, |
| 137 | + { role: 'story' as const, maxBytes: 100 * 1024 * 1024, label: 'Story' }, |
| 138 | + ])('enforces the $role video size limit', async ({ role, maxBytes, label }) => { |
| 139 | + const result = await resolveInstagramMedia({ |
| 140 | + ...context, |
| 141 | + input: uploadedFile({ |
| 142 | + key: 'execution/workflow-1/execution-1/video.mp4', |
| 143 | + name: 'video.mp4', |
| 144 | + size: maxBytes + 1, |
| 145 | + type: 'video/mp4', |
| 146 | + }), |
| 147 | + role, |
| 148 | + label, |
| 149 | + }) |
| 150 | + |
| 151 | + expect(result.error?.message).toContain(`video limit for ${role}`) |
| 152 | + }) |
| 153 | + |
| 154 | + it('rejects unsupported video formats', async () => { |
| 155 | + const result = await resolveInstagramMedia({ |
| 156 | + ...context, |
| 157 | + input: uploadedFile({ name: 'video.webm', type: 'video/webm' }), |
| 158 | + role: 'video', |
| 159 | + label: 'Video', |
| 160 | + }) |
| 161 | + |
| 162 | + expect(result.error?.message).toBe('Video must be an MP4 or MOV video (got video/webm)') |
| 163 | + }) |
| 164 | +}) |
| 165 | + |
| 166 | +describe('resolveInstagramCarouselMedia', () => { |
| 167 | + it('parses JSON input and infers image and video item types sequentially', async () => { |
| 168 | + let activeResolutions = 0 |
| 169 | + let maxActiveResolutions = 0 |
| 170 | + mockResolveFileInputToUrl.mockImplementation(async ({ filePath }: { filePath?: string }) => { |
| 171 | + activeResolutions += 1 |
| 172 | + maxActiveResolutions = Math.max(maxActiveResolutions, activeResolutions) |
| 173 | + await Promise.resolve() |
| 174 | + activeResolutions -= 1 |
| 175 | + return { fileUrl: filePath } |
| 176 | + }) |
| 177 | + |
| 178 | + const result = await resolveInstagramCarouselMedia( |
| 179 | + JSON.stringify([ |
| 180 | + 'https://cdn.example.com/carousel-1.jpg', |
| 181 | + 'https://cdn.example.com/carousel-2.mp4', |
| 182 | + ]), |
| 183 | + context.userId, |
| 184 | + context.requestId, |
| 185 | + logger |
| 186 | + ) |
| 187 | + |
| 188 | + expect(result.items?.map(({ url, kind }) => ({ url, kind }))).toEqual([ |
| 189 | + { url: 'https://cdn.example.com/carousel-1.jpg', kind: 'image' }, |
| 190 | + { url: 'https://cdn.example.com/carousel-2.mp4', kind: 'video' }, |
| 191 | + ]) |
| 192 | + expect(maxActiveResolutions).toBe(1) |
| 193 | + }) |
| 194 | + |
| 195 | + it('supports legacy comma-separated URLs with an explicit video prefix', async () => { |
| 196 | + const result = await resolveInstagramCarouselMedia( |
| 197 | + 'https://cdn.example.com/carousel-1.jpg, video:https://cdn.example.com/carousel-2.mp4', |
| 198 | + context.userId, |
| 199 | + context.requestId, |
| 200 | + logger |
| 201 | + ) |
| 202 | + |
| 203 | + expect(result.items?.map(({ kind }) => kind)).toEqual(['image', 'video']) |
| 204 | + }) |
| 205 | + |
| 206 | + it('infers media types for uploaded file arrays', async () => { |
| 207 | + const result = await resolveInstagramCarouselMedia( |
| 208 | + [ |
| 209 | + uploadedFile(), |
| 210 | + uploadedFile({ |
| 211 | + id: 'file-2', |
| 212 | + key: 'execution/workflow-1/execution-1/video.mov', |
| 213 | + name: 'video.mov', |
| 214 | + type: 'video/quicktime', |
| 215 | + }), |
| 216 | + ], |
| 217 | + context.userId, |
| 218 | + context.requestId, |
| 219 | + logger |
| 220 | + ) |
| 221 | + |
| 222 | + expect(result.items?.map(({ kind }) => kind)).toEqual(['image', 'video']) |
| 223 | + }) |
| 224 | + |
| 225 | + it.each([ |
| 226 | + { count: 1, label: 'too few' }, |
| 227 | + { count: 11, label: 'too many' }, |
| 228 | + ])('rejects $label carousel items before resolving them', async ({ count }) => { |
| 229 | + const input = Array.from( |
| 230 | + { length: count }, |
| 231 | + (_, index) => `https://cdn.example.com/carousel-${index + 1}.jpg` |
| 232 | + ) |
| 233 | + |
| 234 | + const result = await resolveInstagramCarouselMedia( |
| 235 | + input, |
| 236 | + context.userId, |
| 237 | + context.requestId, |
| 238 | + logger |
| 239 | + ) |
| 240 | + |
| 241 | + expect(result.error).toEqual({ |
| 242 | + status: 400, |
| 243 | + message: 'Carousels require between 2 and 10 items', |
| 244 | + }) |
| 245 | + expect(mockResolveFileInputToUrl).not.toHaveBeenCalled() |
| 246 | + }) |
| 247 | + |
| 248 | + it('rejects malformed carousel JSON', async () => { |
| 249 | + const result = await resolveInstagramCarouselMedia( |
| 250 | + '[not-json', |
| 251 | + context.userId, |
| 252 | + context.requestId, |
| 253 | + logger |
| 254 | + ) |
| 255 | + |
| 256 | + expect(result.error).toEqual({ status: 400, message: 'Carousel media JSON is invalid' }) |
| 257 | + }) |
| 258 | +}) |
0 commit comments