570 lines
15 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
})
|