Files
twitter-lite/src/features/posts/post-service.test.ts
T

570 lines
15 KiB
TypeScript

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()
}
})
})