From 5873728c30b3b9359cbd4dec0f38198e9fafefe2 Mon Sep 17 00:00:00 2001 From: yutakobayashidev Date: Mon, 13 Jul 2026 10:21:27 +0900 Subject: [PATCH] feat: validate intentional post requests --- src/features/posts/inputs.test.ts | 41 +++++++++++++++++ src/features/posts/inputs.ts | 74 +++++++++++++++++++++++++++++++ src/features/posts/page.test.ts | 20 +++++++++ src/features/posts/page.ts | 12 +++++ src/features/posts/types.ts | 38 ++++++++++++++++ 5 files changed, 185 insertions(+) create mode 100644 src/features/posts/inputs.test.ts create mode 100644 src/features/posts/inputs.ts create mode 100644 src/features/posts/page.test.ts create mode 100644 src/features/posts/page.ts create mode 100644 src/features/posts/types.ts diff --git a/src/features/posts/inputs.test.ts b/src/features/posts/inputs.test.ts new file mode 100644 index 0000000..01281a9 --- /dev/null +++ b/src/features/posts/inputs.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { buildSearchQuery, normalizeUserTarget } from './inputs' + +describe('normalizeUserTarget', () => { + it.each([ + ['@tan_stack', 'tan_stack'], + ['tan_stack', 'tan_stack'], + ['https://x.com/tan_stack', 'tan_stack'], + ['https://twitter.com/tan_stack/', 'tan_stack'], + ])('normalizes %s', (input, expected) => { + expect(normalizeUserTarget(input)).toBe(expected) + }) + + it.each([ + '', + 'not valid', + 'https://example.com/tan_stack', + 'https://x.com/tan_stack/status/1', + ])('rejects %s', (input) => { + expect(() => normalizeUserTarget(input)).toThrow() + }) +}) + +describe('buildSearchQuery', () => { + it('keeps a deliberate query unchanged', () => { + expect(buildSearchQuery(' AI lang:ja ', false)).toBe('AI lang:ja') + }) + + it('adds the follows operator once', () => { + expect(buildSearchQuery('AI lang:ja', true)).toBe( + 'AI lang:ja filter:follows', + ) + expect(buildSearchQuery('AI filter:follows', true)).toBe( + 'AI filter:follows', + ) + }) + + it('rejects an empty query', () => { + expect(() => buildSearchQuery(' ', false)).toThrow() + }) +}) diff --git a/src/features/posts/inputs.ts b/src/features/posts/inputs.ts new file mode 100644 index 0000000..92451eb --- /dev/null +++ b/src/features/posts/inputs.ts @@ -0,0 +1,74 @@ +import { z } from 'zod' + +const HANDLE = /^[A-Za-z0-9_]{1,15}$/ +const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i + +export class InputError extends Error {} + +export const userPageInputSchema = z.object({ + target: z.string().trim().min(1).max(256), + cursor: z.string().min(1).optional(), +}) + +export const searchPageInputSchema = z.object({ + query: z.string().trim().min(1).max(512), + product: z.enum(['Top', 'Latest']), + following: z.boolean(), + cursor: z.string().min(1).optional(), +}) + +export const userRouteSearchSchema = z.object({ + target: z.string().catch(''), +}) + +export const postSearchRouteSchema = z.object({ + q: z.string().catch(''), + product: z.enum(['Top', 'Latest']).catch('Latest'), + following: z.boolean().catch(false), +}) + +function requireHandle(value: string): string { + if (!HANDLE.test(value)) { + throw new InputError('ハンドルは英数字とアンダースコアで入力してください。') + } + return value +} + +export function normalizeUserTarget(raw: string): string { + const value = raw.trim() + if (!value) { + throw new InputError('ハンドルまたはプロフィール URL を入力してください。') + } + if (value.startsWith('@')) { + return requireHandle(value.slice(1)) + } + if (!value.includes('://')) { + return requireHandle(value) + } + + let url: URL + try { + url = new URL(value) + } catch { + throw new InputError('プロフィール URL の形式を確認してください。') + } + if (!['x.com', 'twitter.com'].includes(url.hostname.toLowerCase())) { + throw new InputError('x.com または twitter.com の URL を入力してください。') + } + const segments = url.pathname.split('/').filter(Boolean) + if (segments.length !== 1) { + throw new InputError('プロフィール URL を入力してください。') + } + return requireHandle(segments[0] ?? '') +} + +export function buildSearchQuery(raw: string, following: boolean): string { + const query = raw.trim() + if (!query) { + throw new InputError('検索語を入力してください。') + } + if (query.length > 512) { + throw new InputError('検索語は 512 文字以内で入力してください。') + } + return following && !FOLLOWS.test(query) ? `${query} filter:follows` : query +} diff --git a/src/features/posts/page.test.ts b/src/features/posts/page.test.ts new file mode 100644 index 0000000..fa842ed --- /dev/null +++ b/src/features/posts/page.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { flattenPostPages } from './page' +import type { Post, PostPage } from './types' + +const post = (id: string): Post => ({ + id, + text: `post-${id}`, + author: { username: `user-${id}`, name: `User ${id}` }, +}) + +describe('flattenPostPages', () => { + it('preserves API order and removes duplicate IDs', () => { + const pages: PostPage[] = [ + { tweets: [post('1'), post('2')], nextCursor: 'next' }, + { tweets: [post('2'), post('3')] }, + ] + + expect(flattenPostPages(pages).map(({ id }) => id)).toEqual(['1', '2', '3']) + }) +}) diff --git a/src/features/posts/page.ts b/src/features/posts/page.ts new file mode 100644 index 0000000..d737758 --- /dev/null +++ b/src/features/posts/page.ts @@ -0,0 +1,12 @@ +import type { Post, PostPage } from './types' + +export function flattenPostPages(pages: PostPage[]): Post[] { + const seen = new Set() + return pages.flatMap(({ tweets }) => + tweets.filter(({ id }) => { + if (seen.has(id)) return false + seen.add(id) + return true + }), + ) +} diff --git a/src/features/posts/types.ts b/src/features/posts/types.ts new file mode 100644 index 0000000..e62f004 --- /dev/null +++ b/src/features/posts/types.ts @@ -0,0 +1,38 @@ +import type { SearchProduct, TweetData } from '@yuta/bird' + +export type Post = TweetData + +export type PostPage = { + tweets: Post[] + nextCursor?: string +} + +export type LoadErrorCode = + | 'invalid-input' + | 'user-not-found' + | 'user-unavailable' + | 'relay-config' + | 'timeout' + | 'upstream' + +export type LoadError = { + code: LoadErrorCode + message: string + retryable: boolean +} + +export type LoadResult = + | { ok: true; page: PostPage } + | { ok: false; error: LoadError } + +export type UserPageInput = { + target: string + cursor?: string +} + +export type SearchPageInput = { + query: string + product: SearchProduct + following: boolean + cursor?: string +}