From 44ad660a460dae8f943c135ab1a14e7f130c0dda Mon Sep 17 00:00:00 2001 From: Tiffany Forkner Date: Wed, 29 Jan 2025 17:13:28 -0500 Subject: [PATCH 1/2] refactor(test): refactoring tests to remove duplication (#1090) * refactoring tests to remove duplication --- .../src/components/Inputs/upload.test.tsx | 2 +- .../src/components/Layout/index.test.tsx | 2 +- .../main/Filtering/Chipbar/index.test.tsx | 246 ++++-------------- .../main/Filtering/Drawer/index.test.tsx | 42 +-- .../main/Filtering/Export/Export.test.tsx | 87 +------ .../main/Filtering/Filtering.test.tsx | 128 ++------- .../Opensearch/main/Table/index.test.tsx | 120 ++------- .../components/RHF/tests/FieldArray.test.tsx | 2 +- .../src/components/RHF/tests/NameGen.test.tsx | 2 +- .../src/components/RHF/tests/Section.test.tsx | 2 +- .../src/components/RHF/tests/Slot.test.tsx | 2 +- .../components/RHF/tests/SlotField.test.tsx | 2 +- .../src/features/profile/profile.test.tsx | 2 +- react-app/src/hooks/UseReadOnlyUser.test.tsx | 2 +- react-app/src/utils/TestWrapper.tsx | 20 -- react-app/src/utils/index.ts | 1 - .../src/utils/test-helpers/dashboard.tsx | 179 +++++++++++++ react-app/src/utils/test-helpers/index.tsx | 5 + react-app/src/utils/test-helpers/render.tsx | 51 ++++ .../src/utils/test-helpers/renderForm.tsx | 44 +--- vitest.config.ts | 36 +-- 21 files changed, 366 insertions(+), 611 deletions(-) delete mode 100644 react-app/src/utils/TestWrapper.tsx create mode 100644 react-app/src/utils/test-helpers/dashboard.tsx create mode 100644 react-app/src/utils/test-helpers/index.tsx create mode 100644 react-app/src/utils/test-helpers/render.tsx diff --git a/react-app/src/components/Inputs/upload.test.tsx b/react-app/src/components/Inputs/upload.test.tsx index 80c9cb539e..05a7a5344d 100644 --- a/react-app/src/components/Inputs/upload.test.tsx +++ b/react-app/src/components/Inputs/upload.test.tsx @@ -1,7 +1,7 @@ import { Upload } from "./upload"; import { screen, fireEvent, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderWithQueryClient } from "@/utils/test-helpers/renderForm"; +import { renderWithQueryClient } from "@/utils/test-helpers"; const defaultProps = { dataTestId: "upload-component", diff --git a/react-app/src/components/Layout/index.test.tsx b/react-app/src/components/Layout/index.test.tsx index 7d1093a6bc..a52ee096a8 100644 --- a/react-app/src/components/Layout/index.test.tsx +++ b/react-app/src/components/Layout/index.test.tsx @@ -5,7 +5,7 @@ import userEvent from "@testing-library/user-event"; import { Auth } from "aws-amplify"; import * as hooks from "@/hooks"; import * as api from "@/api"; -import { renderWithQueryClientAndMemoryRouter } from "@/utils/test-helpers/renderForm"; +import { renderWithQueryClientAndMemoryRouter } from "@/utils/test-helpers"; import { setMockUsername, makoStateSubmitter, noRoleUser, AUTH_CONFIG } from "mocks"; /** diff --git a/react-app/src/components/Opensearch/main/Filtering/Chipbar/index.test.tsx b/react-app/src/components/Opensearch/main/Filtering/Chipbar/index.test.tsx index a66472e033..26c7d84d80 100644 --- a/react-app/src/components/Opensearch/main/Filtering/Chipbar/index.test.tsx +++ b/react-app/src/components/Opensearch/main/Filtering/Chipbar/index.test.tsx @@ -1,10 +1,9 @@ import { describe, expect, it, vi, afterEach } from "vitest"; import { screen, render } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import LZ from "lz-string"; import { ChipBool, ChipDate, ChipTerms, FilterChips } from "./index"; -import { renderWithQueryClientAndMemoryRouter } from "@/utils/test-helpers/renderForm"; -import { FilterDrawerProvider } from "../FilterProvider"; +import { opensearch } from "shared-types"; +import { renderFilterDrawer, getDashboardQueryString, DEFAULT_FILTERS } from "@/utils/test-helpers"; describe("FilterChips", () => { const openDrawer = vi.fn(); @@ -348,80 +347,17 @@ describe("FilterChips", () => { }); describe("FilterChips", () => { - const code = "094230fe-a02f-45d7-a675-05876ab5d76a"; - const queryString = LZ.compressToEncodedURIComponent( - JSON.stringify({ - filters: [ - { - label: "State", - field: "state.keyword", - component: "multiSelect", - prefix: "must", - type: "terms", - value: ["MD"], - }, - { - label: "Authority", - field: "authority.keyword", - component: "multiCheck", - prefix: "must", - type: "terms", - value: ["CHIP SPA"], - }, - { - label: "RAI Withdraw Enabled", - field: "raiWithdrawEnabled", - component: "boolean", - prefix: "must", - type: "match", - value: true, - }, - { - label: "Final Disposition", - field: "finalDispositionDate", - component: "dateRange", - prefix: "must", - type: "range", - value: { - gte: "2025-01-01T00:00:00.000Z", - lte: "2025-01-01T23:59:59.999Z", - }, - }, - ], - search: "", - tab: "spas", - pagination: { - number: 0, - size: 25, - }, - sort: { - field: "submissionDate", - order: "desc", - }, - code, - }), - ); - const routes = [ - { - path: "/dashboard", - element: ( - - - - ), - }, - ]; - const routeOptions = { - initialEntries: [ - { - pathname: "/dashboard", - search: `code=${code}&os=${queryString}`, - }, - ], + const setup = (filters: opensearch.Filterable[]) => { + const user = userEvent.setup(); + const rendered = renderFilterDrawer(, getDashboardQueryString({ filters })); + return { + user, + ...rendered, + }; }; it("should display multiple chips", () => { - renderWithQueryClientAndMemoryRouter(, routes, routeOptions); + setup(DEFAULT_FILTERS); expect(screen.getByText("State: Maryland, MD")).toBeInTheDocument(); expect(screen.getByText("Authority: CHIP SPA")).toBeInTheDocument(); expect(screen.getByText("RAI Withdraw Enabled:")).toBeInTheDocument(); @@ -432,115 +368,45 @@ describe("FilterChips", () => { }); it("should display filters with multiple values", () => { - const queryStringMV = LZ.compressToEncodedURIComponent( - JSON.stringify({ - filters: [ - { - label: "State", - field: "state.keyword", - component: "multiSelect", - prefix: "must", - type: "terms", - value: ["MD", "OH"], - }, - ], - search: "", - tab: "spas", - pagination: { - number: 0, - size: 25, - }, - sort: { - field: "submissionDate", - order: "desc", - }, - code, - }), - ); - renderWithQueryClientAndMemoryRouter(, routes, { - initialEntries: [ - { - pathname: "/dashboard", - search: `code=${code}&os=${queryStringMV}`, - }, - ], - }); + setup([ + { + label: "State", + field: "state.keyword", + component: "multiSelect", + prefix: "must", + type: "terms", + value: ["MD", "OH"], + }, + ]); expect(screen.getByText("State: Maryland, MD")).toBeInTheDocument(); expect(screen.getByText("State: Ohio, OH")).toBeInTheDocument(); }); it("should display no chips if there are no supported filters", () => { - const queryStringMV = LZ.compressToEncodedURIComponent( - JSON.stringify({ - filters: [ - { - label: "RAI Withdraw Enabled", - field: "raiWithdrawEnabled", - component: "boolean", - prefix: "must", - type: "exists", - value: true, - }, - ], - search: "", - tab: "spas", - pagination: { - number: 0, - size: 25, - }, - sort: { - field: "submissionDate", - order: "desc", - }, - code, - }), - ); - const { container } = renderWithQueryClientAndMemoryRouter(, routes, { - initialEntries: [ - { - pathname: "/dashboard", - search: `code=${code}&os=${queryStringMV}`, - }, - ], - }); + const { container } = setup([ + { + label: "RAI Withdraw Enabled", + field: "raiWithdrawEnabled", + component: "boolean", + prefix: "must", + type: "exists", + value: true, + }, + ]); expect(container.childNodes.length).toEqual(1); expect(container.firstChild.childNodes.length).toEqual(0); expect(screen.queryAllByRole("button")).toEqual([]); }); it("should display no chips if there are filters", () => { - const queryStringMV = LZ.compressToEncodedURIComponent( - JSON.stringify({ - filters: [], - search: "", - tab: "spas", - pagination: { - number: 0, - size: 25, - }, - sort: { - field: "submissionDate", - order: "desc", - }, - code, - }), - ); - const { container } = renderWithQueryClientAndMemoryRouter(, routes, { - initialEntries: [ - { - pathname: "/dashboard", - search: `code=${code}&os=${queryStringMV}`, - }, - ], - }); + const { container } = setup([]); expect(container.childNodes.length).toEqual(1); expect(container.firstChild.childNodes.length).toEqual(0); expect(screen.queryAllByRole("button")).toEqual([]); }); it("should handle deleting a chip", async () => { - const user = userEvent.setup(); - renderWithQueryClientAndMemoryRouter(, routes, routeOptions); + const { user } = setup(DEFAULT_FILTERS); await user.click(screen.getAllByRole("button")[0]); expect(screen.queryByText("State: Maryland, MD")).toBeNull(); expect(screen.getByText("Authority: CHIP SPA")).toBeInTheDocument(); @@ -552,48 +418,24 @@ describe("FilterChips", () => { }); it("should handle deleting a chip when the filter has multiple values", async () => { - const user = userEvent.setup(); - const queryStringMV = LZ.compressToEncodedURIComponent( - JSON.stringify({ - filters: [ - { - label: "State", - field: "state.keyword", - component: "multiSelect", - prefix: "must", - type: "terms", - value: ["MD", "OH"], - }, - ], - search: "", - tab: "spas", - pagination: { - number: 0, - size: 25, - }, - sort: { - field: "submissionDate", - order: "desc", - }, - code, - }), - ); - renderWithQueryClientAndMemoryRouter(, routes, { - initialEntries: [ - { - pathname: "/dashboard", - search: `code=${code}&os=${queryStringMV}`, - }, - ], - }); + const { user } = setup([ + { + label: "State", + field: "state.keyword", + component: "multiSelect", + prefix: "must", + type: "terms", + value: ["MD", "OH"], + }, + ]); await user.click(screen.getAllByRole("button")[1]); expect(screen.getByText("State: Maryland, MD")).toBeInTheDocument(); expect(screen.queryByText("State: Ohio, OH")).toBeNull(); }); it("should handle clearing all", async () => { - const user = userEvent.setup(); - renderWithQueryClientAndMemoryRouter(, routes, routeOptions); + const { user } = setup(DEFAULT_FILTERS); + screen.debug(); await user.click(screen.getByText("Clear All").parentElement); expect(screen.queryByText("State: Maryland, MD")).toBeNull(); expect(screen.queryByText("Authority: CHIP SPA")).toBeNull(); diff --git a/react-app/src/components/Opensearch/main/Filtering/Drawer/index.test.tsx b/react-app/src/components/Opensearch/main/Filtering/Drawer/index.test.tsx index e441e2a514..265e9e279e 100644 --- a/react-app/src/components/Opensearch/main/Filtering/Drawer/index.test.tsx +++ b/react-app/src/components/Opensearch/main/Filtering/Drawer/index.test.tsx @@ -1,53 +1,19 @@ import { describe, expect, it } from "vitest"; import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { renderWithQueryClientAndMemoryRouter } from "@/utils/test-helpers/renderForm"; -import LZ from "lz-string"; import { OsFilterDrawer } from "./index"; -import { FilterDrawerProvider } from "../FilterProvider"; import { opensearch } from "shared-types"; - -const routes = [ - { - path: "/dashboard", - element: ( - - - - ), - }, -]; -const code = "094230fe-a02f-45d7-a675-05876ab5d76a"; +import { renderFilterDrawer, getDashboardQueryString } from "@/utils/test-helpers"; const setup = ( filters: opensearch.Filterable[], tab: "spas" | "waivers", ) => { const user = userEvent.setup(); - const queryString = LZ.compressToEncodedURIComponent( - JSON.stringify({ - filters, - search: "", - tab, - pagination: { - number: 0, - size: 25, - }, - sort: { - field: "submissionDate", - order: "desc", - }, - code, - }), + const rendered = renderFilterDrawer( + , + getDashboardQueryString({ filters, tab }), ); - const rendered = renderWithQueryClientAndMemoryRouter(, routes, { - initialEntries: [ - { - pathname: "/dashboard", - search: `code=${code}&os=${queryString}`, - }, - ], - }); return { user, ...rendered, diff --git a/react-app/src/components/Opensearch/main/Filtering/Export/Export.test.tsx b/react-app/src/components/Opensearch/main/Filtering/Export/Export.test.tsx index b9133bce89..a800464eb7 100644 --- a/react-app/src/components/Opensearch/main/Filtering/Export/Export.test.tsx +++ b/react-app/src/components/Opensearch/main/Filtering/Export/Export.test.tsx @@ -1,87 +1,24 @@ import { describe, expect, it, vi } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { renderWithQueryClientAndMemoryRouter } from "@/utils/test-helpers/renderForm"; -import { getFilteredDocList } from "mocks"; -import { BLANK_VALUE } from "@/consts"; -import { OsExportData, OsTableColumn, FilterDrawerProvider } from "@/components"; -import LZ from "lz-string"; +import { OsExportData, OsTableColumn } from "@/components"; import { ExportToCsv } from "export-to-csv"; +import { getFilteredDocList } from "mocks"; +import { + renderFilterDrawer, + DEFAULT_COLUMNS, + HIDDEN_COLUMN, + NO_FIELD_COLUMN, + getDashboardQueryString, +} from "@/utils/test-helpers"; -const code = "094230fe-a02f-45d7-a675-05876ab5d76a"; -const columns: OsTableColumn[] = [ - { - props: { className: "w-[150px]" }, - field: "id.keyword", - label: "SPA ID", - locked: true, - transform: (data) => data.id ?? BLANK_VALUE, - cell: (data) => data.id ?? BLANK_VALUE, - }, - { - field: "state.keyword", - label: "State", - transform: (data) => data.state ?? BLANK_VALUE, - cell: (data) => data.state ?? BLANK_VALUE, - }, - { - field: "authority.keyword", - label: "Authority", - transform: (data) => data.authority ?? BLANK_VALUE, - cell: (data) => data.authority ?? BLANK_VALUE, - }, - { - field: "raiReceivedDate", - label: "Formal RAI Response", - cell: (data) => data.raiReceivedDate ?? BLANK_VALUE, - }, - { - field: "origin.keyword", - label: "Submission Source", - hidden: true, - transform: (data) => data.origin ?? BLANK_VALUE, - cell: (data) => data.origin ?? BLANK_VALUE, - }, -]; +const columns: OsTableColumn[] = [...DEFAULT_COLUMNS, NO_FIELD_COLUMN, HIDDEN_COLUMN]; const setup = (disabled?: boolean) => { const user = userEvent.setup(); - const queryString = LZ.compressToEncodedURIComponent( - JSON.stringify({ - filters: [], - search: "", - tab: "spas", - pagination: { - number: 0, - size: 25, - }, - sort: { - field: "submissionDate", - order: "desc", - }, - code, - }), - ); - const rendered = renderWithQueryClientAndMemoryRouter( + const rendered = renderFilterDrawer( , - [ - { - path: "/dashboard", - element: ( - - - - ), - }, - ], - { - initialEntries: [ - { - pathname: "/dashboard", - search: `code=${code}&os=${queryString}`, - }, - ], - }, + getDashboardQueryString(), ); return { user, diff --git a/react-app/src/components/Opensearch/main/Filtering/Filtering.test.tsx b/react-app/src/components/Opensearch/main/Filtering/Filtering.test.tsx index 3f3511668a..ef5e54375c 100644 --- a/react-app/src/components/Opensearch/main/Filtering/Filtering.test.tsx +++ b/react-app/src/components/Opensearch/main/Filtering/Filtering.test.tsx @@ -1,46 +1,19 @@ import { describe, expect, it, vi } from "vitest"; import { screen, within } from "@testing-library/react"; -import { renderWithQueryClientAndMemoryRouter } from "@/utils/test-helpers/renderForm"; import userEvent from "@testing-library/user-event"; -import { BLANK_VALUE } from "@/consts"; -import LZ from "lz-string"; -import { OsFiltering, OsTableColumn, OsProvider, FilterDrawerProvider } from "@/components"; -import { getFilteredItemList, getFilteredDocList } from "mocks"; +import { OsFiltering, OsTableColumn } from "@/components"; +import { getFilteredDocList } from "mocks"; import { opensearch } from "shared-types"; import { ExportToCsv } from "export-to-csv"; +import { + renderDashboard, + DEFAULT_COLUMNS, + HIDDEN_COLUMN, + getFilteredHits, + getDashboardQueryString, +} from "@/utils/test-helpers"; -const code = "094230fe-a02f-45d7-a675-05876ab5d76a"; -const items: opensearch.Hit[] = getFilteredItemList([ - "CHIP SPA", - "Medicaid SPA", -]).map((item) => ({ ...item, found: undefined }) as opensearch.Hit); -const defaultHits: opensearch.Hits = { - hits: items, - max_score: 5, - total: { value: items.length, relation: "eq" }, -}; -const defaultColumns: OsTableColumn[] = [ - { - props: { className: "w-[150px]" }, - field: "id.keyword", - label: "SPA ID", - locked: true, - transform: (data) => data.id ?? BLANK_VALUE, - cell: (data) => data.id ?? BLANK_VALUE, - }, - { - field: "state.keyword", - label: "State", - transform: (data) => data.state ?? BLANK_VALUE, - cell: (data) => data.state ?? BLANK_VALUE, - }, - { - field: "authority.keyword", - label: "Authority", - transform: (data) => data.authority ?? BLANK_VALUE, - cell: (data) => data.authority ?? BLANK_VALUE, - }, -]; +const defaultHits = getFilteredHits(["CHIP SPA", "Medicaid SPA"]); const setup = ( columns: OsTableColumn[], @@ -49,50 +22,14 @@ const setup = ( hits: opensearch.Hits = defaultHits, ) => { const user = userEvent.setup(); - const queryString = LZ.compressToEncodedURIComponent( - JSON.stringify({ - filters: [], - search: "", - tab: "spas", - pagination: { - number: 0, - size: 25, - }, - sort: { - field: "submissionDate", - order: "desc", - }, - code, - }), - ); - const rendered = renderWithQueryClientAndMemoryRouter( + const rendered = renderDashboard( , - [ - { - path: "/dashboard", - element: ( - - - - - - ), - }, - ], { - initialEntries: [ - { - pathname: "/dashboard", - search: `code=${code}&os=${queryString}`, - }, - ], + data: hits, + error: null, + isLoading: false, }, + getDashboardQueryString(), ); return { user, @@ -103,7 +40,7 @@ const setup = ( describe("Visibility button", () => { it("should display the filtering buttons", async () => { const onToggle = vi.fn(); - setup(defaultColumns, onToggle, false); + setup(DEFAULT_COLUMNS, onToggle, false); const search = screen.queryByLabelText("Search by Package ID, CPOC Name, or Submitter Name"); expect(search).toBeInTheDocument(); @@ -119,26 +56,7 @@ describe("Visibility button", () => { it("should display filtering button with hidden columns", async () => { const onToggle = vi.fn(); - setup( - [ - ...defaultColumns, - { - field: "authority.keyword", - label: "Authority", - transform: (data) => data.authority ?? BLANK_VALUE, - cell: (data) => data.authority ?? BLANK_VALUE, - }, - { - field: "origin.keyword", - label: "Submission Source", - hidden: true, - transform: (data) => data.origin ?? BLANK_VALUE, - cell: (data) => data.origin ?? BLANK_VALUE, - }, - ], - onToggle, - false, - ); + setup([...DEFAULT_COLUMNS, HIDDEN_COLUMN], onToggle, false); const search = screen.queryByLabelText("Search by Package ID, CPOC Name, or Submitter Name"); expect(search).toBeInTheDocument(); @@ -151,7 +69,7 @@ describe("Visibility button", () => { it("should display the filtering buttons with disabled search", async () => { const onToggle = vi.fn(); - setup(defaultColumns, onToggle, true); + setup(DEFAULT_COLUMNS, onToggle, true); const search = screen.queryByLabelText("Search by Package ID, CPOC Name, or Submitter Name"); expect(search).toBeInTheDocument(); @@ -164,7 +82,7 @@ describe("Visibility button", () => { it("should display the filtering buttons with Export disabled", async () => { const onToggle = vi.fn(); - setup(defaultColumns, onToggle, false, { + setup(DEFAULT_COLUMNS, onToggle, false, { hits: [], max_score: 5, total: { value: 0, relation: "eq" }, @@ -184,7 +102,7 @@ describe("Visibility button", () => { it("should handle searching", async () => { const user = userEvent.setup(); const onToggle = vi.fn(); - setup(defaultColumns, onToggle, false); + setup(DEFAULT_COLUMNS, onToggle, false); const search = screen.queryByLabelText("Search by Package ID, CPOC Name, or Submitter Name"); await user.type(search, "testing[Enter]"); @@ -194,7 +112,7 @@ describe("Visibility button", () => { it("should handle clicking the Columns button", async () => { const user = userEvent.setup(); const onToggle = vi.fn(); - setup(defaultColumns, onToggle, false); + setup(DEFAULT_COLUMNS, onToggle, false); expect(screen.queryByRole("dialog")).toBeNull(); await user.click(screen.queryByRole("button", { name: "Columns" })); @@ -208,7 +126,7 @@ describe("Visibility button", () => { it("should handle clicking the Filters button", async () => { const user = userEvent.setup(); const onToggle = vi.fn(); - setup(defaultColumns, onToggle, false); + setup(DEFAULT_COLUMNS, onToggle, false); const filters = screen.getByRole("button", { name: "Filters" }); expect(filters.getAttribute("data-state")).toEqual("closed"); @@ -225,7 +143,7 @@ describe("Visibility button", () => { })); const user = userEvent.setup(); const onToggle = vi.fn(); - setup(defaultColumns, onToggle, false); + setup(DEFAULT_COLUMNS, onToggle, false); await user.click(screen.getByRole("button", { name: "Export" })); expect(spy).toHaveBeenCalledWith(expected); diff --git a/react-app/src/components/Opensearch/main/Table/index.test.tsx b/react-app/src/components/Opensearch/main/Table/index.test.tsx index 0cc935085e..5d3f3bd392 100644 --- a/react-app/src/components/Opensearch/main/Table/index.test.tsx +++ b/react-app/src/components/Opensearch/main/Table/index.test.tsx @@ -1,57 +1,17 @@ import { describe, expect, it, vi } from "vitest"; import { screen } from "@testing-library/react"; -import { renderWithQueryClientAndMemoryRouter } from "@/utils/test-helpers/renderForm"; import userEvent from "@testing-library/user-event"; -import { BLANK_VALUE } from "@/consts"; -import LZ from "lz-string"; +import { OsTable, OsTableColumn } from "@/components"; import { opensearch } from "shared-types"; -import { OsTable, OsTableColumn, OsProvider, FilterDrawerProvider } from "@/components"; -import { getFilteredItemList } from "mocks"; +import { + renderDashboard, + getDashboardQueryString, + getFilteredHits, + DEFAULT_COLUMNS, + EMPTY_HITS, +} from "@/utils/test-helpers"; -const code = "094230fe-a02f-45d7-a675-05876ab5d76a"; -const items: opensearch.Hit[] = getFilteredItemList([ - "CHIP SPA", - "Medicaid SPA", -]).map((item) => ({ ...item, found: undefined }) as opensearch.Hit); -const defaultHits: opensearch.Hits = { - hits: items, - max_score: 5, - total: { value: items.length, relation: "eq" }, -}; -const defaultColumns: OsTableColumn[] = [ - { - props: { className: "w-[150px]" }, - field: "id.keyword", - label: "SPA ID", - locked: true, - transform: (data) => data.id ?? BLANK_VALUE, - cell: (data) => data.id ?? BLANK_VALUE, - }, - { - field: "state.keyword", - label: "State", - transform: (data) => data.state ?? BLANK_VALUE, - cell: (data) => data.state ?? BLANK_VALUE, - }, - { - field: "authority.keyword", - label: "Authority", - transform: (data) => data.authority ?? BLANK_VALUE, - cell: (data) => data.authority ?? BLANK_VALUE, - }, - { - field: "raiReceivedDate", - label: "Formal RAI Response", - cell: (data) => data.raiReceivedDate ?? BLANK_VALUE, - }, - { - field: "origin.keyword", - label: "Submission Source", - hidden: true, - transform: (data) => data.origin ?? BLANK_VALUE, - cell: (data) => data.origin ?? BLANK_VALUE, - }, -]; +const defaultHits = getFilteredHits(["CHIP SPA", "Medicaid SPA"]); const setup = ( columns: OsTableColumn[], @@ -59,50 +19,14 @@ const setup = ( hits: opensearch.Hits, ) => { const user = userEvent.setup(); - const queryString = LZ.compressToEncodedURIComponent( - JSON.stringify({ - filters: [], - search: "", - tab: "spas", - pagination: { - number: 0, - size: 25, - }, - sort: { - field: "submissionDate", - order: "desc", - }, - code, - }), - ); - const rendered = renderWithQueryClientAndMemoryRouter( + const rendered = renderDashboard( , - [ - { - path: "/dashboard", - element: ( - - - - - - ), - }, - ], { - initialEntries: [ - { - pathname: "/dashboard", - search: `code=${code}&os=${queryString}`, - }, - ], + data: hits, + isLoading: false, + error: null, }, + getDashboardQueryString(), ); return { user, @@ -113,33 +37,27 @@ const setup = ( describe("", () => { it("should display the table with values", () => { const onToggle = vi.fn(); - setup(defaultColumns, onToggle, defaultHits); + setup(DEFAULT_COLUMNS, onToggle, defaultHits); // Check that the correct column headers appear - expect(screen.getAllByRole("columnheader").length).toEqual(4); + expect(screen.getAllByRole("columnheader").length).toEqual(3); expect(screen.getByText("SPA ID", { selector: "th>div" })); expect(screen.getByText("State", { selector: "th>div" })); expect(screen.getByText("Authority", { selector: "th>div" })); - expect(screen.getByText("Formal RAI Response", { selector: "th>div" })); // Check that the correct amount rows appear - expect(screen.getAllByRole("row").length).toEqual(items.length + 1); // add 1 for header + expect(screen.getAllByRole("row").length).toEqual(defaultHits.hits.length + 1); // add 1 for header }); it("should display the table with no values", () => { const onToggle = vi.fn(); - setup(defaultColumns, onToggle, { - hits: [], - max_score: 5, - total: { value: 0, relation: "eq" }, - }); + setup(DEFAULT_COLUMNS, onToggle, EMPTY_HITS); // Check that the correct column headers appear - expect(screen.getAllByRole("columnheader").length).toEqual(4); + expect(screen.getAllByRole("columnheader").length).toEqual(3); expect(screen.getByText("SPA ID", { selector: "th>div" })); expect(screen.getByText("State", { selector: "th>div" })); expect(screen.getByText("Authority", { selector: "th>div" })); - expect(screen.getByText("Formal RAI Response", { selector: "th>div" })); expect(screen.getByText("No Results Found")).toBeInTheDocument(); expect( diff --git a/react-app/src/components/RHF/tests/FieldArray.test.tsx b/react-app/src/components/RHF/tests/FieldArray.test.tsx index bc5348a1b3..992da7ea02 100644 --- a/react-app/src/components/RHF/tests/FieldArray.test.tsx +++ b/react-app/src/components/RHF/tests/FieldArray.test.tsx @@ -4,7 +4,7 @@ import { RHFSlot } from ".."; import { Form, FormField } from "../../Inputs"; import { Control, useForm } from "react-hook-form"; import { DefaultFieldGroupProps, RHFSlotProps } from "shared-types"; -import { renderWithQueryClient } from "@/utils/test-helpers/renderForm"; +import { renderWithQueryClient } from "@/utils/test-helpers"; import userEvent from "@testing-library/user-event"; const TestWrapper = (props: RHFSlotProps & { defaultValues?: any }) => { diff --git a/react-app/src/components/RHF/tests/NameGen.test.tsx b/react-app/src/components/RHF/tests/NameGen.test.tsx index 51b55bd0f1..9b103615df 100644 --- a/react-app/src/components/RHF/tests/NameGen.test.tsx +++ b/react-app/src/components/RHF/tests/NameGen.test.tsx @@ -1,6 +1,6 @@ import { describe, test, expect } from "vitest"; import { screen } from "@testing-library/react"; -import { renderWithQueryClient } from "@/utils/test-helpers/renderForm"; +import { renderWithQueryClient } from "@/utils/test-helpers"; import { RHFDocument } from "../."; import { Form } from "../../Inputs"; import { useForm } from "react-hook-form"; diff --git a/react-app/src/components/RHF/tests/Section.test.tsx b/react-app/src/components/RHF/tests/Section.test.tsx index c0f54b449a..04dad15ad9 100644 --- a/react-app/src/components/RHF/tests/Section.test.tsx +++ b/react-app/src/components/RHF/tests/Section.test.tsx @@ -1,6 +1,6 @@ import { describe, test, expect } from "vitest"; import { screen } from "@testing-library/react"; -import { renderWithQueryClient } from "@/utils/test-helpers/renderForm"; +import { renderWithQueryClient } from "@/utils/test-helpers"; import { RHFDocument, documentInitializer } from ".."; import { Form } from "../../Inputs"; import { useForm } from "react-hook-form"; diff --git a/react-app/src/components/RHF/tests/Slot.test.tsx b/react-app/src/components/RHF/tests/Slot.test.tsx index 0dac6a0bc8..e751d640b8 100644 --- a/react-app/src/components/RHF/tests/Slot.test.tsx +++ b/react-app/src/components/RHF/tests/Slot.test.tsx @@ -1,6 +1,6 @@ import { describe, test, expect } from "vitest"; import { screen } from "@testing-library/react"; -import { renderWithQueryClient } from "@/utils/test-helpers/renderForm"; +import { renderWithQueryClient } from "@/utils/test-helpers"; import { RHFSlot } from "../."; import { Form, FormField } from "../../Inputs"; import { Control, useForm } from "react-hook-form"; diff --git a/react-app/src/components/RHF/tests/SlotField.test.tsx b/react-app/src/components/RHF/tests/SlotField.test.tsx index 7846026822..2e325b8d44 100644 --- a/react-app/src/components/RHF/tests/SlotField.test.tsx +++ b/react-app/src/components/RHF/tests/SlotField.test.tsx @@ -1,6 +1,6 @@ import { describe, test, expect } from "vitest"; import { screen } from "@testing-library/react"; -import { renderWithQueryClient } from "@/utils/test-helpers/renderForm"; +import { renderWithQueryClient } from "@/utils/test-helpers"; import { RHFSlot } from "../."; import { Form, FormField } from "../../Inputs"; import { Control, useForm } from "react-hook-form"; diff --git a/react-app/src/features/profile/profile.test.tsx b/react-app/src/features/profile/profile.test.tsx index 07249a1468..2d36a7e34f 100644 --- a/react-app/src/features/profile/profile.test.tsx +++ b/react-app/src/features/profile/profile.test.tsx @@ -2,7 +2,7 @@ import { screen, waitFor } from "@testing-library/react"; import { describe, test, expect, afterEach } from "vitest"; import { Profile } from "."; import { setMockUsername, setDefaultStateSubmitter, multiStateSubmitter } from "mocks"; -import { renderWithQueryClient } from "@/utils/test-helpers/renderForm"; +import { renderWithQueryClient } from "@/utils/test-helpers"; describe("Profile", () => { afterEach(() => { diff --git a/react-app/src/hooks/UseReadOnlyUser.test.tsx b/react-app/src/hooks/UseReadOnlyUser.test.tsx index 33a2a6b833..be82371c88 100644 --- a/react-app/src/hooks/UseReadOnlyUser.test.tsx +++ b/react-app/src/hooks/UseReadOnlyUser.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, afterAll } from "vitest"; import { renderHook, waitFor } from "@testing-library/react"; import { useReadOnlyUser } from "./useReadOnlyUser"; -import { queryClientWrapper } from "@/utils/test-helpers/renderForm"; +import { queryClientWrapper } from "@/utils/test-helpers"; import { setMockUsername, setDefaultReviewer, setDefaultStateSubmitter } from "mocks"; const setup = async () => { diff --git a/react-app/src/utils/TestWrapper.tsx b/react-app/src/utils/TestWrapper.tsx deleted file mode 100644 index 2beeca3597..0000000000 --- a/react-app/src/utils/TestWrapper.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { createBrowserRouter, RouterProvider } from "react-router"; - -const queryClient = new QueryClient(); - -const router = (Element: any) => - createBrowserRouter([ - { - path: "/", - element: , - }, - ]); - -export const TestWrapper = ({ children }: any) => { - return ( - - - - ); -}; diff --git a/react-app/src/utils/index.ts b/react-app/src/utils/index.ts index bc66341c81..3665d095bd 100644 --- a/react-app/src/utils/index.ts +++ b/react-app/src/utils/index.ts @@ -3,7 +3,6 @@ export * from "./stateNames"; export * from "./textHelpers"; export * from "./labelMappers"; export * from "./user"; -export * from "./TestWrapper"; export * from "./crumbs"; export * from "./labels"; export * from "./utils"; diff --git a/react-app/src/utils/test-helpers/dashboard.tsx b/react-app/src/utils/test-helpers/dashboard.tsx new file mode 100644 index 0000000000..21630c5708 --- /dev/null +++ b/react-app/src/utils/test-helpers/dashboard.tsx @@ -0,0 +1,179 @@ +import { BLANK_VALUE } from "@/consts"; +import LZ from "lz-string"; +import React, { ReactElement } from "react"; +import { + ContextState, + OsTableColumn, + OsUrlState, + OsProvider, + FilterDrawerProvider, +} from "@/components"; +import { opensearch } from "shared-types"; +import { getFilteredItemList } from "mocks"; +import { renderWithQueryClientAndMemoryRouter } from "./render"; + +export const DEFAULT_COLUMNS: OsTableColumn[] = [ + { + props: { className: "w-[150px]" }, + field: "id.keyword", + label: "SPA ID", + locked: true, + transform: (data) => data.id ?? BLANK_VALUE, + cell: (data) => data.id ?? BLANK_VALUE, + }, + { + field: "state.keyword", + label: "State", + transform: (data) => data.state ?? BLANK_VALUE, + cell: (data) => data.state ?? BLANK_VALUE, + }, + { + field: "authority.keyword", + label: "Authority", + transform: (data) => data.authority ?? BLANK_VALUE, + cell: (data) => data.authority ?? BLANK_VALUE, + }, +]; + +export const HIDDEN_COLUMN: OsTableColumn = { + field: "origin.keyword", + label: "Submission Source", + hidden: true, + transform: (data) => data.origin ?? BLANK_VALUE, + cell: (data) => data.origin ?? BLANK_VALUE, +}; + +export const NO_FIELD_COLUMN: OsTableColumn = { + field: "raiReceivedDate", + label: "Formal RAI Response", + cell: (data) => data.raiReceivedDate ?? BLANK_VALUE, +}; + +export const DEFAULT_FILTERS: opensearch.Filterable[] = [ + { + label: "State", + field: "state.keyword", + component: "multiSelect", + prefix: "must", + type: "terms", + value: ["MD"], + }, + { + label: "Authority", + field: "authority.keyword", + component: "multiCheck", + prefix: "must", + type: "terms", + value: ["CHIP SPA"], + }, + { + label: "RAI Withdraw Enabled", + field: "raiWithdrawEnabled", + component: "boolean", + prefix: "must", + type: "match", + value: true, + }, + { + label: "Final Disposition", + field: "finalDispositionDate", + component: "dateRange", + prefix: "must", + type: "range", + value: { + gte: "2025-01-01T00:00:00.000Z", + lte: "2025-01-01T23:59:59.999Z", + }, + }, +]; + +export const EMPTY_HITS: opensearch.Hits = { + hits: [], + max_score: 5, + total: { value: 0, relation: "eq" }, +}; + +export const getFilteredHits = ( + authorities: string[], +): opensearch.Hits => { + const items = getFilteredItemList(authorities).map( + (item) => ({ ...item, found: undefined }) as opensearch.Hit, + ); + return { + hits: items, + max_score: 5, + total: { value: items.length, relation: "eq" }, + }; +}; + +export const URL_CODE = "094230fe-a02f-45d7-a675-05876ab5d76a"; + +export const getDashboardQueryString = ({ + filters, + search, + tab, + pagination, + sort, +}: Partial = {}) => { + const queryString = LZ.compressToEncodedURIComponent( + JSON.stringify({ + filters: filters || [], + search: search || "", + tab: tab || "spas", + pagination: pagination || { + number: 0, + size: 25, + }, + sort: sort || { + field: "submissionDate", + order: "desc", + }, + URL_CODE, + }), + ); + return queryString; +}; + +export const renderDashboard = (element: ReactElement, value: ContextState, queryString: string) => + renderWithQueryClientAndMemoryRouter( + element, + [ + { + path: "/dashboard", + element: ( + + {element} + + ), + }, + ], + { + initialEntries: [ + { + pathname: "/dashboard", + search: `code=${URL_CODE}&os=${queryString}`, + }, + ], + }, + ); + +export const renderFilterDrawer = (element: ReactElement, queryString: string) => { + console.log({ element, queryString }); + return renderWithQueryClientAndMemoryRouter( + element, + [ + { + path: "/dashboard", + element: {element}, + }, + ], + { + initialEntries: [ + { + pathname: "/dashboard", + search: `code=${URL_CODE}&os=${queryString}`, + }, + ], + }, + ); +}; diff --git a/react-app/src/utils/test-helpers/index.tsx b/react-app/src/utils/test-helpers/index.tsx new file mode 100644 index 0000000000..52e9b37d5b --- /dev/null +++ b/react-app/src/utils/test-helpers/index.tsx @@ -0,0 +1,5 @@ +export * from "./dashboard"; +export * from "./render"; +export * from "./renderForm"; +export * from "./skipCleanup"; +export * from "./uploadFiles"; diff --git a/react-app/src/utils/test-helpers/render.tsx b/react-app/src/utils/test-helpers/render.tsx new file mode 100644 index 0000000000..0c83f641f1 --- /dev/null +++ b/react-app/src/utils/test-helpers/render.tsx @@ -0,0 +1,51 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React, { ReactElement } from "react"; +import { createMemoryRouter, MemoryRouter, RouterProvider } from "react-router"; +import { render } from "@testing-library/react"; + +export const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + logger: { + log: console.log, + warn: console.warn, + error: () => {}, + }, + }); + +export const queryClientWrapper = ({ children }: { children: ReactElement }) => ( + {children} +); + +export const renderWithQueryClient = (element: ReactElement) => + render(element, { + wrapper: ({ children }) => ( + + {children}, + + ), + }); + +export const renderWithMemoryRouter = ( + element: ReactElement, + ...routing: Parameters +) => + render(element, { + wrapper: () => , + }); + +export const renderWithQueryClientAndMemoryRouter = ( + element: ReactElement, + ...routing: Parameters +) => + render(element, { + wrapper: () => ( + + + + ), + }); diff --git a/react-app/src/utils/test-helpers/renderForm.tsx b/react-app/src/utils/test-helpers/renderForm.tsx index f4cb3f161c..b3ca3addcd 100644 --- a/react-app/src/utils/test-helpers/renderForm.tsx +++ b/react-app/src/utils/test-helpers/renderForm.tsx @@ -1,48 +1,8 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, waitForElementToBeRemoved } from "@testing-library/react"; +import { waitForElementToBeRemoved } from "@testing-library/react"; import React, { ReactElement } from "react"; -import { MemoryRouter, createMemoryRouter, RouterProvider } from "react-router"; import items from "mocks/data/items"; import { Authority } from "shared-types"; - -const createTestQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - logger: { - log: console.log, - warn: console.warn, - error: () => {}, - }, - }); - -export const queryClientWrapper = ({ children }: { children: ReactElement }) => ( - {children} -); - -export const renderWithQueryClient = (element: ReactElement) => - render(element, { - wrapper: ({ children }) => ( - - {children}, - - ), - }); - -export const renderWithQueryClientAndMemoryRouter = ( - element: ReactElement, - ...routing: Parameters -) => - render(element, { - wrapper: () => ( - - - - ), - }); +import { renderWithQueryClient, renderWithQueryClientAndMemoryRouter } from "./render"; export const renderFormAsync = async (form: ReactElement) => { const container = await renderWithQueryClient(form); diff --git a/vitest.config.ts b/vitest.config.ts index 393b7f39f4..47d5bd70c1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,33 +15,33 @@ export default defineConfig({ ".build_run", "build_run", ".cdk", + "bin/app.ts", + "bin/cli/**", "docs/**", - "**/vitest.setup.ts", - "lib/libs/webforms/**", "lib/libs/email/mock-data/**", - "react-app/src/main.tsx", - "react-app/src/features/webforms/**", - "react-app/src/components/ScrollToTop/**", - "**/*.test.ts", - "**/*.test.tsx", - "TestWrapper.tsx", - "lib/stacks/**", - "lib/packages/eslint-config-custom/**", - "lib/packages/eslint-config-custom-server/**", + "lib/libs/webforms/**", "lib/local-aspects", "lib/local-constructs/**", - "bin/cli/**", - "bin/app.ts", + "lib/packages/eslint-config-custom/**", + "lib/packages/eslint-config-custom-server/**", + "lib/stacks/**", + "mocks/**", + "node_modules/**", + "**/node_modules/**", + "react-app/src/components/ScrollToTop/**", + "react-app/src/features/webforms/**", + "react-app/src/main.tsx", + "react-app/src/utils/test-helpers/**", + "test/e2e/**", "vitest.workspace.ts", "**/*/.eslintrc.{ts,js,cjs}", "**/*.config.{ts,js,cjs}", - "**/coverage/**", - "test/e2e/**", - "mocks/**", "**/*.js", + "**/*.test.ts", + "**/*.test.tsx", "**/assets/**", - "node_modules/**", - "**/node_modules/**", + "**/coverage/**", + "**/vitest.setup.ts", ], }, }, From b6c09a7206ed0ad94e5d494d90af613785303aff Mon Sep 17 00:00:00 2001 From: Benjamin Paige Date: Thu, 30 Jan 2025 08:43:09 -0700 Subject: [PATCH 2/2] feat:(allstate): Add allstate users to withdraw package state users (#1088) * Update * Update * add test email to cc in dev * add logic * add check for valid email * add update item in withdraw email logic * feat(lambda): Create split SPAs 2 (#1085) * update id broken * ad dlog * log * log * adjust logic * hm * why * log getpackage * logs * revert? * log os * log * log * client.get stuck * fix * fix * log error * what * revert * revert * wip * validate id * zod error * await * fix query * fix query again * fix query * try keyword * try * query * test * log * last index * logic * test * cleanup * edit names and sinkmainprocessor * weird * log * fix * huh * log * bug * admin change schema * missing in schema * idToBeUpdated * add changelog type * zod typo? * facepalm * admin package activity * log changelog pushing, add success response * debug changelog * log docs * change split spa changelog logic * add tests wip * clean up and fix error bubbling * reference baseschema id shape * fix import errors and tests wip * test after refactor bug * test fix * fix * rm logs and update comments * change body parsing, update timestamps, mod admin schema * fix timestamp * hm * was it this line * test change * revert * log not incrementing * log fix * log hits * m not showing in hits * look for m * query size? * syntax fix * rm logs and test admin schema change again * revert * import error * import again * revert * remove * topic name not defined * rm unnecessary packageId check and wip tests * reduce query size * reduce query size? * change order of pushing? * remove query size * consistent date time * update test fix error rejection * add regexp to query type for split spas * modify mocked os search func to accomodate for split spas * sorry adding split spas into mock items * put query size back * tears. * update test to use requestContext * revert packageExists * correct timestamp and add mockEvent for upload sub docs * remove example json field * mod example json * add logic * add additional types * Revert "Merge remote-tracking branch 'origin' into allstate-james" This reverts commit a4ed2372648274cccc9c498bf3f89b537ff35077, reversing changes made to 4c1ea45a7880c4ddfcde369c6ea237dfbdbb7e82. --------- Co-authored-by: tiffanyvu * sequential processing * Update sinkMainProcessors.test.ts * Update sinkMainProcessors.test.ts * Update sinkMainProcessors.test.ts * Update sinkMainProcessors.test.ts * Update sinkMainProcessors.test.ts * Update sinkMainProcessors.test.ts * Update sinkMainProcessors.test.ts * Update sinkMainProcessors.test.ts * add brians suggestion * logs * add undefined email logic * cleanup * Update email-components.tsx --------- Co-authored-by: James Dinh Co-authored-by: James Dinh Co-authored-by: tiffanyvu --- lib/lambda/processEmails.ts | 79 +++++++++++++------ lib/libs/email/content/email-components.tsx | 41 +++++++--- .../email/content/withdrawPackage/index.tsx | 8 +- lib/libs/email/getAllStateUsers.ts | 53 ++++++++++--- .../shared-types/opensearch/main/index.ts | 1 + 5 files changed, 136 insertions(+), 46 deletions(-) diff --git a/lib/lambda/processEmails.ts b/lib/lambda/processEmails.ts index d5fcfaa884..8950b3d0d8 100644 --- a/lib/lambda/processEmails.ts +++ b/lib/lambda/processEmails.ts @@ -14,7 +14,6 @@ import { getEmailTemplates, getAllStateUsers } from "libs/email"; import * as os from "libs/opensearch-lib"; import { EMAIL_CONFIG, getCpocEmail, getSrtEmails } from "libs/email/content/email-components"; import { htmlToText, HtmlToTextOptions } from "html-to-text"; -import pLimit from "p-limit"; import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; import { getOsNamespace } from "libs/utils"; @@ -37,6 +36,13 @@ interface ProcessEmailConfig { isDev: boolean; } +interface EmailTemplate { + to: string[]; + cc?: string[]; + subject: string; + body: string; +} + export const handler: Handler = async (event) => { const requiredEnvVars = [ "emailAddressLookupSecretName", @@ -140,6 +146,11 @@ export async function processRecord(kafkaRecord: KafkaRecord, config: ProcessEma return; } + if (item._source.withdrawEmailSent) { + console.log("Withdraw email previously sent"); + return; + } + const recordToPass = { timestamp, ...safeSeatoolRecord.data, @@ -151,6 +162,18 @@ export async function processRecord(kafkaRecord: KafkaRecord, config: ProcessEma }; await processAndSendEmails(recordToPass as Events[keyof Events], safeID, config); + + const indexObject = { + index: getOsNamespace("main"), + id: safeID, + body: { + doc: { + withdrawEmailSent: true, + }, + }, + }; + + await os.updateData(config.osDomain, indexObject); } catch (error) { console.error("Error processing record:", JSON.stringify(error, null, 2)); throw error; @@ -210,7 +233,7 @@ export async function processAndSendEmails( if (!templates) { console.log( - `The kafka record has an event type that does not have email support. event: ${record.event}. Doing nothing.`, + `The kafka record has an event type that does not have email support. event: ${record.event}. Doing nothing.`, ); return; } @@ -251,9 +274,12 @@ export async function processAndSendEmails( }; console.log("Template variables:", JSON.stringify(templateVariables, null, 2)); - const limit = pLimit(5); // Limit concurrent emails - const sendEmailPromises = templates.map((template) => - limit(async () => { + + const results = []; + + // Process templates sequentially + for (const template of templates) { + try { const filledTemplate = await template(templateVariables); validateEmailTemplate(filledTemplate); const params = createEmailParams( @@ -262,34 +288,43 @@ export async function processAndSendEmails( config.applicationEndpointUrl, config.isDev, ); - try { - await sendEmail(params, config.region); - } catch (error) { - console.error("Error sending email:", error); - throw error; - } - }), - ); - try { - await Promise.all(sendEmailPromises); - } catch (error) { - console.error("Error sending emails:", error); - throw error; + const result = await sendEmail(params, config.region); + results.push({ success: true, result }); + console.log(`Successfully sent email for template: ${JSON.stringify(result)}`); + } catch (error) { + console.error("Error processing template:", error); + results.push({ success: false, error }); + // Continue with next template instead of throwing + } + } + + // Log final results + const successCount = results.filter((r) => r.success).length; + const failureCount = results.filter((r) => !r.success).length; + + console.log(`Email sending complete. Success: ${successCount}, Failures: ${failureCount}`); + + // If all emails failed, throw an error to trigger retry/DLQ logic + if (failureCount === templates.length) { + throw new Error(`All ${failureCount} email(s) failed to send`); } + + return results; } export function createEmailParams( - filledTemplate: any, + filledTemplate: EmailTemplate, sourceEmail: string, baseUrl: string, isDev: boolean, ): SendEmailCommandInput { - const params = { + const params: SendEmailCommandInput = { Destination: { ToAddresses: filledTemplate.to, - CcAddresses: filledTemplate.cc, - BccAddresses: isDev ? [`State Submitter <${EMAIL_CONFIG.DEV_EMAIL}>`] : [], // this is so emails can be tested in dev as they should have the correct recipients but be blind copied on all emails on dev + CcAddresses: isDev + ? [...(filledTemplate.cc || []), `State Submitter <${EMAIL_CONFIG.DEV_EMAIL}>`] + : filledTemplate.cc, }, Message: { Body: { diff --git a/lib/libs/email/content/email-components.tsx b/lib/libs/email/content/email-components.tsx index f0ffde4a88..bbfa311736 100644 --- a/lib/libs/email/content/email-components.tsx +++ b/lib/libs/email/content/email-components.tsx @@ -308,11 +308,17 @@ const WithdrawRAI: React.FC = ({ variables, relatedEvent }) => const getCpocEmail = (item?: os.main.ItemResult): string[] => { try { - if (item?._source?.leadAnalystEmail && item?._source?.leadAnalystName) { - const cpocEmail = `${item._source.leadAnalystName} <${item._source.leadAnalystEmail}>`; - return [cpocEmail]; + const email = item?._source?.leadAnalystEmail; + const name = item?._source?.leadAnalystName; + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + if (!email || !emailRegex.test(email)) { + console.error(`Invalid or missing email for item: ${JSON.stringify(item?._source, null, 2)}`); + return []; } - return []; + + return [`${name} <${email}>`]; } catch (e) { console.error("Error getting CPOC email", e); return []; @@ -321,12 +327,29 @@ const getCpocEmail = (item?: os.main.ItemResult): string[] => { const getSrtEmails = (item?: os.main.ItemResult): string[] => { try { - if (item?._source?.reviewTeam && item._source.reviewTeam.length > 0) { - return item._source.reviewTeam.map( - (reviewer: { name: string; email: string }) => `${reviewer.name} <${reviewer.email}>`, - ); + const reviewTeam = item?._source?.reviewTeam; + + // Email validation regex + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + if (!reviewTeam || reviewTeam.length === 0) { + return []; } - return []; + + return reviewTeam + .map((reviewer: { name: string; email: string }) => { + const { name, email } = reviewer; + + if (!email || !emailRegex.test(email)) { + console.error( + `Invalid or missing email for reviewer: ${JSON.stringify(reviewer, null, 2)}`, + ); + return null; + } + + return `${name} <${email}>`; + }) + .filter((email): email is string => email !== null); } catch (e) { console.error("Error getting SRT emails", e); return []; diff --git a/lib/libs/email/content/withdrawPackage/index.tsx b/lib/libs/email/content/withdrawPackage/index.tsx index 6b7b3f89ed..bb196dabf4 100644 --- a/lib/libs/email/content/withdrawPackage/index.tsx +++ b/lib/libs/email/content/withdrawPackage/index.tsx @@ -72,7 +72,9 @@ export const withdrawPackage: AuthoritiesWithUserTypesTemplate = { variables: Events["WithdrawPackage"] & CommonEmailVariables & { emails: EmailAddresses }, ) => { return { - to: [`${variables.submitterName} <${variables.submitterEmail}>`], // TODO: change to ALL state users + to: variables.allStateUsersEmails || [ + `${variables.submitterName} <${variables.submitterEmail}>`, + ], subject: `Waiver Package ${variables.id} Withdraw Request`, body: await render(), }; @@ -97,7 +99,9 @@ export const withdrawPackage: AuthoritiesWithUserTypesTemplate = { variables: Events["WithdrawPackage"] & CommonEmailVariables & { emails: EmailAddresses }, ) => { return { - to: [`${variables.submitterName} <${variables.submitterEmail}>`], // TODO: change to ALL state users + to: variables.allStateUsersEmails || [ + `${variables.submitterName} <${variables.submitterEmail}>`, + ], subject: `Waiver Package ${variables.id} Withdraw Request`, body: await render(), }; diff --git a/lib/libs/email/getAllStateUsers.ts b/lib/libs/email/getAllStateUsers.ts index d0f7cf3bc9..91313686f2 100644 --- a/lib/libs/email/getAllStateUsers.ts +++ b/lib/libs/email/getAllStateUsers.ts @@ -3,6 +3,7 @@ import { ListUsersCommand, ListUsersCommandInput, ListUsersCommandOutput, + AttributeType, } from "@aws-sdk/client-cognito-identity-provider"; export type StateUser = { @@ -12,6 +13,14 @@ export type StateUser = { formattedEmailAddress: string; }; +type CognitoUserAttributes = { + email?: string; + given_name?: string; + family_name?: string; + "custom:state"?: string; + [key: string]: string | undefined; +}; + export const getAllStateUsers = async ({ userPoolId, state, @@ -33,26 +42,44 @@ export const getAllStateUsers = async ({ if (!response.Users || response.Users.length === 0) { return []; } + const filteredStateUsers = response.Users.filter((user) => { - const stateAttribute = user.Attributes?.find((attr) => attr.Name === "custom:state"); + const stateAttribute = user.Attributes?.find( + (attr): attr is AttributeType => attr.Name === "custom:state" && attr.Value !== undefined, + ); return stateAttribute?.Value?.split(",").includes(state); }).map((user) => { - const attributes = user.Attributes?.reduce( - (acc, attr) => { - acc[attr.Name as any] = attr.Value; - return acc; - }, - {} as Record, - ); + const attributes = user.Attributes?.reduce((acc, attr) => { + if (attr.Name && attr.Value) { + acc[attr.Name] = attr.Value; + } + return acc; + }, {}); + + // Skip users without valid email components + if (!attributes?.email) { + console.error(`No email found for user: ${JSON.stringify(user, null, 2)}`); + return null; + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(attributes.email)) { + console.error(`Invalid email format for user: ${attributes.email}`); + return null; + } + + const formattedEmailAddress = `${attributes.given_name} ${attributes.family_name} <${attributes.email}>`; + return { - firstName: attributes?.["given_name"], - lastName: attributes?.["family_name"], - email: attributes?.["email"], - formattedEmailAddress: `${attributes?.["given_name"]} ${attributes?.["family_name"]} <${attributes?.["email"]}>`, + firstName: attributes.given_name ?? "", + lastName: attributes.family_name ?? "", + email: attributes.email, + formattedEmailAddress, }; }); - return filteredStateUsers as StateUser[]; + return filteredStateUsers.filter((user): user is StateUser => user !== null); } catch (error) { console.error("Error fetching users:", error); throw new Error("Error fetching users"); diff --git a/lib/packages/shared-types/opensearch/main/index.ts b/lib/packages/shared-types/opensearch/main/index.ts index 489efe900d..45f1bbb59a 100644 --- a/lib/packages/shared-types/opensearch/main/index.ts +++ b/lib/packages/shared-types/opensearch/main/index.ts @@ -67,6 +67,7 @@ export type Document = AppkDocument & changeMade?: string; idToBeUpdated?: string; mockEvent?: string; + withdrawEmailSent?: boolean; }; export type Response = Res;