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; 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' }, ], }); }); });