From 8b792330596d3f01c5b6f665106464a301b79a7d Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 11 May 2026 22:45:57 +0200 Subject: [PATCH] Extract useCatalogFavorites pure helpers + 16 Vitest cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../catalog/useCatalogFavorites.helpers.ts | 85 +++++++++++++ src/hooks/catalog/useCatalogFavorites.ts | 54 +------- tests/catalog-favorites.helpers.test.ts | 119 ++++++++++++++++++ 3 files changed, 206 insertions(+), 52 deletions(-) create mode 100644 src/hooks/catalog/useCatalogFavorites.helpers.ts create mode 100644 tests/catalog-favorites.helpers.test.ts diff --git a/src/hooks/catalog/useCatalogFavorites.helpers.ts b/src/hooks/catalog/useCatalogFavorites.helpers.ts new file mode 100644 index 0000000..67229c1 --- /dev/null +++ b/src/hooks/catalog/useCatalogFavorites.helpers.ts @@ -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 []; + } +}; diff --git a/src/hooks/catalog/useCatalogFavorites.ts b/src/hooks/catalog/useCatalogFavorites.ts index cf51a80..5fc4d9e 100644 --- a/src/hooks/catalog/useCatalogFavorites.ts +++ b/src/hooks/catalog/useCatalogFavorites.ts @@ -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[] => { diff --git a/tests/catalog-favorites.helpers.test.ts b/tests/catalog-favorites.helpers.test.ts new file mode 100644 index 0000000..db2603e --- /dev/null +++ b/tests/catalog-favorites.helpers.test.ts @@ -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 ]); + }); +});