feat: revalidate webhook endpoint with tests

This commit is contained in:
achmad
2026-05-28 22:33:25 +07:00
parent e748725522
commit e45faa201f
2 changed files with 138 additions and 0 deletions
+91
View File
@@ -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('/')
})
})
+47
View File
@@ -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}`],
})
}