feat: add extensive test suite
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { createLiveHandler } from "./handler";
|
||||
|
||||
const liveRequest = (query = "") =>
|
||||
new Request(`https://example.com/api/live${query}`);
|
||||
const youtubeResult = (
|
||||
thumbnails: Record<string, unknown> = {
|
||||
high: { url: "https://img/high.jpg", width: 480, height: 360 },
|
||||
},
|
||||
) => ({
|
||||
items: [
|
||||
{
|
||||
id: { videoId: "video-1" },
|
||||
snippet: { title: "Live title", thumbnails },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("live handler", () => {
|
||||
test("returns none without calling YouTube when configuration is missing", async () => {
|
||||
const fetch = mock(() => Promise.resolve(Response.json({})));
|
||||
const handler = createLiveHandler({
|
||||
fetch,
|
||||
publish: mock(() => {}),
|
||||
getConfig: () => ({ apiKey: "", channelId: "channel" }),
|
||||
});
|
||||
expect(await (await handler(liveRequest())).json()).toBe("none");
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("builds an encoded YouTube request and publishes a live result", async () => {
|
||||
const fetch = mock((_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
Promise.resolve(Response.json(youtubeResult())),
|
||||
);
|
||||
const publish = mock(() => {});
|
||||
const handler = createLiveHandler({
|
||||
fetch,
|
||||
publish,
|
||||
getConfig: () => ({ apiKey: "key +", channelId: "channel/id" }),
|
||||
});
|
||||
const response = await handler(liveRequest());
|
||||
const result = await response.json();
|
||||
|
||||
expect(fetch.mock.calls[0]?.[0]).toContain("channelId=channel%2Fid");
|
||||
expect(fetch.mock.calls[0]?.[0]).toContain("key=key%20%2B");
|
||||
expect(fetch.mock.calls[0]?.[1]).toEqual({ cache: "no-store" });
|
||||
expect(result).toEqual({
|
||||
url: "https://www.youtube.com/watch?v=video-1",
|
||||
thumbnails: {
|
||||
url: "https://img/high.jpg",
|
||||
width: 480,
|
||||
height: 360,
|
||||
},
|
||||
title: "Live title",
|
||||
});
|
||||
expect(publish).toHaveBeenCalledWith(result);
|
||||
});
|
||||
|
||||
test("returns none and logs an upstream error", async () => {
|
||||
const logError = mock(() => {});
|
||||
const handler = createLiveHandler({
|
||||
fetch: mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(null, { status: 403, statusText: "Nope" }),
|
||||
),
|
||||
),
|
||||
publish: mock(() => {}),
|
||||
getConfig: () => ({ apiKey: "key", channelId: "channel" }),
|
||||
logError,
|
||||
});
|
||||
expect(await (await handler(liveRequest())).json()).toBe("none");
|
||||
expect(logError).toHaveBeenCalledWith("YouTube API error: Nope");
|
||||
});
|
||||
|
||||
test("returns none for an empty search", async () => {
|
||||
const publish = mock(() => {});
|
||||
const handler = createLiveHandler({
|
||||
fetch: mock(() => Promise.resolve(Response.json({ items: [] }))),
|
||||
publish,
|
||||
getConfig: () => ({ apiKey: "key", channelId: "channel" }),
|
||||
});
|
||||
expect(await (await handler(liveRequest())).json()).toBe("none");
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns none when YouTube supplies no thumbnail", async () => {
|
||||
const handler = createLiveHandler({
|
||||
fetch: mock(() => Promise.resolve(Response.json(youtubeResult({})))),
|
||||
publish: mock(() => {}),
|
||||
getConfig: () => ({ apiKey: "key", channelId: "channel" }),
|
||||
});
|
||||
expect(await (await handler(liveRequest())).json()).toBe("none");
|
||||
});
|
||||
|
||||
test("last returns a clone of the last successful local response", async () => {
|
||||
let configured = true;
|
||||
const fetch = mock(() => Promise.resolve(Response.json(youtubeResult())));
|
||||
const handler = createLiveHandler({
|
||||
fetch,
|
||||
publish: mock(() => {}),
|
||||
getConfig: () =>
|
||||
configured
|
||||
? { apiKey: "key", channelId: "channel" }
|
||||
: { apiKey: undefined, channelId: undefined },
|
||||
});
|
||||
const first = await handler(liveRequest());
|
||||
configured = false;
|
||||
const last = await handler(liveRequest("?last=true"));
|
||||
|
||||
expect(last).not.toBe(first);
|
||||
expect(await last.json()).toEqual(await first.json());
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("last performs a normal lookup before any successful response", async () => {
|
||||
const fetch = mock(() => Promise.resolve(Response.json({ items: [] })));
|
||||
const handler = createLiveHandler({
|
||||
fetch,
|
||||
publish: mock(() => {}),
|
||||
getConfig: () => ({ apiKey: "key", channelId: "channel" }),
|
||||
});
|
||||
expect(await (await handler(liveRequest("?last"))).json()).toBe("none");
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("an unsuccessful refresh does not replace the last response", async () => {
|
||||
const fetch = mock()
|
||||
.mockResolvedValueOnce(Response.json(youtubeResult()))
|
||||
.mockResolvedValueOnce(Response.json({ items: [] }));
|
||||
const handler = createLiveHandler({
|
||||
fetch,
|
||||
publish: mock(() => {}),
|
||||
getConfig: () => ({ apiKey: "key", channelId: "channel" }),
|
||||
});
|
||||
await handler(liveRequest());
|
||||
expect(await (await handler(liveRequest())).json()).toBe("none");
|
||||
expect(await (await handler(liveRequest("?last"))).json()).toMatchObject({
|
||||
title: "Live title",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
export type YoutubeLiveInfo =
|
||||
| {
|
||||
url: string;
|
||||
thumbnails: { url: string; width: number; height: number };
|
||||
title: string;
|
||||
}
|
||||
| "none";
|
||||
|
||||
type SearchResponse = {
|
||||
items: Array<{
|
||||
id: { videoId: string };
|
||||
snippet: {
|
||||
title: string;
|
||||
thumbnails: Record<
|
||||
string,
|
||||
{ url: string; width: number; height: number }
|
||||
>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
type LiveDependencies = {
|
||||
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
publish: (live: Exclude<YoutubeLiveInfo, "none">) => unknown;
|
||||
getConfig: () => { apiKey?: string; channelId?: string };
|
||||
logError?: (message: string) => void;
|
||||
};
|
||||
|
||||
export function createLiveHandler({
|
||||
fetch,
|
||||
publish,
|
||||
getConfig,
|
||||
logError = console.error,
|
||||
}: LiveDependencies) {
|
||||
let lastResponse: Response | undefined;
|
||||
|
||||
return async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.has("last") && lastResponse)
|
||||
return lastResponse.clone();
|
||||
|
||||
const { apiKey, channelId } = getConfig();
|
||||
if (!apiKey || !channelId) return Response.json("none");
|
||||
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${encodeURIComponent(channelId)}&eventType=live&type=video&maxResults=1&key=${encodeURIComponent(apiKey)}`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
logError(`YouTube API error: ${response.statusText}`);
|
||||
return Response.json("none");
|
||||
}
|
||||
|
||||
const data: SearchResponse = await response.json();
|
||||
if (data.items.length === 0) return Response.json("none");
|
||||
|
||||
const live = data.items[0];
|
||||
const thumbnail = Object.values(live.snippet.thumbnails)[0];
|
||||
if (!thumbnail) return Response.json("none");
|
||||
|
||||
const result: Exclude<YoutubeLiveInfo, "none"> = {
|
||||
url: `https://www.youtube.com/watch?v=${live.id.videoId}`,
|
||||
thumbnails: thumbnail,
|
||||
title: live.snippet.title,
|
||||
};
|
||||
publish(result);
|
||||
lastResponse = Response.json(result);
|
||||
return lastResponse.clone();
|
||||
};
|
||||
}
|
||||
+13
-91
@@ -1,99 +1,21 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { sse } from "@/lib/db/sse-endpoints";
|
||||
import { youtubeCache } from "@/lib/adaptive-cache";
|
||||
import { sse } from "@/lib/db/sse-endpoints";
|
||||
import { createLiveHandler } from "./handler";
|
||||
|
||||
export type YoutubeLiveInfo =
|
||||
| {
|
||||
url: string;
|
||||
thumbnails: {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
title: string;
|
||||
}
|
||||
| "none";
|
||||
export type { YoutubeLiveInfo } from "./handler";
|
||||
|
||||
type APISearchResource = {
|
||||
kind: "youtube#searchResult";
|
||||
etag: string;
|
||||
id: {
|
||||
kind: string;
|
||||
videoId: string;
|
||||
channelId: string;
|
||||
playlistId: string;
|
||||
};
|
||||
snippet: {
|
||||
publishedAt: string;
|
||||
channelId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
thumbnails: {
|
||||
[key: string]: {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
channelTitle: string;
|
||||
liveBroadcastContent: string;
|
||||
};
|
||||
};
|
||||
const handler = createLiveHandler({
|
||||
fetch: youtubeCache.fetch.bind(youtubeCache) as typeof fetch,
|
||||
publish: (live) => sse.active.pub("live", live),
|
||||
getConfig: () => ({
|
||||
apiKey: process.env.YOUTUBE_API_KEY,
|
||||
channelId: process.env.YOUTUBE_CHANNEL_ID,
|
||||
}),
|
||||
});
|
||||
|
||||
type APISearchResponse = {
|
||||
kind: "youtube#searchListResponse";
|
||||
etag: string;
|
||||
nextPageToken: string;
|
||||
prevPageToken: string;
|
||||
regionCode: string;
|
||||
pageInfo: {
|
||||
totalResults: number;
|
||||
resultsPerPage: number;
|
||||
};
|
||||
items: APISearchResource[];
|
||||
};
|
||||
|
||||
var lastResponse: Response;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (req.nextUrl.searchParams.has("last") && lastResponse)
|
||||
return lastResponse.clone();
|
||||
|
||||
const channelId = process.env.YOUTUBE_CHANNEL_ID;
|
||||
const apiKey = process.env.YOUTUBE_API_KEY;
|
||||
|
||||
if (!apiKey || !channelId) return NextResponse.json<YoutubeLiveInfo>("none");
|
||||
|
||||
const response = await youtubeCache.fetch(
|
||||
`https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${channelId}&eventType=live&type=video&maxResults=1&key=${apiKey}`,
|
||||
{
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`YouTube API error: ${response.statusText}`);
|
||||
return NextResponse.json("none");
|
||||
}
|
||||
|
||||
const data: APISearchResponse = await response.json();
|
||||
|
||||
if (data.items.length === 0)
|
||||
return NextResponse.json<YoutubeLiveInfo>("none");
|
||||
|
||||
const live = data.items[0];
|
||||
|
||||
const res: YoutubeLiveInfo = {
|
||||
url: `https://www.youtube.com/watch?v=${live.id.videoId}`,
|
||||
thumbnails:
|
||||
live.snippet.thumbnails[Object.keys(live.snippet.thumbnails)[0]],
|
||||
title: live.snippet.title,
|
||||
};
|
||||
|
||||
sse.active.pub("live", res);
|
||||
|
||||
return (lastResponse = NextResponse.json<YoutubeLiveInfo>(res));
|
||||
export function GET(req: NextRequest) {
|
||||
return handler(req);
|
||||
}
|
||||
|
||||
export const POST = GET;
|
||||
|
||||
Reference in New Issue
Block a user