import { describe, expect, it, vi } from 'vitest' import { type BirdReader, loadListChoices, loadListPage, loadThreadPage, loadUserPage, searchPage, } from './post-service' const reader = (): BirdReader => ({ getLists: vi.fn().mockResolvedValue({ success: true, lists: [ { id: '1234567890', name: 'My List', description: 'Useful accounts', memberCount: 10, subscriberCount: 4, isPrivate: true, createdAt: '2026-01-01T00:00:00.000Z', owner: { id: '42', username: 'private', name: 'Private' }, }, ], }), getTweet: vi.fn().mockResolvedValue({ success: true, tweet: { id: '123', text: 'focal', conversationId: '100', author: { username: 'focus', name: 'Focus' }, }, }), getThreadPaged: vi.fn().mockResolvedValue({ success: true, tweets: [ { id: '100', text: 'root', conversationId: '100', author: { username: 'root', name: 'Root' }, }, ], nextCursor: 'thread-next', }), getUserIdByUsername: vi .fn() .mockResolvedValue({ success: true, userId: '42' }), getUserTweetsPaged: vi.fn().mockResolvedValue({ success: true, tweets: [ { id: '1', text: 'hello', author: { username: 'yuta', name: 'Yuta' } }, ], nextCursor: 'user-next', }), getAllSearchResults: vi.fn().mockResolvedValue({ success: true, tweets: [], nextCursor: 'search-next', }), getAllListTimeline: vi.fn().mockResolvedValue({ success: true, tweets: [ { id: '7', text: 'listed', author: { username: 'bird', name: 'Bird' } }, ], nextCursor: 'list-next', }), }) describe('loadListChoices', () => { it('returns only the fields needed by the list picker', async () => { const client = reader() expect(await loadListChoices(client)).toEqual({ ok: true, lists: [ { id: '1234567890', name: 'My List', description: 'Useful accounts', memberCount: 10, isPrivate: true, }, ], }) expect(client.getLists).toHaveBeenCalledWith(100) }) it('returns a safe retryable error without leaking Bird details', async () => { const client = reader() vi.mocked(client.getLists).mockResolvedValue({ success: false, error: 'PRIVATE_LIST_DIAGNOSTIC', }) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) try { const result = await loadListChoices(client) expect(result).toEqual({ ok: false, error: { code: 'upstream', message: 'X から投稿を取得できませんでした。', retryable: true, }, }) expect(JSON.stringify(result)).not.toContain('PRIVATE_LIST_DIAGNOSTIC') } finally { errorSpy.mockRestore() } }) }) describe('loadThreadPage', () => { it('resolves the focal post and fetches one root conversation page', async () => { const client = reader() const result = await loadThreadPage(client, { tweetId: '123' }) expect(client.getTweet).toHaveBeenCalledWith('123') expect(client.getThreadPaged).toHaveBeenCalledWith('100', { maxPages: 1, pageDelayMs: 0, }) expect(result).toEqual({ ok: true, page: { focalPost: { id: '123', text: 'focal', conversationId: '100', author: { username: 'focus', name: 'Focus' }, }, tweets: [ { id: '100', text: 'root', conversationId: '100', author: { username: 'root', name: 'Root' }, }, ], conversationId: '100', nextCursor: 'thread-next', }, }) }) it('uses the carried root for exactly one continuation read', async () => { const client = reader() const result = await loadThreadPage(client, { tweetId: '123', conversationId: '100', cursor: 'thread-next', }) expect(client.getTweet).not.toHaveBeenCalled() expect(client.getThreadPaged).toHaveBeenCalledOnce() expect(client.getThreadPaged).toHaveBeenCalledWith('100', { cursor: 'thread-next', maxPages: 1, pageDelayMs: 0, }) expect(result).toMatchObject({ ok: true, page: { conversationId: '100', focalPost: undefined }, }) }) it('does not expose a continuation when the focal post has no replies', async () => { const client = reader() vi.mocked(client.getTweet).mockResolvedValue({ success: true, tweet: { id: '123', text: 'focal', conversationId: '100', replyCount: 0, author: { username: 'focus', name: 'Focus' }, }, }) const result = await loadThreadPage(client, { tweetId: '123' }) expect(result).toMatchObject({ ok: true, page: { nextCursor: undefined }, }) }) it.each([ { name: 'an empty final page', tweets: [], returnedCursor: 'thread-final', }, { name: 'the same cursor again', tweets: [ { id: '101', text: 'last reply', author: { username: 'reply', name: 'Reply' }, }, ], returnedCursor: 'thread-next', }, ])('stops after $name', async ({ tweets, returnedCursor }) => { const client = reader() vi.mocked(client.getThreadPaged).mockResolvedValue({ success: true, tweets, nextCursor: returnedCursor, }) const result = await loadThreadPage(client, { tweetId: '123', conversationId: '100', cursor: 'thread-next', }) expect(result).toMatchObject({ ok: true, page: { nextCursor: undefined }, }) }) it('removes raw data from focal, conversation, and quotes', async () => { const client = reader() vi.mocked(client.getTweet).mockResolvedValue({ success: true, tweet: { id: '123', text: 'focal', conversationId: '100', author: { username: 'focus', name: 'Focus' }, quotedTweet: { id: '90', text: 'quote', author: { username: 'quote', name: 'Quote' }, _raw: { rest_id: 'private-quote' }, }, _raw: { rest_id: 'private-focal' }, }, }) vi.mocked(client.getThreadPaged).mockResolvedValue({ success: true, tweets: [ { id: '100', text: 'root', conversationId: '100', author: { username: 'root', name: 'Root' }, _raw: { rest_id: 'private-thread' }, }, ], }) const result = await loadThreadPage(client, { tweetId: '123' }) expect(JSON.stringify(result)).not.toContain('private-') }) it('returns a safe non-retryable missing-post error', async () => { const client = reader() vi.mocked(client.getTweet).mockResolvedValue({ success: false, error: 'Tweet not found: private relay detail', }) expect(await loadThreadPage(client, { tweetId: '404' })).toEqual({ ok: false, error: { code: 'post-not-found', message: '投稿が見つかりませんでした。', retryable: false, }, }) expect(client.getThreadPaged).not.toHaveBeenCalled() }) it('classifies malformed Bird conversation metadata as an upstream failure', async () => { const client = reader() const privateMetadata = 'FINAL_PRIVATE_CONVERSATION_MARKER_71c9' vi.mocked(client.getTweet).mockResolvedValue({ success: true, tweet: { id: '123', text: 'focal', conversationId: privateMetadata, author: { username: 'focus', name: 'Focus' }, }, }) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) try { const result = await loadThreadPage(client, { tweetId: '123' }) expect(result).toEqual({ ok: false, error: { code: 'upstream', message: 'X から投稿を取得できませんでした。', retryable: true, }, }) expect(client.getTweet).toHaveBeenCalledWith('123') expect(client.getThreadPaged).not.toHaveBeenCalled() expect(errorSpy).toHaveBeenCalledTimes(1) expect(errorSpy).toHaveBeenCalledWith( 'Unexpected Bird failure:', expect.objectContaining({ message: '投稿 ID を確認してください。', }), ) expect(JSON.stringify(result)).not.toContain(privateMetadata) expect(errorSpy.mock.calls.flat().join(' ')).not.toContain( privateMetadata, ) } finally { errorSpy.mockRestore() } }) it('rejects an invalid ID without a Bird call', async () => { const client = reader() expect(await loadThreadPage(client, { tweetId: 'not-an-id' })).toEqual({ ok: false, error: { code: 'invalid-input', message: '投稿 ID を確認してください。', retryable: false, }, }) expect(client.getTweet).not.toHaveBeenCalled() expect(client.getThreadPaged).not.toHaveBeenCalled() }) }) describe('loadUserPage', () => { it('resolves a handle and fetches exactly one page', async () => { const client = reader() const result = await loadUserPage(client, { target: 'https://x.com/yuta', cursor: 'cursor-1', }) expect(client.getUserIdByUsername).toHaveBeenCalledWith('yuta') expect(client.getUserTweetsPaged).toHaveBeenCalledWith('42', 20, { cursor: 'cursor-1', maxPages: 1, pageDelayMs: 0, }) expect(result).toMatchObject({ ok: true, page: { nextCursor: 'user-next' }, }) }) it('returns a safe not-found error', async () => { const client = reader() vi.mocked(client.getUserIdByUsername).mockResolvedValue({ success: false, error: 'User not found: private relay detail', }) expect(await loadUserPage(client, { target: 'missing' })).toEqual({ ok: false, error: { code: 'user-not-found', message: 'ユーザーが見つかりませんでした。', retryable: false, }, }) }) }) describe('loadListPage', () => { it('resolves a list URL and fetches exactly one page', async () => { const client = reader() const result = await loadListPage(client, { target: 'https://x.com/i/lists/1234567890', cursor: 'cursor-1', }) expect(client.getAllListTimeline).toHaveBeenCalledWith('1234567890', { cursor: 'cursor-1', maxPages: 1, }) expect(result).toEqual({ ok: true, page: { tweets: [ { id: '7', text: 'listed', author: { username: 'bird', name: 'Bird' }, }, ], nextCursor: 'list-next', }, }) }) it('rejects an invalid target without a Bird call', async () => { const client = reader() expect(await loadListPage(client, { target: 'not-a-list' })).toEqual({ ok: false, error: { code: 'invalid-input', message: 'リスト ID は数字で入力してください。', retryable: false, }, }) expect(client.getAllListTimeline).not.toHaveBeenCalled() }) it('returns a safe not-found error', async () => { const client = reader() vi.mocked(client.getAllListTimeline).mockResolvedValue({ success: false, error: 'List not found: private relay detail', }) expect(await loadListPage(client, { target: '1234567890' })).toEqual({ ok: false, error: { code: 'list-not-found', message: 'リストが見つかりませんでした。', retryable: false, }, }) }) it('returns a safe unavailable error for a protected list', async () => { const client = reader() vi.mocked(client.getAllListTimeline).mockResolvedValue({ success: false, error: 'List is protected: private relay detail', }) expect(await loadListPage(client, { target: '1234567890' })).toEqual({ ok: false, error: { code: 'list-unavailable', message: 'このリストは取得できません。', retryable: false, }, }) }) it('removes raw response data from posts', async () => { const client = reader() vi.mocked(client.getAllListTimeline).mockResolvedValue({ success: true, tweets: [ { id: '7', text: 'listed', author: { username: 'bird', name: 'Bird' }, _raw: { rest_id: 'private-list-detail' }, }, ], }) const result = await loadListPage(client, { target: '1234567890' }) expect(JSON.stringify(result)).not.toContain('private-') }) }) describe('searchPage', () => { it('forwards Top and appends follows once', async () => { const client = reader() await searchPage(client, { query: 'AI lang:ja', product: 'Top', following: true, cursor: 'cursor-2', }) expect(client.getAllSearchResults).toHaveBeenCalledWith( 'AI lang:ja filter:follows', { product: 'Top', cursor: 'cursor-2', maxPages: 1 }, ) }) it('removes raw response data from posts and quoted posts', async () => { const client = reader() vi.mocked(client.getAllSearchResults).mockResolvedValue({ success: true, tweets: [ { id: '1', text: 'hello', author: { username: 'yuta', name: 'Yuta' }, quotedTweet: { id: '2', text: 'quoted', author: { username: 'bird', name: 'Bird' }, _raw: { rest_id: 'private-quoted-detail' }, }, _raw: { rest_id: 'private-detail' }, }, ], }) const result = await searchPage(client, { query: 'AI', product: 'Latest', following: false, }) expect(result).toEqual({ ok: true, page: { nextCursor: undefined, tweets: [ { id: '1', text: 'hello', author: { username: 'yuta', name: 'Yuta' }, quotedTweet: { id: '2', text: 'quoted', author: { username: 'bird', name: 'Bird' }, }, }, ], }, }) }) it('logs unexpected details without returning them', async () => { const client = reader() const privateDetail = 'TASK4_PRIVATE_RELAY_MARKER_9e0b' const upstreamError = new Error(privateDetail) vi.mocked(client.getAllSearchResults).mockRejectedValue(upstreamError) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) try { const result = await searchPage(client, { query: 'AI', product: 'Latest', following: false, }) expect(errorSpy).toHaveBeenCalledTimes(1) expect(errorSpy).toHaveBeenCalledWith( 'Unexpected Bird failure:', upstreamError, ) expect(result).toEqual({ ok: false, error: { code: 'upstream', message: 'X から投稿を取得できませんでした。', retryable: true, }, }) expect(JSON.stringify(result)).not.toContain(privateDetail) } finally { errorSpy.mockRestore() } }) })