feat: load tweet conversations
This commit is contained in:
@@ -1,5 +1,51 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { buildSearchQuery, normalizeUserTarget } from './inputs'
|
import {
|
||||||
|
buildSearchQuery,
|
||||||
|
normalizeTweetId,
|
||||||
|
normalizeUserTarget,
|
||||||
|
threadPageInputSchema,
|
||||||
|
} from './inputs'
|
||||||
|
|
||||||
|
describe('threadPageInputSchema', () => {
|
||||||
|
it('accepts initial and complete continuation requests', () => {
|
||||||
|
expect(threadPageInputSchema.parse({ tweetId: '123' })).toEqual({
|
||||||
|
tweetId: '123',
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
threadPageInputSchema.parse({
|
||||||
|
tweetId: '123',
|
||||||
|
conversationId: '100',
|
||||||
|
cursor: 'thread-next',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
tweetId: '123',
|
||||||
|
conversationId: '100',
|
||||||
|
cursor: 'thread-next',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ tweetId: '' },
|
||||||
|
{ tweetId: 'abc' },
|
||||||
|
{ tweetId: '123', cursor: 'thread-next' },
|
||||||
|
{ tweetId: '123', conversationId: '100' },
|
||||||
|
{ tweetId: '123', conversationId: 'root', cursor: 'thread-next' },
|
||||||
|
])('rejects an invalid thread request %#', (input) => {
|
||||||
|
expect(() => threadPageInputSchema.parse(input)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('normalizeTweetId', () => {
|
||||||
|
it('keeps a decimal post ID', () => {
|
||||||
|
expect(normalizeTweetId(' 1234567890 ')).toBe('1234567890')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['', '123/status', '@123', '123'])('rejects %s', (input) => {
|
||||||
|
expect(() => normalizeTweetId(input)).toThrow(
|
||||||
|
'投稿 ID を確認してください。',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('normalizeUserTarget', () => {
|
describe('normalizeUserTarget', () => {
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
@@ -2,9 +2,27 @@ import { z } from 'zod'
|
|||||||
|
|
||||||
const HANDLE = /^[A-Za-z0-9_]{1,15}$/
|
const HANDLE = /^[A-Za-z0-9_]{1,15}$/
|
||||||
const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i
|
const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i
|
||||||
|
const TWEET_ID = /^\d{1,32}$/
|
||||||
|
const tweetIdSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(TWEET_ID, '投稿 ID を確認してください。')
|
||||||
|
|
||||||
export class InputError extends Error {}
|
export class InputError extends Error {}
|
||||||
|
|
||||||
|
export const threadPageInputSchema = z.union([
|
||||||
|
z.object({
|
||||||
|
tweetId: tweetIdSchema,
|
||||||
|
conversationId: z.undefined().optional(),
|
||||||
|
cursor: z.undefined().optional(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
tweetId: tweetIdSchema,
|
||||||
|
conversationId: tweetIdSchema,
|
||||||
|
cursor: z.string().min(1),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
export const userPageInputSchema = z.object({
|
export const userPageInputSchema = z.object({
|
||||||
target: z.string().trim().min(1).max(256),
|
target: z.string().trim().min(1).max(256),
|
||||||
cursor: z.string().min(1).optional(),
|
cursor: z.string().min(1).optional(),
|
||||||
@@ -27,6 +45,14 @@ export const postSearchRouteSchema = z.object({
|
|||||||
following: z.boolean().catch(false),
|
following: z.boolean().catch(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export function normalizeTweetId(raw: string): string {
|
||||||
|
const value = raw.trim()
|
||||||
|
if (!TWEET_ID.test(value)) {
|
||||||
|
throw new InputError('投稿 ID を確認してください。')
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
function requireHandle(value: string): string {
|
function requireHandle(value: string): string {
|
||||||
if (!HANDLE.test(value)) {
|
if (!HANDLE.test(value)) {
|
||||||
throw new InputError('ハンドルは英数字とアンダースコアで入力してください。')
|
throw new InputError('ハンドルは英数字とアンダースコアで入力してください。')
|
||||||
|
|||||||
@@ -1,7 +1,33 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { type BirdReader, loadUserPage, searchPage } from './post-service'
|
import {
|
||||||
|
type BirdReader,
|
||||||
|
loadThreadPage,
|
||||||
|
loadUserPage,
|
||||||
|
searchPage,
|
||||||
|
} from './post-service'
|
||||||
|
|
||||||
const reader = (): BirdReader => ({
|
const reader = (): BirdReader => ({
|
||||||
|
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
|
getUserIdByUsername: vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ success: true, userId: '42' }),
|
.mockResolvedValue({ success: true, userId: '42' }),
|
||||||
@@ -19,6 +45,130 @@ const reader = (): BirdReader => ({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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('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('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', () => {
|
describe('loadUserPage', () => {
|
||||||
it('resolves a handle and fetches exactly one page', async () => {
|
it('resolves a handle and fetches exactly one page', async () => {
|
||||||
const client = reader()
|
const client = reader()
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import type { SearchProduct, SearchResult, TweetData } from '@yuta/bird'
|
import type {
|
||||||
import { buildSearchQuery, InputError, normalizeUserTarget } from './inputs'
|
GetTweetResult,
|
||||||
|
SearchProduct,
|
||||||
|
SearchResult,
|
||||||
|
TweetData,
|
||||||
|
} from '@yuta/bird'
|
||||||
|
import {
|
||||||
|
buildSearchQuery,
|
||||||
|
InputError,
|
||||||
|
normalizeTweetId,
|
||||||
|
normalizeUserTarget,
|
||||||
|
} from './inputs'
|
||||||
import type {
|
import type {
|
||||||
LoadError,
|
LoadError,
|
||||||
|
LoadFailure,
|
||||||
LoadResult,
|
LoadResult,
|
||||||
Post,
|
Post,
|
||||||
SearchPageInput,
|
SearchPageInput,
|
||||||
|
ThreadLoadResult,
|
||||||
|
ThreadPageInput,
|
||||||
UserPageInput,
|
UserPageInput,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
@@ -15,6 +28,15 @@ type UserLookupResult = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BirdReader {
|
export interface BirdReader {
|
||||||
|
getTweet(tweetId: string): Promise<GetTweetResult>
|
||||||
|
getThreadPaged(
|
||||||
|
tweetId: string,
|
||||||
|
options: {
|
||||||
|
cursor?: string
|
||||||
|
maxPages: number
|
||||||
|
pageDelayMs: number
|
||||||
|
},
|
||||||
|
): Promise<SearchResult>
|
||||||
getUserIdByUsername(username: string): Promise<UserLookupResult>
|
getUserIdByUsername(username: string): Promise<UserLookupResult>
|
||||||
getUserTweetsPaged(
|
getUserTweetsPaged(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -39,9 +61,12 @@ const failure = (
|
|||||||
code: LoadError['code'],
|
code: LoadError['code'],
|
||||||
message: string,
|
message: string,
|
||||||
retryable: boolean,
|
retryable: boolean,
|
||||||
): LoadResult => ({ ok: false, error: { code, message, retryable } })
|
): LoadFailure => ({ ok: false, error: { code, message, retryable } })
|
||||||
|
|
||||||
function upstreamFailure(diagnostic: unknown = ''): LoadResult {
|
function upstreamFailure(
|
||||||
|
diagnostic: unknown = '',
|
||||||
|
subject: 'user' | 'post' = 'user',
|
||||||
|
): LoadFailure {
|
||||||
const message =
|
const message =
|
||||||
diagnostic instanceof Error
|
diagnostic instanceof Error
|
||||||
? diagnostic.message
|
? diagnostic.message
|
||||||
@@ -53,14 +78,18 @@ function upstreamFailure(diagnostic: unknown = ''): LoadResult {
|
|||||||
return failure('timeout', '取得がタイムアウトしました。', true)
|
return failure('timeout', '取得がタイムアウトしました。', true)
|
||||||
}
|
}
|
||||||
if (lower.includes('not found')) {
|
if (lower.includes('not found')) {
|
||||||
return failure('user-not-found', 'ユーザーが見つかりませんでした。', false)
|
return subject === 'post'
|
||||||
|
? failure('post-not-found', '投稿が見つかりませんでした。', false)
|
||||||
|
: failure('user-not-found', 'ユーザーが見つかりませんでした。', false)
|
||||||
}
|
}
|
||||||
if (lower.includes('suspended') || lower.includes('protected')) {
|
if (lower.includes('suspended') || lower.includes('protected')) {
|
||||||
return failure(
|
return subject === 'post'
|
||||||
'user-unavailable',
|
? failure('post-unavailable', 'この投稿は取得できません。', false)
|
||||||
'このユーザーの投稿は取得できません。',
|
: failure(
|
||||||
false,
|
'user-unavailable',
|
||||||
)
|
'このユーザーの投稿は取得できません。',
|
||||||
|
false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
console.error('Unexpected Bird failure:', diagnostic)
|
console.error('Unexpected Bird failure:', diagnostic)
|
||||||
return failure('upstream', 'X から投稿を取得できませんでした。', true)
|
return failure('upstream', 'X から投稿を取得できませんでした。', true)
|
||||||
@@ -87,6 +116,54 @@ function resultPage(result: SearchResult): LoadResult {
|
|||||||
: upstreamFailure(result.error)
|
: upstreamFailure(result.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadThreadPage(
|
||||||
|
reader: BirdReader,
|
||||||
|
input: ThreadPageInput,
|
||||||
|
): Promise<ThreadLoadResult> {
|
||||||
|
try {
|
||||||
|
const tweetId = normalizeTweetId(input.tweetId)
|
||||||
|
let focalPost: Post | undefined
|
||||||
|
let conversationId: string
|
||||||
|
|
||||||
|
if (input.cursor && input.conversationId) {
|
||||||
|
conversationId = normalizeTweetId(input.conversationId)
|
||||||
|
} else {
|
||||||
|
const focal = await reader.getTweet(tweetId)
|
||||||
|
if (!focal.success || !focal.tweet) {
|
||||||
|
return upstreamFailure(focal.error ?? 'Tweet not found', 'post')
|
||||||
|
}
|
||||||
|
focalPost = publicPost(focal.tweet)
|
||||||
|
conversationId = normalizeTweetId(
|
||||||
|
focal.tweet.conversationId ?? focal.tweet.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await reader.getThreadPaged(conversationId, {
|
||||||
|
...(input.cursor ? { cursor: input.cursor } : {}),
|
||||||
|
maxPages: 1,
|
||||||
|
pageDelayMs: 0,
|
||||||
|
})
|
||||||
|
if (!result.success) {
|
||||||
|
return upstreamFailure(result.error, 'post')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
page: {
|
||||||
|
tweets: (result.tweets ?? []).map(publicPost),
|
||||||
|
focalPost,
|
||||||
|
conversationId,
|
||||||
|
nextCursor: result.nextCursor,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof InputError) {
|
||||||
|
return failure('invalid-input', error.message, false)
|
||||||
|
}
|
||||||
|
return upstreamFailure(error, 'post')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadUserPage(
|
export async function loadUserPage(
|
||||||
reader: BirdReader,
|
reader: BirdReader,
|
||||||
input: UserPageInput,
|
input: UserPageInput,
|
||||||
|
|||||||
@@ -9,8 +9,15 @@ export type PostPage = {
|
|||||||
nextCursor?: string
|
nextCursor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ThreadPage = PostPage & {
|
||||||
|
focalPost?: Post
|
||||||
|
conversationId: string
|
||||||
|
}
|
||||||
|
|
||||||
export type LoadErrorCode =
|
export type LoadErrorCode =
|
||||||
| 'invalid-input'
|
| 'invalid-input'
|
||||||
|
| 'post-not-found'
|
||||||
|
| 'post-unavailable'
|
||||||
| 'user-not-found'
|
| 'user-not-found'
|
||||||
| 'user-unavailable'
|
| 'user-unavailable'
|
||||||
| 'relay-config'
|
| 'relay-config'
|
||||||
@@ -23,9 +30,25 @@ export type LoadError = {
|
|||||||
retryable: boolean
|
retryable: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LoadResult =
|
export type LoadFailure = { ok: false; error: LoadError }
|
||||||
| { ok: true; page: PostPage }
|
|
||||||
| { ok: false; error: LoadError }
|
export type LoadResult<TPage extends PostPage = PostPage> =
|
||||||
|
| { ok: true; page: TPage }
|
||||||
|
| LoadFailure
|
||||||
|
|
||||||
|
export type ThreadLoadResult = LoadResult<ThreadPage>
|
||||||
|
|
||||||
|
export type ThreadPageInput =
|
||||||
|
| {
|
||||||
|
tweetId: string
|
||||||
|
conversationId?: never
|
||||||
|
cursor?: never
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
tweetId: string
|
||||||
|
conversationId: string
|
||||||
|
cursor: string
|
||||||
|
}
|
||||||
|
|
||||||
export type UserPageInput = {
|
export type UserPageInput = {
|
||||||
target: string
|
target: string
|
||||||
|
|||||||
Reference in New Issue
Block a user