diff --git a/src/explore-education-statistics-frontend/src/middleware/pages/__tests__/redirectPages.test.ts b/src/explore-education-statistics-frontend/src/middleware/pages/__tests__/redirectPages.test.ts index 772cc444b8..cfed7a91b9 100644 --- a/src/explore-education-statistics-frontend/src/middleware/pages/__tests__/redirectPages.test.ts +++ b/src/explore-education-statistics-frontend/src/middleware/pages/__tests__/redirectPages.test.ts @@ -1,8 +1,9 @@ -import redirectPages from '@frontend/middleware/pages/redirectPages'; +import * as redirectPagesModule from '@frontend/middleware/pages/redirectPages'; import _redirectService, { Redirects, } from '@frontend/services/redirectService'; import { NextResponse } from 'next/server'; +import { Dictionary } from '@common/types'; import runMiddleware from './util/runMiddleware'; jest.mock('@frontend/services/redirectService'); @@ -11,317 +12,735 @@ const redirectService = _redirectService as jest.Mocked< >; describe('redirectPages', () => { - const redirectSpy = jest.spyOn(NextResponse, 'redirect'); - const nextSpy = jest.spyOn(NextResponse, 'next'); + interface OldSlugNewSlugPair { + oldSlug: string; + newSlug: string; + } + + interface NonRedirectedCase { + oldSlugsByPlaceholder: Dictionary; + } + + interface RedirectedCase { + oldSlugNewSlugPairsByPlaceholder: Dictionary; + } + + interface RoutePatternTestCases { + routePattern: string; + redirectedCases: RedirectedCase[]; + nonRedirectedCases: NonRedirectedCase[]; + } + + interface NonRedirectedCaseTestData { + routePattern: string; + oldSlugsByPlaceholder: Dictionary; + } + + interface RedirectedCaseTestData { + routePattern: string; + oldSlugNewSlugPairsByPlaceholder: Dictionary; + } + + const methodologySlugPlaceholder = '{methodology-slug}'; + const publicationSlugPlaceholder = '{publication-slug}'; + const releaseSlugPlaceholder = '{release-slug}'; const testRedirects: Redirects = { methodologyRedirects: [ - { fromSlug: 'original-slug-1', toSlug: 'updated-slug-1' }, - { fromSlug: 'original-slug-2', toSlug: 'updated-slug-2' }, + { + fromSlug: 'original-methodology-slug-1', + toSlug: 'updated-methodology-slug-1', + }, ], publicationRedirects: [ - { fromSlug: 'original-slug-3', toSlug: 'updated-slug-3' }, - { fromSlug: 'original-slug-4', toSlug: 'updated-slug-4' }, + { + fromSlug: 'original-publication-slug-1', + toSlug: 'updated-publication-slug-1', + }, + { + fromSlug: 'original-publication-slug-3', + toSlug: 'updated-publication-slug-3', + }, ], + releaseRedirectsByPublicationSlug: { + 'updated-publication-slug-1': [ + { + fromSlug: 'original-release-slug-1', + toSlug: 'updated-release-slug-1', + }, + ], + 'original-publication-slug-2': [ + { + fromSlug: 'original-release-slug-1', + toSlug: 'updated-release-slug-1', + }, + ], + }, }; - test('does not re-request the list of redirects once it has been fetched', async () => { - redirectService.list.mockResolvedValue(testRedirects); + const mixedPublicationReleasePageRedirectedCases: RedirectedCase[] = [ + // Publication slug IS NOT latest + Release slug IS NOT latest + { + oldSlugNewSlugPairsByPlaceholder: { + [publicationSlugPlaceholder]: { + oldSlug: 'original-publication-slug-1', + newSlug: 'updated-publication-slug-1', + }, + [releaseSlugPlaceholder]: { + oldSlug: 'original-release-slug-1', + newSlug: 'updated-release-slug-1', + }, + }, + }, + // Publication slug IS NOT latest + Release slug IS latest + { + oldSlugNewSlugPairsByPlaceholder: { + [publicationSlugPlaceholder]: { + oldSlug: 'original-publication-slug-1', + newSlug: 'updated-publication-slug-1', + }, + [releaseSlugPlaceholder]: { + oldSlug: 'updated-release-slug-1', + newSlug: 'updated-release-slug-1', + }, + }, + }, + // Publication slug IS NOT latest + Release slug HAS NO redirect + { + oldSlugNewSlugPairsByPlaceholder: { + [publicationSlugPlaceholder]: { + oldSlug: 'original-publication-slug-1', + newSlug: 'updated-publication-slug-1', + }, + [releaseSlugPlaceholder]: { + oldSlug: 'original-release-slug-2', + newSlug: 'original-release-slug-2', + }, + }, + }, + // Publication slug IS NOT latest + Publication HAS NO Release redirects + { + oldSlugNewSlugPairsByPlaceholder: { + [publicationSlugPlaceholder]: { + oldSlug: 'original-publication-slug-3', + newSlug: 'updated-publication-slug-3', + }, + [releaseSlugPlaceholder]: { + oldSlug: 'original-release-slug-1', + newSlug: 'original-release-slug-1', + }, + }, + }, + // Publication slug IS latest + Release slug IS NOT latest + { + oldSlugNewSlugPairsByPlaceholder: { + [publicationSlugPlaceholder]: { + oldSlug: 'updated-publication-slug-1', + newSlug: 'updated-publication-slug-1', + }, + [releaseSlugPlaceholder]: { + oldSlug: 'original-release-slug-1', + newSlug: 'updated-release-slug-1', + }, + }, + }, + // Publication slug HAS NO redirect + Release slug IS NOT latest + { + oldSlugNewSlugPairsByPlaceholder: { + [publicationSlugPlaceholder]: { + oldSlug: 'original-publication-slug-2', + newSlug: 'original-publication-slug-2', + }, + [releaseSlugPlaceholder]: { + oldSlug: 'original-release-slug-1', + newSlug: 'updated-release-slug-1', + }, + }, + }, + ]; + const mixedPublicationReleasePageNonRedirectedCases: NonRedirectedCase[] = [ + // Publication slug IS latest + Release slug IS latest + { + oldSlugsByPlaceholder: { + [publicationSlugPlaceholder]: 'updated-publication-slug-1', + [releaseSlugPlaceholder]: 'updated-release-slug-1', + }, + }, + // Publication slug IS latest + Release slug HAS NO redirect + { + oldSlugsByPlaceholder: { + [publicationSlugPlaceholder]: 'updated-publication-slug-1', + [releaseSlugPlaceholder]: 'original-release-slug-2', + }, + }, + // Publication slug IS latest + Publication HAS NO Release redirects + { + oldSlugsByPlaceholder: { + [publicationSlugPlaceholder]: 'updated-publication-slug-3', + [releaseSlugPlaceholder]: 'original-release-slug-1', + }, + }, + // Publication slug HAS NO redirect + Release slug IS latest + { + oldSlugsByPlaceholder: { + [publicationSlugPlaceholder]: 'original-publication-slug-2', + [releaseSlugPlaceholder]: 'updated-release-slug-1', + }, + }, + // Publication slug HAS NO redirect + Release slug HAS NO redirect + { + oldSlugsByPlaceholder: { + [publicationSlugPlaceholder]: 'original-publication-slug-2', + [releaseSlugPlaceholder]: 'original-release-slug-2', + }, + }, + // Publication slug HAS NO redirect + Publication HAS NO Release redirects + { + oldSlugsByPlaceholder: { + [publicationSlugPlaceholder]: 'original-publication-slug-4', + [releaseSlugPlaceholder]: 'original-release-slug-1', + }, + }, + // Matches redirect slugs from another redirect type + { + oldSlugsByPlaceholder: { + [publicationSlugPlaceholder]: 'original-methodology-slug-1', + [releaseSlugPlaceholder]: 'original-methodology-slug-1', + }, + }, + ]; + + const methodologyPageTestData: RoutePatternTestCases = { + routePattern: `methodology/${methodologySlugPlaceholder}`, + redirectedCases: [ + { + oldSlugNewSlugPairsByPlaceholder: { + [methodologySlugPlaceholder]: { + oldSlug: 'original-methodology-slug-1', + newSlug: 'updated-methodology-slug-1', + }, + }, + }, + ], + nonRedirectedCases: [ + { + oldSlugsByPlaceholder: { + [methodologySlugPlaceholder]: 'original-methodology-slug-2', + }, + }, + // Matches redirect slugs from another redirect type + { + oldSlugsByPlaceholder: { + [methodologySlugPlaceholder]: 'original-publication-slug-1', + }, + }, + ], + }; - await runMiddleware( - redirectPages, - 'https://my-env/methodology/original-slug', - ); + const findStatisticsPublicationPageTestData: RoutePatternTestCases = { + routePattern: `find-statistics/${publicationSlugPlaceholder}`, + redirectedCases: [ + { + oldSlugNewSlugPairsByPlaceholder: { + [publicationSlugPlaceholder]: { + oldSlug: 'original-publication-slug-1', + newSlug: 'updated-publication-slug-1', + }, + }, + }, + ], + nonRedirectedCases: [ + { + oldSlugsByPlaceholder: { + [publicationSlugPlaceholder]: 'original-publication-slug-2', + }, + }, + // Matches redirect slugs from another redirect type + { + oldSlugsByPlaceholder: { + [publicationSlugPlaceholder]: 'original-methodology-slug-1', + }, + }, + ], + }; - expect(redirectService.list).toHaveBeenCalledTimes(1); + const findStatisticsPublicationDataGuidancePageTestData: RoutePatternTestCases = + { + routePattern: `find-statistics/${publicationSlugPlaceholder}/data-guidance`, + redirectedCases: findStatisticsPublicationPageTestData.redirectedCases, + nonRedirectedCases: + findStatisticsPublicationPageTestData.nonRedirectedCases, + }; + + const findStatisticsPublicationPrereleaseAccessListPageTestData: RoutePatternTestCases = + { + routePattern: `find-statistics/${publicationSlugPlaceholder}/prerelease-access-list`, + redirectedCases: findStatisticsPublicationPageTestData.redirectedCases, + nonRedirectedCases: + findStatisticsPublicationPageTestData.nonRedirectedCases, + }; + + const findStatisticsReleasePageTestData: RoutePatternTestCases = { + routePattern: `find-statistics/${publicationSlugPlaceholder}/${releaseSlugPlaceholder}`, + redirectedCases: mixedPublicationReleasePageRedirectedCases, + nonRedirectedCases: mixedPublicationReleasePageNonRedirectedCases, + }; - await runMiddleware( - redirectPages, - 'https://my-env/methodology/another-slug', - ); + const findStatisticsReleaseDataGuidancePageTestData: RoutePatternTestCases = { + routePattern: `find-statistics/${publicationSlugPlaceholder}/${releaseSlugPlaceholder}/data-guidance`, + redirectedCases: findStatisticsReleasePageTestData.redirectedCases, + nonRedirectedCases: findStatisticsReleasePageTestData.nonRedirectedCases, + }; - expect(redirectService.list).toHaveBeenCalledTimes(1); - }); + const findStatisticsReleasePrereleaseAccessListPageTestData: RoutePatternTestCases = + { + routePattern: `find-statistics/${publicationSlugPlaceholder}/${releaseSlugPlaceholder}/prerelease-access-list`, + redirectedCases: findStatisticsReleasePageTestData.redirectedCases, + nonRedirectedCases: findStatisticsReleasePageTestData.nonRedirectedCases, + }; + + const dataTablesPublicationPageTestData: RoutePatternTestCases = { + routePattern: `data-tables/${publicationSlugPlaceholder}`, + redirectedCases: [ + { + oldSlugNewSlugPairsByPlaceholder: { + [publicationSlugPlaceholder]: { + oldSlug: 'original-publication-slug-1', + newSlug: 'updated-publication-slug-1', + }, + }, + }, + ], + nonRedirectedCases: [ + { + oldSlugsByPlaceholder: { + [publicationSlugPlaceholder]: 'original-publication-slug-2', + }, + }, + // Matches redirect slugs from another redirect type + { + oldSlugsByPlaceholder: { + [publicationSlugPlaceholder]: 'original-methodology-slug-1', + }, + }, + ], + }; - test('does not check for redirects for non release or methodology pages', async () => { - await runMiddleware(redirectPages, 'https://my-env/find-statistics'); + const dataTablesReleasePageTestData: RoutePatternTestCases = { + routePattern: `data-tables/${publicationSlugPlaceholder}/${releaseSlugPlaceholder}`, + redirectedCases: mixedPublicationReleasePageRedirectedCases, + nonRedirectedCases: mixedPublicationReleasePageNonRedirectedCases, + }; - expect(redirectService.list).not.toHaveBeenCalled(); - expect(redirectSpy).not.toHaveBeenCalled(); - expect(nextSpy).toHaveBeenCalledTimes(1); + const routePatternTestCases: RoutePatternTestCases[] = [ + methodologyPageTestData, + findStatisticsPublicationPageTestData, + findStatisticsPublicationDataGuidancePageTestData, + findStatisticsPublicationPrereleaseAccessListPageTestData, + findStatisticsReleasePageTestData, + findStatisticsReleaseDataGuidancePageTestData, + findStatisticsReleasePrereleaseAccessListPageTestData, + dataTablesPublicationPageTestData, + dataTablesReleasePageTestData, + ]; + + const redirectedCases: RedirectedCaseTestData[] = + routePatternTestCases.flatMap(td => { + return td.redirectedCases.map( + (rc): RedirectedCaseTestData => ({ + routePattern: td.routePattern, + oldSlugNewSlugPairsByPlaceholder: rc.oldSlugNewSlugPairsByPlaceholder, + }), + ); + }); - await runMiddleware(redirectPages, 'https://my-env/methodology'); + const nonRedirectedCases: NonRedirectedCaseTestData[] = + routePatternTestCases.flatMap(td => { + return td.nonRedirectedCases.map( + (rc): NonRedirectedCaseTestData => ({ + routePattern: td.routePattern, + oldSlugsByPlaceholder: rc.oldSlugsByPlaceholder, + }), + ); + }); - expect(redirectService.list).not.toHaveBeenCalled(); - expect(redirectSpy).not.toHaveBeenCalled(); - expect(nextSpy).toHaveBeenCalledTimes(2); + // These are routes that have slugs defined that would normally be redirected, but shouldn't be due + // to them being for a child-route that is not explicitly captured in the redirect patterns we look for. + const childRouteCases: NonRedirectedCaseTestData[] = [ + { + routePattern: `methodology/${methodologySlugPlaceholder}/child-route`, + oldSlugsByPlaceholder: { + [methodologySlugPlaceholder]: 'original-methodology-slug-1', + }, + }, + { + routePattern: `find-statistics/${publicationSlugPlaceholder}/data-guidance/child-route`, + oldSlugsByPlaceholder: { + [publicationSlugPlaceholder]: 'original-publication-slug-1', + }, + }, + { + routePattern: `find-statistics/${publicationSlugPlaceholder}/prerelease-access-list/child-route`, + oldSlugsByPlaceholder: { + [publicationSlugPlaceholder]: 'original-publication-slug-1', + }, + }, + { + routePattern: `find-statistics/${publicationSlugPlaceholder}/${releaseSlugPlaceholder}/child-route`, + oldSlugsByPlaceholder: { + [publicationSlugPlaceholder]: 'original-publication-slug-1', + [releaseSlugPlaceholder]: 'original-release-slug-1', + }, + }, + { + routePattern: `find-statistics/${publicationSlugPlaceholder}/${releaseSlugPlaceholder}/data-guidance/child-route`, + oldSlugsByPlaceholder: { + [publicationSlugPlaceholder]: 'original-publication-slug-1', + [releaseSlugPlaceholder]: 'original-release-slug-1', + }, + }, + { + routePattern: `find-statistics/${publicationSlugPlaceholder}/${releaseSlugPlaceholder}/prerelease-access-list/child-route`, + oldSlugsByPlaceholder: { + [publicationSlugPlaceholder]: 'original-publication-slug-1', + [releaseSlugPlaceholder]: 'original-release-slug-1', + }, + }, + { + routePattern: `data-tables/${publicationSlugPlaceholder}/${releaseSlugPlaceholder}/child-route`, + oldSlugsByPlaceholder: { + [publicationSlugPlaceholder]: 'original-publication-slug-1', + [releaseSlugPlaceholder]: 'original-release-slug-1', + }, + }, + ]; + + let redirectSpy: jest.SpyInstance; + const nextSpy = jest.spyOn(NextResponse, 'next'); - await runMiddleware(redirectPages, 'https://my-env/data-tables/something'); + let redirectPages: typeof redirectPagesModule.default; - expect(redirectService.list).not.toHaveBeenCalled(); - expect(redirectSpy).not.toHaveBeenCalled(); - expect(nextSpy).toHaveBeenCalledTimes(3); - }); + function buildRoute( + routePattern: string, + slugsByPlaceholder: Dictionary, + ): string { + let route = routePattern; - test('redirects urls with uppercase characters to lowercase', async () => { - redirectService.list.mockResolvedValue(testRedirects); - await runMiddleware(redirectPages, 'https://my-env/Find-Statistics'); + Object.entries(slugsByPlaceholder).forEach(([key, value]) => { + route = route.replace(key, value); + }); - expect(redirectSpy).toHaveBeenCalledTimes(1); - expect(redirectSpy).toHaveBeenCalledWith( - expect.objectContaining({ - href: 'https://my-env/find-statistics', - }), - 301, + return route; + } + + function buildOldRoute( + routePattern: string, + oldSlugNewSlugPairsByPlaceholder: Dictionary, + ): string { + return buildRoute( + routePattern, + Object.fromEntries( + Object.entries(oldSlugNewSlugPairsByPlaceholder).map(([k, v]) => [ + k, + v.oldSlug, + ]), + ), ); - expect(nextSpy).not.toHaveBeenCalled(); - - await runMiddleware( - redirectPages, - 'https://my-env/find-statistics/RELEASE-NAME?testParam=Something', + } + + function buildNewRoute( + routePattern: string, + oldSlugNewSlugPairsByPlaceholder: Dictionary, + ): string { + return buildRoute( + routePattern, + Object.fromEntries( + Object.entries(oldSlugNewSlugPairsByPlaceholder).map(([k, v]) => [ + k, + v.newSlug, + ]), + ), ); + } - expect(redirectSpy).toHaveBeenCalledTimes(2); - expect(redirectSpy).toHaveBeenCalledWith( - expect.objectContaining({ - href: 'https://my-env/find-statistics/release-name?testParam=Something', - }), - 301, - ); - expect(nextSpy).not.toHaveBeenCalled(); + beforeEach(async () => { + await jest.isolateModulesAsync(async () => { + import('@frontend/middleware/pages/redirectPages').then(module => { + redirectPages = module.default; + }); + + import('next/server').then(module => { + redirectSpy = jest.spyOn(module.NextResponse, 'redirect'); + }); + }); }); - describe('redirect methodology pages', () => { - test('redirects the request when the slug for the requested page has changed', async () => { - redirectService.list.mockResolvedValue(testRedirects); + describe('General Checks', () => { + it.each(redirectedCases)( + 'does not re-request the list of redirects once it has been fetched', + async redirectedCase => { + redirectService.list.mockResolvedValue(testRedirects); - await runMiddleware( - redirectPages, - 'https://my-env/methodology/original-slug-1', - ); + const route = buildOldRoute( + redirectedCase.routePattern, + redirectedCase.oldSlugNewSlugPairsByPlaceholder, + ); - expect(redirectSpy).toHaveBeenCalledTimes(1); - expect(redirectSpy).toHaveBeenCalledWith( - expect.objectContaining({ - href: 'https://my-env/methodology/updated-slug-1', - }), - 301, - ); - expect(nextSpy).not.toHaveBeenCalled(); - }); + await runMiddleware(redirectPages, `https://my-env/${route}`); - test('does not redirect when the slug for the requested page has not changed', async () => { - redirectService.list.mockResolvedValue(testRedirects); + expect(redirectService.list).toHaveBeenCalledTimes(1); - await runMiddleware( - redirectPages, - 'https://my-env/methodology/my-methodology', - ); + const anotherRoute = buildOldRoute( + redirectedCase.routePattern, + redirectedCase.oldSlugNewSlugPairsByPlaceholder, + ); - expect(redirectSpy).not.toHaveBeenCalled(); - expect(nextSpy).toHaveBeenCalledTimes(1); - }); + await runMiddleware(redirectPages, `https://my-env/${anotherRoute}`); - test('does not redirect if the `fromSlug` only partially matches', async () => { - redirectService.list.mockResolvedValue(testRedirects); + expect(redirectService.list).toHaveBeenCalledTimes(1); + }, + ); - await runMiddleware(redirectPages, 'https://my-env/methodology/original'); + test('does not check for redirects for non release/publication/methodology pages', async () => { + await runMiddleware(redirectPages, 'https://my-env/find-statistics'); + expect(redirectService.list).not.toHaveBeenCalled(); expect(redirectSpy).not.toHaveBeenCalled(); expect(nextSpy).toHaveBeenCalledTimes(1); - await runMiddleware( - redirectPages, - 'https://my-env/methodology/original-slug-and-something', - ); + await runMiddleware(redirectPages, 'https://my-env/methodology'); + expect(redirectService.list).not.toHaveBeenCalled(); expect(redirectSpy).not.toHaveBeenCalled(); expect(nextSpy).toHaveBeenCalledTimes(2); - }); - - test('redirects child pages', async () => { - redirectService.list.mockResolvedValue(testRedirects); - - await runMiddleware( - redirectPages, - 'https://my-env/methodology/original-slug-1/child-page', - ); - - expect(redirectSpy).toHaveBeenCalledTimes(1); - expect(redirectSpy).toHaveBeenCalledWith( - expect.objectContaining({ - href: 'https://my-env/methodology/updated-slug-1/child-page', - }), - 301, - ); - expect(nextSpy).not.toHaveBeenCalled(); - }); - - test('redirects with query params', async () => { - redirectService.list.mockResolvedValue(testRedirects); - - await runMiddleware( - redirectPages, - 'https://my-env/methodology/original-slug-1?search=something', - ); - - expect(redirectSpy).toHaveBeenCalledTimes(1); - expect(redirectSpy).toHaveBeenCalledWith( - expect.objectContaining({ - href: 'https://my-env/methodology/updated-slug-1?search=something', - }), - 301, - ); - expect(nextSpy).not.toHaveBeenCalled(); - }); - test('does not redirect when the slug matches a `fromSlug` in a different page type', async () => { - redirectService.list.mockResolvedValue(testRedirects); - - await runMiddleware( - redirectPages, - 'https://my-env/methodology/original-slug-4', - ); + await runMiddleware(redirectPages, 'https://my-env/data-tables'); + expect(redirectService.list).not.toHaveBeenCalled(); expect(redirectSpy).not.toHaveBeenCalled(); - expect(nextSpy).toHaveBeenCalledTimes(1); + expect(nextSpy).toHaveBeenCalledTimes(3); }); + }); - test('redirects with uppercase characters', async () => { + describe('Does Redirect', () => { + test('redirects non-redirect urls with uppercase characters to lowercase', async () => { redirectService.list.mockResolvedValue(testRedirects); - - await runMiddleware( - redirectPages, - 'https://my-env/Methodology/original-SLUG-1', - ); + await runMiddleware(redirectPages, 'https://my-env/Find-Statistics'); expect(redirectSpy).toHaveBeenCalledTimes(1); expect(redirectSpy).toHaveBeenCalledWith( expect.objectContaining({ - href: 'https://my-env/methodology/updated-slug-1', + href: 'https://my-env/find-statistics', }), 301, ); expect(nextSpy).not.toHaveBeenCalled(); - }); - }); - - describe('redirect publication pages', () => { - test('redirects the request when the slug for the requested page has changed', async () => { - redirectService.list.mockResolvedValue(testRedirects); await runMiddleware( redirectPages, - 'https://my-env/find-statistics/original-slug-3', + 'https://my-env/find-statistics/RELEASE-NAME?testParam=Something', ); - expect(redirectSpy).toHaveBeenCalledTimes(1); + expect(redirectSpy).toHaveBeenCalledTimes(2); expect(redirectSpy).toHaveBeenCalledWith( expect.objectContaining({ - href: 'https://my-env/find-statistics/updated-slug-3', + href: 'https://my-env/find-statistics/release-name?testParam=Something', }), 301, ); expect(nextSpy).not.toHaveBeenCalled(); }); - test('does not redirect when the slug for the requested page has not changed', async () => { - redirectService.list.mockResolvedValue(testRedirects); - - await runMiddleware( - redirectPages, - 'https://my-env/find-statistics/my-publication', - ); - - expect(redirectSpy).not.toHaveBeenCalled(); - expect(nextSpy).toHaveBeenCalledTimes(1); - }); - - test('does not redirect if the `fromSlug` only partially matches', async () => { - redirectService.list.mockResolvedValue(testRedirects); - - await runMiddleware( - redirectPages, - 'https://my-env/find-statistics/original', - ); - - expect(redirectSpy).not.toHaveBeenCalled(); - expect(nextSpy).toHaveBeenCalledTimes(1); - - await runMiddleware( - redirectPages, - 'https://my-env/find-statistics/original-slug-and-something', - ); - - expect(redirectSpy).not.toHaveBeenCalled(); - expect(nextSpy).toHaveBeenCalledTimes(2); - }); - - test('redirects child pages', async () => { - redirectService.list.mockResolvedValue(testRedirects); + it.each(redirectedCases)( + 'redirects the request when the slug for the requested page has changed', + async redirectedCase => { + redirectService.list.mockResolvedValue(testRedirects); + + const oldRoute = buildOldRoute( + redirectedCase.routePattern, + redirectedCase.oldSlugNewSlugPairsByPlaceholder, + ); + const newRoute = buildNewRoute( + redirectedCase.routePattern, + redirectedCase.oldSlugNewSlugPairsByPlaceholder, + ); + + await runMiddleware(redirectPages, `https://my-env/${oldRoute}`); + + expect(redirectSpy).toHaveBeenCalledTimes(1); + expect(redirectSpy).toHaveBeenCalledWith( + expect.objectContaining({ + href: `https://my-env/${newRoute}`, + }), + 301, + ); + expect(nextSpy).not.toHaveBeenCalled(); + }, + ); - await runMiddleware( - redirectPages, - 'https://my-env/find-statistics/original-slug-3/child-page', - ); + it.each(redirectedCases)( + 'redirects the request when the slug for the requested page has changed, and the url has a trailing slash', + async redirectedCase => { + redirectService.list.mockResolvedValue(testRedirects); + + const oldRoute = buildOldRoute( + redirectedCase.routePattern, + redirectedCase.oldSlugNewSlugPairsByPlaceholder, + ); + const newRoute = buildNewRoute( + redirectedCase.routePattern, + redirectedCase.oldSlugNewSlugPairsByPlaceholder, + ); + + await runMiddleware(redirectPages, `https://my-env/${oldRoute}/`); + + expect(redirectSpy).toHaveBeenCalledTimes(1); + expect(redirectSpy).toHaveBeenCalledWith( + expect.objectContaining({ + href: `https://my-env/${newRoute}/`, + }), + 301, + ); + expect(nextSpy).not.toHaveBeenCalled(); + }, + ); - expect(redirectSpy).toHaveBeenCalledTimes(1); - expect(redirectSpy).toHaveBeenCalledWith( - expect.objectContaining({ - href: 'https://my-env/find-statistics/updated-slug-3/child-page', - }), - 301, - ); - expect(nextSpy).not.toHaveBeenCalled(); - }); + it.each(redirectedCases)( + 'redirects with query params', + async redirectedCase => { + redirectService.list.mockResolvedValue(testRedirects); + + const oldRoute = buildOldRoute( + redirectedCase.routePattern, + redirectedCase.oldSlugNewSlugPairsByPlaceholder, + ); + const newRoute = buildNewRoute( + redirectedCase.routePattern, + redirectedCase.oldSlugNewSlugPairsByPlaceholder, + ); + + await runMiddleware( + redirectPages, + `https://my-env/${oldRoute}?search=something`, + ); + + expect(redirectSpy).toHaveBeenCalledTimes(1); + expect(redirectSpy).toHaveBeenCalledWith( + expect.objectContaining({ + href: `https://my-env/${newRoute}?search=something`, + }), + 301, + ); + expect(nextSpy).not.toHaveBeenCalled(); + }, + ); - test('redirects with query params', async () => { - redirectService.list.mockResolvedValue(testRedirects); + it.each(redirectedCases)( + 'redirects with uppercase characters', + async redirectedCase => { + redirectService.list.mockResolvedValue(testRedirects); + + const oldRoute = buildOldRoute( + redirectedCase.routePattern, + redirectedCase.oldSlugNewSlugPairsByPlaceholder, + ).toUpperCase(); + const newRoute = buildNewRoute( + redirectedCase.routePattern, + redirectedCase.oldSlugNewSlugPairsByPlaceholder, + ); + + await runMiddleware(redirectPages, `https://my-env/${oldRoute}`); + + expect(redirectSpy).toHaveBeenCalledTimes(1); + expect(redirectSpy).toHaveBeenCalledWith( + expect.objectContaining({ + href: `https://my-env/${newRoute}`, + }), + 301, + ); + expect(nextSpy).not.toHaveBeenCalled(); + }, + ); + }); - await runMiddleware( - redirectPages, - 'https://my-env/find-statistics/original-slug-3?search=something', - ); + describe('Does Not Redirect', () => { + it.each(nonRedirectedCases)( + 'does not redirect when the slug(s) for the requested page have no redirect(s) defined for the redirect type(s)', + async nonRedirectedCase => { + redirectService.list.mockResolvedValue(testRedirects); - expect(redirectSpy).toHaveBeenCalledTimes(1); + const route = buildRoute( + nonRedirectedCase.routePattern, + nonRedirectedCase.oldSlugsByPlaceholder, + ); - expect(redirectSpy).toHaveBeenCalledWith( - expect.objectContaining({ - href: 'https://my-env/find-statistics/updated-slug-3?search=something', - }), - 301, - ); - expect(nextSpy).not.toHaveBeenCalled(); - }); + await runMiddleware(redirectPages, `https://my-env/${route}`); - test('does not redirect when the slug matches a `fromSlug` in a different page type', async () => { - redirectService.list.mockResolvedValue(testRedirects); + expect(redirectSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalledTimes(1); + }, + ); - await runMiddleware( - redirectPages, - 'https://my-env/find-statistics/original-slug-1', - ); + it.each(childRouteCases)( + 'does not redirect the request for child-routes not explicitly accounted for, when the slug has changed', + async childRouteCase => { + redirectService.list.mockResolvedValue(testRedirects); - expect(redirectSpy).not.toHaveBeenCalled(); - expect(nextSpy).toHaveBeenCalledTimes(1); - }); + const route = buildRoute( + childRouteCase.routePattern, + childRouteCase.oldSlugsByPlaceholder, + ); - test('redirects with uppercase characters', async () => { - redirectService.list.mockResolvedValue(testRedirects); + await runMiddleware(redirectPages, `https://my-env/${route}`); - await runMiddleware( - redirectPages, - 'https://my-env/find-Statistics/original-SLUG-3', - ); + expect(redirectSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalledTimes(1); + }, + ); - expect(redirectSpy).toHaveBeenCalledTimes(1); - expect(redirectSpy).toHaveBeenCalledWith( - expect.objectContaining({ - href: 'https://my-env/find-statistics/updated-slug-3', - }), - 301, - ); - expect(nextSpy).not.toHaveBeenCalled(); - }); + it.each(nonRedirectedCases)( + 'does not redirect if the `fromSlug` only partially matches a slug with a redirect defined', + async nonRedirectedCase => { + redirectService.list.mockResolvedValue(testRedirects); + + const partiallyMatchedSlugSubstringsByPlaceholder = Object.fromEntries( + Object.entries(nonRedirectedCase.oldSlugsByPlaceholder).map( + ([k, v]) => [k, v.substring(0, v.length - 1)], + ), + ); + + const routeWithRedirectSlugSubstring = buildRoute( + nonRedirectedCase.routePattern, + partiallyMatchedSlugSubstringsByPlaceholder, + ); + + await runMiddleware( + redirectPages, + `https://my-env/${routeWithRedirectSlugSubstring}`, + ); + + expect(redirectSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalledTimes(1); + + const partiallyMatchedSlugsWithSuffixByPlaceholder = Object.fromEntries( + Object.entries(nonRedirectedCase.oldSlugsByPlaceholder).map( + ([k, v]) => [k, `${v}-extra-suffix`], + ), + ); + + const routeWithRedirectSlugWithSuffix = buildRoute( + nonRedirectedCase.routePattern, + partiallyMatchedSlugsWithSuffixByPlaceholder, + ); + + await runMiddleware( + redirectPages, + `https://my-env/${routeWithRedirectSlugWithSuffix}`, + ); + + expect(redirectSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalledTimes(2); + }, + ); }); }); diff --git a/src/explore-education-statistics-frontend/src/middleware/pages/redirectPages.ts b/src/explore-education-statistics-frontend/src/middleware/pages/redirectPages.ts index ea5e6a0950..2b582d94f0 100644 --- a/src/explore-education-statistics-frontend/src/middleware/pages/redirectPages.ts +++ b/src/explore-education-statistics-frontend/src/middleware/pages/redirectPages.ts @@ -1,7 +1,5 @@ -import redirectService, { - Redirects, - RedirectType, -} from '@frontend/services/redirectService'; +import { Dictionary } from '@common/types'; +import redirectService, { Redirects } from '@frontend/services/redirectService'; import type { NextFetchEvent, NextMiddleware, NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; @@ -10,14 +8,30 @@ interface CachedRedirects { fetchedAt: number; } +const methodologySlugKey = 'methodologySlug'; +const publicationSlugKey = 'publicationSlug'; +const releaseSlugKey = 'releaseSlug'; const cacheTime = getCacheTime(); let cachedRedirects: CachedRedirects | undefined; -const redirectPaths = { - methodologyRedirects: '/methodology', - publicationRedirects: '/find-statistics', -}; +const redirectPatterns: URLPattern[] = [ + new URLPattern({ + pathname: `/methodology/:${methodologySlugKey}{/}?`, + }), + new URLPattern({ + pathname: `/find-statistics/:${publicationSlugKey}/(data-guidance/?|prerelease-access-list/?|.{0})?`, + }), + new URLPattern({ + pathname: `/find-statistics/:${publicationSlugKey}/:${releaseSlugKey}/(data-guidance/?|prerelease-access-list/?|.{0})?`, + }), + new URLPattern({ + pathname: `/data-tables/:${publicationSlugKey}{/}?`, + }), + new URLPattern({ + pathname: `/data-tables/:${publicationSlugKey}/:${releaseSlugKey}{/}?`, + }), +]; export default async function redirectPages( request: NextRequest, @@ -26,49 +40,20 @@ export default async function redirectPages( ) { const { nextUrl } = request; const decodedPathname = decodeURIComponent(nextUrl.pathname); + const lowerCasedPathname = decodedPathname.toLowerCase(); - // Check for redirects for release and methodology pages - if ( - Object.values(redirectPaths).find(path => - decodedPathname.toLowerCase().startsWith(path), - ) && - decodedPathname.split('/').length > 2 - ) { - const shouldRefetch = - !cachedRedirects || cachedRedirects.fetchedAt + cacheTime < Date.now(); - - if (shouldRefetch) { - cachedRedirects = { - redirects: await redirectService.list(), - fetchedAt: Date.now(), - }; - } + // Check for redirects for publication, release, and methodology pages + const routeSlugsBySlugKey = findRouteSlugsBySlugKey(lowerCasedPathname); - const redirectPath = Object.keys(redirectPaths).reduce((acc, key) => { - const redirectType = key as RedirectType; - if ( - decodedPathname.toLowerCase().startsWith(redirectPaths[redirectType]) - ) { - const pathSegments = decodedPathname.split('/'); + if (Object.keys(routeSlugsBySlugKey).length > 0) { + await refreshCache(); - const rewriteRule = cachedRedirects?.redirects[redirectType]?.find( - ({ fromSlug }) => pathSegments[2].toLowerCase() === fromSlug, - ); + const redirectPath = determineRedirectPath( + lowerCasedPathname, + routeSlugsBySlugKey, + ); - if (rewriteRule) { - return pathSegments - .map(segment => - segment.toLowerCase() === rewriteRule?.fromSlug - ? rewriteRule?.toSlug - : segment.toLowerCase(), - ) - .join('/'); - } - } - return acc; - }, ''); - - if (redirectPath) { + if (redirectPath !== lowerCasedPathname) { const redirectUrl = nextUrl.clone(); redirectUrl.pathname = redirectPath; return NextResponse.redirect(redirectUrl, 301); @@ -76,15 +61,111 @@ export default async function redirectPages( } // Redirect any URLs with uppercase characters to lowercase. - if (decodedPathname !== decodedPathname.toLowerCase()) { + if (decodedPathname !== lowerCasedPathname) { const url = nextUrl.clone(); - url.pathname = decodedPathname.toLowerCase(); + url.pathname = lowerCasedPathname; return NextResponse.redirect(url, 301); } return middleware(request, event); } +function findRouteSlugsBySlugKey( + lowerCasedPathname: string, +): Dictionary { + for (let i = 0; i < redirectPatterns.length; i += 1) { + const redirectPattern = redirectPatterns[i]; + + const urlPatternMatch = redirectPattern.exec({ + pathname: lowerCasedPathname, + }); + + if (urlPatternMatch) { + return Object.fromEntries( + Object.entries(urlPatternMatch.pathname.groups) + .filter( + ([slugKey, slug]) => + (slugKey === methodologySlugKey || + slugKey === publicationSlugKey || + slugKey === releaseSlugKey) && + slug, + ) + .map(([slugKey, slug]) => [slugKey, slug!]), + ); + } + } + + return {}; +} + +async function refreshCache() { + const shouldRefetch = + !cachedRedirects || cachedRedirects.fetchedAt + cacheTime < Date.now(); + + if (shouldRefetch) { + cachedRedirects = { + redirects: await redirectService.list(), + fetchedAt: Date.now(), + }; + } +} + +function determineRedirectPath( + originalPath: string, + routeSlugsBySlugKey: Dictionary, +): string { + let redirectPath = originalPath; + + Object.entries(routeSlugsBySlugKey).forEach(([slugKey, slug]) => { + const redirect = findRedirectIfExists(slugKey, slug, routeSlugsBySlugKey); + + if (redirect) { + redirectPath = redirectPath.replace(slug, redirect.toSlug); + } + }); + + return redirectPath; +} + +function findRedirectIfExists( + slugKey: string, + slug: string, + routeSlugsBySlugKey: Dictionary, +) { + switch (slugKey) { + case methodologySlugKey: + return cachedRedirects?.redirects.methodologyRedirects?.find( + ({ fromSlug }) => slug === fromSlug, + ); + case publicationSlugKey: + return cachedRedirects?.redirects.publicationRedirects?.find( + ({ fromSlug }) => slug === fromSlug, + ); + case releaseSlugKey: { + let latestPublicationSlug = routeSlugsBySlugKey[publicationSlugKey]; + + const publicationRedirect = + cachedRedirects?.redirects.publicationRedirects?.find( + ({ fromSlug }) => latestPublicationSlug === fromSlug, + ); + + if (publicationRedirect) { + latestPublicationSlug = publicationRedirect.toSlug; + } + + const { + [latestPublicationSlug]: releaseRedirectsForLatestPublication = [], + } = cachedRedirects?.redirects.releaseRedirectsByPublicationSlug || {}; + + return releaseRedirectsForLatestPublication.find( + ({ fromSlug }) => slug === fromSlug, + ); + } + default: + throw new Error(`'${slugKey}' is not a valid redirect type.`); + } +} + // Cache the redirect paths for 2 seconds on Local, // 10 seconds on Development, and 60 seconds in all other // environments. diff --git a/src/explore-education-statistics-frontend/src/services/redirectService.ts b/src/explore-education-statistics-frontend/src/services/redirectService.ts index f90347cc77..a73e83f313 100644 --- a/src/explore-education-statistics-frontend/src/services/redirectService.ts +++ b/src/explore-education-statistics-frontend/src/services/redirectService.ts @@ -1,6 +1,9 @@ +import { Dictionary } from '@common/types'; + export interface Redirects { methodologyRedirects: Redirect[]; publicationRedirects: Redirect[]; + releaseRedirectsByPublicationSlug: Dictionary; } export type RedirectType = keyof Redirects;