Files
twitter-lite/src/features/posts/inputs.test.ts
T
2026-08-10 04:17:54 +09:00

245 lines
6.4 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
buildAdvancedSearchQuery,
buildFilteredSearchQuery,
buildSearchQuery,
normalizeListTarget,
normalizeTweetId,
normalizeUserTarget,
postSearchRouteSchema,
threadPageInputSchema,
} from './inputs'
describe('threadPageInputSchema', () => {
it('accepts initial and complete continuation requests', () => {
expect(threadPageInputSchema.parse({ tweetId: '123' })).toEqual({
tweetId: '123',
})
expect(
threadPageInputSchema.parse({
tweetId: '123',
conversationId: '100',
cursor: 'thread-next',
}),
).toEqual({
tweetId: '123',
conversationId: '100',
cursor: 'thread-next',
})
})
it.each([
{ tweetId: '' },
{ tweetId: 'abc' },
{ tweetId: '123', cursor: 'thread-next' },
{ tweetId: '123', conversationId: '100' },
{ tweetId: '123', conversationId: 'root', cursor: 'thread-next' },
])('rejects an invalid thread request %#', (input) => {
expect(() => threadPageInputSchema.parse(input)).toThrow()
})
})
describe('normalizeTweetId', () => {
it('keeps a decimal post ID', () => {
expect(normalizeTweetId(' 1234567890 ')).toBe('1234567890')
})
it.each(['', '123/status', '@123', '123'])('rejects %s', (input) => {
expect(() => normalizeTweetId(input)).toThrow(
'投稿 ID を確認してください。',
)
})
})
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('normalizeListTarget', () => {
it.each([
['1234567890', '1234567890'],
[' https://x.com/i/lists/1234567890 ', '1234567890'],
['https://twitter.com/i/lists/1234567890/', '1234567890'],
])('normalizes %s', (input, expected) => {
expect(normalizeListTarget(input)).toBe(expected)
})
it.each([
'',
'not-a-list',
'12345',
'https://example.com/i/lists/1234567890',
'https://x.com/tan_stack/lists',
'https://x.com/i/lists/1234567890/members',
'https://x.com/i/lists/not-a-list',
])('rejects %s', (input) => {
expect(() => normalizeListTarget(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()
})
})
describe('buildAdvancedSearchQuery', () => {
it('builds a query from button-friendly filters', () => {
expect(
buildAdvancedSearchQuery({
q: 'TypeScript',
from: '@tan_stack',
since: '2026-08-04',
until: '2026-08-11',
lang: 'ja',
content: 'images',
excludeReplies: true,
excludeReposts: true,
}),
).toBe(
'TypeScript from:tan_stack since:2026-08-04 until:2026-08-11 lang:ja filter:images -filter:replies -filter:retweets',
)
})
it('allows an author search without keywords', () => {
expect(
buildAdvancedSearchQuery({
q: '',
from: 'tan_stack',
since: '',
until: '',
lang: 'all',
content: 'all',
excludeReplies: false,
excludeReposts: false,
}),
).toBe('from:tan_stack')
})
it('rejects filters that have no keyword or author', () => {
expect(() =>
buildAdvancedSearchQuery({
q: '',
from: '',
since: '2026-08-04',
until: '',
lang: 'ja',
content: 'all',
excludeReplies: false,
excludeReposts: false,
}),
).toThrow('検索語または投稿者を入力してください。')
})
it('rejects an invalid author and reversed dates', () => {
const base = {
q: 'TypeScript',
from: '',
since: '',
until: '',
lang: 'all' as const,
content: 'all' as const,
excludeReplies: false,
excludeReposts: false,
}
expect(() =>
buildAdvancedSearchQuery({ ...base, from: 'not-valid!' }),
).toThrow('投稿者のハンドルを確認してください。')
expect(() =>
buildAdvancedSearchQuery({
...base,
since: '2026-08-11',
until: '2026-08-10',
}),
).toThrow('終了日は開始日より後の日付を選んでください。')
})
it('rejects nonexistent dates and a compiled query over 512 characters', () => {
const base = {
q: 'TypeScript',
from: '',
since: '',
until: '',
lang: 'all' as const,
content: 'all' as const,
excludeReplies: false,
excludeReposts: false,
}
expect(() =>
buildAdvancedSearchQuery({ ...base, since: '2026-02-30' }),
).toThrow('開始日を確認してください。')
expect(() =>
buildAdvancedSearchQuery({ ...base, q: 'a'.repeat(510), lang: 'ja' }),
).toThrow('検索語は 512 文字以内で入力してください。')
})
})
describe('buildFilteredSearchQuery', () => {
it('includes the follows filter in the final query and enforces its length', () => {
const input = {
q: 'TypeScript',
from: '',
since: '',
until: '',
lang: 'ja' as const,
content: 'all' as const,
excludeReplies: false,
excludeReposts: false,
}
expect(buildFilteredSearchQuery(input, true)).toBe(
'TypeScript lang:ja filter:follows',
)
expect(() =>
buildFilteredSearchQuery({ ...input, q: 'a'.repeat(498) }, true),
).toThrow('検索語は 512 文字以内で入力してください。')
})
})
describe('postSearchRouteSchema', () => {
it('keeps omitted filter parameters optional', () => {
expect(
postSearchRouteSchema.parse({
q: 'AI',
product: 'Latest',
following: false,
}),
).toEqual({
q: 'AI',
product: 'Latest',
following: false,
})
})
})