diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 4913b20..d5f766a 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -28,8 +28,13 @@ const DomainSettings = dynamic( { ssr: false, loading: () =>

Loading…

} ) -const VALID_TABS = ["general", "radar", "janitor", "domains"] as const -const ADMIN_ONLY_TABS = ["radar", "janitor", "domains"] as const +const SchemaAuditSettings = dynamic( + () => import("@/app/settings/schema-audit").then((m) => m.SchemaAuditSettings), + { ssr: false, loading: () =>

Loading…

} +) + +const VALID_TABS = ["general", "radar", "janitor", "domains", "audit"] as const +const ADMIN_ONLY_TABS = ["radar", "janitor", "domains", "audit"] as const type TabId = (typeof VALID_TABS)[number] function resolveTab(raw: string | null, isAdmin: boolean): TabId { @@ -118,6 +123,7 @@ function SettingsContent() { {isAdmin && Schedule} {isAdmin && Janitors} {isAdmin && Domains} + {isAdmin && Audit} @@ -193,6 +199,12 @@ function SettingsContent() { /> )} + + {isAdmin && ( + + + + )} diff --git a/src/app/settings/schema-audit.tsx b/src/app/settings/schema-audit.tsx new file mode 100644 index 0000000..99416d1 --- /dev/null +++ b/src/app/settings/schema-audit.tsx @@ -0,0 +1,155 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { Button } from "@/components/ui/button" +import { type AuditCategory, type AuditEntry, type SchemaAuditData, getSchemaAudit } from "@/lib/graph-api" +import { isMocksEnabled, MOCK_SCHEMA_AUDIT } from "@/lib/mock-data" + +// ── Badge config ─────────────────────────────────────────────────────────────── + +const CATEGORY_CONFIG = { + healthy: { + label: "✓ Healthy", + className: "bg-green-500/15 text-green-400 border border-green-500/30", + }, + orphaned: { + label: "✕ Orphaned", + className: "bg-red-500/15 text-red-400 border border-red-500/30", + }, + unused: { + label: "◌ Unused", + className: "bg-amber-500/15 text-amber-400 border border-amber-500/30", + }, +} as const + +// ── Sub-components ───────────────────────────────────────────────────────────── + +function AuditEntryRow({ entry, badgeClass }: { entry: AuditEntry; badgeClass: string }) { + return ( +
+ {entry.name} + + {entry.count} + +
+ ) +} + +function CategoryGroup({ + category, + entries, +}: { + category: keyof typeof CATEGORY_CONFIG + entries: AuditEntry[] +}) { + const config = CATEGORY_CONFIG[category] + + if (entries.length === 0) { + return ( +
+ + {config.label} + +

+
+ ) + } + + return ( +
+ + {config.label} + +
+ {entries.map((entry) => ( + + ))} +
+
+ ) +} + +function AuditSection({ + title, + category, +}: { + title: string + category: AuditCategory +}) { + return ( +
+

+ {title} +

+ + + +
+ ) +} + +// ── Main component ───────────────────────────────────────────────────────────── + +export function SchemaAuditSettings({ open }: { open: boolean }) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + if (isMocksEnabled()) { + setData(MOCK_SCHEMA_AUDIT) + return + } + const result = await getSchemaAudit() + setData(result) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load audit data") + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + if (open) load() + }, [open, load]) + + if (loading && !data) { + return

Loading…

