From e45faa201f5e43f5806bb4b23df229b50347201e Mon Sep 17 00:00:00 2001 From: achmad Date: Thu, 28 May 2026 22:33:25 +0700 Subject: [PATCH] feat: revalidate webhook endpoint with tests --- __tests__/api/revalidate.test.ts | 91 ++++++++++++++++++++++++++++++++ app/api/revalidate/route.ts | 47 +++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 __tests__/api/revalidate.test.ts create mode 100644 app/api/revalidate/route.ts diff --git a/__tests__/api/revalidate.test.ts b/__tests__/api/revalidate.test.ts new file mode 100644 index 0000000..bb21d24 --- /dev/null +++ b/__tests__/api/revalidate.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextRequest } from 'next/server' + +const mockRevalidatePath = vi.fn() +const mockRequest = vi.fn() +const mockWith = vi.fn() + +vi.mock('next/cache', () => ({ revalidatePath: mockRevalidatePath })) + +vi.mock('@directus/sdk', () => ({ + createDirectus: vi.fn(() => ({ with: mockWith })), + rest: vi.fn(), + staticToken: vi.fn(), + readItem: vi.fn(), +})) + +mockWith.mockReturnValue({ with: mockWith, request: mockRequest }) + +beforeEach(() => { + vi.resetModules() + mockRevalidatePath.mockReset() + mockRequest.mockReset() + mockWith.mockReturnValue({ with: mockWith, request: mockRequest }) + process.env.REVALIDATE_SECRET = 'test-secret' + process.env.DIRECTUS_URL = 'https://cms.achmad.dev' + process.env.DIRECTUS_TOKEN = 'test-token' +}) + +describe('POST /api/revalidate', () => { + it('returns 401 when secret is wrong', async () => { + const { POST } = await import('@/app/api/revalidate/route') + const req = new NextRequest('http://localhost/api/revalidate', { + method: 'POST', + body: JSON.stringify({ secret: 'wrong-secret', article_id: '123' }), + }) + const res = await POST(req) + expect(res.status).toBe(401) + expect(mockRevalidatePath).not.toHaveBeenCalled() + }) + + it('returns 400 when article_id is missing and type is not homepage', async () => { + const { POST } = await import('@/app/api/revalidate/route') + const req = new NextRequest('http://localhost/api/revalidate', { + method: 'POST', + body: JSON.stringify({ secret: 'test-secret' }), + }) + const res = await POST(req) + expect(res.status).toBe(400) + }) + + it('revalidates /, /category, and /category/slug on valid article request', async () => { + mockRequest.mockResolvedValue({ + slug: 'frieren-season-2', + category: { slug: 'anime' }, + }) + const { POST } = await import('@/app/api/revalidate/route') + const req = new NextRequest('http://localhost/api/revalidate', { + method: 'POST', + body: JSON.stringify({ secret: 'test-secret', article_id: 'uuid-123' }), + }) + const res = await POST(req) + expect(res.status).toBe(200) + expect(mockRevalidatePath).toHaveBeenCalledWith('/') + expect(mockRevalidatePath).toHaveBeenCalledWith('/anime') + expect(mockRevalidatePath).toHaveBeenCalledWith('/anime/frieren-season-2') + }) + + it('returns 404 when article is not found in Directus', async () => { + mockRequest.mockResolvedValue(null) + const { POST } = await import('@/app/api/revalidate/route') + const req = new NextRequest('http://localhost/api/revalidate', { + method: 'POST', + body: JSON.stringify({ secret: 'test-secret', article_id: 'nonexistent' }), + }) + const res = await POST(req) + expect(res.status).toBe(404) + expect(mockRevalidatePath).not.toHaveBeenCalled() + }) + + it('revalidates only / when type is homepage', async () => { + const { POST } = await import('@/app/api/revalidate/route') + const req = new NextRequest('http://localhost/api/revalidate', { + method: 'POST', + body: JSON.stringify({ secret: 'test-secret', type: 'homepage' }), + }) + const res = await POST(req) + expect(res.status).toBe(200) + expect(mockRevalidatePath).toHaveBeenCalledTimes(1) + expect(mockRevalidatePath).toHaveBeenCalledWith('/') + }) +}) diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts new file mode 100644 index 0000000..7899064 --- /dev/null +++ b/app/api/revalidate/route.ts @@ -0,0 +1,47 @@ +import { revalidatePath } from 'next/cache' +import { NextRequest, NextResponse } from 'next/server' +import { createDirectus, rest, staticToken, readItem } from '@directus/sdk' + +export async function POST(req: NextRequest) { + const body = await req.json() + + if (body.secret !== process.env.REVALIDATE_SECRET) { + return NextResponse.json({ error: 'Invalid secret' }, { status: 401 }) + } + + if (body.type === 'homepage') { + revalidatePath('/') + return NextResponse.json({ revalidated: true, path: '/' }) + } + + const { article_id } = body + if (!article_id) { + return NextResponse.json({ error: 'Missing article_id' }, { status: 400 }) + } + + const directus = createDirectus(process.env.DIRECTUS_URL!) + .with(staticToken(process.env.DIRECTUS_TOKEN!)) + .with(rest()) + + const article = (await directus.request( + readItem('articles', article_id, { + fields: ['slug', 'category.slug'], + }) + )) as { slug: string; category: { slug: string } } | null + + if (!article) { + return NextResponse.json({ error: 'Article not found' }, { status: 404 }) + } + + const categorySlug = article.category.slug + const { slug } = article + + revalidatePath('/') + revalidatePath(`/${categorySlug}`) + revalidatePath(`/${categorySlug}/${slug}`) + + return NextResponse.json({ + revalidated: true, + paths: ['/', `/${categorySlug}`, `/${categorySlug}/${slug}`], + }) +}