/** * @file Tests for the about route, covering release fetching, caching, and the * fallback to pending releases when the upstream request fails. * @copyright nhcarrigan * @license Naomi's Public License * @author Naomi Carrigan */ /* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ import { Hono } from "hono"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { pendingReleases } from "../../src/data/pendingReleases.js"; const makeApp = async(): Promise => { const { aboutRouter } = await import("../../src/routes/about.js"); const app = new Hono(); app.route("/about", aboutRouter); return app; }; describe("about route", () => { const mockFetch = vi.fn(); beforeEach(() => { vi.resetModules(); vi.stubGlobal("fetch", mockFetch); }); afterEach(() => { vi.unstubAllGlobals(); mockFetch.mockReset(); }); it("returns pending and fetched releases on a successful fetch", async() => { expect.assertions(2); const releases = [ { body: "notes", id: 1, name: "v1.0.0" } ]; mockFetch.mockResolvedValueOnce({ json: () => { return Promise.resolve(releases); }, ok: true }); const app = await makeApp(); const response = await app.fetch(new Request("http://localhost/about")); expect(response.status, "should respond with 200").toBe(200); const body: { releases: Array } = await response.json(); expect(body.releases, "should merge pending and fetched releases"). toStrictEqual([ ...pendingReleases, ...releases ]); }); it("returns only pending releases when fetch is not ok", async() => { expect.assertions(2); mockFetch.mockResolvedValueOnce({ ok: false }); const app = await makeApp(); const response = await app.fetch(new Request("http://localhost/about")); expect(response.status, "should respond with 200").toBe(200); const body: { releases: Array } = await response.json(); expect(body.releases, "should fall back to pending releases"). toStrictEqual(pendingReleases); }); it("returns only pending releases when fetch throws", async() => { expect.assertions(2); mockFetch.mockRejectedValueOnce(new Error("Network error")); const app = await makeApp(); const response = await app.fetch(new Request("http://localhost/about")); expect(response.status, "should respond with 200").toBe(200); const body: { releases: Array } = await response.json(); expect(body.releases, "should fall back to pending releases"). toStrictEqual(pendingReleases); }); it("returns cached releases on second call within TTL", async() => { expect.assertions(2); const releases = [ { body: "notes", id: 1, name: "v1.0.0" } ]; mockFetch.mockResolvedValueOnce({ json: () => { return Promise.resolve(releases); }, ok: true }); const app = await makeApp(); // First call populates cache await app.fetch(new Request("http://localhost/about")); // Second call should use cache, not call fetch again const response = await app.fetch(new Request("http://localhost/about")); expect(response.status, "should respond with 200").toBe(200); expect(mockFetch, "should not re-fetch within the TTL"). toHaveBeenCalledTimes(1); }); it("includes apiVersion in response", async() => { expect.assertions(2); mockFetch.mockResolvedValueOnce({ json: () => { return Promise.resolve([]); }, ok: true }); const app = await makeApp(); const response = await app.fetch(new Request("http://localhost/about")); expect(response.status, "should respond with 200").toBe(200); const body: { apiVersion: string } = await response.json(); expect(typeof body.apiVersion, "should expose apiVersion as a string"). toBe("string"); }); });