From d8827b65135a1bfbf886a82a03488d1546c7f495 Mon Sep 17 00:00:00 2001 From: yutakobayashidev Date: Tue, 14 Jul 2026 02:04:11 +0900 Subject: [PATCH] fix: classify malformed thread metadata --- README.md | 5 ++- .../specs/2026-07-13-twitter-lite-design.md | 6 ++- src/features/posts/post-service.test.ts | 43 +++++++++++++++++++ src/features/posts/post-service.ts | 10 +++-- 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6424515..553f5de 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/superpowers/specs/2026-07-13-twitter-lite-design.md b/docs/superpowers/specs/2026-07-13-twitter-lite-design.md index 903b50b..e065890 100644 --- a/docs/superpowers/specs/2026-07-13-twitter-lite-design.md +++ b/docs/superpowers/specs/2026-07-13-twitter-lite-design.md @@ -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. diff --git a/src/features/posts/post-service.test.ts b/src/features/posts/post-service.test.ts index a09ec8b..892d19f 100644 --- a/src/features/posts/post-service.test.ts +++ b/src/features/posts/post-service.test.ts @@ -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() diff --git a/src/features/posts/post-service.ts b/src/features/posts/post-service.ts index a06f94b..2378c0c 100644 --- a/src/features/posts/post-service.ts +++ b/src/features/posts/post-service.ts @@ -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, {