Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,6 @@ export async function _getPage(lng: string, heroGroup: string) {
let groups: Record<HeroGroup, GroupInfo>;
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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,8 @@ async function getCurrentHero(
lng: string,
): Promise<HeroWithGroup> {
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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
);
});
});
24 changes: 5 additions & 19 deletions frontend-next-migration/src/entities/Hero/model/HeroManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,8 @@ export class HeroManager {
public async initializeFromDirectus(locale: Locale = 'en'): Promise<void> {
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
Expand Down Expand Up @@ -71,15 +66,7 @@ export class HeroManager {
*/
public async getAllHeroesFromDirectus(locale: Locale = 'en'): Promise<HeroWithGroup[]> {
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
Expand Down Expand Up @@ -114,12 +101,11 @@ export class HeroManager {
locale: Locale = 'en',
): Promise<HeroWithGroup | undefined> {
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`]),
Expand Down Expand Up @@ -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');
Expand Down
94 changes: 94 additions & 0 deletions frontend-next-migration/src/entities/Hero/model/heroApi.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fetch>;

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);
});
});
Loading
Loading