mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
O resolver pedia a janela de 500 (ate 10 paginas de cursor) antes de casar o shortcode; erro em pagina >=2 virava 'indisponivel' mesmo com o post na pagina 1. O dono da paginacao ganha shouldStopAt (board/sync intocados) e o resolver para na pagina que contem o post — a colagem tipica volta a custar uma chamada.
582 lines
19 KiB
TypeScript
582 lines
19 KiB
TypeScript
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<string, unknown> = {},
|
|
): Record<string, unknown> => ({
|
|
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<Response> => {
|
|
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<Response> => {
|
|
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)
|
|
}
|
|
})
|
|
})
|