fix: classify malformed thread metadata
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
# Twitter Lite
|
# Twitter Lite
|
||||||
|
|
||||||
Twitter Lite is an intentional, read-only X reader. It stays empty until you
|
Twitter Lite is an intentional, read-only X reader. It shows content only after
|
||||||
enter a user handle, profile URL, or search query.
|
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
|
## 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:
|
Server functions translate library failures into a small discriminated error model:
|
||||||
|
|
||||||
- `invalid-input`;
|
- `invalid-input`;
|
||||||
|
- `post-not-found`;
|
||||||
|
- `post-unavailable`;
|
||||||
- `user-not-found`;
|
- `user-not-found`;
|
||||||
- `user-unavailable`;
|
- `user-unavailable`;
|
||||||
- `relay-config`;
|
- `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:
|
The work is complete when:
|
||||||
|
|
||||||
1. Bird supports Top and Latest through its public library and CLI contracts while preserving Latest as the default.
|
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.
|
3. A valid handle or profile URL loads that user's posts.
|
||||||
4. Search supports Top, Latest, and the follows filter.
|
4. Search supports Top, Latest, and the follows filter.
|
||||||
5. Infinite scrolling continues cursor pages without a fixed limit and without duplicate posts.
|
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()
|
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 () => {
|
it('rejects an invalid ID without a Bird call', async () => {
|
||||||
const client = reader()
|
const client = reader()
|
||||||
|
|
||||||
|
|||||||
@@ -133,9 +133,13 @@ export async function loadThreadPage(
|
|||||||
return upstreamFailure(focal.error ?? 'Tweet not found', 'post')
|
return upstreamFailure(focal.error ?? 'Tweet not found', 'post')
|
||||||
}
|
}
|
||||||
focalPost = publicPost(focal.tweet)
|
focalPost = publicPost(focal.tweet)
|
||||||
conversationId = normalizeTweetId(
|
try {
|
||||||
focal.tweet.conversationId ?? focal.tweet.id,
|
conversationId = normalizeTweetId(
|
||||||
)
|
focal.tweet.conversationId ?? focal.tweet.id,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
return upstreamFailure(error, 'post')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await reader.getThreadPaged(conversationId, {
|
const result = await reader.getThreadPaged(conversationId, {
|
||||||
|
|||||||
Reference in New Issue
Block a user