diff --git a/packages/storefront/package.json b/packages/storefront/package.json index 1b09143d0..0594da4b7 100644 --- a/packages/storefront/package.json +++ b/packages/storefront/package.json @@ -38,6 +38,7 @@ "preview": "astro preview", "astro": "astro", "prepare-monorepo": "bash scripts/prepare-monorepo.sh", + "test": "vitest run", "lint:fix": "eslint -c ../eslint/storefront.staged.eslintrc.cjs src/lib/**/*.{ts,vue,astro} --fix" }, "dependencies": { diff --git a/packages/storefront/src/lib/components/KitComposition.vue b/packages/storefront/src/lib/components/KitComposition.vue new file mode 100644 index 000000000..5eb38a94a --- /dev/null +++ b/packages/storefront/src/lib/components/KitComposition.vue @@ -0,0 +1,98 @@ + + + + + + {{ $t({ pt: 'Este kit contém', en: 'This kit contains' }) }} + + + + + + + + + + + + {{ getName(item.product) }} + + + {{ item.quantity }}x + + + + {{ $t.i19outOfStock }} + + + + + + {{ $t.i19selectVariation }} + + + {{ variation.name }} + + + + + + + + diff --git a/packages/storefront/src/lib/composables/use-product-card.ts b/packages/storefront/src/lib/composables/use-product-card.ts index 109e58d0b..63102d501 100644 --- a/packages/storefront/src/lib/composables/use-product-card.ts +++ b/packages/storefront/src/lib/composables/use-product-card.ts @@ -5,6 +5,7 @@ import type { SearchItem, ResourceListResult, } from '@cloudcommerce/api/types'; +import type { ExtendedCartItem } from '@@sf/state/shopping-cart'; import { ref, computed, @@ -21,7 +22,11 @@ import { onPromotion as checkOnPromotion, } from '@ecomplus/utils'; import { slugify } from '@@sf/sf-lib'; -import { addProductToCart } from '@@sf/state/shopping-cart'; +import { + addCartItem, + addProductToCart, + parseProduct, +} from '@@sf/state/shopping-cart'; import { emitGtagEvent, getGtagItem } from '@@sf/state/use-analytics'; const idsToStockRefetch: string[] = []; @@ -57,12 +62,132 @@ export const kitItemFields = [ 'slug' as const, 'available' as const, 'price' as const, + 'base_price' as const, 'quantity' as const, 'pictures.normal' as const, + 'variations' as const, ]; export type KitItems = ProductsList; +export type KitItem = KitItems[number]; + +/** + * Variation selected for each `kit_composition` entry, by composition index. + * Entries with a fixed `variation_id` or without variations are ignored. + */ +export type KitVariationIds = Array; + +type KitComposition = Exclude; + +type CartKitComposition = Exclude< + Exclude['composition'], + undefined +>; + +const getKitItemStock = (kitItem: KitItem, variationId?: ResourceId | null) => { + if (variationId) { + const variation = kitItem.variations?.find(({ _id }) => _id === variationId); + const quantity = variation?.quantity ?? kitItem.quantity; + return typeof quantity === 'number' ? quantity : Infinity; + } + if (kitItem.variations?.length) { + /* Free variation: each pack takes all units from a single chosen variation, + so the ceiling is the best variation stock — the parent product `quantity` + aggregates (sums) all variations and would be optimistic. */ + return kitItem.variations.reduce((maxQnt, { quantity }) => { + const variationQnt = typeof quantity === 'number' ? quantity : Infinity; + return variationQnt > maxQnt ? variationQnt : maxQnt; + }, 0); + } + return typeof kitItem.quantity === 'number' ? kitItem.quantity : Infinity; +}; + +/** + * Matches a `kit_composition` entry to its (buyable) product and variation, + * `null` when the item can't be sold with the given selection. + */ +const matchKitItem = ( + kitItems: KitItems, + composition: KitComposition[number], + selectedVariationId?: ResourceId | null, +) => { + const kitItem = kitItems.find(({ _id }) => _id === composition._id); + /* Hidden (`visible: false`) items are still buyable within the kit, + they're commonly gifts/addons kept out of the catalog on purpose. + Same `available === false` rule of `isInStock` on `useProductDetails`. */ + if (!kitItem || kitItem.available === false) return null; + const variationId = composition.variation_id || selectedVariationId || undefined; + const variation = variationId + ? kitItem.variations?.find(({ _id }) => _id === variationId) + : undefined; + // Kit item with variations requires a selected (and valid) SKU + if (kitItem.variations?.length && !variation) return null; + return { kitItem, variationId, variation }; +}; + +const sumToKitComposition = ( + composition: CartKitComposition, + newItem: CartKitComposition[number], +) => { + const currentItem = composition.find((item) => { + return item._id === newItem._id && item.variation_id === newItem.variation_id; + }); + if (currentItem) { + currentItem.quantity = (currentItem.quantity || 0) + (newItem.quantity || 0); + } else { + composition.push(newItem); + } +}; + +/** + * Cart items (with `kit_product` set) for one kit pack, `null` when any item + * is unavailable, out of stock or missing its variation selection. + */ +const parseKitCartItems = ( + kitProduct: { _id: ResourceId, name?: string, price: number }, + kitComposition: KitComposition, + kitItems: KitItems, + quantityToAdd: number, + kitVariationIds?: KitVariationIds, +) => { + let packQuantity = 0; + const composition: CartKitComposition = []; + /* The same product may be repeated on kit composition (with distinct + variations), items must be merged to a single cart item each. */ + const cartItemsByKey: Record = {}; + for (let i = 0; i < kitComposition.length; i++) { + const matched = matchKitItem(kitItems, kitComposition[i], kitVariationIds?.[i]); + if (!matched) return null; + const { kitItem, variationId, variation } = matched; + const quantityPerPack = kitComposition[i].quantity || 1; + const quantity = quantityPerPack * quantityToAdd; + if (!checkInStock({ ...kitItem, ...variation, min_quantity: quantity })) { + return null; + } + /* `pack_quantity` counts units on a single kit pack (not multiplied by + packs to add), it divides `kit_product.price` for the item unit price. */ + packQuantity += quantityPerPack; + sumToKitComposition(composition, { _id: kitItem._id, variation_id: variationId, quantity }); + const key = `${kitItem._id}:${variationId || ''}`; + if (cartItemsByKey[key]) { + cartItemsByKey[key].quantity += quantity; + } else { + cartItemsByKey[key] = parseProduct(kitItem, variationId, quantity); + } + } + return Object.values(cartItemsByKey).map((cartItem) => ({ + ...cartItem, + kit_product: { + _id: kitProduct._id, + name: kitProduct.name, + price: kitProduct.price, + pack_quantity: packQuantity, + composition: composition.map((item) => ({ ...item })), + }, + })); +}; + export type Props = { product?: ProductItem & { __ssr?: boolean }; productId?: ResourceId; @@ -104,6 +229,16 @@ const useProductCard = (props: Pr })(); } + /* Kit packs ceiling by its items stocks, kept apart to be reapplied + whenever the kit product `quantity` is (re)set, e.g. by fresh stocks. */ + let kitMaxQuantity = Infinity; + const applyKitMaxQuantity = () => { + if (kitMaxQuantity === Infinity) return; + product.quantity = typeof product.quantity === 'number' + ? Math.min(product.quantity, kitMaxQuantity) + : kitMaxQuantity; + }; + if (shouldRefetchStock) { idsToStockRefetch.push(product._id); refetchStock(); @@ -111,6 +246,7 @@ const useProductCard = (props: Pr const productStock = result.find(({ _id }) => _id === product._id); if (!productStock) return; Object.assign(product, productStock); + applyKitMaxQuantity(); unwatchStocks(); }); } @@ -154,6 +290,13 @@ const useProductCard = (props: Pr if ((product as SearchItem).has_variations) return true; return Boolean(product.variations?.length); }); + /* Kit product itself has no variations, but a SKU must still be chosen for + each composition item with variations not fixed by the kit. */ + const isKitSkuRequired = computed(() => { + return Boolean(product.kit_composition?.some((item) => { + return item.has_variations && !item.variation_id; + })); + }); emitGtagEvent(isProductPage ? 'view_item' : 'view_item_list', { value: isActive.value ? product.price : 0, items: [{ @@ -164,38 +307,60 @@ const useProductCard = (props: Pr }); const kitItems = ref(null); - const loadKitItems = async () => { + const isLoadingKitItems = ref(false); + let loadingKitItems: Promise | null = null; + const loadKitItems = () => { const kitComposition = product.kit_composition; - if (kitComposition?.length) { + if (!kitComposition?.length) return Promise.resolve(); + if (loadingKitItems) return loadingKitItems; + isLoadingKitItems.value = true; + loadingKitItems = (async () => { + const productIds: ResourceId[] = []; + kitComposition.forEach(({ _id }) => { + if (!productIds.includes(_id)) productIds.push(_id); + }); const { data } = await api.get('products', { - params: { _id: kitComposition.map(({ _id }) => _id) }, + params: { _id: productIds }, fields: kitItemFields, }); kitItems.value = data.result; - let maxKitQnt = product.quantity || 1; - for (let i = 0; i < kitItems.value.length; i++) { - const kitItem = kitItems.value[i]; - if (!kitItem.quantity) { + /* Kit availability is bound to its least available item, + so `quantity` must be the lowest number of packs any item can fill. */ + let maxKitQnt = Infinity; + for (let i = 0; i < kitComposition.length; i++) { + const { _id, quantity, variation_id: variationId } = kitComposition[i]; + const kitItem = data.result.find((item) => item._id === _id); + if (!kitItem) { maxKitQnt = 0; break; } - const compositionQnt = kitComposition - .find(({ _id }) => _id === kitItem._id)?.quantity || 1; - const maxKitQntByItem = Math.floor(kitItem.quantity / compositionQnt); - if (maxKitQntByItem > maxKitQnt) { + const itemStock = getKitItemStock(kitItem, variationId); + const maxKitQntByItem = Math.floor(itemStock / (quantity || 1)); + if (maxKitQntByItem < maxKitQnt) { maxKitQnt = maxKitQntByItem; } } - product.quantity = maxKitQnt; - } + kitMaxQuantity = maxKitQnt; + applyKitMaxQuantity(); + })().catch((err) => { + console.error(err); + }).finally(() => { + /* The memo only dedupes concurrent calls: cleared on settle so later + explicit calls refetch fresh kit items (and stocks) instead of keeping + the first load for the whole session. */ + loadingKitItems = null; + isLoadingKitItems.value = false; + }); + return loadingKitItems; }; const isLoadingToCart = ref(false); const isFailedToCart = ref(false); const loadToCart = async ( quantityToAdd = 1, - { variationId }: { + { variationId, kitVariationIds }: { variationId?: ResourceId | null, + kitVariationIds?: KitVariationIds, } = {}, ) => { isLoadingToCart.value = true; @@ -206,47 +371,18 @@ const useProductCard = (props: Pr if (kitComposition?.length) { if (variationId) return [null]; if (!kitItems.value) await loadKitItems(); - if (kitItems.value?.length !== kitComposition.length) { - return [null]; - } - for (let i = 0; i < kitComposition.length; i++) { - const { _id, quantity } = kitComposition[i]; - const kitItem = kitItems.value.find((item) => item._id === _id); - if ( - !kitItem?.available - || !checkInStock(kitItem) - || (quantity && kitItem.quantity! < quantity) - ) { - return [null]; - } - } - let packQuantity = 0; - const cartKitComposition: Array<{ _id: ResourceId, quantity: number }> = []; - kitComposition.forEach(({ _id, quantity }) => { - const kitItemQuantity = (quantity || 1) * quantityToAdd; - packQuantity += kitItemQuantity; - cartKitComposition.push(({ - _id, - quantity: kitItemQuantity, - })); - }); - return kitItems.value.map((kitItem) => { - const { quantity } = cartKitComposition.find(({ _id }) => { - return _id === kitItem._id; - }) || {}; - if (!quantity) return null; - const cartItem = addProductToCart(kitItem, undefined, quantity); - if (cartItem) { - cartItem.kit_product = { - _id: product._id, - name: product.name, - price: product.price, - pack_quantity: packQuantity, - composition: cartKitComposition, - }; - } - return cartItem; - }); + if (!kitItems.value?.length) return [null]; + const kitCartItems = parseKitCartItems( + product, + kitComposition, + kitItems.value, + quantityToAdd, + kitVariationIds, + ); + if (!kitCartItems) return [null]; + /* `kit_product` is set before adding to cart, otherwise each new item + may be merged with a matching (standalone) item already on cart. */ + return kitCartItems.map(addCartItem); } return [addProductToCart(product, variationId || undefined, quantityToAdd)]; })(); @@ -267,7 +403,9 @@ const useProductCard = (props: Pr isActive, discountPercentage, hasVariations, + isKitSkuRequired, kitItems, + isLoadingKitItems, loadKitItems, loadToCart, isLoadingToCart, diff --git a/packages/storefront/src/lib/composables/use-product-details.ts b/packages/storefront/src/lib/composables/use-product-details.ts index 473a34d2f..1712a3d7d 100644 --- a/packages/storefront/src/lib/composables/use-product-details.ts +++ b/packages/storefront/src/lib/composables/use-product-details.ts @@ -9,7 +9,8 @@ import { onMounted, } from 'vue'; import { useUrlSearchParams } from '@vueuse/core'; -import { useProductCard } from '@@sf/composables/use-product-card'; +import { inStock as checkInStock } from '@ecomplus/utils'; +import { type KitItem, useProductCard } from '@@sf/composables/use-product-card'; export type Props = Partial & { product: Products; @@ -17,12 +18,30 @@ export type Props = Partial & { canUseUrlParams?: boolean; } +export type KitCompositionItem = { + /** Index on product `kit_composition`, also the key for variation selections */ + index: number; + productId: ResourceId; + /** Units of this item on each kit pack */ + quantity: number; + /** Product body, `null` while kit items are still loading */ + product: KitItem | null; + /** Selectable variations, empty when the kit fixes the variation itself */ + variations: Exclude; + variationId: ResourceId | null; + isSelected: boolean; + isInStock: boolean; +}; + export const useProductDetails = (props: Props) => { const { canUseUrlParams = true } = props; const { product, title, isActive, + kitItems, + isLoadingKitItems, + loadKitItems, loadToCart, isFailedToCart, } = useProductCard(props); @@ -51,7 +70,54 @@ export const useProductDetails = (props: Props) => { }); } + const isKit = computed(() => Boolean(product.kit_composition?.length)); + const kitVariationIds = reactive>([]); + onMounted(() => { + if (isKit.value) loadKitItems(); + }); + const kitComposition = computed(() => { + if (!product.kit_composition?.length) return []; + return product.kit_composition.map((composition, index) => { + const { _id: productId, variation_id: fixedVariationId } = composition; + const kitItem = kitItems.value?.find(({ _id }) => _id === productId) || null; + const _variationId = fixedVariationId || kitVariationIds[index] || null; + const variation = _variationId + ? kitItem?.variations?.find(({ _id }) => _id === _variationId) + : undefined; + const variations = (!fixedVariationId && kitItem?.variations) || []; + const quantityPerKit = composition.quantity || 1; + return { + index, + productId, + quantity: quantityPerKit, + product: kitItem, + variations, + variationId: _variationId, + isSelected: Boolean(!variations.length || _variationId), + isInStock: !kitItem || ( + kitItem.available !== false + && checkInStock({ + ...kitItem, + ...variation, + min_quantity: quantityPerKit * quantity.value, + }) + ), + }; + }); + }); + const selectKitVariation = (index: number, _variationId: ResourceId | null) => { + kitVariationIds[index] = _variationId; + if (kitComposition.value.every(({ isSelected }) => isSelected)) { + hasSkuSelectionAlert.value = false; + } + }; + const isSkuSelected = computed(() => { + if (isKit.value) { + // Can't assert selections while kit items are still unknown/loading + if (!kitItems.value) return false; + return kitComposition.value.every(({ isSelected }) => isSelected); + } return Boolean(!product.variations?.length || variationId.value); }); const checkVariation = (ev?: Event) => { @@ -64,19 +130,54 @@ export const useProductDetails = (props: Props) => { return !hasSkuSelectionAlert.value; }; - const addToCart = () => { - if (!checkVariation()) return; - loadToCart(quantity.value, { variationId: variationId.value }); + const addToCart = async () => { + if (isKit.value && !kitItems.value) await loadKitItems(); + if (!checkVariation()) return null; + return loadToCart(quantity.value, { + variationId: variationId.value, + kitVariationIds: isKit.value ? kitVariationIds : undefined, + }); }; const shippedItems = reactive([{ ...product, body_html: undefined, quantity: 1, - }]); - watch(quantity, () => { - shippedItems[0].quantity = quantity.value; - }); + }] as Array>); + /* Kits are shipped as their composition items, the kit product itself + usually has no weight/dimensions to calculate shipping with. */ + watch([quantity, kitComposition], () => { + if (!isKit.value) { + shippedItems[0].quantity = quantity.value; + return; + } + /* Items priced with the kit price split by pack units, as on cart + (`kit_product.price / pack_quantity`), so the shipping subtotal (used on + free shipping rules) is the kit price, not the sum of standalone prices. */ + const packQuantity = kitComposition.value.reduce((sum, item) => sum + item.quantity, 0); + const finalPrice = product.price / packQuantity; + const kitShippedItems = kitComposition.value.reduce((items, item) => { + if (item.product) { + items.push({ + ...item.product, + body_html: undefined, + final_price: finalPrice, + variation_id: item.variationId || undefined, + quantity: item.quantity * quantity.value, + }); + } + return items; + }, [] as Array>); + if (!kitShippedItems.length) { + /* Kit items not loaded (yet or failed): keep the kit product itself, + shipping can't be calculated with no items at all. */ + if (shippedItems[0]?._id === product._id) { + shippedItems[0].quantity = quantity.value; + } + return; + } + shippedItems.splice(0, shippedItems.length, ...kitShippedItems); + }, { immediate: true }); return { product, @@ -90,6 +191,11 @@ export const useProductDetails = (props: Props) => { addToCart, isFailedToCart, shippedItems, + isKit, + isLoadingKitItems, + kitComposition, + kitVariationIds, + selectKitVariation, }; }; diff --git a/packages/storefront/tests/kit-composition.test.ts b/packages/storefront/tests/kit-composition.test.ts new file mode 100644 index 000000000..ccbd6f910 --- /dev/null +++ b/packages/storefront/tests/kit-composition.test.ts @@ -0,0 +1,187 @@ +import type { Products, ResourceId } from '@cloudcommerce/api/types'; +import { + type Mock, + describe, + test, + expect, + vi, + afterEach, +} from 'vitest'; +import { nextTick } from 'vue'; +import api from '@cloudcommerce/api'; +import { price as getPrice } from '@ecomplus/utils'; +import { useProductCard } from '@@sf/composables/use-product-card'; +import { useProductDetails } from '@@sf/composables/use-product-details'; +import { shoppingCart, resetCartItems } from '@@sf/state/shopping-cart'; + +vi.mock('@cloudcommerce/api', () => ({ default: { get: vi.fn() } })); +// Tracking events are out of scope here (and require a browser window) +vi.mock('@@sf/state/use-analytics', async (importOriginal) => ({ + ...await importOriginal>(), + emitGtagEvent: vi.fn(), +})); + +const apiGet = api.get as unknown as Mock; +const id = (char: string) => char.repeat(24) as ResourceId; +const kitId = id('a'); +const shirtId = id('b'); +const socksId = id('c'); +const sizeP = id('d'); +const sizeM = id('e'); + +// Kit items as listed by the API with `kitItemFields`, R$150 as standalone +const kitItems = [{ + _id: shirtId, + sku: 'SHIRT', + name: 'Camisa', + available: true, + price: 80, + quantity: 8, + variations: [ + { _id: sizeP, name: 'P', quantity: 3 }, + { _id: sizeM, name: 'M', quantity: 5 }, + ], +}, { + _id: socksId, + sku: 'SOCKS', + name: 'Meia', + available: true, + price: 35, + quantity: 4, +}]; + +// R$100 kit of 1 shirt (size picked by the buyer, unless fixed) + 2 socks +const getKitProduct = (shirtVariationId?: ResourceId) => ({ + _id: kitId, + sku: 'KIT', + name: 'Kit camisa e meias', + available: true, + visible: true, + price: 100, + quantity: 50, + kit_composition: [{ + _id: shirtId, + quantity: 1, + has_variations: true, + variation_id: shirtVariationId, + }, { + _id: socksId, + quantity: 2, + has_variations: false, + }], +} as Products); + +const mockApi = ({ freshKitQuantity = 50 } = {}) => { + apiGet.mockImplementation(async (_endpoint, { params, fields }) => { + // Kit items are listed with SKU, the kit product fresh stock without + const result = fields.includes('sku') + ? kitItems.filter(({ _id }) => params._id.includes(_id)) + : [{ _id: kitId, price: 100, quantity: freshKitQuantity }]; + return { data: { result } }; + }); +}; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +describe('Kit product card', () => { + test('limits kit stock to the packs its least available item can fill', async () => { + mockApi(); + const { product, loadKitItems } = useProductCard({ + product: getKitProduct(), + isSkipStockRefetch: true, + }); + await loadKitItems(); + // The best shirt size fills 5 packs, the 4 socks only 2 packs + expect(product.quantity).toBe(2); + }); + + test('keeps the kit stock limit after refreshing the kit product stock', async () => { + vi.useFakeTimers(); + vi.stubEnv('SSR', false); + mockApi({ freshKitQuantity: 50 }); + const { product, loadKitItems } = useProductCard({ + product: { ...getKitProduct(), __ssr: true }, + }); + await loadKitItems(); + expect(product.quantity).toBe(2); + // Hydrated products stocks are refreshed (debounced) on browser + await vi.advanceTimersByTimeAsync(1500); + expect(apiGet).toHaveBeenCalledTimes(2); + expect(product.quantity).toBe(2); + }); + + test('requires SKU pick only for kit items with variations not fixed', () => { + const isKitSkuRequired = (product: Products) => { + return useProductCard({ product, isSkipStockRefetch: true }).isKitSkuRequired.value; + }; + expect(isKitSkuRequired(getKitProduct())).toBe(true); + expect(isKitSkuRequired(getKitProduct(sizeM))).toBe(false); + expect(isKitSkuRequired({ ...getKitProduct(), kit_composition: undefined })).toBe(false); + }); + + test('adds a kit to cart only with sizes picked, charging the kit price', async () => { + mockApi(); + resetCartItems(); + const { loadToCart, isFailedToCart } = useProductCard({ + product: getKitProduct(), + isSkipStockRefetch: true, + }); + expect(await loadToCart(1)).toEqual([null]); + expect(isFailedToCart.value).toBe(true); + const cartItems = await loadToCart(2, { kitVariationIds: [sizeM] }); + expect(cartItems).toMatchObject([ + { product_id: shirtId, variation_id: sizeM, quantity: 2 }, + { product_id: socksId, quantity: 4 }, + ]); + // `pack_quantity` counts the units of a single kit pack, not of all packs + cartItems.forEach((item) => expect(item?.kit_product?.pack_quantity).toBe(3)); + await nextTick(); + expect(shoppingCart.subtotal).toBeCloseTo(200); + }); +}); + +describe('Kit product details shipping', () => { + const setup = () => { + // No component instance here, `onMounted` hooks are skipped with warnings + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + return useProductDetails({ product: getKitProduct(), canUseUrlParams: false }); + }; + + test('ships kit items priced by the kit, not by standalone prices', async () => { + mockApi(); + const { + shippedItems, + quantity, + addToCart, + selectKitVariation, + } = setup(); + quantity.value = 2; + // Loads kit items (on mount usually), nothing is added without size picked + expect(await addToCart()).toBe(null); + selectKitVariation(0, sizeM); + await nextTick(); + expect(shippedItems).toMatchObject([ + { _id: shirtId, variation_id: sizeM, quantity: 2 }, + { _id: socksId, quantity: 4 }, + ]); + // Subtotal as summed by the shipping calculator (for free shipping rules) + const subtotal = shippedItems.reduce((sum, item) => { + return sum + getPrice(item) * item.quantity; + }, 0); + expect(subtotal).toBeCloseTo(200); + }); + + test('keeps shipping the kit product itself when kit items fail to load', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + apiGet.mockRejectedValue(new Error('Network error')); + const { shippedItems, quantity, addToCart } = setup(); + await addToCart(); + quantity.value = 3; + await nextTick(); + expect(shippedItems).toMatchObject([{ _id: kitId, quantity: 3 }]); + }); +}); diff --git a/packages/storefront/tests/setup.ts b/packages/storefront/tests/setup.ts new file mode 100644 index 000000000..7bfcbec0b --- /dev/null +++ b/packages/storefront/tests/setup.ts @@ -0,0 +1,5 @@ +// Storefront global context, set by the store SSR/browser runtime +globalThis.$storefront = { + settings: {}, + data: {}, +} as typeof globalThis.$storefront; diff --git a/packages/storefront/vitest.config.ts b/packages/storefront/vitest.config.ts new file mode 100644 index 000000000..144a297c0 --- /dev/null +++ b/packages/storefront/vitest.config.ts @@ -0,0 +1,19 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +const resolvePath = (path: string) => fileURLToPath(new URL(path, import.meta.url)); + +export default defineConfig({ + resolve: { + // Same as `tsconfig.base.json` paths (and Astro `viteAlias`) + alias: { + '@@i18n': resolvePath('./node_modules/@cloudcommerce/i18n/src/pt_br.ts'), + '@@sf': resolvePath('./src/lib'), + '~': resolvePath('./src'), + }, + }, + test: { + include: ['tests/**/*.test.ts'], + setupFiles: ['tests/setup.ts'], + }, +}); diff --git a/tsconfig.test.json b/tsconfig.test.json index ae6c5b0a4..62228400d 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -7,5 +7,6 @@ "packages/*/tests/**/*.ts" ], "exclude": [ + "packages/storefront/tests/**" ] }