import * as React from "react"; import { render } from "@testing-library/react"; import { ALL_LIBRARIES_HEADING } from "../../../src/components/LibraryStats"; import { CustomTooltip } from "../../../src/components/StatsCollectionsBarChart"; import { componentWithProviders, renderWithProviders, } from "../testUtils/withProviders"; import { statisticsApiResponseData, testLibraryKey as sampleLibraryKey, testLibraryName as sampleLibraryName, } from "../../__data__/statisticsApiResponseData"; import { normalizeStatistics } from "../../../src/features/stats/normalizeStatistics"; import { useGetStatsQuery } from "../../../src/features/stats/statsSlice"; import * as fetchMock from "fetch-mock-jest"; import { STATS_API_ENDPOINT } from "../../../src/features/stats/statsSlice"; import Stats from "../../../src/components/Stats"; import { renderHook } from "@testing-library/react-hooks"; import { FetchErrorData } from "@thepalaceproject/web-opds-client/lib/interfaces"; import { store } from "../../../src/store"; import { api } from "../../../src/features/api/apiSlice"; import { AdminRoleData, ConfigurationSettings, RolesData, } from "../../../src/interfaces"; const normalizedData = normalizeStatistics(statisticsApiResponseData); global.ResizeObserver = require("resize-observer-polyfill"); describe("Dashboard Statistics", () => { // NB: This adds test to the already existing tests in: // - `src/components/__tests__/LibraryStats-test.tsx`. // - `src/components/__tests__/SingleStatListItem-test.tsx`. // // Those tests should eventually be migrated here and // adapted to the Jest/React Testing Library paradigm. // Configure standard constructors so that RTK Query works in tests with FetchMockJest Object.assign(fetchMock.config, { fetch, Headers, Request, Response, }); const statGroupToHeading = { patrons: "Current Circulation Activity", circulations: "Circulation Totals", inventory: "Inventory", usageReports: "Usage and Reports", collections: "Configured Collections", }; describe("query hook correctly handles fetch responses", () => { const wrapper = componentWithProviders(); beforeEach(() => { store.dispatch(api.util.resetApiState()); fetchMock.restore(); }); afterAll(() => { store.dispatch(api.util.resetApiState()); fetchMock.restore(); }); it("returns data when fetch successful", async () => { fetchMock.get( `path:${STATS_API_ENDPOINT}`, { body: JSON.stringify(statisticsApiResponseData), status: 200, }, { overwriteRoutes: true } ); const { result, waitFor } = renderHook(() => useGetStatsQuery(), { wrapper, }); // Expect loading status immediately after first use of the hook. let { isSuccess, isError, error, data } = result.current; expect(isSuccess).toBe(false); expect(isError).toBe(false); expect(error).toBe(undefined); expect(data).toEqual(undefined); // Once loaded, we should have our data. await waitFor(() => !result.current.isLoading); ({ isSuccess, isError, error, data } = result.current); expect(isSuccess).toBe(true); expect(isError).toBe(false); expect(error).toBe(undefined); expect(data).toEqual(normalizedData); // But if we use the hook again, we should get the data back from // the cache immediately, without loading state. const { result: result2 } = renderHook(() => useGetStatsQuery(), { wrapper, }); ({ isSuccess, isError, error, data } = result2.current); expect(isSuccess).toBe(true); expect(isError).toBe(false); expect(error).toBe(undefined); expect(data).toEqual(normalizedData); }); it("returns error and no data when request fails", async () => { fetchMock.get( `path:${STATS_API_ENDPOINT}`, { status: 500, }, { overwriteRoutes: true, } ); const { result, waitFor } = renderHook(() => useGetStatsQuery(), { wrapper, }); // Expect loading status immediately after first use of the hook. let { isSuccess, isError, error, data } = result.current; expect(isSuccess).toBe(false); expect(isError).toBe(false); expect(error).toBe(undefined); expect(data).toEqual(undefined); await waitFor(() => !result.current.isLoading); ({ isSuccess, isError, error, data } = result.current); expect(isSuccess).toBe(false); expect(isError).toBe(true); expect(data).toBe(undefined); expect((error as FetchErrorData).status).toBe(500); // But if we use the hook again, we should get our error back from // the cache immediately, without loading state. const { result: result2 } = renderHook(() => useGetStatsQuery(), { wrapper, }); ({ isSuccess, isError, error, data } = result2.current); expect(isSuccess).toBe(false); expect(isError).toBe(true); expect(data).toBe(undefined); expect((error as FetchErrorData).status).toBe(500); }); }); describe("rendering", () => { beforeAll(() => { fetchMock.get(`path:${STATS_API_ENDPOINT}`, { body: JSON.stringify(statisticsApiResponseData), status: 200, }); }); afterAll(() => { fetchMock.restore(); }); describe("correctly handles fetching and caching", () => { afterEach(() => { fetchMock.resetHistory(); }); const assertLoadingState = ({ getByRole }) => { getByRole("dialog", { name: "Loading" }); getByRole("heading", { level: 1, name: "Loading" }); }; const assertNotLoadingState = ({ queryByRole }) => { const missingLoadingDialog = queryByRole("dialog", { name: "Loading" }); const missingLoadingHeading = queryByRole("heading", { level: 1, name: "Loading", }); expect(missingLoadingDialog).not.toBeInTheDocument(); expect(missingLoadingHeading).not.toBeInTheDocument(); }; it("shows/hides the loading indicator", async () => { // We haven't tried to fetch anything yet. expect(fetchMock.calls()).toHaveLength(0); const { rerender, getByRole, queryByRole } = renderWithProviders( ); // We should start in the loading state. assertLoadingState({ getByRole }); // Wait a tick for the statistics to render. await new Promise(process.nextTick); // Now we've fetched something. expect(fetchMock.calls()).toHaveLength(1); rerender(); // We should show our content without the loading state. assertNotLoadingState({ queryByRole }); getByRole("heading", { level: 2, name: ALL_LIBRARIES_HEADING }); // We haven't made another call, since the response is cached. expect(fetchMock.calls()).toHaveLength(1); }); it("doesn't fetch again, because response is cached", async () => { const { getByRole, queryByRole } = renderWithProviders(); // We should show our content immediately, without entering the loading state. assertNotLoadingState({ queryByRole }); getByRole("heading", { level: 2, name: ALL_LIBRARIES_HEADING }); // We never tried to fetch anything because the result is cached. expect(fetchMock.calls()).toHaveLength(0); }); it("show stats for a library, if a library is specified", async () => { const { getByRole, queryByRole, getByText } = renderWithProviders( ); // We should show our content immediately, without entering the loading state. assertNotLoadingState({ queryByRole }); getByRole("heading", { level: 2, name: `${sampleLibraryName} Dashboard`, }); getByRole("heading", { level: 3, name: statGroupToHeading.patrons }); getByText("21"); // We never tried to fetch anything because the result is cached. expect(fetchMock.calls()).toHaveLength(0); }); it("shows site-wide stats when no library specified", async () => { const { getByRole, getByText, queryByRole } = renderWithProviders( ); // We should show our content immediately, without entering the loading state. assertNotLoadingState({ queryByRole }); getByRole("heading", { level: 2, name: ALL_LIBRARIES_HEADING }); getByRole("heading", { level: 3, name: "Current Circulation Activity", }); getByText("1.6k"); // We never tried to fetch anything because the result is cached. expect(fetchMock.calls()).toHaveLength(0); }); }); describe("has correct statistics groups", () => { it("shows the right groups with a library", () => { const { getAllByRole } = renderWithProviders( ); const groupHeadings = getAllByRole("heading", { level: 3 }); const expectedHeadings = [ statGroupToHeading.patrons, statGroupToHeading.usageReports, statGroupToHeading.collections, ]; expect(groupHeadings).toHaveLength(3); groupHeadings.forEach((heading, index) => { expect(heading).toHaveTextContent(expectedHeadings[index]); }); }); it("shows the right groups with/out a library", () => { const { getAllByRole } = renderWithProviders(); const groupHeadings = getAllByRole("heading", { level: 3 }); const expectedHeadings = [ statGroupToHeading.patrons, statGroupToHeading.circulations, statGroupToHeading.inventory, statGroupToHeading.collections, ]; expect(groupHeadings).toHaveLength(4); groupHeadings.forEach((heading, index) => { expect(heading).toHaveTextContent(expectedHeadings[index]); }); }); }); describe("shows the correct UI with/out sysadmin role", () => { const systemAdmin: AdminRoleData[] = [{ role: "system" }]; const managerAll: AdminRoleData[] = [{ role: "manager-all" }]; const librarianAll: AdminRoleData[] = [{ role: "librarian-all" }]; const collectionNames = [ "New BiblioBoard Test", "New Bibliotheca Test Collection", "Palace Bookshelf", "TEST Baker & Taylor", "TEST Palace Marketplace", ]; it("tests BarChart component", () => { const appConfigSettings: Partial = { roles: systemAdmin, dashboardCollectionsBarChart: { width: 800 }, }; const { container, getByRole } = renderWithProviders( , { appConfigSettings } ); const collectionsHeading = getByRole("heading", { level: 3, name: statGroupToHeading.collections, }); const collectionsGroup = collectionsHeading.closest(".stat-group"); const barChartAxisTick = collectionsGroup.querySelectorAll( ".recharts-cartesian-axis-tick" ); // We expect the first ticks to be along the y-axis, which // should have our collection names. collectionNames.forEach((name, index) => { expect(barChartAxisTick[index]).toHaveTextContent(name); }); // Clean up the container after each render. document.body.removeChild(container); }); it("shows collection bar chart for sysadmins, but list for others", () => { // We'll use this function to test multiple scenarios. const testFor = (expectBarChart: boolean, roles: AdminRoleData[]) => { const appConfigSettings: Partial = { roles }; const { container, getByRole } = renderWithProviders( , { appConfigSettings } ); const collectionsHeading = getByRole("heading", { level: 3, name: statGroupToHeading.collections, }); const collectionsGroup = collectionsHeading.closest(".stat-group"); if (expectBarChart) { collectionsGroup.querySelector(".recharts-responsive-container"); } else { const list = collectionsGroup.querySelector("ul"); const items = list.querySelectorAll("li"); expect(items.length).toBe(collectionNames.length); collectionNames.forEach((name: string) => { expect(list).toHaveTextContent(name); }); items.forEach((item, index) => { expect(item).toHaveTextContent(collectionNames[index]); }); } // Clean up the container after each render. document.body.removeChild(container); }; // If the feature flag is set, the button should be visible only to sysadmins. testFor(true, systemAdmin); testFor(false, managerAll); testFor(false, librarianAll); }); it("shows inventory reports only for sysadmins, if sysadmin-only flag set", () => { const fakeQuickSightHref = "https://example.com/fakeQS"; // We'll use this function to test multiple scenarios. const renderFor = (onlySysadmins: boolean, roles: AdminRoleData[]) => { const appConfigSettings: Partial = { featureFlags: { reportsOnlyForSysadmins: onlySysadmins }, roles, quicksightPagePath: fakeQuickSightHref, }; const { container, getByRole, queryByRole, queryByText, } = renderWithProviders(, { appConfigSettings, }); // We should always render a Usage reports group when a library is specified. getByRole("heading", { level: 3, name: statGroupToHeading.usageReports, }); const usageReportLink = getByRole("link", { name: /View Usage/i }); expect(usageReportLink).toHaveAttribute("href", fakeQuickSightHref); const requestButton = queryByRole("button", { name: /Request Report/i, }); const blurb = queryByText( /These reports provide up-to-date data on both inventory and holds/i ); // The inventory report blurb should be visible only when the button is. if (requestButton) { expect(blurb).not.toBeNull(); } else { expect(blurb).toBeNull(); } // Clean up the container after each render. document.body.removeChild(container); return requestButton; }; // If the feature flag is set, the button should be visible only to sysadmins. expect(renderFor(true, systemAdmin)).not.toBeNull(); expect(renderFor(true, managerAll)).toBeNull(); expect(renderFor(true, librarianAll)).toBeNull(); // If the feature flag is false, the button should be visible to all users. expect(renderFor(false, systemAdmin)).not.toBeNull(); expect(renderFor(false, managerAll)).not.toBeNull(); expect(renderFor(false, librarianAll)).not.toBeNull(); }); it("shows quicksight link only for sysadmins, if sysadmin-only flag set", () => { const fakeQuickSightHref = "https://example.com/fakeQS"; // We'll use this function to test multiple scenarios. const renderFor = (onlySysadmins: boolean, roles: AdminRoleData[]) => { const appConfigSettings: Partial = { featureFlags: { quicksightOnlyForSysadmins: onlySysadmins }, roles, quicksightPagePath: fakeQuickSightHref, }; const { container, getByRole, queryByRole, queryByText, } = renderWithProviders(, { appConfigSettings, }); // We should always render a Usage reports group when a library is specified. getByRole("heading", { level: 3, name: statGroupToHeading.usageReports, }); const usageReportLink = queryByRole("link", { name: /View Usage/i }); if (usageReportLink) { expect(usageReportLink).toHaveAttribute("href", fakeQuickSightHref); } // Clean up the container after each render. document.body.removeChild(container); return usageReportLink; }; // If the feature flag is set, the link should be visible only to sysadmins. expect(renderFor(true, systemAdmin)).not.toBeNull(); expect(renderFor(true, managerAll)).toBeNull(); expect(renderFor(true, librarianAll)).toBeNull(); // If the feature flag is false, the button should be visible to all users. expect(renderFor(false, systemAdmin)).not.toBeNull(); expect(renderFor(false, managerAll)).not.toBeNull(); expect(renderFor(false, librarianAll)).not.toBeNull(); }); }); describe("charting - custom tooltip", () => { const defaultLabel = "Collection X"; const summaryInventory = { availableTitles: 7953, licensedTitles: 7974, meteredLicenseTitles: 7974, meteredLicensesAvailable: 75446, meteredLicensesOwned: 301541, openAccessTitles: 0, titles: 7974, unlimitedLicenseTitles: 0, }; const perMediumInventory = { Audio: { availableTitles: 148, licensedTitles: 165, meteredLicenseTitles: 165, meteredLicensesAvailable: 221, meteredLicensesOwned: 392, openAccessTitles: 0, titles: 165, unlimitedLicenseTitles: 0, }, Book: { availableTitles: 7805, licensedTitles: 7809, meteredLicenseTitles: 7809, meteredLicensesAvailable: 75225, meteredLicensesOwned: 301149, openAccessTitles: 0, titles: 7809, unlimitedLicenseTitles: 0, }, }; const defaultChartItemWithoutPerMediumInventory = { name: defaultLabel, ...summaryInventory, }; const defaultChartItemWithPerMediumInventory = { ...defaultChartItemWithoutPerMediumInventory, _by_medium: perMediumInventory, }; const defaultPayload = [ { fill: "#606060", dataKey: "meteredLicenseTitles", name: "Metered License Titles", color: "#606060", value: 7974, }, { fill: "#404040", dataKey: "unlimitedLicenseTitles", name: "Unlimited License Titles", color: "#404040", value: 0, }, { fill: "#202020", dataKey: "openAccessTitles", name: "Open Access Titles", color: "#202020", value: 0, }, ]; const populateTooltipProps = ({ active = true, label = defaultLabel, payload = [], chartItem = undefined, }) => { const constructedChartItem = !chartItem ? chartItem : { ...chartItem, name: label, }; const constructedPayload = payload.map((entry) => ({ ...entry, payload: constructedChartItem, })); return { active, label, payload: constructedPayload, }; }; /** * Helper function to test passing tests for a tooltip * * @param tooltipProps - passed to the component * @param expectedInventoryItemText - the expected inventory item text content */ const expectPassingTestsForActiveTooltip = ({ tooltipProps, expectedInventoryItemText, }) => { const { container, getByRole } = render( ); const tooltipContent = container.querySelector(".customTooltip"); const detail = tooltipContent.querySelector(".customTooltipDetail"); const detailChildren = detail.children; const heading = getByRole("heading", { level: 1, name: "Collection X", }); const items = tooltipContent.querySelectorAll("p.customTooltipItem"); const divider = detail.querySelector("hr"); expect(heading).toHaveTextContent("Collection X"); // Eight (8) metrics in the following order. expect(items).toHaveLength(8); // The expected inventory item labels array should be the same length. expect(expectedInventoryItemText).toHaveLength(items.length); // And the items should contain at least the expected text. Array.from(items).forEach((item, index) => { expect(item).toHaveTextContent(expectedInventoryItemText[index]); }); // The heading should be at the top and the divider (`hr`) // should be between the third and fourth statistics. expect(detailChildren).toHaveLength(10); expect(heading).toEqual(detailChildren[0]); expect(items[0]).toEqual(detailChildren[1]); expect(items[2]).toEqual(detailChildren[3]); expect(divider).toEqual(detailChildren[4]); expect(items[3]).toEqual(detailChildren[5]); expect(items[7]).toEqual(detailChildren[9]); }; it("should not render when active is false", () => { // Recharts sticks some extra props const tooltipProps = populateTooltipProps({ active: false, chartItem: defaultChartItemWithPerMediumInventory, payload: defaultPayload, }); const { container } = render(); const tooltipContent = container.querySelectorAll(".customTooltip"); expect(tooltipContent).toHaveLength(0); }); it("should render when active is true", () => { const tooltipProps = populateTooltipProps({ active: true, chartItem: defaultChartItemWithoutPerMediumInventory, payload: defaultPayload, }); const expectedInventoryItemText = [ "Titles:", "Available Titles:", "Metered License Titles:", "Licensed Titles:", "Metered Licenses Available:", "Metered Licenses Owned:", "Open Access Titles:", "Unlimited License Titles:", ]; expectPassingTestsForActiveTooltip({ tooltipProps, expectedInventoryItemText, }); }); it("should render without per-medium inventory", () => { const tooltipProps = populateTooltipProps({ active: true, chartItem: defaultChartItemWithoutPerMediumInventory, payload: defaultPayload, }); const expectedInventoryItemText = [ "Titles: 7,974", "Available Titles: 7,953", "Metered License Titles: 7,974", "Licensed Titles: 7,974", "Metered Licenses Available: 75,446", "Metered Licenses Owned: 301,541", "Open Access Titles: 0", "Unlimited License Titles: 0", ]; expectPassingTestsForActiveTooltip({ tooltipProps, expectedInventoryItemText, }); }); it("should render additional detail with per-medium inventory", () => { const tooltipProps = populateTooltipProps({ active: true, chartItem: defaultChartItemWithPerMediumInventory, payload: defaultPayload, }); const expectedInventoryItemText = [ "Titles: 7,974 (Audio: 165, Book: 7,809)", "Available Titles: 7,953 (Audio: 148, Book: 7,805)", "Metered License Titles: 7,974 (Audio: 165, Book: 7,809)", "Licensed Titles: 7,974 (Audio: 165, Book: 7,809)", "Metered Licenses Available: 75,446 (Audio: 221, Book: 75,225)", "Metered Licenses Owned: 301,541 (Audio: 392, Book: 301,149)", "Open Access Titles: 0", "Unlimited License Titles: 0", ]; expectPassingTestsForActiveTooltip({ tooltipProps, expectedInventoryItemText, }); }); }); }); });