Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

382 lines
14 KiB
JavaScript

import { describe, expect, it, vi } from 'vitest';
import './following.js';
import { getRegistry } from '@jackwener/opencli/registry';
/**
* Extract the evaluate JS source from the following command pipeline
* so we can test the pagination logic in-process via eval().
*/
function getFollowingEvaluateJs() {
const cmd = getRegistry().get('instagram/following');
const evalStep = cmd.pipeline.find((s) => s.evaluate);
return evalStep.evaluate;
}
/**
* Run the following evaluate script with a mock fetch in global scope.
* Returns the resolved value.
*/
async function runFollowingEvaluate(fetchFn, args = { username: 'testuser', limit: 20 }) {
const jsTemplate = getFollowingEvaluateJs();
// Replace the template placeholders with actual values
const js = jsTemplate
.replace('${{ args.username | json }}', JSON.stringify(args.username))
.replace('${{ args.limit }}', String(args.limit));
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchFn;
try {
return await eval(js);
} finally {
globalThis.fetch = originalFetch;
}
}
/**
* Build a single-page Instagram following API response.
*/
function buildFollowingResponse(users, nextMaxId = null, hasMore = undefined) {
return {
users,
next_max_id: nextMaxId,
...(hasMore === undefined ? {} : { has_more: hasMore }),
};
}
/**
* Build a user object with a given pk and username.
*/
function makeUser(pk, username = null) {
return {
pk,
pk_id: String(pk),
username: username || ('user_' + pk),
full_name: 'User ' + pk,
is_verified: pk % 2 === 0,
is_private: pk % 3 === 0,
};
}
describe('instagram/following pagination', () => {
it('returns a single page when results fit within one request', async () => {
const users = Array.from({ length: 10 }, (_, i) => makeUser(1000 + i));
const fetchFn = vi.fn()
// First call: profile info
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: { user: { id: '12345' } } }),
})
// Second call: following page (no next_max_id)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse(users)),
});
const result = await runFollowingEvaluate(fetchFn, { username: 'alice', limit: 20 });
expect(result).toHaveLength(10);
expect(result[0]).toEqual({
rank: 1,
username: 'user_1000',
name: 'User 1000',
verified: 'Yes',
private: 'No',
});
expect(result[9].rank).toBe(10);
// Only 2 fetch calls total (profile + 1 following page)
expect(fetchFn).toHaveBeenCalledTimes(2);
});
it('paginates across multiple pages via next_max_id cursor', async () => {
const page1Users = Array.from({ length: 50 }, (_, i) => makeUser(1000 + i));
const page2Users = Array.from({ length: 50 }, (_, i) => makeUser(2000 + i));
const page3Users = Array.from({ length: 20 }, (_, i) => makeUser(3000 + i));
const fetchFn = vi.fn()
// Profile info
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: { user: { id: '99999' } } }),
})
// Page 1: 50 users, has next_max_id
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse(page1Users, 'max_cursor_1')),
})
// Page 2: 50 users, has next_max_id
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse(page2Users, 'max_cursor_2')),
})
// Page 3: 20 users, no next_max_id
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse(page3Users)),
});
const result = await runFollowingEvaluate(fetchFn, { username: 'bob', limit: 120 });
expect(result).toHaveLength(120);
// Ranks should be sequential across pages
expect(result[0].rank).toBe(1);
expect(result[49].rank).toBe(50);
expect(result[50].rank).toBe(51);
expect(result[119].rank).toBe(120);
// 4 fetch calls: profile + 3 pages
expect(fetchFn).toHaveBeenCalledTimes(4);
});
it('deduplicates users by pk when cursor overlaps', async () => {
const sharedUser = makeUser(1000, 'shared_user');
const page1Users = [sharedUser, makeUser(1001), makeUser(1002)];
const page2Users = [sharedUser, makeUser(1003), makeUser(1004)];
const fetchFn = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: { user: { id: '55555' } } }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse(page1Users, 'cursor_overlap')),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse(page2Users)),
});
const result = await runFollowingEvaluate(fetchFn, { username: 'carol', limit: 20 });
// 5 unique users total (shared_user counted once)
expect(result).toHaveLength(5);
expect(result.map((r) => r.username)).toEqual([
'shared_user', 'user_1001', 'user_1002', 'user_1003', 'user_1004',
]);
});
it('respects the limit and stops early even with more data available', async () => {
const users = Array.from({ length: 50 }, (_, i) => makeUser(1000 + i));
const fetchFn = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: { user: { id: '77777' } } }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse(users, 'more_available')),
});
const result = await runFollowingEvaluate(fetchFn, { username: 'dave', limit: 5 });
expect(result).toHaveLength(5);
expect(result[4].rank).toBe(5);
// Should NOT have fetched a second following page
expect(fetchFn).toHaveBeenCalledTimes(2);
});
it('honors explicit has_more=false even if a cursor is present', async () => {
const users = Array.from({ length: 50 }, (_, i) => makeUser(1000 + i));
const fetchFn = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: { user: { id: '77778' } } }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse(users, 'stale_cursor', false)),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse(Array.from({ length: 50 }, (_, i) => makeUser(2000 + i)))),
});
const result = await runFollowingEvaluate(fetchFn, { username: 'done', limit: 200 });
expect(result).toHaveLength(50);
expect(fetchFn).toHaveBeenCalledTimes(2);
});
it('handles empty following list gracefully', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: { user: { id: '88888' } } }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse([])),
});
const result = await runFollowingEvaluate(fetchFn, { username: 'empty_user', limit: 20 });
expect(result).toEqual([]);
expect(fetchFn).toHaveBeenCalledTimes(2);
});
it('breaks when next_max_id repeats (cursor loop guard)', async () => {
const page1Users = Array.from({ length: 50 }, (_, i) => makeUser(1000 + i));
const page2Users = Array.from({ length: 50 }, (_, i) => makeUser(2000 + i));
const fetchFn = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: { user: { id: '44444' } } }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse(page1Users, 'cursor_loop')),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse(page2Users, 'cursor_loop')),
})
// If guard fails this would be reached and trigger the assertion below.
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse(page2Users, 'cursor_loop')),
});
const result = await runFollowingEvaluate(fetchFn, { username: 'loopy', limit: 500 });
expect(result).toHaveLength(100);
// Profile + page1 + page2 = 3 calls; cursor loop guard prevents the 4th.
expect(fetchFn).toHaveBeenCalledTimes(3);
});
it('breaks when a page yields zero new unique users', async () => {
const page1Users = Array.from({ length: 50 }, (_, i) => makeUser(1000 + i));
// Page 2 returns the same users with a fresh cursor — dedupe leaves nothing new.
const page2Users = [...page1Users];
const fetchFn = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: { user: { id: '33333' } } }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse(page1Users, 'cursor_a')),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse(page2Users, 'cursor_b')),
});
const result = await runFollowingEvaluate(fetchFn, { username: 'stuck', limit: 500 });
expect(result).toHaveLength(50);
// Profile + 2 following pages, then stop on zero-growth detection.
expect(fetchFn).toHaveBeenCalledTimes(3);
});
it('rejects non-positive limits before fetching', async () => {
const fetchFn = vi.fn();
await expect(
runFollowingEvaluate(fetchFn, { username: 'noop', limit: 0 }),
).rejects.toThrow('limit must be a positive integer');
expect(fetchFn).not.toHaveBeenCalled();
});
it('propagates HTTP errors that occur on a mid-pagination page', async () => {
const page1Users = Array.from({ length: 50 }, (_, i) => makeUser(1000 + i));
const fetchFn = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: { user: { id: '11111' } } }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse(page1Users, 'next')),
})
.mockResolvedValueOnce({ ok: false, status: 429 });
await expect(
runFollowingEvaluate(fetchFn, { username: 'broken', limit: 200 }),
).rejects.toThrow('Failed to fetch following: HTTP 429');
});
it('typed-fails malformed following payloads instead of returning empty rows', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: { user: { id: '10101' } } }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ users: null }),
});
await expect(
runFollowingEvaluate(fetchFn, { username: 'malformed', limit: 20 }),
).rejects.toThrow('Instagram following returned malformed users payload');
});
it('typed-fails malformed user identity rows instead of emitting blank usernames', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: { user: { id: '10102' } } }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse([{ pk: '1', full_name: 'No username' }])),
});
await expect(
runFollowingEvaluate(fetchFn, { username: 'badrow', limit: 20 }),
).rejects.toThrow('Instagram following returned malformed user row');
});
it('typed-fails malformed pagination cursors', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: { user: { id: '10103' } } }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse([makeUser(1)], { cursor: 'bad' })),
});
await expect(
runFollowingEvaluate(fetchFn, { username: 'badcursor', limit: 20 }),
).rejects.toThrow('Instagram following returned malformed pagination cursor');
});
it('typed-fails has_more=true without a pagination cursor', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: { user: { id: '10104' } } }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse([makeUser(1)], null, true)),
});
await expect(
runFollowingEvaluate(fetchFn, { username: 'missingcursor', limit: 20 }),
).rejects.toThrow('Instagram following returned has_more without pagination cursor');
});
it('typed-fails malformed has_more flags', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: { user: { id: '10105' } } }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(buildFollowingResponse([makeUser(1)], 'next', 'yes')),
});
await expect(
runFollowingEvaluate(fetchFn, { username: 'badhasmore', limit: 20 }),
).rejects.toThrow('Instagram following returned malformed has_more flag');
});
});