feat: add authenticated list reading
This commit is contained in:
@@ -1,12 +1,29 @@
|
||||
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: {
|
||||
@@ -43,6 +60,58 @@ const reader = (): BirdReader => ({
|
||||
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', () => {
|
||||
@@ -250,6 +319,101 @@ describe('loadUserPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user