+ } + + if (error && !data) { + return ( +
+

{error}

+ +
+ ) + } + + if (!data) return null + + return ( +
+
+

+ Live Neo4j database vs schema definitions. Counts are namespace-scoped. +

+ +
+ +
+ + +
+
+ ) +} diff --git a/src/lib/__tests__/schema-audit.test.tsx b/src/lib/__tests__/schema-audit.test.tsx new file mode 100644 index 0000000..d62ef94 --- /dev/null +++ b/src/lib/__tests__/schema-audit.test.tsx @@ -0,0 +1,272 @@ +/** + * Tests for src/app/settings/schema-audit.tsx + * + * Covers: + * - Loading state renders indicator + * - Mock mode returns MOCK_SCHEMA_AUDIT without hitting the API + * - All three badge variants (healthy/orphaned/unused) render + * - Count pills display correct values + * - Error state renders message and Retry button + * - Refresh button re-fetches data + * - Component does not fetch when open=false + */ + +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, waitFor, act } from "@testing-library/react" +import userEvent from "@testing-library/user-event" + +const mockGetSchemaAudit = vi.fn() + +vi.mock("@/lib/graph-api", () => ({ + getSchemaAudit: (...args: unknown[]) => mockGetSchemaAudit(...args), +})) + +const mockIsMocksEnabled = vi.fn(() => false) +const MOCK_AUDIT = { + node_labels: { + healthy: [ + { name: "Topic", count: 312 }, + { name: "Person", count: 84 }, + { name: "Episode", count: 57 }, + ], + orphaned: [ + { name: "LegacyTag", count: 7 }, + { name: "OldContent", count: 2 }, + ], + unused: [ + { name: "AgentSession", count: 0 }, + { name: "EvalSet", count: 0 }, + ], + }, + relationship_types: { + healthy: [ + { name: "RELATED_TO", count: 540 }, + { name: "MENTIONED_IN", count: 201 }, + ], + orphaned: [{ name: "OLD_LINK", count: 3 }], + unused: [ + { name: "WORKS_AT", count: 0 }, + { name: "HAS_PRICE", count: 0 }, + ], + }, +} + +vi.mock("@/lib/mock-data", () => ({ + isMocksEnabled: () => mockIsMocksEnabled(), + MOCK_SCHEMA_AUDIT: MOCK_AUDIT, +})) + +async function renderAuditSettings(open = true) { + const { SchemaAuditSettings } = await import("@/app/settings/schema-audit") + return render() +} + +beforeEach(() => { + vi.clearAllMocks() + mockIsMocksEnabled.mockReturnValue(false) +}) + +// ── Loading state ───────────────────────────────────────────────────────────── + +describe("SchemaAuditSettings – loading state", () => { + it("renders loading indicator while fetching", async () => { + let resolveAudit!: (v: unknown) => void + mockGetSchemaAudit.mockReturnValue(new Promise((r) => { resolveAudit = r })) + + await renderAuditSettings(true) + + expect(screen.getByText(/Loading/i)).toBeInTheDocument() + + // cleanup + act(() => resolveAudit(MOCK_AUDIT)) + await waitFor(() => expect(screen.queryByText(/Loading/i)).not.toBeInTheDocument()) + }) +}) + +// ── Mock mode ───────────────────────────────────────────────────────────────── + +describe("SchemaAuditSettings – mock mode", () => { + it("returns MOCK_SCHEMA_AUDIT without calling getSchemaAudit", async () => { + mockIsMocksEnabled.mockReturnValue(true) + + await renderAuditSettings(true) + + await waitFor(() => expect(screen.getByText("Topic")).toBeInTheDocument()) + expect(mockGetSchemaAudit).not.toHaveBeenCalled() + }) + + it("displays all node label entries from mock data", async () => { + mockIsMocksEnabled.mockReturnValue(true) + + await renderAuditSettings(true) + + await waitFor(() => { + expect(screen.getByText("Topic")).toBeInTheDocument() + expect(screen.getByText("Person")).toBeInTheDocument() + expect(screen.getByText("Episode")).toBeInTheDocument() + expect(screen.getByText("LegacyTag")).toBeInTheDocument() + expect(screen.getByText("OldContent")).toBeInTheDocument() + expect(screen.getByText("AgentSession")).toBeInTheDocument() + expect(screen.getByText("EvalSet")).toBeInTheDocument() + }) + }) + + it("displays all relationship type entries from mock data", async () => { + mockIsMocksEnabled.mockReturnValue(true) + + await renderAuditSettings(true) + + await waitFor(() => { + expect(screen.getByText("RELATED_TO")).toBeInTheDocument() + expect(screen.getByText("MENTIONED_IN")).toBeInTheDocument() + expect(screen.getByText("OLD_LINK")).toBeInTheDocument() + expect(screen.getByText("WORKS_AT")).toBeInTheDocument() + expect(screen.getByText("HAS_PRICE")).toBeInTheDocument() + }) + }) +}) + +// ── Badge variants ──────────────────────────────────────────────────────────── + +describe("SchemaAuditSettings – badge variants", () => { + beforeEach(() => { + mockIsMocksEnabled.mockReturnValue(true) + }) + + it("renders ✓ Healthy badge (green)", async () => { + await renderAuditSettings(true) + await waitFor(() => { + const badges = screen.getAllByText("✓ Healthy") + expect(badges.length).toBeGreaterThan(0) + expect(badges[0]).toHaveClass("text-green-400") + }) + }) + + it("renders ✕ Orphaned badge (red)", async () => { + await renderAuditSettings(true) + await waitFor(() => { + const badges = screen.getAllByText("✕ Orphaned") + expect(badges.length).toBeGreaterThan(0) + expect(badges[0]).toHaveClass("text-red-400") + }) + }) + + it("renders ◌ Unused badge (amber)", async () => { + await renderAuditSettings(true) + await waitFor(() => { + const badges = screen.getAllByText("◌ Unused") + expect(badges.length).toBeGreaterThan(0) + expect(badges[0]).toHaveClass("text-amber-400") + }) + }) +}) + +// ── Count pills ─────────────────────────────────────────────────────────────── + +describe("SchemaAuditSettings – count pills", () => { + beforeEach(() => { + mockIsMocksEnabled.mockReturnValue(true) + }) + + it("displays correct count for healthy node label", async () => { + await renderAuditSettings(true) + await waitFor(() => { + // "312" count for Topic + expect(screen.getByText("312")).toBeInTheDocument() + }) + }) + + it("displays correct count for orphaned relationship type", async () => { + await renderAuditSettings(true) + await waitFor(() => { + // "3" count for OLD_LINK + expect(screen.getByText("3")).toBeInTheDocument() + }) + }) + + it("displays 0 count for unused entries", async () => { + await renderAuditSettings(true) + await waitFor(() => { + // Multiple "0" pills for unused entries + const zeroPills = screen.getAllByText("0") + expect(zeroPills.length).toBeGreaterThanOrEqual(4) // AgentSession, EvalSet, WORKS_AT, HAS_PRICE + }) + }) +}) + +// ── Error state ─────────────────────────────────────────────────────────────── + +describe("SchemaAuditSettings – error state", () => { + it("renders error message and Retry button on failure", async () => { + mockGetSchemaAudit.mockRejectedValue(new Error("Network error")) + + await renderAuditSettings(true) + + await waitFor(() => { + expect(screen.getByText("Network error")).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Retry/i })).toBeInTheDocument() + }) + }) + + it("Retry button re-fetches data", async () => { + mockGetSchemaAudit + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce(MOCK_AUDIT) + + await renderAuditSettings(true) + + await waitFor(() => expect(screen.getByRole("button", { name: /Retry/i })).toBeInTheDocument()) + + const user = userEvent.setup() + await user.click(screen.getByRole("button", { name: /Retry/i })) + + await waitFor(() => expect(screen.getByText("Topic")).toBeInTheDocument()) + expect(mockGetSchemaAudit).toHaveBeenCalledTimes(2) + }) +}) + +// ── Refresh button ──────────────────────────────────────────────────────────── + +describe("SchemaAuditSettings – Refresh button", () => { + it("Refresh button re-fetches when data is loaded", async () => { + mockIsMocksEnabled.mockReturnValue(true) + + await renderAuditSettings(true) + await waitFor(() => expect(screen.getByText("Topic")).toBeInTheDocument()) + + const user = userEvent.setup() + await user.click(screen.getByRole("button", { name: /Refresh/i })) + + // Still shows data after refresh in mock mode + await waitFor(() => expect(screen.getByText("Topic")).toBeInTheDocument()) + }) +}) + +// ── open=false does not fetch ───────────────────────────────────────────────── + +describe("SchemaAuditSettings – open=false", () => { + it("does not fetch data when open is false", async () => { + mockGetSchemaAudit.mockResolvedValue(MOCK_AUDIT) + + await renderAuditSettings(false) + + // Nothing rendered, no fetch + expect(mockGetSchemaAudit).not.toHaveBeenCalled() + expect(screen.queryByText("Topic")).not.toBeInTheDocument() + }) +}) + +// ── Section headers ─────────────────────────────────────────────────────────── + +describe("SchemaAuditSettings – section headers", () => { + it("renders Node Labels and Relationship Types section headers", async () => { + mockIsMocksEnabled.mockReturnValue(true) + + await renderAuditSettings(true) + + await waitFor(() => { + expect(screen.getByText("Node Labels")).toBeInTheDocument() + expect(screen.getByText("Relationship Types")).toBeInTheDocument() + }) + }) +}) diff --git a/src/lib/__tests__/settings-page.test.tsx b/src/lib/__tests__/settings-page.test.tsx index ff820af..28674b0 100644 --- a/src/lib/__tests__/settings-page.test.tsx +++ b/src/lib/__tests__/settings-page.test.tsx @@ -60,6 +60,10 @@ vi.mock("@/components/modals/domain-settings", () => ({ DomainSettings: ({ open }: { open: boolean }) => open ?
Domains
: null, })) +vi.mock("@/app/settings/schema-audit", () => ({ + SchemaAuditSettings: ({ open }: { open: boolean }) => + open ?
Audit
: null, +})) // next/dynamic is used in the page; replace with a passthrough so mocked modules are used vi.mock("next/dynamic", () => ({ @@ -190,13 +194,14 @@ describe("SettingsPage – General tab", () => { // ── Admin-only tabs ─────────────────────────────────────────────────────────── describe("SettingsPage – admin-only tabs", () => { - it("shows Schedule, Janitors, Domains tabs for admins", async () => { + it("shows Schedule, Janitors, Domains, Audit tabs for admins", async () => { userState.isAuthenticated = true userState.isAdmin = true await renderPage() expect(screen.getByRole("tab", { name: /Schedule/i })).toBeInTheDocument() expect(screen.getByRole("tab", { name: /Janitors/i })).toBeInTheDocument() expect(screen.getByRole("tab", { name: /Domains/i })).toBeInTheDocument() + expect(screen.getByRole("tab", { name: /Audit/i })).toBeInTheDocument() }) it("does not show admin-only tabs for non-admins (not yet authenticated)", async () => { @@ -207,6 +212,7 @@ describe("SettingsPage – admin-only tabs", () => { expect(screen.queryByRole("tab", { name: /Schedule/i })).not.toBeInTheDocument() expect(screen.queryByRole("tab", { name: /Janitors/i })).not.toBeInTheDocument() expect(screen.queryByRole("tab", { name: /Domains/i })).not.toBeInTheDocument() + expect(screen.queryByRole("tab", { name: /Audit/i })).not.toBeInTheDocument() }) }) @@ -246,6 +252,13 @@ describe("SettingsPage – tab query param", () => { expect(domainsTab).toHaveAttribute("aria-selected", "true") }) + it("activates audit tab for ?tab=audit", async () => { + mockSearchParams = { get: (k: string) => (k === "tab" ? "audit" : null) } + await renderPage() + const auditTab = screen.getByRole("tab", { name: /Audit/i }) + expect(auditTab).toHaveAttribute("aria-selected", "true") + }) + it("falls back to general for unknown ?tab value", async () => { mockSearchParams = { get: (k: string) => (k === "tab" ? "unknown-tab-xyz" : null) } await renderPage() diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index 9728527..39d0fdf 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -518,6 +518,17 @@ export interface SchemaDomainsResponse { hidden_domains: string[] } +export type AuditEntry = { name: string; count: number } +export type AuditCategory = { + healthy: AuditEntry[] + orphaned: AuditEntry[] + unused: AuditEntry[] +} +export type SchemaAuditData = { + node_labels: AuditCategory + relationship_types: AuditCategory +} + // Returns the available domain roots for this namespace plus the hidden_types // and hidden_domains lists (schema types/domains excluded from Domain_* labeling). export async function getSchemaDomains( @@ -778,3 +789,7 @@ export async function dismissReview( signal ) } + +export async function getSchemaAudit(): Promise { + return api.get("/schema/audit") +} diff --git a/src/lib/mock-data.ts b/src/lib/mock-data.ts index 705229b..a367ab3 100644 --- a/src/lib/mock-data.ts +++ b/src/lib/mock-data.ts @@ -1,4 +1,4 @@ -import type { GraphNode, GraphEdge, GraphData, Review, StakworkRun } from "./graph-api" +import type { GraphNode, GraphEdge, GraphData, Review, StakworkRun, SchemaAuditData } from "./graph-api" import type { CreatorInsightsResponse } from "./creator-insights" export const MOCK_NODES: GraphNode[] = [ @@ -899,6 +899,35 @@ export const MOCK_DOMAINS = { hidden_domains: [] as string[], } +export const MOCK_SCHEMA_AUDIT: SchemaAuditData = { + node_labels: { + healthy: [ + { name: "Topic", count: 312 }, + { name: "Person", count: 84 }, + { name: "Episode", count: 57 }, + ], + orphaned: [ + { name: "LegacyTag", count: 7 }, + { name: "OldContent", count: 2 }, + ], + unused: [ + { name: "AgentSession", count: 0 }, + { name: "EvalSet", count: 0 }, + ], + }, + relationship_types: { + healthy: [ + { name: "RELATED_TO", count: 540 }, + { name: "MENTIONED_IN", count: 201 }, + ], + orphaned: [{ name: "OLD_LINK", count: 3 }], + unused: [ + { name: "WORKS_AT", count: 0 }, + { name: "HAS_PRICE", count: 0 }, + ], + }, +} + // Enriched Topic node for Deep Research mock UI (graphRAG-style) export const MOCK_DEEP_RESEARCH_TOPIC: GraphNode = { ref_id: "n-graphrag-deep",