From 0603346065acead35d0f83a2ac753fa1446df92b Mon Sep 17 00:00:00 2001 From: Scott Gress Date: Tue, 6 Jan 2026 09:32:36 -0600 Subject: [PATCH] Add tests for new "auto update" front-end (#37877) **Related issue:** For #35459 # Details This PR adds front-end tests for: * `` * Smoke-test of basic functionality (showing the software title and type, showing an icon) * Action dropdown options for various kinds of software * `` * Options for auto-updates (enable button, maintenance window validation) * Targets ("All hosts" and "Custom" values and validation) * Form submission (ensuring the API receives the expected payload for various permutations of the form) The `` component has its own tests so we don't go through it thoroughly here, just integration tests with the new component. Conversely the `` component _doesn't_ have its own tests, and could use some, but in this instance we're just concerned with how it integrates with the software summary card (that is, how the passed-in software title affects the Actions dropdown). ## Testing - [X] Added/updated automated tests --------- Co-authored-by: Gabriel Hernandez Co-authored-by: Nico <32375741+nulmete@users.noreply.github.com> --- .../DropdownWrapper/DropdownWrapper.tsx | 2 +- .../EditAutoUpdateConfigModal.tests.tsx | 586 ++++++++++++++++++ .../SoftwareSummaryCard.tests.tsx | 156 +++++ .../SoftwareDetailsSummary.tsx | 2 +- frontend/test/jest.config.ts | 1 + 5 files changed, 745 insertions(+), 2 deletions(-) create mode 100644 frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tests.tsx create mode 100644 frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tests.tsx diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx index 504d071f18..c137f559f7 100644 --- a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx @@ -42,7 +42,7 @@ const CustomOption = (props: CustomOptionProps) => { const { data, ...rest } = props; const optionContent = ( -
+
{data.label} {data.helpText && ( {data.helpText} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tests.tsx new file mode 100644 index 0000000000..c4aaf413a5 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tests.tsx @@ -0,0 +1,586 @@ +import React from "react"; + +import { + createMockSoftwareTitleDetails, + createMockAppStoreApp, +} from "__mocks__/softwareMock"; + +import { act, screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import mockServer from "test/mock-server"; +import { createCustomRenderer } from "test/test-utils"; +import { ILabelSummary } from "interfaces/label"; + +import createMockUser from "__mocks__/userMock"; + +import EditAutoUpdateConfigModal, { + ISoftwareAutoUpdateConfigFormData, +} from "./EditAutoUpdateConfigModal"; + +const baseUrl = (path: string) => { + return `/api/latest/fleet${path}`; +}; + +const mockLabels: ILabelSummary[] = [ + { + id: 1, + name: "Fun", + description: "Computers that like to have a good time", + label_type: "regular", + }, + { + id: 2, + name: "Fresh", + description: "Laptops with dirty mouths", + label_type: "regular", + }, +]; + +const labelSummariesHandler = http.get(baseUrl("/labels/summary"), () => { + return HttpResponse.json({ + labels: mockLabels, + }); +}); + +describe("Edit Auto Update Config Modal", () => { + beforeEach(() => { + mockServer.use(labelSummariesHandler); + }); + + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: createMockUser(), + isGlobalObserver: false, + isGlobalAdmin: true, + isGlobalMaintainer: false, + isOnGlobalTeam: true, + isPremiumTier: true, + isSandboxMode: false, + }, + }, + }); + describe("Auto updates options", () => { + it("Does not show maintenance window options when 'Enable auto updates' is not configured", async () => { + render( + + ); + // Verify that "Enable auto updates" checkbox is not checked. + const enableAutoUpdatesCheckbox = screen.getByRole("checkbox", { + name: "Enable auto updates", + }); + expect(enableAutoUpdatesCheckbox).not.toBeChecked(); + // Verify that the maintenance window fields are not shown. + expect( + screen.queryByLabelText("Earliest start time") + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("Latest start time") + ).not.toBeInTheDocument(); + }); + + it("Shows maintenance window options when 'Enable auto updates' is configured", async () => { + render( + + ); + // Verify that "Enable auto updates" checkbox is checked. + const enableAutoUpdatesCheckbox = screen.getByRole("checkbox", { + name: "Enable auto updates", + }); + expect(enableAutoUpdatesCheckbox).toBeChecked(); + // Verify that the maintenance window fields are shown correctly. + const startTimeField = screen.getByLabelText("Earliest start time"); + const endTimeField = screen.getByLabelText("Latest start time"); + expect(startTimeField).toBeInTheDocument(); + expect(startTimeField).toHaveValue("02:00"); + expect(endTimeField).toBeInTheDocument(); + expect(endTimeField).toHaveValue("04:00"); + }); + + it("Shows maintenance window options when 'Enable auto updates' is checked", async () => { + const { user } = render( + + ); + const enableAutoUpdatesCheckbox = screen.getByRole("checkbox", { + name: "Enable auto updates", + }); + expect(enableAutoUpdatesCheckbox).not.toBeChecked(); + // Click the checkbox to enable auto updates. + await user.click(enableAutoUpdatesCheckbox); + await waitFor(() => { + expect(enableAutoUpdatesCheckbox).toBeChecked(); + // Verify that the maintenance window fields are shown (but empty). + const startTimeField = screen.getByLabelText("Earliest start time"); + const endTimeField = screen.getByLabelText("Latest start time"); + expect(startTimeField).toBeInTheDocument(); + expect(endTimeField).toBeInTheDocument(); + expect(startTimeField).toHaveValue(""); + expect(endTimeField).toHaveValue(""); + }); + }); + + it("Hides maintenance window options when 'Enable auto updates' is unchecked", async () => { + const { user } = render( + + ); + const enableAutoUpdatesCheckbox = screen.getByRole("checkbox", { + name: "Enable auto updates", + }); + expect(enableAutoUpdatesCheckbox).toBeChecked(); + // Click the checkbox to disable auto updates. + await user.click(enableAutoUpdatesCheckbox); + await waitFor(() => { + expect(enableAutoUpdatesCheckbox).not.toBeChecked(); + // Verify that the maintenance window fields are not shown. + const startTimeField = screen.queryByText("Earliest start time"); + const endTimeField = screen.queryByText("Latest start time"); + expect(startTimeField).not.toBeInTheDocument(); + expect(endTimeField).not.toBeInTheDocument(); + }); + }); + + describe("Maintenance window validation", () => { + it("Requires start time to be HH:MM format", async () => { + const { user } = render( + + ); + const enableAutoUpdatesCheckbox = screen.getByRole("checkbox", { + name: "Enable auto updates", + }); + expect(enableAutoUpdatesCheckbox).not.toBeChecked(); + // Click the checkbox to enable auto updates. + await user.click(enableAutoUpdatesCheckbox); + await waitFor(() => { + expect(enableAutoUpdatesCheckbox).toBeChecked(); + }); + const startTimeField = screen.getByLabelText("Earliest start time"); + let endTimeField = screen.getByLabelText("Latest start time"); + expect(startTimeField).toBeInTheDocument(); + expect(endTimeField).toBeInTheDocument(); + // Enter invalid start time. + await user.type(startTimeField, "19:99"); + // Move focus to trigger validation. + await user.click(endTimeField); + await user.type(endTimeField, "12:00"); + // Verify that validation message is shown + const errorField = screen.getByLabelText( + "Use HH:MM format (24-hour clock)" + ); + expect(errorField).toBeInTheDocument(); + expect(errorField).toHaveValue("19:99"); + // Veryfy that end time is still present with valid label. + endTimeField = screen.getByLabelText("Latest start time"); + expect(endTimeField).toBeInTheDocument(); + expect(endTimeField).toHaveValue("12:00"); + + const saveButton = screen.getByRole("button", { name: "Save" }); + expect(saveButton).toBeDisabled(); + }); + + it("Requires end time to be HH:MM format", async () => { + const { user } = render( + + ); + const enableAutoUpdatesCheckbox = screen.getByRole("checkbox", { + name: "Enable auto updates", + }); + expect(enableAutoUpdatesCheckbox).not.toBeChecked(); + await user.click(enableAutoUpdatesCheckbox); + await waitFor(() => { + expect(enableAutoUpdatesCheckbox).toBeChecked(); + }); + let startTimeField = screen.getByLabelText("Earliest start time"); + const endTimeField = screen.getByLabelText("Latest start time"); + expect(startTimeField).toBeInTheDocument(); + expect(endTimeField).toBeInTheDocument(); + // Enter invalid end time. + await user.type(endTimeField, "19:99"); + // Move focus to trigger validation + await user.click(startTimeField); + await user.type(startTimeField, "12:00"); + // Verify that validation message is shown. + const errorField = screen.getByLabelText( + "Use HH:MM format (24-hour clock)" + ); + expect(errorField).toBeInTheDocument(); + expect(errorField).toHaveValue("19:99"); + // Veryfy that start time is still present with valid label. + startTimeField = screen.getByLabelText("Earliest start time"); + expect(startTimeField).toBeInTheDocument(); + expect(startTimeField).toHaveValue("12:00"); + + const saveButton = screen.getByRole("button", { + name: "Save", + }); + expect(saveButton).toBeDisabled(); + }); + + it("Requires both start and end times to be set", async () => { + const { user } = render( + + ); + const enableAutoUpdatesCheckbox = screen.getByRole("checkbox", { + name: "Enable auto updates", + }); + expect(enableAutoUpdatesCheckbox).not.toBeChecked(); + await user.click(enableAutoUpdatesCheckbox); + await waitFor(() => { + expect(enableAutoUpdatesCheckbox).toBeChecked(); + }); + const startTimeField = screen.getByLabelText("Earliest start time"); + const endTimeField = screen.getByLabelText("Latest start time"); + const saveButton = screen.getByRole("button", { name: "Save" }); + + expect(startTimeField).toBeInTheDocument(); + expect(endTimeField).toBeInTheDocument(); + // Enter only start time. + await user.type(startTimeField, "10:00"); + // Click Save button to trigger validation. + await user.click(saveButton); + // Verify that validation message is shown for end time. + expect( + screen.getByLabelText("Latest start time is required") + ).toBeInTheDocument(); + // Now enter only end time. + await user.clear(startTimeField); + await user.type(endTimeField, "12:00"); + // Click Save button to trigger validation. + await user.click(saveButton); + // Verify that validation message is shown for start time + // but the end-time validation message is cleared. + expect( + screen.getByLabelText("Earliest start time is required") + ).toBeInTheDocument(); + expect( + screen.queryByText("Latest start time is required") + ).not.toBeInTheDocument(); + expect(saveButton).toBeDisabled(); + + // Clear both + await user.clear(startTimeField); + await user.clear(endTimeField); + // Click Save button to trigger validation. + await user.click(saveButton); + // Verify that validation message is shown for both times. + expect( + screen.getByLabelText("Earliest start time is required") + ).toBeInTheDocument(); + expect( + screen.getByLabelText("Latest start time is required") + ).toBeInTheDocument(); + expect(saveButton).toBeDisabled(); + // Fill both with valid values. + await user.type(startTimeField, "10:00"); + await user.type(endTimeField, "12:30"); + await user.click(endTimeField); + // Verify that no validation messages are shown. + expect( + screen.queryByLabelText("Earliest start time is required") + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("Latest start time is required") + ).not.toBeInTheDocument(); + expect(saveButton).toBeEnabled(); + }); + + it("Requires window to be at least one hour", async () => { + const { user } = render( + + ); + const enableAutoUpdatesCheckbox = screen.getByRole("checkbox", { + name: "Enable auto updates", + }); + expect(enableAutoUpdatesCheckbox).not.toBeChecked(); + await user.click(enableAutoUpdatesCheckbox); + await waitFor(() => { + expect(enableAutoUpdatesCheckbox).toBeChecked(); + }); + const startTimeField = screen.getByLabelText("Earliest start time"); + const endTimeField = screen.getByLabelText("Latest start time"); + expect(startTimeField).toBeInTheDocument(); + expect(endTimeField).toBeInTheDocument(); + // Set a 59-minute window. + await user.type(startTimeField, "12:00"); + await user.click(endTimeField); + await user.type(endTimeField, "12:59"); + await user.click(startTimeField); + // Verify that validation message is shown. + const error = screen.getByText( + "Update window must be at least 60 minutes long" + ); + expect(error).toBeInTheDocument(); + const saveButton = screen.getByRole("button", { + name: "Save", + }); + expect(saveButton).toBeDisabled(); + }); + }); + }); + + describe("Target options", () => { + it("Shows 'All hosts' if no labels are configured for the title", async () => { + render( + + ); + expect(screen.getByLabelText("All hosts")).toBeInTheDocument(); + expect(screen.getByLabelText("Custom")).toBeInTheDocument(); + expect(screen.getByLabelText("All hosts")).toBeChecked(); + expect(screen.getByLabelText("Custom")).not.toBeChecked(); + }); + it("Shows label options if labels are configured for the title", async () => { + render( + + ); + expect(screen.getByLabelText("All hosts")).toBeInTheDocument(); + expect(screen.getByLabelText("Custom")).toBeInTheDocument(); + expect(screen.getByLabelText("All hosts")).not.toBeChecked(); + expect(screen.getByLabelText("Custom")).toBeChecked(); + expect(screen.getByLabelText(mockLabels[1].name)).toBeInTheDocument(); + expect(screen.getByLabelText(mockLabels[1].name)).toBeChecked(); + expect(screen.getByLabelText(mockLabels[0].name)).toBeInTheDocument(); + expect(screen.getByLabelText(mockLabels[0].name)).not.toBeChecked(); + const saveButton = screen.getByRole("button", { + name: "Save", + }); + expect(saveButton).toBeEnabled(); + }); + + it("Requires at least one label to be selected if 'Custom' is selected", async () => { + const { user } = render( + + ); + const customOption = screen.getByLabelText("Custom"); + expect(customOption).toBeChecked(); + const labelOption = screen.getByLabelText(mockLabels[1].name); + expect(labelOption).toBeChecked(); + await user.click(labelOption); + expect(labelOption).not.toBeChecked(); + const saveButton = screen.getByRole("button", { + name: "Save", + }); + expect(saveButton).toBeDisabled(); + }); + }); + + describe("Submitting the form", () => { + const requestSpy = jest.fn(); + const submitHandler = http.patch( + baseUrl("/software/titles/*/app_store_app"), + async ({ request }) => { + const requestData = (await request.json()) as ISoftwareAutoUpdateConfigFormData; + requestSpy(requestData); + return HttpResponse.json({}); + } + ); + beforeEach(() => { + mockServer.use(submitHandler); + requestSpy.mockClear(); + }); + it("Sends the correct payload when 'Enable auto updates' is unchecked", async () => { + render( + + ); + const saveButton = screen.getByRole("button", { + name: "Save", + }); + expect(saveButton).toBeEnabled(); + await act(() => { + saveButton.click(); + }); + await waitFor(() => { + expect(requestSpy).toHaveBeenCalledWith({ + auto_update_enabled: false, + labels_include_any: [], + labels_exclude_any: [], + team_id: 1, + }); + }); + }); + + it("Sends the correct payload when 'Enable auto updates' is checked and a valid window is configured", async () => { + const { user } = render( + + ); + const enableAutoUpdatesCheckbox = screen.getByRole("checkbox", { + name: "Enable auto updates", + }); + await act(() => { + enableAutoUpdatesCheckbox.click(); + }); + await waitFor(() => { + expect(enableAutoUpdatesCheckbox).toBeChecked(); + }); + const startTimeField = screen.getByLabelText("Earliest start time"); + const endTimeField = screen.getByLabelText("Latest start time"); + await user.type(startTimeField, "02:00"); + await user.type(endTimeField, "04:00"); + const saveButton = screen.getByRole("button", { + name: "Save", + }); + expect(saveButton).toBeEnabled(); + await act(() => { + saveButton.click(); + }); + await waitFor(() => { + expect(requestSpy).toHaveBeenCalledWith({ + auto_update_enabled: true, + auto_update_start_time: "02:00", + auto_update_end_time: "04:00", + labels_include_any: [], + labels_exclude_any: [], + team_id: 1, + }); + }); + }); + + it("Sends the correct payload when 'All hosts' is selected as the target", async () => { + const { user } = render( + + ); + const allHostsRadio = screen.getByLabelText("All hosts"); + expect(allHostsRadio).toBeInTheDocument(); + await user.click(allHostsRadio); + const saveButton = screen.getByRole("button", { + name: "Save", + }); + expect(saveButton).toBeEnabled(); + await user.click(saveButton); + await waitFor(() => { + expect(requestSpy).toHaveBeenCalledWith({ + auto_update_enabled: false, + labels_include_any: [], + labels_exclude_any: [], + team_id: 1, + }); + }); + }); + + it("Sends the correct payload when specific labels are selected as the target", async () => { + const { user } = render( + + ); + const saveButton = screen.getByRole("button", { + name: "Save", + }); + expect(saveButton).toBeEnabled(); + await act(() => { + user.click(saveButton); + }); + await waitFor(() => { + expect(requestSpy).toHaveBeenCalledWith({ + auto_update_enabled: false, + labels_include_any: [mockLabels[1].name], + team_id: 1, + }); + }); + }); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tests.tsx new file mode 100644 index 0000000000..847c4db010 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tests.tsx @@ -0,0 +1,156 @@ +import React from "react"; + +import { + createMockSoftwareTitle, + createMockSoftwarePackage, + createMockAppStoreApp, + createMockAppStoreAppAndroid, +} from "__mocks__/softwareMock"; + +import { render as defaultRender, screen } from "@testing-library/react"; +import { UserEvent } from "@testing-library/user-event"; +import { createCustomRenderer, createMockRouter } from "test/test-utils"; + +import SoftwareSummaryCard from "./SoftwareSummaryCard"; + +const router = createMockRouter(); + +// Mock the SoftwareIcon component since it makes API calls. +// We'll just check that it's called with the correct URL. +const mockSoftwareIcon = jest.fn(); +jest.mock("../../components/icons/SoftwareIcon", () => { + return { + __esModule: true, + default: ({ url }: { url: string }) => { + mockSoftwareIcon({ url }); + return
; + }, + }; +}); + +describe("Software Summary Card", () => { + beforeEach(() => { + mockSoftwareIcon.mockClear(); + }); + it("Shows the correct basic info about a software title", async () => { + const softwareTitle = createMockSoftwareTitle({ + icon_url: "https://example.com/icon.png", + }); + defaultRender( + + ); + // Get the text with aria label "software display name" + const displayNameElement = screen.getByLabelText("software display name"); + expect(displayNameElement).toHaveTextContent(softwareTitle.name); + // Check for type "Application (macOS)" + expect(screen.getByText("Application (macOS)")).toBeInTheDocument(); + // Check that the icon component is called with the correct URL. + expect(mockSoftwareIcon).toHaveBeenCalledWith({ + url: "https://example.com/icon.png", + }); + }); + + describe("Actions dropdown", () => { + const render = createCustomRenderer({ + context: { + app: { + isPremiumTier: true, + isGlobalAdmin: true, + config: { + gitops: { + gitops_mode_enabled: false, + repository_url: "", + }, + }, + }, + }, + }); + + // Shared helper function to open the actions dropdown and retrieve all visible options. + const getDropdownOptions = async (user: UserEvent): Promise => { + const actionsButton = screen.getByText("Actions"); + expect(actionsButton).toBeInTheDocument(); + + await user.click(actionsButton); + + // Get all options from the dropdown menu + const options = screen.getAllByTestId("dropdown-option"); + return options.map((option) => option.textContent || ""); + }; + + it("displays Edit appearance and Edit software options for standard software packages", async () => { + const { user } = render( + + ); + + const options = await getDropdownOptions(user); + + expect(options).toContain("Edit appearance"); + expect(options).toContain("Edit software"); + expect(options).not.toContain("Edit configuration"); + expect(options).not.toContain("Schedule auto updates"); + }); + + it("displays Edit appearance, Edit software, and Schedule auto updates for iOS/iPadOS apps", async () => { + const { user } = render( + + ); + + const options = await getDropdownOptions(user); + + expect(options).toContain("Edit appearance"); + expect(options).toContain("Edit software"); + expect(options).toContain("Schedule auto updates"); + expect(options).not.toContain("Edit configuration"); + }); + + it("displays Edit appearance and Edit configuration (but not Edit software) for Android apps", async () => { + const { user } = render( + + ); + + const options = await getDropdownOptions(user); + + expect(options).toContain("Edit appearance"); + expect(options).toContain("Edit configuration"); + expect(options).not.toContain("Edit software"); + expect(options).not.toContain("Schedule auto updates"); + }); + }); +}); diff --git a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx index f45800c45a..2227cc43c3 100644 --- a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx +++ b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx @@ -229,7 +229,7 @@ const SoftwareDetailsSummary = ({ )}
-

+

{isRollingArch ? ( // wrap a tooltip around the "rolling" suffix <> diff --git a/frontend/test/jest.config.ts b/frontend/test/jest.config.ts index c75ae8d0d3..3d04922f53 100644 --- a/frontend/test/jest.config.ts +++ b/frontend/test/jest.config.ts @@ -33,6 +33,7 @@ const config: Config = { moduleNameMapper: { "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/frontend/__mocks__/fileMock.js", + "\\.(sh|ps1)$": "/frontend/__mocks__/fileMock.js", "\\.(css|scss|sass)$": "identity-obj-proxy", }, testMatch: ["**/*tests.[jt]s?(x)"],