Skip to content

Commit 0f88cf1

Browse files
committed
feat(config): sort navigation by group.rank and rank, strip ordering fields from response
- Add rank and group.rank to NavigationItemInput document type - Add sortNavigation() pure function with group.rank → rank sort, key tiebreaker, sentinel for unranked items - Replace spread pattern in transformer with explicit field construction to prevent rank leakage - Export NavigationItemInput type for testability - Add 4 sortNavigation unit tests and 1 integration test for sorted output with rank stripping
1 parent ff9b6e5 commit 0f88cf1

3 files changed

Lines changed: 217 additions & 8 deletions

File tree

src/package/config/config.document.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@ import { Config } from './config.types.ts';
22
import { WithId } from 'mongodb';
33

44
/** Raw MongoDB navigation item — key is optional because the transformer
5-
* generates it from destination when absent in the persisted document. */
6-
type NavigationItemInput = Omit<Config['navigation'][number], 'key'> & {
7-
key?: string | null;
8-
};
5+
* generates it from destination when absent in the persisted document.
6+
* rank and group.rank are ordering fields stripped before the response. */
7+
export type NavigationItemInput =
8+
& Omit<Config['navigation'][number], 'key' | 'group'>
9+
& {
10+
key?: string | null;
11+
rank?: number;
12+
group: Config['navigation'][number]['group'] & {
13+
rank?: number;
14+
};
15+
};
916

1017
export type ConfigDocument =
1118
& WithId<

src/package/config/config.service.test.ts

Lines changed: 175 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { NotFoundException } from '@danet/core';
44
import { createMockLogger } from '@scope/common/testing';
55
import { ConfigService } from './config.service.ts';
66
import { validateNavigation } from './config.validation.ts';
7+
import { sortNavigation } from './config.transformer.ts';
78
import type { Config } from './config.types.ts';
9+
import type { NavigationItemInput } from './config.document.ts';
810
import type { ConfigRepository } from './config.repository.ts';
911
import type { ExperimentService } from '@scope/experiment';
1012

@@ -190,6 +192,119 @@ describe('validateNavigation', () => {
190192
});
191193
});
192194

195+
// ---------------------------------------------------------------------------
196+
// Unit: sortNavigation
197+
// ---------------------------------------------------------------------------
198+
199+
function makeDocNavItem(
200+
overrides?: Partial<NavigationItemInput>,
201+
): NavigationItemInput {
202+
return {
203+
criteria: 'test',
204+
destination: '/test',
205+
i18n: 'nav.test',
206+
icon: 'test',
207+
group: { authenticated: false, i18n: 'nav.group.main' },
208+
...overrides,
209+
};
210+
}
211+
212+
describe('sortNavigation', () => {
213+
it('sorts by group.rank ascending, then rank ascending', () => {
214+
const nav: NavigationItemInput[] = [
215+
makeDocNavItem({
216+
key: 'catalogs-1',
217+
group: { authenticated: false, i18n: 'nav.group.catalogs', rank: 2 },
218+
rank: 1,
219+
}),
220+
makeDocNavItem({
221+
key: 'general-1',
222+
group: { authenticated: false, i18n: 'nav.group.general', rank: 0 },
223+
rank: 0,
224+
}),
225+
makeDocNavItem({
226+
key: 'general-2',
227+
group: { authenticated: false, i18n: 'nav.group.general', rank: 0 },
228+
rank: 1,
229+
}),
230+
makeDocNavItem({
231+
key: 'manage-1',
232+
group: { authenticated: true, i18n: 'nav.group.manage', rank: 1 },
233+
rank: 0,
234+
}),
235+
makeDocNavItem({
236+
key: 'support-1',
237+
group: { authenticated: false, i18n: 'nav.group.support', rank: 3 },
238+
rank: 0,
239+
}),
240+
];
241+
const sorted = sortNavigation(nav);
242+
assertEquals(sorted.map((i) => i.key), [
243+
'general-1',
244+
'general-2',
245+
'manage-1',
246+
'catalogs-1',
247+
'support-1',
248+
]);
249+
});
250+
251+
it('sorts items with undefined rank after ranked items', () => {
252+
const nav: NavigationItemInput[] = [
253+
makeDocNavItem({
254+
key: 'has-rank',
255+
group: { authenticated: false, i18n: 'group', rank: 0 },
256+
rank: 0,
257+
}),
258+
makeDocNavItem({
259+
key: 'no-rank',
260+
group: { authenticated: false, i18n: 'group' },
261+
}),
262+
];
263+
const sorted = sortNavigation(nav);
264+
assertEquals(sorted.map((i) => i.key), ['has-rank', 'no-rank']);
265+
});
266+
267+
it('does not mutate the input array', () => {
268+
const nav: NavigationItemInput[] = [
269+
makeDocNavItem({
270+
key: 'b',
271+
group: { authenticated: false, i18n: 'group', rank: 0 },
272+
rank: 1,
273+
}),
274+
makeDocNavItem({
275+
key: 'a',
276+
group: { authenticated: false, i18n: 'group', rank: 0 },
277+
rank: 0,
278+
}),
279+
];
280+
const copy = [...nav];
281+
sortNavigation(nav);
282+
assertEquals(nav, copy);
283+
});
284+
285+
it('uses key as tiebreaker for deterministic ordering', () => {
286+
const nav: NavigationItemInput[] = [
287+
makeDocNavItem({
288+
key: 'z',
289+
group: { authenticated: false, i18n: 'group', rank: 0 },
290+
rank: 0,
291+
}),
292+
makeDocNavItem({
293+
key: 'a',
294+
group: { authenticated: false, i18n: 'group', rank: 0 },
295+
rank: 0,
296+
}),
297+
makeDocNavItem({
298+
key: 'm',
299+
group: { authenticated: false, i18n: 'group', rank: 0 },
300+
rank: 0,
301+
}),
302+
];
303+
const sorted = sortNavigation(nav);
304+
assertEquals(sorted.map((i) => i.key), ['a', 'm', 'z']);
305+
});
306+
});
307+
193308
// ---------------------------------------------------------------------------
194309
// Stub classes for integration tests
195310
// ---------------------------------------------------------------------------
@@ -207,7 +322,7 @@ type ConfigDocumentStub = {
207322
_id: { toString(): string };
208323
image: Record<string, string>;
209324
genres: Array<{ name: string; mediaId: number }>;
210-
navigation: Config['navigation'];
325+
navigation: NavigationItemInput[];
211326
};
212327

