347 lines
10 KiB
TypeScript
347 lines
10 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { TwitterClient } from '../src/lib/twitter-client.js';
|
|
import { testRelayBaseUrl } from './twitter-client-fixtures.js';
|
|
|
|
const originalFetch = global.fetch;
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('TwitterClient getUserIdByUsername', () => {
|
|
let mockFetch: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
mockFetch = vi.fn();
|
|
global.fetch = mockFetch as unknown as typeof fetch;
|
|
});
|
|
|
|
it('resolves username to userId via GraphQL', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
data: {
|
|
user: {
|
|
result: {
|
|
__typename: 'User',
|
|
rest_id: '12345',
|
|
legacy: {
|
|
screen_name: 'testuser',
|
|
name: 'Test User',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
|
|
const result = await client.getUserIdByUsername('@testuser');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.userId).toBe('12345');
|
|
expect(result.username).toBe('testuser');
|
|
expect(result.name).toBe('Test User');
|
|
});
|
|
|
|
it('handles username without @ prefix', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
data: {
|
|
user: {
|
|
result: {
|
|
__typename: 'User',
|
|
rest_id: '67890',
|
|
legacy: {
|
|
screen_name: 'anotheruser',
|
|
name: 'Another User',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
|
|
const result = await client.getUserIdByUsername('anotheruser');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.userId).toBe('67890');
|
|
});
|
|
|
|
it('returns error for invalid username format', async () => {
|
|
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
|
|
|
|
const result = await client.getUserIdByUsername('invalid handle with spaces');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Invalid username');
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns error for empty username', async () => {
|
|
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
|
|
|
|
const result = await client.getUserIdByUsername('');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Invalid username');
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns error for username exceeding 15 characters', async () => {
|
|
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
|
|
|
|
const result = await client.getUserIdByUsername('thisusernameiswaytoolong');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Invalid username');
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns error when user is unavailable', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
data: {
|
|
user: {
|
|
result: {
|
|
__typename: 'UserUnavailable',
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
|
|
const result = await client.getUserIdByUsername('suspended');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('not found or unavailable');
|
|
});
|
|
|
|
it('retries with fallback query IDs on 404', async () => {
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
text: async () => 'Not found',
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
text: async () => 'Not found',
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
data: {
|
|
user: {
|
|
result: {
|
|
__typename: 'User',
|
|
rest_id: '99999',
|
|
legacy: {
|
|
screen_name: 'founduser',
|
|
name: 'Found User',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
|
|
const result = await client.getUserIdByUsername('founduser');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.userId).toBe('99999');
|
|
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('resolves a rich profile by screen name', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
data: {
|
|
user: {
|
|
result: {
|
|
__typename: 'User',
|
|
rest_id: '12345',
|
|
is_blue_verified: true,
|
|
verification_info: { reason: { verified_since_msec: '1700000000000' } },
|
|
avatar: { image_url: 'https://pbs.twimg.com/profile_images/123/avatar_normal.jpg' },
|
|
legacy: {
|
|
screen_name: 'testuser',
|
|
name: 'Test User',
|
|
description: 'Building tools',
|
|
location: 'Internet',
|
|
url: 'https://t.co/site',
|
|
created_at: 'Mon Jan 06 00:00:00 +0000 2025',
|
|
verified: false,
|
|
verified_type: 'Business',
|
|
followers_count: 123,
|
|
friends_count: 45,
|
|
profile_image_url_https: 'https://pbs.twimg.com/profile_images/123/legacy_normal.jpg',
|
|
entities: {
|
|
url: {
|
|
urls: [
|
|
{
|
|
url: 'https://t.co/site',
|
|
expanded_url: 'https://example.com',
|
|
display_url: 'example.com',
|
|
},
|
|
],
|
|
},
|
|
description: {
|
|
urls: [
|
|
{
|
|
url: 'https://t.co/bio',
|
|
expanded_url: 'https://bio.example',
|
|
display_url: 'bio.example',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
affiliates_highlighted_label: {
|
|
label: {
|
|
description: 'Example Org',
|
|
url: { url: 'https://x.com/example' },
|
|
badge: { url: 'https://pbs.twimg.com/profile_images/org/badge.png' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
|
|
const result = await client.getUserProfile('@testuser');
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
profile: expect.objectContaining({
|
|
id: '12345',
|
|
username: 'testuser',
|
|
name: 'Test User',
|
|
description: 'Building tools',
|
|
location: 'Internet',
|
|
url: 'https://example.com',
|
|
verified: true,
|
|
verifiedType: 'Business',
|
|
followersCount: 123,
|
|
followingCount: 45,
|
|
profileImageUrl: 'https://pbs.twimg.com/profile_images/123/avatar_normal.jpg',
|
|
entities: expect.objectContaining({
|
|
url: expect.objectContaining({
|
|
urls: [expect.objectContaining({ expanded_url: 'https://example.com' })],
|
|
}),
|
|
}),
|
|
affiliation: {
|
|
description: 'Example Org',
|
|
url: 'https://x.com/example',
|
|
badgeUrl: 'https://pbs.twimg.com/profile_images/org/badge.png',
|
|
},
|
|
raw: expect.any(Object),
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('resolves a rich profile by user id', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
data: {
|
|
user: {
|
|
result: {
|
|
__typename: 'User',
|
|
rest_id: '42',
|
|
core: {
|
|
screen_name: 'byid',
|
|
name: 'By Id',
|
|
created_at: 'Tue Feb 01 00:00:00 +0000 2022',
|
|
},
|
|
location: {
|
|
location: 'Vienna & London',
|
|
},
|
|
legacy: {
|
|
description: 'Lookup by id',
|
|
followers_count: 10,
|
|
friends_count: 2,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
|
|
const result = await client.getUserProfile('42');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.profile).toMatchObject({
|
|
id: '42',
|
|
username: 'byid',
|
|
name: 'By Id',
|
|
description: 'Lookup by id',
|
|
location: 'Vienna & London',
|
|
createdAt: 'Tue Feb 01 00:00:00 +0000 2022',
|
|
followersCount: 10,
|
|
followingCount: 2,
|
|
});
|
|
expect(String(mockFetch.mock.calls[0]?.[0])).toContain('/UserByRestId?');
|
|
});
|
|
|
|
it('resolves profile batches with per-target errors', async () => {
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
data: {
|
|
user: {
|
|
result: {
|
|
__typename: 'User',
|
|
rest_id: '1',
|
|
legacy: { screen_name: 'alpha', name: 'Alpha' },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
data: {
|
|
user: {
|
|
result: {
|
|
__typename: 'UserUnavailable',
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
|
|
const result = await client.getUserProfiles(['alpha', 'missing']);
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
results: [
|
|
{ target: 'alpha', profile: expect.objectContaining({ id: '1', username: 'alpha' }) },
|
|
{ target: 'missing', error: 'User @missing not found or unavailable' },
|
|
],
|
|
});
|
|
});
|
|
});
|