test: add comprehensive unit tests for all components, API routes, and lib functions

This commit is contained in:
achmad
2026-05-31 17:20:01 +07:00
parent 963ff5affc
commit c62ac1da0b
10 changed files with 1062 additions and 0 deletions
+74
View File
@@ -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),
)
})
})
+33
View File
@@ -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()
})
})
+61
View File
@@ -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]')
})
})
+37
View File
@@ -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')
})
})
+76
View File
@@ -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')
})
})
+100
View File
@@ -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')
})
})
+320
View File
@@ -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('')
})
})
+33
View File
@@ -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()
})
})
+183
View File
@@ -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()
})
})