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 pic

' + 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(