1import { readFileSync } from 'node:fs';
2import { fileURLToPath } from 'node:url';
3import { MemoryStorage } from '@crawlee/memory-storage';
4import { Actor } from 'apify';
5import { describe, expect, it, vi } from 'vitest';
6import { processRun } from '../src/actor.js';
7import { BlockedRequestError } from '../src/instagram/challengeShell.js';
8import { ItemNotFoundError, type ItemFetcher } from '../src/instagram/fetcher.js';
9import { PrivateProfileError, ProfileNotFoundError, type ProfileFetcher } from '../src/instagram/profileFetcher.js';
10import { InvalidInputError } from '../src/input.js';
11
12async function createTestActor(): Promise<Actor> {
13 const actor = new Actor({
14 storageClient: new MemoryStorage({ persistStorage: false }),
15 });
16 await actor.init();
17 return actor;
18}
19
20function loadFixture(name: string): unknown {
21 const path = fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url));
22 return JSON.parse(readFileSync(path, 'utf8'));
23}
24
25function stubFetcher(fetchRawItem: ItemFetcher['fetchRawItem']): ItemFetcher {
26 return { fetchRawItem };
27}
28
29function stubProfileFetcher(fetchProfileListing: ProfileFetcher['fetchProfileListing']): ProfileFetcher {
30 return { fetchProfileListing };
31}
32
33const neverCalledFetcher = stubFetcher(async () => {
34 throw new Error('fetcher should not be called for this input');
35});
36
37const neverCalledProfileFetcher = stubProfileFetcher(async () => {
38 throw new Error('profileFetcher should not be called for this input');
39});
40
41describe('local Dataset storage harness', () => {
42 it('can observe an item pushed to the Dataset', async () => {
43 const actor = await createTestActor();
44 const dataset = await actor.openDataset();
45
46 await dataset.pushData({ shortcode: 'ABC123' });
47
48 const { items } = await dataset.getData();
49 expect(items).toEqual([{ shortcode: 'ABC123' }]);
50 });
51});
52
53describe('processRun', () => {
54 it('rejects with a clear error when neither username nor postOrReelUrl is provided', async () => {
55 const actor = await createTestActor();
56 const keyValueStore = await actor.openKeyValueStore();
57 await keyValueStore.setValue('INPUT', {});
58
59 await expect(processRun(actor, neverCalledFetcher, neverCalledProfileFetcher)).rejects.toThrow(InvalidInputError);
60 });
61
62 it('pushes a normalized reel item and charges the reel event, given a postOrReelUrl', async () => {
63 const actor = await createTestActor();
64 const keyValueStore = await actor.openKeyValueStore();
65 await keyValueStore.setValue('INPUT', { postOrReelUrl: 'https://www.instagram.com/reel/DbvqPafyqY-/' });
66 const fetcher = stubFetcher(async () => loadFixture('reel-single.json'));
67 const chargeSpy = vi.spyOn(actor, 'pushData');
68
69 await expect(processRun(actor, fetcher, neverCalledProfileFetcher)).resolves.toBeUndefined();
70
71 expect(chargeSpy).toHaveBeenCalledWith(expect.objectContaining({ shortcode: 'DbvqPafyqY-' }), 'reel-scraped');
72 const dataset = await actor.openDataset();
73 const { items } = await dataset.getData();
74 expect(items).toEqual([expect.objectContaining({ shortcode: 'DbvqPafyqY-', contentType: 'reel' })]);
75 });
76
77 it('pushes a normalized post item and charges the post event, given a postOrReelUrl', async () => {
78 const actor = await createTestActor();
79 const keyValueStore = await actor.openKeyValueStore();
80 await keyValueStore.setValue('INPUT', { postOrReelUrl: 'https://www.instagram.com/p/DbbY9pdm6Q2/' });
81 const fetcher = stubFetcher(async () => loadFixture('post-carousel.json'));
82 const chargeSpy = vi.spyOn(actor, 'pushData');
83
84 await expect(processRun(actor, fetcher, neverCalledProfileFetcher)).resolves.toBeUndefined();
85
86 expect(chargeSpy).toHaveBeenCalledWith(expect.objectContaining({ shortcode: 'DbbY9pdm6Q2' }), 'post-scraped');
87 const dataset = await actor.openDataset();
88 const { items } = await dataset.getData();
89 expect(items).toEqual([expect.objectContaining({ shortcode: 'DbbY9pdm6Q2', contentType: 'post' })]);
90 });
91
92 it('rejects with a clear error and leaves the Dataset empty when the item is not found', async () => {
93 const actor = await createTestActor();
94 const keyValueStore = await actor.openKeyValueStore();
95 await keyValueStore.setValue('INPUT', { postOrReelUrl: 'https://www.instagram.com/p/deleted000/' });
96 const fetcher = stubFetcher(async () => {
97 throw new ItemNotFoundError('This post or reel is not available.');
98 });
99
100 await expect(processRun(actor, fetcher, neverCalledProfileFetcher)).rejects.toThrow(ItemNotFoundError);
101
102 const dataset = await actor.openDataset();
103 const { items } = await dataset.getData();
104 expect(items).toHaveLength(0);
105 });
106
107 it('rejects with PrivateProfileError and pushes nothing, given a private profile', async () => {
108 const actor = await createTestActor();
109 const keyValueStore = await actor.openKeyValueStore();
110 await keyValueStore.setValue('INPUT', { username: 'a-private-account' });
111 const profileFetcher = stubProfileFetcher(async () => {
112 throw new PrivateProfileError('This profile is private.');
113 });
114
115 await expect(processRun(actor, neverCalledFetcher, profileFetcher)).rejects.toThrow(PrivateProfileError);
116
117 const dataset = await actor.openDataset();
118 const { items } = await dataset.getData();
119 expect(items).toHaveLength(0);
120 });
121
122 it('resolves with an explicit empty Dataset, given a public profile with zero items', async () => {
123 const actor = await createTestActor();
124 const keyValueStore = await actor.openKeyValueStore();
125 await keyValueStore.setValue('INPUT', { username: 'an-empty-account' });
126 const profileFetcher = stubProfileFetcher(async () => ({ items: [], limitReached: false }));
127
128 await expect(processRun(actor, neverCalledFetcher, profileFetcher)).resolves.toBeUndefined();
129
130 const dataset = await actor.openDataset();
131 const { items } = await dataset.getData();
132 expect(items).toHaveLength(0);
133 });
134
135 it('fetches, normalizes, and pushes each discovered item at the correct permalink, given a populated profile', async () => {
136 const actor = await createTestActor();
137 const keyValueStore = await actor.openKeyValueStore();
138 await keyValueStore.setValue('INPUT', { username: 'nasa' });
139 const profileFetcher = stubProfileFetcher(async () => ({
140 items: [
141 { shortcode: 'DbvqPafyqY-', contentType: 'reel' },
142 { shortcode: 'DbbY9pdm6Q2', contentType: 'post' },
143 ],
144 limitReached: false,
145 }));
146 const requestedUrls: string[] = [];
147 const fetcher = stubFetcher(async (url) => {
148 requestedUrls.push(url);
149 return url.includes('/reel/') ? loadFixture('reel-single.json') : loadFixture('post-carousel.json');
150 });
151 const chargeSpy = vi.spyOn(actor, 'pushData');
152
153 await expect(processRun(actor, fetcher, profileFetcher)).resolves.toBeUndefined();
154
155 expect(requestedUrls).toEqual([
156 'https://www.instagram.com/reel/DbvqPafyqY-/',
157 'https://www.instagram.com/p/DbbY9pdm6Q2/',
158 ]);
159 expect(chargeSpy).toHaveBeenCalledWith(expect.objectContaining({ shortcode: 'DbvqPafyqY-' }), 'reel-scraped');
160 expect(chargeSpy).toHaveBeenCalledWith(expect.objectContaining({ shortcode: 'DbbY9pdm6Q2' }), 'post-scraped');
161 const dataset = await actor.openDataset();
162 const { items } = await dataset.getData();
163 expect(items).toHaveLength(2);
164 });
165
166 it('rejects with ProfileNotFoundError and pushes nothing, given an unrecognized profile', async () => {
167 const actor = await createTestActor();
168 const keyValueStore = await actor.openKeyValueStore();
169 await keyValueStore.setValue('INPUT', { username: 'this-should-not-exist' });
170 const profileFetcher = stubProfileFetcher(async () => {
171 throw new ProfileNotFoundError('Could not find profile data.');
172 });
173
174 await expect(processRun(actor, neverCalledFetcher, profileFetcher)).rejects.toThrow(ProfileNotFoundError);
175
176 const dataset = await actor.openDataset();
177 const { items } = await dataset.getData();
178 expect(items).toHaveLength(0);
179 });
180
181 it('skips an item that fails with a content-level error and keeps scraping the rest of the profile', async () => {
182 const actor = await createTestActor();
183 const keyValueStore = await actor.openKeyValueStore();
184 await keyValueStore.setValue('INPUT', { username: 'nasa' });
185 const profileFetcher = stubProfileFetcher(async () => ({
186 items: [
187 { shortcode: 'DbvqPafyqY-', contentType: 'reel' },
188 { shortcode: 'deleted000', contentType: 'post' },
189 { shortcode: 'DbbY9pdm6Q2', contentType: 'post' },
190 ],
191 limitReached: false,
192 }));
193 const fetcher = stubFetcher(async (url) => {
194 if (url.includes('deleted000')) throw new ItemNotFoundError('This post or reel is not available.');
195 return url.includes('/reel/') ? loadFixture('reel-single.json') : loadFixture('post-carousel.json');
196 });
197
198 await expect(processRun(actor, fetcher, profileFetcher)).resolves.toBeUndefined();
199
200 const dataset = await actor.openDataset();
201 const { items } = await dataset.getData();
202 expect(items).toEqual([
203 expect.objectContaining({ shortcode: 'DbvqPafyqY-' }),
204 expect.objectContaining({ shortcode: 'DbbY9pdm6Q2' }),
205 ]);
206 });
207
208 it('reports a clear partial-results status message when the anonymous pagination limit is hit', async () => {
209 const actor = await createTestActor();
210 const keyValueStore = await actor.openKeyValueStore();
211 await keyValueStore.setValue('INPUT', { username: 'a-very-active-account' });
212 const profileFetcher = stubProfileFetcher(async () => ({ items: [], limitReached: true }));
213 const statusSpy = vi.spyOn(actor, 'setStatusMessage').mockResolvedValue({} as never);
214
215 await expect(processRun(actor, neverCalledFetcher, profileFetcher)).resolves.toBeUndefined();
216
217 expect(statusSpy).toHaveBeenCalledWith(expect.stringMatching(/incomplete|limit/i), expect.anything());
218 });
219
220 it('propagates BlockedRequestError from the profile listing fetch rather than swallowing it', async () => {
221 const actor = await createTestActor();
222 const keyValueStore = await actor.openKeyValueStore();
223 await keyValueStore.setValue('INPUT', { username: 'nasa' });
224 const profileFetcher = stubProfileFetcher(async () => {
225 throw new BlockedRequestError('Instagram served an anti-bot challenge shell.');
226 });
227
228 await expect(processRun(actor, neverCalledFetcher, profileFetcher)).rejects.toThrow(BlockedRequestError);
229 });
230
231 it('skips an item that fails with BlockedRequestError and keeps scraping the rest of the profile', async () => {
232
233
234
235 const actor = await createTestActor();
236 const keyValueStore = await actor.openKeyValueStore();
237 await keyValueStore.setValue('INPUT', { username: 'nasa' });
238 const profileFetcher = stubProfileFetcher(async () => ({
239 items: [
240 { shortcode: 'blocked000', contentType: 'post' },
241 { shortcode: 'DbvqPafyqY-', contentType: 'reel' },
242 ],
243 limitReached: false,
244 }));
245 const fetcher = stubFetcher(async (url) => {
246 if (url.includes('blocked000')) throw new BlockedRequestError('Instagram served an anti-bot challenge shell.');
247 return loadFixture('reel-single.json');
248 });
249
250 await expect(processRun(actor, fetcher, profileFetcher)).resolves.toBeUndefined();
251
252 const dataset = await actor.openDataset();
253 const { items } = await dataset.getData();
254 expect(items).toEqual([expect.objectContaining({ shortcode: 'DbvqPafyqY-' })]);
255 });
256});