From 5019508182064b35c496e3fa419e315b44959a83 Mon Sep 17 00:00:00 2001 From: ktvtk Date: Fri, 20 Dec 2024 21:45:30 +0500 Subject: [PATCH] hw 9.2 --- .../cities-list/cities-list.test.tsx | 80 ++++++++++++++++++ src/components/header/header.test.tsx | 8 +- src/components/map/map.test.tsx | 48 +++++++++++ src/components/map/map.tsx | 2 +- .../offers-list/offers-list.test.tsx | 43 ++++++++++ .../favorites-screen/favorite-screen.test.tsx | 61 ++++++++++++++ src/pages/login-screen/login-screen.test.tsx | 65 ++++++++++++++ .../main-empty-screen.test.tsx | 42 ++++++++++ src/pages/main-screen/main-screen.test.tsx | 44 ++++++++++ .../not-found-screen.test.tsx | 19 +++++ src/pages/offer-screen/offer-screen.test.tsx | 84 +++++++++++++++++++ src/store/api-actions.test.ts | 6 +- src/store/user-data/selectors.test.ts | 3 +- src/store/user-data/user-data.test.ts | 5 +- src/utils/mocks-components.tsx | 31 +++++++ src/utils/mocks.ts | 28 ++++++- 16 files changed, 558 insertions(+), 11 deletions(-) create mode 100644 src/components/cities-list/cities-list.test.tsx create mode 100644 src/components/map/map.test.tsx create mode 100644 src/components/offers-list/offers-list.test.tsx create mode 100644 src/pages/favorites-screen/favorite-screen.test.tsx create mode 100644 src/pages/login-screen/login-screen.test.tsx create mode 100644 src/pages/main-empty-screen/main-empty-screen.test.tsx create mode 100644 src/pages/main-screen/main-screen.test.tsx create mode 100644 src/pages/not-found-screen/not-found-screen.test.tsx create mode 100644 src/pages/offer-screen/offer-screen.test.tsx create mode 100644 src/utils/mocks-components.tsx diff --git a/src/components/cities-list/cities-list.test.tsx b/src/components/cities-list/cities-list.test.tsx new file mode 100644 index 0000000..e2cc900 --- /dev/null +++ b/src/components/cities-list/cities-list.test.tsx @@ -0,0 +1,80 @@ +import {BRUSSELS, CITIES, PARIS} from '../../const.ts'; +import {configureMockStore} from '@jedmao/redux-mock-store'; +import {State} from '../../types/state.ts'; +import {render, screen} from '@testing-library/react'; +import {Provider} from 'react-redux'; +import {MemoryRouter} from 'react-router-dom'; +import MemoizedCitiesList from './cities-list.tsx'; +import userEvent from '@testing-library/user-event'; + +const mockStore = configureMockStore(); + +describe('Component: CitiesList', () => { + const initialState = { + APP: { + city: PARIS, + }, + }; + + const store = mockStore(initialState); + + it('should render all cities correctly', () => { + render( + + + + + + ); + + CITIES.forEach((city) => { + expect(screen.getByText(city.name)).toBeInTheDocument(); + }); + }); + + it('should have active state for the currently selected city', () => { + render( + + + + + + ); + + const parisLink = screen.getByText('Paris'); + expect(parisLink.closest('div')).toHaveClass('tabs__item--active'); + }); + + it('should dispatch active city change on city click', async () => { + const user = userEvent.setup(); + + render( + + + + + + ); + + const brusselsLink = screen.getByText('Brussels'); + await user.click(brusselsLink); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0].type).toBe('APP/changeActiveCity'); + expect(actions[0].payload).toBe(BRUSSELS); + }); + + it('should have correct number of city items', () => { + render( + + + + + + ); + + const cityItems = screen.getAllByRole('listitem'); + expect(cityItems).toHaveLength(CITIES.length); + }); +}); diff --git a/src/components/header/header.test.tsx b/src/components/header/header.test.tsx index 58b395e..2a05102 100644 --- a/src/components/header/header.test.tsx +++ b/src/components/header/header.test.tsx @@ -8,6 +8,7 @@ import MemoizedHeader from './header.tsx'; import userEvent from '@testing-library/user-event'; import {logout} from '../../store/api-actions.ts'; import {MemoryRouter} from 'react-router-dom'; +import {internet} from 'faker'; describe('Component: Header', () => { const middlewares = [thunk]; @@ -22,10 +23,11 @@ describe('Component: Header', () => { }, }; + const testUserEmail = internet.email() const initialStateAuth = { USER: { authorizationStatus: AuthorizationStatus.Authorized, - userEmail: 'example@example.com', + userEmail: testUserEmail, }, OFFERS: { favoritesCount: 2, @@ -59,7 +61,7 @@ describe('Component: Header', () => { ); - const userEmail = screen.getByText('example@example.com'); + const userEmail = screen.getByText(testUserEmail); expect(userEmail).toBeInTheDocument(); const favoritesCount = screen.getByText('2'); @@ -99,7 +101,7 @@ describe('Component: Header', () => { ); - const favoritesLink = screen.getByText('example@example.com').closest('a'); + const favoritesLink = screen.getByText(testUserEmail).closest('a'); expect(favoritesLink).toHaveAttribute('href', AppRoute.Favorites); }); }); diff --git a/src/components/map/map.test.tsx b/src/components/map/map.test.tsx new file mode 100644 index 0000000..bd5ef72 --- /dev/null +++ b/src/components/map/map.test.tsx @@ -0,0 +1,48 @@ +import {Offer} from '../../types/offer.ts'; +import {makeFakeOffer} from '../../utils/mocks.ts'; +import {render, screen} from '@testing-library/react'; +import {PARIS} from '../../const.ts'; +import {Map} from './map.tsx'; + +vi.mock('../../hooks/useMap', () => ({ + __esModule: true, + default: vi.fn(), +})); + + +describe('Component: Map', () => { + let mockOffers: Offer[]; + let mockActiveOffer: Offer; + + beforeEach(() => { + mockOffers = [makeFakeOffer(), makeFakeOffer()]; + mockActiveOffer = mockOffers[0]; + }); + + it('should render map correctly', () => { + render( + + ); + + const mapContainer = screen.getByTestId('map'); + expect(mapContainer).toBeInTheDocument(); + expect(mapContainer).toHaveClass('cities__map map') + }); + + it('should highlight active marker when activeOffer is provided', () => { + render( + + ); + const activeMarkerIconUrl = 'img/pin-active.svg'; + expect(mockActiveOffer.id).toBe(mockOffers[0].id); + expect(activeMarkerIconUrl).toBe('img/pin-active.svg'); + }); +}); diff --git a/src/components/map/map.tsx b/src/components/map/map.tsx index b973e97..b5309f0 100644 --- a/src/components/map/map.tsx +++ b/src/components/map/map.tsx @@ -38,5 +38,5 @@ export function Map({ city, offers, selectedOffer }: MapProps): JSX.Element { } }, [map, offers, selectedOffer]); - return
; + return
; } diff --git a/src/components/offers-list/offers-list.test.tsx b/src/components/offers-list/offers-list.test.tsx new file mode 100644 index 0000000..70eec12 --- /dev/null +++ b/src/components/offers-list/offers-list.test.tsx @@ -0,0 +1,43 @@ +import {render, screen} from '@testing-library/react'; +import {Provider} from 'react-redux'; +import {store} from '../../store'; +import {MemoryRouter} from 'react-router-dom'; +import {makeFakeOffer} from '../../utils/mocks.ts'; +import {OffersList} from './offers-list.tsx'; +import userEvent from '@testing-library/user-event'; + +describe('Component: OffersList', () => { + const renderWithProvider = (elem: React.ReactElement) => + render( + + {elem} + + ); + + it('should render offers correctly', () => { + const mockOffers = [makeFakeOffer(), makeFakeOffer(), makeFakeOffer()]; + + renderWithProvider( {}} />); + + mockOffers.forEach((offer) => { + expect(screen.getByText(offer.title)).toBeInTheDocument(); + }); + }); + + it('should call onOfferHover when an offer is hovered', async () => { + const mockOffers = [makeFakeOffer(), makeFakeOffer()]; + const handleOfferHover = vi.fn(); + const user = userEvent.setup(); + + renderWithProvider(); + + const firstOffer = screen.getByText(mockOffers[0].title); + await user.hover(firstOffer); + + expect(handleOfferHover).toHaveBeenCalledWith(mockOffers[0].id); + + await user.unhover(firstOffer); + + expect(handleOfferHover).toHaveBeenCalledWith(null); + }); +}); diff --git a/src/pages/favorites-screen/favorite-screen.test.tsx b/src/pages/favorites-screen/favorite-screen.test.tsx new file mode 100644 index 0000000..afb1b58 --- /dev/null +++ b/src/pages/favorites-screen/favorite-screen.test.tsx @@ -0,0 +1,61 @@ +import {FavoriteScreen} from './favorite-screen.tsx'; +import {withStore} from '../../utils/mocks-components.tsx'; +import {makeFakeOffer, makeFakeStore} from '../../utils/mocks.ts'; +import {AuthorizationStatus} from '../../const.ts'; +import {render, screen} from '@testing-library/react'; +import {HelmetProvider} from 'react-helmet-async'; +import {MemoryRouter} from 'react-router-dom'; +import {internet} from 'faker'; + +describe('Component: FavoriteScreen', () => { + const component = + + + + + + it('should render "Nothing yet saved" when there are no favorite offers', () => { + const { withStoreComponent } = withStore( + component, + makeFakeStore({ + USER: { + authorizationStatus: AuthorizationStatus.Authorized, + userEmail: internet.email(), + }, + OFFERS: { + offers: [], + favoritesOffers: [], + favoritesCount: 0, + }, + }) + ); + + render(withStoreComponent); + + expect(screen.getByText(/Nothing yet saved/i)).toBeInTheDocument(); + }); + + it('should render favorite offers when there are favorite offers', () => { + const favoriteOffer = makeFakeOffer(); + + const { withStoreComponent } = withStore( + component, + makeFakeStore({ + USER: { + authorizationStatus: AuthorizationStatus.Authorized, + userEmail: internet.email(), + }, + OFFERS: { + offers: [], + favoritesOffers: [favoriteOffer], + favoritesCount: 0, + }, + }) + ); + + render(withStoreComponent); + + expect(screen.getByText(/Saved listing/i)).toBeInTheDocument(); + expect(screen.getByText(favoriteOffer.title)).toBeInTheDocument(); + }); +}); diff --git a/src/pages/login-screen/login-screen.test.tsx b/src/pages/login-screen/login-screen.test.tsx new file mode 100644 index 0000000..87a21ec --- /dev/null +++ b/src/pages/login-screen/login-screen.test.tsx @@ -0,0 +1,65 @@ +import {withStore} from '../../utils/mocks-components.tsx'; +import {makeFakeStore} from '../../utils/mocks.ts'; +import {AuthorizationStatus, LoadingStatus, PARIS} from '../../const.ts'; +import {MemoryRouter} from 'react-router-dom'; +import {HelmetProvider} from 'react-helmet-async'; +import {LoginScreen} from './login-screen.tsx'; +import {fireEvent, render, screen} from '@testing-library/react'; +import {internet} from 'faker'; + +describe('Component: LoginScreen', () => { + const component = + + + + + + it('should render correctly', () => { + const { withStoreComponent } = withStore( + component, + makeFakeStore({ + USER: { + authorizationStatus: AuthorizationStatus.Unauthorized, + userEmail: null, + }, + APP: { + city: PARIS, + loadingStatus: LoadingStatus.Succeed + }, + }) + ); + + render(withStoreComponent); + + expect(screen.getByRole('heading', { name: /Sign in/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/Email/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/Password/i)).toBeInTheDocument(); + }); + + + it('should handle form submission', () => { + const { withStoreComponent, mockStore } = withStore( + component, + makeFakeStore({ + USER: { + authorizationStatus: AuthorizationStatus.Unauthorized, + userEmail: null, + }, + APP: { + city: PARIS, + loadingStatus: LoadingStatus.Succeed + }, + }) + ); + + render(withStoreComponent); + + fireEvent.change(screen.getByPlaceholderText(/Email/i), { target: { value: internet.email() } }); + fireEvent.change(screen.getByPlaceholderText(/Password/i), { target: { value: 'password123' } }); + fireEvent.click(screen.getByRole('button', { name: /Sign in/i })); + + const actions = mockStore.getActions(); + expect(actions[0].type).toBe('auth/login/pending'); + }); +}); diff --git a/src/pages/main-empty-screen/main-empty-screen.test.tsx b/src/pages/main-empty-screen/main-empty-screen.test.tsx new file mode 100644 index 0000000..f6ff9ae --- /dev/null +++ b/src/pages/main-empty-screen/main-empty-screen.test.tsx @@ -0,0 +1,42 @@ +import {MemoryRouter} from 'react-router-dom'; +import {HelmetProvider} from 'react-helmet-async'; +import {MainEmptyScreen} from './main-empty-screen.tsx'; +import {withStore} from '../../utils/mocks-components.tsx'; +import {makeFakeStore} from '../../utils/mocks.ts'; +import {AuthorizationStatus, LoadingStatus, PARIS} from '../../const.ts'; +import {internet} from 'faker'; +import {render, screen} from '@testing-library/react'; + +describe('Component: MainEmptyScreen', () => { + const component = + + + + + + it('should render "No places to stay available" when there are no offers', () => { + const { withStoreComponent } = withStore( + component, + makeFakeStore({ + USER: { + authorizationStatus: AuthorizationStatus.Authorized, + userEmail: internet.email(), + }, + OFFERS: { + offers: [], + favoritesOffers: [], + favoritesCount: 0, + }, + APP: { + city: PARIS, + loadingStatus: LoadingStatus.Succeed + }, + }) + ); + + render(withStoreComponent); + + expect(screen.getByText(/No places to stay available/i)).toBeInTheDocument(); + expect(screen.getByText(/We could not find any property available at the moment in Paris/i)).toBeInTheDocument(); + }); +}) diff --git a/src/pages/main-screen/main-screen.test.tsx b/src/pages/main-screen/main-screen.test.tsx new file mode 100644 index 0000000..3ff9775 --- /dev/null +++ b/src/pages/main-screen/main-screen.test.tsx @@ -0,0 +1,44 @@ +import {MemoryRouter} from 'react-router-dom'; +import {HelmetProvider} from 'react-helmet-async'; +import {MainScreen} from './main-screen.tsx'; +import {makeFakeOffer, makeFakeStore} from '../../utils/mocks.ts'; +import {withStore} from '../../utils/mocks-components.tsx'; +import {AuthorizationStatus, LoadingStatus} from '../../const.ts'; +import {internet} from 'faker'; +import {render, screen} from '@testing-library/react'; + +describe('Component: MainScreen', () => { + const component = + + + + + + it('should render offers when there are offers available', () => { + const offer = makeFakeOffer(); + + const { withStoreComponent } = withStore( + component, + makeFakeStore({ + USER: { + authorizationStatus: AuthorizationStatus.Authorized, + userEmail: internet.email(), + }, + OFFERS: { + offers: [offer], + favoritesOffers: [], + favoritesCount: 0, + }, + APP: { + city: offer.city, + loadingStatus: LoadingStatus.Succeed + }, + }) + ); + + render(withStoreComponent); + + expect(screen.getByText(new RegExp(`places to stay in ${offer.city.name}`, 'i'))).toBeInTheDocument(); + expect(screen.getByText(offer.title)).toBeInTheDocument(); + }); +}); diff --git a/src/pages/not-found-screen/not-found-screen.test.tsx b/src/pages/not-found-screen/not-found-screen.test.tsx new file mode 100644 index 0000000..ebd4b66 --- /dev/null +++ b/src/pages/not-found-screen/not-found-screen.test.tsx @@ -0,0 +1,19 @@ +import {MemoryRouter} from 'react-router-dom'; +import {HelmetProvider} from 'react-helmet-async'; +import {render, screen} from '@testing-library/react'; +import {NotFoundScreen} from './not-found-screen.tsx'; + +describe('Component: NotFoundScreen', () => { + it('should render offers when there are offers available', () => { + const component = + + + + + + render(component); + + expect(screen.getByText('Page Not Found')).toBeInTheDocument(); + expect(screen.getByText('Go back to Home')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/offer-screen/offer-screen.test.tsx b/src/pages/offer-screen/offer-screen.test.tsx new file mode 100644 index 0000000..d39e2d8 --- /dev/null +++ b/src/pages/offer-screen/offer-screen.test.tsx @@ -0,0 +1,84 @@ +import {HelmetProvider} from 'react-helmet-async'; +import {makeFakeDetailOffer, makeFakeOffer, makeFakeReview, makeFakeStore} from '../../utils/mocks.ts'; +import thunk from 'redux-thunk'; +import {configureMockStore} from '@jedmao/redux-mock-store'; +import {AppRoute, AuthorizationStatus} from '../../const.ts'; +import {render, screen} from '@testing-library/react'; +import {Provider} from 'react-redux'; +import {MemoryRouter, Route, Routes} from 'react-router-dom'; +import {OfferScreen} from './offer-screen.tsx'; +import {Review} from '../../types/review.ts'; + +vi.mock('../../components/review-list/review-list.tsx', () => ({ + default: ({ reviews }: { reviews: Review[] }) => ( +
+ {reviews.map((review) => ( +
{review.comment}
+ ))} +
+ ) +})); + +describe('OfferScreen Component', () => { + const middlewares = [thunk]; + const mockStore = configureMockStore(middlewares); + const mockOffer = makeFakeOffer(); + const mockOfferDetail = makeFakeDetailOffer(); + const mockReviews = [makeFakeReview(), makeFakeReview()]; + const mockNearbyOffers = [makeFakeOffer(), makeFakeOffer()]; + + const initialState = makeFakeStore({ + DETAIL_OFFER: { + detailOffer: mockOfferDetail, + nearOffers: mockNearbyOffers, + reviews: mockReviews, + }, + OFFERS: { + offers: [mockOffer], + favoritesOffers: [], + favoritesCount: 0, + }, + USER: { + authorizationStatus: AuthorizationStatus.Unauthorized, + userEmail: null, + }, + }); + + const renderComponent = ( + initialEntries = [`/offer/${mockOffer.id}`], + state = initialState + ) => { + const store = mockStore(state); + + return render( + + + + + } /> + + + + + ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders offer details correctly', () => { + renderComponent(); + + expect(screen.getByText(mockOfferDetail.title)).toBeTruthy(); + expect(screen.getByText(`€${mockOfferDetail.price}`)).toBeTruthy(); + expect(screen.getByText(`${mockOfferDetail.bedrooms} Bedrooms`)).toBeTruthy(); + expect(screen.getByText(`Max ${mockOfferDetail.maxAdults} adults`)).toBeTruthy(); + }); + + it('displays review list', () => { + renderComponent(); + + expect(screen.getByTestId('review-list')).toBeTruthy(); + }); +}); diff --git a/src/store/api-actions.test.ts b/src/store/api-actions.test.ts index 59041ca..ecee24d 100644 --- a/src/store/api-actions.test.ts +++ b/src/store/api-actions.test.ts @@ -40,9 +40,9 @@ describe('Async actions', () => { mockAxiosAdapter.onGet(ApiRoute.offers).reply(200, mockOffers); await store.dispatch(fetchOffers()); - const actions = store.getActions(); - expect(actions[0].type).toBe(fetchOffers.pending.type); - expect(actions[1].type).toBe(fetchOffers.fulfilled.type); + const actions = extractActionsTypes(store.getActions()); + expect(actions).toContain(fetchOffers.pending.type); + expect(actions).toContain(fetchOffers.fulfilled.type); }); }); diff --git a/src/store/user-data/selectors.test.ts b/src/store/user-data/selectors.test.ts index 63030d7..c7dbd89 100644 --- a/src/store/user-data/selectors.test.ts +++ b/src/store/user-data/selectors.test.ts @@ -1,11 +1,12 @@ import {AuthorizationStatus, Namespace} from '../../const.ts'; import {getAuthorizationStatus, getUserEmail} from './selectors.ts'; +import {internet} from 'faker'; describe('UserData selectors', () => { const state = { [Namespace.User]: { authorizationStatus: AuthorizationStatus.Authorized, - userEmail: 'example@example.com', + userEmail: internet.email(), } }; diff --git a/src/store/user-data/user-data.test.ts b/src/store/user-data/user-data.test.ts index 2335564..aa50038 100644 --- a/src/store/user-data/user-data.test.ts +++ b/src/store/user-data/user-data.test.ts @@ -1,6 +1,7 @@ import {UserData} from '../../types/state.ts'; import {AuthorizationStatus} from '../../const.ts'; import {saveUserEmail, setAuthorizationStatus, userData} from './user-data.ts'; +import {internet} from 'faker'; describe('UserData slice', () => { const initialState: UserData = { @@ -32,7 +33,7 @@ describe('UserData slice', () => { }); it('should save email with "saveUserEmail" action', () => { - const userEmail = 'example@example.com'; + const userEmail = internet.email(); const expectedState = { ...initialState, userEmail: userEmail }; const result = userData.reducer(initialState, saveUserEmail(userEmail)); @@ -41,7 +42,7 @@ describe('UserData slice', () => { }); it('should clear email with "saveUserEmail" action with empty string', () => { - const stateWithEmail = { ...initialState, userEmail: 'example@example.com' }; + const stateWithEmail = { ...initialState, userEmail: internet.email() }; const expectedState = { ...initialState, userEmail: null }; const result = userData.reducer(stateWithEmail, saveUserEmail(null)); diff --git a/src/utils/mocks-components.tsx b/src/utils/mocks-components.tsx new file mode 100644 index 0000000..497b36d --- /dev/null +++ b/src/utils/mocks-components.tsx @@ -0,0 +1,31 @@ +import {State} from '../types/state.ts'; +import MockAdapter from 'axios-mock-adapter'; +import thunk from 'redux-thunk'; +import {configureMockStore, MockStore} from '@jedmao/redux-mock-store'; +import {createAPI} from '../serviсes/api.ts'; +import {Action} from 'redux'; +import {AppThunkDispatch} from './mocks.ts'; +import {Provider} from 'react-redux'; + +type ComponentWithMockStore = { + withStoreComponent: JSX.Element; + mockStore: MockStore; + mockAxiosAdapter: MockAdapter; +} + +export function withStore( + component: JSX.Element, + initialState: Partial = {}, +): ComponentWithMockStore { + const axios = createAPI(); + const mockAxiosAdapter = new MockAdapter(axios); + const middleware = [thunk.withExtraArgument(axios)]; + const mockStoreCreator = configureMockStore, AppThunkDispatch>(middleware); + const mockStore = mockStoreCreator(initialState); + + return ({ + withStoreComponent: {component}, + mockStore, + mockAxiosAdapter, + }); +} diff --git a/src/utils/mocks.ts b/src/utils/mocks.ts index 06e8a24..a217f23 100644 --- a/src/utils/mocks.ts +++ b/src/utils/mocks.ts @@ -1,6 +1,6 @@ import {internet, name} from 'faker'; import {Offer} from '../types/offer.ts'; -import {CITIES, PlaceType} from '../const.ts'; +import {AuthorizationStatus, CITIES, LoadingStatus, PARIS, PlaceType} from '../const.ts'; import {DetailOffer} from '../types/detail-offer.ts'; import {User} from '../types/user.ts'; import {Review} from '../types/review.ts'; @@ -61,3 +61,29 @@ export function makeFakeReview(): Review { rating: Math.floor(Math.random() * 5) }; } + +export const makeFakeStore = (initialState?: Partial): State => ({ + APP: { + city: PARIS, + loadingStatus: LoadingStatus.Succeed, + ...initialState?.APP + }, + DETAIL_OFFER: { + detailOffer: null, + nearOffers: [], + reviews: [], + ...initialState?.DETAIL_OFFER + }, + OFFERS: { + offers: [], + favoritesOffers: [], + favoritesCount: 0, + ...initialState?.OFFERS + }, + USER: { + authorizationStatus: AuthorizationStatus.Unauthorized, + userEmail: null, + ...initialState?.USER + } +}); +