feat: add extensive test suite

This commit is contained in:
2026-08-30 11:33:53 +07:00 Unverified
parent 061cce00dc
commit 8359c7272e
131 changed files with 8717 additions and 2200 deletions
+141
View File
@@ -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",
});
});
});
+70
View File
@@ -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
View File
@@ -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;