fix: classify malformed thread metadata
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
# Twitter Lite
|
||||
|
||||
Twitter Lite is an intentional, read-only X reader. It stays empty until you
|
||||
enter a user handle, profile URL, or search query.
|
||||
Twitter Lite is an intentional, read-only X reader. It shows content only after
|
||||
you enter a user handle, profile URL, or search query, follow a deliberate post
|
||||
detail link, or manually open a valid `/status/:tweetId` URL.
|
||||
|
||||
## Scope
|
||||
|
||||
|
||||
@@ -258,6 +258,8 @@ External links open in a new tab with `rel="noreferrer noopener"`. The app never
|
||||
Server functions translate library failures into a small discriminated error model:
|
||||
|
||||
- `invalid-input`;
|
||||
- `post-not-found`;
|
||||
- `post-unavailable`;
|
||||
- `user-not-found`;
|
||||
- `user-unavailable`;
|
||||
- `relay-config`;
|
||||
@@ -335,7 +337,9 @@ Bird's README and changelog document the search product option. No AGENTS.md or
|
||||
The work is complete when:
|
||||
|
||||
1. Bird supports Top and Latest through its public library and CLI contracts while preserving Latest as the default.
|
||||
2. Twitter Lite shows no posts before an explicit user submission.
|
||||
2. Twitter Lite shows no posts before an explicit user action: submitting a
|
||||
target or query, following a post detail link, or manually opening a valid
|
||||
status URL.
|
||||
3. A valid handle or profile URL loads that user's posts.
|
||||
4. Search supports Top, Latest, and the follows filter.
|
||||
5. Infinite scrolling continues cursor pages without a fixed limit and without duplicate posts.
|
||||
|
||||
@@ -153,6 +153,49 @@ describe('loadThreadPage', () => {
|
||||
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()
|
||||
|
||||
|
||||
@@ -133,9 +133,13 @@ export async function loadThreadPage(
|
||||
return upstreamFailure(focal.error ?? 'Tweet not found', 'post')
|
||||
}
|
||||
focalPost = publicPost(focal.tweet)
|
||||
conversationId = normalizeTweetId(
|
||||
focal.tweet.conversationId ?? focal.tweet.id,
|
||||
)
|
||||
try {
|
||||
conversationId = normalizeTweetId(
|
||||
focal.tweet.conversationId ?? focal.tweet.id,
|
||||
)
|
||||
} catch (error) {
|
||||
return upstreamFailure(error, 'post')
|
||||
}
|
||||
}
|
||||
|
||||
const result = await reader.getThreadPaged(conversationId, {
|
||||
|
||||
Reference in New Issue
Block a user