Extract useCatalogFavorites pure helpers + 16 Vitest cases

The 5 pure functions inside useCatalogFavorites
(normalizeCatalogType, getOffersStorageKey, getPagesStorageKey,
parseOffers, parsePages) handle the v2 -> v3 storage-key migration
that runs once per user the first time they open the v3 client. The
parseOffers branch in particular silently morphs the legacy number[]
shape into IFavoriteOffer[] — exactly the kind of one-shot migration
code that should have coverage so a refactor doesn't break old saves.

Move them into useCatalogFavorites.helpers.ts (sibling file, matching
the WiredCreatorTools / useInventoryFurni.reducers / avatarInfo.reducers
convention). useCatalogFavorites imports them back, plus re-exports
the IFavoriteOffer type from the helper module for the public API.

Both helpers import CatalogType from the concrete file path
('../../api/catalog/CatalogType') rather than the api barrel, so the
test file doesn't drag in the renderer SDK and run aground in jsdom.

Tests cover:
- normalizeCatalogType fallback to NORMAL on undefined/garbage/explicit
- storage-key routing for NORMAL / BUILDER / missing arg
- parseOffers: invalid JSON, non-array, empty array, v2 number[] migration,
  v3 IFavoriteOffer[] passthrough, mixed-array passthrough
- parsePages: invalid JSON, non-array, normal array

