diff --git a/__tests__/api/image.test.ts b/__tests__/api/image.test.ts
new file mode 100644
index 0000000..03ce299
--- /dev/null
+++ b/__tests__/api/image.test.ts
@@ -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),
+ )
+ })
+})
diff --git a/__tests__/components/ArticleBody.test.tsx b/__tests__/components/ArticleBody.test.tsx
new file mode 100644
index 0000000..31b2466
--- /dev/null
+++ b/__tests__/components/ArticleBody.test.tsx
@@ -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()
+ expect(screen.getByText('Hello world')).toBeInTheDocument()
+ })
+
+ it('renders multiple HTML elements', () => {
+ render()
+ expect(screen.getByText('Title')).toBeInTheDocument()
+ expect(screen.getByText('Paragraph')).toBeInTheDocument()
+ })
+
+ it('applies the article-body class', () => {
+ const { container } = render()
+ expect(container.firstChild).toHaveClass('article-body')
+ })
+
+ it('renders complex HTML including images and links', () => {
+ const html = '
Text with a link and 
'
+ render()
+ expect(screen.getByText('a link')).toBeInTheDocument()
+ expect(screen.getByAltText('pic')).toBeInTheDocument()
+ })
+
+ it('renders empty string without crashing', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+})
diff --git a/__tests__/components/ArticleGrid.test.tsx b/__tests__/components/ArticleGrid.test.tsx
new file mode 100644
index 0000000..f2951ed
--- /dev/null
+++ b/__tests__/components/ArticleGrid.test.tsx
@@ -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 => ({
+ ...baseArticle,
+ ...overrides,
+})
+
+describe('ArticleGrid', () => {
+ it('returns null when articles array is empty', () => {
+ const { container } = render()
+ expect(container.innerHTML).toBe('')
+ })
+
+ it('renders the title when provided', () => {
+ render()
+ expect(screen.getByText('Latest')).toBeInTheDocument()
+ })
+
+ it('does not render a title section when title is omitted', () => {
+ render()
+ 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()
+ 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()
+ const section = container.querySelector('section')
+ expect(section).toHaveClass('max-w-[1200px]')
+ })
+})
diff --git a/__tests__/components/Footer.test.tsx b/__tests__/components/Footer.test.tsx
new file mode 100644
index 0000000..8d1cc7e
--- /dev/null
+++ b/__tests__/components/Footer.test.tsx
@@ -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()
+ expect(screen.getByText('言羽')).toBeInTheDocument()
+ })
+
+ it('renders the tagline', () => {
+ render()
+ expect(
+ screen.getByText(/Japanese pop-culture news/)
+ ).toBeInTheDocument()
+ })
+
+ it('renders the current year in copyright', () => {
+ render()
+ expect(screen.getByText(/2026 Kotobane/)).toBeInTheDocument()
+ })
+
+ it('renders a footer element with border class', () => {
+ const { container } = render()
+ const footer = container.querySelector('footer')
+ expect(footer).toHaveClass('border-t')
+ })
+})
diff --git a/__tests__/components/HeroSection.test.tsx b/__tests__/components/HeroSection.test.tsx
new file mode 100644
index 0000000..6f9ae44
--- /dev/null
+++ b/__tests__/components/HeroSection.test.tsx
@@ -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) => {
+ const { fill, priority, ...rest } = props
+ return
+ },
+}))
+
+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()
+ expect(screen.getByText('Frieren Season 2 Officially Announced')).toBeInTheDocument()
+ })
+
+ it('renders the category name', () => {
+ render()
+ expect(screen.getByText('Anime')).toBeInTheDocument()
+ })
+
+ it('renders the Featured badge', () => {
+ render()
+ expect(screen.getByText('Featured')).toBeInTheDocument()
+ })
+
+ it('renders the excerpt when provided', () => {
+ render()
+ expect(screen.getByText('The beloved series returns with a new season.')).toBeInTheDocument()
+ })
+
+ it('links to the article page', () => {
+ render()
+ 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()
+ 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()
+ 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()
+ expect(screen.queryByText('The beloved series returns with a new season.')).not.toBeInTheDocument()
+ })
+})
diff --git a/__tests__/components/LoadMoreButton.test.tsx b/__tests__/components/LoadMoreButton.test.tsx
new file mode 100644
index 0000000..9bcb61e
--- /dev/null
+++ b/__tests__/components/LoadMoreButton.test.tsx
@@ -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()
+ expect(screen.getByText('Load more')).toBeInTheDocument()
+ })
+
+ it('hides the button when hasMore is false', () => {
+ render()
+ 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()
+ fireEvent.click(screen.getByText('Load more'))
+
+ expect(await screen.findByText('Loading…')).toBeInTheDocument()
+ })
+
+ it('fetches articles with correct parameters', async () => {
+ render()
+ 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()
+ 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()
+ 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()
+ fireEvent.click(screen.getByText('Load more'))
+
+ await waitFor(() => {
+ expect(screen.getByText('Load more')).toBeInTheDocument()
+ })
+ })
+
+ it('calls the correct Directus API URL', async () => {
+ render()
+ 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')
+ })
+})
diff --git a/__tests__/components/NavbarClient.test.tsx b/__tests__/components/NavbarClient.test.tsx
new file mode 100644
index 0000000..3c8ce1e
--- /dev/null
+++ b/__tests__/components/NavbarClient.test.tsx
@@ -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()
+ expect(screen.getByText('Anime')).toBeInTheDocument()
+ expect(screen.getByText('Games')).toBeInTheDocument()
+ expect(screen.getByText('Music')).toBeInTheDocument()
+ })
+
+ it('renders the site logo', () => {
+ render()
+ expect(screen.getByText('言羽')).toBeInTheDocument()
+ })
+
+ it('renders a search button', () => {
+ render()
+ expect(screen.getByLabelText('Open search')).toBeInTheDocument()
+ })
+
+ it('dispatches custom event when search button is clicked', () => {
+ const dispatchSpy = vi.fn()
+ window.dispatchEvent = dispatchSpy
+
+ render()
+ fireEvent.click(screen.getByLabelText('Open search'))
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'kotobane:open-search' })
+ )
+ })
+
+ it('shows mobile hamburger menu button', () => {
+ render()
+ expect(screen.getByLabelText('Open menu')).toBeInTheDocument()
+ })
+
+ it('opens mobile overlay when hamburger is clicked', () => {
+ render()
+ fireEvent.click(screen.getByLabelText('Open menu'))
+ expect(screen.getByLabelText('Close menu')).toBeInTheDocument()
+ })
+
+ it('closes mobile overlay when close button is clicked', () => {
+ render()
+ 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()
+ 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()
+ 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()
+ const animeLink = screen.getByText('Anime').closest('a')
+ expect(animeLink).toHaveClass('text-accent')
+ })
+})
diff --git a/__tests__/components/SearchOverlay.test.tsx b/__tests__/components/SearchOverlay.test.tsx
new file mode 100644
index 0000000..541cfa4
--- /dev/null
+++ b/__tests__/components/SearchOverlay.test.tsx
@@ -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()
+ expect(container.innerHTML).toBe('')
+ })
+
+ it('opens when custom event is dispatched', () => {
+ render()
+ act(() => {
+ openSearch()
+ })
+ expect(screen.getByPlaceholderText('Search articles…')).toBeInTheDocument()
+ })
+
+ it('opens when Ctrl+K / Cmd+K is pressed', () => {
+ render()
+ fireEvent.keyDown(window, { key: 'k', metaKey: true })
+ expect(screen.getByPlaceholderText('Search articles…')).toBeInTheDocument()
+ })
+
+ it('closes when Escape is pressed', () => {
+ render()
+ 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()
+ 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()
+ 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()
+ 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()
+ 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()
+ 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()
+ 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()
+ 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()
+ 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()
+ 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()
+ 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('')
+ })
+})
diff --git a/__tests__/components/TagRow.test.tsx b/__tests__/components/TagRow.test.tsx
new file mode 100644
index 0000000..85d1f79
--- /dev/null
+++ b/__tests__/components/TagRow.test.tsx
@@ -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()
+ expect(screen.getByText('Anime')).toBeInTheDocument()
+ expect(screen.getByText('Music')).toBeInTheDocument()
+ })
+
+ it('returns null when tags array is empty', () => {
+ const { container } = render()
+ expect(container.innerHTML).toBe('')
+ })
+
+ it('returns null when tags is undefined', () => {
+ const { container } = render()
+ expect(container.innerHTML).toBe('')
+ })
+
+ it('renders each tag with unique key', () => {
+ const tags = [makeTag('1', 'VTuber', 'vtubers')]
+ render()
+ expect(screen.getByText('VTuber')).toBeInTheDocument()
+ })
+})
diff --git a/__tests__/lib/directus.test.ts b/__tests__/lib/directus.test.ts
index 596a380..e6123d6 100644
--- a/__tests__/lib/directus.test.ts
+++ b/__tests__/lib/directus.test.ts
@@ -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()
+ })
})