diff --git a/.changeset/empty-frogs-admire.md b/.changeset/empty-frogs-admire.md new file mode 100644 index 00000000..d8764d3c --- /dev/null +++ b/.changeset/empty-frogs-admire.md @@ -0,0 +1,5 @@ +--- +'@noaignite/utils': minor +--- + +Add initial version of `cacheAside` diff --git a/.changeset/shiny-kangaroos-dress.md b/.changeset/shiny-kangaroos-dress.md new file mode 100644 index 00000000..e7943a57 --- /dev/null +++ b/.changeset/shiny-kangaroos-dress.md @@ -0,0 +1,5 @@ +--- +'@noaignite/next-centra-checkout': patch +--- + +Add initial version of `createGetCentraWebhookEvents` diff --git a/packages/next-centra-checkout/eslint.config.js b/packages/next-centra-checkout/eslint.config.js new file mode 100644 index 00000000..e763b5b5 --- /dev/null +++ b/packages/next-centra-checkout/eslint.config.js @@ -0,0 +1,3 @@ +import nextConfig from '@noaignite/style-guide/eslint/next' + +export default nextConfig diff --git a/packages/next-centra-checkout/package.json b/packages/next-centra-checkout/package.json new file mode 100644 index 00000000..4f3b7de7 --- /dev/null +++ b/packages/next-centra-checkout/package.json @@ -0,0 +1,63 @@ +{ + "name": "@noaignite/next-centra-checkout", + "version": "0.0.0", + "private": false, + "description": "Next.js helpers for Centra checkout api", + "keywords": [ + "backend", + "centra", + "nextjs", + "react", + "typescript", + "vercel" + ], + "bugs": { + "url": "https://github.com/noaignite/accelerator/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/noaignite/accelerator.git", + "directory": "packages/next-centra-checkout" + }, + "license": "MIT", + "author": "NoA Ignite", + "sideEffects": false, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist/**", + "README.md" + ], + "scripts": { + "build": "tsup", + "lint": "eslint .", + "test:coverage": "vitest run --coverage", + "test:unit": "vitest run", + "test:unit:watch": "vitest" + }, + "dependencies": { + "@noaignite/utils": "workspace:*" + }, + "devDependencies": { + "@noaignite/centra-types": "workspace:*", + "@noaignite/style-guide": "workspace:*", + "@types/react": "^19.1.2", + "next": "^15.0.0", + "nock": "14.0.1", + "react": "^19.1.0", + "tsup": "^8.3.5", + "typescript": "5.4.5" + }, + "peerDependencies": { + "next": "^14.0.0 || ^15.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/next-centra-checkout/src/createCentraProductsCacheClientHelpers.test.ts b/packages/next-centra-checkout/src/createCentraProductsCacheClientHelpers.test.ts new file mode 100644 index 00000000..eaf248db --- /dev/null +++ b/packages/next-centra-checkout/src/createCentraProductsCacheClientHelpers.test.ts @@ -0,0 +1,43 @@ +import nock from 'nock' +import { describe, expect, it } from 'vitest' +import { createCentraProductsCacheClientHelpers } from './createCentraProductsCacheClientHelpers' + +describe('createCentraProductsCacheClientHelpers', () => { + const hostname = 'http://localhost:3000' + const path = 'api/centra' + const endpointBasePath = `${hostname}/${path}` + const { fetchProducts } = createCentraProductsCacheClientHelpers({ + endpointBasePath, + }) + + it('returns a fetchProducts callback', () => { + expect(fetchProducts).toBeDefined() + }) + + describe('fetchProducts', () => { + it('performs a GET request by the passed endpoint base path', async () => { + const market = '1' + const pricelist = '2' + const language = 'en' + const id = '123' + + const mockedProduct = { product: id } + + const scope = nock(hostname) + .get(`/${path}/${market}/${pricelist}/${language}?ids=${id}`) + .reply(200, [mockedProduct]) + + const results = await fetchProducts( + { + market, + pricelist, + language, + }, + [id], + ) + + expect(scope.isDone()).toBe(true) + expect(results[0]).toMatchObject(mockedProduct) + }) + }) +}) diff --git a/packages/next-centra-checkout/src/createCentraProductsCacheClientHelpers.ts b/packages/next-centra-checkout/src/createCentraProductsCacheClientHelpers.ts new file mode 100644 index 00000000..890e8d75 --- /dev/null +++ b/packages/next-centra-checkout/src/createCentraProductsCacheClientHelpers.ts @@ -0,0 +1,37 @@ +import type { SessionContext } from './createCentraProductsCacheServerHelpers' + +type ClientHelperOptions = { + /** + * Endpoint base path. + * The returned API endpoint should be absolute, meaning either a full URL or a path beginning + * with a `/`. The returned API endpoint should not contain any search parameters. + * + * @example `/api/centra` + */ + endpointBasePath: string +} + +/** + * Create Centra product cache client helpers. + * Helpers to be called client-side to retrieve cached data from Centra. + * To be used in conjunction with `createCentraProductsCacheServerHelpers` + */ +export function createCentraProductsCacheClientHelpers(options: ClientHelperOptions) { + const { endpointBasePath } = options + + return { + /** + * Fetch products. + * + * @param sessionContext - The current users session context. + * @param ids - The product IDs to be fetched. + */ + fetchProducts: async (sessionContext: SessionContext, ids: string[], init?: RequestInit) => { + const endpoint = `${endpointBasePath}/${sessionContext.market}/${sessionContext.pricelist}/${sessionContext.language}` + + return fetch(`${endpoint}?ids=${ids.join(',')}`, init).then( + (response) => response.json() as Promise, + ) + }, + } +} diff --git a/packages/next-centra-checkout/src/createCentraProductsCacheServerHelpers.test.ts b/packages/next-centra-checkout/src/createCentraProductsCacheServerHelpers.test.ts new file mode 100644 index 00000000..579b0461 --- /dev/null +++ b/packages/next-centra-checkout/src/createCentraProductsCacheServerHelpers.test.ts @@ -0,0 +1,405 @@ +import type { NextApiRequest, NextApiResponse } from 'next' +import nock from 'nock' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createCentraProductCacheServerHelpers, + nextPagesAdapter, +} from './createCentraProductsCacheServerHelpers' + +const CHECKOUT_API = 'https://mock-centra-checkout.com/api' + +describe('createCentraProductCacheServerHelpers', () => { + const mockedProduct = { product: '1234' } + + const mockedSessionContext = { + market: '50', + pricelist: '6', + language: 'en', + } + + const map = new Map() + + afterEach(() => { + map.clear() + nock.cleanAll() + }) + + const formatProduct = vi.fn((product: { product: string }) => { + return { + product: product.product, + } + }) + + const cache = { + get: vi.fn(async (sessionContext, ids: string[]) => { + return Promise.resolve( + ids + .map((id) => + map.get( + `${sessionContext.market}:${sessionContext.pricelist}:${sessionContext.language}:${id}`, + ), + ) + .filter(Boolean), + ) + }), + set: vi.fn((sessionContext, products: { product: string }[]) => { + return Promise.resolve( + products.map((product) => + map.set( + `${sessionContext.market}:${sessionContext.pricelist}:${sessionContext.language}:${product.product}`, + product, + ), + ), + ) + }), + } + + describe('fetchProducts - signal', () => { + const serverHelpers = createCentraProductCacheServerHelpers({ + centraCheckout: { + url: CHECKOUT_API, + secret: '', + init: () => ({ + signal: AbortSignal.abort() + }) + }, + cache, + formatProduct, + getSessionContexts: () => { + return [ + { + market: '50', + pricelist: '6', + language: 'en', + }, + { + market: '50', + pricelist: '2', + language: 'en', + }, + ] + }, + }) + + const { fetchProducts } = serverHelpers + + it('respects the passed AbortSignal', async () => { + const scope = nock(CHECKOUT_API) + .post('/products') + .reply(200, { products: [{ product: '2' }] }) + + const promise = fetchProducts(mockedSessionContext, ['2', '1']) + + await expect(promise).rejects.toMatchObject({ message: /aborted/i }) + + expect(scope.isDone()).toBe(false) + }) + }) + + const serverHelpers = createCentraProductCacheServerHelpers({ + centraCheckout: { + url: CHECKOUT_API, + secret: '', + }, + cache, + formatProduct, + getSessionContexts: () => { + return [ + { + market: '50', + pricelist: '6', + language: 'en', + }, + { + market: '50', + pricelist: '2', + language: 'en', + }, + ] + }, + }) + + describe('fetchProducts', () => { + const { fetchProducts } = serverHelpers + + describe('unknown session context', () => { + it('performs request to origin on unknown session context', async () => { + const scope = nock(CHECKOUT_API) + .post('/products') + .reply(200, { products: [{ product: '1' }, { product: '2' }] }) + + await fetchProducts( + // Unknown / non-configured session context + { + market: '1', + pricelist: '2', + language: 'sv', + }, + ['1', '2'], + ) + + expect(scope.isDone()).toBe(true) + }) + + it('handles unknown responses gracefully', async () => { + const scope = nock(CHECKOUT_API).post('/products').reply(200, { error: {} }) // unknown response + + const results = await fetchProducts( + // Unknown / non-configured session context + { + market: '1', + pricelist: '2', + language: 'sv', + }, + ['1', '2'], + ) + + expect(results).toMatchObject([]) + expect(scope.isDone()).toBe(true) + }) + }) + + it('retrieves product from cache, if available', async () => { + // Pre-filling the cache with a product. + cache.set(mockedSessionContext, [mockedProduct]) + + const scope = nock(CHECKOUT_API) + .post('/products') + .reply(200, { products: [mockedProduct] }) + + await fetchProducts(mockedSessionContext, ['1234']) + + expect(cache.get).toHaveBeenCalledWith(mockedSessionContext, ['1234']) + expect(scope.isDone()).toBe(false) // Since we don't expect `fetchProducts` to call Centra on cache hit. + }) + + it('returns the products in the same order as request', async () => { + // Pre-filling the cache with products. + cache.set(mockedSessionContext, [{ product: '1' }, { product: '2' }]) + const results = await fetchProducts(mockedSessionContext, ['2', '1']) + + expect(results).toMatchObject([{ product: '2' }, { product: '1' }]) + }) + + it('calls origin on lack of cached product', async () => { + // Pre-filling the cache with a product. + cache.set(mockedSessionContext, [{ product: '1' }]) + + const scope = nock(CHECKOUT_API) + .post('/products') + .reply(200, { products: [{ product: '2' }] }) + + const results = await fetchProducts(mockedSessionContext, ['2', '1']) + + expect(results).toMatchObject([{ product: '2' }, { product: '1' }]) + expect(scope.isDone()).toBe(true) + }) + + it('handles unknown response from origin gracefully', async () => { + // Pre-filling the cache with a product. + cache.set(mockedSessionContext, [{ product: '1' }]) + + const scope = nock(CHECKOUT_API).post('/products').reply(200, { error: null }) + + const results = await fetchProducts(mockedSessionContext, ['2', '1']) + + expect(results).toMatchObject([{ product: '1' }]) + expect(scope.isDone()).toBe(true) + }) + }) + + describe('updateProducts', async () => { + const { updateProducts } = serverHelpers + + const scope = nock(CHECKOUT_API) + .post('/products', { + ...mockedSessionContext, + pricelist: 'all', + relatedProducts: true, + products: [mockedProduct.product], + }) + .reply(200, { + products: [ + { + product: '1234', + prices: { + '2': { + priceAsNumber: 200, + }, + '6': { + priceAsnumber: 600, + }, + }, + }, + { + product: '2', + }, + ], + }) + + await updateProducts([mockedProduct.product]) + + it('calls the formatProduct callback with the retrieved product', async () => { + expect(formatProduct).toHaveBeenCalledWith({ + product: '1234', + priceAsNumber: 200, + }) + }) + + it('avoids using products without matching `pricelist` on `prices` property', () => { + expect(formatProduct).not.toHaveBeenCalledWith({ product: '2' }) + }) + + it('performs a POST /products request to the target Centra Checkout API instance', () => { + expect(scope.isDone()).toBe(true) + }) + }) +}) + +describe('nextPagesAdapter', async () => { + const callback = vi.fn(() => undefined) as unknown as Parameters[0] + + describe('assertions', () => { + it('throws error on lack of segmentName option', () => { + // @ts-expect-error -- Lacking `segmentName` option in `options` parameter + expect(() => nextPagesAdapter(callback, {})).toThrowError() + }) + + const nextPagesCallback = nextPagesAdapter(callback, { + segmentName: 'segments', + }) + + it("throws error on multiple 'ids' search params", async () => { + // We avoid mocking the entirety of the `NextApiRequest` type, since the typing is considered not as important for this test, since we expect the tests to fail if the mocks to be outdated. + const req = { + query: { + ids: ['1', '2'], + }, + } as unknown as NextApiRequest + + // We avoid mocking the entirety of the `NextApiResponse` type, since the typing is considered not as important for this test, since we expect the tests to fail if the mocks to be outdated. + const res = { + setHeaders: vi.fn(), + status: vi.fn(() => ({ json: vi.fn() })), + } as unknown as NextApiResponse + + await expect(nextPagesCallback(req, res)).rejects.toThrowError( + "search parameter 'ids' should be a string", + ) + }) + + it('throws error on leaf segment', async () => { + // We avoid mocking the entirety of the `NextApiRequest` type, since the typing is considered not as important for this test, since we expect the tests to fail if the mocks to be outdated. + const req = { + query: { + ids: '1,2,3', + segments: '123', + }, + } as unknown as NextApiRequest + + // We avoid mocking the entirety of the `NextApiResponse` type, since the typing is considered not as important for this test, since we expect the tests to fail if the mocks to be outdated. + const res = { + setHeaders: vi.fn(), + status: vi.fn(() => ({ json: vi.fn() })), + } as unknown as NextApiResponse + + await expect(nextPagesCallback(req, res)).rejects.toThrowError( + /^The current segment is not of type array. Assure that your route name is.*/, + ) + }) + + it('throws on lacking segment parameter', async () => { + // We avoid mocking the entirety of the `NextApiRequest` type, since the typing is considered not as important for this test, since we expect the tests to fail if the mocks to be outdated. + const req = { + query: { + ids: '1,2,3', + }, + } as unknown as NextApiRequest + + // We avoid mocking the entirety of the `NextApiResponse` type, since the typing is considered not as important for this test, since we expect the tests to fail if the mocks to be outdated. + const res = { + setHeaders: vi.fn(), + status: vi.fn(() => ({ json: vi.fn() })), + } as unknown as NextApiResponse + + await expect( + nextPagesCallback( + { + ...req, + query: { + ...req.query, + segments: [], + }, + } as unknown as NextApiRequest, + res, + ), + ).rejects.toThrowError("missing segment parameter 'market'") + + await expect( + nextPagesCallback( + { + ...req, + query: { + ...req.query, + segments: ['1', ''], + }, + } as unknown as NextApiRequest, + res, + ), + ).rejects.toThrowError("missing segment parameter 'pricelist'") + + await expect( + nextPagesCallback( + { + ...req, + query: { + ...req.query, + segments: ['1', '1'], + }, + } as unknown as NextApiRequest, + res, + ), + ).rejects.toThrowError("missing segment parameter 'language'") + }) + }) + + // We avoid mocking the entirety of the `NextApiRequest` type, since the typing is considered not as important for this test, since we expect the tests to fail if the mocks to be outdated. + const req = { + query: { + ids: '1,2,3', + }, + } as unknown as NextApiRequest + + // We avoid mocking the entirety of the `NextApiResponse` type, since the typing is considered not as important for this test, since we expect the tests to fail if the mocks to be outdated. + const res = { + setHeader: vi.fn(), + status: vi.fn(() => ({ json: vi.fn() })), + } as unknown as NextApiResponse + + const key = 'x-foo' + const value = 'bar' + + const responseHeaders = new Headers({ + [key]: value, + }) + + const nextPagesCallback = nextPagesAdapter(callback, { + segmentName: 'segments', + responseHeaders, + }) + + await nextPagesCallback( + { + ...req, + query: { + ...req.query, + segments: ['1', '2', '3'], + }, + } as unknown as NextApiRequest, + res, + ) + + it('appends responseHeaders', () => { + expect(res.setHeader).toHaveBeenCalledWith(key, value) + }) +}) diff --git a/packages/next-centra-checkout/src/createCentraProductsCacheServerHelpers.ts b/packages/next-centra-checkout/src/createCentraProductsCacheServerHelpers.ts new file mode 100644 index 00000000..2e0ce832 --- /dev/null +++ b/packages/next-centra-checkout/src/createCentraProductsCacheServerHelpers.ts @@ -0,0 +1,333 @@ +import { type ProductsResponse } from '@noaignite/centra-types' +import { assert, cacheAside } from '@noaignite/utils' +import { type NextApiRequest, type NextApiResponse } from 'next/types' + +export type CentraCheckoutCredentials = { + /** + * The Centra Checkout API URL. + * To be used to get products from the Checkout API. + */ + url: string + /** + * The Centra Checkout API Secret. + * To be used to get products from the Checkout API. + */ + secret: string + init?: () => Pick +} + +export type ServerHelpersOptions< + TCacheProduct extends ProductBase, + TOriginProduct extends ProductBase, +> = { + centraCheckout: CentraCheckoutCredentials + /** + * A cache client. + * Used to get and set products. + */ + cache: { + /** + * get products from the cache. + * + * @param sessionContext - The session context used to retrieve the products. + * @param ids - The ids of the products to retrieve. + */ + get: (sessionContext: SessionContext, ids: string[]) => Promise + /** + * Upsert products in the cache. + * + * @param sessionContext - The session context of the passed products. + * @param products - The products to insert to the cache. + */ + set: (sessionContext: SessionContext, products: TCacheProduct[]) => Promise + } + /** + * Format product to the expected shape to be stored in cache. + * Could be useful to lower bandwidth / cost, both to cache and hosting provider. + * Will be used both in API endpoint on non-cached products, as well when updating products in cache. + * + * @param product - The product retrieved from Centra. + */ + formatProduct: (product: TOriginProduct) => TCacheProduct + /** + * Get all the expected session contexts that user may have while browsing the site. + * Used to update the exposed product representations on webhook calls. + */ + getSessionContexts: () => SessionContext[] | Promise +} + +export type SessionContext = { + market: string + pricelist: string + language: string +} + +export type ProductBase = { + product: string +} + +export type RequestAdapterBase = ( + createFetchProductsEndpoint: ( + sessionContext: SessionContext, + ids: string[], + ) => Promise, +) => unknown + +const fetchProductsCentra = async < + TProduct extends ProductBase = ProductBase, + TCentraProductsResponse extends ProductsResponse = ProductsResponse, +>( + centraCheckout: CentraCheckoutCredentials, + sessionContext: SessionContext, + ids: string[], +) => { + const response = await fetch(`${centraCheckout.url}/products`, { + method: 'POST', + body: JSON.stringify({ + ...sessionContext, + products: ids, + relatedProducts: true, + }), + headers: { + Authorization: `Bearer ${centraCheckout.secret}`, + }, + signal: centraCheckout.init?.().signal, + }) + + const responseBody = (await response.json()) as TCentraProductsResponse + + return responseBody.products +} + +function groupByMarketAndLanguage(sessionContexts: SessionContext[]) { + const grouped = sessionContexts.reduce< + Record + >((acc, { market, pricelist, language }) => { + // If this market and language pair hasn't been seen before, initialize an entry. + if (!acc[`${market}-${language}`]) { + acc[`${market}-${language}`] = { market, pricelists: [pricelist], language } + } else { + // Otherwise, add the current pricelist to the existing array. + acc[`${market}-${language}`]?.pricelists.push(pricelist) + } + return acc + }, {}) + + // Convert the grouped object back into an array. + return Object.values(grouped) +} + +type PagesRouterApiAdapterOptions = { + /** + * The segment name of the API route. + * If the file name is `[[...slug]]`, then the value should be `slug`. + * @example `segments` + */ + segmentName: string + /** + * Headers that will be applied to the response. Only applied on `200` responses + */ + responseHeaders?: Headers +} + +export function nextPagesAdapter( + callback: (sessionContext: SessionContext, ids: string[]) => Promise, + options: PagesRouterApiAdapterOptions, +) { + assert( + options.segmentName, + `'segmentName' is not defined. Please pass the segment name of the route.`, + ) + + return async (req: NextApiRequest, res: NextApiResponse) => { + const { segmentName } = options + const { ids: idsParam } = req.query + const segments = req.query[segmentName] + + assert(typeof idsParam === 'string', "search parameter 'ids' should be a string") + assert( + Array.isArray(segments), + `The current segment is not of type array. Assure that your route name is '[...${segmentName}]' or '[[...${segmentName}]].'`, + ) + + const [market, pricelist, language] = segments + + assert(market, "missing segment parameter 'market'") + assert(pricelist, "missing segment parameter 'pricelist'") + assert(language, "missing segment parameter 'language'") + + const ids = idsParam.split(',') + + const products = await callback({ market, pricelist, language }, ids) + + if (options.responseHeaders) { + for (const [key, value] of options.responseHeaders) { + res.setHeader(key, value) + } + } + + res.status(200).json(products) + } +} + +/** + * Define Centra product cache server helpers. + * Helpers to be called server-side to retrieve cached data from Centra. + * To be used in conjunction with `createCentraProductCacheClientHelpers` + * @example + * ```ts + * const {fetchProducts, updateProducts} = createGetCentraProductCacheServerHelpers({ + * cache: { + * get: (sessionContext, ids) => { + * // Get products from cache, using ids and the session context (market, pricelist, language). + * }, + * set: (sessionContext, products) => { + * // Insert products to the cache, using the products and the session context (market, pricelist, language) used to retrieve them. + * }, + * }, + * centraCheckout: { + * url: 'https://acme.centraqa.com/api/checkout/100', + * secret: 'secret' + * }, + * formatProduct: (product) => { + * // Format the product to your liking. + * }, + * getSessionContexts: () => { + * // Retrieve all the possible session contexts that a user could be using while viewing the product assortment. + * } + * }) + * ``` + */ +export function createCentraProductCacheServerHelpers< + TCachedProduct extends ProductBase = ProductBase, + TOriginProduct extends ProductBase = ProductBase, +>(options: ServerHelpersOptions) { + const { + cache, + centraCheckout: centraCheckoutCredentials, + getSessionContexts, + formatProduct, + } = options + + return { + /** + * Fetch products, using the Cache-Aside pattern. + * Will primarily get products from the cache. + * If one or more products were not resolved from cache, those products will be retrieved from + * the Centra Checkout API. + * + * @param sessionContext - The session context to retrieve the products + * + * @see {@link https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-aside} + */ + fetchProducts: async (sessionContext: SessionContext, ids: string[]) => { + const configuredSessionContexts = await getSessionContexts() + + const isSessionContextConfigured = Boolean( + configuredSessionContexts.find( + (configuredSessionContext) => + configuredSessionContext.market === sessionContext.market && + configuredSessionContext.pricelist === sessionContext.pricelist && + configuredSessionContext.language === sessionContext.language, + ), + ) + + const compareIdsByArgumentOrder = (a: string, b: string) => { + const aIdx = ids.findIndex((id) => id === a) + const bIdx = ids.findIndex((id) => id === b) + + return aIdx - bIdx + } + + if (!isSessionContextConfigured) { + // Since the passed session context is not known among the session contexts, we'll try to + // fetch the products directly to source. + // This is due to that we will not update this session context in our `updateProducts` + // handler. If we would "Cache-Aside" these products, then we'll serve stale product data. + return fetchProductsCentra( + centraCheckoutCredentials, + sessionContext, + ids, + ).then((products) => + (products?.map(formatProduct) ?? []).sort((a, b) => + compareIdsByArgumentOrder(a.product, b.product), + ), + ) + } + + const products = await cacheAside(ids, { + cache: { + get: (idsInner: string[]) => cache.get(sessionContext, idsInner), + set: (products: TCachedProduct[]) => cache.set(sessionContext, products), + }, + isHit: (id, cachedProducts) => + cachedProducts.some((cachedProduct) => cachedProduct.product === id), + onMiss: async (idsNotFoundInCache: string[]) => { + const products = await fetchProductsCentra( + centraCheckoutCredentials, + sessionContext, + idsNotFoundInCache, + ) + + return products?.map(formatProduct) ?? [] + }, + }) + + return products.sort((a, b) => compareIdsByArgumentOrder(a.product, b.product)) + }, + /** + * Update products in cache. + * Will update the various product versions, by each `sessionContext` retrieved from + * `getSessionContexts`. + * + * @param ids - The product IDs to update. + */ + updateProducts: async ( + ids: string[], + ): Promise< + ReturnType['cache']['set']> + > => { + const sessionContexts = await getSessionContexts() + + // Grouping pricelists by market and pricelist, to minimize requests. + const sessionContextsByMarketAndLanguage = groupByMarketAndLanguage(sessionContexts) + + return Promise.all( + sessionContextsByMarketAndLanguage.map(async (marketLanguagePricelists) => { + const { market, language, pricelists } = marketLanguagePricelists + + const products = await fetchProductsCentra< + TOriginProduct & { prices?: Record } + >(centraCheckoutCredentials, { market, language, pricelist: 'all' }, ids) + + // Inserting the products for each pricelist for the current market and language + // combination. + const results = await Promise.all( + pricelists.map((pricelist) => + cache.set( + { market, language, pricelist }, + ( + products?.map((product) => { + const { prices, ...other } = product + + const currentPrice = prices?.[pricelist] + if (!currentPrice) { + return null + } + + return formatProduct({ + ...(other as TOriginProduct), + ...currentPrice, + }) + }) ?? [] + ).filter((product): product is NonNullable => Boolean(product)), + ), + ), + ) + + return results + }), + ) + }, + } +} diff --git a/packages/next-centra-checkout/src/createGetCentraWebhookEvents.test.ts b/packages/next-centra-checkout/src/createGetCentraWebhookEvents.test.ts new file mode 100644 index 00000000..a56df0dc --- /dev/null +++ b/packages/next-centra-checkout/src/createGetCentraWebhookEvents.test.ts @@ -0,0 +1,245 @@ +import type { NextApiRequest } from 'next' +import crypto from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import { + createGetCentraWebhookEvents, + nextAppRouterAdapter, + nextPagesRouterAdapter, +} from './createGetCentraWebhookEvents' + +vi.mock('node:crypto', async () => { + return { + default: { + createHmac: vi.fn().mockReturnValue({ + update: vi.fn(), + digest: vi.fn().mockReturnValue('valid-signature'), + }), + }, + } +}) + +describe('createGetCentraWebhookEvents', () => { + describe('with secret', () => { + const mockSecret = 'test-secret' + const mockPayload = JSON.stringify({ products: ['product-1'] }) + const mockTimestamp = '1234567890' + const mockSignature = `t=${mockTimestamp},v1=valid-signature` + + it('validates signature correctly', async () => { + // Arrange + const mockRequest = { + method: 'POST', + headers: { + 'x-centra-signature': mockSignature, + }, + body: { + payload: mockPayload, + }, + } as unknown as NextApiRequest // Only mocking the properties used internally of the function. + + const getCentraWebhookEvents = createGetCentraWebhookEvents({ + adapter: { + isRequestMethodPost: () => true, + getHeader: (req: NextApiRequest, key) => req.headers[key] as string, + getRawBody: () => Promise.resolve(mockRequest.body), + }, + secret: mockSecret, + }) + + // Act + const result = await getCentraWebhookEvents(mockRequest) + + // Assert + expect(crypto.createHmac).toHaveBeenCalledWith('sha256', mockSecret) + expect(result).toEqual([undefined, JSON.parse(mockPayload)]) + }) + + it('returns error for invalid signature', async () => { + // Arrange + const mockRequest = { + method: 'POST', + headers: { + 'x-centra-signature': 't=1234567890,v1=invalid-signature', + }, + body: { + payload: mockPayload, + }, + } as unknown as NextApiRequest // Only mocking the properties used internally of the function. + + const getCentraWebhookEvents = createGetCentraWebhookEvents({ + adapter: { + isRequestMethodPost: () => true, + getHeader: (req: NextApiRequest, key) => req.headers[key] as string, + getRawBody: () => Promise.resolve(mockRequest.body), + }, + secret: mockSecret, + }) + + // Act + const result = await getCentraWebhookEvents(mockRequest) + + // Assert + expect(result).toEqual([{ message: 'Invalid signature' }]) + }) + + it('returns error when no signature is provided', async () => { + // Arrange + const mockRequest = { + method: 'POST', + headers: {}, + body: { + payload: mockPayload, + }, + } + + const getCentraWebhookEvents = createGetCentraWebhookEvents({ + adapter: { + isRequestMethodPost: () => true, + getHeader: () => undefined, + getRawBody: () => Promise.resolve(mockRequest.body), + }, + secret: mockSecret, + }) + + // Act + const result = await getCentraWebhookEvents(mockRequest) + + // Assert + expect(result).toEqual([{ message: 'No signature' }]) + }) + }) + + describe('without secret', () => { + it('skips signature validation when secret is null', async () => { + // Arrange + const mockPayload = JSON.stringify({ products: ['product-1'] }) + const mockRequest = { + method: 'POST', + headers: {}, + body: { + payload: mockPayload, + }, + } + + const getCentraWebhookEvents = createGetCentraWebhookEvents({ + adapter: { + isRequestMethodPost: () => true, + getHeader: () => undefined, + getRawBody: () => Promise.resolve(mockRequest.body), + }, + secret: null, + }) + + // Act + const result = await getCentraWebhookEvents(mockRequest) + + // Assert + expect(result).toEqual([undefined, JSON.parse(mockPayload)]) + }) + }) + + describe('request validation', () => { + it('returns error for non-POST requests', async () => { + // Arrange + const mockRequest = { + method: 'GET', + headers: {}, + body: {}, + } + + const getCentraWebhookEvents = createGetCentraWebhookEvents({ + adapter: { + isRequestMethodPost: () => false, + getHeader: () => undefined, + getRawBody: () => Promise.resolve({}), + }, + secret: null, + }) + + // Act + const result = await getCentraWebhookEvents(mockRequest) + + // Assert + expect(result).toEqual([{ message: 'Invalid request method' }]) + }) + + it('returns error for invalid request body', async () => { + // Arrange + const mockRequest = { + method: 'POST', + headers: {}, + body: 'invalid-body', + } + + const getCentraWebhookEvents = createGetCentraWebhookEvents({ + adapter: { + isRequestMethodPost: () => true, + getHeader: () => undefined, + getRawBody: () => Promise.resolve('invalid-body'), + }, + secret: null, + }) + + // Act + const result = await getCentraWebhookEvents(mockRequest) + + // Assert + expect(result).toEqual([{ message: 'Invalid request body' }]) + }) + }) + + describe('adapters', () => { + it('nextAppRouterAdapter works correctly', async () => { + // Arrange + const mockFormData = new FormData() + mockFormData.append('payload', JSON.stringify({ products: ['product-1'] })) + + const mockRequest = { + headers: new Headers({ + 'x-centra-signature': 't=1234567890,v1=valid-signature', + }), + formData: () => Promise.resolve(mockFormData), + } + + const getCentraWebhookEvents = createGetCentraWebhookEvents({ + adapter: nextAppRouterAdapter, + secret: null, + }) + + // Act + const result = await getCentraWebhookEvents(mockRequest as unknown as Request) + + // Assert + expect(result[0]).toBeUndefined() + expect(result[1]).toEqual({ products: ['product-1'] }) + }) + + it('nextPagesRouterAdapter works correctly', async () => { + // Arrange + const mockRequest = { + method: 'POST', + headers: { + 'x-centra-signature': 't=1234567890,v1=valid-signature', + }, + body: { + payload: JSON.stringify({ products: ['product-1'] }), + }, + } as unknown as NextApiRequest // Only mocking the properties used internally of the function. + + const getCentraWebhookEvents = createGetCentraWebhookEvents({ + adapter: nextPagesRouterAdapter, + secret: null, + }) + + // Act + const result = await getCentraWebhookEvents(mockRequest) + + // Assert + expect(nextPagesRouterAdapter.getHeader(mockRequest, 'x-centra-signature')).toBe( + 't=1234567890,v1=valid-signature', + ) + expect(result[0]).toBeUndefined() + expect(result[1]).toEqual({ products: ['product-1'] }) + }) + }) +}) diff --git a/packages/next-centra-checkout/src/createGetCentraWebhookEvents.ts b/packages/next-centra-checkout/src/createGetCentraWebhookEvents.ts new file mode 100644 index 00000000..8ab8e914 --- /dev/null +++ b/packages/next-centra-checkout/src/createGetCentraWebhookEvents.ts @@ -0,0 +1,224 @@ +import { type Events } from '@noaignite/centra-types' +import { isObject } from '@noaignite/utils' +import type { NextApiRequest } from 'next' +import crypto from 'node:crypto' + +const sign = (secret: string, dataString: string) => { + const hmac = crypto.createHmac('sha256', secret) + + hmac.update(dataString) + + return hmac.digest('hex') +} + +/** + * Parse a named parameters string into an object. + * @example + * ```ts + * parseNamedParametersString('foo=bar,baz=qux') // { foo: 'bar', baz: 'qux' } + * ``` + */ +const parseNamedParametersString = (input: string): Record => { + return input.split(',').reduce((acc, pair) => { + const [key, value] = pair.split('=') + if (!key) { + return acc + } + + return { ...acc, [key]: value } + }, {}) +} + +/** + * Parse raw request body, originating from Centra webhook plugin. + */ +const parseRawBody = (rawBody: unknown): { payload: string } | null => { + if (rawBody && isObject(rawBody) && 'payload' in rawBody && typeof rawBody.payload === 'string') { + return rawBody as { payload: string } + } + + return null +} + +export type CreateGetCentraWebhookEventsConfig = { + adapter: { + /** + * Handler to determine request method of incoming request. + */ + isRequestMethodPost?: (request: TRequest) => boolean + /** + * Handler to get request header. + * + */ + getHeader: (request: TRequest, headerKey: string) => string | null | undefined + /** + * Handler to get raw request body. + */ + getRawBody: (request: TRequest) => unknown + } + /** + * Endpoint secret, populated in the "Centra Webhooks" plugin. + * Will be used to validate incoming requests, if not `null`. + */ + secret: string | null +} + +/** + * Centra webhook events error. + * Errors that could occur while validating / parsing incoming request that (should) originate from Centra. + */ +export type CentraWebhookEventsError = + | [ + { + /** + * The incoming request method used from Centra is invalid. + * The request method should be `"POST"`. + */ + message: 'Invalid request method' + }, + ] + | [ + { + /** + * The incoming request didn't contain a signature. + * @see https://centra.dev/docs/services/centra-webhooks#signature-verification + */ + message: 'No signature' + }, + ] + | [ + { + /** + * The incoming request body is invalid. + */ + message: 'Invalid request body' + }, + ] + | [ + { + /** + * The incoming signature is invalid. + * @see https://centra.dev/docs/services/centra-webhooks#signature-verification + * + */ + message: 'Invalid signature' + }, + ] + +export type CentraWebhookEventsData = [undefined, Partial>] + +export type CentraWebhookEventsResults = CentraWebhookEventsError | CentraWebhookEventsData + +/** + * Create get Centra webhook events. + * Parses and returns the events from the incoming Centra request. + * + * @example + * ```ts + * const getCentraWebhookEvents = createGetCentraWebhookEvents({ + * adapter: nextAppRouterAdapter + * }) + * + * const POST = async (request: Request) => { + * const [error, results] = await getCentraWebhookEvents(req) + * + * if (error) { + * // Handle error codes + * return Response.json({ message: 'Error' }, { status: 500 }) + * } + * + * if ('products' in results) { + * return Response.json({ products: results.products }, { status: 200 }) + * } + * } + * ``` + * + * @see https://centra.dev/docs/services/centra-webhooks + */ +export function createGetCentraWebhookEvents( + config: CreateGetCentraWebhookEventsConfig, +) { + const { adapter, secret } = config + const { getHeader, getRawBody, isRequestMethodPost } = adapter + + return async (request: TRequest): Promise => { + if (isRequestMethodPost && !isRequestMethodPost(request)) { + return [{ message: 'Invalid request method' as const }] as const + } + + const rawBody = await getRawBody(request) + const body = parseRawBody(rawBody) + + if (!body) { + return [ + { + message: 'Invalid request body', + }, + ] satisfies CentraWebhookEventsError + } + + if (secret) { + const signature = getHeader(request, 'x-centra-signature') + + if (!signature) { + return [ + { + message: 'No signature', + }, + ] satisfies CentraWebhookEventsError + } + + const parameters = parseNamedParametersString(signature) + + // @see https://centra.dev/docs/extend-with-plugins/integrations/centra-webhooks#signature-verification + if ( + sign(secret, `${parameters.t}.payload=${encodeURIComponent(body.payload)}`) !== + parameters.v1 + ) { + console.error('Invalid signature') + + return [{ message: 'Invalid signature' }] satisfies CentraWebhookEventsError + } + } + + const payloadParsed = JSON.parse(body.payload) as unknown + + return [undefined, payloadParsed] as CentraWebhookEventsData + } +} + +/** + * `app` router adapter, to be used with `createGetCentraWebhookEvents` + */ +export const nextAppRouterAdapter = { + getHeader: (request, headerKey) => { + return request.headers.get(headerKey) + }, + getRawBody: async (request) => { + const formData = await request.formData() + + // Returning an object representation of `FormData` + return Object.fromEntries([...formData.entries()]) + }, +} satisfies CreateGetCentraWebhookEventsConfig['adapter'] + +/** + * `pages` router adapter, to be used with `createGetCentraWebhookEvents` + */ +export const nextPagesRouterAdapter = { + isRequestMethodPost: (request) => { + return request.method === 'POST' + }, + getHeader: (request, headerKey) => { + const header = request.headers[headerKey] + + if (Array.isArray(header)) { + throw new Error(`Multiple headers with same key '${headerKey}' passed.`) + } + + return header + }, + getRawBody: (req) => { + return req.body as unknown + }, +} satisfies CreateGetCentraWebhookEventsConfig['adapter'] diff --git a/packages/next-centra-checkout/src/index.ts b/packages/next-centra-checkout/src/index.ts new file mode 100644 index 00000000..97ff9fbd --- /dev/null +++ b/packages/next-centra-checkout/src/index.ts @@ -0,0 +1 @@ +export * from './createGetCentraWebhookEvents' diff --git a/packages/next-centra-checkout/tsconfig.json b/packages/next-centra-checkout/tsconfig.json new file mode 100644 index 00000000..5f7b66a1 --- /dev/null +++ b/packages/next-centra-checkout/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@noaignite/style-guide/typescript/next" +} diff --git a/packages/next-centra-checkout/tsup.config.ts b/packages/next-centra-checkout/tsup.config.ts new file mode 100644 index 00000000..2acfd731 --- /dev/null +++ b/packages/next-centra-checkout/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/**/*.{ts,tsx}', '!src/**/*.test.*'], + format: ['esm', 'cjs'], + clean: true, + dts: true, + minify: true, + sourcemap: true, +}) diff --git a/packages/utils/src/cacheAside.test.ts b/packages/utils/src/cacheAside.test.ts new file mode 100644 index 00000000..e0fb3868 --- /dev/null +++ b/packages/utils/src/cacheAside.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { type CacheAsideOptions, cacheAside } from './cacheAside' + +describe('EntriesClient', () => { + type ClientCacheEntry = { id: string; name: string } + + /** + * Creates a mocked `EntriesClientCache`. + * + */ + function createMockedCacheClient() { + const cache = new Map() + + return { + clear: () => { + cache.clear() + }, + get: vi.fn(async (ids: string[]) => { + try { + const entries = ids.map((id) => { + const entry = cache.get(id) + if (!entry) return undefined + return JSON.parse(entry) as ClientCacheEntry + }) + + return Promise.resolve(entries.filter(Boolean) as ClientCacheEntry[]) + } catch { + return [] + } + }), + set: vi.fn(async (entries: ClientCacheEntry[]) => { + entries.forEach((product) => { + cache.set(product.id, JSON.stringify(product)) + }) + + return Promise.resolve() + }), + } + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('when cache entry is available', async () => { + // Arrange + const mockedKvClient = createMockedCacheClient() + const onMiss = vi.fn((_ids: string[]) => Promise.resolve([])) + + const entryId = '123' + + // Inserting a cache entry + await mockedKvClient.set([{ id: entryId, name: 'foo' }]) + + const options: CacheAsideOptions< + string[], + Awaited> + > = { + cache: mockedKvClient, + isHit: (id, entries) => Boolean(entries.find((entry) => entry.id === id)), + onMiss, + } + + it('should only pass ids that resulted in a cache miss', async () => { + await cacheAside([entryId], options) + + expect(options.onMiss).toHaveBeenCalledTimes(0) + + await cacheAside([entryId, '1'], options) + + expect(onMiss).toHaveBeenLastCalledWith(['1']) + }) + + it('returns a product with price and inventory', async () => { + // Act + const entries = await cacheAside([entryId], options) + + // Assert + expect(entries).toEqual([{ id: entryId, name: 'foo' }]) + expect(onMiss).toHaveBeenCalledTimes(0) + expect(mockedKvClient.get).toHaveBeenCalledTimes(1) + }) + }) + + describe('when cache entry is unavailable', () => { + // Arrange + const mockedKvClient = createMockedCacheClient() + + const onMiss = vi.fn((ids: string[]) => Promise.resolve(ids.map((id) => ({ id, name: 'foo' })))) + + const productId = '123' + + // Passing in a cache client with no entries. + const options: CacheAsideOptions>> = { + cache: mockedKvClient, + onMiss, + isHit: (id, entries) => Boolean(entries.find((entry) => entry.id === id)), + } + + afterEach(() => { + // Clearing the cache after each test. + mockedKvClient.clear() + }) + + it('retrieves price and inventory from onMiss', async () => { + // Act + const entries = await cacheAside([productId], options) + + // Assert + const results = [{ id: productId, name: 'foo' }] + expect(entries).toEqual(results) + expect(onMiss).toHaveBeenCalledTimes(1) + expect(onMiss).toHaveResolvedWith(results) + expect(mockedKvClient.get).toHaveBeenCalledTimes(1) + }) + + it('supports single retrieval interface', async () => { + const cache = new Map() + + const singleRetrievalCacheClient = { + clear: () => { + cache.clear() + }, + get: vi.fn(async (id: string) => { + try { + const entry = cache.get(id) + if (!entry) return undefined + + return JSON.parse(entry) as ClientCacheEntry + } catch { + return undefined + } + }), + set: vi.fn(async (entry: ClientCacheEntry | undefined) => { + if (entry) { + cache.set(entry.id, JSON.stringify(entry)) + + return Promise.resolve() + } + }), + } + + const onMissSingleEntry = vi.fn((id: string) => Promise.resolve({ id, name: 'foo' })) + + const options: CacheAsideOptions< + string, + Awaited | undefined> + > = { + cache: singleRetrievalCacheClient, + onMiss: onMissSingleEntry, + isHit: (id, entry) => entry?.id === id, + } + // Act + const entries = await cacheAside(productId, options) + + // Assert + const results = { id: productId, name: 'foo' } + expect(entries).toEqual(results) + expect(onMissSingleEntry).toHaveBeenCalledTimes(1) + expect(onMissSingleEntry).toHaveResolvedWith(results) + expect(singleRetrievalCacheClient.get).toHaveBeenCalledTimes(1) + + const entriesFromSecondCall = await cacheAside(productId, options) + expect(entriesFromSecondCall).toEqual(results) + expect(onMissSingleEntry).toHaveBeenCalledTimes(1) + expect(singleRetrievalCacheClient.get).toHaveBeenCalledTimes(2) + }) + + it('inserts price and inventory in cache upon retrieval from externalServiceCallback', async () => { + // Act + const entries = await cacheAside([productId], options) + + // Assert + const results = [{ id: productId, name: 'foo' }] + expect(entries).toEqual(results) + expect(onMiss).toHaveBeenCalledTimes(1) + expect(mockedKvClient.get).toHaveBeenCalledTimes(1) + expect(mockedKvClient.set).toHaveBeenCalledTimes(1) + // @ts-expect-error -- This is fine. + await expect(mockedKvClient.get([results[0].id])).resolves.toEqual(results) + }) + + it('returns an empty array on no matches from externalServiceCallback', async () => { + // Arrange + onMiss.mockImplementation((_: string[]) => { + return Promise.resolve([]) + }) + + // Act + const entries = await cacheAside([productId], options) + + // Assert + expect(entries).toEqual([]) + expect(onMiss).toHaveBeenCalledTimes(1) + expect(mockedKvClient.get).toHaveBeenCalledTimes(1) + expect(mockedKvClient.set).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/packages/utils/src/cacheAside.ts b/packages/utils/src/cacheAside.ts new file mode 100644 index 00000000..ff446e31 --- /dev/null +++ b/packages/utils/src/cacheAside.ts @@ -0,0 +1,75 @@ +export type CacheClient = { + /** + * Get entries by their keys from cache. + */ + get: (param: TParam) => Promise + /** + * Insert entries to the cache. + */ + set: (entries: TResults) => Promise +} + +export type CacheAsideOptions = { + /** + * The cache client. This will be used to get and set cached content. + */ + cache: CacheClient + /** + * The callback to be called when entries is not found in the cache. + * If the entries not found in `cache` gets returned from `onMiss`, they will be + * set in the cache and be appended to the result from the cache. + * + * @param args - The arguments that resulted in a cache miss. + */ + onMiss: (param: TParam) => Promise + isHit: ( + param: TInnerParam, + cacheResults: TResults, + ) => boolean +} + +/** + * Creates a `Cache-Aside` callback. + * @param param - The parameter to use to retrieve a value from the cache client (or `onMiss` callback). + * @param options - Configurable options. + * + * @see https://www.geeksforgeeks.org/cache-aside-pattern/ + */ +export async function cacheAside< + TParam, + TResults extends TParam extends unknown[] ? unknown[] : unknown, +>(param: TParam, options: CacheAsideOptions): Promise { + const { cache, isHit, onMiss } = options + // Get entries by keys from cache + const cacheResults = await cache.get(param) + + if (!Array.isArray(param) && !isHit(param as Parameters[0], cacheResults)) { + const originResults = await onMiss(param) + + if (originResults) { + await cache.set(originResults) + } + + return originResults + } + + if (Array.isArray(param)) { + const missedArgs = param.filter((p): p is TParam extends unknown[] ? TParam[number] : never => !isHit(p, cacheResults)) + + if (missedArgs.length > 0) { + // Get entries from origin + const originResults = (await onMiss(missedArgs as Parameters[0])) as unknown[] + + // Store products in cache + if (originResults.length > 0) { + await cache.set(originResults as TResults) + } + + return [...(cacheResults as unknown[]), ...originResults] as TResults + } + + return cacheResults + } + + return cacheResults +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 6424869a..345aa4f9 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,4 +1,5 @@ export * from './assert' +export * from './cacheAside' export * from './calculateContrast' export * from './calculateLuminance' export * from './capitalize' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 703aed7d..5726954e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,6 +134,40 @@ importers: specifier: ^2.4.9 version: 2.4.9 + packages/next-centra-checkout: + dependencies: + '@noaignite/utils': + specifier: workspace:* + version: link:../utils + react-dom: + specifier: ^18.0.0 || ^19.0.0 + version: 19.1.1(react@19.1.1) + devDependencies: + '@noaignite/centra-types': + specifier: workspace:* + version: link:../centra-types + '@noaignite/style-guide': + specifier: workspace:* + version: link:../style-guide + '@types/react': + specifier: ^19.1.2 + version: 19.1.13 + next: + specifier: ^15.0.0 + version: 15.5.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(sass@1.79.4) + nock: + specifier: 14.0.1 + version: 14.0.1 + react: + specifier: ^19.1.0 + version: 19.1.1 + tsup: + specifier: ^8.3.5 + version: 8.4.0(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.4)(typescript@5.4.5)(yaml@2.8.1) + typescript: + specifier: 5.4.5 + version: 5.4.5 + packages/react-centra-checkout: dependencies: '@noaignite/utils': @@ -1253,6 +1287,10 @@ packages: '@microsoft/tsdoc@0.15.1': resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} + '@mswjs/interceptors@0.37.6': + resolution: {integrity: sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==} + engines: {node: '>=18'} + '@mswjs/interceptors@0.38.5': resolution: {integrity: sha512-YSa0sYrniWIfsJBabu/YRVG10v5bqWk0PprwERFDEd776nAe/aafkUd68g7vOhVK1xG2H+Pb8e3sAnCOu/V47w==} engines: {node: '>=18'} @@ -4519,6 +4557,10 @@ packages: nlcst-to-string@4.0.0: resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + nock@14.0.1: + resolution: {integrity: sha512-IJN4O9pturuRdn60NjQ7YkFt6Rwei7ZKaOwb1tvUIIqTgeD0SDDAX3vrqZD4wcXczeEy/AsUXxpGpP/yHqV7xg==} + engines: {node: '>=18.20.0 <20 || >=20.12.1'} + nock@14.0.4: resolution: {integrity: sha512-86fh+gIKH8H02+y0/HKAOZZXn6OwgzXvl6JYwfjvKkoKxUWz54wIIDU/+w24xzMvk/R8pNVXOrvTubyl+Ml6cg==} engines: {node: '>=18.20.0 <20 || >=20.12.1'} @@ -5804,6 +5846,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' + typescript@5.4.5: + resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} + engines: {node: '>=14.17'} + hasBin: true + typescript@5.8.3: resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} @@ -6974,6 +7021,15 @@ snapshots: '@microsoft/tsdoc@0.15.1': {} + '@mswjs/interceptors@0.37.6': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + '@mswjs/interceptors@0.38.5': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -7612,7 +7668,6 @@ snapshots: '@types/react@19.1.13': dependencies: csstype: 3.1.3 - optional: true '@types/react@19.1.9': dependencies: @@ -10980,6 +11035,12 @@ snapshots: dependencies: '@types/nlcst': 2.0.3 + nock@14.0.1: + dependencies: + '@mswjs/interceptors': 0.37.6 + json-stringify-safe: 5.0.1 + propagate: 2.0.1 + nock@14.0.4: dependencies: '@mswjs/interceptors': 0.38.5 @@ -12338,6 +12399,33 @@ snapshots: tslib@2.8.1: {} + tsup@8.4.0(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.4)(typescript@5.4.5)(yaml@2.8.1): + dependencies: + bundle-require: 5.1.0(esbuild@0.25.2) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.0 + esbuild: 0.25.2 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.4)(yaml@2.8.1) + resolve-from: 5.0.0 + rollup: 4.34.8 + source-map: 0.8.0-beta.0 + sucrase: 3.35.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.12 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.6 + typescript: 5.4.5 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + tsup@8.4.0(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.4)(typescript@5.8.3)(yaml@2.8.1): dependencies: bundle-require: 5.1.0(esbuild@0.25.2) @@ -12491,6 +12579,8 @@ snapshots: transitivePeerDependencies: - supports-color + typescript@5.4.5: {} + typescript@5.8.3: {} ufo@1.6.1: {}