fix: classify malformed thread metadata

This commit is contained in:
2026-07-14 02:04:11 +09:00
parent f82f6641b2
commit d8827b6513
4 changed files with 58 additions and 6 deletions
+3 -2
View File
@@ -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.
+43
View File
@@ -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()
+7 -3
View File
@@ -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, {