diff --git a/__tests__/app/about.test.tsx b/__tests__/app/about.test.tsx new file mode 100644 index 00000000..4b96239c --- /dev/null +++ b/__tests__/app/about.test.tsx @@ -0,0 +1,51 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockGetAppMeta = vi.fn(); + +vi.mock("@/lib/api", () => ({ + getAppMeta: (...args: unknown[]) => mockGetAppMeta(...args), +})); + +import AboutPage from "@/app/about/page"; +import { BRANDING } from "@/lib/branding"; + +const fixedMeta = { + app_name: "OpenGit", + version: "1.2.3", + git_commit: "abc1234", + build_date: "2025-01-01T00:00:00Z", + license: "Apache-2.0", + source_url: "https://example.org/repo", +}; + +describe("AboutPage", () => { + beforeEach(() => { + mockGetAppMeta.mockReset(); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:8080"); + }); + + it("shows loading state before promise resolves", () => { + mockGetAppMeta.mockReturnValue(new Promise(() => {})); + + render(); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + expect(screen.getByText("dev")).toBeInTheDocument(); + }); + + it("renders app name, version, and link to licenses", async () => { + mockGetAppMeta.mockResolvedValue(fixedMeta); + + render(); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: BRANDING.appName })).toBeInTheDocument(); + expect(screen.getByText("1.2.3")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Apache-2.0" })).toHaveAttribute( + "href", + "/licenses", + ); + }); + }); +}); diff --git a/__tests__/app/licenses.test.tsx b/__tests__/app/licenses.test.tsx new file mode 100644 index 00000000..c72709ae --- /dev/null +++ b/__tests__/app/licenses.test.tsx @@ -0,0 +1,59 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockGetAppLicenses = vi.fn(); + +vi.mock("@/lib/api", () => ({ + getAppLicenses: (...args: unknown[]) => mockGetAppLicenses(...args), +})); + +import LicensesPage from "@/app/licenses/page"; + +describe("LicensesPage", () => { + beforeEach(() => { + mockGetAppLicenses.mockReset(); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:8080"); + }); + + it("renders third-party license entries", async () => { + mockGetAppLicenses.mockResolvedValue({ + app_license: "Apache-2.0", + third_party: [ + { + name: "react", + version: "18.0.0", + license: "MIT", + url: "https://react.dev", + }, + { + name: "next", + version: "14.0.0", + license: "MIT", + url: "https://nextjs.org", + }, + ], + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("react")).toBeInTheDocument(); + expect(screen.getByText("next")).toBeInTheDocument(); + }); + }); + + it("shows fallback text when third_party is empty", async () => { + mockGetAppLicenses.mockResolvedValue({ + app_license: "Apache-2.0", + third_party: [], + }); + + render(); + + await waitFor(() => { + expect( + screen.getByText("ライセンス情報を取得できませんでした"), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/__tests__/pages/about.test.tsx b/__tests__/pages/about.test.tsx index c220890b..070b02e4 100644 --- a/__tests__/pages/about.test.tsx +++ b/__tests__/pages/about.test.tsx @@ -1,43 +1,34 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +const mockGetAppMeta = vi.fn(); + +vi.mock("@/lib/api", () => ({ + getAppMeta: (...args: unknown[]) => mockGetAppMeta(...args), +})); + import AboutPage from "@/app/about/page"; describe("AboutPage", () => { beforeEach(() => { + mockGetAppMeta.mockReset(); vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:8080"); - - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - if (url.endsWith("/api/v1/version")) { - return { - ok: true, - status: 200, - json: async () => ({ - data: { - version: "1.0.0", - commit: "abc1234", - buildDate: "2025-01-01T00:00:00Z", - }, - }), - }; - } - return { - ok: false, - status: 404, - json: async () => ({}), - }; - }), - ); + mockGetAppMeta.mockResolvedValue({ + app_name: "OpenGit", + version: "1.0.0", + git_commit: "abc1234", + build_date: "2025-01-01T00:00:00Z", + license: "Apache-2.0", + source_url: "https://example.org/repo", + }); }); it("renders project name and includes a link", async () => { - const page = await AboutPage(); - render(page); + render(); - expect(screen.getByText("OpenGit")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("OpenGit")).toBeInTheDocument(); + }); expect(document.querySelector("a")).not.toBeNull(); }); }); diff --git a/app/about/page.tsx b/app/about/page.tsx index 83e83cb7..35c9529f 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -1,71 +1,82 @@ +"use client"; + import Link from "next/link"; +import { useEffect, useState } from "react"; +import { getAppMeta } from "@/lib/api"; +import type { AppMeta } from "@/lib/api-types"; import { BRANDING } from "@/lib/branding"; -const API_BASE = - process.env.NEXT_PUBLIC_API_BASE_URL ?? - process.env.NEXT_PUBLIC_API_URL ?? - ""; - -interface VersionData { - version: string; - commit: string; - buildDate: string; -} +export default function AboutPage() { + const [meta, setMeta] = useState(null); + const [loading, setLoading] = useState(true); -async function fetchVersion(): Promise { - try { - const response = await fetch(`${API_BASE}/api/v1/version`, { - headers: { Accept: "application/json" }, - cache: "no-store", - }); + useEffect(() => { + let cancelled = false; - if (!response.ok) { - return null; - } + getAppMeta(process.env.NEXT_PUBLIC_API_BASE_URL ?? "") + .then((data) => { + if (!cancelled) { + setMeta(data); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); - const body = (await response.json()) as { data?: VersionData }; - return body.data ?? null; - } catch { - return null; - } -} + return () => { + cancelled = true; + }; + }, []); -export default async function AboutPage() { - const version = await fetchVersion(); + const version = loading ? "dev" : (meta?.version ?? "dev"); + const commit = meta?.git_commit ?? "unknown"; + const buildDate = meta?.build_date ?? "unknown"; + const licenseName = meta?.license ?? BRANDING.licenseName; + const sourceUrl = meta?.source_url ?? BRANDING.sourceUrl; return (
-

{BRANDING.appName}

+

{BRANDING.appName}

+ + {loading ?

Loading...

: null}
-
Version
-
{version?.version ?? "unknown"}
+
Version
+
{version}
-
Commit
-
{version?.commit ?? "unknown"}
+
Commit
+
{commit}
-
Build Date
-
{version?.buildDate ?? "unknown"}
+
+ Build Date +
+
{buildDate}
-
License
-
{BRANDING.licenseName}
+
License
+
+ + {licenseName} + +
diff --git a/app/licenses/page.tsx b/app/licenses/page.tsx new file mode 100644 index 00000000..0dfd052a --- /dev/null +++ b/app/licenses/page.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { getAppLicenses } from "@/lib/api"; +import type { AppLicenses } from "@/lib/api-types"; + +export default function LicensesPage() { + const [licenses, setLicenses] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + getAppLicenses(process.env.NEXT_PUBLIC_API_BASE_URL ?? "") + .then(setLicenses) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( +
+

Loading...

+
+ ); + } + + return ( +
+

Licenses

+ +
+

Project License

+

{licenses?.app_license ?? ""}

+
+ + {licenses?.third_party.length === 0 ? ( +

ライセンス情報を取得できませんでした

+ ) : ( +
+

Third-Party Licenses

+ + + + + + + + + + + {licenses?.third_party.map((entry) => ( + + + + + + + ))} + +
NameVersionLicenseURL
{entry.name}{entry.version}{entry.license} + + {entry.url} + +
+
+ )} +
+ ); +} diff --git a/lib/api-types.ts b/lib/api-types.ts index 4cddd759..47d9241a 100644 --- a/lib/api-types.ts +++ b/lib/api-types.ts @@ -185,6 +185,27 @@ export interface ScanJob { error: string | null; } +export interface AppMeta { + app_name: string; + version: string; + git_commit: string; + build_date: string; + license: string; + source_url: string; +} + +export interface LicenseEntry { + name: string; + version: string; + license: string; + url: string; +} + +export interface AppLicenses { + app_license: string; + third_party: LicenseEntry[]; +} + export type ActionCompatibilityStatus = | "supported" | "partial" diff --git a/lib/api.ts b/lib/api.ts index 8ddea256..ec9a3411 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -1,5 +1,7 @@ import type { AccessTokenMeta, + AppLicenses, + AppMeta, CreateTokenResult, OAuthApp, OAuthAppCreateInput, @@ -652,3 +654,17 @@ export function getOrgs( export function getCurrentUser(token: string): Promise { return fetchWithToken(token, "/api/v3/user"); } + +export async function getAppMeta(baseURL: string): Promise { + const response = await fetch(`${baseURL}/api/meta`); + return response.json() as Promise; +} + +export async function getAppLicenses(baseURL: string): Promise { + try { + const response = await fetch(`${baseURL}/api/licenses`); + return (await response.json()) as AppLicenses; + } catch { + return { app_license: "", third_party: [] }; + } +}