From dae02a20d0750ea343f17d366a03c5d1a6c15819 Mon Sep 17 00:00:00 2001 From: Sim Pi Agent Date: Sun, 26 Jul 2026 07:17:24 +0000 Subject: [PATCH 1/4] test: add images to link previews --- LINK_PREVIEW_IMAGE_CHANGES.md | 111 +++ apps/sim/app/api/link-preview/route.test.ts | 843 ++++++++++++++++++++ apps/sim/app/api/link-preview/route.ts | 22 +- apps/sim/lib/api/contracts/link-preview.ts | 1 + 4 files changed, 974 insertions(+), 3 deletions(-) create mode 100644 LINK_PREVIEW_IMAGE_CHANGES.md create mode 100644 apps/sim/app/api/link-preview/route.test.ts diff --git a/LINK_PREVIEW_IMAGE_CHANGES.md b/LINK_PREVIEW_IMAGE_CHANGES.md new file mode 100644 index 00000000000..ce3dc6be341 --- /dev/null +++ b/LINK_PREVIEW_IMAGE_CHANGES.md @@ -0,0 +1,111 @@ +# Link Preview Image URL Feature + +## Summary + +Extended the `/api/link-preview` endpoint to extract and return image URLs from Open Graph and Twitter meta tags. The implementation supports both absolute and relative image URLs, with proper URL resolution and error handling. + +## Changes Made + +### 1. API Contract (`apps/sim/lib/api/contracts/link-preview.ts`) + +- Added `imageUrl: z.string().nullable()` field to the `linkPreviewResponseSchema` +- The `LinkPreview` type now includes `imageUrl` which consumers can access + +### 2. API Route (`apps/sim/app/api/link-preview/route.ts`) + +- Added `IMAGE_URL_MAX_CHARS` constant (2048 characters) +- Updated `parsePreview` function to: + - Accept a `baseUrl` parameter for resolving relative URLs + - Extract image URL from `og:image` or `twitter:image` meta tags (in that order) + - Resolve relative URLs to absolute URLs using the `URL` constructor + - Handle invalid URLs gracefully (returns null on parse error) + - Truncate long image URLs to the maximum allowed length + - Return a preview object with `imageUrl` field +- Updated `fetchPreview` to pass the base URL to `parsePreview` +- Modified the null check to include `imageUrl` (returns null only if all fields are empty) + +### 3. Tests (`apps/sim/app/api/link-preview/route.test.ts`) + +Created comprehensive test suite covering: + +#### Authentication & Rate Limiting +- Unauthenticated users receive 401 +- Rate limiting is enforced correctly + +#### URL Validation +- Missing, empty, invalid, and too-long URLs return 400 +- Non-HTTPS URLs return null preview without fetching + +#### Image URL Extraction +- Extracts absolute `og:image` URLs +- Extracts absolute `twitter:image` URLs when `og:image` is missing +- Prefers `og:image` over `twitter:image` when both exist +- Resolves relative image URLs (e.g., `/images/photo.jpg`) to absolute +- Resolves protocol-relative URLs (e.g., `//cdn.example.com/image.jpg`) +- Returns null `imageUrl` when missing +- Returns null `imageUrl` when URL resolution fails +- Truncates image URLs exceeding maximum length +- Returns preview with only `imageUrl` when no other metadata exists + +#### Caching Behavior +- Returns cached previews when available +- Caches successful fetches with 24-hour TTL +- Caches failed fetches with 1-hour TTL +- Handles Redis read/write failures gracefully +- Works when Redis is unavailable + +#### Fetch Failures +- Returns null preview on network errors +- Returns null preview for non-2xx status codes +- Returns null preview for non-HTML content types +- Returns null preview when no metadata is found + +#### Complete Metadata +- Extracts all fields (title, description, siteName, imageUrl) correctly + +## Implementation Details + +### URL Resolution + +The implementation uses JavaScript's built-in `URL` constructor to handle URL resolution: + +```typescript +const resolvedUrl = new URL(rawImageUrl, baseUrl) +``` + +This correctly handles: +- Absolute URLs: `https://example.com/image.jpg` → unchanged +- Relative URLs: `/images/photo.jpg` → `https://example.com/images/photo.jpg` +- Protocol-relative: `//cdn.example.com/image.jpg` → `https://cdn.example.com/image.jpg` + +### Error Handling + +When image URL resolution fails (e.g., invalid URL format): +- Logs a warning with truncated URL for debugging +- Sets `imageUrl` to `null` in the response +- Does not fail the entire preview (other fields still returned) + +### Backward Compatibility + +The changes are fully backward compatible: +- Existing consumers get the new `imageUrl` field automatically (typed as nullable) +- Components not using the image URL continue to work unchanged +- The `useLinkPreview` hook automatically includes the new field in its response type + +## Testing + +All tests follow the project's testing standards: +- Use `@vitest-environment node` +- Mock global dependencies properly +- Clear mocks between tests +- Test both success and failure paths +- Verify authentication, rate limiting, and caching behavior + +Run tests with: +```bash +bun test apps/sim/app/api/link-preview/route.test.ts +``` + +## Future Enhancements + +The `ExternalLink` component (`apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx`) currently displays title, description, and site name in a tooltip but does not display the image. The component could be enhanced to show the image thumbnail in the preview tooltip if desired. diff --git a/apps/sim/app/api/link-preview/route.test.ts b/apps/sim/app/api/link-preview/route.test.ts new file mode 100644 index 00000000000..19278db79d4 --- /dev/null +++ b/apps/sim/app/api/link-preview/route.test.ts @@ -0,0 +1,843 @@ +/** + * Tests for link preview API route + * + * @vitest-environment node + */ + +import { + authMockFns, + createMockRedis, + createMockRequest, + inputValidationMock, + inputValidationMockFns, + redisConfigMockFns, + type MockRedis, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockEnforceUserRateLimit = vi.fn() + +vi.mock('@/lib/core/rate-limiter/route-helpers', () => ({ + enforceUserRateLimit: mockEnforceUserRateLimit, +})) + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + +import { GET } from '@/app/api/link-preview/route' + +describe('Link Preview API Route', () => { + let mockRedis: MockRedis + + beforeEach(() => { + vi.clearAllMocks() + + // Default auth: authenticated user + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-123', email: 'test@example.com' }, + session: { id: 'session-123' }, + }) + + // Default rate limit: not limited + mockEnforceUserRateLimit.mockResolvedValue(null) + + // Default redis: available + mockRedis = createMockRedis() + redisConfigMockFns.mockGetRedisClient.mockReturnValue(mockRedis as never) + }) + + describe('Authentication', () => { + it('should return 401 when user is not authenticated', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data.error).toBe('Unauthorized') + }) + + it('should return 401 when session exists but no user', async () => { + authMockFns.mockGetSession.mockResolvedValue({ session: { id: 'session-123' } }) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data.error).toBe('Unauthorized') + }) + }) + + describe('Rate Limiting', () => { + it('should enforce rate limits for the user', async () => { + const rateLimitResponse = new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { + status: 429, + }) + mockEnforceUserRateLimit.mockResolvedValue(rateLimitResponse) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(429) + expect(data.error).toBe('Rate limit exceeded') + expect(mockEnforceUserRateLimit).toHaveBeenCalledWith('link-preview', 'user-123') + }) + }) + + describe('URL Validation', () => { + it('should return 400 when url parameter is missing', async () => { + const request = createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/link-preview') + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('url is required') + }) + + it('should return 400 when url parameter is empty', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('url is required') + }) + + it('should return 400 when url parameter is not a valid URL', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=not-a-url' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('url must be a valid URL') + }) + + it('should return 400 when url parameter exceeds maximum length', async () => { + const longUrl = 'https://example.com/' + 'a'.repeat(2100) + const request = createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/link-preview?url=${encodeURIComponent(longUrl)}` + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('url must be 2048 characters or less') + }) + + it('should return null preview for non-HTTPS URLs', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=http://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview).toBeNull() + expect(inputValidationMockFns.mockSecureFetchWithValidation).not.toHaveBeenCalled() + }) + }) + + describe('Image URL Extraction', () => { + it('should extract absolute og:image URL', async () => { + const html = ` + + + + Test Page + + + + + + ` + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + new Response(html, { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview).toEqual({ + title: 'Test Title', + description: 'Test Description', + siteName: null, + imageUrl: 'https://example.com/image.jpg', + }) + }) + + it('should extract absolute twitter:image URL when og:image is missing', async () => { + const html = ` + + + + Test Page + + + + + ` + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + new Response(html, { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview).toEqual({ + title: 'Test Title', + description: null, + siteName: null, + imageUrl: 'https://example.com/twitter-image.jpg', + }) + }) + + it('should prefer og:image over twitter:image when both exist', async () => { + const html = ` + + + + Test Page + + + + + + ` + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + new Response(html, { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview?.imageUrl).toBe('https://example.com/og-image.jpg') + }) + + it('should resolve relative image URLs to absolute URLs', async () => { + const html = ` + + + + Test Page + + + + + ` + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + new Response(html, { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com/page/article' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview?.imageUrl).toBe('https://example.com/images/relative-image.jpg') + }) + + it('should resolve protocol-relative image URLs', async () => { + const html = ` + + + + Test Page + + + + + ` + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + new Response(html, { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview?.imageUrl).toBe('https://cdn.example.com/image.jpg') + }) + + it('should handle missing image URL', async () => { + const html = ` + + + + Test Page + + + + + ` + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + new Response(html, { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview).toEqual({ + title: 'Test Title', + description: 'Test Description', + siteName: null, + imageUrl: null, + }) + }) + + it('should return null imageUrl when image URL resolution fails', async () => { + const html = ` + + + + Test Page + + + + + ` + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + new Response(html, { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview).toEqual({ + title: 'Test Title', + description: null, + siteName: null, + imageUrl: null, + }) + }) + + it('should truncate long image URLs', async () => { + const longImageUrl = 'https://example.com/' + 'a'.repeat(2100) + '.jpg' + const html = ` + + + + Test Page + + + + + ` + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + new Response(html, { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview?.imageUrl).toBeDefined() + expect(data.preview?.imageUrl?.length).toBeLessThanOrEqual(2048) + expect(data.preview?.imageUrl).toContain('...') + }) + + it('should return preview with imageUrl only when no other metadata exists', async () => { + const html = ` + + + + + + + ` + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + new Response(html, { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview).toEqual({ + title: null, + description: null, + siteName: null, + imageUrl: 'https://example.com/image.jpg', + }) + }) + }) + + describe('Caching', () => { + it('should return cached preview when available', async () => { + const cachedPreview = { + title: 'Cached Title', + description: 'Cached Description', + siteName: 'Cached Site', + imageUrl: 'https://example.com/cached-image.jpg', + } + + mockRedis.get.mockResolvedValue(JSON.stringify(cachedPreview)) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview).toEqual(cachedPreview) + expect(mockRedis.get).toHaveBeenCalled() + expect(inputValidationMockFns.mockSecureFetchWithValidation).not.toHaveBeenCalled() + }) + + it('should cache successful preview fetches', async () => { + mockRedis.get.mockResolvedValue(null) + + const html = ` + + + + Test Page + + + + + ` + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + new Response(html, { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(mockRedis.set).toHaveBeenCalled() + const setCalls = (mockRedis.set as ReturnType).mock.calls + const cacheValue = JSON.parse(setCalls[0][1] as string) + expect(cacheValue).toEqual(data.preview) + expect(setCalls[0][3]).toBe(24 * 60 * 60) // TTL for successful fetch + }) + + it('should cache null previews with shorter TTL', async () => { + mockRedis.get.mockResolvedValue(null) + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + new Response('Not Found', { status: 404 }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + await response.json() + + expect(mockRedis.set).toHaveBeenCalled() + const setCalls = (mockRedis.set as ReturnType).mock.calls + expect(setCalls[0][3]).toBe(60 * 60) // TTL for failed fetch + }) + + it('should handle redis cache read failures gracefully', async () => { + mockRedis.get.mockRejectedValue(new Error('Redis connection failed')) + + const html = ` + + + + Test Page + + + + ` + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + new Response(html, { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview).toBeTruthy() + }) + + it('should handle redis cache write failures gracefully', async () => { + mockRedis.get.mockResolvedValue(null) + mockRedis.set.mockRejectedValue(new Error('Redis write failed')) + + const html = ` + + + + Test Page + + + + ` + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + new Response(html, { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview).toBeTruthy() + }) + + it('should work when redis is not available', async () => { + redisConfigMockFns.mockGetRedisClient.mockReturnValue(null) + + const html = ` + + + + Test Page + + + + ` + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + new Response(html, { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview).toBeTruthy() + }) + }) + + describe('Fetch Failures', () => { + it('should return null preview when fetch fails', async () => { + mockRedis.get.mockResolvedValue(null) + + inputValidationMockFns.mockSecureFetchWithValidation.mockRejectedValue( + new Error('Network error') + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview).toBeNull() + }) + + it('should return null preview for non-2xx status codes', async () => { + mockRedis.get.mockResolvedValue(null) + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + new Response('Not Found', { status: 404 }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview).toBeNull() + }) + + it('should return null preview for non-HTML content types', async () => { + mockRedis.get.mockResolvedValue(null) + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview).toBeNull() + }) + + it('should return null preview when no metadata is found', async () => { + mockRedis.get.mockResolvedValue(null) + + const html = ` + + + + + + ` + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + new Response(html, { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview).toBeNull() + }) + }) + + describe('Complete Metadata Extraction', () => { + it('should extract all metadata fields including imageUrl', async () => { + mockRedis.get.mockResolvedValue(null) + + const html = ` + + + + Fallback Title + + + + + + + ` + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + new Response(html, { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview).toEqual({ + title: 'Open Graph Title', + description: 'Open Graph Description', + siteName: 'Example Site', + imageUrl: 'https://example.com/og-image.jpg', + }) + }) + }) +}) diff --git a/apps/sim/app/api/link-preview/route.ts b/apps/sim/app/api/link-preview/route.ts index 5dc8c230df1..097fdfb69b7 100644 --- a/apps/sim/app/api/link-preview/route.ts +++ b/apps/sim/app/api/link-preview/route.ts @@ -21,6 +21,7 @@ const MAX_RESPONSE_BYTES = 256 * 1024 const MAX_REDIRECTS = 3 const TITLE_MAX_CHARS = 200 const DESCRIPTION_MAX_CHARS = 300 +const IMAGE_URL_MAX_CHARS = 2048 const CACHE_TTL_SECONDS = 24 * 60 * 60 const NEGATIVE_CACHE_TTL_SECONDS = 60 * 60 const CACHE_KEY_PREFIX = 'link-preview:v1:' @@ -30,7 +31,7 @@ const CACHE_KEY_PREFIX = 'link-preview:v1:' * MAX_RESPONSE_BYTES); cheerio handles attribute order, quoting, and entity * decoding. */ -function parsePreview(html: string): LinkPreview { +function parsePreview(html: string, baseUrl: string): LinkPreview { const $ = cheerio.load(html) const meta = (key: string): string | null => { @@ -42,12 +43,27 @@ function parsePreview(html: string): LinkPreview { meta('og:title') ?? meta('twitter:title') ?? ($('title').first().text().trim() || null) const description = meta('og:description') ?? meta('twitter:description') ?? meta('description') const siteName = meta('og:site_name') + const rawImageUrl = meta('og:image') ?? meta('twitter:image') - if (!title && !description && !siteName) return null + let imageUrl: string | null = null + if (rawImageUrl) { + try { + const resolvedUrl = new URL(rawImageUrl, baseUrl) + imageUrl = truncate(resolvedUrl.href, IMAGE_URL_MAX_CHARS) + } catch (error) { + logger.warn('Failed to resolve image URL', { + rawImageUrl: truncate(rawImageUrl, 100), + error: getErrorMessage(error), + }) + } + } + + if (!title && !description && !siteName && !imageUrl) return null return { title: title ? truncate(title, TITLE_MAX_CHARS) : null, description: description ? truncate(description, DESCRIPTION_MAX_CHARS) : null, siteName: siteName ? truncate(siteName, TITLE_MAX_CHARS) : null, + imageUrl, } } @@ -66,7 +82,7 @@ async function fetchPreview(url: string): Promise { if (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) { return null } - return parsePreview(await response.text()) + return parsePreview(await response.text(), url) } export const GET = withRouteHandler(async (request: NextRequest) => { diff --git a/apps/sim/lib/api/contracts/link-preview.ts b/apps/sim/lib/api/contracts/link-preview.ts index 48b705c19e4..daeccda78ba 100644 --- a/apps/sim/lib/api/contracts/link-preview.ts +++ b/apps/sim/lib/api/contracts/link-preview.ts @@ -16,6 +16,7 @@ export const linkPreviewResponseSchema = z.object({ title: z.string().nullable(), description: z.string().nullable(), siteName: z.string().nullable(), + imageUrl: z.string().nullable(), }) .nullable(), }) From 82edd48d190ba5ef8da81a4c9ce74fe56f7c7e25 Mon Sep 17 00:00:00 2001 From: Sim Pi Agent Date: Sun, 26 Jul 2026 07:28:44 +0000 Subject: [PATCH 2/4] Pi Babysit: address PR #5968 feedback --- LINK_PREVIEW_IMAGE_CHANGES.md | 111 ------------------ apps/sim/app/api/link-preview/route.test.ts | 108 +++++++++++------ apps/sim/app/api/link-preview/route.ts | 14 ++- .../core/security/input-validation.server.ts | 3 + 4 files changed, 84 insertions(+), 152 deletions(-) delete mode 100644 LINK_PREVIEW_IMAGE_CHANGES.md diff --git a/LINK_PREVIEW_IMAGE_CHANGES.md b/LINK_PREVIEW_IMAGE_CHANGES.md deleted file mode 100644 index ce3dc6be341..00000000000 --- a/LINK_PREVIEW_IMAGE_CHANGES.md +++ /dev/null @@ -1,111 +0,0 @@ -# Link Preview Image URL Feature - -## Summary - -Extended the `/api/link-preview` endpoint to extract and return image URLs from Open Graph and Twitter meta tags. The implementation supports both absolute and relative image URLs, with proper URL resolution and error handling. - -## Changes Made - -### 1. API Contract (`apps/sim/lib/api/contracts/link-preview.ts`) - -- Added `imageUrl: z.string().nullable()` field to the `linkPreviewResponseSchema` -- The `LinkPreview` type now includes `imageUrl` which consumers can access - -### 2. API Route (`apps/sim/app/api/link-preview/route.ts`) - -- Added `IMAGE_URL_MAX_CHARS` constant (2048 characters) -- Updated `parsePreview` function to: - - Accept a `baseUrl` parameter for resolving relative URLs - - Extract image URL from `og:image` or `twitter:image` meta tags (in that order) - - Resolve relative URLs to absolute URLs using the `URL` constructor - - Handle invalid URLs gracefully (returns null on parse error) - - Truncate long image URLs to the maximum allowed length - - Return a preview object with `imageUrl` field -- Updated `fetchPreview` to pass the base URL to `parsePreview` -- Modified the null check to include `imageUrl` (returns null only if all fields are empty) - -### 3. Tests (`apps/sim/app/api/link-preview/route.test.ts`) - -Created comprehensive test suite covering: - -#### Authentication & Rate Limiting -- Unauthenticated users receive 401 -- Rate limiting is enforced correctly - -#### URL Validation -- Missing, empty, invalid, and too-long URLs return 400 -- Non-HTTPS URLs return null preview without fetching - -#### Image URL Extraction -- Extracts absolute `og:image` URLs -- Extracts absolute `twitter:image` URLs when `og:image` is missing -- Prefers `og:image` over `twitter:image` when both exist -- Resolves relative image URLs (e.g., `/images/photo.jpg`) to absolute -- Resolves protocol-relative URLs (e.g., `//cdn.example.com/image.jpg`) -- Returns null `imageUrl` when missing -- Returns null `imageUrl` when URL resolution fails -- Truncates image URLs exceeding maximum length -- Returns preview with only `imageUrl` when no other metadata exists - -#### Caching Behavior -- Returns cached previews when available -- Caches successful fetches with 24-hour TTL -- Caches failed fetches with 1-hour TTL -- Handles Redis read/write failures gracefully -- Works when Redis is unavailable - -#### Fetch Failures -- Returns null preview on network errors -- Returns null preview for non-2xx status codes -- Returns null preview for non-HTML content types -- Returns null preview when no metadata is found - -#### Complete Metadata -- Extracts all fields (title, description, siteName, imageUrl) correctly - -## Implementation Details - -### URL Resolution - -The implementation uses JavaScript's built-in `URL` constructor to handle URL resolution: - -```typescript -const resolvedUrl = new URL(rawImageUrl, baseUrl) -``` - -This correctly handles: -- Absolute URLs: `https://example.com/image.jpg` → unchanged -- Relative URLs: `/images/photo.jpg` → `https://example.com/images/photo.jpg` -- Protocol-relative: `//cdn.example.com/image.jpg` → `https://cdn.example.com/image.jpg` - -### Error Handling - -When image URL resolution fails (e.g., invalid URL format): -- Logs a warning with truncated URL for debugging -- Sets `imageUrl` to `null` in the response -- Does not fail the entire preview (other fields still returned) - -### Backward Compatibility - -The changes are fully backward compatible: -- Existing consumers get the new `imageUrl` field automatically (typed as nullable) -- Components not using the image URL continue to work unchanged -- The `useLinkPreview` hook automatically includes the new field in its response type - -## Testing - -All tests follow the project's testing standards: -- Use `@vitest-environment node` -- Mock global dependencies properly -- Clear mocks between tests -- Test both success and failure paths -- Verify authentication, rate limiting, and caching behavior - -Run tests with: -```bash -bun test apps/sim/app/api/link-preview/route.test.ts -``` - -## Future Enhancements - -The `ExternalLink` component (`apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx`) currently displays title, description, and site name in a tooltip but does not display the image. The component could be enhanced to show the image thumbnail in the preview tooltip if desired. diff --git a/apps/sim/app/api/link-preview/route.test.ts b/apps/sim/app/api/link-preview/route.test.ts index 19278db79d4..179a402b789 100644 --- a/apps/sim/app/api/link-preview/route.test.ts +++ b/apps/sim/app/api/link-preview/route.test.ts @@ -193,10 +193,10 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - new Response(html, { + Object.assign(new Response(html, { status: 200, headers: { 'content-type': 'text/html' }, - }) + }), { url: 'https://example.com' }) ) const request = createMockRequest( @@ -231,10 +231,10 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - new Response(html, { + Object.assign(new Response(html, { status: 200, headers: { 'content-type': 'text/html' }, - }) + }), { url: 'https://example.com' }) ) const request = createMockRequest( @@ -270,10 +270,10 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - new Response(html, { + Object.assign(new Response(html, { status: 200, headers: { 'content-type': 'text/html' }, - }) + }), { url: 'https://example.com' }) ) const request = createMockRequest( @@ -303,10 +303,10 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - new Response(html, { + Object.assign(new Response(html, { status: 200, headers: { 'content-type': 'text/html' }, - }) + }), { url: 'https://example.com' }) ) const request = createMockRequest( @@ -336,10 +336,10 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - new Response(html, { + Object.assign(new Response(html, { status: 200, headers: { 'content-type': 'text/html' }, - }) + }), { url: 'https://example.com' }) ) const request = createMockRequest( @@ -356,6 +356,39 @@ describe('Link Preview API Route', () => { expect(data.preview?.imageUrl).toBe('https://cdn.example.com/image.jpg') }) + it('should resolve relative image URLs against final URL after redirects', async () => { + const html = ` + + + + Test Page + + + + + ` + + inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( + Object.assign(new Response(html, { + status: 200, + headers: { 'content-type': 'text/html' }, + }), { url: 'https://redirected.example.com/page' }) + ) + + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/link-preview?url=https://example.com' + ) + + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.preview?.imageUrl).toBe('https://redirected.example.com/images/redirected-image.jpg') + }) + it('should handle missing image URL', async () => { const html = ` @@ -369,10 +402,10 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - new Response(html, { + Object.assign(new Response(html, { status: 200, headers: { 'content-type': 'text/html' }, - }) + }), { url: 'https://example.com' }) ) const request = createMockRequest( @@ -407,10 +440,10 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - new Response(html, { + Object.assign(new Response(html, { status: 200, headers: { 'content-type': 'text/html' }, - }) + }), { url: 'https://example.com' }) ) const request = createMockRequest( @@ -432,7 +465,7 @@ describe('Link Preview API Route', () => { }) }) - it('should truncate long image URLs', async () => { + it('should drop image URLs that exceed maximum length', async () => { const longImageUrl = 'https://example.com/' + 'a'.repeat(2100) + '.jpg' const html = ` @@ -446,10 +479,10 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - new Response(html, { + Object.assign(new Response(html, { status: 200, headers: { 'content-type': 'text/html' }, - }) + }), { url: 'https://example.com' }) ) const request = createMockRequest( @@ -463,9 +496,8 @@ describe('Link Preview API Route', () => { const data = await response.json() expect(response.status).toBe(200) - expect(data.preview?.imageUrl).toBeDefined() - expect(data.preview?.imageUrl?.length).toBeLessThanOrEqual(2048) - expect(data.preview?.imageUrl).toContain('...') + expect(data.preview?.imageUrl).toBeNull() + expect(data.preview?.title).toBe('Test Title') }) it('should return preview with imageUrl only when no other metadata exists', async () => { @@ -479,10 +511,10 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - new Response(html, { + Object.assign(new Response(html, { status: 200, headers: { 'content-type': 'text/html' }, - }) + }), { url: 'https://example.com' }) ) const request = createMockRequest( @@ -547,10 +579,10 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - new Response(html, { + Object.assign(new Response(html, { status: 200, headers: { 'content-type': 'text/html' }, - }) + }), { url: 'https://example.com' }) ) const request = createMockRequest( @@ -575,7 +607,7 @@ describe('Link Preview API Route', () => { mockRedis.get.mockResolvedValue(null) inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - new Response('Not Found', { status: 404 }) + Object.assign(new Response('Not Found', { status: 404 }), { url: 'https://example.com' }) ) const request = createMockRequest( @@ -607,10 +639,10 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - new Response(html, { + Object.assign(new Response(html, { status: 200, headers: { 'content-type': 'text/html' }, - }) + }), { url: 'https://example.com' }) ) const request = createMockRequest( @@ -642,10 +674,10 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - new Response(html, { + Object.assign(new Response(html, { status: 200, headers: { 'content-type': 'text/html' }, - }) + }), { url: 'https://example.com' }) ) const request = createMockRequest( @@ -676,10 +708,10 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - new Response(html, { + Object.assign(new Response(html, { status: 200, headers: { 'content-type': 'text/html' }, - }) + }), { url: 'https://example.com' }) ) const request = createMockRequest( @@ -723,7 +755,7 @@ describe('Link Preview API Route', () => { mockRedis.get.mockResolvedValue(null) inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - new Response('Not Found', { status: 404 }) + Object.assign(new Response('Not Found', { status: 404 }), { url: 'https://example.com' }) ) const request = createMockRequest( @@ -744,10 +776,10 @@ describe('Link Preview API Route', () => { mockRedis.get.mockResolvedValue(null) inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - new Response('{}', { + Object.assign(new Response('{}' , { status: 200, headers: { 'content-type': 'application/json' }, - }) + }), { url: 'https://example.com' }) ) const request = createMockRequest( @@ -776,10 +808,10 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - new Response(html, { + Object.assign(new Response(html, { status: 200, headers: { 'content-type': 'text/html' }, - }) + }), { url: 'https://example.com' }) ) const request = createMockRequest( @@ -815,10 +847,10 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - new Response(html, { + Object.assign(new Response(html, { status: 200, headers: { 'content-type': 'text/html' }, - }) + }), { url: 'https://example.com' }) ) const request = createMockRequest( diff --git a/apps/sim/app/api/link-preview/route.ts b/apps/sim/app/api/link-preview/route.ts index 097fdfb69b7..a4ff7b71cb6 100644 --- a/apps/sim/app/api/link-preview/route.ts +++ b/apps/sim/app/api/link-preview/route.ts @@ -24,7 +24,7 @@ const DESCRIPTION_MAX_CHARS = 300 const IMAGE_URL_MAX_CHARS = 2048 const CACHE_TTL_SECONDS = 24 * 60 * 60 const NEGATIVE_CACHE_TTL_SECONDS = 60 * 60 -const CACHE_KEY_PREFIX = 'link-preview:v1:' +const CACHE_KEY_PREFIX = 'link-preview:v2:' /** * Parses preview metadata from the fetched document (already capped at @@ -49,7 +49,15 @@ function parsePreview(html: string, baseUrl: string): LinkPreview { if (rawImageUrl) { try { const resolvedUrl = new URL(rawImageUrl, baseUrl) - imageUrl = truncate(resolvedUrl.href, IMAGE_URL_MAX_CHARS) + if (resolvedUrl.href.length <= IMAGE_URL_MAX_CHARS) { + imageUrl = resolvedUrl.href + } else { + logger.warn('Image URL exceeds maximum length; dropping', { + rawImageUrl: truncate(rawImageUrl, 100), + resolvedLength: resolvedUrl.href.length, + maxLength: IMAGE_URL_MAX_CHARS, + }) + } } catch (error) { logger.warn('Failed to resolve image URL', { rawImageUrl: truncate(rawImageUrl, 100), @@ -82,7 +90,7 @@ async function fetchPreview(url: string): Promise { if (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) { return null } - return parsePreview(await response.text(), url) + return parsePreview(await response.text(), response.url) } export const GET = withRouteHandler(async (request: NextRequest) => { diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index e8ea4071577..ca44e901fed 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -462,6 +462,7 @@ export interface SecureFetchResponse { statusText: string headers: SecureFetchHeaders body: ReadableStream | null + url: string text: () => Promise json: () => Promise arrayBuffer: () => Promise @@ -1170,6 +1171,7 @@ export async function secureFetchWithPinnedIP( statusText: res.statusMessage || '', headers: new SecureFetchHeaders(headersRecord, setCookieArray), body: null, + url, text: async () => '', json: async () => ({}), arrayBuffer: async () => new ArrayBuffer(0), @@ -1249,6 +1251,7 @@ export async function secureFetchWithPinnedIP( statusText: res.statusMessage || '', headers: new SecureFetchHeaders(headersRecord, setCookieArray), body, + url, text: async () => (await readBodyAsBuffer()).toString('utf-8'), json: async () => JSON.parse((await readBodyAsBuffer()).toString('utf-8')), arrayBuffer: async () => { From 04b1759b5c401631ca0839cd5674412b9dbe6bf2 Mon Sep 17 00:00:00 2001 From: Sim Pi Agent Date: Sun, 26 Jul 2026 07:37:46 +0000 Subject: [PATCH 3/4] Pi Babysit: address PR #5968 feedback --- apps/sim/app/api/link-preview/route.test.ts | 110 +++++++++++++------- 1 file changed, 73 insertions(+), 37 deletions(-) diff --git a/apps/sim/app/api/link-preview/route.test.ts b/apps/sim/app/api/link-preview/route.test.ts index 179a402b789..798a9656615 100644 --- a/apps/sim/app/api/link-preview/route.test.ts +++ b/apps/sim/app/api/link-preview/route.test.ts @@ -24,6 +24,25 @@ vi.mock('@/lib/core/rate-limiter/route-helpers', () => ({ vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) import { GET } from '@/app/api/link-preview/route' +/** + * Creates a mock Response with a settable url property. + * Response.url is read-only, so we create a mock object that looks like a Response. + */ +function createMockResponse( + body: string | null, + init: ResponseInit & { url: string } +): Response { + const { url, ...responseInit } = init + const response = new Response(body, responseInit) + // Create a wrapper that delegates to the real Response but allows url override + Object.defineProperty(response, 'url', { + value: url, + writable: false, + enumerable: true, + configurable: true, + }) + return response +} describe('Link Preview API Route', () => { let mockRedis: MockRedis @@ -83,7 +102,7 @@ describe('Link Preview API Route', () => { describe('Rate Limiting', () => { it('should enforce rate limits for the user', async () => { - const rateLimitResponse = new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { + const rateLimitResponse = new Response(JSON.stringify({ error: 'Rate limit exceeded' }) { status: 429, }) mockEnforceUserRateLimit.mockResolvedValue(rateLimitResponse) @@ -193,10 +212,11 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response(html, { + createMockResponse(html, { status: 200, headers: { 'content-type': 'text/html' }, - }), { url: 'https://example.com' }) + url: 'https://example.com', + }) ) const request = createMockRequest( @@ -231,10 +251,11 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response(html, { + createMockResponse(html, { status: 200, headers: { 'content-type': 'text/html' }, - }), { url: 'https://example.com' }) + url: 'https://example.com', + }) ) const request = createMockRequest( @@ -270,10 +291,11 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response(html, { + createMockResponse(html, { status: 200, headers: { 'content-type': 'text/html' }, - }), { url: 'https://example.com' }) + url: 'https://example.com', + }) ) const request = createMockRequest( @@ -303,10 +325,11 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response(html, { + createMockResponse(html, { status: 200, headers: { 'content-type': 'text/html' }, - }), { url: 'https://example.com' }) + url: 'https://example.com', + }) ) const request = createMockRequest( @@ -336,10 +359,11 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response(html, { + createMockResponse(html, { status: 200, headers: { 'content-type': 'text/html' }, - }), { url: 'https://example.com' }) + url: 'https://example.com', + }) ) const request = createMockRequest( @@ -369,10 +393,11 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response(html, { + createMockResponse(html, { status: 200, headers: { 'content-type': 'text/html' }, - }), { url: 'https://redirected.example.com/page' }) + url: 'https://redirected.example.com/page', + }) ) const request = createMockRequest( @@ -402,10 +427,11 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response(html, { + createMockResponse(html, { status: 200, headers: { 'content-type': 'text/html' }, - }), { url: 'https://example.com' }) + url: 'https://example.com', + }) ) const request = createMockRequest( @@ -440,10 +466,11 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response(html, { + createMockResponse(html, { status: 200, headers: { 'content-type': 'text/html' }, - }), { url: 'https://example.com' }) + url: 'https://example.com', + }) ) const request = createMockRequest( @@ -479,10 +506,11 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response(html, { + createMockResponse(html, { status: 200, headers: { 'content-type': 'text/html' }, - }), { url: 'https://example.com' }) + url: 'https://example.com', + }) ) const request = createMockRequest( @@ -511,10 +539,11 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response(html, { + createMockResponse(html, { status: 200, headers: { 'content-type': 'text/html' }, - }), { url: 'https://example.com' }) + url: 'https://example.com', + }) ) const request = createMockRequest( @@ -579,10 +608,11 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response(html, { + createMockResponse(html, { status: 200, headers: { 'content-type': 'text/html' }, - }), { url: 'https://example.com' }) + url: 'https://example.com', + }) ) const request = createMockRequest( @@ -607,7 +637,7 @@ describe('Link Preview API Route', () => { mockRedis.get.mockResolvedValue(null) inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response('Not Found', { status: 404 }), { url: 'https://example.com' }) + createMockResponse('Not Found', { status: 404, url: 'https://example.com' }) ) const request = createMockRequest( @@ -639,10 +669,11 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response(html, { + createMockResponse(html, { status: 200, headers: { 'content-type': 'text/html' }, - }), { url: 'https://example.com' }) + url: 'https://example.com', + }) ) const request = createMockRequest( @@ -674,10 +705,11 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response(html, { + createMockResponse(html, { status: 200, headers: { 'content-type': 'text/html' }, - }), { url: 'https://example.com' }) + url: 'https://example.com', + }) ) const request = createMockRequest( @@ -708,10 +740,11 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response(html, { + createMockResponse(html, { status: 200, headers: { 'content-type': 'text/html' }, - }), { url: 'https://example.com' }) + url: 'https://example.com', + }) ) const request = createMockRequest( @@ -755,7 +788,7 @@ describe('Link Preview API Route', () => { mockRedis.get.mockResolvedValue(null) inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response('Not Found', { status: 404 }), { url: 'https://example.com' }) + createMockResponse('Not Found', { status: 404, url: 'https://example.com' }) ) const request = createMockRequest( @@ -776,10 +809,11 @@ describe('Link Preview API Route', () => { mockRedis.get.mockResolvedValue(null) inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response('{}' , { + createMockResponse('{}', { status: 200, headers: { 'content-type': 'application/json' }, - }), { url: 'https://example.com' }) + url: 'https://example.com', + }) ) const request = createMockRequest( @@ -808,10 +842,11 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response(html, { + createMockResponse(html, { status: 200, headers: { 'content-type': 'text/html' }, - }), { url: 'https://example.com' }) + url: 'https://example.com', + }) ) const request = createMockRequest( @@ -847,10 +882,11 @@ describe('Link Preview API Route', () => { ` inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue( - Object.assign(new Response(html, { + createMockResponse(html, { status: 200, headers: { 'content-type': 'text/html' }, - }), { url: 'https://example.com' }) + url: 'https://example.com', + }) ) const request = createMockRequest( From abb5282e9d8414dfdeb93ddf6876c7b5fa0866a8 Mon Sep 17 00:00:00 2001 From: Sim Pi Agent Date: Sun, 26 Jul 2026 07:45:31 +0000 Subject: [PATCH 4/4] Pi Babysit: address PR #5968 feedback --- apps/sim/app/api/link-preview/route.test.ts | 2 +- apps/sim/app/api/tools/agiloft/attach/route.test.ts | 1 + apps/sim/app/api/tools/agiloft/retrieve/route.test.ts | 1 + apps/sim/app/api/tools/stt/route.test.ts | 1 + apps/sim/lib/knowledge/documents/utils.test.ts | 1 + 5 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/link-preview/route.test.ts b/apps/sim/app/api/link-preview/route.test.ts index 798a9656615..7cbf71002d5 100644 --- a/apps/sim/app/api/link-preview/route.test.ts +++ b/apps/sim/app/api/link-preview/route.test.ts @@ -460,7 +460,7 @@ describe('Link Preview API Route', () => { Test Page - + ` diff --git a/apps/sim/app/api/tools/agiloft/attach/route.test.ts b/apps/sim/app/api/tools/agiloft/attach/route.test.ts index 3f1ac2d1fae..a7dfd4b0fc8 100644 --- a/apps/sim/app/api/tools/agiloft/attach/route.test.ts +++ b/apps/sim/app/api/tools/agiloft/attach/route.test.ts @@ -55,6 +55,7 @@ function mockSecureFetchResponse(body: { statusText: '', headers: new Headers(), body: null, + url: 'https://example.com', text: async () => body.text ?? '', json: async () => body.json ?? {}, arrayBuffer: async () => new ArrayBuffer(0), diff --git a/apps/sim/app/api/tools/agiloft/retrieve/route.test.ts b/apps/sim/app/api/tools/agiloft/retrieve/route.test.ts index efd435b5b04..994b5b6539e 100644 --- a/apps/sim/app/api/tools/agiloft/retrieve/route.test.ts +++ b/apps/sim/app/api/tools/agiloft/retrieve/route.test.ts @@ -40,6 +40,7 @@ function mockSecureFetchResponse(body: { statusText: '', headers: body.headers ?? new Headers(), body: null, + url: 'https://example.com', text: async () => body.text ?? '', json: async () => body.json ?? {}, arrayBuffer: async () => body.arrayBuffer ?? new ArrayBuffer(0), diff --git a/apps/sim/app/api/tools/stt/route.test.ts b/apps/sim/app/api/tools/stt/route.test.ts index 3413350e8e5..c95b1d0d01a 100644 --- a/apps/sim/app/api/tools/stt/route.test.ts +++ b/apps/sim/app/api/tools/stt/route.test.ts @@ -51,6 +51,7 @@ function mockSecureFetchResponse(body: { ok?: boolean; contentType?: string }) { statusText: '', headers: new Headers({ 'content-type': body.contentType ?? 'audio/mpeg' }), body: null, + url: 'https://example.com', text: async () => '', json: async () => ({}), arrayBuffer: async () => new ArrayBuffer(8), diff --git a/apps/sim/lib/knowledge/documents/utils.test.ts b/apps/sim/lib/knowledge/documents/utils.test.ts index afa2bf1483b..657f26b8b57 100644 --- a/apps/sim/lib/knowledge/documents/utils.test.ts +++ b/apps/sim/lib/knowledge/documents/utils.test.ts @@ -26,6 +26,7 @@ function fakeResponse( statusText: `status-${status}`, headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, body: null, + url: 'https://example.com', text: async () => options.body ?? '', json: async () => JSON.parse(options.body ?? '{}'), arrayBuffer: async () => new ArrayBuffer(0),