diff --git a/frontend-next-migration/src/app/[lng]/(helper)/defense-gallery/[herogroup]/_getPage.ts b/frontend-next-migration/src/app/[lng]/(helper)/defense-gallery/[herogroup]/_getPage.ts index c3d5fea3e..2635c4b24 100644 --- a/frontend-next-migration/src/app/[lng]/(helper)/defense-gallery/[herogroup]/_getPage.ts +++ b/frontend-next-migration/src/app/[lng]/(helper)/defense-gallery/[herogroup]/_getPage.ts @@ -34,10 +34,6 @@ export async function _getPage(lng: string, heroGroup: string) { let groups: Record; try { groups = await initializeHeroGroupsFromDirectus(lng as 'en' | 'fi' | 'ru'); - // If Directus returns empty, fallback to static - if (Object.keys(groups).length === 0) { - groups = initializeHeroGroups(t); - } } catch { groups = initializeHeroGroups(t); } diff --git a/frontend-next-migration/src/app/[lng]/(helper)/hero-development/[slug]/_getPage.ts b/frontend-next-migration/src/app/[lng]/(helper)/hero-development/[slug]/_getPage.ts index 6dc8f55af..60edc5f69 100644 --- a/frontend-next-migration/src/app/[lng]/(helper)/hero-development/[slug]/_getPage.ts +++ b/frontend-next-migration/src/app/[lng]/(helper)/hero-development/[slug]/_getPage.ts @@ -18,14 +18,11 @@ export async function _getPage(lng: string, slug: string) { // Initialize from Directus first await heroManager.initializeFromDirectus(lng as 'en' | 'fi' | 'ru'); - // Try to get hero from Directus, fallback to static data - let currentHero = await heroManager.getHeroBySlugAsync( + // Only a failed Directus request may use the static fallback. + const currentHero = await heroManager.getHeroBySlugAsync( slug as HeroSlug, lng as 'en' | 'fi' | 'ru', ); - if (!currentHero) { - currentHero = heroManager.getHeroBySlug(slug as HeroSlug); - } if (!currentHero) { notFound(); } diff --git a/frontend-next-migration/src/app/[lng]/(helper)/heroes/[slug]/_getPage.ts b/frontend-next-migration/src/app/[lng]/(helper)/heroes/[slug]/_getPage.ts index d56254297..58d00bddb 100644 --- a/frontend-next-migration/src/app/[lng]/(helper)/heroes/[slug]/_getPage.ts +++ b/frontend-next-migration/src/app/[lng]/(helper)/heroes/[slug]/_getPage.ts @@ -17,15 +17,8 @@ async function getCurrentHero( lng: string, ): Promise { const locale = lng as 'en' | 'fi' | 'ru'; - let currentHero = await heroManager.getHeroBySlugAsync(slug as HeroSlug, locale); + const currentHero = await heroManager.getHeroBySlugAsync(slug as HeroSlug, locale); if (!currentHero) { - // eslint-disable-next-line no-console - console.log(`[heroes/_getPage] Hero "${slug}" not found in Directus, trying static data`); - currentHero = heroManager.getHeroBySlug(slug as HeroSlug); - } - if (!currentHero) { - // eslint-disable-next-line no-console - console.error(`[heroes/_getPage] Hero "${slug}" not found in Directus or static data`); notFound(); } return currentHero; @@ -83,11 +76,8 @@ export async function _getPage(lng: string, slug: string) { // Get current hero with fallback const currentHero = await getCurrentHero(heroManager, slug, lng); - // Get all heroes for navigation (try Directus first, fallback to static) - let heroes = await heroManager.getAllHeroesFromDirectus(lng as 'en' | 'fi' | 'ru'); - if (heroes.length === 0) { - heroes = heroManager.getAllHeroes(); - } + // Get all published heroes for navigation. + const heroes = await heroManager.getAllHeroesFromDirectus(lng as 'en' | 'fi' | 'ru'); const { prevHero, nextHero } = getNavigationHeroes(heroes, currentHero); const prevHeroLink = getRouteOneHeroPage(prevHero.slug); diff --git a/frontend-next-migration/src/entities/Hero/model/HeroManager.test.ts b/frontend-next-migration/src/entities/Hero/model/HeroManager.test.ts new file mode 100644 index 000000000..2338dbed7 --- /dev/null +++ b/frontend-next-migration/src/entities/Hero/model/HeroManager.test.ts @@ -0,0 +1,65 @@ +import { HeroSlug } from '../types/hero'; +import { HeroManager } from './HeroManager'; +import { fetchAllHeroes, fetchHeroBySlug } from './heroApi'; + +jest.mock('./heroApi', () => ({ + fetchAllHeroes: jest.fn(), + fetchHeroBySlug: jest.fn(), +})); + +const mockedFetchAllHeroes = jest.mocked(fetchAllHeroes); +const mockedFetchHeroBySlug = jest.mocked(fetchHeroBySlug); + +describe('HeroManager Directus fallback behavior', () => { + const manager = () => new HeroManager((key) => key); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not use static heroes after a successful empty all-heroes response', async () => { + mockedFetchAllHeroes.mockResolvedValue([]); + + await expect(manager().getAllHeroesFromDirectus()).resolves.toEqual([]); + }); + + it('uses static heroes after a failed all-heroes request', async () => { + mockedFetchAllHeroes.mockRejectedValue(new Error('network failure')); + + await expect(manager().getAllHeroesFromDirectus()).resolves.toEqual( + manager().getAllHeroes(), + ); + }); + + it('keeps groups empty after a successful empty initialization response', async () => { + mockedFetchAllHeroes.mockResolvedValue([]); + const heroManager = manager(); + + await heroManager.initializeFromDirectus(); + + expect(heroManager.getGroupsWithHeroes()).toEqual({}); + }); + + it('keeps static groups after a failed initialization request', async () => { + mockedFetchAllHeroes.mockRejectedValue(new Error('network failure')); + const heroManager = manager(); + + await heroManager.initializeFromDirectus(); + + expect(Object.keys(heroManager.getGroupsWithHeroes()).length).toBeGreaterThan(0); + }); + + it('does not use a static hero after a successful no-match slug response', async () => { + mockedFetchHeroBySlug.mockResolvedValue(undefined); + + await expect(manager().getHeroBySlugAsync(HeroSlug.OVEREATER)).resolves.toBeUndefined(); + }); + + it('uses static heroes after a failed Directus request', async () => { + mockedFetchHeroBySlug.mockRejectedValue(new Error('network failure')); + + await expect(manager().getHeroBySlugAsync(HeroSlug.OVEREATER)).resolves.toEqual( + manager().getHeroBySlug(HeroSlug.OVEREATER), + ); + }); +}); diff --git a/frontend-next-migration/src/entities/Hero/model/HeroManager.ts b/frontend-next-migration/src/entities/Hero/model/HeroManager.ts index 69f8c06f9..5cf7e19fd 100644 --- a/frontend-next-migration/src/entities/Hero/model/HeroManager.ts +++ b/frontend-next-migration/src/entities/Hero/model/HeroManager.ts @@ -22,13 +22,8 @@ export class HeroManager { public async initializeFromDirectus(locale: Locale = 'en'): Promise { try { const directusGroups = await initializeHeroGroupsFromDirectus(locale); - // Only replace static data if Directus returned non-empty groups - if (directusGroups && Object.keys(directusGroups).length > 0) { - this.heroGroups = directusGroups; - this.heroesCache = null; // Clear cache to force recalculation - } else { - console.warn('[HeroManager] Directus returned empty groups, keeping static data'); - } + this.heroGroups = directusGroups; + this.heroesCache = null; // Clear cache to force recalculation } catch (error) { console.error('[HeroManager] Failed to initialize hero groups from Directus:', error); // Keep existing static data as fallback @@ -71,15 +66,7 @@ export class HeroManager { */ public async getAllHeroesFromDirectus(locale: Locale = 'en'): Promise { try { - const heroes = await fetchAllHeroes(locale); - // If Directus returns empty array, fallback to static data - if (heroes.length === 0) { - console.warn( - '[HeroManager] Directus returned empty heroes array, using static data', - ); - return this.getAllHeroes(); - } - return heroes; + return await fetchAllHeroes(locale); } catch (error) { console.error('[HeroManager] Failed to fetch all heroes from Directus:', error); // Fallback to static data @@ -114,12 +101,11 @@ export class HeroManager { locale: Locale = 'en', ): Promise { try { - const hero = await fetchHeroBySlug(slug, locale); - if (hero) return hero; + return await fetchHeroBySlug(slug, locale); } catch { // ignore error and fallback + return this.getHeroBySlug(slug); } - return this.getHeroBySlug(slug); } public getHeroesBySpecificGroup(group: HeroGroup): HeroWithGroup[] | undefined { diff --git a/frontend-next-migration/src/entities/Hero/model/buildHeroQueryParams.ts b/frontend-next-migration/src/entities/Hero/model/buildHeroQueryParams.ts index c1504b3fc..f9434aea8 100644 --- a/frontend-next-migration/src/entities/Hero/model/buildHeroQueryParams.ts +++ b/frontend-next-migration/src/entities/Hero/model/buildHeroQueryParams.ts @@ -20,6 +20,7 @@ const languageCode = (locale: Locale): string => locale; export const FIELDS = [ 'id', 'slug', + 'status', 'order', ...HERO_IMG_KEYS.flatMap((key) => [`${key}.id`, `${key}.width`, `${key}.height`]), ...HERO_GIF_KEYS.flatMap((key) => [`${key}.id`, `${key}.width`, `${key}.height`]), @@ -67,6 +68,7 @@ export function buildHeroQueryParams( ): URLSearchParams { const params = new URLSearchParams(); if (options?.slug) params.set('filter[slug][_eq]', options.slug); + params.set('filter[status][_eq]', 'Published'); if (options?.limit) params.set('limit', options.limit); params.set('fields', FIELDS); params.set('sort', 'order'); diff --git a/frontend-next-migration/src/entities/Hero/model/heroApi.test.ts b/frontend-next-migration/src/entities/Hero/model/heroApi.test.ts new file mode 100644 index 000000000..5cde26b82 --- /dev/null +++ b/frontend-next-migration/src/entities/Hero/model/heroApi.test.ts @@ -0,0 +1,94 @@ +import { HeroGroup, HeroSlug } from '../types/hero'; +import { groupHeroesByGroup } from './groupHeroesByGroup'; +import { fetchAllHeroes, fetchHeroBySlug } from './heroApi'; + +jest.mock('@/shared/const/envHelper', () => ({ + envHelper: { directusHost: 'https://directus.test' }, +})); + +const publishedHero = { + id: 1, + slug: HeroSlug.OVEREATER, + status: 'Published', + order: 1, + translations: [ + { + languages_code: 'en-US', + title: 'Overeater', + description: 'Published hero', + alt: 'Overeater', + altGif: 'Overeater animated', + }, + ], + group: { + key: HeroGroup.RETROFLECTOR, + bgColour: '#000', + translations: [{ languages_code: 'en-US', name: 'Retroflector', description: 'Group' }], + }, + heroes_stats: [], +}; + +const response = (data: unknown) => + ({ + ok: true, + json: async () => ({ data }), + text: async () => JSON.stringify({ data }), + }) as Response; + +describe('heroApi status gating', () => { + const fetchMock = jest.fn() as jest.MockedFunction; + + beforeEach(() => { + fetchMock.mockReset(); + global.fetch = fetchMock; + }); + + it.each(['Draft', 'Archived'])('does not return a %s hero by slug', async (status) => { + fetchMock.mockImplementation(async (input) => { + const url = new URL(String(input)); + expect(url.searchParams.get('filter[status][_eq]')).toBe('Published'); + return response([{ ...publishedHero, status }]); + }); + + await expect(fetchHeroBySlug('draft-hero' as HeroSlug)).resolves.toBeUndefined(); + }); + + it('returns only the published heroes from the all-heroes request', async () => { + fetchMock.mockImplementation(async (input) => { + const url = new URL(String(input)); + expect(url.searchParams.get('filter[status][_eq]')).toBe('Published'); + return response([publishedHero]); + }); + + const heroes = await fetchAllHeroes(); + + expect(heroes).toHaveLength(1); + expect(heroes[0].slug).toBe(HeroSlug.OVEREATER); + }); + + it('keeps a successful empty all-heroes response empty', async () => { + fetchMock.mockResolvedValue(response([])); + + await expect(fetchAllHeroes()).resolves.toEqual([]); + }); + + it('rejects when the all-heroes request fails', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 503, text: async () => '' } as Response); + + await expect(fetchAllHeroes()).rejects.toThrow('Directus request failed with status 503'); + }); + + it('groups the published heroes returned by the filtered request', async () => { + fetchMock.mockImplementation(async (input) => { + const url = new URL(String(input)); + expect(url.searchParams.get('filter[status][_eq]')).toBe('Published'); + return response([publishedHero]); + }); + + const heroes = await fetchAllHeroes(); + const result = groupHeroesByGroup(heroes); + + expect(result[HeroGroup.RETROFLECTOR].heroes).toHaveLength(1); + expect(result[HeroGroup.RETROFLECTOR].heroes[0].slug).toBe(HeroSlug.OVEREATER); + }); +}); diff --git a/frontend-next-migration/src/entities/Hero/model/heroApi.ts b/frontend-next-migration/src/entities/Hero/model/heroApi.ts index c541e3cd4..ac958ae22 100644 --- a/frontend-next-migration/src/entities/Hero/model/heroApi.ts +++ b/frontend-next-migration/src/entities/Hero/model/heroApi.ts @@ -34,6 +34,7 @@ const normalizeLocale = (locale: Locale): string => { return localeMap[locale] || locale; }; const languageCode = (locale: Locale): string => normalizeLocale(locale); +const isPublished = (item: any): boolean => item?.status === 'Published'; function pickTranslationByLocale( arr: T[] | undefined, @@ -46,6 +47,7 @@ function pickTranslationByLocale( const FIELDS = [ 'id', 'slug', + 'status', 'order', ...HERO_IMG_KEYS.flatMap((key) => [`${key}.id`, `${key}.width`, `${key}.height`]), ...HERO_GIF_KEYS.flatMap((key) => [`${key}.id`, `${key}.width`, `${key}.height`]), @@ -175,6 +177,7 @@ function mapHero(item: any, locale: Locale): HeroWithGroup { function buildParams(locale: Locale, options?: { slug?: string; limit?: string }) { const params = new URLSearchParams(); if (options?.slug) params.set('filter[slug][_eq]', options.slug); + params.set('filter[status][_eq]', 'Published'); if (options?.limit) params.set('limit', options.limit); params.set('fields', FIELDS); params.set('sort', 'order'); @@ -195,7 +198,9 @@ export const heroApi = directusApi.injectEndpoints({ }), transformResponse: (resp: any, _meta, args) => { const item = resp?.data?.[0]; - return item ? mapHero(item, (args?.locale ?? 'en') as Locale) : undefined; + return item && isPublished(item) + ? mapHero(item, (args?.locale ?? 'en') as Locale) + : undefined; }, providesTags: (_res, _err, args) => [{ type: 'Hero' as const, id: args.slug }], }), @@ -216,7 +221,7 @@ export const heroApi = directusApi.injectEndpoints({ url: `/items/heroes?${buildParams(locale, { limit: '-1' }).toString()}`, }), transformResponse: (resp: any, _meta, args) => { - const items = resp?.data || []; + const items = (resp?.data || []).filter(isPublished); return items.map((item: any) => mapHero(item, (args?.locale ?? 'en') as Locale)); }, providesTags: () => [{ type: 'Hero' as const, id: 'LIST' }], @@ -226,7 +231,7 @@ export const heroApi = directusApi.injectEndpoints({ url: `/items/heroes?${buildParams(locale, { limit: '-1' }).toString()}`, }), transformResponse: (resp: any, _meta, args) => { - const items = resp?.data || []; + const items = (resp?.data || []).filter(isPublished); const heroes = items.map((item: any) => mapHero(item, (args?.locale ?? 'en') as Locale), ); @@ -254,7 +259,7 @@ export async function fetchHeroBySlug( console.warn( '[fetchHeroBySlug] Directus host not configured - check NEXT_PUBLIC_DIRECTUS_HOST env var', ); - return undefined; + throw new Error('Directus host is not configured'); } const params = buildParams(locale, { slug, limit: '1' }); @@ -268,12 +273,12 @@ export async function fetchHeroBySlug( console.warn( `[fetchHeroBySlug] Directus error ${res.status} for "${slug}": ${errorText.substring(0, 200)}`, ); - return undefined; + throw new Error(`Directus request failed with status ${res.status}`); } const json = await res.json(); const item = json?.data?.[0]; - if (!item) { + if (!item || !isPublished(item)) { // eslint-disable-next-line no-console console.warn( `[fetchHeroBySlug] No hero found in Directus for slug "${slug}" (response had ${json?.data?.length || 0} items)`, @@ -288,7 +293,7 @@ export async function fetchHeroBySlug( `[fetchHeroBySlug] ✗ Error fetching "${slug}":`, error instanceof Error ? error.message : error, ); - return undefined; + throw error; } } @@ -299,7 +304,7 @@ export async function fetchAllHeroes(locale: Locale = 'en'): Promise mapHero(item, locale)); } catch (error) { // eslint-disable-next-line no-console @@ -326,6 +331,6 @@ export async function fetchAllHeroes(locale: Locale = 'en'): Promise ({ + fetchAllHeroes: jest.fn(), +})); + +const mockedFetchAllHeroes = jest.mocked(fetchAllHeroes); + +describe('initializeHeroGroupsFromDirectus', () => { + it('groups the heroes returned by Directus', async () => { + mockedFetchAllHeroes.mockResolvedValue([ + { + id: 1, + slug: HeroSlug.OVEREATER, + title: 'Overeater', + groupEnum: HeroGroup.RETROFLECTOR, + groupName: 'Retroflector', + groupDescription: 'Group description', + groupBgColour: '#000', + groupLabel: '', + srcImg: '', + srcGif: '', + alt: '', + altGif: '', + rarityClass: 'common', + description: '', + stats: [], + }, + ]); + + const groups = await initializeHeroGroupsFromDirectus('en'); + + expect(groups.RETROFLECTOR.heroes).toHaveLength(1); + expect(groups.RETROFLECTOR.heroes[0].title).toBe('Overeater'); + }); +}); diff --git a/frontend-next-migration/src/entities/Hero/model/initializeHeroGroups.ts b/frontend-next-migration/src/entities/Hero/model/initializeHeroGroups.ts index 50db6bc82..3d2730352 100644 --- a/frontend-next-migration/src/entities/Hero/model/initializeHeroGroups.ts +++ b/frontend-next-migration/src/entities/Hero/model/initializeHeroGroups.ts @@ -22,16 +22,6 @@ export const initializeHeroGroups = (t: (key: string) => string): Record> { - try { - const heroes = await fetchAllHeroes(locale); - return groupHeroesByGroup(heroes); - } catch (error) { - // eslint-disable-next-line no-console - console.error( - '[initializeHeroGroupsFromDirectus] Failed to fetch hero groups from Directus:', - error, - ); - // Return empty record - caller should check and keep static data - return {} as Record; - } + const heroes = await fetchAllHeroes(locale); + return groupHeroesByGroup(heroes); } diff --git a/frontend-next-migration/src/features/NavigateHeroGroups/ui/HeroGroupNavMenu/HeroGroupNavMenu.test.tsx b/frontend-next-migration/src/features/NavigateHeroGroups/ui/HeroGroupNavMenu/HeroGroupNavMenu.test.tsx new file mode 100644 index 000000000..86a282750 --- /dev/null +++ b/frontend-next-migration/src/features/NavigateHeroGroups/ui/HeroGroupNavMenu/HeroGroupNavMenu.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from '@testing-library/react'; +import HeroGroupNavMenu from './HeroGroupNavMenu'; +import { useGetHeroGroupsQuery } from '@/entities/Hero/model/heroApi'; +import { useParams } from 'next/navigation'; + +jest.mock('next/navigation', () => ({ + usePathname: () => '/en/defense-gallery/RETROFLECTOR', + useParams: jest.fn(() => ({ lng: 'en' })), +})); + +jest.mock('@/shared/i18n', () => ({ + useClientTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('@/entities/Hero/model/heroApi', () => ({ + useGetHeroGroupsQuery: jest.fn(), +})); + +jest.mock('@/entities/Hero/model/initializeHeroGroups', () => ({ + initializeHeroGroups: () => ({ + RETROFLECTOR: { name: 'Static group', heroes: [] }, + }), +})); + +jest.mock('@/shared/ui/NavMenuWithDropdownsV2', () => ({ + NavMenuWithDropdowns: ({ + title, + dropdownItems, + }: { + title: string; + dropdownItems: { elementText: string }[]; + }) => ( +
+ {title} + {dropdownItems.map((item) => ( + {item.elementText} + ))} +
+ ), +})); + +const mockedUseGetHeroGroupsQuery = jest.mocked(useGetHeroGroupsQuery); +const mockedUseParams = jest.mocked(useParams); + +describe('HeroGroupNavMenu', () => { + it('uses static groups when Directus fails', () => { + mockedUseGetHeroGroupsQuery.mockReturnValue({ isError: true } as unknown as ReturnType< + typeof useGetHeroGroupsQuery + >); + + render(); + + expect(screen.getByText('Static group')).toBeInTheDocument(); + }); + + it('uses published Directus groups when the request succeeds', () => { + mockedUseGetHeroGroupsQuery.mockReturnValue({ + isError: false, + data: { RETROFLECTOR: { name: 'directus group', heroes: [] } }, + } as unknown as ReturnType); + + render(); + + expect(screen.getByText('Directus group')).toBeInTheDocument(); + }); + + it('keeps the navigation empty after a successful empty response', () => { + mockedUseGetHeroGroupsQuery.mockReturnValue({ + isError: false, + data: undefined, + } as unknown as ReturnType); + + render(); + + expect(screen.queryByText('Static group')).not.toBeInTheDocument(); + }); + + it.each(['fi', 'ru'])('maps the %s route locale for Directus', (lng) => { + mockedUseParams.mockReturnValue({ lng }); + mockedUseGetHeroGroupsQuery.mockReturnValue({ + isError: false, + data: {}, + } as unknown as ReturnType); + + render(); + + expect(mockedUseGetHeroGroupsQuery).toHaveBeenLastCalledWith({ locale: lng }); + }); + + it('defaults to English and handles an empty group name', () => { + mockedUseParams.mockReturnValue({}); + mockedUseGetHeroGroupsQuery.mockReturnValue({ + isError: false, + data: { RETROFLECTOR: { name: '', heroes: [] } }, + } as unknown as ReturnType); + + render(); + + expect(mockedUseGetHeroGroupsQuery).toHaveBeenLastCalledWith({ locale: 'en' }); + }); +}); diff --git a/frontend-next-migration/src/features/NavigateHeroGroups/ui/HeroGroupNavMenu/HeroGroupNavMenu.tsx b/frontend-next-migration/src/features/NavigateHeroGroups/ui/HeroGroupNavMenu/HeroGroupNavMenu.tsx index 9a9cdc70f..3ba6866c1 100644 --- a/frontend-next-migration/src/features/NavigateHeroGroups/ui/HeroGroupNavMenu/HeroGroupNavMenu.tsx +++ b/frontend-next-migration/src/features/NavigateHeroGroups/ui/HeroGroupNavMenu/HeroGroupNavMenu.tsx @@ -25,14 +25,11 @@ const HeroGroupNavMenu: React.FC = ({ className: _classNa const selectedHeroGroup = pathname.split('/')[3]; // Try to fetch from Directus first, fallback to static data - const { data: directusGroups } = useGetHeroGroupsQuery({ locale }); + const { data: directusGroups, isError } = useGetHeroGroupsQuery({ locale }); const staticGroups = React.useMemo(() => initializeHeroGroups(t), [t]); const allHeroGroups = React.useMemo(() => { - if (directusGroups && Object.keys(directusGroups).length > 0) { - return directusGroups; - } - return staticGroups; - }, [directusGroups, staticGroups]); + return isError ? staticGroups : (directusGroups ?? ({} as typeof staticGroups)); + }, [directusGroups, isError, staticGroups]); function capitalizeString(inputString: HeroGroup | string) { if (!inputString) return ''; diff --git a/frontend-next-migration/src/preparedPages/DefenseGalleryPages/ui/DefenseGalleryPage.tsx b/frontend-next-migration/src/preparedPages/DefenseGalleryPages/ui/DefenseGalleryPage.tsx index 3c7a39649..44b2514b4 100644 --- a/frontend-next-migration/src/preparedPages/DefenseGalleryPages/ui/DefenseGalleryPage.tsx +++ b/frontend-next-migration/src/preparedPages/DefenseGalleryPages/ui/DefenseGalleryPage.tsx @@ -27,7 +27,7 @@ const DefenseGalleryPage = () => { const lng = (params?.lng as string) || 'en'; const locale = (lng === 'en' ? 'en' : lng === 'fi' ? 'fi' : 'ru') as 'en' | 'fi' | 'ru'; - // Try to fetch from Directus first, fallback to static data + // Try to fetch from Directus const { data: directusGroups, isError, error } = useGetHeroGroupsQuery({ locale }); const staticGroups = React.useMemo(() => initializeHeroGroups(t), [t]); const heroGroups = React.useMemo(() => { @@ -36,10 +36,7 @@ const DefenseGalleryPage = () => { console.warn('[DefenseGalleryPage] Directus query failed, using static data:', error); return staticGroups; } - if (directusGroups && Object.keys(directusGroups).length > 0) { - return directusGroups; - } - return staticGroups; + return directusGroups ?? {}; }, [directusGroups, staticGroups, isError, error]); // Create an array of all heroes with their group information diff --git a/frontend-next-migration/src/preparedPages/DefenseGalleryPages/ui/SingleDefensePage.tsx b/frontend-next-migration/src/preparedPages/DefenseGalleryPages/ui/SingleDefensePage.tsx index 19cfa12ba..3f1f67111 100644 --- a/frontend-next-migration/src/preparedPages/DefenseGalleryPages/ui/SingleDefensePage.tsx +++ b/frontend-next-migration/src/preparedPages/DefenseGalleryPages/ui/SingleDefensePage.tsx @@ -229,8 +229,6 @@ function useHeroGroupsWithFallback( ): Record { const { data: directusGroups, isError, error } = useGetHeroGroupsQuery({ locale }); const staticGroups = React.useMemo(() => initializeHeroGroups(t), [t]); - // Use Directus data only if it exists, has keys, and there's no error - const hasDirectusData = !isError && directusGroups && Object.keys(directusGroups).length > 0; if (isError) { // eslint-disable-next-line no-console console.warn( @@ -239,8 +237,9 @@ function useHeroGroupsWithFallback( ); } return React.useMemo( - () => (hasDirectusData ? directusGroups : staticGroups), - [hasDirectusData, directusGroups, staticGroups], + () => + isError ? staticGroups : (directusGroups ?? ({} as Record)), + [directusGroups, isError, staticGroups], ); } diff --git a/frontend-next-migration/src/preparedPages/HeroesPages/ui/SingleHeroPage.tsx b/frontend-next-migration/src/preparedPages/HeroesPages/ui/SingleHeroPage.tsx index 5adff5ad7..e6938ed38 100644 --- a/frontend-next-migration/src/preparedPages/HeroesPages/ui/SingleHeroPage.tsx +++ b/frontend-next-migration/src/preparedPages/HeroesPages/ui/SingleHeroPage.tsx @@ -56,7 +56,7 @@ const SingleHeroPage = (props: Props) => { const { isMobileSize, isTabletSize } = useSizes(); // Try to fetch from Directus if hero not provided from server - const { data: directusHero } = useGetHeroBySlugQuery( + const { data: directusHero, isError: isDirectusHeroError } = useGetHeroBySlugQuery( { slug, locale }, { skip: !!newSelectedHero }, ); @@ -73,7 +73,7 @@ const SingleHeroPage = (props: Props) => { // Determine selected hero and its group to populate localized titles const { titleText, hero } = useMemo<{ titleText: string; hero?: Hero }>(() => { - // Priority: 1. Server-provided hero, 2. Directus hero, 3. Static data fallback + // Priority: 1. Server-provided hero, 2. Directus hero, 3. static fallback after failure const selectedHero = newSelectedHero || directusHero; if (selectedHero) { // Merge Directus stats (levels) with baseline tiers (rarityClass) from the hero data. @@ -109,7 +109,11 @@ const SingleHeroPage = (props: Props) => { }; } - // Fallback to static data + if (!isDirectusHeroError) { + return { titleText: '', hero: undefined }; + } + + // Fall back to static data only when the Directus request failed. const heroGroups = initializeHeroGroups(t); for (const groupKey in heroGroups) { const group = heroGroups[groupKey as keyof typeof heroGroups]; @@ -122,7 +126,7 @@ const SingleHeroPage = (props: Props) => { } } return { titleText: '', hero: undefined }; - }, [newSelectedHero, directusHero, directusStats, slug, t]); + }, [newSelectedHero, directusHero, directusStats, isDirectusHeroError, slug, t]); const rarityLabel = useMemo(() => { if (!hero?.rarityClass) return ''; diff --git a/frontend-next-migration/src/shared/assets/images/altLogoold.png b/frontend-next-migration/src/shared/assets/images/altLogoold.png index cc4540b40..388432320 100644 Binary files a/frontend-next-migration/src/shared/assets/images/altLogoold.png and b/frontend-next-migration/src/shared/assets/images/altLogoold.png differ diff --git a/frontend-next-migration/src/widgets/SectionHeroesBlocks/ui/index.test.tsx b/frontend-next-migration/src/widgets/SectionHeroesBlocks/ui/index.test.tsx new file mode 100644 index 000000000..c41bad158 --- /dev/null +++ b/frontend-next-migration/src/widgets/SectionHeroesBlocks/ui/index.test.tsx @@ -0,0 +1,160 @@ +import { render, screen } from '@testing-library/react'; +import Main from './index'; +import { useGetHeroGroupsQuery } from '@/entities/Hero/model/heroApi'; +import { useParams } from 'next/navigation'; + +jest.mock('react-intersection-observer', () => ({ + useInView: () => ({ ref: jest.fn(), inView: false }), +})); + +jest.mock('next/navigation', () => ({ + useParams: jest.fn(() => ({ lng: 'en' })), +})); + +jest.mock('@/shared/i18n', () => ({ + useClientTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('@/entities/Hero/model/heroApi', () => ({ + useGetHeroGroupsQuery: jest.fn(), +})); + +jest.mock('@/entities/Hero', () => ({ + HeroManager: jest.fn().mockImplementation(() => ({ + getGroupsWithHeroesAsArray: () => [ + { name: 'Static group', heroes: [], label: '', bgColour: '#000' }, + ], + })), +})); + +jest.mock('./heroesBlocks/HeroesBlocks', () => ({ + __esModule: true, + default: ({ labelText }: { labelText: string }) => ( +
{labelText}
+ ), +})); + +jest.mock('@/shared/ui/Container', () => ({ + Container: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +jest.mock('@/shared/ui/Button', () => ({ + Button: jest + .requireActual('react') + .forwardRef( + ({ children }: { children: React.ReactNode }, ref: React.Ref) => ( + + ), + ), + ButtonSize: { XL: 'XL' }, + ButtonTheme: { Graffiti: 'Graffiti' }, +})); + +jest.mock('@/shared/ui/AppLink/AppLink', () => ({ + AppLink: ({ children }: { children: React.ReactNode }) => {children}, +})); + +const mockedUseGetHeroGroupsQuery = jest.mocked(useGetHeroGroupsQuery); +const mockedUseParams = jest.mocked(useParams); + +describe('SectionHeroesBlocks', () => { + it('renders no groups while Directus is loading', () => { + mockedUseGetHeroGroupsQuery.mockReturnValue({ isLoading: true } as unknown as ReturnType< + typeof useGetHeroGroupsQuery + >); + + render(
); + + expect(screen.queryByTestId('hero-group')).not.toBeInTheDocument(); + }); + + it('uses static groups after a Directus error', () => { + mockedUseGetHeroGroupsQuery.mockReturnValue({ + isLoading: false, + isError: true, + } as unknown as ReturnType); + + render(
); + + expect(screen.getByText('Static group')).toBeInTheDocument(); + }); + + it('renders Directus groups and the see-more link after success', () => { + mockedUseGetHeroGroupsQuery.mockReturnValue({ + isLoading: false, + isError: false, + data: { + RETROFLECTOR: { name: 'Directus group', heroes: [], label: '', bgColour: '#000' }, + }, + } as unknown as ReturnType); + + render( +
, + ); + + expect(screen.getByText('Directus group')).toBeInTheDocument(); + expect(screen.getByText('See more')).toBeInTheDocument(); + }); + + it('keeps the group list empty after a successful empty response', () => { + mockedUseGetHeroGroupsQuery.mockReturnValue({ + isLoading: false, + isError: false, + data: undefined, + } as unknown as ReturnType); + + render(
); + + expect(screen.queryByTestId('hero-group')).not.toBeInTheDocument(); + }); + + it('does not limit groups when maxGroupsPerPage is zero', () => { + mockedUseGetHeroGroupsQuery.mockReturnValue({ + isLoading: false, + isError: false, + data: { + RETROFLECTOR: { name: 'First group', heroes: [], label: '', bgColour: '#000' }, + DESENSITIZER: { name: 'Second group', heroes: [], label: '', bgColour: '#000' }, + }, + } as unknown as ReturnType); + + render( +
, + ); + + expect(screen.getAllByTestId('hero-group')).toHaveLength(2); + }); + + it.each(['fi', 'ru'])('maps the %s route locale for Directus', (lng) => { + mockedUseParams.mockReturnValue({ lng }); + mockedUseGetHeroGroupsQuery.mockReturnValue({ + isLoading: false, + isError: false, + data: undefined, + } as unknown as ReturnType); + + render(
); + + expect(mockedUseGetHeroGroupsQuery).toHaveBeenLastCalledWith({ locale: lng }); + }); + + it('defaults to English when the route has no locale', () => { + mockedUseParams.mockReturnValue({}); + mockedUseGetHeroGroupsQuery.mockReturnValue({ + isLoading: false, + isError: false, + data: undefined, + } as unknown as ReturnType); + + render(
); + + expect(mockedUseGetHeroGroupsQuery).toHaveBeenLastCalledWith({ locale: 'en' }); + }); +}); diff --git a/frontend-next-migration/src/widgets/SectionHeroesBlocks/ui/index.tsx b/frontend-next-migration/src/widgets/SectionHeroesBlocks/ui/index.tsx index 12e81b7be..3bd97237e 100644 --- a/frontend-next-migration/src/widgets/SectionHeroesBlocks/ui/index.tsx +++ b/frontend-next-migration/src/widgets/SectionHeroesBlocks/ui/index.tsx @@ -1,11 +1,14 @@ 'use client'; +import React from 'react'; import { useInView } from 'react-intersection-observer'; import { HeroManager } from '@/entities/Hero'; +import { useGetHeroGroupsQuery } from '@/entities/Hero/model/heroApi'; import { Button, ButtonSize, ButtonTheme } from '@/shared/ui/Button'; import { classNames } from '@/shared/lib/classNames/classNames'; import { useClientTranslation } from '@/shared/i18n'; import { Container } from '@/shared/ui/Container'; import { AppLink } from '@/shared/ui/AppLink/AppLink'; +import { useParams } from 'next/navigation'; import HeroesBlocks from './heroesBlocks/HeroesBlocks'; import cls from './main.module.scss'; @@ -34,8 +37,16 @@ function Main(props: Props) { }; const { t } = useClientTranslation('heroes'); - const heroManager = new HeroManager(t); - const heroesGroups2 = heroManager.getGroupsWithHeroesAsArray(); + const params = useParams(); + const lng = (params?.lng as string) || 'en'; + const locale = (lng === 'en' ? 'en' : lng === 'fi' ? 'fi' : 'ru') as 'en' | 'fi' | 'ru'; + const { data: directusGroups, isError, isLoading } = useGetHeroGroupsQuery({ locale }); + const staticGroups = React.useMemo(() => new HeroManager(t).getGroupsWithHeroesAsArray(), [t]); + const heroesGroups2 = React.useMemo(() => { + if (isLoading) return []; + if (isError) return staticGroups; + return directusGroups ? Object.values(directusGroups) : []; + }, [directusGroups, isError, isLoading, staticGroups]); const displayedGroups = maxGroupsPerPage ? heroesGroups2.slice(0, maxGroupsPerPage)