diff --git a/src/app/components/ArticleNotFoundUASCleanup/ArticleNotFoundUASCleanup.test.tsx b/src/app/components/ArticleNotFoundUASCleanup/ArticleNotFoundUASCleanup.test.tsx new file mode 100644 index 00000000000..c214f5ce47f --- /dev/null +++ b/src/app/components/ArticleNotFoundUASCleanup/ArticleNotFoundUASCleanup.test.tsx @@ -0,0 +1,146 @@ +import { use, StrictMode } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { AccountContext } from '#app/contexts/AccountContext'; +import { RequestContext } from '#app/contexts/RequestContext'; +import uasApiRequest from '#app/lib/uasApi'; +import uasKeys from '#app/lib/uasApi/queryKeys'; +import parseRoute from '#app/routes/utils/parseRoute'; +import ArticleNotFoundUASCleanup from './ArticleNotFoundUASCleanup'; + +jest.mock('#app/lib/uasApi'); +jest.mock('#app/routes/utils/parseRoute'); +jest.mock('react', () => ({ + ...jest.requireActual('react'), + use: jest.fn(), +})); + +const mockGetQueriesData = jest.fn(); +const mockRemoveQueries = jest.fn(); +const mockQueryClient = { + getQueriesData: mockGetQueriesData, + removeQueries: mockRemoveQueries, +}; + +jest.mock('@tanstack/react-query', () => ({ + ...jest.requireActual('@tanstack/react-query'), + useQueryClient: () => mockQueryClient, +})); + +const mockUse = use as jest.Mock; +const mockUasApiRequest = uasApiRequest as jest.Mock; +const mockParseRoute = parseRoute as jest.Mock; + +const HASHED_USER_ID = 'user-123'; +const ARTICLE_ID = 'article-123'; + +describe('ArticleNotFoundUASCleanup', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUse.mockImplementation(context => { + if (context === AccountContext) + return { hashedUserId: HASHED_USER_ID, isRefreshAvailable: true }; + if (context === RequestContext) + return { pathname: '/news/articles/article-123' }; + return {}; + }); + + mockParseRoute.mockReturnValue({ assetId: ARTICLE_ID }); + mockGetQueriesData.mockReturnValue([]); + mockUasApiRequest.mockResolvedValue({ ok: true, status: 200 }); + }); + + it('renders null', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('calls getQueriesData with the queryKey from uasKeys.favouritesList(hashedUserId)', async () => { + render(); + + await waitFor(() => { + expect(mockGetQueriesData).toHaveBeenCalledWith({ + queryKey: uasKeys.favouritesList(HASHED_USER_ID), + }); + }); + }); + + it('does not call uasApiRequest when articleId is not present in any cached favourites page', async () => { + mockGetQueriesData.mockReturnValue([ + [ + uasKeys.favouritesPage(HASHED_USER_ID, 0), + { savedArticles: [{ id: 'some-other-article' }] }, + ], + ]); + + render(); + + await waitFor(() => { + expect(mockGetQueriesData).toHaveBeenCalled(); + }); + expect(mockUasApiRequest).not.toHaveBeenCalled(); + }); + + it('proceeds with deletion when articleId is found on one of several cached pages', async () => { + mockGetQueriesData.mockReturnValue([ + [ + uasKeys.favouritesPage(HASHED_USER_ID, 0), + { savedArticles: [{ id: 'some-other-article' }] }, + ], + [ + uasKeys.favouritesPage(HASHED_USER_ID, 1), + { savedArticles: [{ id: ARTICLE_ID }] }, + ], + ]); + + render(); + + await waitFor(() => { + expect(mockUasApiRequest).toHaveBeenCalledWith('DELETE', 'favourites', { + globalId: `urn:bbc:world-service-news:article:${ARTICLE_ID}`, + isRefreshAvailable: true, + }); + }); + }); + + it('calls queryClient.removeQueries with the favouritesList queryKey after a successful delete', async () => { + mockGetQueriesData.mockReturnValue([ + [ + uasKeys.favouritesPage(HASHED_USER_ID, 0), + { savedArticles: [{ id: ARTICLE_ID }] }, + ], + ]); + + render(); + + await waitFor(() => { + expect(mockRemoveQueries).toHaveBeenCalledWith({ + queryKey: uasKeys.favouritesList(HASHED_USER_ID), + }); + }); + }); + + it('only calls uasApiRequest once even if the effect re-runs with the same hashedUserId/articleId', async () => { + mockGetQueriesData.mockReturnValue([ + [ + uasKeys.favouritesPage(HASHED_USER_ID, 0), + { savedArticles: [{ id: ARTICLE_ID }] }, + ], + ]); + + // StrictMode intentionally mounts, cleans up, and remounts on initial + // render, invoking effects twice with identical deps. This is what + // hasAttemptedDeletion.current guards against in the component. + render( + + + , + ); + + await waitFor(() => { + expect(mockRemoveQueries).toHaveBeenCalledTimes(1); + }); + expect(mockUasApiRequest).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/components/ArticleNotFoundUASCleanup/ArticleNotFoundUASCleanup.tsx b/src/app/components/ArticleNotFoundUASCleanup/ArticleNotFoundUASCleanup.tsx new file mode 100644 index 00000000000..ad3b25c0ed2 --- /dev/null +++ b/src/app/components/ArticleNotFoundUASCleanup/ArticleNotFoundUASCleanup.tsx @@ -0,0 +1,69 @@ +import { use, useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { AccountContext } from '#app/contexts/AccountContext'; +import { RequestContext } from '#app/contexts/RequestContext'; +import parseRoute from '#app/routes/utils/parseRoute'; +import uasApiRequest from '#app/lib/uasApi'; +import { + buildGlobalId, + FAVOURITES_CONFIG, + SavedArticle, +} from '#app/lib/uasApi/uasUtility'; +import uasKeys from '#app/lib/uasApi/queryKeys'; +import { UAS_API_ERROR } from '#app/lib/logger.const'; +import nodeLogger from '#lib/logger.node'; + +const logger = nodeLogger(__filename); + +/** + * Client-side only component that removes a 404'd article from UAS favourites. + * Rendered only after hydration (ssr: false in index.tsx) to avoid SSR errors + * from useQueryClient. Mount conditions (personalization, pageType, errorCode) + * are checked in ErrorPage before rendering this component. + */ +const ArticleNotFoundUASCleanup = () => { + const { hashedUserId = '', isRefreshAvailable } = use(AccountContext); + const { pathname } = use(RequestContext); + const queryClient = useQueryClient(); + const { assetId: articleId } = parseRoute(pathname); + const hasAttemptedDeletion = useRef(false); + + useEffect(() => { + if (!hashedUserId || !articleId || hasAttemptedDeletion.current) return; + + const removeDeletedArticleFromUAS = async () => { + try { + hasAttemptedDeletion.current = true; + + const cachedPages = queryClient.getQueriesData<{ + savedArticles: SavedArticle[]; + }>({ queryKey: uasKeys.favouritesList(hashedUserId) }); + + const isInFavouritesList = cachedPages.some(([, data]) => + data?.savedArticles?.some(article => article.id === articleId), + ); + + if (!isInFavouritesList) return; + + const globalId = buildGlobalId(articleId); + await uasApiRequest('DELETE', FAVOURITES_CONFIG.activityType, { + globalId, + isRefreshAvailable, + }); + + queryClient.removeQueries({ + queryKey: uasKeys.favouritesList(hashedUserId), + }); + } catch { + logger.error(UAS_API_ERROR, { + error: 'Failed to remove deleted article from UAS favourites', + }); + } + }; + + removeDeletedArticleFromUAS(); + }, [queryClient, hashedUserId, articleId, isRefreshAvailable]); + return null; +}; + +export default ArticleNotFoundUASCleanup; diff --git a/src/app/components/ArticleNotFoundUASCleanup/index.tsx b/src/app/components/ArticleNotFoundUASCleanup/index.tsx new file mode 100644 index 00000000000..e082f7290b6 --- /dev/null +++ b/src/app/components/ArticleNotFoundUASCleanup/index.tsx @@ -0,0 +1,10 @@ +import dynamic from 'next/dynamic'; + +// TanStack Query must not be bundled server-side — ssr: false ensures the +// client component (and its useQueryClient call) only loads after hydration. +const ArticleNotFoundUASCleanup = dynamic( + () => import('./ArticleNotFoundUASCleanup'), + { ssr: false }, +); + +export default ArticleNotFoundUASCleanup; diff --git a/src/app/pages/ErrorPage/ErrorPage.jsx b/src/app/pages/ErrorPage/ErrorPage.jsx index c5582377263..c107b199c75 100644 --- a/src/app/pages/ErrorPage/ErrorPage.jsx +++ b/src/app/pages/ErrorPage/ErrorPage.jsx @@ -2,8 +2,12 @@ import { use } from 'react'; import { Helmet } from 'react-helmet'; import ErrorMain from '#components/ErrorMain'; import { useTheme } from '@emotion/react'; -import { ServiceContext } from '../../contexts/ServiceContext'; - +import { ARTICLE_PAGE, MEDIA_ARTICLE_PAGE } from '#app/routes/utils/pageTypes'; +import ArticleNotFoundUASCleanup from '#app/components/ArticleNotFoundUASCleanup'; +import { NOT_FOUND } from '#lib/statusCodes.const'; +import { RequestContext } from '#app/contexts/RequestContext'; +import { ServiceContext } from '#app/contexts/ServiceContext'; +import { AccountContext } from '#app/contexts/AccountContext'; /* * MVP Metadata for the error * This will be refactored out in https://github.com/bbc/simorgh/issues/1350 @@ -34,14 +38,22 @@ const ErrorMetadata = ({ dir, lang, messaging, brandName, themeColor }) => { const ErrorPage = ({ errorCode }) => { const { brandName, dir, lang, translations } = use(ServiceContext); + const { pageType } = use(RequestContext); + const { isPersonalizationEnabled } = use(AccountContext); const messaging = translations.error[errorCode] || translations.error[500]; const { palette: { BRAND_BACKGROUND }, } = useTheme(); + const shouldRenderArticleCleanup = + isPersonalizationEnabled && + errorCode === NOT_FOUND && + (pageType === ARTICLE_PAGE || pageType === MEDIA_ARTICLE_PAGE); + return ( <> + {shouldRenderArticleCleanup && } ({ + __esModule: true, + default: () =>
, +})); + +const personalizationToggle = { + uasPersonalization: { enabled: true, value: 'hindi' }, +}; + +const personalizedRenderOptions = { + service: 'hindi', + toggles: personalizationToggle, + idctaConfig: { ...mockIdctaConfig, initialIsSignedIn: true }, +}; describe('ErrorPage', () => { it('should correctly render for 404', () => { @@ -36,4 +56,38 @@ describe('ErrorPage', () => { }); expect(container).toMatchSnapshot(); }); + + it('renders ArticleNotFoundUASCleanup when personalization is enabled, errorCode is 404 and pageType is an article page', () => { + render(, { + ...personalizedRenderOptions, + pageType: ARTICLE_PAGE, + }); + + expect( + screen.getByTestId('article-not-found-uas-cleanup'), + ).toBeInTheDocument(); + }); + + it('renders ArticleNotFoundUASCleanup when personalization is enabled, errorCode is 404 and pageType is a media article page', () => { + render(, { + ...personalizedRenderOptions, + pageType: MEDIA_ARTICLE_PAGE, + }); + + expect( + screen.getByTestId('article-not-found-uas-cleanup'), + ).toBeInTheDocument(); + }); + + it('does not render ArticleNotFoundUASCleanup when user is not signed in', () => { + render(, { + ...personalizedRenderOptions, + idctaConfig: { ...mockIdctaConfig, initialIsSignedIn: false }, + pageType: ARTICLE_PAGE, + }); + + expect( + screen.queryByTestId('article-not-found-uas-cleanup'), + ).not.toBeInTheDocument(); + }); });