213328
function makeDocumentStub(
@@ -265,6 +380,65 @@ describe('ConfigService.getConfig', () => {
265380
assertEquals(result.navigation[1].key, 'search');
266381
});
267382

383+
it('returns navigation sorted by group.rank then rank, without rank fields in output', async () => {
384+
const repository = {
385+
getConfig: async () =>
386+
makeDocumentStub({
387+
navigation: [
388+
makeDocNavItem({
389+
key: 'support',
390+
destination: '/support',
391+
group: { authenticated: false, i18n: 'support', rank: 3 },
392+
rank: 0,
393+
}),
394+
makeDocNavItem({
395+
key: 'general',
396+
destination: '/home',
397+
group: { authenticated: false, i18n: 'general', rank: 0 },
398+
rank: 0,
399+
}),
400+
makeDocNavItem({
401+
key: 'catalogs',
402+
destination: '/discover',
403+
group: { authenticated: false, i18n: 'catalogs', rank: 2 },
404+
rank: 0,
405+
}),
406+
makeDocNavItem({
407+
key: 'manage',
408+
destination: '/animelist',
409+
group: { authenticated: true, i18n: 'manage', rank: 1 },
410+
rank: 0,
411+
}),
412+
],
413+
}),
414+
};
415+
const experiment = new ExperimentStub();
416+
const { logger } = createMockLogger();
417+
const service = new ConfigService(
418+
repository as unknown as ConfigRepository,
419+
experiment as unknown as ExperimentService,
420+
logger,
421+
);
422+
423+
const result = await service.getConfig();
424+
assertEquals(result.navigation.length, 4);
425+
// Expected order: General(0) → Manage(1) → Catalogs(2) → Support(3)
426+
assertEquals(result.navigation.map((i) => i.key), [
427+
'general',
428+
'manage',
429+
'catalogs',
430+
'support',
431+
]);
432+
// Verify rank is not in the output
433+
for (const item of result.navigation) {
434+
assertEquals((item as Record<string, unknown>).rank, undefined);
435+
assertEquals(
436+
((item.group as unknown) as Record<string, unknown>).rank,
437+
undefined,
438+
);
439+
}
440+
});
441+
268442
it('throws Error when navigation has duplicate keys', async () => {
269443
const repository = {
270444
getConfig: async () =>

src/package/config/config.transformer.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Transform } from '@scope/common/transformer';
2-
import { ConfigDocument } from './config.document.ts';
2+
import { ConfigDocument, NavigationItemInput } from './config.document.ts';
33
import { Config } from './config.types.ts';
44
import { PlatformSource } from '@scope/experiment';
55

@@ -18,6 +18,27 @@ const toImageUrl = (image: string, source: PlatformSource): string => {
1818
const keyFromDestination = (destination: string): string =>
1919
destination.replace(/^\/+/, '').replace(/\//g, '-') || 'unknown';
2020

21+
/**
22+
* Sort navigation items by group.rank ascending, then by item rank
23+
* ascending. Items with undefined ranks sort last. The item key is
24+
* used as a tiebreaker for deterministic ordering.
25+
*/
26+
export function sortNavigation(
27+
navigation: NavigationItemInput[],
28+
): NavigationItemInput[] {
29+
const sorted = [...navigation];
30+
sorted.sort((a, b) => {
31+
const groupRankA = a.group?.rank ?? Number.MAX_SAFE_INTEGER;
32+
const groupRankB = b.group?.rank ?? Number.MAX_SAFE_INTEGER;
33+
if (groupRankA !== groupRankB) return groupRankA - groupRankB;
34+
const rankA = a.rank ?? Number.MAX_SAFE_INTEGER;
35+
const rankB = b.rank ?? Number.MAX_SAFE_INTEGER;
36+
if (rankA !== rankB) return rankA - rankB;
37+
return (a.key ?? '').localeCompare(b.key ?? '');
38+
});
39+
return sorted;
40+
}
41+
2142
export const transform: Transform<
2243
{
2344
document: ConfigDocument;
@@ -42,8 +63,15 @@ export const transform: Transform<
4263
info: toImageUrl(image.info, platformSource),
4364
default: toImageUrl(image.default, platformSource),
4465
},
45-
navigation: navigation.map((item) => ({
46-
...item,
66+
navigation: sortNavigation(navigation).map((item) => ({
67+
criteria: item.criteria,
68+
destination: item.destination,
69+
i18n: item.i18n,
70+
icon: item.icon,
71+
group: {
72+
authenticated: item.group?.authenticated ?? false,
73+
i18n: item.group?.i18n ?? '',
74+
},
4775
key: item.key || keyFromDestination(item.destination),
4876
})),
4977
};

0 commit comments

Comments
 (0)