diff --git a/__tests__/lib/directus.test.ts b/__tests__/lib/directus.test.ts new file mode 100644 index 0000000..7ee128a --- /dev/null +++ b/__tests__/lib/directus.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockRequest = vi.fn() +const mockWith = vi.fn() + +vi.mock('@directus/sdk', () => ({ + createDirectus: vi.fn(() => ({ with: mockWith })), + rest: vi.fn(() => 'rest-plugin'), + staticToken: vi.fn(() => 'token-plugin'), + readItems: vi.fn((collection: string) => ({ _op: 'readItems', collection })), + readSingleton: vi.fn((collection: string) => ({ _op: 'readSingleton', collection })), + readItem: vi.fn((collection: string, id: string) => ({ _op: 'readItem', collection, id })), +})) + +mockWith.mockReturnValue({ with: mockWith, request: mockRequest }) + +beforeEach(() => { + vi.resetModules() + mockRequest.mockReset() + process.env.DIRECTUS_URL = 'https://cms.achmad.dev' + process.env.DIRECTUS_TOKEN = 'test-token' +}) + +describe('getAllCategories', () => { + it('returns array of categories from Directus', async () => { + mockRequest.mockResolvedValue([ + { id: '1', name: 'Anime', slug: 'anime', description: null }, + { id: '2', name: 'VTubers', slug: 'vtubers', description: null }, + ]) + const { getAllCategories } = await import('@/lib/directus') + const result = await getAllCategories() + expect(result).toHaveLength(2) + expect(result[0].slug).toBe('anime') + }) +}) + +describe('getArticleBySlug', () => { + it('returns null when no article matches', async () => { + mockRequest.mockResolvedValue([]) + const { getArticleBySlug } = await import('@/lib/directus') + const result = await getArticleBySlug('nonexistent') + expect(result).toBeNull() + }) + + it('returns the first matching article', async () => { + mockRequest.mockResolvedValue([ + { + id: '1', title: 'Frieren S2', slug: 'frieren-s2', status: 'published', + content: '

Body

', excerpt: 'Short', featured_image: null, + published_at: '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 { getArticleBySlug } = await import('@/lib/directus') + const result = await getArticleBySlug('frieren-s2') + expect(result?.title).toBe('Frieren S2') + }) +}) + +describe('getAssetUrl', () => { + it('constructs a Directus asset URL from a file UUID', async () => { + const { getAssetUrl } = await import('@/lib/directus') + const url = getAssetUrl('abc-123-uuid') + expect(url).toBe('https://cms.achmad.dev/assets/abc-123-uuid') + }) + + it('appends width and quality query params', async () => { + const { getAssetUrl } = await import('@/lib/directus') + const url = getAssetUrl('abc-123-uuid', { width: 800, quality: 80 }) + expect(url).toContain('width=800') + expect(url).toContain('quality=80') + }) +}) diff --git a/lib/directus.ts b/lib/directus.ts new file mode 100644 index 0000000..0e70e3d --- /dev/null +++ b/lib/directus.ts @@ -0,0 +1,147 @@ +import { + createDirectus, + rest, + staticToken, + readItems, + readSingleton, + readItem, +} from '@directus/sdk' +import type { Article, Category, SiteSettings } from './types' + +const directus = createDirectus(process.env.DIRECTUS_URL!) + .with(staticToken(process.env.DIRECTUS_TOKEN!)) + .with(rest()) + +export async function getAllCategories(): Promise { + return directus.request( + readItems('categories', { + fields: ['id', 'name', 'slug', 'description'], + sort: ['name'], + }) + ) as Promise +} + +export async function getCategoryBySlug(slug: string): Promise { + const results = await directus.request( + readItems('categories', { + fields: ['id', 'name', 'slug', 'description'], + filter: { slug: { _eq: slug } }, + limit: 1, + }) + ) as Category[] + return results[0] ?? null +} + +export async function getArticles(options: { + limit?: number + offset?: number + categorySlug?: string +} = {}): Promise { + const { limit = 12, offset = 0, categorySlug } = options + const filter: Record = { status: { _eq: 'published' } } + if (categorySlug) { + filter['category'] = { slug: { _eq: categorySlug } } + } + return directus.request( + readItems('articles', { + fields: [ + 'id', 'title', 'slug', 'status', 'excerpt', 'featured_image', + 'published_at', 'is_featured', + 'category.id', 'category.name', 'category.slug', + ], + filter, + sort: ['-published_at'], + limit, + offset, + }) + ) as Promise +} + +export async function getArticleBySlug(slug: string): Promise
{ + const results = await directus.request( + readItems('articles', { + fields: [ + 'id', 'title', 'slug', 'status', 'content', 'excerpt', + 'featured_image', 'published_at', 'is_featured', + 'seo_title', 'seo_description', + 'category.id', 'category.name', 'category.slug', + 'tags.tags_id.id', 'tags.tags_id.name', 'tags.tags_id.slug', + ], + filter: { slug: { _eq: slug }, status: { _eq: 'published' } }, + limit: 1, + }) + ) as Article[] + return results[0] ?? null +} + +export async function getArticlePathById(id: string): Promise<{ + slug: string + category: { slug: string } +} | null> { + try { + return await directus.request( + readItem('articles', id, { + fields: ['slug', 'category.slug'], + }) + ) as { slug: string; category: { slug: string } } + } catch { + return null + } +} + +export async function getRelatedArticles( + categorySlug: string, + excludeSlug: string, +): Promise { + return directus.request( + readItems('articles', { + fields: [ + 'id', 'title', 'slug', 'excerpt', 'featured_image', + 'published_at', 'category.id', 'category.name', 'category.slug', + ], + filter: { + status: { _eq: 'published' }, + category: { slug: { _eq: categorySlug } }, + slug: { _neq: excludeSlug }, + }, + sort: ['-published_at'], + limit: 4, + }) + ) as Promise +} + +export async function getSiteSettings(): Promise { + return directus.request( + readSingleton('site_settings', { + fields: [ + 'id', 'site_name', + 'hero_article.id', 'hero_article.title', 'hero_article.slug', + 'hero_article.excerpt', 'hero_article.featured_image', + 'hero_article.category.slug', 'hero_article.category.name', + 'nav_categories.id', 'nav_categories.name', 'nav_categories.slug', + ], + }) + ) as Promise +} + +export async function searchArticles(query: string): Promise { + return directus.request( + readItems('articles', { + fields: ['id', 'title', 'slug', 'category.slug', 'category.name'], + search: query, + filter: { status: { _eq: 'published' } }, + limit: 8, + }) + ) as Promise +} + +export function getAssetUrl( + fileId: string, + params?: { width?: number; height?: number; quality?: number }, +): string { + const url = new URL(`/assets/${fileId}`, process.env.DIRECTUS_URL!) + if (params?.width) url.searchParams.set('width', String(params.width)) + if (params?.height) url.searchParams.set('height', String(params.height)) + if (params?.quality) url.searchParams.set('quality', String(params.quality)) + return url.toString() +}