diff --git a/.env.test b/.env.test index 202db3be58..d6757d6b92 100644 --- a/.env.test +++ b/.env.test @@ -23,6 +23,7 @@ API_ONISEP_ACCOUNT_PASSWORD=password-bidon API_ONISEP_APPLICATION_ID=123456789 # API POLE EMPLOI +API_POLE_EMPLOI_IS_MOCK_ACTIVE=1 API_POLE_EMPLOI_OFFRES_URL=https://api.pole-emploi.io/partenaire/offresdemploi/v2/offres API_POLE_EMPLOI_REFERENTIEL_URL=https://api.pole-emploi.io/partenaire/offresdemploi/v2/referentiel POLE_EMPLOI_CONNECT_CLIENT_ID=POLE_EMPLOI_CONNECT_CLIENT_ID diff --git a/cypress/e2e/emplois.cy.ts b/cypress/e2e/emplois.cy.ts index ad6fc6bd3e..4fb86cf0d2 100644 --- a/cypress/e2e/emplois.cy.ts +++ b/cypress/e2e/emplois.cy.ts @@ -3,78 +3,61 @@ import { stringify } from 'querystring'; -import { aBarmanOffre, aRésultatEchantillonOffre } from '~/server/offres/domain/offre.fixture'; - -import { interceptGet } from '../interceptGet'; +import { Success } from '~/server/errors/either'; +import { Offre, RésultatsRechercheOffre } from '~/server/offres/domain/offre'; +import { + getOffreRepositoryMockResults, + searchOffreRepositoryMockResults, +} from '~/server/offres/infra/repositories/mockOffre.repository'; describe('Page de recherche d’emplois', () => { beforeEach(() => { cy.viewport('iphone-x'); - cy.intercept( - '/api/emplois*', - aRésultatEchantillonOffre(), - ).as('recherche-emplois'); }); context('Parcours standard', () => { it('affiche 15 résultats par défaut', () => { + const expectedResult = searchOffreRepositoryMockResults({ page: 1 }) as Success; + cy.visit('/emplois'); - cy.wait('@recherche-emplois'); cy.findByRole('list', { name: /Offres d‘emplois/i }) .children() - .should('have.length', 15); + .should('have.length', expectedResult.result.résultats.length); cy.findByRole('list', { name: /Offres d‘emplois/i }) .children() .first() - .should('contain.text', 'Barman / Barmaid (H/F)'); - }); - - it('place le focus sur le premier input du formulaire de recherche', () => { - cy.visit('/emplois'); - cy.wait('@recherche-emplois'); - - cy.findAllByRole('textbox').first().should('have.focus'); + .should('contain.text', expectedResult.result.résultats[0].intitulé); }); context('quand l‘utilisateur rentre un mot clé', () => { it('filtre les résultats par mot clé', () => { + const expectedResult = searchOffreRepositoryMockResults({ motClé: 'barman', page: 1 }) as Success; + cy.visit('/emplois'); - cy.wait('@recherche-emplois'); - - cy.findByRole('textbox', { name: /Métier, mot-clé/i }).type('barman'), - interceptGet({ - actionBeforeWaitTheCall: () => - cy.findByRole('button', { name: /Rechercher/i }).click(), - alias: 'recherche-emplois', - path: '/api/emplois*', - response: JSON.stringify({ nombreRésultats: 1, résultats: [aBarmanOffre()] }), - }); - - cy.findByRole('list', { name: /Offres d‘emplois/i }).children().should('have.length', 1); + + cy.findByRole('textbox', { name: /Métier, mot-clé/i }).type('barman'); + + cy.findByRole('button', { name: /Rechercher/i }).click(); + + cy.findByRole('list', { name: /Offres d‘emplois/i }) + .children() + .should('have.length', expectedResult.result.résultats.length); }); }); context('quand l‘utilisateur veut sélectionner la première offre', () => { it('navigue vers le détail de l‘offre', () => { + const expectedResult = getOffreRepositoryMockResults() as Success; + cy.visit('/emplois'); - cy.wait('@recherche-emplois'); - - const id = aBarmanOffre().id; - - interceptGet({ - actionBeforeWaitTheCall: () => ( - cy.findByRole('list', { name: /Offres d‘emplois/i }) - .children() - .first() - .click() - ), - alias: 'get-emplois', - path: `/_next/data/*/emplois/${id}.json?id=${id}`, - response: JSON.stringify({ pageProps: { offreEmploi: aBarmanOffre() } }), - }); - - cy.findByRole('heading', { level: 1 }).should('contain.text', 'Barman / Barmaid (H/F)'); + + cy.findByRole('list', { name: /Offres d‘emplois/i }) + .children() + .first() + .click(); + + cy.findByRole('heading', { level: 1 }).should('contain.text', expectedResult.result.intitulé); }); }); }); @@ -91,11 +74,11 @@ describe('Page de recherche d’emplois', () => { typeDeContrats: 'CDI', typeLocalisation: 'DEPARTEMENT', }; + cy.visit(`/emplois?${stringify(query)}`); - cy.wait('@recherche-emplois'); - cy.findByRole('textbox', { name: /Métier, Mot-clé/i }).should('have.value', 'Informatique'); - cy.findByRole('combobox', { name: /Localisation/i }).should('have.value', 'Paris (75)'); + cy.findByRole('textbox', { name: /Métier, Mot-clé/i }).should('have.value', query.motCle); + cy.findByRole('combobox', { name: /Localisation/i }).should('have.value', `${query.nomLocalisation} (${query.codeLocalisation})`); cy.findByRole('button', { name: /Filtrer ma recherche/i }).click(); @@ -111,13 +94,7 @@ describe('Page de recherche d’emplois', () => { context('quand les paramètres de l’url ne respectent pas le schema de validation du controller', () => { it('retourne une erreur de demande incorrecte', () => { - interceptGet({ - actionBeforeWaitTheCall: () => cy.visit('/emplois?page=67'), - alias: 'recherche-emplois-failed', - path:'/api/emplois?page=67', - response: JSON.stringify({ error: "les paramètres dans l'url ne respectent pas le schema de validation" }), - statusCode: 400, - }); + cy.visit('/emplois?page=67'); cy.findByText('Erreur - Demande incorrecte').should('exist'); }); diff --git a/next.config.js b/next.config.js index 70b7ede644..e3aae2c82c 100644 --- a/next.config.js +++ b/next.config.js @@ -48,6 +48,9 @@ const moduleExports = { NEXT_PUBLIC_APPLICATION_NAME: name, NEXT_PUBLIC_APPLICATION_VERSION: version, }, + experimental: { + scrollRestoration: true, + }, images: { remotePatterns: [ ...getImagesRemotePattern(), diff --git a/src/client/components/features/OffreEmploi/FormulaireRecherche/FormulaireRechercheOffreEmploi.test.tsx b/src/client/components/features/OffreEmploi/FormulaireRecherche/FormulaireRechercheOffreEmploi.test.tsx index 1d3fd33c48..947cc41e4f 100644 --- a/src/client/components/features/OffreEmploi/FormulaireRecherche/FormulaireRechercheOffreEmploi.test.tsx +++ b/src/client/components/features/OffreEmploi/FormulaireRecherche/FormulaireRechercheOffreEmploi.test.tsx @@ -50,7 +50,7 @@ describe('FormulaireRechercheOffreEmploi', () => { await user.click(buttonRechercher); // THEN - expect(routerPush).toHaveBeenCalledWith({ query: 'motCle=boulanger&page=1' }, undefined, { shallow: true }); + expect(routerPush).toHaveBeenCalledWith({ query: 'motCle=boulanger&page=1' }, undefined, { scroll: false }); }); }); @@ -84,7 +84,7 @@ describe('FormulaireRechercheOffreEmploi', () => { await user.click(buttonAppliquerFiltres); // THEN - expect(routerPush).toHaveBeenCalledWith({ query: 'typeDeContrats=MIS&page=1' }, undefined, { shallow: true }); + expect(routerPush).toHaveBeenCalledWith({ query: 'typeDeContrats=MIS&page=1' }, undefined, { scroll: false }); }); }); @@ -118,7 +118,7 @@ describe('FormulaireRechercheOffreEmploi', () => { await user.click(buttonAppliquerFiltres); // THEN - expect(routerPush).toHaveBeenCalledWith({ query: 'tempsDeTravail=tempsPlein&page=1' }, undefined, { shallow: true }); + expect(routerPush).toHaveBeenCalledWith({ query: 'tempsDeTravail=tempsPlein&page=1' }, undefined, { scroll: false }); }); }); @@ -152,7 +152,7 @@ describe('FormulaireRechercheOffreEmploi', () => { await user.click(buttonAppliquerFiltres); // THEN - expect(routerPush).toHaveBeenCalledWith({ query: 'experienceExigence=D&page=1' }, undefined, { shallow: true }); + expect(routerPush).toHaveBeenCalledWith({ query: 'experienceExigence=D&page=1' }, undefined, { scroll: false }); }); }); @@ -186,10 +186,10 @@ describe('FormulaireRechercheOffreEmploi', () => { await user.click(buttonRechercher); // THEN - expect(routerPush).toHaveBeenCalledWith({ query: expect.stringContaining('nomLocalisation=Paris') }, undefined, { shallow: true }); - expect(routerPush).toHaveBeenCalledWith({ query: expect.stringContaining('codePostalLocalisation=75001') }, undefined, { shallow: true }); - expect(routerPush).toHaveBeenCalledWith({ query: expect.stringContaining('typeLocalisation=COMMUNE') }, undefined, { shallow: true }); - expect(routerPush).toHaveBeenCalledWith({ query: expect.stringContaining('codeLocalisation=75101') }, undefined, { shallow: true }); + expect(routerPush).toHaveBeenCalledWith({ query: expect.stringContaining('nomLocalisation=Paris') }, undefined, { scroll: false }); + expect(routerPush).toHaveBeenCalledWith({ query: expect.stringContaining('codePostalLocalisation=75001') }, undefined, { scroll: false }); + expect(routerPush).toHaveBeenCalledWith({ query: expect.stringContaining('typeLocalisation=COMMUNE') }, undefined, { scroll: false }); + expect(routerPush).toHaveBeenCalledWith({ query: expect.stringContaining('codeLocalisation=75101') }, undefined, { scroll: false }); }); }); @@ -224,7 +224,7 @@ describe('FormulaireRechercheOffreEmploi', () => { await user.click(buttonAppliquerFiltres); // THEN - expect(routerPush).toHaveBeenCalledWith({ query: 'grandDomaine=C&page=1' }, undefined, { shallow: true }); + expect(routerPush).toHaveBeenCalledWith({ query: 'grandDomaine=C&page=1' }, undefined, { scroll: false }); }); }); }); @@ -272,7 +272,7 @@ describe('FormulaireRechercheOffreEmploi', () => { const buttonRechercher = screen.getByRole('button', { name: 'Rechercher' }); await user.click(buttonRechercher); - expect(routerPush).toHaveBeenCalledWith({ query: 'typeDeContrats=CDD&page=1' }, undefined, { shallow: true }); + expect(routerPush).toHaveBeenCalledWith({ query: 'typeDeContrats=CDD&page=1' }, undefined, { scroll: false }); }); }); @@ -300,7 +300,7 @@ describe('FormulaireRechercheOffreEmploi', () => { const buttonRechercher = screen.getByRole('button', { name: 'Rechercher' }); await user.click(buttonRechercher); - expect(routerPush).toHaveBeenCalledWith({ query: 'grandDomaine=C&page=1' }, undefined, { shallow: true }); + expect(routerPush).toHaveBeenCalledWith({ query: 'grandDomaine=C&page=1' }, undefined, { scroll: false }); }); }); @@ -328,7 +328,7 @@ describe('FormulaireRechercheOffreEmploi', () => { const buttonRechercher = screen.getByRole('button', { name: 'Rechercher' }); await user.click(buttonRechercher); - expect(routerPush).toHaveBeenCalledWith({ query: 'experienceExigence=D&page=1' }, undefined, { shallow: true }); + expect(routerPush).toHaveBeenCalledWith({ query: 'experienceExigence=D&page=1' }, undefined, { scroll: false }); }); }); @@ -356,7 +356,7 @@ describe('FormulaireRechercheOffreEmploi', () => { const buttonRechercher = screen.getByRole('button', { name: 'Rechercher' }); await user.click(buttonRechercher); - expect(routerPush).toHaveBeenCalledWith({ query: 'tempsDeTravail=tempsPlein&page=1' }, undefined, { shallow: true }); + expect(routerPush).toHaveBeenCalledWith({ query: 'tempsDeTravail=tempsPlein&page=1' }, undefined, { scroll: false }); }); }); }); diff --git a/src/client/components/features/OffreEmploi/FormulaireRecherche/FormulaireRechercheOffreEmploi.tsx b/src/client/components/features/OffreEmploi/FormulaireRecherche/FormulaireRechercheOffreEmploi.tsx index b47a2efa22..0de6202af4 100644 --- a/src/client/components/features/OffreEmploi/FormulaireRecherche/FormulaireRechercheOffreEmploi.tsx +++ b/src/client/components/features/OffreEmploi/FormulaireRecherche/FormulaireRechercheOffreEmploi.tsx @@ -80,7 +80,7 @@ export function FormulaireRechercheOffreEmploi() { function updateRechercherOffreEmploiQueryParams(event: FormEvent) { event.preventDefault(); const query = getFormAsQuery(event.currentTarget, queryParams); - return router.push({ query }, undefined, { shallow: true }); + router.push({ query }, undefined, { scroll: false }); } return ( @@ -96,7 +96,6 @@ export function FormulaireRechercheOffreEmploi() { label="Métier, mot-clé" value={inputMotCle} name="motCle" - autoFocus placeholder="Exemples : boulanger, informatique..." onChange={(event: ChangeEvent) => setInputMotCle(event.currentTarget.value)} /> diff --git a/src/client/components/features/OffreEmploi/Rechercher/RechercherOffreEmploi.test.tsx b/src/client/components/features/OffreEmploi/Rechercher/RechercherOffreEmploi.test.tsx index b61352c1db..acef55a2c5 100644 --- a/src/client/components/features/OffreEmploi/Rechercher/RechercherOffreEmploi.test.tsx +++ b/src/client/components/features/OffreEmploi/Rechercher/RechercherOffreEmploi.test.tsx @@ -16,6 +16,7 @@ import { aNoResultOffreService, aSingleResultOffreService, } from '~/client/services/offre/offreService.fixture'; +import { aBarmanOffre, aRésultatsRechercheOffre } from '~/server/offres/domain/offre.fixture'; describe('RechercherOffreEmploi', () => { beforeEach(() => { @@ -29,15 +30,13 @@ describe('RechercherOffreEmploi', () => { describe('quand le composant est affiché sans recherche', () => { it('affiche un formulaire pour la recherche d‘offres d‘emploi, avec un échantillon de résultat', async () => { // GIVEN - const offreServiceMock = anOffreService(); const localisationServiceMock = aLocalisationService(); mockUseRouter({ query: { page: '1' } }); render( - + , ); @@ -72,7 +71,7 @@ describe('RechercherOffreEmploi', () => { - + , ); @@ -83,6 +82,7 @@ describe('RechercherOffreEmploi', () => { expect(within(filtresRecherche).getByText('BOURG LES VALENCE (26)')).toBeInTheDocument(); }); }); + describe('que la recherche est de type commun', () => { it('affiche les critères de recherche sous forme d‘étiquettes', async () => { // GIVEN @@ -102,7 +102,7 @@ describe('RechercherOffreEmploi', () => { - + , ); @@ -112,6 +112,7 @@ describe('RechercherOffreEmploi', () => { expect(filtresRecherche).toBeInTheDocument(); expect(within(filtresRecherche).getByText('BOURG LES VALENCE (26500)')).toBeInTheDocument(); }); + it('quand il n‘y a pas de code postal dans la query, affiche seulement le nom de la localisation dans l‘étiquette', async () => { // GIVEN const offreServiceMock = anOffreService(); @@ -129,7 +130,7 @@ describe('RechercherOffreEmploi', () => { - + , ); @@ -153,7 +154,7 @@ describe('RechercherOffreEmploi', () => { localisationService={localisationServiceMock} offreService={offreServiceMock} > - + , ); @@ -172,6 +173,12 @@ describe('RechercherOffreEmploi', () => { it('affiche le nombre de résultat au singulier', async () => { // GIVEN const offreServiceMock = aSingleResultOffreService(); + const offre = aRésultatsRechercheOffre({ + nombreRésultats: 1, + résultats: [ + aBarmanOffre(), + ], + }); const localisationServiceMock = aLocalisationService(); mockUseRouter({ query: { motCle: 'barman', page: '1' } }); @@ -180,7 +187,7 @@ describe('RechercherOffreEmploi', () => { localisationService={localisationServiceMock} offreService={offreServiceMock} > - + , ); @@ -196,6 +203,10 @@ describe('RechercherOffreEmploi', () => { it('affiche un message dédié', async () => { // GIVEN const offreServiceMock = aNoResultOffreService(); + const resultats = aRésultatsRechercheOffre({ + nombreRésultats: 0, + résultats: [], + }); const localisationServiceMock = aLocalisationService(); mockUseRouter({ query: { motCle: 'mot clé qui ne donne aucun résultat', page: '1' } }); @@ -204,7 +215,7 @@ describe('RechercherOffreEmploi', () => { localisationService={localisationServiceMock} offreService={offreServiceMock} > - + , ); @@ -215,46 +226,4 @@ describe('RechercherOffreEmploi', () => { expect(errorMessage).toBeInTheDocument(); }); }); - - it('filtre les query params envoyés', async () => { - mockUseRouter({ - query: { - codeLocalisation: '75', - motCle: 'Informatique', - nomLocalisation: 'Paris', - test: 'test', - typeLocalisation: 'DEPARTEMENT', - }, - }); - const service = anOffreService(); - - render( - - - , - ); - - await screen.findByText('3 offres d‘emplois pour Informatique'); - - expect(service.rechercherOffreEmploi).toHaveBeenCalledTimes(1); - expect(service.rechercherOffreEmploi).toHaveBeenCalledWith({ - codeLocalisation: '75', - motCle: 'Informatique', - nomLocalisation: 'Paris', - typeLocalisation: 'DEPARTEMENT', - }); - }); - - it('n’appelle pas le service sans query params', () => { - mockUseRouter({ query: {} }); - const service = anOffreService(); - - render( - - - , - ); - - expect(service.rechercherOffreEmploi).not.toHaveBeenCalled(); - }); }); diff --git a/src/client/components/features/OffreEmploi/Rechercher/RechercherOffreEmploi.tsx b/src/client/components/features/OffreEmploi/Rechercher/RechercherOffreEmploi.tsx index 845cfcf009..ae866760a7 100644 --- a/src/client/components/features/OffreEmploi/Rechercher/RechercherOffreEmploi.tsx +++ b/src/client/components/features/OffreEmploi/Rechercher/RechercherOffreEmploi.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { FormulaireRechercheOffreEmploi, @@ -24,45 +24,31 @@ import { LightHeroPrimaryText, LightHeroSecondaryText, } from '~/client/components/ui/Hero/LightHero'; -import { useDependency } from '~/client/context/dependenciesContainer.context'; import { useOffreQuery } from '~/client/hooks/useOffreQuery'; -import { OffreService } from '~/client/services/offre/offre.service'; -import empty from '~/client/utils/empty'; import { formatRechercherSolutionDocumentTitle } from '~/client/utils/formatRechercherSolutionDocumentTitle.util'; import { Erreur } from '~/server/errors/erreur.types'; -import { MAX_PAGE_ALLOWED_BY_POLE_EMPLOI, NOMBRE_RÉSULTATS_OFFRE_PAR_PAGE, Offre } from '~/server/offres/domain/offre'; +import { + MAX_PAGE_ALLOWED_BY_POLE_EMPLOI, + NOMBRE_RÉSULTATS_OFFRE_PAR_PAGE, + Offre, + RésultatsRechercheOffre, +} from '~/server/offres/domain/offre'; const PREFIX_TITRE_PAGE = 'Rechercher un emploi'; const LOGO_OFFRE_EMPLOI = '/images/logos/pole-emploi.svg'; -export function RechercherOffreEmploi() { - const offreQuery = useOffreQuery(); - const offreService = useDependency('offreService'); +interface RechercherOffreEmploiProps { + erreurRecherche?: Erreur + resultats?: RésultatsRechercheOffre +} - const [title, setTitle] = useState(`${PREFIX_TITRE_PAGE} | 1jeune1solution`); - const [offreEmploiList, setOffreEmploiList] = useState([]); - const [nombreRésultats, setNombreRésultats] = useState(0); - const [isLoading, setIsLoading] = useState(false); - const [erreurRecherche, setErreurRecherche] = useState(undefined); +export function RechercherOffreEmploi(props: RechercherOffreEmploiProps) { + const offreQuery = useOffreQuery(); - useEffect(function fetchOffres() { - if (empty(offreQuery)) { return; } - - setIsLoading(true); - setErreurRecherche(undefined); - offreService.rechercherOffreEmploi(offreQuery) - .then((response) => { - if (response.instance === 'success') { - setTitle(formatRechercherSolutionDocumentTitle(`${PREFIX_TITRE_PAGE}${response.result.nombreRésultats === 0 ? ' - Aucun résultat' : ''}`)); - setOffreEmploiList(response.result.résultats); - setNombreRésultats(response.result.nombreRésultats); - } else { - setTitle(formatRechercherSolutionDocumentTitle(PREFIX_TITRE_PAGE, response.errorType)); - setErreurRecherche(response.errorType); - } - setIsLoading(false); - }); - }, [offreQuery, offreService]); + const title = formatRechercherSolutionDocumentTitle(`${PREFIX_TITRE_PAGE}${props.resultats?.nombreRésultats === 0 ? ' - Aucun résultat' : ''}`); + const offreEmploiList = props.resultats?.résultats || []; + const nombreRésultats = props.resultats?.nombreRésultats || 0; + const erreurRecherche = props.erreurRecherche; const messageRésultatRecherche: string = useMemo(() => { const messageRésultatRechercheSplit: string[] = [`${nombreRésultats}`]; @@ -90,7 +76,7 @@ export function RechercherOffreEmploi() { erreurRecherche={erreurRecherche} étiquettesRecherche={} formulaireRecherche={} - isLoading={isLoading} + isLoading={false} messageRésultatRecherche={messageRésultatRecherche} nombreSolutions={nombreRésultats} paginationOffset={NOMBRE_RÉSULTATS_OFFRE_PAR_PAGE} diff --git a/src/client/components/useRouter.mock.ts b/src/client/components/useRouter.mock.ts index 22556b3de3..36febfa436 100644 --- a/src/client/components/useRouter.mock.ts +++ b/src/client/components/useRouter.mock.ts @@ -14,12 +14,14 @@ interface MockUseRouter { replace?: jest.Mock reload?: jest.Mock back?: jest.Mock + isReady?: boolean } -export function mockUseRouter({ asPath = '', pathname = '', query = {}, route = '', prefetch = jest.fn(), push = jest.fn(), reload = jest.fn(), replace = jest.fn(), back = jest.fn() }: MockUseRouter) { +export function mockUseRouter({ asPath = '', pathname = '', query = {}, route = '', prefetch = jest.fn(), push = jest.fn(), reload = jest.fn(), replace = jest.fn(), back = jest.fn(), isReady = true }: MockUseRouter) { useRouter.mockImplementation(() => ({ asPath, back, + isReady, pathname, prefetch, push, diff --git a/src/pages/api/emplois/index.controller.test.ts b/src/pages/api/emplois/index.controller.test.ts index cf3fde15de..a6e218880b 100644 --- a/src/pages/api/emplois/index.controller.test.ts +++ b/src/pages/api/emplois/index.controller.test.ts @@ -54,7 +54,7 @@ describe('rechercher une offre d‘emploi', () => { }, } as unknown as NextApiRequest; - const result = emploiFiltreMapper(request); + const result = emploiFiltreMapper(request.query); expect(result).toEqual({ experienceExigence: undefined, diff --git a/src/pages/api/emplois/index.controller.ts b/src/pages/api/emplois/index.controller.ts index 7f59ceb1e0..f6810ec6b7 100644 --- a/src/pages/api/emplois/index.controller.ts +++ b/src/pages/api/emplois/index.controller.ts @@ -12,6 +12,8 @@ import { DomaineCode, MAX_PAGE_ALLOWED_BY_POLE_EMPLOI, RésultatsRechercheOffre import { mapLocalisation } from '~/server/offres/infra/controller/offreFiltre.mapper'; import { dependencies } from '~/server/start'; +type RequestQuery = Partial<{[p: string]: string | string[]}>; + export const emploisQuerySchema = Joi.object({ codeLocalisation: Joi.string().alphanum().max(5), experienceExigence: Joi.string().valid('D', 'S', 'E'), @@ -26,16 +28,14 @@ export const emploisQuerySchema = Joi.object({ export async function rechercherOffreEmploiHandler( req: NextApiRequest, res: NextApiResponse) { - const params = emploiFiltreMapper(req); + const params = emploiFiltreMapper(req.query); const résultatsRechercheOffreEmploi = await dependencies.offreEmploiDependencies.rechercherOffreEmploi.handle(params); return handleResponse(résultatsRechercheOffreEmploi, res); } export default withMonitoring(withValidation({ query: emploisQuerySchema }, rechercherOffreEmploiHandler)); -export function emploiFiltreMapper(request: NextApiRequest): EmploiFiltre { - const { query } = request; - +export function emploiFiltreMapper(query: RequestQuery): EmploiFiltre { return { experienceExigence: query.experienceExigence ? String(query.experienceExigence) : undefined, grandDomaineList: query.grandDomaine ? queryToArray(query.grandDomaine) : undefined, diff --git a/src/pages/emplois/index.page.test.tsx b/src/pages/emplois/index.page.test.tsx index 395cd98bfe..0f7442210d 100644 --- a/src/pages/emplois/index.page.test.tsx +++ b/src/pages/emplois/index.page.test.tsx @@ -5,6 +5,7 @@ import '~/test-utils'; import { render, screen } from '@testing-library/react'; +import { GetServerSidePropsContext } from 'next'; import { mockUseRouter } from '~/client/components/useRouter.mock'; import { mockLargeScreen } from '~/client/components/window.mock'; @@ -12,23 +13,163 @@ import { DependenciesProvider } from '~/client/context/dependenciesContainer.con import { aManualAnalyticsService } from '~/client/services/analytics/analytics.service.fixture'; import { aLocalisationService } from '~/client/services/localisation/localisation.service.fixture'; import { anOffreService } from '~/client/services/offre/offreService.fixture'; -import RechercherOffreEmploiPage from '~/pages/emplois/index.page'; - -describe('', () => { - it('n‘a pas de défaut d‘accessibilité', async () => { - mockUseRouter({ query: { page: '1' } }); - mockLargeScreen(); - - const { container } = render( - - ); - ); - - await screen.findByRole('list', { name: /Offres d‘emplois/i }); - await expect(container).toBeAccessible(); +import RechercherOffreEmploiPage, { getServerSideProps } from '~/pages/emplois/index.page'; +import { createFailure, createSuccess } from '~/server/errors/either'; +import { ErreurMetier } from '~/server/errors/erreurMetier.types'; +import { aRésultatsRechercheOffre } from '~/server/offres/domain/offre.fixture'; +import { dependencies } from '~/server/start'; + +jest.mock('~/server/start', () => ({ + dependencies: { + offreEmploiDependencies: { + rechercherOffreEmploi: { + handle: jest.fn(), + }, + }, + }, +})); + +describe('Page Emploi', () => { + describe('', () => { + it('n‘a pas de défaut d‘accessibilité', async () => { + mockUseRouter({ query: { page: '1' } }); + mockLargeScreen(); + + const { container } = render( + + ); + ); + + await screen.findByRole('list', { name: /Offres d‘emplois/i }); + await expect(container).toBeAccessible(); + }); + + describe('lorsque la page est chargée sans query params', () => { + it('redirige vers la page 1', async () => { + // GIVEN + const routerReplace = jest.fn(); + mockUseRouter({ isReady: true, query: {}, replace: routerReplace }); + mockLargeScreen(); + const offres = aRésultatsRechercheOffre({ + nombreRésultats: 0, + résultats: [], + }); + + // WHEN + render( + + ); + , + ); + + // THEN + expect(routerReplace).toHaveBeenCalledWith({ query: 'page=1' }, undefined, { scroll: false }); + }); + }); + }); + + describe('getServerSideProps', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('lorsque la recherche est lancée sans query params', () => { + it('retourne un résultat vide', async () => { + // GIVEN + const context = { + query: {}, + } as GetServerSidePropsContext; + + // WHEN + const result = await getServerSideProps(context); + + // THEN + expect(result).toEqual({ + props: {}, + }); + expect(dependencies.offreEmploiDependencies.rechercherOffreEmploi.handle).not.toHaveBeenCalled(); + }); + }); + + describe('lorsque la recherche est lancée avec des query params', () => { + it('filtre les offres et retourne le résultat', async () => { + // GIVEN + (dependencies.offreEmploiDependencies.rechercherOffreEmploi.handle as jest.Mock).mockReturnValue(createSuccess(aRésultatsRechercheOffre())); + + const context = { + query: { + page: 1, + }, + } as unknown as GetServerSidePropsContext; + + // WHEN + const result = await getServerSideProps(context); + + // THEN + expect(result).toEqual({ + props: { + resultats: aRésultatsRechercheOffre(), + }, + }); + expect(dependencies.offreEmploiDependencies.rechercherOffreEmploi.handle).toHaveBeenCalledWith({ + page: 1, + }); + }); + + describe('lorsque la recherche retourne une erreur', () => { + it('retourne une erreur de service indisponible', async () => { + // GIVEN + (dependencies.offreEmploiDependencies.rechercherOffreEmploi.handle as jest.Mock).mockReturnValue(createFailure(ErreurMetier.SERVICE_INDISPONIBLE)); + const context = { + query: { + page: 1, + }, + } as unknown as GetServerSidePropsContext; + + // WHEN + const result = await getServerSideProps(context); + + // THEN + expect(result).toEqual({ + props: { + erreurRecherche: ErreurMetier.SERVICE_INDISPONIBLE, + }, + }); + expect(dependencies.offreEmploiDependencies.rechercherOffreEmploi.handle).toHaveBeenCalledWith({ + page: 1, + }); + }); + }); + }); + + describe('lorsque la recherche est lancée avec des query params invalides', () => { + it('retourne une erreur de demande incorrecte', async () => { + // GIVEN + const context = { + query: { + page: 'invalid', + }, + } as unknown as GetServerSidePropsContext; + + // WHEN + const result = await getServerSideProps(context); + + // THEN + expect(result).toEqual({ + props: { + erreurRecherche: ErreurMetier.DEMANDE_INCORRECTE, + }, + }); + expect(dependencies.offreEmploiDependencies.rechercherOffreEmploi.handle).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/src/pages/emplois/index.page.tsx b/src/pages/emplois/index.page.tsx index 6497186990..9bcae878b8 100644 --- a/src/pages/emplois/index.page.tsx +++ b/src/pages/emplois/index.page.tsx @@ -1,28 +1,66 @@ +import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'; import { useRouter } from 'next/router'; import { stringify } from 'querystring'; import React, { useEffect } from 'react'; import { RechercherOffreEmploi } from '~/client/components/features/OffreEmploi/Rechercher/RechercherOffreEmploi'; import useAnalytics from '~/client/hooks/useAnalytics'; +import empty from '~/client/utils/empty'; +import { emploiFiltreMapper, emploisQuerySchema } from '~/pages/api/emplois/index.controller'; import analytics from '~/pages/emplois/index.analytics'; +import { Erreur } from '~/server/errors/erreur.types'; +import { ErreurMetier } from '~/server/errors/erreurMetier.types'; +import { RésultatsRechercheOffre } from '~/server/offres/domain/offre'; +import { dependencies } from '~/server/start'; -export default function RechercherOffreEmploiPage() { +interface RechercherOffreEmploiPageProps { + erreurRecherche?: Erreur + resultats?: RésultatsRechercheOffre +} + +export default function RechercherOffreEmploiPage(props: RechercherOffreEmploiPageProps) { const router = useRouter(); useAnalytics(analytics); useEffect(() => { if (router.isReady) { const queryString = stringify(router.query); - if (queryString.length === 0) router.replace({ query: 'page=1' }, undefined, { shallow: true }); + if (queryString.length === 0) router.replace({ query: 'page=1' }, undefined, { scroll: false }); } }, [router]); - return ; + return ; } -// NOTE (GAFI 08-08-2023): Rend le composant server-side -export function getServerSideProps() { +export async function getServerSideProps(context: GetServerSidePropsContext): Promise> { + const { query } = context; + + if (empty(query)) { + return { + props: {}, + }; + } + + if (emploisQuerySchema.validate(query).error) { + return { + props: { + erreurRecherche: ErreurMetier.DEMANDE_INCORRECTE, + }, + }; + } + const filtres = emploiFiltreMapper(context.query); + + const resultatsRecherche = await dependencies.offreEmploiDependencies.rechercherOffreEmploi.handle(filtres); + if (resultatsRecherche.instance === 'failure') { + return { + props: { + erreurRecherche: resultatsRecherche.errorType, + }, + }; + } return { - props: {}, + props: { + resultats: JSON.parse(JSON.stringify(resultatsRecherche.result)), + }, }; } diff --git a/src/server/configuration/dependencies.container.ts b/src/server/configuration/dependencies.container.ts index 486b44a51f..a11d1aa45d 100644 --- a/src/server/configuration/dependencies.container.ts +++ b/src/server/configuration/dependencies.container.ts @@ -147,6 +147,7 @@ import { getApiPoleEmploiOffresConfig, getApiPoleEmploiReferentielsConfig, } from '~/server/offres/configuration/pole-emploi/poleEmploiHttpClient.config'; +import { MockOffreRepository } from '~/server/offres/infra/repositories/mockOffre.repository'; import { ApiPoleEmploiOffreErrorManagementServiceGet, ApiPoleEmploiOffreErrorManagementServiceSearch, @@ -236,7 +237,9 @@ export function dependenciesContainer(): Dependencies { const apiPoleEmploiOffreErreurManagementServiceSearch = new ApiPoleEmploiOffreErrorManagementServiceSearch(loggerService); const apiPoleEmploiOffreErreurManagementServiceGet = new ApiPoleEmploiOffreErrorManagementServiceGet(loggerService); const apiPoleEmploiOffreRepository = new ApiPoleEmploiOffreRepository(poleEmploiOffresHttpClientService, poleEmploiParamètreBuilderService, cacheService, apiPoleEmploiOffreErreurManagementServiceSearch, apiPoleEmploiOffreErreurManagementServiceGet); - const offreEmploiDependencies = offresEmploiDependenciesContainer(apiPoleEmploiOffreRepository); + const offreEmploiDependencies = serverConfigurationService.getConfiguration().API_POLE_EMPLOI_IS_MOCK_ACTIVE + ? offresEmploiDependenciesContainer(new MockOffreRepository()) + : offresEmploiDependenciesContainer(apiPoleEmploiOffreRepository); const apiPoleEmploiJobÉtudiantOffreRepository = new ApiPoleEmploiJobÉtudiantRepository(poleEmploiOffresHttpClientService, poleEmploiParamètreBuilderService, cacheService, apiPoleEmploiOffreErreurManagementServiceSearch, apiPoleEmploiOffreErreurManagementServiceGet); const offreJobÉtudiantDependencies = jobsÉtudiantsDependenciesContainer(apiPoleEmploiJobÉtudiantOffreRepository); diff --git a/src/server/offres/infra/repositories/mockOffre.repository.ts b/src/server/offres/infra/repositories/mockOffre.repository.ts new file mode 100644 index 0000000000..b81e2b290c --- /dev/null +++ b/src/server/offres/infra/repositories/mockOffre.repository.ts @@ -0,0 +1,39 @@ +import { createFailure, createSuccess, Either } from '~/server/errors/either'; +import { ErreurMetier } from '~/server/errors/erreurMetier.types'; + +import { Offre, OffreFiltre, RésultatsRechercheOffre } from '../../domain/offre'; +import { aBarmanOffre, aRésultatEchantillonOffre, aRésultatsRechercheOffre } from '../../domain/offre.fixture'; +import { OffreRepository } from '../../domain/offre.repository'; + +export function searchOffreRepositoryMockResults(filtre: OffreFiltre): Either { + if (filtre.page === 1 && !filtre.motClé) { + return createSuccess(aRésultatEchantillonOffre()); + } + if (filtre.page === 1 && filtre.motClé === 'barman') { + return createSuccess(aRésultatsRechercheOffre({ + nombreRésultats: 1, + résultats: [aBarmanOffre()], + })); + } + if (filtre.page === 67) { + return createFailure(ErreurMetier.DEMANDE_INCORRECTE); + } + + return createSuccess(aRésultatsRechercheOffre()); +} + +export function getOffreRepositoryMockResults(): Either { + return createSuccess(aBarmanOffre()); +} + +export class MockOffreRepository implements OffreRepository { + paramètreParDéfaut: string | undefined; + + get(): Promise> { + return Promise.resolve(getOffreRepositoryMockResults()); + } + + search(filtre: OffreFiltre): Promise> { + return Promise.resolve(searchOffreRepositoryMockResults(filtre)); + } +} diff --git a/src/server/services/configuration.service.fixture.ts b/src/server/services/configuration.service.fixture.ts index 7e288dedd7..cb26ba9fa2 100644 --- a/src/server/services/configuration.service.fixture.ts +++ b/src/server/services/configuration.service.fixture.ts @@ -24,6 +24,7 @@ export class ConfigurationServiceFixture implements ConfigurationService { API_ONISEP_ACCOUNT_PASSWORD: 'password-test', API_ONISEP_APPLICATION_ID: '123456789', API_ONISEP_BASE_URL: 'https://fake-onisep.fr', + API_POLE_EMPLOI_IS_MOCK_ACTIVE: false, API_POLE_EMPLOI_OFFRES_URL: 'https://api.pole-emploi.io/partenaire/offresdemploi/v2/offres', API_POLE_EMPLOI_REFERENTIEL_URL: 'https://api.pole-emploi.io/partenaire/offresdemploi/v2/referentiel', API_TRAJECTOIRES_PRO_URL: 'https://trajectoires-pro-recette.apprentissage.beta.gouv.fr/api/', diff --git a/src/server/services/serverConfiguration.service.ts b/src/server/services/serverConfiguration.service.ts index af425e58cb..1c4bfb4fe8 100644 --- a/src/server/services/serverConfiguration.service.ts +++ b/src/server/services/serverConfiguration.service.ts @@ -17,6 +17,7 @@ export default class ServerConfigurationService implements ConfigurationService API_ONISEP_ACCOUNT_PASSWORD: ServerConfigurationService.getOrThrowError('API_ONISEP_ACCOUNT_PASSWORD'), API_ONISEP_APPLICATION_ID: ServerConfigurationService.getOrThrowError('API_ONISEP_APPLICATION_ID'), API_ONISEP_BASE_URL: ServerConfigurationService.getOrThrowError('API_ONISEP_BASE_URL'), + API_POLE_EMPLOI_IS_MOCK_ACTIVE: Boolean(Number(ServerConfigurationService.getOrDefault('API_POLE_EMPLOI_IS_MOCK_ACTIVE', '0'))), API_POLE_EMPLOI_OFFRES_URL: ServerConfigurationService.getOrThrowError('API_POLE_EMPLOI_OFFRES_URL'), API_POLE_EMPLOI_REFERENTIEL_URL: ServerConfigurationService.getOrThrowError('API_POLE_EMPLOI_REFERENTIEL_URL'), API_TRAJECTOIRES_PRO_URL: ServerConfigurationService.getOrThrowError('API_TRAJECTOIRES_PRO_URL'), @@ -102,6 +103,7 @@ export interface EnvironmentVariables { readonly API_ONISEP_ACCOUNT_EMAIL: string readonly API_ONISEP_ACCOUNT_PASSWORD: string readonly API_ONISEP_APPLICATION_ID: string + readonly API_POLE_EMPLOI_IS_MOCK_ACTIVE: boolean readonly API_POLE_EMPLOI_OFFRES_URL: string readonly API_POLE_EMPLOI_REFERENTIEL_URL: string readonly API_TRAJECTOIRES_PRO_URL: string