From e02290d9d394de90f150eda9c891c885c4c3a969 Mon Sep 17 00:00:00 2001 From: codens-agent Date: Sun, 28 Jun 2026 09:40:21 +0000 Subject: [PATCH] chore: Add getAppMeta/getAppLicenses to lib/api.ts, create app/about/page.tsx and app/licenses/page.tsx (with tests) --- __tests__/app/about.test.tsx | 51 +++++++++++++++++++++++ __tests__/app/licenses.test.tsx | 59 ++++++++++++++++++++++++++ app/about/page.tsx | 72 ++++++++++++++++++++++++++++++++ app/licenses/page.tsx | 73 +++++++++++++++++++++++++++++++++ lib/api-types.ts | 21 ++++++++++ lib/api.ts | 16 ++++++++ 6 files changed, 292 insertions(+) create mode 100644 __tests__/app/about.test.tsx create mode 100644 __tests__/app/licenses.test.tsx create mode 100644 app/about/page.tsx create mode 100644 app/licenses/page.tsx 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/app/about/page.tsx b/app/about/page.tsx new file mode 100644 index 00000000..c2a63450 --- /dev/null +++ b/app/about/page.tsx @@ -0,0 +1,72 @@ +"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"; + +export default function AboutPage() { + const [meta, setMeta] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + getAppMeta(process.env.NEXT_PUBLIC_API_BASE_URL ?? "") + .then((data) => { + if (!cancelled) { + setMeta(data); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, []); + + const version = loading ? "dev" : (meta?.version ?? "dev"); + const licenseName = meta?.license ?? BRANDING.licenseName; + + return ( +
+

{BRANDING.appName}

+ + {loading ?

Loading...

: null} + +
+
+
Version
+
{version}
+
+
+
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 061a518b..f16e7245 100644 --- a/lib/api-types.ts +++ b/lib/api-types.ts @@ -184,3 +184,24 @@ export interface ScanJob { finished_at: string | null; 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[]; +} diff --git a/lib/api.ts b/lib/api.ts index 124399ec..7ee53efc 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -1,5 +1,7 @@ import type { AccessTokenMeta, + AppLicenses, + AppMeta, CreateTokenResult, OAuthApp, OAuthAppCreateInput, @@ -255,3 +257,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: [] }; + } +}