import { describe, expect, it } from 'vitest' import type { InstagramPost } from '@/utilities/socialFeed/instagramFeed' import { INSTAGRAM_MAX_RESULTS_CAP, eligibleInstagramPosts, loadInstagramFeed, parseInstagramMediaPage, parseInstagramMediaResponse, pickInstagramThumbnail, } from '@/utilities/socialFeed/instagramFeed' const MEDIA_ITEM = ( id: string, overrides: Record = {}, ): Record => ({ id, caption: `Legenda ${id}`, media_type: 'IMAGE', media_url: `https://scontent.cdninstagram.com/${id}.jpg`, permalink: `https://www.instagram.com/p/${id}/`, timestamp: '2026-08-18T10:00:00+00:00', ...overrides, }) describe('pickInstagramThumbnail', () => { it('uses the media URL for images', () => { expect(pickInstagramThumbnail(MEDIA_ITEM('img'))).toBe( 'https://scontent.cdninstagram.com/img.jpg', ) }) it('uses the thumbnail for videos and reels, never the mp4', () => { expect( pickInstagramThumbnail( MEDIA_ITEM('vid', { media_type: 'VIDEO', media_url: 'https://scontent.cdninstagram.com/vid.mp4', thumbnail_url: 'https://scontent.cdninstagram.com/vid.jpg', }), ), ).toBe('https://scontent.cdninstagram.com/vid.jpg') expect( pickInstagramThumbnail( MEDIA_ITEM('reel', { media_type: 'REEL', thumbnail_url: 'https://scontent.cdninstagram.com/reel.jpg', }), ), ).toBe('https://scontent.cdninstagram.com/reel.jpg') }) it('resolves carousels from the first child that carries a URL', () => { expect( pickInstagramThumbnail( MEDIA_ITEM('car', { media_type: 'CAROUSEL_ALBUM', children: { data: [ { media_url: 'https://scontent.cdninstagram.com/first.jpg' }, { media_url: 'https://scontent.cdninstagram.com/second.jpg' }, ], }, }), ), ).toBe('https://scontent.cdninstagram.com/first.jpg') expect( pickInstagramThumbnail( MEDIA_ITEM('car2', { media_type: 'CAROUSEL_ALBUM', children: { data: [ { thumbnail_url: 'https://scontent.cdninstagram.com/child-video.jpg' }, { media_url: 'https://scontent.cdninstagram.com/child-image.jpg' }, ], }, }), ), ).toBe('https://scontent.cdninstagram.com/child-video.jpg') }) it('reads missing or malformed thumbnails as undefined', () => { expect( pickInstagramThumbnail( MEDIA_ITEM('no-thumb', { media_type: 'REEL', thumbnail_url: undefined }), ), ).toBeUndefined() expect( pickInstagramThumbnail( MEDIA_ITEM('empty-children', { media_type: 'CAROUSEL_ALBUM', children: { data: [] } }), ), ).toBeUndefined() expect( pickInstagramThumbnail( MEDIA_ITEM('bad-children', { media_type: 'CAROUSEL_ALBUM', children: { data: ['nope'] }, }), ), ).toBeUndefined() expect( pickInstagramThumbnail( MEDIA_ITEM('no-type', { media_type: undefined, media_url: undefined }), ), ).toBeUndefined() }) }) describe('parseInstagramMediaResponse', () => { it('parses a valid media response, keeping null captions', () => { const posts = parseInstagramMediaResponse({ data: [ MEDIA_ITEM('post1'), MEDIA_ITEM('post2', { caption: null, media_type: 'REEL', thumbnail_url: 'thumb.jpg' }), MEDIA_ITEM('post3', { media_type: 'CAROUSEL_ALBUM', children: { data: [{ media_url: 'child.jpg' }] }, }), ], }) expect(posts).toHaveLength(3) expect(posts[0]).toEqual({ id: 'post1', caption: 'Legenda post1', mediaType: 'IMAGE', permalink: 'https://www.instagram.com/p/post1/', mediaUrl: 'https://scontent.cdninstagram.com/post1.jpg', thumbnailUrl: 'https://scontent.cdninstagram.com/post1.jpg', timestamp: '2026-08-18T10:00:00+00:00', }) expect(posts[1].caption).toBeNull() expect(posts[1].thumbnailUrl).toBe('thumb.jpg') expect(posts[2].thumbnailUrl).toBe('child.jpg') }) it('drops items without id, permalink or timestamp but throws on protocol violations', () => { expect( parseInstagramMediaResponse({ data: [ MEDIA_ITEM('ok'), { ...MEDIA_ITEM('no-permalink'), permalink: undefined }, { ...MEDIA_ITEM('no-timestamp'), timestamp: undefined }, { id: 'no-caption', permalink: 'https://instagram.com/p/x/', timestamp: 't' }, 'malformed', ], }), ).toEqual([ expect.objectContaining({ id: 'ok' }), expect.objectContaining({ id: 'no-caption', caption: null }), ]) expect(() => parseInstagramMediaResponse({})).toThrow() expect(() => parseInstagramMediaResponse(null)).toThrow() expect(() => parseInstagramMediaResponse({ data: 'nope' })).toThrow() }) }) describe('parseInstagramMediaPage', () => { it('reads the after cursor and ignores paging.next (it carries the token)', () => { const page = parseInstagramMediaPage({ data: [MEDIA_ITEM('post1')], paging: { cursors: { before: 'before-cursor', after: 'next-cursor' }, next: 'https://graph.instagram.com/me/media?access_token=secret&after=next-cursor', }, }) expect(page.posts.map((post) => post.id)).toEqual(['post1']) expect(page.nextCursor).toBe('next-cursor') }) it('reads a missing or malformed cursor as undefined', () => { expect(parseInstagramMediaPage({ data: [] }).nextCursor).toBeUndefined() expect(parseInstagramMediaPage({ data: [], paging: {} }).nextCursor).toBeUndefined() expect( parseInstagramMediaPage({ data: [], paging: { cursors: {} } }).nextCursor, ).toBeUndefined() expect( parseInstagramMediaPage({ data: [], paging: { cursors: { after: '' } } }).nextCursor, ).toBeUndefined() expect( parseInstagramMediaPage({ data: [], paging: { cursors: { after: 42 } } }).nextCursor, ).toBeUndefined() }) }) describe('eligibleInstagramPosts', () => { const posts: InstagramPost[] = [ { id: 'a', caption: 'A', mediaType: 'IMAGE', permalink: 'https://instagram.com/p/a/', mediaUrl: null, timestamp: 't1', }, { id: 'b', caption: 'B', mediaType: 'IMAGE', permalink: 'https://instagram.com/p/b/', mediaUrl: null, timestamp: 't2', }, { id: 'c', caption: 'C', mediaType: 'IMAGE', permalink: 'https://instagram.com/p/c/', mediaUrl: null, timestamp: 't3', }, { id: 'd', caption: 'D', mediaType: 'IMAGE', permalink: 'https://instagram.com/p/d/', mediaUrl: null, timestamp: 't4', }, ] it('drops excluded ids and caps at maxItems, keeping API order', () => { expect(eligibleInstagramPosts(posts, ['b'], 2)).toEqual([posts[0], posts[2]]) expect(eligibleInstagramPosts(posts, [], 3)).toEqual(posts.slice(0, 3)) expect(eligibleInstagramPosts(posts, ['a', 'b', 'c', 'd'], 5)).toEqual([]) }) }) describe('loadInstagramFeed', () => { const fakeResponse = (body: unknown, ok = true): Response => ({ ok, json: async () => body }) as unknown as Response const calls: string[] = [] const fetchImpl = async (input: string): Promise => { calls.push(input) if (input.includes('/media')) { return fakeResponse({ data: [ MEDIA_ITEM('post1', { caption: 'Post 1' }), MEDIA_ITEM('post2', { caption: 'Post 2' }), ], }) } if (input.includes('/refresh_access_token')) { return fakeResponse({ access_token: 'refreshed-token' }) } return fakeResponse({ username: 'depjorgesolla' }) } const args = { accessToken: 'test-token', userId: '17841400000000000', maxResults: 3, fetchImpl, baseUrl: 'http://localhost:5000', } it('fetches user then media and resolves the thumbnail per type', async () => { calls.length = 0 const result = await loadInstagramFeed(args) expect(result.username).toBe('depjorgesolla') expect(result.refreshedAccessToken).toBeUndefined() expect(result.posts).toEqual([ expect.objectContaining({ id: 'post1', caption: 'Post 1' }), expect.objectContaining({ id: 'post2' }), ]) const userCall = calls[0] expect(userCall).toContain(`/17841400000000000?`) expect(userCall).toContain('fields=username') expect(userCall).toContain(`access_token=test-token`) expect(calls[1]).toContain('/media?') expect(calls[1]).toContain('fields=id%2Ccaption%2Cmedia_type%2Cmedia_url') expect(calls[1]).toContain('children%7Bmedia_url%2Cthumbnail_url%7D') expect(calls[1]).toContain('limit=3') }) it('clamps maxResults to the API cap and the minimum of 1', async () => { calls.length = 0 await loadInstagramFeed({ ...args, maxResults: 100 }) expect(calls[1]).toContain(`limit=${INSTAGRAM_MAX_RESULTS_CAP}`) calls.length = 0 await loadInstagramFeed({ ...args, maxResults: 0 }) expect(calls[1]).toContain('limit=1') }) it('makes exactly one media call when the window fits one page (board contract)', async () => { calls.length = 0 const result = await loadInstagramFeed({ ...args, maxResults: INSTAGRAM_MAX_RESULTS_CAP, fetchImpl: async (input) => { calls.push(input) if (input.includes('/media')) { return fakeResponse({ data: Array.from({ length: INSTAGRAM_MAX_RESULTS_CAP }, (_, index) => MEDIA_ITEM(`post${index}`), ), paging: { cursors: { after: 'cursor-1' } }, }) } return fakeResponse({ username: 'depjorgesolla' }) }, }) expect(result.posts).toHaveLength(INSTAGRAM_MAX_RESULTS_CAP) expect(calls.filter((call) => call.includes('/media'))).toHaveLength(1) }) it('walks the cursor while the window is not filled', async () => { calls.length = 0 const result = await loadInstagramFeed({ ...args, maxResults: 3, fetchImpl: async (input) => { calls.push(input) if (input.includes('/media')) { if (input.includes('after=cursor-1')) { return fakeResponse({ data: [MEDIA_ITEM('post3')] }) } return fakeResponse({ data: [MEDIA_ITEM('post1'), MEDIA_ITEM('post2')], paging: { cursors: { after: 'cursor-1' } }, }) } return fakeResponse({ username: 'depjorgesolla' }) }, }) expect(result.posts.map((post) => post.id)).toEqual(['post1', 'post2', 'post3']) const mediaCalls = calls.filter((call) => call.includes('/media')) expect(mediaCalls).toHaveLength(2) expect(mediaCalls[1]).toContain('after=cursor-1') expect(mediaCalls[1]).toContain('limit=3') }) it('stops at the page that holds the searched post and never asks a deeper cursor', async () => { calls.length = 0 const result = await loadInstagramFeed({ ...args, maxResults: 500, shouldStopAt: (post) => post.id === 'post2', fetchImpl: async (input) => { calls.push(input) if (input.includes('/media')) { // A deeper page exists and would fail loudly: the lookup must never ask. if (input.includes('after=')) throw new Error('deeper page must not be requested') return fakeResponse({ data: [MEDIA_ITEM('post1'), MEDIA_ITEM('post2')], paging: { cursors: { after: 'cursor-1' } }, }) } return fakeResponse({ username: 'depjorgesolla' }) }, }) expect(result.posts.map((post) => post.id)).toEqual(['post1', 'post2']) const mediaCalls = calls.filter((call) => call.includes('/media')) expect(mediaCalls).toHaveLength(1) expect(calls.some((call) => call.includes('after='))).toBe(false) }) it('keeps walking when the searched post is deeper in the window', async () => { calls.length = 0 const result = await loadInstagramFeed({ ...args, maxResults: 500, shouldStopAt: (post) => post.id === 'post3', fetchImpl: async (input) => { calls.push(input) if (input.includes('/media')) { if (input.includes('after=cursor-1')) { return fakeResponse({ data: [MEDIA_ITEM('post3')], paging: { cursors: { after: 'cursor-2' } }, }) } return fakeResponse({ data: [MEDIA_ITEM('post1'), MEDIA_ITEM('post2')], paging: { cursors: { after: 'cursor-1' } }, }) } return fakeResponse({ username: 'depjorgesolla' }) }, }) expect(result.posts.map((post) => post.id)).toEqual(['post1', 'post2', 'post3']) const mediaCalls = calls.filter((call) => call.includes('/media')) expect(mediaCalls).toHaveLength(2) expect(calls.some((call) => call.includes('after=cursor-2'))).toBe(false) }) it('refreshes once and refetches the whole window when a later page fails', async () => { calls.length = 0 let mediaCalls = 0 const result = await loadInstagramFeed({ ...args, maxResults: 3, fetchImpl: async (input) => { calls.push(input) if (input.includes('/refresh_access_token')) { return fakeResponse({ access_token: 'refreshed-token' }) } if (input.includes('/media')) { mediaCalls += 1 if (mediaCalls === 2) return fakeResponse({ error: { message: 'boom' } }, false) if (input.includes('after=cursor-1')) { return fakeResponse({ data: [MEDIA_ITEM('post2')] }) } return fakeResponse({ data: [MEDIA_ITEM('post1')], paging: { cursors: { after: 'cursor-1' } }, }) } return fakeResponse({ username: 'depjorgesolla' }) }, }) expect(result.refreshedAccessToken).toBe('refreshed-token') expect(result.posts.map((post) => post.id)).toEqual(['post1', 'post2']) expect(calls.filter((call) => call.includes('/refresh_access_token'))).toHaveLength(1) }) it('refreshes the token once and retries when the media call fails', async () => { calls.length = 0 let mediaFailures = 1 const result = await loadInstagramFeed({ ...args, fetchImpl: async (input) => { calls.push(input) if (input.includes('/media')) { if (mediaFailures > 0) { mediaFailures -= 1 return fakeResponse({ error: { code: 190 } }, false) } return fakeResponse({ data: [MEDIA_ITEM('post-retry')] }) } if (input.includes('/refresh_access_token')) { return fakeResponse({ access_token: 'refreshed-token' }) } return fakeResponse({ username: 'depjorgesolla' }) }, }) expect(result.refreshedAccessToken).toBe('refreshed-token') expect(result.posts).toEqual([expect.objectContaining({ id: 'post-retry' })]) const refreshCall = calls.find((call) => call.includes('/refresh_access_token')) expect(calls.filter((call) => call.includes('/refresh_access_token'))).toHaveLength(1) expect(calls.filter((call) => call.includes('/media'))).toHaveLength(2) expect(refreshCall).toContain('grant_type=ig_refresh_token') expect(refreshCall).toContain('access_token=test-token') }) it('throws when the refresh fails or returns no token (fail-closed to the snapshot)', async () => { await expect( loadInstagramFeed({ ...args, fetchImpl: async () => fakeResponse({}, false), }), ).rejects.toThrow() await expect( loadInstagramFeed({ ...args, fetchImpl: async (input) => input.includes('/media') ? fakeResponse({}, false) : fakeResponse({}, true), }), ).rejects.toThrow(/refresh/) await expect( loadInstagramFeed({ ...args, fetchImpl: async (input) => input.includes('/media') ? fakeResponse({}, false) : fakeResponse({ nope: true }), }), ).rejects.toThrow(/sem novo token/) }) it('throws on protocol violations and a failed retry', async () => { await expect( loadInstagramFeed({ ...args, fetchImpl: async () => fakeResponse({ data: 'nope' }), }), ).rejects.toThrow() await expect( loadInstagramFeed({ ...args, fetchImpl: async (input) => input.includes('/media') ? fakeResponse({}, false) : fakeResponse({}, false), }), ).rejects.toThrow() }) it('reads a missing or malformed username as null', async () => { const result = await loadInstagramFeed({ ...args, fetchImpl: async (input) => input.includes('/media') ? fakeResponse({ data: [MEDIA_ITEM('p')] }) : fakeResponse({}), }) expect(result.username).toBeNull() }) it('throws an InstagramApiError carrying the parsed Graph API error body', async () => { const oauthError = { error: { message: 'Error validating access token: Session has expired', type: 'OAuthException', code: 190, }, } const userCall = loadInstagramFeed({ ...args, fetchImpl: async (input) => input.includes('/media') ? fakeResponse({}, false) : fakeResponse(oauthError, false), }) await expect(userCall).rejects.toMatchObject({ name: 'InstagramApiError', status: undefined, apiMessage: 'Error validating access token: Session has expired', apiType: 'OAuthException', }) // A refresh rejected with the same OAuth body surfaces the typed error too // (the FB-page-token case: refresh fails → the panel must say why). const refreshRejectCall = loadInstagramFeed({ ...args, fetchImpl: async (input) => input.includes('/refresh_access_token') ? fakeResponse(oauthError, false) : fakeResponse({}, false), }) await expect(refreshRejectCall).rejects.toMatchObject({ apiType: 'OAuthException' }) }) it('survives a non-JSON error body (status kept, api message null)', async () => { const errorResponse = { ok: false, status: 502, json: async () => { throw new Error('boom') }, } as unknown as Response await expect( loadInstagramFeed({ ...args, fetchImpl: async () => errorResponse, }), ).rejects.toMatchObject({ name: 'InstagramApiError', status: 502, apiMessage: null, apiType: null, }) }) it('forwards the abort signal to every fetch', async () => { const receivedInits: RequestInit[] = [] const fetchImpl = async (input: string, init?: RequestInit): Promise => { receivedInits.push(init ?? {}) if (input.includes('/media')) { return fakeResponse({ data: [MEDIA_ITEM('p')] }) } if (input.includes('/refresh_access_token')) { return fakeResponse({ access_token: 't' }) } return fakeResponse({ username: 'depjorgesolla' }) } const controller = new AbortController() await loadInstagramFeed({ ...args, fetchImpl, signal: controller.signal }) expect(receivedInits).toHaveLength(2) for (const init of receivedInits) { expect(init.signal).toBe(controller.signal) } }) })