From 22f88373213c6a6f56228b116eb007a5c8a14a5f Mon Sep 17 00:00:00 2001 From: David Graham Date: Mon, 18 Dec 2023 16:04:44 -0500 Subject: [PATCH] Update test suite to use our new RTK query focused hooks implement basic tagging in RTK query endpoints for caching behavior replace useHtApi hook usage with RTK query endpoint, remove unused useHtApi hook remove old browser setup worker which was not doing anything reorganize the redux store remove old profile slice --- client/src/components/Auth/LoginForm.tsx | 9 +- client/src/components/Layout/PrivateRoute.tsx | 3 +- .../src/components/Layout/TopNav/TopNav.tsx | 4 +- .../ManifestCancelBtn.tsx | 0 .../{Buttons => Actions}/ManifestEditBtn.tsx | 0 .../ManifestFABs.spec.tsx | 6 +- .../{Buttons => Actions}/ManifestFABs.tsx | 4 +- .../{Buttons => Actions}/ManifestSaveBtn.tsx | 0 .../Handler/Search/HandlerSearchForm.spec.tsx | 76 ++++----- .../Handler/Search/HandlerSearchForm.tsx | 17 +- .../src/components/Manifest/ManifestForm.tsx | 34 +++- .../QuickerSign/SignBtn/QuickSignBtn.spec.tsx | 86 +++------- .../QuickerSign/SignBtn/QuickSignBtn.tsx | 25 ++- .../Manifest/SiteSelect/SiteSelect.spec.tsx | 26 +-- .../Manifest/SiteSelect/SiteSelect.tsx | 22 ++- client/src/components/Org/OrgSitesTable.tsx | 3 +- .../components/RcraProfile/RcraProfile.tsx | 4 +- .../RcraApiUserBtn/RcraApiUserBtn.spec.tsx | 13 +- .../SyncManifestBtn/SyncManifestBtn.spec.tsx | 26 +-- .../src/components/User/UserInfoForm.spec.tsx | 31 +--- .../src/features/Dashboard/Dashboard.spec.tsx | 35 ++-- .../MmanifestDetails/ManifestDetails.tsx | 18 +-- .../features/NewManifest/NewManifest.spec.tsx | 112 +++++-------- .../src/features/NewManifest/NewManifest.tsx | 27 +++- .../src/features/SiteList/SiteList.spec.tsx | 18 +-- client/src/hooks/index.ts | 9 +- client/src/hooks/useHtAPI/useHtApi.spec.tsx | 86 ---------- client/src/hooks/useHtAPI/useHtApi.tsx | 33 ---- .../src/store/authSlice/auth.slice.spec.tsx | 86 ++++++++++ client/src/store/authSlice/auth.slice.ts | 54 +++++++ .../{haztrakApiSlice.ts => htApi.slice.ts} | 26 ++- client/src/store/index.ts | 30 ++-- .../store/profileSlice/profile.slice.spec.tsx | 91 ----------- .../src/store/profileSlice/profile.slice.ts | 149 ------------------ client/src/store/rootStore.ts | 12 +- .../src/store/userSlice/user.slice.spec.tsx | 60 +++++++ client/src/store/userSlice/user.slice.ts | 139 +++++++--------- client/src/test-utils/fixtures/index.ts | 1 + client/src/test-utils/fixtures/mockUser.ts | 57 +++++++ client/src/test-utils/{index.tsx => index.ts} | 0 client/src/test-utils/mock/browser.js | 4 - .../mock/{handlers.ts => htApiMocks.ts} | 32 +--- client/src/test-utils/mock/index.ts | 2 + client/src/test-utils/mock/userApiMocks.ts | 48 ++++++ ...iews.py => test_rcrainfo_profile_views.py} | 0 45 files changed, 676 insertions(+), 842 deletions(-) rename client/src/components/Manifest/{Buttons => Actions}/ManifestCancelBtn.tsx (100%) rename client/src/components/Manifest/{Buttons => Actions}/ManifestEditBtn.tsx (100%) rename client/src/components/Manifest/{Buttons => Actions}/ManifestFABs.spec.tsx (97%) rename client/src/components/Manifest/{Buttons => Actions}/ManifestFABs.tsx (88%) rename client/src/components/Manifest/{Buttons => Actions}/ManifestSaveBtn.tsx (100%) delete mode 100644 client/src/hooks/useHtAPI/useHtApi.spec.tsx delete mode 100644 client/src/hooks/useHtAPI/useHtApi.tsx create mode 100644 client/src/store/authSlice/auth.slice.spec.tsx create mode 100644 client/src/store/authSlice/auth.slice.ts rename client/src/store/{haztrakApiSlice.ts => htApi.slice.ts} (86%) delete mode 100644 client/src/store/profileSlice/profile.slice.spec.tsx delete mode 100644 client/src/store/profileSlice/profile.slice.ts create mode 100644 client/src/store/userSlice/user.slice.spec.tsx create mode 100644 client/src/test-utils/fixtures/mockUser.ts rename client/src/test-utils/{index.tsx => index.ts} (100%) delete mode 100644 client/src/test-utils/mock/browser.js rename client/src/test-utils/mock/{handlers.ts => htApiMocks.ts} (57%) create mode 100644 client/src/test-utils/mock/index.ts create mode 100644 client/src/test-utils/mock/userApiMocks.ts rename server/apps/core/tests/{test_rcra_profile_views.py => test_rcrainfo_profile_views.py} (100%) diff --git a/client/src/components/Auth/LoginForm.tsx b/client/src/components/Auth/LoginForm.tsx index c2d4ba8d3..9e23e77a9 100644 --- a/client/src/components/Auth/LoginForm.tsx +++ b/client/src/components/Auth/LoginForm.tsx @@ -4,8 +4,13 @@ import React, { useEffect, useState } from 'react'; import { FloatingLabel, Form } from 'react-bootstrap'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; -import { setCredentials, useAppDispatch, useAppSelector, useLoginMutation } from 'store'; -import { selectAuthenticated } from 'store/userSlice/user.slice'; +import { + selectAuthenticated, + setCredentials, + useAppDispatch, + useAppSelector, + useLoginMutation, +} from 'store'; import { z } from 'zod'; const loginSchema = z.object({ diff --git a/client/src/components/Layout/PrivateRoute.tsx b/client/src/components/Layout/PrivateRoute.tsx index 9eb23e117..06e388012 100644 --- a/client/src/components/Layout/PrivateRoute.tsx +++ b/client/src/components/Layout/PrivateRoute.tsx @@ -1,7 +1,6 @@ import React, { ReactElement } from 'react'; import { Navigate } from 'react-router-dom'; -import { useAppSelector } from 'store'; -import { selectAuthenticated } from 'store/userSlice/user.slice'; +import { selectAuthenticated, useAppSelector } from 'store'; interface Props { children: any; diff --git a/client/src/components/Layout/TopNav/TopNav.tsx b/client/src/components/Layout/TopNav/TopNav.tsx index c2a84195f..4dc7cf30b 100644 --- a/client/src/components/Layout/TopNav/TopNav.tsx +++ b/client/src/components/Layout/TopNav/TopNav.tsx @@ -6,7 +6,7 @@ import React, { useContext } from 'react'; import { Button, Dropdown } from 'react-bootstrap'; import { useDispatch, useSelector } from 'react-redux'; import { Link, useNavigate } from 'react-router-dom'; -import { logout, selectAuthenticated } from 'store/userSlice/user.slice'; +import { removeCredentials, selectAuthenticated } from 'store'; export function TopNav() { const { showSidebar, setShowSidebar } = useContext(NavContext); @@ -17,7 +17,7 @@ export function TopNav() { if (!isAuthenticated) return null; const handleLogout = () => { - dispatch(logout()); + dispatch(removeCredentials()); navigation('/login'); }; diff --git a/client/src/components/Manifest/Buttons/ManifestCancelBtn.tsx b/client/src/components/Manifest/Actions/ManifestCancelBtn.tsx similarity index 100% rename from client/src/components/Manifest/Buttons/ManifestCancelBtn.tsx rename to client/src/components/Manifest/Actions/ManifestCancelBtn.tsx diff --git a/client/src/components/Manifest/Buttons/ManifestEditBtn.tsx b/client/src/components/Manifest/Actions/ManifestEditBtn.tsx similarity index 100% rename from client/src/components/Manifest/Buttons/ManifestEditBtn.tsx rename to client/src/components/Manifest/Actions/ManifestEditBtn.tsx diff --git a/client/src/components/Manifest/Buttons/ManifestFABs.spec.tsx b/client/src/components/Manifest/Actions/ManifestFABs.spec.tsx similarity index 97% rename from client/src/components/Manifest/Buttons/ManifestFABs.spec.tsx rename to client/src/components/Manifest/Actions/ManifestFABs.spec.tsx index 048a339a5..de67ff2bf 100644 --- a/client/src/components/Manifest/Buttons/ManifestFABs.spec.tsx +++ b/client/src/components/Manifest/Actions/ManifestFABs.spec.tsx @@ -1,10 +1,10 @@ import '@testing-library/jest-dom'; -import { ManifestFABs } from 'components/Manifest/Buttons/ManifestFABs'; +import { ManifestFABs } from 'components/Manifest/Actions/ManifestFABs'; +import { ManifestContext } from 'components/Manifest/ManifestForm'; +import { ManifestStatus } from 'components/Manifest/manifestSchema'; import React from 'react'; import { cleanup, renderWithProviders, screen } from 'test-utils'; import { afterEach, describe, expect, test } from 'vitest'; -import { ManifestContext } from 'components/Manifest/ManifestForm'; -import { ManifestStatus } from 'components/Manifest/manifestSchema'; const TestComponent = ({ status, diff --git a/client/src/components/Manifest/Buttons/ManifestFABs.tsx b/client/src/components/Manifest/Actions/ManifestFABs.tsx similarity index 88% rename from client/src/components/Manifest/Buttons/ManifestFABs.tsx rename to client/src/components/Manifest/Actions/ManifestFABs.tsx index 593f27d0e..58fcb1dab 100644 --- a/client/src/components/Manifest/Buttons/ManifestFABs.tsx +++ b/client/src/components/Manifest/Actions/ManifestFABs.tsx @@ -1,5 +1,5 @@ -import { ManifestEditBtn } from 'components/Manifest/Buttons/ManifestEditBtn'; -import { ManifestSaveBtn } from 'components/Manifest/Buttons/ManifestSaveBtn'; +import { ManifestEditBtn } from 'components/Manifest/Actions/ManifestEditBtn'; +import { ManifestSaveBtn } from 'components/Manifest/Actions/ManifestSaveBtn'; import { ManifestContext } from 'components/Manifest/ManifestForm'; import { QuickSignBtn } from 'components/Manifest/QuickerSign'; import { FloatingActionBtn } from 'components/UI'; diff --git a/client/src/components/Manifest/Buttons/ManifestSaveBtn.tsx b/client/src/components/Manifest/Actions/ManifestSaveBtn.tsx similarity index 100% rename from client/src/components/Manifest/Buttons/ManifestSaveBtn.tsx rename to client/src/components/Manifest/Actions/ManifestSaveBtn.tsx diff --git a/client/src/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx b/client/src/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx index a26b7d658..b8967e5e9 100644 --- a/client/src/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx +++ b/client/src/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx @@ -1,36 +1,51 @@ +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; import React from 'react'; import '@testing-library/jest-dom'; -import { HandlerSearchForm } from './HandlerSearchForm'; +import { HaztrakProfileResponse } from 'store/userSlice/user.slice'; import { cleanup, renderWithProviders, screen } from 'test-utils'; -import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; import { createMockRcrainfoSite } from 'test-utils/fixtures'; -import { setupServer } from 'msw/node'; -import { http, HttpResponse } from 'msw'; -import { API_BASE_URL } from 'test-utils/mock/handlers'; -import userEvent from '@testing-library/user-event'; +import { userApiMocks } from 'test-utils/mock'; +import { API_BASE_URL } from 'test-utils/mock/htApiMocks'; +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; +import { HandlerSearchForm } from './HandlerSearchForm'; const mockRcraSite1Id = 'VATEST111111111'; const mockRcraSite2Id = 'VATEST222222222'; const mockRcrainfoSite1Id = 'VATEST333333333'; const mockRcrainfoSite2Id = 'VATEST444444444'; +const mockProfile: HaztrakProfileResponse = { + user: 'testuser1', + sites: [], + org: { + name: 'my org', + rcrainfoIntegrated: true, + id: '1234', + }, +}; + const mockRcrainfoSite1 = createMockRcrainfoSite({ epaSiteId: mockRcrainfoSite1Id }); const mockRcrainfoSite2 = createMockRcrainfoSite({ epaSiteId: mockRcrainfoSite2Id }); const mockRcraSite1 = createMockRcrainfoSite({ epaSiteId: mockRcraSite1Id }); const mockRcraSite2 = createMockRcrainfoSite({ epaSiteId: mockRcraSite2Id }); -export const testURL = [ - http.get(`${API_BASE_URL}/api/site/search`, (info) => { +export const mockHandlerSearches = [ + http.get(`${API_BASE_URL}/api/site/search`, () => { return HttpResponse.json([mockRcraSite1, mockRcraSite2], { status: 200 }); }), - http.get(`${API_BASE_URL}/api/rcra/handler/search`, (info) => { + http.get(`${API_BASE_URL}/api/rcra/handler/search`, () => { return HttpResponse.json([mockRcrainfoSite1, mockRcrainfoSite2], { status: 200 }); }), - http.post(`${API_BASE_URL}/api/rcra/handler/search`, (info) => { + http.get(`${API_BASE_URL}/api/user/profile`, () => { + return HttpResponse.json({ ...mockProfile }, { status: 200 }); + }), + http.post(`${API_BASE_URL}/api/rcra/handler/search`, () => { return HttpResponse.json([mockRcrainfoSite1, mockRcrainfoSite2], { status: 200 }); }), ]; -const server = setupServer(...testURL); +const server = setupServer(...userApiMocks, ...mockHandlerSearches); afterEach(() => { cleanup(); }); @@ -46,19 +61,7 @@ describe('HandlerSearchForm', () => { }); test('retrieves rcra sites from haztrak and RCRAInfo', async () => { renderWithProviders( - undefined} handlerType="generator" />, - { - preloadedState: { - profile: { - user: 'testuser1', - org: { - name: 'my org', - rcrainfoIntegrated: true, - id: '1234', - }, - }, - }, - } + undefined} handlerType="generator" /> ); const epaId = screen.getByRole('combobox'); await userEvent.type(epaId, 'VATEST'); @@ -68,20 +71,19 @@ describe('HandlerSearchForm', () => { expect(await screen.findByText(new RegExp(mockRcrainfoSite2Id, 'i'))).toBeInTheDocument(); }); test('retrieves rcra sites from haztrak if org not rcrainfo integrated', async () => { - renderWithProviders( - undefined} handlerType="generator" />, - { - preloadedState: { - profile: { - user: 'testuser1', - org: { - name: 'my org', - rcrainfoIntegrated: false, - id: '1234', - }, + server.use( + http.get(`${API_BASE_URL}/api/user/profile`, () => { + return HttpResponse.json( + { + ...mockProfile, + org: { rcrainfoIntegrated: false }, }, - }, - } + { status: 200 } + ); + }) + ); + renderWithProviders( + undefined} handlerType="generator" /> ); const epaId = screen.getByRole('combobox'); await userEvent.type(epaId, 'VATEST'); diff --git a/client/src/components/Manifest/Handler/Search/HandlerSearchForm.tsx b/client/src/components/Manifest/Handler/Search/HandlerSearchForm.tsx index 35b37dd7c..0f5381d52 100644 --- a/client/src/components/Manifest/Handler/Search/HandlerSearchForm.tsx +++ b/client/src/components/Manifest/Handler/Search/HandlerSearchForm.tsx @@ -1,3 +1,4 @@ +import { RcrainfoSiteSearchBadge } from 'components/Manifest/Handler/Search/RcrainfoSiteSearchBadge'; import { ManifestContext, ManifestContextType } from 'components/Manifest/ManifestForm'; import { Manifest, SiteType, Transporter } from 'components/Manifest/manifestSchema'; import { RcraSite } from 'components/RcraSite'; @@ -12,13 +13,7 @@ import { useFormContext, } from 'react-hook-form'; import Select from 'react-select'; -import { - selectHaztrakProfile, - useAppSelector, - useSearchRcrainfoSitesQuery, - useSearchRcraSitesQuery, -} from 'store'; -import { RcrainfoSiteSearchBadge } from 'components/Manifest/Handler/Search/RcrainfoSiteSearchBadge'; +import { useGetProfileQuery, useSearchRcrainfoSitesQuery, useSearchRcraSitesQuery } from 'store'; interface Props { handleClose: () => void; @@ -42,9 +37,13 @@ export function HandlerSearchForm({ const manifestMethods = useFormContext(); const [inputValue, setInputValue] = useState(''); const [selectedHandler, setSelectedHandler] = useState(null); - const { org } = useAppSelector(selectHaztrakProfile); + const { org } = useGetProfileQuery(undefined, { + selectFromResult: ({ data }) => { + return { org: data?.org }; + }, + }); const [skip, setSkip] = useState(true); - const { data, error, isLoading } = useSearchRcraSitesQuery( + const { data } = useSearchRcraSitesQuery( { siteType: handlerType, siteId: inputValue, diff --git a/client/src/components/Manifest/ManifestForm.tsx b/client/src/components/Manifest/ManifestForm.tsx index 9861bbe71..6529f4fe5 100644 --- a/client/src/components/Manifest/ManifestForm.tsx +++ b/client/src/components/Manifest/ManifestForm.tsx @@ -1,24 +1,25 @@ import { ErrorMessage } from '@hookform/error-message'; import { zodResolver } from '@hookform/resolvers/zod'; +import { createSelector } from '@reduxjs/toolkit'; +import { ManifestCancelBtn } from 'components/Manifest/Actions/ManifestCancelBtn'; +import { ManifestEditBtn } from 'components/Manifest/Actions/ManifestEditBtn'; +import { ManifestFABs } from 'components/Manifest/Actions/ManifestFABs'; +import { ManifestSaveBtn } from 'components/Manifest/Actions/ManifestSaveBtn'; import { AdditionalInfoForm } from 'components/Manifest/AdditionalInfo'; -import { ManifestCancelBtn } from 'components/Manifest/Buttons/ManifestCancelBtn'; -import { ManifestEditBtn } from 'components/Manifest/Buttons/ManifestEditBtn'; -import { ManifestFABs } from 'components/Manifest/Buttons/ManifestFABs'; -import { ManifestSaveBtn } from 'components/Manifest/Buttons/ManifestSaveBtn'; import { UpdateRcra } from 'components/Manifest/UpdateRcra/UpdateRcra'; import { WasteLine } from 'components/Manifest/WasteLine/wasteLineSchema'; import { RcraSiteDetails } from 'components/RcraSite'; import { HtButton, HtCard, HtForm, InfoIconTooltip } from 'components/UI'; -import React, { createContext, useEffect, useState } from 'react'; +import React, { createContext, useEffect, useMemo, useState } from 'react'; import { Alert, Button, Col, Container, Form, Row, Stack } from 'react-bootstrap'; import { FormProvider, SubmitHandler, useFieldArray, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import { toast } from 'react-toastify'; import { manifest } from 'services'; import { - selectHaztrakSiteEpaIds, - useAppSelector, + ProfileSlice, useCreateManifestMutation, + useGetProfileQuery, useSaveEManifestMutation, useUpdateManifestMutation, } from 'store'; @@ -239,8 +240,25 @@ export function ManifestForm({ manifestData?.status ); + const selectUserSiteIds = useMemo( + () => + createSelector( + (res) => res.data, + (data: ProfileSlice) => + !data ?? !data.sites + ? [] + : Object.values(data.sites).map((site) => site.handler.epaSiteId) + ), + [] + ); + const nextSigner = manifest.getNextSigner(manifestData); - const userSiteIds = useAppSelector(selectHaztrakSiteEpaIds); + const { userSiteIds } = useGetProfileQuery(undefined, { + selectFromResult: (result) => ({ + ...result, + userSiteIds: selectUserSiteIds(result), + }), + }); // Whether the user has permissions and manifest is in a state to be signed const signAble = userSiteIds.includes(nextSigner?.epaSiteId ?? ''); diff --git a/client/src/components/Manifest/QuickerSign/SignBtn/QuickSignBtn.spec.tsx b/client/src/components/Manifest/QuickerSign/SignBtn/QuickSignBtn.spec.tsx index 532c57d50..c878b7f7a 100644 --- a/client/src/components/Manifest/QuickerSign/SignBtn/QuickSignBtn.spec.tsx +++ b/client/src/components/Manifest/QuickerSign/SignBtn/QuickSignBtn.spec.tsx @@ -2,12 +2,31 @@ import '@testing-library/jest-dom'; import { ManifestContext } from 'components/Manifest/ManifestForm'; import { Handler, RcraSiteType } from 'components/Manifest/manifestSchema'; import { QuickSignBtn } from 'components/Manifest/QuickerSign/index'; +import { setupServer } from 'msw/node'; import React from 'react'; +import { HaztrakProfileResponse } from 'store/userSlice/user.slice'; import { cleanup, renderWithProviders, screen } from 'test-utils'; import { createMockMTNHandler } from 'test-utils/fixtures'; -import { afterEach, describe, expect, test } from 'vitest'; +import { userApiMocks } from 'test-utils/mock'; +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; import { undefined } from 'zod'; +const mockProfile: HaztrakProfileResponse = { + user: 'testuser1', + sites: [], + org: { + name: 'my org', + rcrainfoIntegrated: true, + id: '1234', + }, +}; + +const server = setupServer(...userApiMocks); +afterEach(() => { + cleanup(); +}); +beforeAll(() => server.listen()); +afterAll(() => server.close()); // Disable API mocking after the tests are done. afterEach(() => { cleanup(); }); @@ -40,60 +59,12 @@ function TestComponent({ } describe('QuickSignBtn', () => { - test('renders', () => { - const handlerId = 'TXD987654321'; - const handler = createMockMTNHandler({ siteType: 'Generator', epaSiteId: handlerId }); - renderWithProviders( - , - { - preloadedState: { - profile: { - user: 'testuser1', - org: { - rcrainfoIntegrated: true, - id: '123', - name: 'Test Org', - }, - sites: { - TXD987654321: { - name: 'Test Site', - handler: handler, - permissions: { eManifest: 'signer' }, - }, - }, - }, - }, - } - ); - expect(screen.getByRole('button')).toBeInTheDocument(); - }); test('is not disabled when user org is rcrainfo integrated', () => { const unsigned_handler = createMockMTNHandler({ signed: false, electronicSignaturesInfo: [], }); - renderWithProviders(, { - // Redux store state with an API user is required for this button to be active - preloadedState: { - profile: { - org: { - name: 'Test Org', - id: '123', - rcrainfoIntegrated: true, - }, - user: 'username', - rcrainfoProfile: { - user: 'username', - phoneNumber: '1231231234', - apiUser: true, - }, - }, - }, - }); + renderWithProviders(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); test('is disabled when API user but already signed', () => { @@ -108,20 +79,7 @@ describe('QuickSignBtn', () => { siteType={'Tsdf'} signingSite={{ epaSiteId: 'other_site', siteType: 'transporter' }} handler={unsigned_handler} - />, - // Redux store state with an API user is required for this button to be active - { - preloadedState: { - profile: { - user: 'username', - rcrainfoProfile: { - user: 'username', - phoneNumber: '1231231234', - apiUser: false, - }, - }, - }, - } + /> ); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); diff --git a/client/src/components/Manifest/QuickerSign/SignBtn/QuickSignBtn.tsx b/client/src/components/Manifest/QuickerSign/SignBtn/QuickSignBtn.tsx index 9a958ca08..3f582c5e0 100644 --- a/client/src/components/Manifest/QuickerSign/SignBtn/QuickSignBtn.tsx +++ b/client/src/components/Manifest/QuickerSign/SignBtn/QuickSignBtn.tsx @@ -1,11 +1,12 @@ import { faFeather } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { createSelector } from '@reduxjs/toolkit'; import { ManifestContext } from 'components/Manifest/ManifestForm'; import { Handler, RcraSiteType } from 'components/Manifest/manifestSchema'; import { RcraApiUserBtn } from 'components/Rcrainfo'; -import React, { useContext } from 'react'; +import React, { useContext, useMemo } from 'react'; import { ButtonProps } from 'react-bootstrap'; -import { siteByEpaIdSelector, useAppSelector } from 'store'; +import { ProfileSlice, useGetProfileQuery } from 'store'; interface QuickSignBtnProps extends ButtonProps { siteType?: RcraSiteType; @@ -25,8 +26,24 @@ export function QuickSignBtn({ ...props }: QuickSignBtnProps) { const { nextSigningSite } = useContext(ManifestContext); - // if next site to sign is not one of the user's sites, don't show the button - if (!useAppSelector(siteByEpaIdSelector(nextSigningSite?.epaSiteId))) return <>; + + const selectBySiteId = useMemo(() => { + return createSelector( + (res) => res.data, + (res, siteId) => siteId, + (data: ProfileSlice, siteId) => { + return data && data.sites ? data.sites[siteId] : undefined; + } + ); + }, []); + + const { userSite } = useGetProfileQuery(undefined, { + selectFromResult: (res) => ({ + ...res, + userSite: selectBySiteId(res, nextSigningSite?.epaSiteId), + }), + }); + if (!userSite) return <>; if (mtnHandler && mtnHandler?.epaSiteId !== nextSigningSite?.epaSiteId) return <>; diff --git a/client/src/components/Manifest/SiteSelect/SiteSelect.spec.tsx b/client/src/components/Manifest/SiteSelect/SiteSelect.spec.tsx index d433b84fa..38b05eb59 100644 --- a/client/src/components/Manifest/SiteSelect/SiteSelect.spec.tsx +++ b/client/src/components/Manifest/SiteSelect/SiteSelect.spec.tsx @@ -3,8 +3,6 @@ import { SiteSelect } from 'components/Manifest/SiteSelect/SiteSelect'; import React, { useState } from 'react'; import { useForm } from 'react-hook-form'; import { renderWithProviders } from 'test-utils'; -import { createMockSite } from 'test-utils/fixtures'; -import { createMockRcrainfoPermissions } from 'test-utils/fixtures/mockHandler'; import { describe, expect, test } from 'vitest'; function TestComponent() { @@ -16,29 +14,7 @@ function TestComponent() { describe('SiteSelect', () => { test('renders', () => { - const mySite = createMockSite(); - renderWithProviders(, { - preloadedState: { - profile: { - user: 'username', - rcrainfoProfile: { - user: 'username', - phoneNumber: '1231231234', - apiUser: false, - rcraSites: { - VATESTGEN001: { - epaSiteId: 'VATESTGEN001', - permissions: createMockRcrainfoPermissions(), - }, - VATEST00001: { - epaSiteId: 'VATEST00001', - permissions: createMockRcrainfoPermissions(), - }, - }, - }, - }, - }, - }); + renderWithProviders(); expect(screen.queryByTestId('siteSelect')).toBeDefined(); }); }); diff --git a/client/src/components/Manifest/SiteSelect/SiteSelect.tsx b/client/src/components/Manifest/SiteSelect/SiteSelect.tsx index 2b381f798..3fa1efb95 100644 --- a/client/src/components/Manifest/SiteSelect/SiteSelect.tsx +++ b/client/src/components/Manifest/SiteSelect/SiteSelect.tsx @@ -1,9 +1,10 @@ +import { createSelector } from '@reduxjs/toolkit'; import { RcraSite } from 'components/RcraSite'; import { HtForm } from 'components/UI'; -import React from 'react'; +import React, { useMemo } from 'react'; import { Control, Controller } from 'react-hook-form'; import Select from 'react-select'; -import { selectHaztrakSites, useAppSelector } from 'store'; +import { ProfileSlice, useGetProfileQuery } from 'store'; interface SiteSelectProps { control: Control; @@ -16,8 +17,21 @@ export function SiteSelect({ value, handleChange, }: SiteSelectProps) { - const userSites = useAppSelector(selectHaztrakSites); - const siteOptions = userSites?.map((site) => site.handler); + const selectUserSites = useMemo(() => { + return createSelector( + (res) => res?.data, + (data: ProfileSlice) => + !data || !data.sites ? [] : Object.values(data.sites).map((site) => site.handler) + ); + }, []); + + const { siteOptions } = useGetProfileQuery(undefined, { + selectFromResult: (result) => ({ + ...result, + siteOptions: selectUserSites(result), + }), + }); + return ( <> Site diff --git a/client/src/components/Org/OrgSitesTable.tsx b/client/src/components/Org/OrgSitesTable.tsx index e11a6bb40..6a932e15b 100644 --- a/client/src/components/Org/OrgSitesTable.tsx +++ b/client/src/components/Org/OrgSitesTable.tsx @@ -1,8 +1,7 @@ import { HaztrakSite } from 'components/HaztrakSite'; import React from 'react'; import { Row, Table } from 'react-bootstrap'; -import { useGetOrgSitesQuery } from 'store'; -import { HaztrakProfileOrg } from 'store/profileSlice/profile.slice'; +import { HaztrakProfileOrg, useGetOrgSitesQuery } from 'store'; interface OrgSitesProps { org: HaztrakProfileOrg; diff --git a/client/src/components/RcraProfile/RcraProfile.tsx b/client/src/components/RcraProfile/RcraProfile.tsx index 7ddf6874b..abcc14703 100644 --- a/client/src/components/RcraProfile/RcraProfile.tsx +++ b/client/src/components/RcraProfile/RcraProfile.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useState } from 'react'; import { Button, Col, Container, Form, Row, Table } from 'react-bootstrap'; import { useForm } from 'react-hook-form'; import { RcrainfoProfileState, useAppDispatch, useUpdateRcrainfoProfileMutation } from 'store'; -import { authApi } from 'store/userSlice/user.slice'; +import { userApi } from 'store/userSlice/user.slice'; import { z } from 'zod'; interface ProfileViewProps { @@ -33,7 +33,7 @@ export function RcraProfile({ profile }: ProfileViewProps) { }); useEffect(() => { - dispatch(authApi.util?.invalidateTags(['rcrainfoProfile'])); + dispatch(userApi.util?.invalidateTags(['rcrainfoProfile'])); }, [inProgress]); const { diff --git a/client/src/components/Rcrainfo/buttons/RcraApiUserBtn/RcraApiUserBtn.spec.tsx b/client/src/components/Rcrainfo/buttons/RcraApiUserBtn/RcraApiUserBtn.spec.tsx index 36eecbb75..f1d9bd0a6 100644 --- a/client/src/components/Rcrainfo/buttons/RcraApiUserBtn/RcraApiUserBtn.spec.tsx +++ b/client/src/components/Rcrainfo/buttons/RcraApiUserBtn/RcraApiUserBtn.spec.tsx @@ -23,18 +23,7 @@ describe('RcraApiUserBtn', () => { expect(screen.getByRole('button')).toBeDisabled(); }); test('is disabled when apiUser=false', () => { - renderWithProviders(Click Me, { - preloadedState: { - profile: { - user: 'username', - rcrainfoProfile: { - user: 'username', - phoneNumber: '1231231234', - apiUser: false, - }, - }, - }, - }); + renderWithProviders(Click Me); expect(screen.getByRole('button')).toBeDisabled(); }); }); diff --git a/client/src/components/Rcrainfo/buttons/SyncManifestBtn/SyncManifestBtn.spec.tsx b/client/src/components/Rcrainfo/buttons/SyncManifestBtn/SyncManifestBtn.spec.tsx index e280e3078..2fa54bd87 100644 --- a/client/src/components/Rcrainfo/buttons/SyncManifestBtn/SyncManifestBtn.spec.tsx +++ b/client/src/components/Rcrainfo/buttons/SyncManifestBtn/SyncManifestBtn.spec.tsx @@ -4,23 +4,23 @@ import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import React from 'react'; import { cleanup, renderWithProviders, screen } from 'test-utils'; -import { API_BASE_URL } from 'test-utils/mock/handlers'; +import { userApiMocks } from 'test-utils/mock'; +import { API_BASE_URL } from 'test-utils/mock/htApiMocks'; import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vitest'; const testTaskID = 'testTaskId'; -const server = setupServer( - ...[ - http.post(`${API_BASE_URL}rcra/manifest/emanifest/sync`, () => { - // Mock Sync Site Manifests response - return HttpResponse.json( - { - task: testTaskID, - }, - { status: 200 } - ); - }), - ] +const server = setupServer(...userApiMocks); +server.use( + http.post(`${API_BASE_URL}rcra/manifest/emanifest/sync`, () => { + // Mock Sync Site Manifests response + return HttpResponse.json( + { + task: testTaskID, + }, + { status: 200 } + ); + }) ); beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); // setup mock http server diff --git a/client/src/components/User/UserInfoForm.spec.tsx b/client/src/components/User/UserInfoForm.spec.tsx index e501814da..00abfb9e5 100644 --- a/client/src/components/User/UserInfoForm.spec.tsx +++ b/client/src/components/User/UserInfoForm.spec.tsx @@ -2,27 +2,15 @@ import '@testing-library/jest-dom'; import { cleanup } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { UserInfoForm } from 'components/User/UserInfoForm'; -import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import React from 'react'; import { HaztrakUser, ProfileSlice } from 'store'; import { renderWithProviders, screen } from 'test-utils'; -import { API_BASE_URL } from 'test-utils/mock/handlers'; +import { createMockHaztrakUser } from 'test-utils/fixtures'; +import { userApiMocks } from 'test-utils/mock'; import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vitest'; -const DEFAULT_USER: HaztrakUser = { - username: 'test', - firstName: 'David', - lastName: 'smith', - email: 'test@mail.com', -}; - -const server = setupServer( - http.put(`${API_BASE_URL}/api/user`, (info) => { - const user: HaztrakUser = { ...DEFAULT_USER }; - return HttpResponse.json({ ...user, ...info.request.body }); - }) -); +const server = setupServer(...userApiMocks); // pre-/post-test hooks beforeAll(() => server.listen()); @@ -35,13 +23,10 @@ afterAll(() => server.close()); // Disable API mocking after the tests are done. describe('UserProfile', () => { test('renders', () => { - const user: HaztrakUser = { - ...DEFAULT_USER, - username: 'test', - firstName: 'David', - }; + const myUsername = 'myUsername'; + const user: HaztrakUser = createMockHaztrakUser({ username: myUsername, firstName: 'David' }); const profile: ProfileSlice = { - user: 'test', + user: myUsername, }; renderWithProviders(, {}); expect(screen.getByRole('textbox', { name: 'First Name' })).toHaveValue(user.firstName); @@ -50,9 +35,7 @@ describe('UserProfile', () => { test('update profile fields', async () => { // Arrange const newEmail = 'newMockEmail@mail.com'; - const user: HaztrakUser = { - ...DEFAULT_USER, - }; + const user: HaztrakUser = createMockHaztrakUser({ email: 'oldEmail@gmail.com' }); const profile: ProfileSlice = { user: 'test', }; diff --git a/client/src/features/Dashboard/Dashboard.spec.tsx b/client/src/features/Dashboard/Dashboard.spec.tsx index 5f81fbaf5..aeaf61ec2 100644 --- a/client/src/features/Dashboard/Dashboard.spec.tsx +++ b/client/src/features/Dashboard/Dashboard.spec.tsx @@ -1,33 +1,26 @@ import '@testing-library/jest-dom'; import { Dashboard } from 'features/Dashboard/Dashboard'; -import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; -import React from 'react'; +import React, { createElement } from 'react'; import { cleanup, renderWithProviders, screen } from 'test-utils'; -import { API_BASE_URL, handlers } from 'test-utils/mock/handlers'; +import { userApiMocks } from 'test-utils/mock'; +import { htApiMocks } from 'test-utils/mock/htApiMocks'; import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vitest'; const USERNAME = 'testuser1'; -const myAPIHandlers = [ - http.get(`${API_BASE_URL}/api/user/rcrainfo-profile/${USERNAME}`, (info) => { - return HttpResponse.json( - { - user: USERNAME, - rcraAPIID: 'mockRcraAPIID', - rcraUsername: undefined, - epaSites: [], - phoneNumber: undefined, - apiUser: true, - }, - { status: 200 } - ); - }), -]; - -const server = setupServer(...handlers, ...myAPIHandlers); +const server = setupServer(...htApiMocks, ...userApiMocks); -beforeAll(() => server.listen()); // setup mock http server +beforeAll(() => { + vi.mock('recharts', async (importOriginal) => { + const originalModule = (await importOriginal()) as Record; + return { + ...originalModule, + ResponsiveContainer: () => createElement('div'), + }; + }); + server.listen(); +}); // setup mock http server afterEach(() => { server.resetHandlers(); cleanup(); diff --git a/client/src/features/MmanifestDetails/ManifestDetails.tsx b/client/src/features/MmanifestDetails/ManifestDetails.tsx index e2be40c90..87c1650dc 100644 --- a/client/src/features/MmanifestDetails/ManifestDetails.tsx +++ b/client/src/features/MmanifestDetails/ManifestDetails.tsx @@ -1,14 +1,14 @@ import { ManifestForm } from 'components/Manifest'; -import { Manifest } from 'components/Manifest/manifestSchema'; import { HtSpinner } from 'components/UI'; -import { useHtApi, useTitle } from 'hooks'; +import { useTitle } from 'hooks'; import React from 'react'; import { useParams } from 'react-router-dom'; +import { useGetManifestQuery } from 'store'; export function ManifestDetails() { const { mtn, action, siteId } = useParams(); useTitle(`${mtn}`); - const [manifestData, loading, error] = useHtApi(`rcra/manifest/${mtn}`); + const { data, error, isLoading } = useGetManifestQuery(mtn!, { skip: !mtn }); let readOnly = true; if (action === 'edit') { @@ -16,7 +16,6 @@ export function ManifestDetails() { } if (error) { - // TODO: add global error handling via redux return (

Something went wrong

@@ -25,15 +24,10 @@ export function ManifestDetails() { ); } - return loading ? ( + return isLoading ? ( - ) : manifestData ? ( - + ) : data ? ( + ) : (

Something went wrong

diff --git a/client/src/features/NewManifest/NewManifest.spec.tsx b/client/src/features/NewManifest/NewManifest.spec.tsx index db68e7dbe..b74e56df8 100644 --- a/client/src/features/NewManifest/NewManifest.spec.tsx +++ b/client/src/features/NewManifest/NewManifest.spec.tsx @@ -1,86 +1,58 @@ import '@testing-library/jest-dom'; import userEvent from '@testing-library/user-event'; import { NewManifest } from 'features/NewManifest/NewManifest'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; import React from 'react'; -import { renderWithProviders, screen } from 'test-utils'; -import { createMockRcrainfoPermissions, createMockSite } from 'test-utils/fixtures'; -import { describe, expect, test } from 'vitest'; +import { HaztrakProfileResponse } from 'store/userSlice/user.slice'; +import { cleanup, renderWithProviders, screen } from 'test-utils'; +import { createMockSite } from 'test-utils/fixtures'; +import { userApiMocks } from 'test-utils/mock'; +import { API_BASE_URL } from 'test-utils/mock/htApiMocks'; +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; + +const mySiteId = 'VATESTGEN001'; +const mySiteName = 'My Site'; +const mySite = createMockSite({ + name: mySiteName, + // @ts-ignore + handler: { epaSiteId: mySiteId, siteType: 'Tsdf' }, +}); + +const mockProfile: HaztrakProfileResponse = { + user: 'testuser1', + sites: [{ site: mySite, eManifest: 'viewer' }], + org: { + name: 'my org', + rcrainfoIntegrated: true, + id: '1234', + }, +}; + +const server = setupServer(...userApiMocks); +server.use( + http.get(`${API_BASE_URL}/api/user/profile`, () => { + return HttpResponse.json({ ...mockProfile }, { status: 200 }); + }) +); +afterEach(() => { + cleanup(); +}); +beforeAll(() => server.listen()); +afterAll(() => server.close()); // Disable API mocking after the tests are done. describe('NewManifest', () => { - test('renders', () => { - const mySiteId = 'VATESTGEN001'; - const mySiteName = 'My Site'; - const mySite = createMockSite({ - name: mySiteName, - // @ts-ignore - handler: { epaSiteId: mySiteId, siteType: 'Tsdf' }, - }); - renderWithProviders(, { - preloadedState: { - profile: { - user: 'testuser1', - sites: { VATESTGEN001: { ...mySite, permissions: { eManifest: 'viewer' } } }, - rcrainfoProfile: { - user: 'username', - phoneNumber: '1231231234', - apiUser: false, - rcraSites: { - VATESTGEN001: { - epaSiteId: mySiteId, - permissions: createMockRcrainfoPermissions(), - }, - }, - }, - }, - }, - }); + test('renders', async () => { + renderWithProviders(); expect(screen.getByRole('combobox', { name: /site Role/i })).toBeInTheDocument(); }); test('site type is initially disabled', () => { - const mySiteId = 'VATESTGEN001'; - const mySiteName = 'My Site'; - const mySite = createMockSite({ - name: mySiteName, - // @ts-ignore - handler: { epaSiteId: mySiteId, siteType: 'Tsdf' }, - }); - renderWithProviders(, { - preloadedState: { - profile: { - user: 'testuser1', - rcrainfoProfile: { - user: 'username', - phoneNumber: '1231231234', - apiUser: false, - rcraSites: { - VATESTGEN001: { - epaSiteId: mySiteId, - permissions: createMockRcrainfoPermissions(), - }, - }, - }, - }, - }, - }); + renderWithProviders(); const siteRole = screen.getByRole('combobox', { name: /site Role/i }); expect(siteRole).toBeDisabled(); }); test('site type is not disabled after selecting a site', async () => { - const mySiteId = 'VATESTGEN001'; - const mySiteName = 'My Site'; - const mySite = createMockSite({ - name: mySiteName, - // @ts-ignore - handler: { epaSiteId: mySiteId, siteType: 'Tsdf' }, - }); - renderWithProviders(, { - preloadedState: { - profile: { - user: 'testuser1', - sites: { VATESTGEN001: { ...mySite, permissions: { eManifest: 'viewer' } } }, - }, - }, - }); + renderWithProviders(); const siteSelection = screen.getByRole('combobox', { name: /site select/i }); await userEvent.click(siteSelection); await userEvent.keyboard('{enter}'); diff --git a/client/src/features/NewManifest/NewManifest.tsx b/client/src/features/NewManifest/NewManifest.tsx index ddfe74583..f283ca3c9 100644 --- a/client/src/features/NewManifest/NewManifest.tsx +++ b/client/src/features/NewManifest/NewManifest.tsx @@ -1,14 +1,15 @@ +import { createSelector } from '@reduxjs/toolkit'; import { Manifest, ManifestForm } from 'components/Manifest'; import { RcraSiteType } from 'components/Manifest/manifestSchema'; import { SiteSelect, SiteTypeSelect } from 'components/Manifest/SiteSelect'; import { RcraSite } from 'components/RcraSite'; -import { HtCard } from 'components/UI'; +import { HtCard, HtSpinner } from 'components/UI'; import { useTitle } from 'hooks'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Col, Container, Form } from 'react-bootstrap'; import { useForm } from 'react-hook-form'; import { useParams } from 'react-router-dom'; -import { siteByEpaIdSelector, useAppSelector } from 'store'; +import { useGetProfileQuery } from 'store'; /** * NewManifest component allows a user to create a new electronic manifest. @@ -21,7 +22,23 @@ export function NewManifest() { useTitle('New Manifest'); const { control } = useForm(); const { siteId } = useParams(); - const rcraSite = useAppSelector(siteByEpaIdSelector(siteId)); + + const selectBySiteId = useMemo(() => { + return createSelector( + (res) => res.data, + (res, siteId) => siteId, + (data, siteId) => { + return data?.sites[siteId]?.handler ?? undefined; + } + ); + }, []); + + const { rcraSite, isLoading } = useGetProfileQuery(undefined, { + selectFromResult: (result) => ({ + ...result, + rcraSite: selectBySiteId(result, siteId), + }), + }); const [manifestingSite, setManifestingSite] = useState(rcraSite); const [selectedSiteType, setSelectedSiteType] = useState( rcraSite?.siteType === 'Generator' ? rcraSite.siteType : undefined @@ -30,6 +47,8 @@ export function NewManifest() { rcraSite?.siteType ); + if (isLoading && siteId) return ; + const handleSiteChange = (site: any) => { setManifestingSite(site); setManifestingSiteType(site.siteType); diff --git a/client/src/features/SiteList/SiteList.spec.tsx b/client/src/features/SiteList/SiteList.spec.tsx index 54fb6d0db..028237f0d 100644 --- a/client/src/features/SiteList/SiteList.spec.tsx +++ b/client/src/features/SiteList/SiteList.spec.tsx @@ -3,7 +3,8 @@ import { setupServer } from 'msw/node'; import React from 'react'; import { renderWithProviders, screen } from 'test-utils'; import { createMockHandler, createMockSite } from 'test-utils/fixtures/mockHandler'; -import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; +import { htApiMocks, userApiMocks } from 'test-utils/mock'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { SiteList } from './SiteList'; const mockHandler1 = createMockHandler({ epaSiteId: 'VAT987654321' }); @@ -12,11 +13,7 @@ const mockSites = [ createMockSite({ handler: mockHandler1 }), createMockSite({ handler: mockHandler2 }), ]; -const server = setupServer( - http.get(`${import.meta.env.VITE_HT_API_URL}/api/site`, (info) => { - return HttpResponse.json(mockSites, { status: 200 }); - }) -); +const server = setupServer(...userApiMocks, ...htApiMocks); // pre-/post-test hooks beforeAll(() => server.listen()); @@ -26,11 +23,14 @@ describe('SiteList component', () => { test('renders', () => { renderWithProviders(, {}); }); - test('fetches displays all sites a user has access to', async () => { - // Act + test('displays all sites a user has access to', async () => { + server.use( + http.get(`${import.meta.env.VITE_HT_API_URL}/api/site`, (info) => { + return HttpResponse.json(mockSites, { status: 200 }); + }) + ); renderWithProviders(); let numIds = await screen.findAllByRole('listitem'); - // Assert expect(numIds.length).toEqual(mockSites.length); }); }); diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts index 60c17db15..2a9292a7c 100644 --- a/client/src/hooks/index.ts +++ b/client/src/hooks/index.ts @@ -1,6 +1,3 @@ -import { useHtApi } from './useHtAPI/useHtApi'; -import { usePagination } from './usePagination/usePagination'; -import { useProgressTracker } from './useProgressTracker/useProgressTracker'; -import { useTitle } from './useTitle/useTitle'; - -export { usePagination, useTitle, useHtApi, useProgressTracker }; +export { usePagination } from './usePagination/usePagination'; +export { useProgressTracker } from './useProgressTracker/useProgressTracker'; +export { useTitle } from './useTitle/useTitle'; diff --git a/client/src/hooks/useHtAPI/useHtApi.spec.tsx b/client/src/hooks/useHtAPI/useHtApi.spec.tsx deleted file mode 100644 index e62832880..000000000 --- a/client/src/hooks/useHtAPI/useHtApi.spec.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import '@testing-library/jest-dom'; -import { cleanup } from '@testing-library/react'; -import { useHtApi } from 'hooks'; -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import React from 'react'; -import { render, renderWithProviders, screen } from 'test-utils'; -import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vitest'; - -interface exampleData { - foo: string; - bar: string; -} - -interface exampleProps { - url: string; -} - -function TestComponent({ url }: exampleProps) { - const [data, loading, error] = useHtApi(url); - if (error) { - return ( - <> -

{error.message}

- - ); - } else { - return <>{loading ?

loading

:

{data?.foo}

}; - } -} - -/** - * mock Rest API - */ -const API_BASE_URL = import.meta.env.VITE_HT_API_URL; -export const testURL = [ - http.get(`${API_BASE_URL}/api/test/url`, (info) => { - return HttpResponse.json( - { - foo: 'foo', - bar: 'bar', - }, - { status: 200 } - ); - }), - http.get(`${API_BASE_URL}/api/bad/url`, (info) => { - return HttpResponse.json( - { - error: { - message: 'resource not found', - }, - }, - { status: 404 } - ); - }), -]; - -const server = setupServer(...testURL); - -beforeAll(() => server.listen()); // setup mock http server -afterEach(() => { - server.resetHandlers(); - cleanup(); - vi.resetAllMocks(); -}); -afterAll(() => server.close()); // Disable API mocking after the tests are done. - -afterEach(() => { - cleanup(); - vi.resetAllMocks(); -}); - -describe('useHtAPI', () => { - test('initially sets loading to true', () => { - render(); - expect(screen.getByText(/loading/i)).toBeInTheDocument(); - }); - test('sets data to the response body', async () => { - renderWithProviders(); - expect(await screen.findByText(/foo/i)).toBeInTheDocument(); - }); - test('sets the error for bad requests', async () => { - renderWithProviders(); - expect(await screen.findByText(/404/i)).toBeInTheDocument(); - }); -}); diff --git a/client/src/hooks/useHtAPI/useHtApi.tsx b/client/src/hooks/useHtAPI/useHtApi.tsx deleted file mode 100644 index 728fa49e2..000000000 --- a/client/src/hooks/useHtAPI/useHtApi.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useEffect, useState } from 'react'; -import { htApi } from 'services'; - -/** - * Hook for retrieving data from the haztrak http server - * - * @description - * Easy abstraction for GET request from the haztrak http server. Currently, - * does not support other http methods since those situations tend to require - * more thought. - * - * @param url {string} - * @return array {[data, loading, error]} - * - * @example - * const [data, loading , error ] = useHtApi(`/api/resource/path/${id}`) - */ -export function useHtApi(url: string) { - const [data, setData] = useState(undefined); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(undefined); - - useEffect(() => { - if (!url) return; - htApi - .get(url) - .then((response) => setData(response.data)) - .then(() => setLoading(false)) - .catch(setError); - }, [url]); - - return [data, loading, error] as const; -} diff --git a/client/src/store/authSlice/auth.slice.spec.tsx b/client/src/store/authSlice/auth.slice.spec.tsx new file mode 100644 index 000000000..2ee8c01fe --- /dev/null +++ b/client/src/store/authSlice/auth.slice.spec.tsx @@ -0,0 +1,86 @@ +import '@testing-library/jest-dom'; +import userEvent from '@testing-library/user-event'; +import { + removeCredentials, + selectAuthenticated, + setCredentials, + useAppDispatch, + useAppSelector, +} from 'store'; +import { renderWithProviders, screen } from 'test-utils'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +const getItemSpy = vi.spyOn(Storage.prototype, 'getItem'); +const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); +const removeItemSpy = vi.spyOn(Storage.prototype, 'removeItem'); + +afterEach(() => { + getItemSpy.mockClear(); + setItemSpy.mockClear(); + removeItemSpy.mockClear(); +}); + +const TestComponent = () => { + const dispatch = useAppDispatch(); + const isAuthenticated = useAppSelector(selectAuthenticated); + return ( +
+

Test

+

{isAuthenticated ? 'authenticated' : 'unauthenticated'}

+
+ + +
+
+ ); +}; + +describe('auth slice', () => { + test('our localStorage mocks are working', () => { + const value = 'test'; + localStorage.setItem('token', value); + expect(setItemSpy).toHaveBeenCalled(); + const foo = localStorage.getItem('token'); + expect(setItemSpy).toHaveBeenCalled(); + expect(foo).toBe(value); + }); + test('initial state is unauthenticated', () => { + renderWithProviders(); + expect(screen.queryByText('unauthenticated')).toBeInTheDocument(); + expect(screen.queryByText('authenticated')).not.toBeInTheDocument(); + }); + test('setCredentials stores the token in local storage', async () => { + renderWithProviders(); + await userEvent.click(screen.getByText('Log in')); + expect(setItemSpy).toHaveBeenCalled(); + }); + test('selectAuthenticated is true after credentials are set', async () => { + renderWithProviders(); + await userEvent.click(screen.getByText('Log in')); + expect(screen.queryByText('authenticated')).toBeInTheDocument(); + }); + test('selectAuthenticated is false removeCredentials is dispatched', async () => { + renderWithProviders(, { preloadedState: { auth: { token: 'mockToken' } } }); + localStorage.setItem('token', 'mockToken'); + await userEvent.click(screen.getByText('Log out')); + expect(screen.queryByText('unauthenticated')).toBeInTheDocument(); + }); + test('tokens are removed from localStorage on logout', async () => { + renderWithProviders(, { preloadedState: { auth: { token: 'mockToken' } } }); + localStorage.setItem('token', 'mockToken'); + await userEvent.click(screen.getByText('Log out')); + expect(removeItemSpy).toHaveBeenCalled(); + }); +}); diff --git a/client/src/store/authSlice/auth.slice.ts b/client/src/store/authSlice/auth.slice.ts new file mode 100644 index 000000000..7dc0ddd9f --- /dev/null +++ b/client/src/store/authSlice/auth.slice.ts @@ -0,0 +1,54 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export interface HaztrakUser { + username: string; + email?: string; + firstName?: string; + lastName?: string; + isLoading?: boolean; + error?: string; +} + +/** + * The Redux stored information on the current haztrak user + */ +export interface AuthSlice { + user?: HaztrakUser; + token?: string; + loading?: boolean; + error?: string; +} + +const initialState: AuthSlice = { + user: { username: JSON.parse(localStorage.getItem('user') || 'null') || null, isLoading: false }, + token: JSON.parse(localStorage.getItem('token') || 'null') || null, + loading: false, + error: undefined, +}; +export const authSlice = createSlice({ + name: 'auth', + initialState, + selectors: { + selectAuthenticated: (state: AuthSlice): boolean => !!state.token, + selectUser: (state: AuthSlice): HaztrakUser | undefined => state.user, + selectUserName: (state: AuthSlice): string | undefined => state.user?.username, + }, + reducers: { + setCredentials(state: AuthSlice, action: PayloadAction<{ token: string }>) { + const { token } = action.payload; + localStorage.setItem('token', JSON.stringify(token)); + return { + ...state, + token, + } as AuthSlice; + }, + removeCredentials(): AuthSlice { + localStorage.removeItem('token'); + return { ...initialState, user: undefined, token: undefined }; + }, + }, +}); + +export default authSlice.reducer; +export const { removeCredentials, setCredentials } = authSlice.actions; +export const { selectAuthenticated, selectUser, selectUserName } = authSlice.selectors; diff --git a/client/src/store/haztrakApiSlice.ts b/client/src/store/htApi.slice.ts similarity index 86% rename from client/src/store/haztrakApiSlice.ts rename to client/src/store/htApi.slice.ts index e105eef6a..f2a8dac6c 100644 --- a/client/src/store/haztrakApiSlice.ts +++ b/client/src/store/htApi.slice.ts @@ -19,10 +19,8 @@ export interface HtApiQueryArgs { params?: AxiosRequestConfig['params']; } -export interface HtApiError { - status?: number; +export interface HtApiError extends AxiosError { data?: AxiosResponse['data']; - code?: string; statusText?: string; } @@ -50,10 +48,10 @@ export const htApiBaseQuery = return { data: response.data }; } catch (axiosError) { let err = axiosError as AxiosError; + console.log(err); return { error: { - code: err.code, - status: err.response?.status, + ...err, statusText: err.response?.statusText, data: err.response?.data || err.message, } as HtApiError, @@ -76,7 +74,7 @@ interface RcrainfoSiteSearch { } export const haztrakApi = createApi({ - tagTypes: ['user'], + tagTypes: ['user', 'auth', 'profile', 'rcrainfoProfile', 'site', 'code', 'manifest'], reducerPath: 'haztrakApi', baseQuery: htApiBaseQuery({ baseUrl: `${import.meta.env.VITE_HT_API_URL}/api/`, @@ -102,24 +100,35 @@ export const haztrakApi = createApi({ }), getFedWasteCodes: build.query, void>({ query: () => ({ url: 'rcra/waste/code/federal', method: 'get' }), + providesTags: ['code'], }), getStateWasteCodes: build.query, string>({ query: (state) => ({ url: `rcra/waste/code/state/${state}`, method: 'get' }), + providesTags: ['code'], }), getDotIdNumbers: build.query, string>({ query: (id) => ({ url: 'rcra/waste/dot/id', method: 'get', params: { q: id } }), + providesTags: ['code'], }), getOrgSites: build.query, string>({ query: (id) => ({ url: `org/${id}/site`, method: 'get' }), + providesTags: ['site'], }), getUserHaztrakSites: build.query, void>({ query: () => ({ url: 'site', method: 'get' }), + providesTags: ['site'], }), getUserHaztrakSite: build.query({ query: (epaId) => ({ url: `site/${epaId}`, method: 'get' }), + providesTags: ['site'], }), getMTN: build.query, string | undefined>({ query: (siteId) => ({ url: siteId ? `rcra/mtn/${siteId}` : 'rcra/mtn', method: 'get' }), + providesTags: ['manifest'], + }), + getManifest: build.query({ + query: (mtn) => ({ url: `rcra/manifest/${mtn}`, method: 'get' }), + providesTags: ['manifest'], }), createManifest: build.mutation({ query: (data) => ({ @@ -127,6 +136,7 @@ export const haztrakApi = createApi({ method: 'POST', data, }), + invalidatesTags: ['manifest'], }), updateManifest: build.mutation({ query: ({ mtn, manifest }) => ({ @@ -134,6 +144,7 @@ export const haztrakApi = createApi({ method: 'PUT', data: manifest, }), + invalidatesTags: ['manifest'], }), saveEManifest: build.mutation({ query: (data) => ({ @@ -141,6 +152,7 @@ export const haztrakApi = createApi({ method: 'POST', data, }), + invalidatesTags: ['manifest'], }), syncEManifest: build.mutation({ query: (siteId) => ({ @@ -148,6 +160,7 @@ export const haztrakApi = createApi({ method: 'POST', data: { siteId: siteId }, }), + invalidatesTags: ['manifest'], }), signEManifest: build.mutation({ query: (signature) => ({ @@ -155,6 +168,7 @@ export const haztrakApi = createApi({ method: 'POST', data: signature, }), + invalidatesTags: ['manifest'], }), }), }); diff --git a/client/src/store/index.ts b/client/src/store/index.ts index 2e28f106e..aab921280 100644 --- a/client/src/store/index.ts +++ b/client/src/store/index.ts @@ -1,6 +1,6 @@ -import { authApi } from 'store/userSlice/user.slice'; // Haztrak API - RTK Query -import { haztrakApi } from './haztrakApiSlice'; +import { haztrakApi } from 'store/htApi.slice'; +import { userApi } from 'store/userSlice/user.slice'; import type { AppDispatch, AppStore, RootState } from './rootStore'; // Root Store @@ -23,6 +23,7 @@ export const { useSyncEManifestMutation, useSignEManifestMutation, useUpdateManifestMutation, + useGetManifestQuery, } = haztrakApi; export const { @@ -33,26 +34,17 @@ export const { useUpdateUserMutation, useUpdateRcrainfoProfileMutation, useSyncRcrainfoProfileMutation, -} = authApi; +} = userApi; // Authentication Slice export { selectUser, selectUserName, - updateUserProfile, setCredentials, -} from 'store/userSlice/user.slice'; - -// Profile Slice -export { - selectRcrainfoSites, - selectRcraProfile, - siteByEpaIdSelector, - updateProfile, - selectHaztrakSites, - selectHaztrakSiteEpaIds, - selectHaztrakProfile, -} from './profileSlice/profile.slice'; + selectAuthenticated, + removeCredentials, +} from 'store/authSlice/auth.slice'; +export type { HaztrakUser } from 'store/authSlice/auth.slice'; // Notification Slice export { @@ -71,9 +63,8 @@ export { export { addError, selectAllErrors } from './errorSlice/error.slice'; // Types -export type { HaztrakUser } from 'store/userSlice/user.slice'; export type { HaztrakError } from './errorSlice/error.slice'; -export type { TaskStatus } from './haztrakApiSlice'; +export type { TaskStatus } from 'store/htApi.slice'; export type { LongRunningTask, HaztrakAlert } from './notificationSlice/notification.slice'; export type { ProfileSlice, @@ -82,6 +73,7 @@ export type { HaztrakSitePermissions, RcrainfoSitePermissions, HaztrakModulePermissions, + HaztrakProfileOrg, RcrainfoProfile, RcrainfoProfileSite, -} from './profileSlice/profile.slice'; +} from './userSlice/user.slice'; diff --git a/client/src/store/profileSlice/profile.slice.spec.tsx b/client/src/store/profileSlice/profile.slice.spec.tsx deleted file mode 100644 index cb3d5bcc8..000000000 --- a/client/src/store/profileSlice/profile.slice.spec.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/** - * RcrainfoProfile tests - */ -import '@testing-library/jest-dom'; -import { screen } from '@testing-library/react'; -import React from 'react'; -import { useAppSelector } from 'store'; -import { renderWithProviders } from 'test-utils'; -import { createMockSite } from 'test-utils/fixtures'; -import { createMockRcrainfoPermissions } from 'test-utils/fixtures/mockHandler'; -import { describe, expect, test } from 'vitest'; -import { selectRcrainfoSites, siteByEpaIdSelector } from './profile.slice'; - -interface TestComponentProps { - siteId: string; -} - -function TestComponent({ siteId }: TestComponentProps) { - const rcraSite = useAppSelector(siteByEpaIdSelector(siteId)); - return ( - <> -

{rcraSite?.epaSiteId}

- - ); -} - -describe('RcraProfileSlice selectors', () => { - test('Retrieve RcraProfileSite by EPA ID', () => { - const mySite = createMockSite(); - renderWithProviders(, { - preloadedState: { - profile: { - user: 'testuser1', - sites: { VATESTGEN001: { ...mySite, permissions: { eManifest: 'viewer' } } }, - rcrainfoProfile: { - user: 'username', - phoneNumber: '1231231234', - apiUser: false, - rcraSites: { - VATESTGEN001: { - epaSiteId: mySite.handler.epaSiteId, - permissions: createMockRcrainfoPermissions(), - }, - }, - }, - }, - }, - }); - expect(screen.getByText(mySite.handler.epaSiteId)).toBeInTheDocument(); - }); - test('retrieve all RcraProfileSites', () => { - const mySite = createMockSite(); - const TestComp = () => { - const myRcraSite = useAppSelector(selectRcrainfoSites); - return ( - <> -

{myRcraSite ? Object.keys(myRcraSite).length : 'not defined'}

- {myRcraSite - ? myRcraSite.map((site, index) => ( -

{site.epaSiteId}

- )) - : 'not defined'} - - ); - }; - renderWithProviders(, { - preloadedState: { - profile: { - user: 'testuser1', - rcrainfoProfile: { - user: 'username', - phoneNumber: '1231231234', - apiUser: false, - rcraSites: { - VATESTGEN001: { - epaSiteId: mySite.handler.epaSiteId, - permissions: createMockRcrainfoPermissions(), - }, - VATEST00001: { - epaSiteId: mySite.handler.epaSiteId, - permissions: createMockRcrainfoPermissions(), - }, - }, - }, - }, - }, - }); - expect(screen.getByText('2')).toBeInTheDocument(); - expect(screen.queryByText(/Not Defined/i)).not.toBeInTheDocument(); - }); -}); diff --git a/client/src/store/profileSlice/profile.slice.ts b/client/src/store/profileSlice/profile.slice.ts deleted file mode 100644 index f7629eb5c..000000000 --- a/client/src/store/profileSlice/profile.slice.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * A user's RcrainfoProfile slice encapsulates our logic related what actions and data a user - * has access to for each EPA site ID. - */ -import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { HaztrakSite } from 'components/HaztrakSite'; -import { RcraSite } from 'components/RcraSite'; -import { RootState } from 'store'; - -/**The user's RCRAInfo account data stored in the Redux store*/ -export interface ProfileSlice { - user: string | undefined; - rcrainfoProfile?: RcrainfoProfile>; - sites?: Record; - org?: HaztrakProfileOrg | null; - loading?: boolean; - error?: string; -} - -export interface HaztrakProfileOrg { - id: string; - name: string; - rcrainfoIntegrated: boolean; -} - -/** A site a user has access to in RCRAInfo and their module permissions */ -export interface RcrainfoProfileSite { - epaSiteId: string; - permissions: RcrainfoSitePermissions; -} - -export interface HaztrakProfileSite extends HaztrakSite { - permissions: HaztrakSitePermissions; -} - -export type HaztrakModulePermissions = 'viewer' | 'editor' | 'signer'; - -export interface HaztrakSitePermissions { - eManifest: HaztrakModulePermissions; -} - -export interface RcrainfoProfileState - extends RcrainfoProfile> {} - -export interface RcrainfoProfile { - user: string; - rcraAPIID?: string; - rcraUsername?: string; - rcraAPIKey?: string; - apiUser?: boolean; - rcraSites?: T; - phoneNumber?: string; - isLoading?: boolean; - error?: string; -} - -export interface RcrainfoSitePermissions { - siteManagement: boolean; - annualReport: string; - biennialReport: string; - eManifest: string; - WIETS: string; - myRCRAid: string; -} - -/**initial, state of a user's RcrainfoProfile.*/ -const initialState: ProfileSlice = { - user: undefined, - rcrainfoProfile: undefined, - sites: undefined, - loading: false, - error: undefined, -}; - -const profileSlice = createSlice({ - name: 'profile', - initialState, - reducers: { - updateProfile: (state: ProfileSlice, action: PayloadAction) => { - return { - ...state, - ...action.payload, - }; - }, - }, -}); - -/** Retrieve a Haztrak site from the users Profile by the site's EPA ID number */ -export const siteByEpaIdSelector = ( - epaId: string | undefined -): ((state: RootState) => RcraSite | undefined) => - createSelector( - (state: { profile: ProfileSlice }) => state.profile.sites, - (sites: Record | undefined) => { - if (!sites) return undefined; - - const siteId = Object.keys(sites).find((key) => sites[key]?.handler.epaSiteId === epaId); - if (!siteId) return undefined; - - const sitePermissions = sites[siteId]; - if (!sitePermissions) return undefined; - - return sitePermissions.handler; - } - ); - -/** Get all sites a user has access to their Haztrak Profile*/ -export const selectHaztrakSites = createSelector( - (state: { profile: ProfileSlice }) => state.profile.sites, - (sites: Record | undefined) => { - if (!sites) return undefined; - - return Object.values(sites).map((site) => site); - } -); - -/** Get all sites a user has access to their Haztrak Profile*/ -export const selectHaztrakSiteEpaIds = createSelector( - (state: { profile: ProfileSlice }) => state.profile.sites, - (sites: Record | undefined) => { - if (!sites) return []; - return Object.values(sites).map((site) => site.handler.epaSiteId); - } -); - -/** select all RCRAInfo sites a user has access to from their RCRAInfo Profile if they're updated it*/ -export const selectRcrainfoSites = createSelector( - (state: { profile: ProfileSlice }) => state.profile.rcrainfoProfile?.rcraSites, - (rcraSites: Record | undefined) => { - if (!rcraSites) return undefined; - - return Object.values(rcraSites).map((site) => site); - } -); - -/** Retrieve a user's RcrainfoProfile from the Redux store. */ -export const selectRcraProfile = createSelector( - (state: RootState) => state, - (state: RootState) => state.profile.rcrainfoProfile -); - -/** Retrieve a user's HaztrakProfile from the Redux store. */ -export const selectHaztrakProfile = createSelector( - (state: RootState) => state, - (state: RootState) => state.profile -); - -export default profileSlice.reducer; -export const { updateProfile } = profileSlice.actions; diff --git a/client/src/store/rootStore.ts b/client/src/store/rootStore.ts index f9077ac99..8a80e7aeb 100644 --- a/client/src/store/rootStore.ts +++ b/client/src/store/rootStore.ts @@ -1,26 +1,22 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'; import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; -import userReducers, { authApi } from 'store/userSlice/user.slice'; +import authReducers from 'store/authSlice/auth.slice'; +import { haztrakApi } from 'store/htApi.slice'; import errorReducers from './errorSlice/error.slice'; -import { haztrakApi } from './haztrakApiSlice'; import notificationReducers from './notificationSlice/notification.slice'; -import profileReducers from './profileSlice/profile.slice'; const rootReducer = combineReducers({ - auth: userReducers, - profile: profileReducers, + auth: authReducers, error: errorReducers, notifications: notificationReducers, [haztrakApi.reducerPath]: haztrakApi.reducer, - [authApi.reducerPath]: authApi.reducer, }); /**A utility function to initialize the store with preloaded state used for testing*/ const setupStore = (preloadedState?: Partial) => { return configureStore({ reducer: rootReducer, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware().concat(haztrakApi.middleware, authApi.middleware), + middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(haztrakApi.middleware), preloadedState, }); }; diff --git a/client/src/store/userSlice/user.slice.spec.tsx b/client/src/store/userSlice/user.slice.spec.tsx new file mode 100644 index 000000000..40fd90705 --- /dev/null +++ b/client/src/store/userSlice/user.slice.spec.tsx @@ -0,0 +1,60 @@ +import '@testing-library/jest-dom'; +import { waitFor } from '@testing-library/react'; +import { setupServer } from 'msw/node'; +import { useEffect, useState } from 'react'; +import { useGetUserQuery, useUpdateUserMutation } from 'store'; +import { cleanup, renderWithProviders, screen } from 'test-utils'; +import { userApiMocks } from 'test-utils/mock'; +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; + +const server = setupServer(...userApiMocks); +afterEach(() => { + cleanup(); +}); +beforeAll(() => server.listen()); +afterAll(() => server.close()); + +const UserQueryComponent = () => { + const [fetchCount, setFetchCount] = useState(0); + const { data, error, isLoading, isFetching } = useGetUserQuery(); + const [updateUser, results] = useUpdateUserMutation(); + + useEffect(() => { + if (isFetching) setFetchCount(fetchCount + 1); + }, [isFetching]); + + if (isLoading || isFetching) { + return
Loading
; + } + if (error) { + return
Error
; + } + if (data) { + return ( +
+

Data

+

username: {data.username}

+

email: {data.email}

+

fetchCount: {fetchCount}

+
+ +
+
+ ); + } +}; + +describe('userApi', () => { + test('get user query', async () => { + renderWithProviders(); + await waitFor(() => screen.getByText('Data')); + expect(screen.queryByText(/username:/i)).toBeInTheDocument(); + }); +}); diff --git a/client/src/store/userSlice/user.slice.ts b/client/src/store/userSlice/user.slice.ts index 72976a720..3d84ebd4a 100644 --- a/client/src/store/userSlice/user.slice.ts +++ b/client/src/store/userSlice/user.slice.ts @@ -1,81 +1,69 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { createApi } from '@reduxjs/toolkit/query/react'; import { HaztrakSite } from 'components/HaztrakSite'; -import { htApiBaseQuery, TaskResponse } from 'store/haztrakApiSlice'; -import { - HaztrakModulePermissions, - ProfileSlice, - RcrainfoProfile, - RcrainfoProfileSite, - RcrainfoProfileState, -} from 'store/profileSlice/profile.slice'; +import { HaztrakUser } from 'store/authSlice/auth.slice'; +import { haztrakApi, TaskResponse } from 'store/htApi.slice'; -export interface HaztrakUser { - username: string; - email?: string; - firstName?: string; - lastName?: string; - isLoading?: boolean; +/**The user's RCRAInfo account data stored in the Redux store*/ +export interface ProfileSlice { + user: string | undefined; + rcrainfoProfile?: RcrainfoProfile>; + sites?: Record; + org?: HaztrakProfileOrg | null; + loading?: boolean; error?: string; } -/** - * The Redux stored information on the current haztrak user - */ -export interface UserSlice { - user?: HaztrakUser; - token?: string; - loading?: boolean; - error?: string; +export interface HaztrakProfileOrg { + id: string; + name: string; + rcrainfoIntegrated: boolean; } -const initialState: UserSlice = { - user: { username: JSON.parse(localStorage.getItem('user') || 'null') || null, isLoading: false }, - token: JSON.parse(localStorage.getItem('token') || 'null') || null, - loading: false, - error: undefined, -}; +/** A site a user has access to in RCRAInfo and their module permissions */ +export interface RcrainfoProfileSite { + epaSiteId: string; + permissions: RcrainfoSitePermissions; +} -const authSlice = createSlice({ - name: 'auth', - initialState, - selectors: { - selectAuthenticated: (state: UserSlice): boolean => !!state.token, - selectUser: (state: UserSlice): HaztrakUser | undefined => state.user, - selectUserName: (state: UserSlice): string | undefined => state.user?.username, - }, - reducers: { - setCredentials(state: UserSlice, action: PayloadAction<{ token: string }>) { - const token = action.payload.token; - localStorage.setItem('token', JSON.stringify(token)); - return { - ...state, - token, - } as UserSlice; - }, - logout(state: UserSlice): UserSlice { - localStorage.removeItem('token'); - return { ...initialState, user: undefined, token: undefined }; - }, - updateUserProfile(state: UserSlice, action: PayloadAction) { - return { - ...state, - user: action.payload, - }; - }, - }, -}); +export interface HaztrakProfileSite extends HaztrakSite { + permissions: HaztrakSitePermissions; +} -export default authSlice.reducer; -export const { updateUserProfile, logout, setCredentials } = authSlice.actions; -export const { selectAuthenticated, selectUser, selectUserName } = authSlice.selectors; +export type HaztrakModulePermissions = 'viewer' | 'editor' | 'signer'; -interface LoginRequest { +export interface HaztrakSitePermissions { + eManifest: HaztrakModulePermissions; +} + +export interface RcrainfoProfileState + extends RcrainfoProfile> {} + +export interface RcrainfoProfile { + user: string; + rcraAPIID?: string; + rcraUsername?: string; + rcraAPIKey?: string; + apiUser?: boolean; + rcraSites?: T; + phoneNumber?: string; + isLoading?: boolean; + error?: string; +} + +export interface RcrainfoSitePermissions { + siteManagement: boolean; + annualReport: string; + biennialReport: string; + eManifest: string; + WIETS: string; + myRCRAid: string; +} + +export interface LoginRequest { username: string; password: string; } -interface LoginResponse { +export interface LoginResponse { key: string; } @@ -85,7 +73,7 @@ interface HaztrakOrgResponse { rcrainfoIntegrated: boolean; } -interface HaztrakProfileResponse { +export interface HaztrakProfileResponse { user: string; sites: Array<{ site: HaztrakSite; @@ -96,17 +84,12 @@ interface HaztrakProfileResponse { interface RcrainfoProfileResponse extends RcrainfoProfile> {} -export const authApi = createApi({ - tagTypes: ['user', 'auth', 'profile', 'rcrainfoProfile'], - reducerPath: 'authApi', - baseQuery: htApiBaseQuery({ - baseUrl: `${import.meta.env.VITE_HT_API_URL}/api/user`, - }), +export const userApi = haztrakApi.injectEndpoints({ endpoints: (build) => ({ // Note: build.query login: build.mutation({ query: (data) => ({ - url: '/login', + url: 'login', method: 'POST', data: data, }), @@ -114,14 +97,14 @@ export const authApi = createApi({ }), getUser: build.query({ query: () => ({ - url: '', + url: 'user', method: 'GET', }), providesTags: ['user'], }), updateUser: build.mutation({ query: (data) => ({ - url: '', + url: 'user', method: 'PUT', data: data, }), @@ -129,7 +112,7 @@ export const authApi = createApi({ }), getProfile: build.query({ query: () => ({ - url: '/profile', + url: 'user/profile', method: 'GET', }), providesTags: ['profile'], @@ -152,7 +135,7 @@ export const authApi = createApi({ }), getRcrainfoProfile: build.query({ query: (username) => ({ - url: `/rcrainfo-profile/${username}`, + url: `user/rcrainfo-profile/${username}`, method: 'GET', }), providesTags: ['rcrainfoProfile'], @@ -171,7 +154,7 @@ export const authApi = createApi({ }), updateRcrainfoProfile: build.mutation({ query: (data) => ({ - url: `/rcrainfo-profile/${data.username}`, + url: `user/rcrainfo-profile/${data.username}`, method: 'PUT', data: data.data, }), @@ -179,7 +162,7 @@ export const authApi = createApi({ }), syncRcrainfoProfile: build.mutation({ query: () => ({ - url: `/rcrainfo-profile/sync`, + url: `user/rcrainfo-profile/sync`, method: 'POST', }), invalidatesTags: ['rcrainfoProfile'], diff --git a/client/src/test-utils/fixtures/index.ts b/client/src/test-utils/fixtures/index.ts index 687b20c00..3938bc145 100644 --- a/client/src/test-utils/fixtures/index.ts +++ b/client/src/test-utils/fixtures/index.ts @@ -9,3 +9,4 @@ export { createMockRcrainfoPermissions, } from './mockHandler'; export { createMockManifest } from './mockManifest'; +export { createMockHaztrakUser } from './mockUser'; diff --git a/client/src/test-utils/fixtures/mockUser.ts b/client/src/test-utils/fixtures/mockUser.ts new file mode 100644 index 000000000..ad145e19e --- /dev/null +++ b/client/src/test-utils/fixtures/mockUser.ts @@ -0,0 +1,57 @@ +import { HaztrakProfileOrg, HaztrakUser, RcrainfoProfile, RcrainfoProfileSite } from 'store'; +import { HaztrakProfileResponse } from 'store/userSlice/user.slice'; +import { createMockSite } from 'test-utils/fixtures/mockHandler'; + +export const DEFAULT_HAZTRAK_USER: HaztrakUser = { + username: 'testuser1', + firstName: 'john', + lastName: 'smith', + email: 'test@mail.com', +}; + +export function createMockHaztrakUser(overWrites?: Partial): HaztrakUser { + return { + ...DEFAULT_HAZTRAK_USER, + ...overWrites, + }; +} + +interface RcrainfoProfileResponse extends RcrainfoProfile> {} + +const DEFAULT_RCRAINFO_PROFILE_RESPONSE: RcrainfoProfileResponse = { + user: 'testuser1', + rcraAPIID: 'mockRcraAPIID', + rcraUsername: undefined, + rcraSites: [], + phoneNumber: undefined, + apiUser: true, +}; + +export function createMockRcrainfoProfileResponse( + overWrites?: Partial +): RcrainfoProfileResponse { + return { + ...DEFAULT_RCRAINFO_PROFILE_RESPONSE, + ...overWrites, + }; +} + +export function createMockOrg(overWrites?: Partial): HaztrakProfileOrg { + return { + name: 'mockOrg', + id: 'mockOrgId', + rcrainfoIntegrated: true, + ...overWrites, + }; +} + +export function createMockProfileResponse( + overWrites?: Partial +): HaztrakProfileResponse { + return { + user: DEFAULT_HAZTRAK_USER.username, + org: createMockOrg(), + sites: [{ site: createMockSite(), eManifest: 'signer' }], + ...overWrites, + }; +} diff --git a/client/src/test-utils/index.tsx b/client/src/test-utils/index.ts similarity index 100% rename from client/src/test-utils/index.tsx rename to client/src/test-utils/index.ts diff --git a/client/src/test-utils/mock/browser.js b/client/src/test-utils/mock/browser.js deleted file mode 100644 index b3e2bd2f8..000000000 --- a/client/src/test-utils/mock/browser.js +++ /dev/null @@ -1,4 +0,0 @@ -// src/test/browser.js - -// This configures a Service Worker with the given request handlers. -export const worker = setupWorker(...handlers); diff --git a/client/src/test-utils/mock/handlers.ts b/client/src/test-utils/mock/htApiMocks.ts similarity index 57% rename from client/src/test-utils/mock/handlers.ts rename to client/src/test-utils/mock/htApiMocks.ts index 828706099..a17dcfe9b 100644 --- a/client/src/test-utils/mock/handlers.ts +++ b/client/src/test-utils/mock/htApiMocks.ts @@ -4,39 +4,9 @@ import { createMockHandler, createMockManifest, createMockSite } from '../fixtur export const API_BASE_URL = import.meta.env.VITE_HT_API_URL; const mockMTN = createMockManifest().manifestTrackingNumber; const mockEpaId = createMockHandler().epaSiteId; -const mockUsername = 'testuser1'; const mockSites = [createMockSite(), createMockSite()]; -export const handlers = [ - /** Login endpoint*/ - http.post(`${API_BASE_URL}/api/user/login`, (info) => { - // Persist user's authentication in the session - sessionStorage.setItem('token', 'this_is_a_fake_token'); - sessionStorage.setItem('user', mockUsername); - - // Mock response from haztrak API - return HttpResponse.json( - { - token: 'fake_token', - user: mockUsername, - }, - { status: 200 } - ); - }), - /** User RcrainfoProfile data*/ - http.get(`${API_BASE_URL}/api/user/rcrainfo-profile/${mockUsername}`, (info) => { - return HttpResponse.json( - { - user: mockUsername, - rcraAPIID: 'mockRcraAPIID', - rcraUsername: undefined, - epaSites: [], - phoneNumber: undefined, - apiUser: true, - }, - { status: 200 } - ); - }), +export const htApiMocks = [ /** List user sites*/ http.get(`${API_BASE_URL}/api/site`, (info) => { return HttpResponse.json(mockSites, { status: 200 }); diff --git a/client/src/test-utils/mock/index.ts b/client/src/test-utils/mock/index.ts new file mode 100644 index 000000000..720f3980a --- /dev/null +++ b/client/src/test-utils/mock/index.ts @@ -0,0 +1,2 @@ +export { userApiMocks } from './userApiMocks'; +export { htApiMocks } from './htApiMocks'; diff --git a/client/src/test-utils/mock/userApiMocks.ts b/client/src/test-utils/mock/userApiMocks.ts new file mode 100644 index 000000000..eaf852d3e --- /dev/null +++ b/client/src/test-utils/mock/userApiMocks.ts @@ -0,0 +1,48 @@ +import { http, HttpResponse } from 'msw'; +import { HaztrakUser } from 'store/authSlice/auth.slice'; +import { LoginResponse } from 'store/userSlice/user.slice'; +import { createMockHaztrakUser } from 'test-utils/fixtures'; +import { + createMockProfileResponse, + createMockRcrainfoProfileResponse, +} from 'test-utils/fixtures/mockUser'; + +/** mock Rest API*/ +const API_BASE_URL = import.meta.env.VITE_HT_API_URL; +export const userApiMocks = [ + /** GET User */ + http.get(`${API_BASE_URL}/api/user`, () => { + return HttpResponse.json({ ...createMockHaztrakUser() }, { status: 200 }); + }), + /** Update User */ + http.put(`${API_BASE_URL}/api/user`, (info) => { + const user: HaztrakUser = { ...createMockHaztrakUser() }; + return HttpResponse.json({ ...user, ...info.request.body }, { status: 200 }); + }), + /** GET Profile */ + http.get(`${API_BASE_URL}/api/user/profile`, () => { + return HttpResponse.json({ ...createMockProfileResponse() }, { status: 200 }); + }), + /** Login */ + http.post(`${API_BASE_URL}/api/user/login`, (info) => { + const body: LoginResponse = { key: 'mockToken' }; + return HttpResponse.json( + { + ...body, + }, + { status: 200 } + ); + }), + /** GET RCRAInfo profile */ + http.get(`${API_BASE_URL}/api/user/rcrainfo-profile/:username`, (info) => { + const { username } = info.params; + // @ts-ignore + const rcrainfoProfile = createMockRcrainfoProfileResponse({ user: username ?? '' }); + return HttpResponse.json( + { + ...rcrainfoProfile, + }, + { status: 200 } + ); + }), +]; diff --git a/server/apps/core/tests/test_rcra_profile_views.py b/server/apps/core/tests/test_rcrainfo_profile_views.py similarity index 100% rename from server/apps/core/tests/test_rcra_profile_views.py rename to server/apps/core/tests/test_rcrainfo_profile_views.py