Net Vitest count: 83 -> 99 (7 test files).
This commit is contained in:
simoleo89
2026-05-11 22:45:57 +02:00
parent 7b062299de
commit 8b79233059
3 changed files with 206 additions and 52 deletions
@@ -0,0 +1,85 @@
import { CatalogType } from '../../api/catalog/CatalogType';
/**
* Pure helpers consumed by useCatalogFavorites. Extracted standalone
* so localStorage parse/migration logic can be covered by Vitest
* without React or the renderer SDK in the loop.
*
* The favorites system tracks two parallel lists per catalog type
* (NORMAL vs BUILDER): a list of favorited offers (offer id + display
* metadata) and a list of favorited page ids. Both persist in
* localStorage under per-type keys; a v1 → v3 migration runs for the
* NORMAL catalog when the v3 key is empty but the legacy key exists.
*/
export interface IFavoriteOffer
{
offerId: number;
name?: string;
iconUrl?: string;
}
export const LEGACY_STORAGE_KEY_OFFERS = 'catalog_fav_offers_v2';
export const LEGACY_STORAGE_KEY_PAGES = 'catalog_fav_pages';
export const STORAGE_KEY_OFFERS_NORMAL = 'catalog_fav_offers_v3_normal';
export const STORAGE_KEY_OFFERS_BUILDER = 'catalog_fav_offers_v3_builder';
export const STORAGE_KEY_PAGES_NORMAL = 'catalog_fav_pages_v2_normal';
export const STORAGE_KEY_PAGES_BUILDER = 'catalog_fav_pages_v2_builder';
export const normalizeCatalogType = (catalogType?: string): string =>
((catalogType === CatalogType.BUILDER) ? CatalogType.BUILDER : CatalogType.NORMAL);
export const getOffersStorageKey = (catalogType?: string): string =>
((normalizeCatalogType(catalogType) === CatalogType.BUILDER) ? STORAGE_KEY_OFFERS_BUILDER : STORAGE_KEY_OFFERS_NORMAL);
export const getPagesStorageKey = (catalogType?: string): string =>
((normalizeCatalogType(catalogType) === CatalogType.BUILDER) ? STORAGE_KEY_PAGES_BUILDER : STORAGE_KEY_PAGES_NORMAL);
/**
* Parse a serialized offers list from localStorage. Handles three
* cases:
* - well-formed `IFavoriteOffer[]` → returned as-is
* - legacy `number[]` (v2 format) → migrated to `IFavoriteOffer[]`
* with only offerId populated
* - anything else (corrupt JSON, wrong shape) → empty array
*/
export const parseOffers = (raw: string): IFavoriteOffer[] =>
{
try
{
const parsed = JSON.parse(raw);
if(!Array.isArray(parsed)) return [];
// migrate from old format (number[]) to new format (IFavoriteOffer[])
if(parsed.length > 0 && typeof parsed[0] === 'number')
{
return (parsed as number[]).map(id => ({ offerId: id }));
}
return parsed;
}
catch
{
return [];
}
};
/**
* Parse a serialized pages list from localStorage. Accepts any
* array, rejects everything else. (The pages list has always been
* `number[]`, no legacy format to migrate.)
*/
export const parsePages = (raw: string): number[] =>
{
try
{
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
}
catch
{
return [];
}
};
+2 -52
View File
@@ -2,59 +2,9 @@ import { useCallback, useEffect, useState } from 'react';
import { useBetween } from 'use-between';
import { CatalogType } from '../../api';
import { useCatalog } from './useCatalog';
import { getOffersStorageKey, getPagesStorageKey, IFavoriteOffer, LEGACY_STORAGE_KEY_OFFERS, LEGACY_STORAGE_KEY_PAGES, normalizeCatalogType, parseOffers, parsePages } from './useCatalogFavorites.helpers';
export interface IFavoriteOffer
{
offerId: number;
name?: string;
iconUrl?: string;
}
const LEGACY_STORAGE_KEY_OFFERS = 'catalog_fav_offers_v2';
const LEGACY_STORAGE_KEY_PAGES = 'catalog_fav_pages';
const STORAGE_KEY_OFFERS_NORMAL = 'catalog_fav_offers_v3_normal';
const STORAGE_KEY_OFFERS_BUILDER = 'catalog_fav_offers_v3_builder';
const STORAGE_KEY_PAGES_NORMAL = 'catalog_fav_pages_v2_normal';
const STORAGE_KEY_PAGES_BUILDER = 'catalog_fav_pages_v2_builder';
const normalizeCatalogType = (catalogType?: string) => ((catalogType === CatalogType.BUILDER) ? CatalogType.BUILDER : CatalogType.NORMAL);
const getOffersStorageKey = (catalogType?: string) => ((normalizeCatalogType(catalogType) === CatalogType.BUILDER) ? STORAGE_KEY_OFFERS_BUILDER : STORAGE_KEY_OFFERS_NORMAL);
const getPagesStorageKey = (catalogType?: string) => ((normalizeCatalogType(catalogType) === CatalogType.BUILDER) ? STORAGE_KEY_PAGES_BUILDER : STORAGE_KEY_PAGES_NORMAL);
const parseOffers = (raw: string): IFavoriteOffer[] =>
{
try
{
const parsed = JSON.parse(raw);
if(!Array.isArray(parsed)) return [];
// migrate from old format (number[]) to new format (IFavoriteOffer[])
if(parsed.length > 0 && typeof parsed[0] === 'number')
{
return (parsed as number[]).map(id => ({ offerId: id }));
}
return parsed;
}
catch
{
return [];
}
};
const parsePages = (raw: string): number[] =>
{
try
{
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
}
catch
{
return [];
}
};
export type { IFavoriteOffer } from './useCatalogFavorites.helpers';
const readOffers = (catalogType?: string): IFavoriteOffer[] =>
{
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest';
import { CatalogType } from '../src/api/catalog/CatalogType';
import { getOffersStorageKey, getPagesStorageKey, normalizeCatalogType, parseOffers, parsePages, STORAGE_KEY_OFFERS_BUILDER, STORAGE_KEY_OFFERS_NORMAL, STORAGE_KEY_PAGES_BUILDER, STORAGE_KEY_PAGES_NORMAL } from '../src/hooks/catalog/useCatalogFavorites.helpers';
describe('normalizeCatalogType', () =>
{
it('returns NORMAL when nothing is passed', () =>
{
expect(normalizeCatalogType()).toBe(CatalogType.NORMAL);
});
it('returns NORMAL for unknown strings', () =>
{
expect(normalizeCatalogType('not-a-real-type')).toBe(CatalogType.NORMAL);
});
it('returns BUILDER only for the exact BUILDER constant', () =>
{
expect(normalizeCatalogType(CatalogType.BUILDER)).toBe(CatalogType.BUILDER);
});
it('returns NORMAL for NORMAL explicitly', () =>
{
expect(normalizeCatalogType(CatalogType.NORMAL)).toBe(CatalogType.NORMAL);
});
});
describe('getOffersStorageKey / getPagesStorageKey', () =>
{
it('routes the BUILDER catalog to the builder storage keys', () =>
{
expect(getOffersStorageKey(CatalogType.BUILDER)).toBe(STORAGE_KEY_OFFERS_BUILDER);
expect(getPagesStorageKey(CatalogType.BUILDER)).toBe(STORAGE_KEY_PAGES_BUILDER);
});
it('routes the NORMAL catalog to the normal storage keys', () =>
{
expect(getOffersStorageKey(CatalogType.NORMAL)).toBe(STORAGE_KEY_OFFERS_NORMAL);
expect(getPagesStorageKey(CatalogType.NORMAL)).toBe(STORAGE_KEY_PAGES_NORMAL);
});
it('falls back to NORMAL keys for unknown / missing catalog type', () =>
{
expect(getOffersStorageKey()).toBe(STORAGE_KEY_OFFERS_NORMAL);
expect(getOffersStorageKey('garbage')).toBe(STORAGE_KEY_OFFERS_NORMAL);
expect(getPagesStorageKey()).toBe(STORAGE_KEY_PAGES_NORMAL);
expect(getPagesStorageKey('garbage')).toBe(STORAGE_KEY_PAGES_NORMAL);
});
});
describe('parseOffers', () =>
{
it('returns an empty array on invalid JSON', () =>
{
expect(parseOffers('not json')).toEqual([]);
expect(parseOffers('{')).toEqual([]);
});
it('returns an empty array when the parsed value is not an array', () =>
{
expect(parseOffers('null')).toEqual([]);
expect(parseOffers('{}')).toEqual([]);
expect(parseOffers('42')).toEqual([]);
expect(parseOffers('"hello"')).toEqual([]);
});
it('returns an empty array unchanged', () =>
{
expect(parseOffers('[]')).toEqual([]);
});
it('migrates the v2 number[] format into IFavoriteOffer[] with offerId only', () =>
{
expect(parseOffers('[101, 202, 303]')).toEqual([
{ offerId: 101 },
{ offerId: 202 },
{ offerId: 303 }
]);
});
it('passes through a well-formed v3 IFavoriteOffer[] unchanged', () =>
{
const v3 = [
{ offerId: 5, name: 'red sofa', iconUrl: 'http://example.com/sofa.png' },
{ offerId: 9 }
];
expect(parseOffers(JSON.stringify(v3))).toEqual(v3);
});
it('only triggers migration when the first element is a number (mixed arrays go through as-is)', () =>
{
const mixed = [ { offerId: 1 }, { offerId: 2 } ];
expect(parseOffers(JSON.stringify(mixed))).toEqual(mixed);
});
});
describe('parsePages', () =>
{
it('returns an empty array on invalid JSON', () =>
{
expect(parsePages('not json')).toEqual([]);
expect(parsePages('}{')).toEqual([]);
});
it('returns an empty array when the parsed value is not an array', () =>
{
expect(parsePages('null')).toEqual([]);
expect(parsePages('{ "pages": [1, 2] }')).toEqual([]);
expect(parsePages('"42"')).toEqual([]);
});
it('returns the parsed array as-is', () =>
{
expect(parsePages('[]')).toEqual([]);
expect(parsePages('[1, 2, 3]')).toEqual([ 1, 2, 3 ]);
});
});