import { GET } from "@/app/api/youtube/route"
import { describe, it, expect, vi, beforeEach } from "vitest"
// Mock fetch globally
const mockFetch = vi.fn()
vi.stubGlobal("fetch", mockFetch)
describe("/api/youtube", () => {
beforeEach(() => {
vi.clearAllMocks()
})
const mockXmlResponse = `
test-video-id-1
Test Video Title 1
This is a test video description that should be truncated if it exceeds 150 characters to ensure proper display in the UI components.
2024-01-01T00:00:00.000Z
test-video-id-2
Test Video Title 2 & Special Characters
Short description
2024-01-02T00:00:00.000Z
`
const expectedVideos = [
{
id: "test-video-id-1",
title: "Test Video Title 1",
description:
"This is a test video description that should be truncated if it exceeds 150 characters to ensure proper display in the UI components.",
thumbnailUrl: "https://i.ytimg.com/vi/test-video-id-1/hqdefault.jpg",
publishedAt: "2024-01-01T00:00:00.000Z",
channelTitle: "Privacy Stewards of Ethereum",
url: "https://www.youtube.com/watch?v=test-video-id-1",
},
{
id: "test-video-id-2",
title: "Test Video Title 2 & Special Characters",
description: "Short description",
thumbnailUrl: "https://i.ytimg.com/vi/test-video-id-2/hqdefault.jpg",
publishedAt: "2024-01-02T00:00:00.000Z",
channelTitle: "Privacy Stewards of Ethereum",
url: "https://www.youtube.com/watch?v=test-video-id-2",
},
]
describe("GET /api/youtube", () => {
it("successfully fetches and parses YouTube videos from RSS feed", async () => {
mockFetch.mockResolvedValue({
ok: true,
text: () => Promise.resolve(mockXmlResponse),
})
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({
videos: expectedVideos,
})
expect(mockFetch).toHaveBeenCalledWith(
"https://www.youtube.com/feeds/videos.xml?channel_id=UCh7qkafm95-kRiLMVPlbIcQ",
{ next: { revalidate: 3600 } }
)
})
it("handles YouTube RSS feed HTTP errors", async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
})
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {})
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({
videos: [],
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error fetching videos from RSS feed:",
expect.any(Error)
)
consoleErrorSpy.mockRestore()
})
it("handles network errors gracefully", async () => {
mockFetch.mockRejectedValue(new Error("Network error"))
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {})
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({
videos: [],
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error fetching videos from RSS feed:",
expect.any(Error)
)
consoleErrorSpy.mockRestore()
})
it("limits videos to maximum count", async () => {
// Create XML with more than 6 videos (MAX_VIDEOS = 6)
const manyVideosXml = `
${Array.from(
{ length: 10 },
(_, i) => `
video-${i}
Video ${i}
Description ${i}
2024-01-0${(i % 9) + 1}T00:00:00.000Z
`
).join("")}
`
mockFetch.mockResolvedValue({
ok: true,
text: () => Promise.resolve(manyVideosXml),
})
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data.videos).toHaveLength(6) // MAX_VIDEOS = 6
})
it("handles malformed XML gracefully", async () => {
mockFetch.mockResolvedValue({
ok: true,
text: () => Promise.resolve("Invalid XML content"),
})
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({
videos: [],
})
})
it("properly decodes HTML entities in video titles and descriptions", async () => {
const xmlWithEntities = `
test-video
Title with & <special> "characters" 'test'
Description with & <entities>
2024-01-01T00:00:00.000Z
`
mockFetch.mockResolvedValue({
ok: true,
text: () => Promise.resolve(xmlWithEntities),
})
const response = await GET()
const data = await response.json()
expect(data.videos[0].title).toBe(
"Title with & \"characters\" 'test'"
)
expect(data.videos[0].description).toBe("Description with & ")
})
it("handles entries missing required fields", async () => {
const incompleteXml = `
Video without ID
Description
2024-01-01T00:00:00.000Z
valid-video
Valid Video
Valid Description
2024-01-01T00:00:00.000Z
`
mockFetch.mockResolvedValue({
ok: true,
text: () => Promise.resolve(incompleteXml),
})
const response = await GET()
const data = await response.json()
expect(data.videos).toHaveLength(1)
expect(data.videos[0].id).toBe("valid-video")
})
it("generates correct thumbnail URLs", async () => {
mockFetch.mockResolvedValue({
ok: true,
text: () => Promise.resolve(mockXmlResponse),
})
const response = await GET()
const data = await response.json()
expect(data.videos[0].thumbnailUrl).toBe(
"https://i.ytimg.com/vi/test-video-id-1/hqdefault.jpg"
)
})
it("generates correct video URLs", async () => {
mockFetch.mockResolvedValue({
ok: true,
text: () => Promise.resolve(mockXmlResponse),
})
const response = await GET()
const data = await response.json()
expect(data.videos[0].url).toBe(
"https://www.youtube.com/watch?v=test-video-id-1"
)
})
it("handles missing published date gracefully", async () => {
const xmlWithoutDate = `
test-video
Video without date
Description
`
mockFetch.mockResolvedValue({
ok: true,
text: () => Promise.resolve(xmlWithoutDate),
})
const response = await GET()
const data = await response.json()
expect(data.videos[0].publishedAt).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/
)
})
it("uses correct channel ID in RSS URL", async () => {
mockFetch.mockResolvedValue({
ok: true,
text: () => Promise.resolve(""),
})
await GET()
expect(mockFetch).toHaveBeenCalledWith(
"https://www.youtube.com/feeds/videos.xml?channel_id=UCh7qkafm95-kRiLMVPlbIcQ",
expect.any(Object)
)
})
})
})