test: add comprehensive unit tests for all components, API routes, and lib functions
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
global.fetch = mockFetch
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
process.env.DIRECTUS_URL = 'https://cms.achmad.dev'
|
||||
process.env.DIRECTUS_TOKEN = 'test-token'
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
body: new ReadableStream(),
|
||||
headers: new Headers({ 'Content-Type': 'image/jpeg' }),
|
||||
})
|
||||
})
|
||||
|
||||
async function getImage(id: string, width?: string) {
|
||||
const { GET } = await import('@/app/api/image/[id]/route')
|
||||
let url = `http://localhost/api/image/${id}`
|
||||
if (width) url += `?width=${width}`
|
||||
return GET(new NextRequest(url), { params: Promise.resolve({ id }) })
|
||||
}
|
||||
|
||||
describe('GET /api/image/[id]', () => {
|
||||
it('proxies the image from Directus', async () => {
|
||||
const res = await getImage('uuid-123')
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/assets/uuid-123'),
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer test-token' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('sets Content-Type from upstream response', async () => {
|
||||
const res = await getImage('uuid-123')
|
||||
expect(res.headers.get('Content-Type')).toBe('image/jpeg')
|
||||
})
|
||||
|
||||
it('sets immutable cache headers', async () => {
|
||||
const res = await getImage('uuid-123')
|
||||
expect(res.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable')
|
||||
})
|
||||
|
||||
it('returns 404 when upstream returns non-ok status', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: () => Promise.resolve({ error: 'Not found' }),
|
||||
})
|
||||
const res = await getImage('bad-id')
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error).toBe('Image not found')
|
||||
})
|
||||
|
||||
it('returns 502 when fetch throws an error', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('Network failure'))
|
||||
const res = await getImage('uuid-123')
|
||||
expect(res.status).toBe(502)
|
||||
const body = await res.json()
|
||||
expect(body.error).toBe('Failed to fetch image')
|
||||
})
|
||||
|
||||
it('passes width and quality query params to Directus', async () => {
|
||||
await getImage('uuid-123', '400')
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('width=400'),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ArticleBody from '@/components/article/ArticleBody'
|
||||
|
||||
describe('ArticleBody', () => {
|
||||
it('renders HTML content', () => {
|
||||
render(<ArticleBody html="<p>Hello world</p>" />)
|
||||
expect(screen.getByText('Hello world')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders multiple HTML elements', () => {
|
||||
render(<ArticleBody html="<h1>Title</h1><p>Paragraph</p>" />)
|
||||
expect(screen.getByText('Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Paragraph')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies the article-body class', () => {
|
||||
const { container } = render(<ArticleBody html="<p>Test</p>" />)
|
||||
expect(container.firstChild).toHaveClass('article-body')
|
||||
})
|
||||
|
||||
it('renders complex HTML including images and links', () => {
|
||||
const html = '<p>Text with <a href="/test">a link</a> and <img src="img.jpg" alt="pic" /></p>'
|
||||
render(<ArticleBody html={html} />)
|
||||
expect(screen.getByText('a link')).toBeInTheDocument()
|
||||
expect(screen.getByAltText('pic')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders empty string without crashing', () => {
|
||||
const { container } = render(<ArticleBody html="" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ArticleGrid from '@/components/home/ArticleGrid'
|
||||
import type { Article } from '@/lib/types'
|
||||
|
||||
const baseArticle: Article = {
|
||||
id: '1',
|
||||
title: 'Test Article',
|
||||
slug: 'test-article',
|
||||
status: 'published',
|
||||
content: null,
|
||||
excerpt: 'An excerpt.',
|
||||
featured_image: null,
|
||||
published_at: '2026-05-28T00:00:00Z',
|
||||
date_created: '2026-05-28T00:00:00Z',
|
||||
is_featured: false,
|
||||
seo_title: null,
|
||||
seo_description: null,
|
||||
category: { id: '1', name: 'Anime', slug: 'anime', description: null },
|
||||
tags: [],
|
||||
}
|
||||
|
||||
const makeArticle = (overrides: Partial<Article>): Article => ({
|
||||
...baseArticle,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ArticleGrid', () => {
|
||||
it('returns null when articles array is empty', () => {
|
||||
const { container } = render(<ArticleGrid articles={[]} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders the title when provided', () => {
|
||||
render(<ArticleGrid articles={[baseArticle]} title="Latest" />)
|
||||
expect(screen.getByText('Latest')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render a title section when title is omitted', () => {
|
||||
render(<ArticleGrid articles={[baseArticle]} />)
|
||||
expect(screen.queryByText('Latest')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the correct number of ArticleCards', () => {
|
||||
const articles = [
|
||||
makeArticle({ id: '1', title: 'Article 1' }),
|
||||
makeArticle({ id: '2', title: 'Article 2' }),
|
||||
makeArticle({ id: '3', title: 'Article 3' }),
|
||||
]
|
||||
render(<ArticleGrid articles={articles} />)
|
||||
expect(screen.getByText('Article 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Article 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Article 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a section element with max-width class', () => {
|
||||
const { container } = render(<ArticleGrid articles={[baseArticle]} title="Latest" />)
|
||||
const section = container.querySelector('section')
|
||||
expect(section).toHaveClass('max-w-[1200px]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Footer from '@/components/layout/Footer'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-05-31'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Footer', () => {
|
||||
it('renders the site logo', () => {
|
||||
render(<Footer />)
|
||||
expect(screen.getByText('言羽')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the tagline', () => {
|
||||
render(<Footer />)
|
||||
expect(
|
||||
screen.getByText(/Japanese pop-culture news/)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the current year in copyright', () => {
|
||||
render(<Footer />)
|
||||
expect(screen.getByText(/2026 Kotobane/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a footer element with border class', () => {
|
||||
const { container } = render(<Footer />)
|
||||
const footer = container.querySelector('footer')
|
||||
expect(footer).toHaveClass('border-t')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import HeroSection from '@/components/home/HeroSection'
|
||||
import type { Article } from '@/lib/types'
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
const { fill, priority, ...rest } = props
|
||||
return <img data-fill={fill ? 'true' : undefined} data-priority={priority ? 'true' : undefined} {...rest} />
|
||||
},
|
||||
}))
|
||||
|
||||
const mockArticle: Article = {
|
||||
id: '1',
|
||||
title: 'Frieren Season 2 Officially Announced',
|
||||
slug: 'frieren-s2',
|
||||
status: 'published',
|
||||
content: null,
|
||||
excerpt: 'The beloved series returns with a new season.',
|
||||
featured_image: 'uuid-image-123',
|
||||
published_at: '2026-05-28T00:00:00Z',
|
||||
date_created: '2026-05-28T00:00:00Z',
|
||||
is_featured: true,
|
||||
seo_title: null,
|
||||
seo_description: null,
|
||||
category: { id: '1', name: 'Anime', slug: 'anime', description: null },
|
||||
tags: [],
|
||||
}
|
||||
|
||||
describe('HeroSection', () => {
|
||||
it('renders the article title', () => {
|
||||
render(<HeroSection article={mockArticle} />)
|
||||
expect(screen.getByText('Frieren Season 2 Officially Announced')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the category name', () => {
|
||||
render(<HeroSection article={mockArticle} />)
|
||||
expect(screen.getByText('Anime')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the Featured badge', () => {
|
||||
render(<HeroSection article={mockArticle} />)
|
||||
expect(screen.getByText('Featured')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the excerpt when provided', () => {
|
||||
render(<HeroSection article={mockArticle} />)
|
||||
expect(screen.getByText('The beloved series returns with a new season.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links to the article page', () => {
|
||||
render(<HeroSection article={mockArticle} />)
|
||||
const link = screen.getByRole('link', { name: /read article/i })
|
||||
expect(link).toHaveAttribute('href', '/anime/frieren-s2')
|
||||
})
|
||||
|
||||
it('renders an image when featured_image is set', () => {
|
||||
render(<HeroSection article={mockArticle} />)
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toBeInTheDocument()
|
||||
expect(img).toHaveAttribute('alt', 'Frieren Season 2 Officially Announced')
|
||||
})
|
||||
|
||||
it('renders placeholder when featured_image is null', () => {
|
||||
const articleNoImg = { ...mockArticle, featured_image: null }
|
||||
const { container } = render(<HeroSection article={articleNoImg} />)
|
||||
expect(container.querySelector('.bg-bg-elevated')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render excerpt section when excerpt is null', () => {
|
||||
const articleNoExcerpt = { ...mockArticle, excerpt: null }
|
||||
render(<HeroSection article={articleNoExcerpt} />)
|
||||
expect(screen.queryByText('The beloved series returns with a new season.')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import LoadMoreButton from '@/components/article/LoadMoreButton'
|
||||
|
||||
process.env.NEXT_PUBLIC_DIRECTUS_URL = 'https://cms.achmad.dev'
|
||||
process.env.NEXT_PUBLIC_DIRECTUS_TOKEN = 'test-token'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
global.fetch = mockFetch
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
})
|
||||
})
|
||||
|
||||
describe('LoadMoreButton', () => {
|
||||
it('renders the Load more button by default', () => {
|
||||
render(<LoadMoreButton categorySlug="games" initialCount={12} />)
|
||||
expect(screen.getByText('Load more')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the button when hasMore is false', () => {
|
||||
render(<LoadMoreButton categorySlug="games" initialCount={12} hasMore={false} />)
|
||||
expect(screen.queryByText('Load more')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows loading state while fetching', async () => {
|
||||
mockFetch.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
}),
|
||||
100,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
render(<LoadMoreButton categorySlug="games" initialCount={12} />)
|
||||
fireEvent.click(screen.getByText('Load more'))
|
||||
|
||||
expect(await screen.findByText('Loading…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('fetches articles with correct parameters', async () => {
|
||||
render(<LoadMoreButton categorySlug="games" initialCount={12} />)
|
||||
fireEvent.click(screen.getByText('Load more'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const url = mockFetch.mock.calls[0][0] as string
|
||||
expect(url).toContain('/items/articles')
|
||||
expect(url).toContain('games')
|
||||
expect(url).toContain('offset=12')
|
||||
expect(url).toContain('limit=12')
|
||||
expect(url).toContain('access_token=test-token')
|
||||
})
|
||||
|
||||
it('appends fetched articles to the display', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
id: '101',
|
||||
title: 'Loaded Article',
|
||||
slug: 'loaded-article',
|
||||
excerpt: null,
|
||||
featured_image: null,
|
||||
published_at: null,
|
||||
date_created: '2026-05-31T00:00:00Z',
|
||||
is_featured: false,
|
||||
category: { id: '1', name: 'Games', slug: 'games' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
render(<LoadMoreButton categorySlug="games" initialCount={12} />)
|
||||
fireEvent.click(screen.getByText('Load more'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Loaded Article')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('hides button after last page when fewer results than limit are returned', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
})
|
||||
|
||||
render(<LoadMoreButton categorySlug="games" initialCount={12} />)
|
||||
expect(screen.getByText('Load more')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Load more'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Load more')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps button visible when exactly limit articles are returned', async () => {
|
||||
const articles = Array.from({ length: 12 }, (_, i) => ({
|
||||
id: String(200 + i),
|
||||
title: `Article ${i}`,
|
||||
slug: `article-${i}`,
|
||||
excerpt: null,
|
||||
featured_image: null,
|
||||
published_at: null,
|
||||
date_created: '2026-05-31T00:00:00Z',
|
||||
is_featured: false,
|
||||
category: { id: '1', name: 'Games', slug: 'games' },
|
||||
}))
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () => Promise.resolve({ data: articles }),
|
||||
})
|
||||
|
||||
render(<LoadMoreButton categorySlug="games" initialCount={12} />)
|
||||
fireEvent.click(screen.getByText('Load more'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Load more')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls the correct Directus API URL', async () => {
|
||||
render(<LoadMoreButton categorySlug="vtubers" initialCount={14} />)
|
||||
fireEvent.click(screen.getByText('Load more'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const url = mockFetch.mock.calls[0][0] as string
|
||||
expect(url).toContain('vtubers')
|
||||
expect(url).toContain('offset=14')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import NavbarClient from '@/components/layout/NavbarClient'
|
||||
import type { Category } from '@/lib/types'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: vi.fn(() => '/'),
|
||||
useRouter: vi.fn(() => ({ push: mockPush })),
|
||||
}))
|
||||
|
||||
const categories: Category[] = [
|
||||
{ id: '1', name: 'Anime', slug: 'anime', description: 'Anime news' },
|
||||
{ id: '2', name: 'Games', slug: 'games', description: null },
|
||||
{ id: '3', name: 'Music', slug: 'music', description: 'Music coverage' },
|
||||
]
|
||||
|
||||
describe('NavbarClient', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockReset()
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
it('renders all category links', () => {
|
||||
render(<NavbarClient categories={categories} />)
|
||||
expect(screen.getByText('Anime')).toBeInTheDocument()
|
||||
expect(screen.getByText('Games')).toBeInTheDocument()
|
||||
expect(screen.getByText('Music')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the site logo', () => {
|
||||
render(<NavbarClient categories={categories} />)
|
||||
expect(screen.getByText('言羽')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a search button', () => {
|
||||
render(<NavbarClient categories={categories} />)
|
||||
expect(screen.getByLabelText('Open search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dispatches custom event when search button is clicked', () => {
|
||||
const dispatchSpy = vi.fn()
|
||||
window.dispatchEvent = dispatchSpy
|
||||
|
||||
render(<NavbarClient categories={categories} />)
|
||||
fireEvent.click(screen.getByLabelText('Open search'))
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'kotobane:open-search' })
|
||||
)
|
||||
})
|
||||
|
||||
it('shows mobile hamburger menu button', () => {
|
||||
render(<NavbarClient categories={categories} />)
|
||||
expect(screen.getByLabelText('Open menu')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens mobile overlay when hamburger is clicked', () => {
|
||||
render(<NavbarClient categories={categories} />)
|
||||
fireEvent.click(screen.getByLabelText('Open menu'))
|
||||
expect(screen.getByLabelText('Close menu')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes mobile overlay when close button is clicked', () => {
|
||||
render(<NavbarClient categories={categories} />)
|
||||
fireEvent.click(screen.getByLabelText('Open menu'))
|
||||
expect(screen.getByLabelText('Close menu')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Close menu'))
|
||||
expect(screen.queryByLabelText('Close menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes mobile overlay when backdrop is clicked', () => {
|
||||
render(<NavbarClient categories={categories} />)
|
||||
fireEvent.click(screen.getByLabelText('Open menu'))
|
||||
const backdrop = document.querySelector('.bg-black\\/60')
|
||||
expect(backdrop).toBeInTheDocument()
|
||||
if (backdrop) fireEvent.click(backdrop)
|
||||
expect(screen.queryByLabelText('Close menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('locks body scroll when menu is open, unlocks when closed', () => {
|
||||
render(<NavbarClient categories={categories} />)
|
||||
fireEvent.click(screen.getByLabelText('Open menu'))
|
||||
expect(document.body.style.overflow).toBe('hidden')
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Close menu'))
|
||||
expect(document.body.style.overflow).toBe('')
|
||||
})
|
||||
|
||||
it('marks the current path category as active', async () => {
|
||||
const { usePathname } = await import('next/navigation')
|
||||
vi.mocked(usePathname).mockReturnValue('/anime')
|
||||
|
||||
render(<NavbarClient categories={categories} />)
|
||||
const animeLink = screen.getByText('Anime').closest('a')
|
||||
expect(animeLink).toHaveClass('text-accent')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,320 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import SearchOverlay from '@/components/search/SearchOverlay'
|
||||
|
||||
process.env.NEXT_PUBLIC_DIRECTUS_URL = 'https://cms.achmad.dev'
|
||||
process.env.NEXT_PUBLIC_DIRECTUS_TOKEN = 'test-token'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: vi.fn(() => ({ push: mockPush })),
|
||||
}))
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
global.fetch = mockFetch
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.useFakeTimers()
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
})
|
||||
mockPush.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function openSearch() {
|
||||
window.dispatchEvent(new CustomEvent('kotobane:open-search'))
|
||||
}
|
||||
|
||||
describe('SearchOverlay', () => {
|
||||
it('does not render when closed initially', () => {
|
||||
const { container } = render(<SearchOverlay />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('opens when custom event is dispatched', () => {
|
||||
render(<SearchOverlay />)
|
||||
act(() => {
|
||||
openSearch()
|
||||
})
|
||||
expect(screen.getByPlaceholderText('Search articles…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens when Ctrl+K / Cmd+K is pressed', () => {
|
||||
render(<SearchOverlay />)
|
||||
fireEvent.keyDown(window, { key: 'k', metaKey: true })
|
||||
expect(screen.getByPlaceholderText('Search articles…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes when Escape is pressed', () => {
|
||||
render(<SearchOverlay />)
|
||||
act(() => {
|
||||
openSearch()
|
||||
})
|
||||
expect(screen.getByPlaceholderText('Search articles…')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(screen.queryByPlaceholderText('Search articles…')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes when clicking the backdrop', () => {
|
||||
render(<SearchOverlay />)
|
||||
act(() => {
|
||||
openSearch()
|
||||
})
|
||||
expect(screen.getByPlaceholderText('Search articles…')).toBeInTheDocument()
|
||||
|
||||
const backdrop = document.querySelector('.bg-black\\/60')
|
||||
expect(backdrop).toBeInTheDocument()
|
||||
if (backdrop) fireEvent.click(backdrop)
|
||||
expect(screen.queryByPlaceholderText('Search articles…')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('fetches search results after debounce delay', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Search Result',
|
||||
slug: 'search-result',
|
||||
category: { slug: 'anime', name: 'Anime' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
render(<SearchOverlay />)
|
||||
act(() => {
|
||||
openSearch()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('Search articles…')
|
||||
fireEvent.change(input, { target: { value: 'Frieren' } })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Search Result')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows category name in search results', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Result',
|
||||
slug: 'result',
|
||||
category: { slug: 'music', name: 'Music' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
render(<SearchOverlay />)
|
||||
act(() => {
|
||||
openSearch()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('Search articles…')
|
||||
fireEvent.change(input, { target: { value: 'song' } })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Music')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows loading indicator while searching', async () => {
|
||||
mockFetch.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
}),
|
||||
500,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
render(<SearchOverlay />)
|
||||
act(() => {
|
||||
openSearch()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('Search articles…')
|
||||
fireEvent.change(input, { target: { value: 'test' } })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
})
|
||||
|
||||
// After the debounce fires, setLoading(true) is called synchronously
|
||||
expect(screen.getByText('Searching…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows no results message when search returns empty', async () => {
|
||||
render(<SearchOverlay />)
|
||||
act(() => {
|
||||
openSearch()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('Search articles…')
|
||||
fireEvent.change(input, { target: { value: 'zzzz' } })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
})
|
||||
|
||||
expect(screen.getByText(/no results/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates to article on click', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Clickable Result',
|
||||
slug: 'clickable-result',
|
||||
category: { slug: 'anime', name: 'Anime' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
render(<SearchOverlay />)
|
||||
act(() => {
|
||||
openSearch()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('Search articles…')
|
||||
fireEvent.change(input, { target: { value: 'item' } })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Clickable Result')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Clickable Result'))
|
||||
expect(mockPush).toHaveBeenCalledWith('/anime/clickable-result')
|
||||
})
|
||||
|
||||
it('clears results when input is cleared', () => {
|
||||
render(<SearchOverlay />)
|
||||
act(() => {
|
||||
openSearch()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('Search articles…')
|
||||
fireEvent.change(input, { target: { value: 'test' } })
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
|
||||
expect(screen.queryByText('No results')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('debounces search requests', () => {
|
||||
render(<SearchOverlay />)
|
||||
act(() => {
|
||||
openSearch()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('Search articles…')
|
||||
fireEvent.change(input, { target: { value: 'a' } })
|
||||
fireEvent.change(input, { target: { value: 'ab' } })
|
||||
fireEvent.change(input, { target: { value: 'abc' } })
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// Should not have fired yet (debounce is 300ms)
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('navigates on Enter key press', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Enter Result',
|
||||
slug: 'enter-result',
|
||||
category: { slug: 'manga', name: 'Manga' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
render(<SearchOverlay />)
|
||||
act(() => {
|
||||
openSearch()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('Search articles…')
|
||||
fireEvent.change(input, { target: { value: 'manga' } })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Enter Result')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(mockPush).toHaveBeenCalledWith('/manga/enter-result')
|
||||
})
|
||||
|
||||
it('clears search results when overlay reopens via Escape', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Temp Result',
|
||||
slug: 'temp',
|
||||
category: { slug: 'anime', name: 'Anime' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
render(<SearchOverlay />)
|
||||
act(() => {
|
||||
openSearch()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('Search articles…')
|
||||
fireEvent.change(input, { target: { value: 'temp' } })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Temp Result')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
|
||||
act(() => {
|
||||
openSearch()
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Temp Result')).not.toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Search articles…')).toHaveValue('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import TagRow from '@/components/article/TagRow'
|
||||
import type { ArticleTag } from '@/lib/types'
|
||||
|
||||
const makeTag = (id: string, name: string, slug: string): ArticleTag => ({
|
||||
tags_id: { id, name, slug },
|
||||
})
|
||||
|
||||
describe('TagRow', () => {
|
||||
it('renders tag names', () => {
|
||||
const tags = [makeTag('1', 'Anime', 'anime'), makeTag('2', 'Music', 'music')]
|
||||
render(<TagRow tags={tags} />)
|
||||
expect(screen.getByText('Anime')).toBeInTheDocument()
|
||||
expect(screen.getByText('Music')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns null when tags array is empty', () => {
|
||||
const { container } = render(<TagRow tags={[]} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('returns null when tags is undefined', () => {
|
||||
const { container } = render(<TagRow tags={undefined as unknown as ArticleTag[]} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders each tag with unique key', () => {
|
||||
const tags = [makeTag('1', 'VTuber', 'vtubers')]
|
||||
render(<TagRow tags={tags} />)
|
||||
expect(screen.getByText('VTuber')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -73,4 +73,187 @@ describe('getAssetUrl', () => {
|
||||
expect(url).toContain('quality=80')
|
||||
expect(url).toContain('/api/image/')
|
||||
})
|
||||
|
||||
it('handles height param', async () => {
|
||||
const { getAssetUrl } = await import('@/lib/directus')
|
||||
const url = getAssetUrl('abc-123-uuid', { height: 600 })
|
||||
expect(url).toContain('height=600')
|
||||
})
|
||||
|
||||
it('omits params when none are passed', async () => {
|
||||
const { getAssetUrl } = await import('@/lib/directus')
|
||||
const url = getAssetUrl('abc-123-uuid')
|
||||
expect(url).toBe('/api/image/abc-123-uuid')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCategoryBySlug', () => {
|
||||
it('returns the matching category when found', async () => {
|
||||
mockRequest.mockResolvedValue([
|
||||
{ id: '1', name: 'Anime', slug: 'anime', description: null },
|
||||
])
|
||||
const { getCategoryBySlug } = await import('@/lib/directus')
|
||||
const result = await getCategoryBySlug('anime')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.name).toBe('Anime')
|
||||
})
|
||||
|
||||
it('returns null when no category matches', async () => {
|
||||
mockRequest.mockResolvedValue([])
|
||||
const { getCategoryBySlug } = await import('@/lib/directus')
|
||||
const result = await getCategoryBySlug('nonexistent')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getArticles', () => {
|
||||
it('returns articles with default limit and sort', async () => {
|
||||
mockRequest.mockResolvedValue([
|
||||
{
|
||||
id: '1', title: 'Article 1', slug: 'article-1', status: 'published',
|
||||
excerpt: null, featured_image: null,
|
||||
published_at: '2026-05-30T00:00:00Z', date_created: '2026-05-30T00:00:00Z',
|
||||
is_featured: false,
|
||||
category: { id: '1', name: 'Anime', slug: 'anime' },
|
||||
},
|
||||
])
|
||||
const { getArticles } = await import('@/lib/directus')
|
||||
const result = await getArticles()
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].title).toBe('Article 1')
|
||||
})
|
||||
|
||||
it('passes categorySlug filter when provided', async () => {
|
||||
mockRequest
|
||||
.mockResolvedValueOnce([{ id: '1', name: 'Games', slug: 'games', description: null }])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: '1', title: 'Game Article', slug: 'game-1', status: 'published',
|
||||
excerpt: null, featured_image: null,
|
||||
published_at: '2026-05-30T00:00:00Z', date_created: '2026-05-30T00:00:00Z',
|
||||
is_featured: false,
|
||||
category: { id: '1', name: 'Games', slug: 'games' },
|
||||
},
|
||||
])
|
||||
const { getArticles } = await import('@/lib/directus')
|
||||
const result = await getArticles({ categorySlug: 'games' })
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].title).toBe('Game Article')
|
||||
})
|
||||
|
||||
it('passes categoryId filter when provided', async () => {
|
||||
mockRequest.mockResolvedValue([
|
||||
{
|
||||
id: '1', title: 'Music Article', slug: 'music-1', status: 'published',
|
||||
excerpt: null, featured_image: null,
|
||||
published_at: '2026-05-30T00:00:00Z', date_created: '2026-05-30T00:00:00Z',
|
||||
is_featured: false,
|
||||
category: { id: '2', name: 'Music', slug: 'music' },
|
||||
},
|
||||
])
|
||||
const { getArticles } = await import('@/lib/directus')
|
||||
const result = await getArticles({ categoryId: '2' })
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].title).toBe('Music Article')
|
||||
})
|
||||
|
||||
it('applies limit and offset', async () => {
|
||||
const { getArticles } = await import('@/lib/directus')
|
||||
const { readItems } = await import('@directus/sdk')
|
||||
await getArticles({ limit: 5, offset: 10 })
|
||||
expect(readItems).toHaveBeenCalledWith(
|
||||
'articles',
|
||||
expect.objectContaining({ limit: 5, offset: 10 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns empty array when no articles match categorySlug and category does not exist', async () => {
|
||||
mockRequest.mockResolvedValue([])
|
||||
const { getArticles } = await import('@/lib/directus')
|
||||
const result = await getArticles({ categorySlug: 'unknown' })
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRelatedArticles', () => {
|
||||
it('returns related articles in the same category excluding the current article', async () => {
|
||||
mockRequest.mockResolvedValue([
|
||||
{
|
||||
id: '2', title: 'Related', slug: 'related', status: 'published',
|
||||
excerpt: null, featured_image: null,
|
||||
published_at: '2026-05-29T00:00:00Z', date_created: '2026-05-29T00:00:00Z',
|
||||
category: { id: '1', name: 'Games', slug: 'games' },
|
||||
},
|
||||
])
|
||||
const { getRelatedArticles } = await import('@/lib/directus')
|
||||
const result = await getRelatedArticles('games', 'current-article')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].title).toBe('Related')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSiteSettings', () => {
|
||||
it('returns site settings with hero article', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
id: '1',
|
||||
site_name: 'Kotobane',
|
||||
hero_article: { id: '1', title: 'Hero', slug: 'hero' },
|
||||
})
|
||||
const { getSiteSettings } = await import('@/lib/directus')
|
||||
const result = await getSiteSettings()
|
||||
expect(result.site_name).toBe('Kotobane')
|
||||
expect(result.hero_article?.title).toBe('Hero')
|
||||
})
|
||||
|
||||
it('returns settings without hero when none is set', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
id: '1',
|
||||
site_name: 'Kotobane',
|
||||
hero_article: null,
|
||||
})
|
||||
const { getSiteSettings } = await import('@/lib/directus')
|
||||
const result = await getSiteSettings()
|
||||
expect(result.hero_article).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchArticles', () => {
|
||||
it('searches articles by title or excerpt', async () => {
|
||||
mockRequest.mockResolvedValue([
|
||||
{
|
||||
id: '1', title: 'Frieren News', slug: 'frieren-news',
|
||||
category: { slug: 'anime', name: 'Anime' },
|
||||
},
|
||||
])
|
||||
const { searchArticles } = await import('@/lib/directus')
|
||||
const result = await searchArticles('Frieren')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].title).toBe('Frieren News')
|
||||
})
|
||||
|
||||
it('returns empty array when no matches', async () => {
|
||||
mockRequest.mockResolvedValue([])
|
||||
const { searchArticles } = await import('@/lib/directus')
|
||||
const result = await searchArticles('zzzzzz')
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getArticlePathById', () => {
|
||||
it('returns slug and category slug', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
slug: 'my-article',
|
||||
category: { slug: 'anime' },
|
||||
})
|
||||
const { getArticlePathById } = await import('@/lib/directus')
|
||||
const result = await getArticlePathById('uuid-123')
|
||||
expect(result).toEqual({ slug: 'my-article', category: { slug: 'anime' } })
|
||||
})
|
||||
|
||||
it('returns null when article is not found', async () => {
|
||||
mockRequest.mockRejectedValue(new Error('Not found'))
|
||||
const { getArticlePathById } = await import('@/lib/directus')
|
||||
const result = await getArticlePathById('nonexistent')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user